diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..35410cac --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/DjangoBlog.iml b/.idea/DjangoBlog.iml new file mode 100644 index 00000000..04dd6f0c --- /dev/null +++ b/.idea/DjangoBlog.iml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 00000000..105ce2da --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..39587bef --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..243d1272 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..83067447 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 00000000..783800c8 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + { + "associatedIndex": 3 +} + + + + + + + + + + + + + + + + + + + 1760256904468 + + + + + + \ No newline at end of file diff --git a/doc/19组 开源软件泛读、标注和维护报告文档.docx b/doc/19组 开源软件泛读、标注和维护报告文档.docx new file mode 100644 index 00000000..bd0c81d4 Binary files /dev/null and b/doc/19组 开源软件泛读、标注和维护报告文档.docx differ diff --git a/doc/19组-开源软件的质量分析报告文档.docx b/doc/19组-开源软件的质量分析报告文档.docx new file mode 100644 index 00000000..2568053c Binary files /dev/null and b/doc/19组-开源软件的质量分析报告文档.docx differ diff --git a/doc/Python 小组编码规范文档.docx b/doc/Python 小组编码规范文档.docx new file mode 100644 index 00000000..85b303a2 Binary files /dev/null and b/doc/Python 小组编码规范文档.docx differ diff --git a/doc/实践考评-开源软件大作业项目的自评报告.xlsx b/doc/实践考评-开源软件大作业项目的自评报告.xlsx new file mode 100644 index 00000000..de761eab Binary files /dev/null and b/doc/实践考评-开源软件大作业项目的自评报告.xlsx differ diff --git a/doc/新增功能展示.mp4 b/doc/新增功能展示.mp4 new file mode 100644 index 00000000..e2b42440 Binary files /dev/null and b/doc/新增功能展示.mp4 differ diff --git a/doc/第四周软件界面设计说明书.docx b/doc/第四周软件界面设计说明书.docx new file mode 100644 index 00000000..b7210af7 Binary files /dev/null and b/doc/第四周软件界面设计说明书.docx differ diff --git a/doc/系统架构图.docx b/doc/系统架构图.docx new file mode 100644 index 00000000..774f7d78 Binary files /dev/null and b/doc/系统架构图.docx differ diff --git a/doc/项目数据库和model的分析与设计.docx b/doc/项目数据库和model的分析与设计.docx new file mode 100644 index 00000000..d72f05a4 Binary files /dev/null and b/doc/项目数据库和model的分析与设计.docx differ diff --git a/src/DjangoBlog-NEW/.idea/.gitignore b/src/DjangoBlog-NEW/.idea/.gitignore new file mode 100644 index 00000000..35410cac --- /dev/null +++ b/src/DjangoBlog-NEW/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/src/DjangoBlog-NEW/.idea/DjangoBlog-master.iml b/src/DjangoBlog-NEW/.idea/DjangoBlog-master.iml new file mode 100644 index 00000000..74b565f1 --- /dev/null +++ b/src/DjangoBlog-NEW/.idea/DjangoBlog-master.iml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/DjangoBlog-NEW/.idea/inspectionProfiles/profiles_settings.xml b/src/DjangoBlog-NEW/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 00000000..105ce2da --- /dev/null +++ b/src/DjangoBlog-NEW/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/src/DjangoBlog-NEW/.idea/misc.xml b/src/DjangoBlog-NEW/.idea/misc.xml new file mode 100644 index 00000000..2ad41b3a --- /dev/null +++ b/src/DjangoBlog-NEW/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/src/DjangoBlog-NEW/.idea/modules.xml b/src/DjangoBlog-NEW/.idea/modules.xml new file mode 100644 index 00000000..5494c002 --- /dev/null +++ b/src/DjangoBlog-NEW/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/.coveragerc b/src/DjangoBlog-NEW/DjangoBlog-master/.coveragerc new file mode 100644 index 00000000..9757484f --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/.coveragerc @@ -0,0 +1,10 @@ +[run] +source = . +include = *.py +omit = + *migrations* + *tests* + *.html + *whoosh_cn_backend* + *settings.py* + *venv* diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/.dockerignore b/src/DjangoBlog-NEW/DjangoBlog-master/.dockerignore new file mode 100644 index 00000000..2818c38d --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/.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/src/DjangoBlog-NEW/DjangoBlog-master/.gitattributes b/src/DjangoBlog-NEW/DjangoBlog-master/.gitattributes new file mode 100644 index 00000000..fd52ece8 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/.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/src/DjangoBlog-NEW/DjangoBlog-master/.github/ISSUE_TEMPLATE.md b/src/DjangoBlog-NEW/DjangoBlog-master/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..2b5b7aa7 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/.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/src/DjangoBlog-NEW/DjangoBlog-master/.github/workflows/codeql-analysis.yml b/src/DjangoBlog-NEW/DjangoBlog-master/.github/workflows/codeql-analysis.yml new file mode 100644 index 00000000..6b765223 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/.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/src/DjangoBlog-NEW/DjangoBlog-master/.github/workflows/django.yml b/src/DjangoBlog-NEW/DjangoBlog-master/.github/workflows/django.yml new file mode 100644 index 00000000..94baea98 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/.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/src/DjangoBlog-NEW/DjangoBlog-master/.github/workflows/docker.yml b/src/DjangoBlog-NEW/DjangoBlog-master/.github/workflows/docker.yml new file mode 100644 index 00000000..a312e2fa --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/.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/src/DjangoBlog-NEW/DjangoBlog-master/.github/workflows/publish-release.yml b/src/DjangoBlog-NEW/DjangoBlog-master/.github/workflows/publish-release.yml new file mode 100644 index 00000000..5eb08539 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/.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/src/DjangoBlog-NEW/DjangoBlog-master/.gitignore b/src/DjangoBlog-NEW/DjangoBlog-master/.gitignore new file mode 100644 index 00000000..30158169 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/.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/src/DjangoBlog-NEW/DjangoBlog-master/Dockerfile b/src/DjangoBlog-NEW/DjangoBlog-master/Dockerfile new file mode 100644 index 00000000..80b46acc --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/LICENSE b/src/DjangoBlog-NEW/DjangoBlog-master/LICENSE new file mode 100644 index 00000000..3b08474a --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/README.md b/src/DjangoBlog-NEW/DjangoBlog-master/README.md new file mode 100644 index 00000000..56aa4cc5 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/README.md @@ -0,0 +1,158 @@ +# DjangoBlog + +

+ Django CI + CodeQL + codecov + license +

+ +

+ 一款功能强大、设计优雅的现代化博客系统 +
+ 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** 为本项目提供的免费开源许可证。 + +

+ + JetBrains Logo + +

+ +--- +> 如果本项目帮助到了你,请在[这里](https://github.com/liangliangyy/DjangoBlog/issues/214)留下你的网址,让更多的人看到。您的回复将会是我继续更新维护下去的动力。 diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/accounts/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/accounts/admin.py b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/admin.py new file mode 100644 index 00000000..32e483c0 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/accounts/apps.py b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/apps.py new file mode 100644 index 00000000..9b3fc5a4 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = 'accounts' diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/accounts/forms.py b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/forms.py new file mode 100644 index 00000000..f653253d --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/forms.py @@ -0,0 +1,155 @@ +import math + +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 + +# 功能:登录验证码与锁定校验(行14-55) +class LoginForm(AuthenticationForm): + captcha = forms.CharField( + label=_("Captcha"), + widget=forms.TextInput( + attrs={'placeholder': _("Captcha"), "class": "form-control"} + ), + max_length=8, + required=True, + ) + + error_messages = { + **AuthenticationForm.error_messages, + 'invalid_captcha': _("Captcha error"), + 'locked': _("Too many failed attempts. Please try again in %(minutes)d minutes."), + } + + 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"}) + self.fields['captcha'].widget = widgets.TextInput( + attrs={'placeholder': _("Captcha"), "class": "form-control"}) + + def clean_captcha(self): + value = self.cleaned_data.get('captcha') + if not utils.validate_login_captcha(self.request, value): + raise ValidationError(self.error_messages['invalid_captcha'], code='invalid_captcha') + return value + + def clean(self): + username = self.cleaned_data.get('username') + identifier = utils.get_login_identifier(username, self.request) + is_locked, remaining = utils.is_login_locked(identifier) + if is_locked: + minutes = max(1, math.ceil(remaining / 60)) + raise ValidationError( + self.error_messages['locked'] % {'minutes': minutes}, + code='locked' + ) + return super().clean() + + +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/src/DjangoBlog-NEW/DjangoBlog-master/accounts/migrations/0001_initial.py b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/migrations/0001_initial.py new file mode 100644 index 00000000..d2fbcab5 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py new file mode 100644 index 00000000..1a9f5095 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/accounts/migrations/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/accounts/models.py b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/models.py new file mode 100644 index 00000000..3baddbb2 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/accounts/templatetags/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/accounts/tests.py b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/tests.py new file mode 100644 index 00000000..6893411c --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/accounts/urls.py b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/urls.py new file mode 100644 index 00000000..17e7ee5f --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/urls.py @@ -0,0 +1,30 @@ +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}), + # 功能:登录验证码接口路由(行14) + path('login/captcha/', views.LoginCaptchaView.as_view(), name='login_captcha'), + 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/src/DjangoBlog-NEW/DjangoBlog-master/accounts/user_login_backend.py b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/user_login_backend.py new file mode 100644 index 00000000..73cdca1b --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/accounts/utils.py b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/utils.py new file mode 100644 index 00000000..017257d8 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/utils.py @@ -0,0 +1,160 @@ +import io +import random +import string +import typing +from datetime import timedelta + +from django.conf import settings +from django.core.cache import cache +from django.http import HttpRequest +from django.utils import timezone +from django.utils.translation import gettext +from django.utils.translation import gettext_lazy as _ +from ipware import get_client_ip +from PIL import Image, ImageDraw, ImageFont, ImageFilter + +from djangoblog.utils import send_email + +# 功能:登录安全缓存键配置(行19-22) +_code_ttl = timedelta(minutes=5) +_LOGIN_FAIL_PREFIX = 'login:fail:' +_LOGIN_LOCK_PREFIX = 'login:lock:' +LOGIN_CAPTCHA_SESSION_KEY = 'account_login_captcha' + + +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) + + +# 功能:登录失败统计与锁定逻辑(行65-122) +def _build_identifier(username: typing.Optional[str], request: typing.Optional[HttpRequest]) -> typing.Optional[str]: + if username: + return username.strip().lower() + if request is None: + return None + client_ip, _ = get_client_ip(request) + return client_ip or request.META.get('REMOTE_ADDR') + + +def get_login_identifier(username: typing.Optional[str], request: typing.Optional[HttpRequest]) -> typing.Optional[str]: + """选择合适的登录标识,优先用户名,其次IP""" + return _build_identifier(username, request) + + +def _login_fail_key(identifier: str) -> str: + return f'{_LOGIN_FAIL_PREFIX}{identifier}' + + +def _login_lock_key(identifier: str) -> str: + return f'{_LOGIN_LOCK_PREFIX}{identifier}' + + +def increase_login_failure(identifier: typing.Optional[str]) -> int: + """记录失败次数并返回当前次数""" + if not identifier: + return 0 + key = _login_fail_key(identifier) + count = cache.get(key, 0) + 1 + cache.set(key, count, getattr(settings, 'LOGIN_FAIL_WINDOW', 15 * 60)) + if count >= getattr(settings, 'LOGIN_MAX_ATTEMPTS', 5): + lock_login(identifier) + return count + + +def reset_login_failure(identifier: typing.Optional[str]): + if not identifier: + return + cache.delete(_login_fail_key(identifier)) + cache.delete(_login_lock_key(identifier)) + + +def lock_login(identifier: str): + lock_seconds = getattr(settings, 'LOGIN_LOCK_SECONDS', 30 * 60) + lock_until = timezone.now() + timedelta(seconds=lock_seconds) + cache.set(_login_lock_key(identifier), lock_until, lock_seconds) + + +def is_login_locked(identifier: typing.Optional[str]) -> typing.Tuple[bool, int]: + if not identifier: + return False, 0 + lock_until = cache.get(_login_lock_key(identifier)) + if not lock_until: + return False, 0 + now = timezone.now() + if lock_until <= now: + cache.delete(_login_lock_key(identifier)) + return False, 0 + return True, int((lock_until - now).total_seconds()) + + +# 功能:登录验证码生成与校验(行126-158) +def store_login_captcha(request: HttpRequest, text: str): + request.session[LOGIN_CAPTCHA_SESSION_KEY] = text + + +def validate_login_captcha(request: HttpRequest, value: str) -> bool: + cached = request.session.get(LOGIN_CAPTCHA_SESSION_KEY) + if not cached or not value: + return False + if cached.lower() != value.strip().lower(): + return False + request.session.pop(LOGIN_CAPTCHA_SESSION_KEY, None) + return True + + +def refresh_login_captcha(): + """生成验证码文本和图像字节""" + text = ''.join(random.choices(string.ascii_uppercase + string.digits, k=getattr(settings, 'LOGIN_CAPTCHA_LENGTH', 4))) + width, height = 120, 40 + image = Image.new('RGB', (width, height), (255, 255, 255)) + font = ImageFont.load_default() + draw = ImageDraw.Draw(image) + for _ in range(150): + draw.point((random.randint(0, width), random.randint(0, height)), fill=_random_color()) + for i, char in enumerate(text): + position = (10 + i * 20, random.randint(0, 10)) + draw.text(position, char, fill=_random_color(), font=font) + image = image.filter(ImageFilter.SMOOTH) + buffer = io.BytesIO() + image.save(buffer, 'PNG') + buffer.seek(0) + return text, buffer.getvalue() + + +def _random_color(): + return tuple(random.randint(0, 200) for _ in range(3)) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/accounts/views.py b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/views.py new file mode 100644 index 00000000..de83ec8a --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/accounts/views.py @@ -0,0 +1,232 @@ +import logging +import math +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.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) + + # 功能:为登录表单传递 request 对象(行119-121) + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + # 功能:登录成功后重置失败计数(行125-134) + def form_valid(self, form): + 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) + identifier = utils.get_login_identifier( + form.cleaned_data.get('username'), self.request) + utils.reset_login_failure(identifier) + return super(LoginView, self).form_valid(form) + + # 功能:登录失败计数与锁定提示(行137-149) + def form_invalid(self, form): + username = self.request.POST.get('username') + identifier = utils.get_login_identifier(username, self.request) + if identifier and not form.has_error(None, 'locked') and 'captcha' not in form.errors: + utils.increase_login_failure(identifier) + locked, remaining = utils.is_login_locked(identifier) + if locked and not form.has_error(None, 'locked'): + minutes = max(1, math.ceil(remaining / 60)) + form.add_error( + None, + _('Too many failed attempts. Please try again in %(minutes)d minutes.') % {'minutes': minutes} + ) + return super().form_invalid(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") + + +# 功能:登录验证码图片接口(行224-232) +class LoginCaptchaView(View): + + def get(self, request: HttpRequest): + text, image = utils.refresh_login_captcha() + utils.store_login_captcha(request, text) + response = HttpResponse(image, content_type='image/png') + response['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' + response['Pragma'] = 'no-cache' + return response diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/blog/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/blog/admin.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/admin.py new file mode 100644 index 00000000..ba995c15 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/blog/admin.py @@ -0,0 +1,118 @@ +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] + + # 新增(第67-71行):添加JavaScript文件支持标签推荐功能 + class Media: + js = ( + 'blog/js/tag_recommender.js', + ) + + 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/src/DjangoBlog-NEW/DjangoBlog-master/blog/apps.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/apps.py new file mode 100644 index 00000000..79305878 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/blog/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + name = 'blog' diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/blog/context_processors.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/context_processors.py new file mode 100644 index 00000000..73e3088b --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/documents.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/documents.py new file mode 100644 index 00000000..0f1db7b7 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/forms.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/forms.py new file mode 100644 index 00000000..715be762 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/commands/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/commands/build_index.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/commands/build_index.py new file mode 100644 index 00000000..3c4acd74 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/commands/build_search_words.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/commands/build_search_words.py new file mode 100644 index 00000000..cfe7e0d5 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/commands/clear_cache.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/commands/clear_cache.py new file mode 100644 index 00000000..0d66172c --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/commands/create_testdata.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/commands/create_testdata.py new file mode 100644 index 00000000..675d2ba6 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/commands/ping_baidu.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/commands/ping_baidu.py new file mode 100644 index 00000000..2c7fbdd6 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/commands/sync_user_avatar.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/management/commands/sync_user_avatar.py new file mode 100644 index 00000000..d0f46127 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/middleware.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/middleware.py new file mode 100644 index 00000000..94dd70c9 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/migrations/0001_initial.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/migrations/0001_initial.py new file mode 100644 index 00000000..3d391b62 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/migrations/0002_blogsettings_global_footer_and_more.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/migrations/0002_blogsettings_global_footer_and_more.py new file mode 100644 index 00000000..adbaa36b --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/migrations/0003_blogsettings_comment_need_review.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/migrations/0003_blogsettings_comment_need_review.py new file mode 100644 index 00000000..e9f55024 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py new file mode 100644 index 00000000..ceb13982 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py new file mode 100644 index 00000000..d08e8534 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/migrations/0006_alter_blogsettings_options.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/migrations/0006_alter_blogsettings_options.py new file mode 100644 index 00000000..e36feb4c --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/migrations/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/blog/models.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/models.py new file mode 100644 index 00000000..083788bb --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/search_indexes.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/search_indexes.py new file mode 100644 index 00000000..7f1dfac1 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/tag_recommender.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/tag_recommender.py new file mode 100644 index 00000000..7cba9d1c --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/blog/tag_recommender.py @@ -0,0 +1,183 @@ +# 新增(第1-118行):标签推荐工具模块 +""" +基于历史标签和文章内容自动生成推荐标签的功能 +""" +import re +import logging +from collections import Counter +from typing import List, Dict, Tuple + +from django.db.models import Count, Q +from django.utils.html import strip_tags + +from .models import Article, Tag, Category + +logger = logging.getLogger(__name__) + +# 新增(第15-20行):停用词列表(常见无意义词汇) +STOP_WORDS = { + '的', '了', '在', '是', '我', '有', '和', '就', '不', '人', '都', '一', '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有', '看', '好', '自己', '这', + '为', '能', '他', '她', '它', '们', '这', '那', '这样', '那样', '什么', '怎么', '如何', '可以', '应该', '可能', '如果', '因为', '所以', '但是', '然而', '不过', + '而且', '或者', '以及', '还有', '另外', '此外', '同时', '然后', '接着', '最后', '首先', '其次', '总之', '因此', '所以', '总之', '例如', '比如', '比如', '等等', + '这个', '那个', '这些', '那些', '这样', '那样', '这里', '那里', '这里', '那里', '这样', '那样', '这样', '那样' +} + + +def extract_keywords_from_text(text: str, max_keywords: int = 10) -> List[str]: + """ + 新增(第25-58行):从文本中提取关键词 + 使用简单的分词和词频统计方法 + """ + if not text: + return [] + + # 移除HTML标签和Markdown标记 + text = strip_tags(text) + text = re.sub(r'[#*`\[\](){}]', ' ', text) # 移除Markdown标记 + text = re.sub(r'!\[.*?\]\(.*?\)', '', text) # 移除图片标记 + text = re.sub(r'\[.*?\]\(.*?\)', '', text) # 移除链接标记 + + # 提取中文词汇(2-6个字符) + chinese_words = re.findall(r'[\u4e00-\u9fa5]{2,6}', text) + + # 提取英文单词(3个字符以上) + english_words = re.findall(r'[a-zA-Z]{3,}', text) + + # 合并所有词汇 + all_words = chinese_words + [w.lower() for w in english_words] + + # 过滤停用词 + filtered_words = [w for w in all_words if w not in STOP_WORDS and len(w) >= 2] + + # 统计词频 + word_freq = Counter(filtered_words) + + # 返回频率最高的关键词 + keywords = [word for word, count in word_freq.most_common(max_keywords)] + + return keywords + + +def get_tags_from_category(category: Category, limit: int = 10) -> List[Tuple[str, int]]: + """ + 新增(第61-75行):获取同一分类下最常用的标签 + 返回标签名和文章数量的元组列表 + """ + if not category: + return [] + + # 获取该分类下所有已发布文章使用的标签 + tags = Tag.objects.filter( + article__category=category, + article__status='p' + ).annotate( + article_count=Count('article') + ).order_by('-article_count')[:limit] + + return [(tag.name, tag.article_count) for tag in tags] + + +def get_tags_from_author(author, limit: int = 10) -> List[Tuple[str, int]]: + """ + 新增(第78-92行):获取同一作者最常用的标签 + 返回标签名和文章数量的元组列表 + """ + if not author: + return [] + + # 获取该作者所有已发布文章使用的标签 + tags = Tag.objects.filter( + article__author=author, + article__status='p' + ).annotate( + article_count=Count('article') + ).order_by('-article_count')[:limit] + + return [(tag.name, tag.article_count) for tag in tags] + + +def recommend_tags_by_content(title: str, body: str, category: Category = None, + author=None, max_recommendations: int = 10) -> List[Dict]: + """ + 新增(第95-118行):基于文章内容推荐标签 + 结合标题、正文关键词、分类历史标签和作者历史标签 + 返回推荐标签列表,每个标签包含名称、匹配度和来源 + """ + recommendations = {} + + # 1. 从标题和正文提取关键词 + title_keywords = extract_keywords_from_text(title, max_keywords=5) + body_keywords = extract_keywords_from_text(body, max_keywords=10) + + # 合并关键词,标题权重更高 + all_keywords = title_keywords * 2 + body_keywords + + # 2. 查找匹配的现有标签 + for keyword in all_keywords: + if len(keyword) < 2: + continue + + # 模糊匹配标签名 + matching_tags = Tag.objects.filter( + Q(name__icontains=keyword) | Q(name=keyword) + ) + + for tag in matching_tags: + if tag.name not in recommendations: + # 计算匹配度:标题中的关键词权重更高 + score = 2.0 if keyword in title_keywords else 1.0 + recommendations[tag.name] = { + 'name': tag.name, + 'score': score, + 'source': 'content', + 'article_count': tag.get_article_count() + } + else: + # 如果已存在,增加匹配度 + if keyword in title_keywords: + recommendations[tag.name]['score'] += 2.0 + else: + recommendations[tag.name]['score'] += 1.0 + + # 3. 添加分类历史标签推荐 + if category: + category_tags = get_tags_from_category(category, limit=5) + for tag_name, article_count in category_tags: + if tag_name not in recommendations: + recommendations[tag_name] = { + 'name': tag_name, + 'score': article_count * 0.1, # 根据使用频率给分 + 'source': 'category', + 'article_count': article_count + } + else: + recommendations[tag_name]['score'] += article_count * 0.1 + if recommendations[tag_name]['source'] == 'content': + recommendations[tag_name]['source'] = 'content+category' + + # 4. 添加作者历史标签推荐 + if author: + author_tags = get_tags_from_author(author, limit=5) + for tag_name, article_count in author_tags: + if tag_name not in recommendations: + recommendations[tag_name] = { + 'name': tag_name, + 'score': article_count * 0.05, # 作者标签权重较低 + 'source': 'author', + 'article_count': article_count + } + else: + recommendations[tag_name]['score'] += article_count * 0.05 + if 'author' not in recommendations[tag_name]['source']: + recommendations[tag_name]['source'] += '+author' + + # 5. 按匹配度排序并返回 + sorted_recommendations = sorted( + recommendations.values(), + key=lambda x: x['score'], + reverse=True + )[:max_recommendations] + + return sorted_recommendations + + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/blog/templatetags/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/blog/templatetags/blog_tags.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/templatetags/blog_tags.py new file mode 100644 index 00000000..d6cd5d5a --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/tests.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/tests.py new file mode 100644 index 00000000..ee135052 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/blog/urls.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/urls.py new file mode 100644 index 00000000..f0164a5a --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/blog/urls.py @@ -0,0 +1,72 @@ +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'), + # 新增(第63-66行):标签推荐API路由 + path( + r'recommend-tags', + views.recommend_tags_view, + name='recommend_tags'), + # 功能:文章AI问答接口路由(行68-71) + path( + r'article//ai-answer/', + views.article_ai_answer_view, + name='article_ai_answer'), +] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/blog/views.py b/src/DjangoBlog-NEW/DjangoBlog-master/blog/views.py new file mode 100644 index 00000000..6f809db7 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/blog/views.py @@ -0,0 +1,528 @@ +import json +import logging +import os +import uuid + +import openai +from openai.error import OpenAIError +from django.conf import settings +from django.core.paginator import Paginator +# 新增(第7行):导入JsonResponse用于API响应 +from django.http import HttpResponse, HttpResponseForbidden, JsonResponse +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.html import strip_tags +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +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 +# 新增(第23-24行):导入标签推荐模块 +from .tag_recommender import recommend_tags_by_content + +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() + + # 新增(第122-123行):获取排序方式,默认为按时间倒序 + sort_by = self.request.GET.get('comment_sort', 'time_desc') + parent_comments = article_comments.filter(parent_comment=None) + + # 新增(第126-134行):根据排序方式排序(支持按时间、点赞数排序) + if sort_by == 'time_asc': + parent_comments = parent_comments.order_by('creation_time') + elif sort_by == 'like_desc': + parent_comments = parent_comments.order_by('-like_count', '-creation_time') + elif sort_by == 'like_asc': + parent_comments = parent_comments.order_by('like_count', 'creation_time') + else: # time_desc (默认) + parent_comments = parent_comments.order_by('-creation_time') + + 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 + + # 新增(第152-161行):构建URL时保留排序参数 + base_url = self.object.get_absolute_url() + sort_param = f'&comment_sort={sort_by}' if sort_by != 'time_desc' else '' + + if next_page: + kwargs[ + 'comment_next_page_url'] = base_url + f'?comment_page={next_page}{sort_param}#commentlist-container' + if prev_page: + kwargs[ + 'comment_prev_page_url'] = base_url + f'?comment_page={prev_page}{sort_param}#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 + # 新增(第167行):传递排序参数到模板 + kwargs['comment_sort'] = sort_by + + 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') + + +# 新增(第404-467行):标签推荐API视图 +@csrf_exempt +def recommend_tags_view(request): + """ + 基于文章标题、正文、分类和作者历史标签推荐标签 + 接受POST请求,参数: + - title: 文章标题 + - body: 文章正文 + - category_id: 分类ID(可选) + - author_id: 作者ID(可选) + - max_recommendations: 最大推荐数量(可选,默认10) + """ + if request.method != 'POST': + return JsonResponse({'error': 'Only POST method allowed'}, status=405) + + try: + title = request.POST.get('title', '') + body = request.POST.get('body', '') + category_id = request.POST.get('category_id') + author_id = request.POST.get('author_id') + max_recommendations = int(request.POST.get('max_recommendations', 10)) + + # 验证必需参数 + if not title and not body: + return JsonResponse({'error': 'Title or body is required'}, status=400) + + # 获取分类和作者对象 + category = None + if category_id: + try: + category = Category.objects.get(pk=category_id) + except Category.DoesNotExist: + pass + + author = None + if author_id: + try: + from accounts.models import BlogUser + author = BlogUser.objects.get(pk=author_id) + except BlogUser.DoesNotExist: + pass + + # 获取推荐标签 + recommendations = recommend_tags_by_content( + title=title, + body=body, + category=category, + author=author, + max_recommendations=max_recommendations + ) + + return JsonResponse({ + 'success': True, + 'recommendations': recommendations, + 'count': len(recommendations) + }) + + except Exception as e: + logger.error(f"Error in recommend_tags_view: {str(e)}", exc_info=True) + return JsonResponse({ + 'error': 'Internal server error', + 'message': str(e) + }, status=500) + + +# 功能:文章AI问答接口(行469-526) +@require_POST +def article_ai_answer_view(request, article_id): + if not settings.OPENAI_API_KEY: + return JsonResponse({'error': _('AI assistant is not configured yet.')}, status=503) + + try: + payload = json.loads(request.body.decode('utf-8')) + except (ValueError, json.JSONDecodeError): + payload = {} + + question = (payload.get('question') or '').strip() + if not question: + return JsonResponse({'error': _('Please enter a question.')}, status=400) + if len(question) > 500: + return JsonResponse({'error': _('Question is too long, please be concise.')}, status=400) + + article = get_object_or_404(Article, pk=article_id, status='p') + content_parts = [ + _('Title: %(title)s') % {'title': article.title}, + strip_tags(getattr(article, 'summary', '') or '')[:800], + strip_tags(article.body or '')[:4000], + ] + context_text = '\n\n'.join(filter(None, content_parts)) + + openai.api_key = settings.OPENAI_API_KEY + messages = [ + { + 'role': 'system', + 'content': _('You are an AI assistant embedded in a blog article page. Answer questions strictly based on ' + 'the provided article content. If the answer cannot be found in the article, clearly say that ' + 'you do not know. Provide concise answers in the language of the question.') + }, + { + 'role': 'user', + 'content': _('Article Content:\n%(content)s\n\nQuestion: %(question)s') % { + 'content': context_text, + 'question': question + } + } + ] + try: + response = openai.ChatCompletion.create( + model=getattr(settings, 'OPENAI_CHAT_MODEL', 'gpt-3.5-turbo'), + messages=messages, + max_tokens=getattr(settings, 'OPENAI_MAX_TOKENS', 512), + temperature=0.3, + timeout=getattr(settings, 'OPENAI_TIMEOUT', 30), + ) + answer = response['choices'][0]['message']['content'].strip() + return JsonResponse({'answer': answer}) + except OpenAIError as exc: + logger.error('article_ai_answer_view error: %s', exc, exc_info=True) + return JsonResponse({'error': _('AI service is unavailable, please try again later.')}, status=502) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/comments/MIGRATION_README.md b/src/DjangoBlog-NEW/DjangoBlog-master/comments/MIGRATION_README.md new file mode 100644 index 00000000..27f491d9 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/comments/MIGRATION_README.md @@ -0,0 +1,71 @@ +# 评论系统优化 - 数据库迁移说明 + +## 新增功能 + +本次更新为评论系统添加了以下功能: + +1. **楼中楼回复** - 已支持(原有功能) +2. **点赞功能** - 新增 +3. **举报功能** - 新增 +4. **表情包支持** - 新增 +5. **评论排序** - 新增(按时间、点赞数排序) + +## 数据库迁移步骤 + +在应用这些更改之前,您需要运行数据库迁移: + +```bash +# 1. 创建迁移文件 +python manage.py makemigrations comments + +# 2. 应用迁移 +python manage.py migrate comments +``` + +## 新增的模型 + +1. **CommentLike** - 存储用户对评论的点赞记录 +2. **CommentReport** - 存储用户对评论的举报记录 + +## 更新的模型 + +**Comment** 模型新增字段: +- `like_count` - 点赞数(默认0) +- `report_count` - 举报数(默认0) + +## 新增的URL路由 + +- `/comments/like//` - 点赞/取消点赞 +- `/comments/report//` - 举报评论 +- `/comments/check-like//` - 检查点赞状态 + +## 注意事项 + +1. 迁移后,所有现有评论的 `like_count` 和 `report_count` 将默认为 0 +2. 如果之前有用户点赞数据,需要手动迁移或重新计算 +3. 确保静态文件已正确收集:`python manage.py collectstatic` + +## 功能说明 + +### 点赞功能 +- 用户可以对评论进行点赞/取消点赞 +- 点赞数实时更新 +- 未登录用户无法点赞 + +### 举报功能 +- 用户可以对不当评论进行举报 +- 支持多种举报原因(垃圾信息、辱骂、不当内容、其他) +- 每个用户对每条评论只能举报一次 +- 管理员可在后台查看和处理举报 + +### 表情包功能 +- 评论表单中可选择表情包 +- 选择的表情会自动添加到评论内容中 +- 支持100+种常用表情 + +### 排序功能 +- 支持按时间排序(最新/最早) +- 支持按点赞数排序(最热) +- 排序选项显示在评论列表顶部 + + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/comments/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/comments/admin.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/admin.py new file mode 100644 index 00000000..accf859f --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/comments/admin.py @@ -0,0 +1,92 @@ +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 _ + +# 新增(第6-7行):导入点赞和举报模型 +from .models import Comment, CommentLike, CommentReport + + +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', + # 新增(第29-31行):在管理界面显示点赞数和举报数 + 'like_count', + 'report_count', + 'is_enable', + 'creation_time') + list_display_links = ('id', 'body', 'is_enable') + # 新增(第35-36行):添加创建时间过滤器 + list_filter = ('is_enable', 'creation_time') + exclude = ('creation_time', 'last_modify_time') + actions = [disable_commentstatus, enable_commentstatus] + # 新增(第39-40行):添加搜索字段 + search_fields = ('body', 'author__username', 'author__email') + + 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') + + +# 新增(第59-65行):点赞记录管理类 +class CommentLikeAdmin(admin.ModelAdmin): + list_per_page = 20 + list_display = ('id', 'user', 'comment', 'created_time') + list_filter = ('created_time',) + search_fields = ('user__username', 'comment__body') + readonly_fields = ('created_time',) + + +# 新增(第68-86行):举报记录管理类 +class CommentReportAdmin(admin.ModelAdmin): + list_per_page = 20 + list_display = ('id', 'user', 'comment', 'reason', 'is_handled', 'created_time') + list_filter = ('is_handled', 'reason', 'created_time') + search_fields = ('user__username', 'comment__body', 'description') + readonly_fields = ('created_time',) + # 新增(第75-76行):添加批量处理举报的操作 + actions = ['mark_as_handled', 'mark_as_unhandled'] + + # 新增(第78-81行):标记为已处理 + def mark_as_handled(self, request, queryset): + queryset.update(is_handled=True) + mark_as_handled.short_description = _('Mark as handled') + + # 新增(第83-86行):标记为未处理 + def mark_as_unhandled(self, request, queryset): + queryset.update(is_handled=False) + mark_as_unhandled.short_description = _('Mark as unhandled') + + +admin.site.register(Comment, CommentAdmin) +# 新增(第90-92行):注册点赞和举报模型到管理界面 +admin.site.register(CommentLike, CommentLikeAdmin) +admin.site.register(CommentReport, CommentReportAdmin) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/comments/apps.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/apps.py new file mode 100644 index 00000000..ff01b775 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/comments/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CommentsConfig(AppConfig): + name = 'comments' diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/comments/forms.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/forms.py new file mode 100644 index 00000000..782d5a0a --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/comments/forms.py @@ -0,0 +1,228 @@ +from django import forms +from django.forms import ModelForm + +from .models import Comment + + +# 新增(第7-193行):常用表情包列表 +EMOJI_LIST = [ + ('😀', '😀'), + ('😃', '😃'), + ('😄', '😄'), + ('😁', '😁'), + ('😆', '😆'), + ('😅', '😅'), + ('🤣', '🤣'), + ('😂', '😂'), + ('🙂', '🙂'), + ('🙃', '🙃'), + ('😉', '😉'), + ('😊', '😊'), + ('😇', '😇'), + ('🥰', '🥰'), + ('😍', '😍'), + ('🤩', '🤩'), + ('😘', '😘'), + ('😗', '😗'), + ('😚', '😚'), + ('😙', '😙'), + ('😋', '😋'), + ('😛', '😛'), + ('😜', '😜'), + ('🤪', '🤪'), + ('😝', '😝'), + ('🤑', '🤑'), + ('🤗', '🤗'), + ('🤭', '🤭'), + ('🤫', '🤫'), + ('🤔', '🤔'), + ('🤐', '🤐'), + ('🤨', '🤨'), + ('😐', '😐'), + ('😑', '😑'), + ('😶', '😶'), + ('😏', '😏'), + ('😒', '😒'), + ('🙄', '🙄'), + ('😬', '😬'), + ('🤥', '🤥'), + ('😌', '😌'), + ('😔', '😔'), + ('😪', '😪'), + ('🤤', '🤤'), + ('😴', '😴'), + ('😷', '😷'), + ('🤒', '🤒'), + ('🤕', '🤕'), + ('🤢', '🤢'), + ('🤮', '🤮'), + ('🤧', '🤧'), + ('🥵', '🥵'), + ('🥶', '🥶'), + ('😶‍🌫️', '😶‍🌫️'), + ('😵', '😵'), + ('😵‍💫', '😵‍💫'), + ('🤯', '🤯'), + ('🤠', '🤠'), + ('🥳', '🥳'), + ('🥸', '🥸'), + ('😎', '😎'), + ('🤓', '🤓'), + ('🧐', '🧐'), + ('😕', '😕'), + ('😟', '😟'), + ('🙁', '🙁'), + ('☹️', '☹️'), + ('😮', '😮'), + ('😯', '😯'), + ('😲', '😲'), + ('😳', '😳'), + ('🥺', '🥺'), + ('😦', '😦'), + ('😧', '😧'), + ('😨', '😨'), + ('😰', '😰'), + ('😥', '😥'), + ('😢', '😢'), + ('😭', '😭'), + ('😱', '😱'), + ('😖', '😖'), + ('😣', '😣'), + ('😞', '😞'), + ('😓', '😓'), + ('😩', '😩'), + ('😫', '😫'), + ('🥱', '🥱'), + ('😤', '😤'), + ('😡', '😡'), + ('😠', '😠'), + ('🤬', '🤬'), + ('😈', '😈'), + ('👿', '👿'), + ('💀', '💀'), + ('☠️', '☠️'), + ('💩', '💩'), + ('🤡', '🤡'), + ('👹', '👹'), + ('👺', '👺'), + ('👻', '👻'), + ('👽', '👽'), + ('👾', '👾'), + ('🤖', '🤖'), + ('😺', '😺'), + ('😸', '😸'), + ('😹', '😹'), + ('😻', '😻'), + ('😼', '😼'), + ('😽', '😽'), + ('🙀', '🙀'), + ('😿', '😿'), + ('😾', '😾'), + ('👍', '👍'), + ('👎', '👎'), + ('👌', '👌'), + ('✌️', '✌️'), + ('🤞', '🤞'), + ('🤟', '🤟'), + ('🤘', '🤘'), + ('🤙', '🤙'), + ('👈', '👈'), + ('👉', '👉'), + ('👆', '👆'), + ('🖕', '🖕'), + ('👇', '👇'), + ('☝️', '☝️'), + ('👏', '👏'), + ('🙌', '🙌'), + ('👐', '👐'), + ('🤲', '🤲'), + ('🤝', '🤝'), + ('🙏', '🙏'), + ('✍️', '✍️'), + ('💪', '💪'), + ('🦾', '🦾'), + ('🦿', '🦿'), + ('🦵', '🦵'), + ('🦶', '🦶'), + ('👂', '👂'), + ('🦻', '🦻'), + ('👃', '👃'), + ('🧠', '🧠'), + ('🦷', '🦷'), + ('🦴', '🦴'), + ('👀', '👀'), + ('👁️', '👁️'), + ('👅', '👅'), + ('👄', '👄'), + ('💋', '💋'), + ('🩸', '🩸'), + ('💌', '💌'), + ('💘', '💘'), + ('💝', '💝'), + ('💖', '💖'), + ('💗', '💗'), + ('💓', '💓'), + ('💞', '💞'), + ('💕', '💕'), + ('💟', '💟'), + ('❣️', '❣️'), + ('💔', '💔'), + ('❤️', '❤️'), + ('🧡', '🧡'), + ('💛', '💛'), + ('💚', '💚'), + ('💙', '💙'), + ('💜', '💜'), + ('🖤', '🖤'), + ('🤍', '🤍'), + ('🤎', '🤎'), + ('💯', '💯'), + ('💢', '💢'), + ('💥', '💥'), + ('💫', '💫'), + ('💦', '💦'), + ('💨', '💨'), + ('🕳️', '🕳️'), + ('💣', '💣'), + ('💬', '💬'), + ('👁️‍🗨️', '👁️‍🗨️'), + ('🗨️', '🗨️'), + ('🗯️', '🗯️'), + ('💭', '💭'), + ('💤', '💤'), +] + + +class CommentForm(ModelForm): + parent_comment_id = forms.IntegerField( + widget=forms.HiddenInput, required=False) + + # 新增(第200-205行):表情包选择字段 + emoji = forms.ChoiceField( + choices=[('', '选择表情')] + EMOJI_LIST, + required=False, + widget=forms.Select(attrs={'class': 'comment-emoji-select'}) + ) + + class Meta: + model = Comment + fields = ['body'] + # 新增(第210-217行):自定义评论输入框样式 + widgets = { + 'body': forms.Textarea(attrs={ + 'class': 'comment-body-input', + 'rows': 5, + 'placeholder': '请输入评论内容...' + }) + } + + # 新增(第219-228行):清理评论内容,将选择的表情添加到评论中 + def clean_body(self): + body = self.cleaned_data.get('body') + emoji = self.cleaned_data.get('emoji', '') + + # 如果选择了表情,将其添加到评论内容中 + if emoji: + body = body + ' ' + emoji if body else emoji + + return body diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/comments/migrations/0001_initial.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/migrations/0001_initial.py new file mode 100644 index 00000000..61d1e539 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/comments/migrations/0002_alter_comment_is_enable.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/migrations/0002_alter_comment_is_enable.py new file mode 100644 index 00000000..17c44db8 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py new file mode 100644 index 00000000..a1ca9708 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/comments/migrations/0004_commentlike_commentreport_comment_like_count_and_more.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/migrations/0004_commentlike_commentreport_comment_like_count_and_more.py new file mode 100644 index 00000000..ad5e8429 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/comments/migrations/0004_commentlike_commentreport_comment_like_count_and_more.py @@ -0,0 +1,102 @@ +# Generated by Django 5.2.4 on 2025-11-23 23:26 + +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0006_alter_blogsettings_options'), + ('comments', '0003_alter_comment_options_remove_comment_created_time_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CommentLike', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created time')), + ], + options={ + 'verbose_name': 'comment like', + 'verbose_name_plural': 'comment like', + }, + ), + migrations.CreateModel( + name='CommentReport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reason', models.CharField(choices=[('spam', 'Spam'), ('abuse', 'Abuse'), ('inappropriate', 'Inappropriate Content'), ('other', 'Other')], default='other', max_length=20, verbose_name='reason')), + ('description', models.TextField(blank=True, max_length=500, verbose_name='description')), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created time')), + ('is_handled', models.BooleanField(default=False, verbose_name='is handled')), + ], + options={ + 'verbose_name': 'comment report', + 'verbose_name_plural': 'comment report', + }, + ), + migrations.AddField( + model_name='comment', + name='like_count', + field=models.PositiveIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)], verbose_name='like count'), + ), + migrations.AddField( + model_name='comment', + name='report_count', + field=models.PositiveIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)], verbose_name='report count'), + ), + migrations.AddIndex( + model_name='comment', + index=models.Index(fields=['-creation_time'], name='comments_co_creatio_444c12_idx'), + ), + migrations.AddIndex( + model_name='comment', + index=models.Index(fields=['-like_count'], name='comments_co_like_co_572784_idx'), + ), + migrations.AddField( + model_name='commentlike', + name='comment', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='comments.comment', verbose_name='comment'), + ), + migrations.AddField( + model_name='commentlike', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user'), + ), + migrations.AddField( + model_name='commentreport', + name='comment', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='comments.comment', verbose_name='comment'), + ), + migrations.AddField( + model_name='commentreport', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user'), + ), + migrations.AddIndex( + model_name='commentlike', + index=models.Index(fields=['comment', 'user'], name='comments_co_comment_e08f6a_idx'), + ), + migrations.AlterUniqueTogether( + name='commentlike', + unique_together={('user', 'comment')}, + ), + migrations.AddIndex( + model_name='commentreport', + index=models.Index(fields=['comment', 'user'], name='comments_co_comment_22fd70_idx'), + ), + migrations.AddIndex( + model_name='commentreport', + index=models.Index(fields=['is_handled'], name='comments_co_is_hand_8df8dc_idx'), + ), + migrations.AlterUniqueTogether( + name='commentreport', + unique_together={('user', 'comment')}, + ), + ] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/comments/migrations/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/comments/models.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/models.py new file mode 100644 index 00000000..b592b704 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/comments/models.py @@ -0,0 +1,127 @@ +from django.conf import settings +from django.db import models +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +# 新增(第5-6行):导入验证器用于点赞数和举报数字段 +from django.core.validators import MinValueValidator + +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) + # 新增(第33-34行):点赞数字段 + like_count = models.PositiveIntegerField(_('like count'), default=0, validators=[MinValueValidator(0)]) + # 新增(第35-36行):举报数字段 + report_count = models.PositiveIntegerField(_('report count'), default=0, validators=[MinValueValidator(0)]) + + class Meta: + ordering = ['-id'] + verbose_name = _('comment') + verbose_name_plural = verbose_name + get_latest_by = 'id' + # 新增(第43-47行):添加索引以优化排序查询性能 + indexes = [ + models.Index(fields=['-creation_time']), + models.Index(fields=['-like_count']), + ] + + def __str__(self): + return self.body + + # 新增(第52-55行):获取点赞数的方法 + def get_like_count(self): + """获取点赞数""" + return self.like_count + + # 新增(第57-60行):获取回复数的方法 + def get_reply_count(self): + """获取回复数""" + return Comment.objects.filter(parent_comment=self, is_enable=True).count() + + +# 新增(第63-86行):评论点赞记录模型 +class CommentLike(models.Model): + """评论点赞记录""" + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_('user'), + on_delete=models.CASCADE) + comment = models.ForeignKey( + Comment, + verbose_name=_('comment'), + on_delete=models.CASCADE, + related_name='likes') + created_time = models.DateTimeField(_('created time'), default=now) + + class Meta: + verbose_name = _('comment like') + verbose_name_plural = verbose_name + unique_together = [['user', 'comment']] + indexes = [ + models.Index(fields=['comment', 'user']), + ] + + def __str__(self): + return f"{self.user.username} liked comment {self.comment.id}" + + +# 新增(第89-127行):评论举报记录模型 +class CommentReport(models.Model): + """评论举报记录""" + REPORT_REASONS = ( + ('spam', _('Spam')), + ('abuse', _('Abuse')), + ('inappropriate', _('Inappropriate Content')), + ('other', _('Other')), + ) + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_('user'), + on_delete=models.CASCADE) + comment = models.ForeignKey( + Comment, + verbose_name=_('comment'), + on_delete=models.CASCADE, + related_name='reports') + reason = models.CharField( + _('reason'), + max_length=20, + choices=REPORT_REASONS, + default='other') + description = models.TextField(_('description'), max_length=500, blank=True) + created_time = models.DateTimeField(_('created time'), default=now) + is_handled = models.BooleanField(_('is handled'), default=False) + + class Meta: + verbose_name = _('comment report') + verbose_name_plural = verbose_name + unique_together = [['user', 'comment']] + indexes = [ + models.Index(fields=['comment', 'user']), + models.Index(fields=['is_handled']), + ] + + def __str__(self): + return f"Report on comment {self.comment.id} by {self.user.username}" diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/comments/templatetags/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/comments/templatetags/comments_tags.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/templatetags/comments_tags.py new file mode 100644 index 00000000..fde02b47 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/comments/tests.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/tests.py new file mode 100644 index 00000000..2a7f55f1 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/comments/urls.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/urls.py new file mode 100644 index 00000000..75f430eb --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/comments/urls.py @@ -0,0 +1,26 @@ +from django.urls import path + +from . import views + +app_name = "comments" +urlpatterns = [ + path( + 'article//postcomment', + views.CommentPostView.as_view(), + name='postcomment'), + # 新增(第11-15行):点赞/取消点赞评论的URL路由 + path( + 'like//', + views.comment_like, + name='comment_like'), + # 新增(第16-20行):举报评论的URL路由 + path( + 'report//', + views.comment_report, + name='comment_report'), + # 新增(第21-25行):检查点赞状态的URL路由 + path( + 'check-like//', + views.check_comment_like_status, + name='check_comment_like_status'), +] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/comments/utils.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/utils.py new file mode 100644 index 00000000..f01dba7e --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/comments/views.py b/src/DjangoBlog-NEW/DjangoBlog-master/comments/views.py new file mode 100644 index 00000000..4a5653b1 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/comments/views.py @@ -0,0 +1,158 @@ +# Create your views here. +# 新增(第2-3行):导入JSON处理模块 +import json +# 新增(第4-5行):导入登录验证装饰器 +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError +# 新增(第7-8行):导入JsonResponse用于AJAX响应 +from django.http import HttpResponseRedirect, JsonResponse +from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_protect +# 新增(第12-13行):导入HTTP方法限制装饰器 +from django.views.decorators.http import require_http_methods +from django.views.generic.edit import FormView + +from accounts.models import BlogUser +from blog.models import Article +from .forms import CommentForm +# 新增(第19-20行):导入点赞和举报模型 +from .models import Comment, CommentLike, CommentReport + + +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)) + + +# 新增(第74-100行):点赞/取消点赞评论的视图函数 +@login_required +@require_http_methods(["POST"]) +def comment_like(request, comment_id): + """点赞/取消点赞评论""" + comment = get_object_or_404(Comment, pk=comment_id, is_enable=True) + user = request.user + + try: + like = CommentLike.objects.get(user=user, comment=comment) + # 取消点赞 + like.delete() + comment.like_count = max(0, comment.like_count - 1) + comment.save(update_fields=['like_count']) + is_liked = False + except CommentLike.DoesNotExist: + # 点赞 + CommentLike.objects.create(user=user, comment=comment) + comment.like_count += 1 + comment.save(update_fields=['like_count']) + is_liked = True + + return JsonResponse({ + 'success': True, + 'is_liked': is_liked, + 'like_count': comment.like_count + }) + + +# 新增(第103-141行):举报评论的视图函数 +@login_required +@require_http_methods(["POST"]) +def comment_report(request, comment_id): + """举报评论""" + comment = get_object_or_404(Comment, pk=comment_id, is_enable=True) + user = request.user + + # 检查是否已经举报过 + if CommentReport.objects.filter(user=user, comment=comment).exists(): + return JsonResponse({ + 'success': False, + 'message': '您已经举报过这条评论了' + }, status=400) + + try: + data = json.loads(request.body) + reason = data.get('reason', 'other') + description = data.get('description', '') + + CommentReport.objects.create( + user=user, + comment=comment, + reason=reason, + description=description + ) + + comment.report_count += 1 + comment.save(update_fields=['report_count']) + + return JsonResponse({ + 'success': True, + 'message': '举报成功,我们会尽快处理' + }) + except Exception as e: + return JsonResponse({ + 'success': False, + 'message': '举报失败,请稍后重试' + }, status=500) + + +# 新增(第144-158行):检查用户是否已点赞评论的视图函数 +@require_http_methods(["GET"]) +def check_comment_like_status(request, comment_id): + """检查用户是否已点赞评论""" + if not request.user.is_authenticated: + return JsonResponse({ + 'is_liked': False + }) + + comment = get_object_or_404(Comment, pk=comment_id) + is_liked = CommentLike.objects.filter(user=request.user, comment=comment).exists() + + return JsonResponse({ + 'is_liked': is_liked + }) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/deploy/docker-compose/docker-compose.es.yml b/src/DjangoBlog-NEW/DjangoBlog-master/deploy/docker-compose/docker-compose.es.yml new file mode 100644 index 00000000..83e35ffd --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/deploy/docker-compose/docker-compose.yml b/src/DjangoBlog-NEW/DjangoBlog-master/deploy/docker-compose/docker-compose.yml new file mode 100644 index 00000000..9609af3f --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/deploy/entrypoint.sh b/src/DjangoBlog-NEW/DjangoBlog-master/deploy/entrypoint.sh new file mode 100644 index 00000000..2fb64919 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/deploy/k8s/configmap.yaml b/src/DjangoBlog-NEW/DjangoBlog-master/deploy/k8s/configmap.yaml new file mode 100644 index 00000000..835d4ad0 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/deploy/k8s/deployment.yaml b/src/DjangoBlog-NEW/DjangoBlog-master/deploy/k8s/deployment.yaml new file mode 100644 index 00000000..414fdcc5 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/deploy/k8s/gateway.yaml b/src/DjangoBlog-NEW/DjangoBlog-master/deploy/k8s/gateway.yaml new file mode 100644 index 00000000..a8de073b --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/deploy/k8s/pv.yaml b/src/DjangoBlog-NEW/DjangoBlog-master/deploy/k8s/pv.yaml new file mode 100644 index 00000000..874b72f3 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/deploy/k8s/pvc.yaml b/src/DjangoBlog-NEW/DjangoBlog-master/deploy/k8s/pvc.yaml new file mode 100644 index 00000000..ef238c5a --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/deploy/k8s/service.yaml b/src/DjangoBlog-NEW/DjangoBlog-master/deploy/k8s/service.yaml new file mode 100644 index 00000000..4ef2931e --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/deploy/k8s/storageclass.yaml b/src/DjangoBlog-NEW/DjangoBlog-master/deploy/k8s/storageclass.yaml new file mode 100644 index 00000000..5d5a14cd --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/deploy/nginx.conf b/src/DjangoBlog-NEW/DjangoBlog-master/deploy/nginx.conf new file mode 100644 index 00000000..32161d86 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/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/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/__init__.py new file mode 100644 index 00000000..1e205f40 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/__init__.py @@ -0,0 +1 @@ +default_app_config = 'djangoblog.apps.DjangoblogAppConfig' diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/admin_site.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/admin_site.py new file mode 100644 index 00000000..f1204059 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/admin_site.py @@ -0,0 +1,64 @@ +from django.contrib.admin import AdminSite +from django.contrib.admin.models import LogEntry +from django.contrib.sites.admin import SiteAdmin +from django.contrib.sites.models import Site + +from accounts.admin import * +from blog.admin import * +from blog.models import * +from comments.admin import * +from comments.models import * +from djangoblog.logentryadmin import LogEntryAdmin +from oauth.admin import * +from oauth.models import * +from owntracks.admin import * +from owntracks.models import * +from servermanager.admin import * +from servermanager.models import * + + +class DjangoBlogAdminSite(AdminSite): + site_header = 'djangoblog administration' + site_title = 'djangoblog site admin' + + def __init__(self, name='admin'): + super().__init__(name) + + def has_permission(self, request): + return request.user.is_superuser + + # def get_urls(self): + # urls = super().get_urls() + # from django.urls import path + # from blog.views import refresh_memcache + # + # my_urls = [ + # path('refresh/', self.admin_view(refresh_memcache), name="refresh"), + # ] + # return urls + my_urls + + +admin_site = DjangoBlogAdminSite(name='admin') + +admin_site.register(Article, ArticlelAdmin) +admin_site.register(Category, CategoryAdmin) +admin_site.register(Tag, TagAdmin) +admin_site.register(Links, LinksAdmin) +admin_site.register(SideBar, SideBarAdmin) +admin_site.register(BlogSettings, BlogSettingsAdmin) + +admin_site.register(commands, CommandsAdmin) +admin_site.register(EmailSendLog, EmailSendLogAdmin) + +admin_site.register(BlogUser, BlogUserAdmin) + +admin_site.register(Comment, CommentAdmin) + +admin_site.register(OAuthUser, OAuthUserAdmin) +admin_site.register(OAuthConfig, OAuthConfigAdmin) + +admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) + +admin_site.register(Site, SiteAdmin) + +admin_site.register(LogEntry, LogEntryAdmin) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/apps.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/apps.py new file mode 100644 index 00000000..d29e318a --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + +class DjangoblogAppConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'djangoblog' + + def ready(self): + super().ready() + # Import and load plugins here + from .plugin_manage.loader import load_plugins + load_plugins() \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/blog_signals.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/blog_signals.py new file mode 100644 index 00000000..393f441c --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/blog_signals.py @@ -0,0 +1,122 @@ +import _thread +import logging + +import django.dispatch +from django.conf import settings +from django.contrib.admin.models import LogEntry +from django.contrib.auth.signals import user_logged_in, user_logged_out +from django.core.mail import EmailMultiAlternatives +from django.db.models.signals import post_save +from django.dispatch import receiver + +from comments.models import Comment +from comments.utils import send_comment_email +from djangoblog.spider_notify import SpiderNotify +from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, delete_view_cache +from djangoblog.utils import get_current_site +from oauth.models import OAuthUser + +logger = logging.getLogger(__name__) + +oauth_user_login_signal = django.dispatch.Signal(['id']) +send_email_signal = django.dispatch.Signal( + ['emailto', 'title', 'content']) + + +@receiver(send_email_signal) +def send_email_signal_handler(sender, **kwargs): + emailto = kwargs['emailto'] + title = kwargs['title'] + content = kwargs['content'] + + msg = EmailMultiAlternatives( + title, + content, + from_email=settings.DEFAULT_FROM_EMAIL, + to=emailto) + msg.content_subtype = "html" + + from servermanager.models import EmailSendLog + log = EmailSendLog() + log.title = title + log.content = content + log.emailto = ','.join(emailto) + + try: + result = msg.send() + log.send_result = result > 0 + except Exception as e: + logger.error(f"失败邮箱号: {emailto}, {e}") + log.send_result = False + log.save() + + +@receiver(oauth_user_login_signal) +def oauth_user_login_signal_handler(sender, **kwargs): + id = kwargs['id'] + oauthuser = OAuthUser.objects.get(id=id) + site = get_current_site().domain + if oauthuser.picture and not oauthuser.picture.find(site) >= 0: + from djangoblog.utils import save_user_avatar + oauthuser.picture = save_user_avatar(oauthuser.picture) + oauthuser.save() + + delete_sidebar_cache() + + +@receiver(post_save) +def model_post_save_callback( + sender, + instance, + created, + raw, + using, + update_fields, + **kwargs): + clearcache = False + if isinstance(instance, LogEntry): + return + if 'get_full_url' in dir(instance): + is_update_views = update_fields == {'views'} + if not settings.TESTING and not is_update_views: + try: + notify_url = instance.get_full_url() + SpiderNotify.baidu_notify([notify_url]) + except Exception as ex: + logger.error("notify sipder", ex) + if not is_update_views: + clearcache = True + + if isinstance(instance, Comment): + if instance.is_enable: + path = instance.article.get_absolute_url() + site = get_current_site().domain + if site.find(':') > 0: + site = site[0:site.find(':')] + + expire_view_cache( + path, + servername=site, + serverport=80, + key_prefix='blogdetail') + if cache.get('seo_processor'): + cache.delete('seo_processor') + comment_cache_key = 'article_comments_{id}'.format( + id=instance.article.id) + cache.delete(comment_cache_key) + delete_sidebar_cache() + delete_view_cache('article_comments', [str(instance.article.pk)]) + + _thread.start_new_thread(send_comment_email, (instance,)) + + if clearcache: + cache.clear() + + +@receiver(user_logged_in) +@receiver(user_logged_out) +def user_auth_callback(sender, request, user, **kwargs): + if user and user.username: + logger.info(user) + delete_sidebar_cache() + # cache.clear() diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/elasticsearch_backend.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/elasticsearch_backend.py new file mode 100644 index 00000000..4afe4981 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/elasticsearch_backend.py @@ -0,0 +1,183 @@ +from django.utils.encoding import force_str +from elasticsearch_dsl import Q +from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query +from haystack.forms import ModelSearchForm +from haystack.models import SearchResult +from haystack.utils import log as logging + +from blog.documents import ArticleDocument, ArticleDocumentManager +from blog.models import Article + +logger = logging.getLogger(__name__) + + +class ElasticSearchBackend(BaseSearchBackend): + def __init__(self, connection_alias, **connection_options): + super( + ElasticSearchBackend, + self).__init__( + connection_alias, + **connection_options) + self.manager = ArticleDocumentManager() + self.include_spelling = True + + def _get_models(self, iterable): + models = iterable if iterable and iterable[0] else Article.objects.all() + docs = self.manager.convert_to_doc(models) + return docs + + def _create(self, models): + self.manager.create_index() + docs = self._get_models(models) + self.manager.rebuild(docs) + + def _delete(self, models): + for m in models: + m.delete() + return True + + def _rebuild(self, models): + models = models if models else Article.objects.all() + docs = self.manager.convert_to_doc(models) + self.manager.update_docs(docs) + + def update(self, index, iterable, commit=True): + + models = self._get_models(iterable) + self.manager.update_docs(models) + + def remove(self, obj_or_string): + models = self._get_models([obj_or_string]) + self._delete(models) + + def clear(self, models=None, commit=True): + self.remove(None) + + @staticmethod + def get_suggestion(query: str) -> str: + """获取推荐词, 如果没有找到添加原搜索词""" + + search = ArticleDocument.search() \ + .query("match", body=query) \ + .suggest('suggest_search', query, term={'field': 'body'}) \ + .execute() + + keywords = [] + for suggest in search.suggest.suggest_search: + if suggest["options"]: + keywords.append(suggest["options"][0]["text"]) + else: + keywords.append(suggest["text"]) + + return ' '.join(keywords) + + @log_query + def search(self, query_string, **kwargs): + logger.info('search query_string:' + query_string) + + start_offset = kwargs.get('start_offset') + end_offset = kwargs.get('end_offset') + + # 推荐词搜索 + if getattr(self, "is_suggest", None): + suggestion = self.get_suggestion(query_string) + else: + suggestion = query_string + + q = Q('bool', + should=[Q('match', body=suggestion), Q('match', title=suggestion)], + minimum_should_match="70%") + + search = ArticleDocument.search() \ + .query('bool', filter=[q]) \ + .filter('term', status='p') \ + .filter('term', type='a') \ + .source(False)[start_offset: end_offset] + + results = search.execute() + hits = results['hits'].total + raw_results = [] + for raw_result in results['hits']['hits']: + app_label = 'blog' + model_name = 'Article' + additional_fields = {} + + result_class = SearchResult + + result = result_class( + app_label, + model_name, + raw_result['_id'], + raw_result['_score'], + **additional_fields) + raw_results.append(result) + facets = {} + spelling_suggestion = None if query_string == suggestion else suggestion + + return { + 'results': raw_results, + 'hits': hits, + 'facets': facets, + 'spelling_suggestion': spelling_suggestion, + } + + +class ElasticSearchQuery(BaseSearchQuery): + def _convert_datetime(self, date): + if hasattr(date, 'hour'): + return force_str(date.strftime('%Y%m%d%H%M%S')) + else: + return force_str(date.strftime('%Y%m%d000000')) + + def clean(self, query_fragment): + """ + Provides a mechanism for sanitizing user input before presenting the + value to the backend. + + Whoosh 1.X differs here in that you can no longer use a backslash + to escape reserved characters. Instead, the whole word should be + quoted. + """ + words = query_fragment.split() + cleaned_words = [] + + for word in words: + if word in self.backend.RESERVED_WORDS: + word = word.replace(word, word.lower()) + + for char in self.backend.RESERVED_CHARACTERS: + if char in word: + word = "'%s'" % word + break + + cleaned_words.append(word) + + return ' '.join(cleaned_words) + + def build_query_fragment(self, field, filter_type, value): + return value.query_string + + def get_count(self): + results = self.get_results() + return len(results) if results else 0 + + def get_spelling_suggestion(self, preferred_query=None): + return self._spelling_suggestion + + def build_params(self, spelling_query=None): + kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query) + return kwargs + + +class ElasticSearchModelSearchForm(ModelSearchForm): + + def search(self): + # 是否建议搜索 + self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no" + sqs = super().search() + return sqs + + +class ElasticSearchEngine(BaseEngine): + backend = ElasticSearchBackend + query = ElasticSearchQuery diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/feeds.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/feeds.py new file mode 100644 index 00000000..8c4e851c --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/feeds.py @@ -0,0 +1,40 @@ +from django.contrib.auth import get_user_model +from django.contrib.syndication.views import Feed +from django.utils import timezone +from django.utils.feedgenerator import Rss201rev2Feed + +from blog.models import Article +from djangoblog.utils import CommonMarkdown + + +class DjangoBlogFeed(Feed): + feed_type = Rss201rev2Feed + + description = '大巧无工,重剑无锋.' + title = "且听风吟 大巧无工,重剑无锋. " + link = "/feed/" + + def author_name(self): + return get_user_model().objects.first().nickname + + def author_link(self): + return get_user_model().objects.first().get_absolute_url() + + def items(self): + return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5] + + def item_title(self, item): + return item.title + + def item_description(self, item): + return CommonMarkdown.get_markdown(item.body) + + def feed_copyright(self): + now = timezone.now() + return "Copyright© {year} 且听风吟".format(year=now.year) + + def item_link(self, item): + return item.get_absolute_url() + + def item_guid(self, item): + return diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/logentryadmin.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/logentryadmin.py new file mode 100644 index 00000000..2f6a5353 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/logentryadmin.py @@ -0,0 +1,91 @@ +from django.contrib import admin +from django.contrib.admin.models import DELETION +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse, NoReverseMatch +from django.utils.encoding import force_str +from django.utils.html import escape +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + + +class LogEntryAdmin(admin.ModelAdmin): + list_filter = [ + 'content_type' + ] + + search_fields = [ + 'object_repr', + 'change_message' + ] + + list_display_links = [ + 'action_time', + 'get_change_message', + ] + list_display = [ + 'action_time', + 'user_link', + 'content_type', + 'object_link', + 'get_change_message', + ] + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return ( + request.user.is_superuser or + request.user.has_perm('admin.change_logentry') + ) and request.method != 'POST' + + def has_delete_permission(self, request, obj=None): + return False + + def object_link(self, obj): + object_link = escape(obj.object_repr) + content_type = obj.content_type + + if obj.action_flag != DELETION and content_type is not None: + # try returning an actual link instead of object repr string + try: + url = reverse( + 'admin:{}_{}_change'.format(content_type.app_label, + content_type.model), + args=[obj.object_id] + ) + object_link = '{}'.format(url, object_link) + except NoReverseMatch: + pass + return mark_safe(object_link) + + object_link.admin_order_field = 'object_repr' + object_link.short_description = _('object') + + def user_link(self, obj): + content_type = ContentType.objects.get_for_model(type(obj.user)) + user_link = escape(force_str(obj.user)) + try: + # try returning an actual link instead of object repr string + url = reverse( + 'admin:{}_{}_change'.format(content_type.app_label, + content_type.model), + args=[obj.user.pk] + ) + user_link = '{}'.format(url, user_link) + except NoReverseMatch: + pass + return mark_safe(user_link) + + user_link.admin_order_field = 'user' + user_link.short_description = _('user') + + def get_queryset(self, request): + queryset = super(LogEntryAdmin, self).get_queryset(request) + return queryset.prefetch_related('content_type') + + def get_actions(self, request): + actions = super(LogEntryAdmin, self).get_actions(request) + if 'delete_selected' in actions: + del actions['delete_selected'] + return actions diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/plugin_manage/base_plugin.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/plugin_manage/base_plugin.py new file mode 100644 index 00000000..2b4be5cb --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/plugin_manage/base_plugin.py @@ -0,0 +1,41 @@ +import logging + +logger = logging.getLogger(__name__) + + +class BasePlugin: + # 插件元数据 + PLUGIN_NAME = None + PLUGIN_DESCRIPTION = None + PLUGIN_VERSION = None + + def __init__(self): + if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]): + raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.") + self.init_plugin() + self.register_hooks() + + def init_plugin(self): + """ + 插件初始化逻辑 + 子类可以重写此方法来实现特定的初始化操作 + """ + logger.info(f'{self.PLUGIN_NAME} initialized.') + + def register_hooks(self): + """ + 注册插件钩子 + 子类可以重写此方法来注册特定的钩子 + """ + pass + + def get_plugin_info(self): + """ + 获取插件信息 + :return: 包含插件元数据的字典 + """ + return { + 'name': self.PLUGIN_NAME, + 'description': self.PLUGIN_DESCRIPTION, + 'version': self.PLUGIN_VERSION + } diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/plugin_manage/hook_constants.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/plugin_manage/hook_constants.py new file mode 100644 index 00000000..6685b7ce --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/plugin_manage/hook_constants.py @@ -0,0 +1,7 @@ +ARTICLE_DETAIL_LOAD = 'article_detail_load' +ARTICLE_CREATE = 'article_create' +ARTICLE_UPDATE = 'article_update' +ARTICLE_DELETE = 'article_delete' + +ARTICLE_CONTENT_HOOK_NAME = "the_content" + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/plugin_manage/hooks.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/plugin_manage/hooks.py new file mode 100644 index 00000000..d7125402 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/plugin_manage/hooks.py @@ -0,0 +1,44 @@ +import logging + +logger = logging.getLogger(__name__) + +_hooks = {} + + +def register(hook_name: str, callback: callable): + """ + 注册一个钩子回调。 + """ + if hook_name not in _hooks: + _hooks[hook_name] = [] + _hooks[hook_name].append(callback) + logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'") + + +def run_action(hook_name: str, *args, **kwargs): + """ + 执行一个 Action Hook。 + 它会按顺序执行所有注册到该钩子上的回调函数。 + """ + if hook_name in _hooks: + logger.debug(f"Running action hook '{hook_name}'") + for callback in _hooks[hook_name]: + try: + callback(*args, **kwargs) + except Exception as e: + logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) + + +def apply_filters(hook_name: str, value, *args, **kwargs): + """ + 执行一个 Filter Hook。 + 它会把 value 依次传递给所有注册的回调函数进行处理。 + """ + if hook_name in _hooks: + logger.debug(f"Applying filter hook '{hook_name}'") + for callback in _hooks[hook_name]: + try: + value = callback(value, *args, **kwargs) + except Exception as e: + logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) + return value diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/plugin_manage/loader.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/plugin_manage/loader.py new file mode 100644 index 00000000..12e824ba --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/plugin_manage/loader.py @@ -0,0 +1,19 @@ +import os +import logging +from django.conf import settings + +logger = logging.getLogger(__name__) + +def load_plugins(): + """ + Dynamically loads and initializes plugins from the 'plugins' directory. + This function is intended to be called when the Django app registry is ready. + """ + for plugin_name in settings.ACTIVE_PLUGINS: + plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name) + if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')): + try: + __import__(f'plugins.{plugin_name}.plugin') + logger.info(f"Successfully loaded plugin: {plugin_name}") + except ImportError as e: + logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/settings.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/settings.py new file mode 100644 index 00000000..615bcf53 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/settings.py @@ -0,0 +1,356 @@ +""" +Django settings for djangoblog project. + +Generated by 'django-admin startproject' using Django 1.10.2. + +For more information on this file, see +https://docs.djangoproject.com/en/1.10/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.10/ref/settings/ +""" +import os +import sys +from pathlib import Path + +from django.utils.translation import gettext_lazy as _ + + +def env_to_bool(env, default): + str_val = os.environ.get(env) + return default if str_val is None else str_val == 'True' + + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get( + 'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6' +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = env_to_bool('DJANGO_DEBUG', True) +# DEBUG = False +TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test' + +# ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com'] +# django 4.0新增配置 +CSRF_TRUSTED_ORIGINS = ['http://example.com'] +# Application definition + + +INSTALLED_APPS = [ + # 'django.contrib.admin', + 'django.contrib.admin.apps.SimpleAdminConfig', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.sites', + 'django.contrib.sitemaps', + 'mdeditor', + 'haystack', + 'blog', + 'accounts', + 'comments', + 'oauth', + 'servermanager', + 'owntracks', + 'compressor', + 'djangoblog' +] + +MIDDLEWARE = [ + + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.gzip.GZipMiddleware', + # 'django.middleware.cache.UpdateCacheMiddleware', + 'django.middleware.common.CommonMiddleware', + # 'django.middleware.cache.FetchFromCacheMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.middleware.http.ConditionalGetMiddleware', + 'blog.middleware.OnlineMiddleware' +] + +ROOT_URLCONF = 'djangoblog.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'templates')], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'blog.context_processors.seo_processor' + ], + }, + }, +] + +WSGI_APPLICATION = 'djangoblog.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/1.10/ref/settings/#databases + + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': 'djangoblog', + 'USER': 'root', + 'PASSWORD': 'Zyl123456789', + 'HOST': '127.0.0.1', + 'PORT': int( + 3306), + 'OPTIONS': { + 'charset': 'utf8mb4'}, + }} + +# Password validation +# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +LANGUAGES = ( + ('en', _('English')), + ('zh-hans', _('Simplified Chinese')), + ('zh-hant', _('Traditional Chinese')), +) +LOCALE_PATHS = ( + os.path.join(BASE_DIR, 'locale'), +) + +LANGUAGE_CODE = 'zh-hans' + +TIME_ZONE = 'Asia/Shanghai' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = False + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.10/howto/static-files/ + + +HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', + 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), + }, +} +# Automatically update searching index +HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor' +# Allow user login with username and password +AUTHENTICATION_BACKENDS = [ + 'accounts.user_login_backend.EmailOrUsernameModelBackend'] + +STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic') + +STATIC_URL = '/static/' +STATICFILES = os.path.join(BASE_DIR, 'static') + +AUTH_USER_MODEL = 'accounts.BlogUser' +LOGIN_URL = '/login/' + +TIME_FORMAT = '%Y-%m-%d %H:%M:%S' +DATE_TIME_FORMAT = '%Y-%m-%d' + +# bootstrap color styles +BOOTSTRAP_COLOR_TYPES = [ + 'default', 'primary', 'success', 'info', 'warning', 'danger' +] + +# paginate +PAGINATE_BY = 10 +# http cache timeout +CACHE_CONTROL_MAX_AGE = 2592000 +# cache setting +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'TIMEOUT': 10800, + 'LOCATION': 'unique-snowflake', + } +} +# 使用redis作为缓存 +if os.environ.get("DJANGO_REDIS_URL"): + CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}', + } + } + +SITE_ID = 1 +BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \ + or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn' + +# Email: +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False) +EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True) +EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com' +EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465) +EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER') +EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD') +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER +SERVER_EMAIL = EMAIL_HOST_USER +# Setting debug=false did NOT handle except email notifications +ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')] +# WX ADMIN password(Two times md5) +WXADMIN = os.environ.get( + 'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7' + +LOG_PATH = os.path.join(BASE_DIR, 'logs') +if not os.path.exists(LOG_PATH): + os.makedirs(LOG_PATH, exist_ok=True) + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'root': { + 'level': 'INFO', + 'handlers': ['console', 'log_file'], + }, + 'formatters': { + 'verbose': { + 'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s', + } + }, + 'filters': { + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse', + }, + 'require_debug_true': { + '()': 'django.utils.log.RequireDebugTrue', + }, + }, + 'handlers': { + 'log_file': { + 'level': 'INFO', + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': os.path.join(LOG_PATH, 'djangoblog.log'), + 'when': 'D', + 'formatter': 'verbose', + 'interval': 1, + 'delay': True, + 'backupCount': 5, + 'encoding': 'utf-8' + }, + 'console': { + 'level': 'DEBUG', + 'filters': ['require_debug_true'], + 'class': 'logging.StreamHandler', + 'formatter': 'verbose' + }, + 'null': { + 'class': 'logging.NullHandler', + }, + 'mail_admins': { + 'level': 'ERROR', + 'filters': ['require_debug_false'], + 'class': 'django.utils.log.AdminEmailHandler' + } + }, + 'loggers': { + 'djangoblog': { + 'handlers': ['log_file', 'console'], + 'level': 'INFO', + 'propagate': True, + }, + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': False, + } + } +} + +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + # other + 'compressor.finders.CompressorFinder', +) +COMPRESS_ENABLED = True +# COMPRESS_OFFLINE = True + + +COMPRESS_CSS_FILTERS = [ + # creates absolute urls from relative ones + 'compressor.filters.css_default.CssAbsoluteFilter', + # css minimizer + 'compressor.filters.cssmin.CSSMinFilter' +] +COMPRESS_JS_FILTERS = [ + 'compressor.filters.jsmin.JSMinFilter' +] + +MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') +MEDIA_URL = '/media/' +X_FRAME_OPTIONS = 'SAMEORIGIN' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +if os.environ.get('DJANGO_ELASTICSEARCH_HOST'): + ELASTICSEARCH_DSL = { + 'default': { + 'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST') + }, + } + HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine', + }, + } + +# Plugin System +PLUGINS_DIR = BASE_DIR / 'plugins' +ACTIVE_PLUGINS = [ + 'article_copyright', + 'reading_time', + 'external_links', + 'view_count', + 'seo_optimizer' +] + +# 功能:登录安全配置(行347-350) +# Login security +LOGIN_MAX_ATTEMPTS = int(os.environ.get('DJANGO_LOGIN_MAX_ATTEMPTS', 5)) +LOGIN_FAIL_WINDOW = int(os.environ.get('DJANGO_LOGIN_FAIL_WINDOW', 15 * 60)) +LOGIN_LOCK_SECONDS = int(os.environ.get('DJANGO_LOGIN_LOCK_SECONDS', 30 * 60)) +LOGIN_CAPTCHA_LENGTH = int(os.environ.get('DJANGO_LOGIN_CAPTCHA_LENGTH', 4)) + +# 功能:OpenAI 接口配置(行353-356) +OPENAI_API_KEY = os.environ.get('DJANGO_OPENAI_API_KEY') +OPENAI_CHAT_MODEL = os.environ.get('DJANGO_OPENAI_CHAT_MODEL', 'gpt-3.5-turbo') +OPENAI_MAX_TOKENS = int(os.environ.get('DJANGO_OPENAI_MAX_TOKENS', 512)) +OPENAI_TIMEOUT = int(os.environ.get('DJANGO_OPENAI_TIMEOUT', 30)) \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/sitemap.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/sitemap.py new file mode 100644 index 00000000..8b7d4460 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/sitemap.py @@ -0,0 +1,59 @@ +from django.contrib.sitemaps import Sitemap +from django.urls import reverse + +from blog.models import Article, Category, Tag + + +class StaticViewSitemap(Sitemap): + priority = 0.5 + changefreq = 'daily' + + def items(self): + return ['blog:index', ] + + def location(self, item): + return reverse(item) + + +class ArticleSiteMap(Sitemap): + changefreq = "monthly" + priority = "0.6" + + def items(self): + return Article.objects.filter(status='p') + + def lastmod(self, obj): + return obj.last_modify_time + + +class CategorySiteMap(Sitemap): + changefreq = "Weekly" + priority = "0.6" + + def items(self): + return Category.objects.all() + + def lastmod(self, obj): + return obj.last_modify_time + + +class TagSiteMap(Sitemap): + changefreq = "Weekly" + priority = "0.3" + + def items(self): + return Tag.objects.all() + + def lastmod(self, obj): + return obj.last_modify_time + + +class UserSiteMap(Sitemap): + changefreq = "Weekly" + priority = "0.3" + + def items(self): + return list(set(map(lambda x: x.author, Article.objects.all()))) + + def lastmod(self, obj): + return obj.date_joined diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/spider_notify.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/spider_notify.py new file mode 100644 index 00000000..7b909e96 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/spider_notify.py @@ -0,0 +1,21 @@ +import logging + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + + +class SpiderNotify(): + @staticmethod + def baidu_notify(urls): + try: + data = '\n'.join(urls) + result = requests.post(settings.BAIDU_NOTIFY_URL, data=data) + logger.info(result.text) + except Exception as e: + logger.error(e) + + @staticmethod + def notify(url): + SpiderNotify.baidu_notify(url) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/tests.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/tests.py new file mode 100644 index 00000000..01237d9a --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/tests.py @@ -0,0 +1,32 @@ +from django.test import TestCase + +from djangoblog.utils import * + + +class DjangoBlogTest(TestCase): + def setUp(self): + pass + + def test_utils(self): + md5 = get_sha256('test') + self.assertIsNotNone(md5) + c = CommonMarkdown.get_markdown(''' + # Title1 + + ```python + import os + ``` + + [url](https://www.lylinux.net/) + + [ddd](http://www.baidu.com) + + + ''') + self.assertIsNotNone(c) + d = { + 'd': 'key1', + 'd2': 'key2' + } + data = parse_dict_to_url(d) + self.assertIsNotNone(data) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/urls.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/urls.py new file mode 100644 index 00000000..4aae58a6 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/urls.py @@ -0,0 +1,64 @@ +"""djangoblog URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.10/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf import settings +from django.conf.urls.i18n import i18n_patterns +from django.conf.urls.static import static +from django.contrib.sitemaps.views import sitemap +from django.urls import path, include +from django.urls import re_path +from haystack.views import search_view_factory + +from blog.views import EsSearchView +from djangoblog.admin_site import admin_site +from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm +from djangoblog.feeds import DjangoBlogFeed +from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap + +sitemaps = { + + 'blog': ArticleSiteMap, + 'Category': CategorySiteMap, + 'Tag': TagSiteMap, + 'User': UserSiteMap, + 'static': StaticViewSitemap +} + +handler404 = 'blog.views.page_not_found_view' +handler500 = 'blog.views.server_error_view' +handle403 = 'blog.views.permission_denied_view' + +urlpatterns = [ + path('i18n/', include('django.conf.urls.i18n')), +] +urlpatterns += i18n_patterns( + re_path(r'^admin/', admin_site.urls), + re_path(r'', include('blog.urls', namespace='blog')), + re_path(r'mdeditor/', include('mdeditor.urls')), + re_path(r'', include('comments.urls', namespace='comment')), + re_path(r'', include('accounts.urls', namespace='account')), + re_path(r'', include('oauth.urls', namespace='oauth')), + re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, + name='django.contrib.sitemaps.views.sitemap'), + re_path(r'^feed/$', DjangoBlogFeed()), + re_path(r'^rss/$', DjangoBlogFeed()), + re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm), + name='search'), + re_path(r'', include('servermanager.urls', namespace='servermanager')), + re_path(r'', include('owntracks.urls', namespace='owntracks')) + , prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, + document_root=settings.MEDIA_ROOT) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/utils.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/utils.py new file mode 100644 index 00000000..57f63dca --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/utils.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python +# encoding: utf-8 + + +import logging +import os +import random +import string +import uuid +from hashlib import sha256 + +import bleach +import markdown +import requests +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.cache import cache +from django.templatetags.static import static + +logger = logging.getLogger(__name__) + + +def get_max_articleid_commentid(): + from blog.models import Article + from comments.models import Comment + return (Article.objects.latest().pk, Comment.objects.latest().pk) + + +def get_sha256(str): + m = sha256(str.encode('utf-8')) + return m.hexdigest() + + +def cache_decorator(expiration=3 * 60): + def wrapper(func): + def news(*args, **kwargs): + try: + view = args[0] + key = view.get_cache_key() + except: + key = None + if not key: + unique_str = repr((func, args, kwargs)) + + m = sha256(unique_str.encode('utf-8')) + key = m.hexdigest() + value = cache.get(key) + if value is not None: + # logger.info('cache_decorator get cache:%s key:%s' % (func.__name__, key)) + if str(value) == '__default_cache_value__': + return None + else: + return value + else: + logger.debug( + 'cache_decorator set cache:%s key:%s' % + (func.__name__, key)) + value = func(*args, **kwargs) + if value is None: + cache.set(key, '__default_cache_value__', expiration) + else: + cache.set(key, value, expiration) + return value + + return news + + return wrapper + + +def expire_view_cache(path, servername, serverport, key_prefix=None): + ''' + 刷新视图缓存 + :param path:url路径 + :param servername:host + :param serverport:端口 + :param key_prefix:前缀 + :return:是否成功 + ''' + from django.http import HttpRequest + from django.utils.cache import get_cache_key + + request = HttpRequest() + request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport} + request.path = path + + key = get_cache_key(request, key_prefix=key_prefix, cache=cache) + if key: + logger.info('expire_view_cache:get key:{path}'.format(path=path)) + if cache.get(key): + cache.delete(key) + return True + return False + + +@cache_decorator() +def get_current_site(): + site = Site.objects.get_current() + return site + + +class CommonMarkdown: + @staticmethod + def _convert_markdown(value): + md = markdown.Markdown( + extensions=[ + 'extra', + 'codehilite', + 'toc', + 'tables', + ] + ) + body = md.convert(value) + toc = md.toc + return body, toc + + @staticmethod + def get_markdown_with_toc(value): + body, toc = CommonMarkdown._convert_markdown(value) + return body, toc + + @staticmethod + def get_markdown(value): + body, toc = CommonMarkdown._convert_markdown(value) + return body + + +def send_email(emailto, title, content): + from djangoblog.blog_signals import send_email_signal + send_email_signal.send( + send_email.__class__, + emailto=emailto, + title=title, + content=content) + + +def generate_code() -> str: + """生成随机数验证码""" + return ''.join(random.sample(string.digits, 6)) + + +def parse_dict_to_url(dict): + from urllib.parse import quote + url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/')) + for k, v in dict.items()]) + return url + + +def get_blog_setting(): + value = cache.get('get_blog_setting') + if value: + return value + else: + from blog.models import BlogSettings + if not BlogSettings.objects.count(): + setting = BlogSettings() + setting.site_name = 'djangoblog' + setting.site_description = '基于Django的博客系统' + setting.site_seo_description = '基于Django的博客系统' + setting.site_keywords = 'Django,Python' + setting.article_sub_length = 300 + setting.sidebar_article_count = 10 + setting.sidebar_comment_count = 5 + setting.show_google_adsense = False + setting.open_site_comment = True + setting.analytics_code = '' + setting.beian_code = '' + setting.show_gongan_code = False + setting.comment_need_review = False + setting.save() + value = BlogSettings.objects.first() + logger.info('set cache get_blog_setting') + cache.set('get_blog_setting', value) + return value + + +def save_user_avatar(url): + ''' + 保存用户头像 + :param url:头像url + :return: 本地路径 + ''' + logger.info(url) + + try: + basedir = os.path.join(settings.STATICFILES, 'avatar') + rsp = requests.get(url, timeout=2) + if rsp.status_code == 200: + if not os.path.exists(basedir): + os.makedirs(basedir) + + image_extensions = ['.jpg', '.png', 'jpeg', '.gif'] + isimage = len([i for i in image_extensions if url.endswith(i)]) > 0 + ext = os.path.splitext(url)[1] if isimage else '.jpg' + save_filename = str(uuid.uuid4().hex) + ext + logger.info('保存用户头像:' + basedir + save_filename) + with open(os.path.join(basedir, save_filename), 'wb+') as file: + file.write(rsp.content) + return static('avatar/' + save_filename) + except Exception as e: + logger.error(e) + return static('blog/img/avatar.png') + + +def delete_sidebar_cache(): + from blog.models import LinkShowType + keys = ["sidebar" + x for x in LinkShowType.values] + for k in keys: + logger.info('delete sidebar key:' + k) + cache.delete(k) + + +def delete_view_cache(prefix, keys): + from django.core.cache.utils import make_template_fragment_key + key = make_template_fragment_key(prefix, keys) + cache.delete(key) + + +def get_resource_url(): + if settings.STATIC_URL: + return settings.STATIC_URL + else: + site = get_current_site() + return 'http://' + site.domain + '/static/' + + +ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1', + 'h2', 'p'] +ALLOWED_ATTRIBUTES = {'a': ['href', 'title'], 'abbr': ['title'], 'acronym': ['title']} + + +def sanitize_html(html): + return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/whoosh_cn_backend.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/whoosh_cn_backend.py new file mode 100644 index 00000000..04e3f7fd --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/whoosh_cn_backend.py @@ -0,0 +1,1044 @@ +# encoding: utf-8 + +from __future__ import absolute_import, division, print_function, unicode_literals + +import json +import os +import re +import shutil +import threading +import warnings + +import six +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from datetime import datetime +from django.utils.encoding import force_str +from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query +from haystack.constants import DJANGO_CT, DJANGO_ID, ID +from haystack.exceptions import MissingDependency, SearchBackendError, SkipDocument +from haystack.inputs import Clean, Exact, PythonData, Raw +from haystack.models import SearchResult +from haystack.utils import get_identifier, get_model_ct +from haystack.utils import log as logging +from haystack.utils.app_loading import haystack_get_model +from jieba.analyse import ChineseAnalyzer +from whoosh import index +from whoosh.analysis import StemmingAnalyzer +from whoosh.fields import BOOLEAN, DATETIME, IDLIST, KEYWORD, NGRAM, NGRAMWORDS, NUMERIC, Schema, TEXT +from whoosh.fields import ID as WHOOSH_ID +from whoosh.filedb.filestore import FileStorage, RamStorage +from whoosh.highlight import ContextFragmenter, HtmlFormatter +from whoosh.highlight import highlight as whoosh_highlight +from whoosh.qparser import QueryParser +from whoosh.searching import ResultsPage +from whoosh.writing import AsyncWriter + +try: + import whoosh +except ImportError: + raise MissingDependency( + "The 'whoosh' backend requires the installation of 'Whoosh'. Please refer to the documentation.") + +# Handle minimum requirement. +if not hasattr(whoosh, '__version__') or whoosh.__version__ < (2, 5, 0): + raise MissingDependency( + "The 'whoosh' backend requires version 2.5.0 or greater.") + +# Bubble up the correct error. + +DATETIME_REGEX = re.compile( + '^(?P\d{4})-(?P\d{2})-(?P\d{2})T(?P\d{2}):(?P\d{2}):(?P\d{2})(\.\d{3,6}Z?)?$') +LOCALS = threading.local() +LOCALS.RAM_STORE = None + + +class WhooshHtmlFormatter(HtmlFormatter): + """ + This is a HtmlFormatter simpler than the whoosh.HtmlFormatter. + We use it to have consistent results across backends. Specifically, + Solr, Xapian and Elasticsearch are using this formatting. + """ + template = '<%(tag)s>%(t)s' + + +class WhooshSearchBackend(BaseSearchBackend): + # Word reserved by Whoosh for special use. + RESERVED_WORDS = ( + 'AND', + 'NOT', + 'OR', + 'TO', + ) + + # Characters reserved by Whoosh for special use. + # The '\\' must come first, so as not to overwrite the other slash + # replacements. + RESERVED_CHARACTERS = ( + '\\', '+', '-', '&&', '||', '!', '(', ')', '{', '}', + '[', ']', '^', '"', '~', '*', '?', ':', '.', + ) + + def __init__(self, connection_alias, **connection_options): + super( + WhooshSearchBackend, + self).__init__( + connection_alias, + **connection_options) + self.setup_complete = False + self.use_file_storage = True + self.post_limit = getattr( + connection_options, + 'POST_LIMIT', + 128 * 1024 * 1024) + self.path = connection_options.get('PATH') + + if connection_options.get('STORAGE', 'file') != 'file': + self.use_file_storage = False + + if self.use_file_storage and not self.path: + raise ImproperlyConfigured( + "You must specify a 'PATH' in your settings for connection '%s'." % + connection_alias) + + self.log = logging.getLogger('haystack') + + def setup(self): + """ + Defers loading until needed. + """ + from haystack import connections + new_index = False + + # Make sure the index is there. + if self.use_file_storage and not os.path.exists(self.path): + os.makedirs(self.path) + new_index = True + + if self.use_file_storage and not os.access(self.path, os.W_OK): + raise IOError( + "The path to your Whoosh index '%s' is not writable for the current user/group." % + self.path) + + if self.use_file_storage: + self.storage = FileStorage(self.path) + else: + global LOCALS + + if getattr(LOCALS, 'RAM_STORE', None) is None: + LOCALS.RAM_STORE = RamStorage() + + self.storage = LOCALS.RAM_STORE + + self.content_field_name, self.schema = self.build_schema( + connections[self.connection_alias].get_unified_index().all_searchfields()) + self.parser = QueryParser(self.content_field_name, schema=self.schema) + + if new_index is True: + self.index = self.storage.create_index(self.schema) + else: + try: + self.index = self.storage.open_index(schema=self.schema) + except index.EmptyIndexError: + self.index = self.storage.create_index(self.schema) + + self.setup_complete = True + + def build_schema(self, fields): + schema_fields = { + ID: WHOOSH_ID(stored=True, unique=True), + DJANGO_CT: WHOOSH_ID(stored=True), + DJANGO_ID: WHOOSH_ID(stored=True), + } + # Grab the number of keys that are hard-coded into Haystack. + # We'll use this to (possibly) fail slightly more gracefully later. + initial_key_count = len(schema_fields) + content_field_name = '' + + for field_name, field_class in fields.items(): + if field_class.is_multivalued: + if field_class.indexed is False: + schema_fields[field_class.index_fieldname] = IDLIST( + stored=True, field_boost=field_class.boost) + else: + schema_fields[field_class.index_fieldname] = KEYWORD( + stored=True, commas=True, scorable=True, field_boost=field_class.boost) + elif field_class.field_type in ['date', 'datetime']: + schema_fields[field_class.index_fieldname] = DATETIME( + stored=field_class.stored, sortable=True) + elif field_class.field_type == 'integer': + schema_fields[field_class.index_fieldname] = NUMERIC( + stored=field_class.stored, numtype=int, field_boost=field_class.boost) + elif field_class.field_type == 'float': + schema_fields[field_class.index_fieldname] = NUMERIC( + stored=field_class.stored, numtype=float, field_boost=field_class.boost) + elif field_class.field_type == 'boolean': + # Field boost isn't supported on BOOLEAN as of 1.8.2. + schema_fields[field_class.index_fieldname] = BOOLEAN( + stored=field_class.stored) + elif field_class.field_type == 'ngram': + schema_fields[field_class.index_fieldname] = NGRAM( + minsize=3, maxsize=15, stored=field_class.stored, field_boost=field_class.boost) + elif field_class.field_type == 'edge_ngram': + schema_fields[field_class.index_fieldname] = NGRAMWORDS(minsize=2, maxsize=15, at='start', + stored=field_class.stored, + field_boost=field_class.boost) + else: + # schema_fields[field_class.index_fieldname] = TEXT(stored=True, analyzer=StemmingAnalyzer(), field_boost=field_class.boost, sortable=True) + schema_fields[field_class.index_fieldname] = TEXT( + stored=True, analyzer=ChineseAnalyzer(), field_boost=field_class.boost, sortable=True) + if field_class.document is True: + content_field_name = field_class.index_fieldname + schema_fields[field_class.index_fieldname].spelling = True + + # Fail more gracefully than relying on the backend to die if no fields + # are found. + if len(schema_fields) <= initial_key_count: + raise SearchBackendError( + "No fields were found in any search_indexes. Please correct this before attempting to search.") + + return (content_field_name, Schema(**schema_fields)) + + def update(self, index, iterable, commit=True): + if not self.setup_complete: + self.setup() + + self.index = self.index.refresh() + writer = AsyncWriter(self.index) + + for obj in iterable: + try: + doc = index.full_prepare(obj) + except SkipDocument: + self.log.debug(u"Indexing for object `%s` skipped", obj) + else: + # Really make sure it's unicode, because Whoosh won't have it any + # other way. + for key in doc: + doc[key] = self._from_python(doc[key]) + + # Document boosts aren't supported in Whoosh 2.5.0+. + if 'boost' in doc: + del doc['boost'] + + try: + writer.update_document(**doc) + except Exception as e: + if not self.silently_fail: + raise + + # We'll log the object identifier but won't include the actual object + # to avoid the possibility of that generating encoding errors while + # processing the log message: + self.log.error( + u"%s while preparing object for update" % + e.__class__.__name__, + exc_info=True, + extra={ + "data": { + "index": index, + "object": get_identifier(obj)}}) + + if len(iterable) > 0: + # For now, commit no matter what, as we run into locking issues + # otherwise. + writer.commit() + + def remove(self, obj_or_string, commit=True): + if not self.setup_complete: + self.setup() + + self.index = self.index.refresh() + whoosh_id = get_identifier(obj_or_string) + + try: + self.index.delete_by_query( + q=self.parser.parse( + u'%s:"%s"' % + (ID, whoosh_id))) + except Exception as e: + if not self.silently_fail: + raise + + self.log.error( + "Failed to remove document '%s' from Whoosh: %s", + whoosh_id, + e, + exc_info=True) + + def clear(self, models=None, commit=True): + if not self.setup_complete: + self.setup() + + self.index = self.index.refresh() + + if models is not None: + assert isinstance(models, (list, tuple)) + + try: + if models is None: + self.delete_index() + else: + models_to_delete = [] + + for model in models: + models_to_delete.append( + u"%s:%s" % + (DJANGO_CT, get_model_ct(model))) + + self.index.delete_by_query( + q=self.parser.parse( + u" OR ".join(models_to_delete))) + except Exception as e: + if not self.silently_fail: + raise + + if models is not None: + self.log.error( + "Failed to clear Whoosh index of models '%s': %s", + ','.join(models_to_delete), + e, + exc_info=True) + else: + self.log.error( + "Failed to clear Whoosh index: %s", e, exc_info=True) + + def delete_index(self): + # Per the Whoosh mailing list, if wiping out everything from the index, + # it's much more efficient to simply delete the index files. + if self.use_file_storage and os.path.exists(self.path): + shutil.rmtree(self.path) + elif not self.use_file_storage: + self.storage.clean() + + # Recreate everything. + self.setup() + + def optimize(self): + if not self.setup_complete: + self.setup() + + self.index = self.index.refresh() + self.index.optimize() + + def calculate_page(self, start_offset=0, end_offset=None): + # Prevent against Whoosh throwing an error. Requires an end_offset + # greater than 0. + if end_offset is not None and end_offset <= 0: + end_offset = 1 + + # Determine the page. + page_num = 0 + + if end_offset is None: + end_offset = 1000000 + + if start_offset is None: + start_offset = 0 + + page_length = end_offset - start_offset + + if page_length and page_length > 0: + page_num = int(start_offset / page_length) + + # Increment because Whoosh uses 1-based page numbers. + page_num += 1 + return page_num, page_length + + @log_query + def search( + self, + query_string, + sort_by=None, + start_offset=0, + end_offset=None, + fields='', + highlight=False, + facets=None, + date_facets=None, + query_facets=None, + narrow_queries=None, + spelling_query=None, + within=None, + dwithin=None, + distance_point=None, + models=None, + limit_to_registered_models=None, + result_class=None, + **kwargs): + if not self.setup_complete: + self.setup() + + # A zero length query should return no results. + if len(query_string) == 0: + return { + 'results': [], + 'hits': 0, + } + + query_string = force_str(query_string) + + # A one-character query (non-wildcard) gets nabbed by a stopwords + # filter and should yield zero results. + if len(query_string) <= 1 and query_string != u'*': + return { + 'results': [], + 'hits': 0, + } + + reverse = False + + if sort_by is not None: + # Determine if we need to reverse the results and if Whoosh can + # handle what it's being asked to sort by. Reversing is an + # all-or-nothing action, unfortunately. + sort_by_list = [] + reverse_counter = 0 + + for order_by in sort_by: + if order_by.startswith('-'): + reverse_counter += 1 + + if reverse_counter and reverse_counter != len(sort_by): + raise SearchBackendError("Whoosh requires all order_by fields" + " to use the same sort direction") + + for order_by in sort_by: + if order_by.startswith('-'): + sort_by_list.append(order_by[1:]) + + if len(sort_by_list) == 1: + reverse = True + else: + sort_by_list.append(order_by) + + if len(sort_by_list) == 1: + reverse = False + + sort_by = sort_by_list[0] + + if facets is not None: + warnings.warn( + "Whoosh does not handle faceting.", + Warning, + stacklevel=2) + + if date_facets is not None: + warnings.warn( + "Whoosh does not handle date faceting.", + Warning, + stacklevel=2) + + if query_facets is not None: + warnings.warn( + "Whoosh does not handle query faceting.", + Warning, + stacklevel=2) + + narrowed_results = None + self.index = self.index.refresh() + + if limit_to_registered_models is None: + limit_to_registered_models = getattr( + settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True) + + if models and len(models): + model_choices = sorted(get_model_ct(model) for model in models) + elif limit_to_registered_models: + # Using narrow queries, limit the results to only models handled + # with the current routers. + model_choices = self.build_models_list() + else: + model_choices = [] + + if len(model_choices) > 0: + if narrow_queries is None: + narrow_queries = set() + + narrow_queries.add(' OR '.join( + ['%s:%s' % (DJANGO_CT, rm) for rm in model_choices])) + + narrow_searcher = None + + if narrow_queries is not None: + # Potentially expensive? I don't see another way to do it in + # Whoosh... + narrow_searcher = self.index.searcher() + + for nq in narrow_queries: + recent_narrowed_results = narrow_searcher.search( + self.parser.parse(force_str(nq)), limit=None) + + if len(recent_narrowed_results) <= 0: + return { + 'results': [], + 'hits': 0, + } + + if narrowed_results: + narrowed_results.filter(recent_narrowed_results) + else: + narrowed_results = recent_narrowed_results + + self.index = self.index.refresh() + + if self.index.doc_count(): + searcher = self.index.searcher() + parsed_query = self.parser.parse(query_string) + + # In the event of an invalid/stopworded query, recover gracefully. + if parsed_query is None: + return { + 'results': [], + 'hits': 0, + } + + page_num, page_length = self.calculate_page( + start_offset, end_offset) + + search_kwargs = { + 'pagelen': page_length, + 'sortedby': sort_by, + 'reverse': reverse, + } + + # Handle the case where the results have been narrowed. + if narrowed_results is not None: + search_kwargs['filter'] = narrowed_results + + try: + raw_page = searcher.search_page( + parsed_query, + page_num, + **search_kwargs + ) + except ValueError: + if not self.silently_fail: + raise + + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': None, + } + + # Because as of Whoosh 2.5.1, it will return the wrong page of + # results if you request something too high. :( + if raw_page.pagenum < page_num: + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': None, + } + + results = self._process_results( + raw_page, + highlight=highlight, + query_string=query_string, + spelling_query=spelling_query, + result_class=result_class) + searcher.close() + + if hasattr(narrow_searcher, 'close'): + narrow_searcher.close() + + return results + else: + if self.include_spelling: + if spelling_query: + spelling_suggestion = self.create_spelling_suggestion( + spelling_query) + else: + spelling_suggestion = self.create_spelling_suggestion( + query_string) + else: + spelling_suggestion = None + + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': spelling_suggestion, + } + + def more_like_this( + self, + model_instance, + additional_query_string=None, + start_offset=0, + end_offset=None, + models=None, + limit_to_registered_models=None, + result_class=None, + **kwargs): + if not self.setup_complete: + self.setup() + + # Deferred models will have a different class ("RealClass_Deferred_fieldname") + # which won't be in our registry: + model_klass = model_instance._meta.concrete_model + + field_name = self.content_field_name + narrow_queries = set() + narrowed_results = None + self.index = self.index.refresh() + + if limit_to_registered_models is None: + limit_to_registered_models = getattr( + settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True) + + if models and len(models): + model_choices = sorted(get_model_ct(model) for model in models) + elif limit_to_registered_models: + # Using narrow queries, limit the results to only models handled + # with the current routers. + model_choices = self.build_models_list() + else: + model_choices = [] + + if len(model_choices) > 0: + if narrow_queries is None: + narrow_queries = set() + + narrow_queries.add(' OR '.join( + ['%s:%s' % (DJANGO_CT, rm) for rm in model_choices])) + + if additional_query_string and additional_query_string != '*': + narrow_queries.add(additional_query_string) + + narrow_searcher = None + + if narrow_queries is not None: + # Potentially expensive? I don't see another way to do it in + # Whoosh... + narrow_searcher = self.index.searcher() + + for nq in narrow_queries: + recent_narrowed_results = narrow_searcher.search( + self.parser.parse(force_str(nq)), limit=None) + + if len(recent_narrowed_results) <= 0: + return { + 'results': [], + 'hits': 0, + } + + if narrowed_results: + narrowed_results.filter(recent_narrowed_results) + else: + narrowed_results = recent_narrowed_results + + page_num, page_length = self.calculate_page(start_offset, end_offset) + + self.index = self.index.refresh() + raw_results = EmptyResults() + + if self.index.doc_count(): + query = "%s:%s" % (ID, get_identifier(model_instance)) + searcher = self.index.searcher() + parsed_query = self.parser.parse(query) + results = searcher.search(parsed_query) + + if len(results): + raw_results = results[0].more_like_this( + field_name, top=end_offset) + + # Handle the case where the results have been narrowed. + if narrowed_results is not None and hasattr(raw_results, 'filter'): + raw_results.filter(narrowed_results) + + try: + raw_page = ResultsPage(raw_results, page_num, page_length) + except ValueError: + if not self.silently_fail: + raise + + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': None, + } + + # Because as of Whoosh 2.5.1, it will return the wrong page of + # results if you request something too high. :( + if raw_page.pagenum < page_num: + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': None, + } + + results = self._process_results(raw_page, result_class=result_class) + searcher.close() + + if hasattr(narrow_searcher, 'close'): + narrow_searcher.close() + + return results + + def _process_results( + self, + raw_page, + highlight=False, + query_string='', + spelling_query=None, + result_class=None): + from haystack import connections + results = [] + + # It's important to grab the hits first before slicing. Otherwise, this + # can cause pagination failures. + hits = len(raw_page) + + if result_class is None: + result_class = SearchResult + + facets = {} + spelling_suggestion = None + unified_index = connections[self.connection_alias].get_unified_index() + indexed_models = unified_index.get_indexed_models() + + for doc_offset, raw_result in enumerate(raw_page): + score = raw_page.score(doc_offset) or 0 + app_label, model_name = raw_result[DJANGO_CT].split('.') + additional_fields = {} + model = haystack_get_model(app_label, model_name) + + if model and model in indexed_models: + for key, value in raw_result.items(): + index = unified_index.get_index(model) + string_key = str(key) + + if string_key in index.fields and hasattr( + index.fields[string_key], 'convert'): + # Special-cased due to the nature of KEYWORD fields. + if index.fields[string_key].is_multivalued: + if value is None or len(value) == 0: + additional_fields[string_key] = [] + else: + additional_fields[string_key] = value.split( + ',') + else: + additional_fields[string_key] = index.fields[string_key].convert( + value) + else: + additional_fields[string_key] = self._to_python(value) + + del (additional_fields[DJANGO_CT]) + del (additional_fields[DJANGO_ID]) + + if highlight: + sa = StemmingAnalyzer() + formatter = WhooshHtmlFormatter('em') + terms = [token.text for token in sa(query_string)] + + whoosh_result = whoosh_highlight( + additional_fields.get(self.content_field_name), + terms, + sa, + ContextFragmenter(), + formatter + ) + additional_fields['highlighted'] = { + self.content_field_name: [whoosh_result], + } + + result = result_class( + app_label, + model_name, + raw_result[DJANGO_ID], + score, + **additional_fields) + results.append(result) + else: + hits -= 1 + + if self.include_spelling: + if spelling_query: + spelling_suggestion = self.create_spelling_suggestion( + spelling_query) + else: + spelling_suggestion = self.create_spelling_suggestion( + query_string) + + return { + 'results': results, + 'hits': hits, + 'facets': facets, + 'spelling_suggestion': spelling_suggestion, + } + + def create_spelling_suggestion(self, query_string): + spelling_suggestion = None + reader = self.index.reader() + corrector = reader.corrector(self.content_field_name) + cleaned_query = force_str(query_string) + + if not query_string: + return spelling_suggestion + + # Clean the string. + for rev_word in self.RESERVED_WORDS: + cleaned_query = cleaned_query.replace(rev_word, '') + + for rev_char in self.RESERVED_CHARACTERS: + cleaned_query = cleaned_query.replace(rev_char, '') + + # Break it down. + query_words = cleaned_query.split() + suggested_words = [] + + for word in query_words: + suggestions = corrector.suggest(word, limit=1) + + if len(suggestions) > 0: + suggested_words.append(suggestions[0]) + + spelling_suggestion = ' '.join(suggested_words) + return spelling_suggestion + + def _from_python(self, value): + """ + Converts Python values to a string for Whoosh. + + Code courtesy of pysolr. + """ + if hasattr(value, 'strftime'): + if not hasattr(value, 'hour'): + value = datetime(value.year, value.month, value.day, 0, 0, 0) + elif isinstance(value, bool): + if value: + value = 'true' + else: + value = 'false' + elif isinstance(value, (list, tuple)): + value = u','.join([force_str(v) for v in value]) + elif isinstance(value, (six.integer_types, float)): + # Leave it alone. + pass + else: + value = force_str(value) + return value + + def _to_python(self, value): + """ + Converts values from Whoosh to native Python values. + + A port of the same method in pysolr, as they deal with data the same way. + """ + if value == 'true': + return True + elif value == 'false': + return False + + if value and isinstance(value, six.string_types): + possible_datetime = DATETIME_REGEX.search(value) + + if possible_datetime: + date_values = possible_datetime.groupdict() + + for dk, dv in date_values.items(): + date_values[dk] = int(dv) + + return datetime( + date_values['year'], + date_values['month'], + date_values['day'], + date_values['hour'], + date_values['minute'], + date_values['second']) + + try: + # Attempt to use json to load the values. + converted_value = json.loads(value) + + # Try to handle most built-in types. + if isinstance( + converted_value, + (list, + tuple, + set, + dict, + six.integer_types, + float, + complex)): + return converted_value + except BaseException: + # If it fails (SyntaxError or its ilk) or we don't trust it, + # continue on. + pass + + return value + + +class WhooshSearchQuery(BaseSearchQuery): + def _convert_datetime(self, date): + if hasattr(date, 'hour'): + return force_str(date.strftime('%Y%m%d%H%M%S')) + else: + return force_str(date.strftime('%Y%m%d000000')) + + def clean(self, query_fragment): + """ + Provides a mechanism for sanitizing user input before presenting the + value to the backend. + + Whoosh 1.X differs here in that you can no longer use a backslash + to escape reserved characters. Instead, the whole word should be + quoted. + """ + words = query_fragment.split() + cleaned_words = [] + + for word in words: + if word in self.backend.RESERVED_WORDS: + word = word.replace(word, word.lower()) + + for char in self.backend.RESERVED_CHARACTERS: + if char in word: + word = "'%s'" % word + break + + cleaned_words.append(word) + + return ' '.join(cleaned_words) + + def build_query_fragment(self, field, filter_type, value): + from haystack import connections + query_frag = '' + is_datetime = False + + if not hasattr(value, 'input_type_name'): + # Handle when we've got a ``ValuesListQuerySet``... + if hasattr(value, 'values_list'): + value = list(value) + + if hasattr(value, 'strftime'): + is_datetime = True + + if isinstance(value, six.string_types) and value != ' ': + # It's not an ``InputType``. Assume ``Clean``. + value = Clean(value) + else: + value = PythonData(value) + + # Prepare the query using the InputType. + prepared_value = value.prepare(self) + + if not isinstance(prepared_value, (set, list, tuple)): + # Then convert whatever we get back to what pysolr wants if needed. + prepared_value = self.backend._from_python(prepared_value) + + # 'content' is a special reserved word, much like 'pk' in + # Django's ORM layer. It indicates 'no special field'. + if field == 'content': + index_fieldname = '' + else: + index_fieldname = u'%s:' % connections[self._using].get_unified_index( + ).get_index_fieldname(field) + + filter_types = { + 'content': '%s', + 'contains': '*%s*', + 'endswith': "*%s", + 'startswith': "%s*", + 'exact': '%s', + 'gt': "{%s to}", + 'gte': "[%s to]", + 'lt': "{to %s}", + 'lte': "[to %s]", + 'fuzzy': u'%s~', + } + + if value.post_process is False: + query_frag = prepared_value + else: + if filter_type in [ + 'content', + 'contains', + 'startswith', + 'endswith', + 'fuzzy']: + if value.input_type_name == 'exact': + query_frag = prepared_value + else: + # Iterate over terms & incorportate the converted form of + # each into the query. + terms = [] + + if isinstance(prepared_value, six.string_types): + possible_values = prepared_value.split(' ') + else: + if is_datetime is True: + prepared_value = self._convert_datetime( + prepared_value) + + possible_values = [prepared_value] + + for possible_value in possible_values: + terms.append( + filter_types[filter_type] % + self.backend._from_python(possible_value)) + + if len(terms) == 1: + query_frag = terms[0] + else: + query_frag = u"(%s)" % " AND ".join(terms) + elif filter_type == 'in': + in_options = [] + + for possible_value in prepared_value: + is_datetime = False + + if hasattr(possible_value, 'strftime'): + is_datetime = True + + pv = self.backend._from_python(possible_value) + + if is_datetime is True: + pv = self._convert_datetime(pv) + + if isinstance(pv, six.string_types) and not is_datetime: + in_options.append('"%s"' % pv) + else: + in_options.append('%s' % pv) + + query_frag = "(%s)" % " OR ".join(in_options) + elif filter_type == 'range': + start = self.backend._from_python(prepared_value[0]) + end = self.backend._from_python(prepared_value[1]) + + if hasattr(prepared_value[0], 'strftime'): + start = self._convert_datetime(start) + + if hasattr(prepared_value[1], 'strftime'): + end = self._convert_datetime(end) + + query_frag = u"[%s to %s]" % (start, end) + elif filter_type == 'exact': + if value.input_type_name == 'exact': + query_frag = prepared_value + else: + prepared_value = Exact(prepared_value).prepare(self) + query_frag = filter_types[filter_type] % prepared_value + else: + if is_datetime is True: + prepared_value = self._convert_datetime(prepared_value) + + query_frag = filter_types[filter_type] % prepared_value + + if len(query_frag) and not isinstance(value, Raw): + if not query_frag.startswith('(') and not query_frag.endswith(')'): + query_frag = "(%s)" % query_frag + + return u"%s%s" % (index_fieldname, query_frag) + + # if not filter_type in ('in', 'range'): + # # 'in' is a bit of a special case, as we don't want to + # # convert a valid list/tuple to string. Defer handling it + # # until later... + # value = self.backend._from_python(value) + + +class WhooshEngine(BaseEngine): + backend = WhooshSearchBackend + query = WhooshSearchQuery diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/wsgi.py b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/wsgi.py new file mode 100644 index 00000000..2295efd5 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/djangoblog/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for djangoblog project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings") + +application = get_wsgi_application() diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/docs/README-en.md b/src/DjangoBlog-NEW/DjangoBlog-master/docs/README-en.md new file mode 100644 index 00000000..37ea0699 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/docs/README-en.md @@ -0,0 +1,158 @@ +# DjangoBlog + +

+ Django CI + CodeQL + codecov + license +

+ +

+ A powerful, elegant, and modern blog system. +
+ English简体中文 +

+ +--- + +DjangoBlog is a high-performance blog platform built with Python 3.10 and Django 4.0. It not only provides all the core functionalities of a traditional blog but also features a flexible plugin system, allowing you to easily extend and customize your website. Whether you are a personal blogger, a tech enthusiast, or a content creator, DjangoBlog aims to provide a stable, efficient, and easy-to-maintain environment for writing and publishing. + +## ✨ Features + +- **Powerful Content Management**: Full support for managing articles, standalone pages, categories, and tags. Comes with a powerful built-in Markdown editor with syntax highlighting. +- **Full-Text Search**: Integrated search engine for fast and accurate content searching. +- **Interactive Comment System**: Supports replies, email notifications, and Markdown formatting in comments. +- **Flexible Sidebar**: Customizable modules for displaying recent articles, most viewed posts, tag cloud, and more. +- **Social Login**: Built-in OAuth support, with integrations for Google, GitHub, Facebook, Weibo, QQ, and other major platforms. +- **High-Performance Caching**: Native support for Redis caching with an automatic refresh mechanism to ensure high-speed website responses. +- **SEO Friendly**: Basic SEO features are included, with automatic notifications to Google and Baidu upon new content publication. +- **Extensible Plugin System**: Extend blog functionalities by creating standalone plugins, ensuring decoupled and maintainable code. We have already implemented features like view counting and SEO optimization through plugins! +- **Integrated Image Hosting**: A simple, built-in image hosting feature for easy uploads and management. +- **Automated Frontend**: Integrated with `django-compressor` to automatically compress and optimize CSS and JavaScript files. +- **Robust Operations**: Built-in email notifications for website exceptions and management capabilities through a WeChat Official Account. + +## 🛠️ Tech Stack + +- **Backend**: Python 3.10, Django 4.0 +- **Database**: MySQL, SQLite (configurable) +- **Cache**: Redis +- **Frontend**: HTML5, CSS3, JavaScript +- **Search**: Whoosh, Elasticsearch (configurable) +- **Editor**: Markdown (mdeditor) + +## 🚀 Getting Started + +### 1. Prerequisites + +Ensure you have Python 3.10+ and MySQL/MariaDB installed on your system. + +### 2. Clone & Installation + +```bash +# Clone the project to your local machine +git clone https://github.com/liangliangyy/DjangoBlog.git +cd DjangoBlog + +# Install dependencies +pip install -r requirements.txt +``` + +### 3. Project Configuration + +- **Database**: + Open `djangoblog/settings.py`, locate the `DATABASES` section, and update it with your MySQL connection details. + + ```python + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': 'djangoblog', + 'USER': 'root', + 'PASSWORD': 'your_password', + 'HOST': '127.0.0.1', + 'PORT': 3306, + } + } + ``` + Create the database in MySQL: + ```sql + CREATE DATABASE `djangoblog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + ``` + +- **More Configurations**: + For advanced settings such as email, OAuth, caching, and more, please refer to our [Detailed Configuration Guide](/docs/config-en.md). + +### 4. Database Initialization + +```bash +python manage.py makemigrations +python manage.py migrate + +# Create a superuser account +python manage.py createsuperuser +``` + +### 5. Running the Project + +```bash +# (Optional) Generate some test data +python manage.py create_testdata + +# (Optional) Collect and compress static files +python manage.py collectstatic --noinput +python manage.py compress --force + +# Start the development server +python manage.py runserver +``` + +Now, open your browser and navigate to `http://127.0.0.1:8000/`. You should see the DjangoBlog homepage! + +## Deployment + +- **Traditional Deployment**: A detailed guide for server deployment is available here: [Deployment Tutorial](https://www.lylinux.net/article/2019/8/5/58.html) (in Chinese). +- **Docker Deployment**: This project fully supports Docker. If you are familiar with containerization, please refer to the [Docker Deployment Guide](/docs/docker-en.md) for a quick start. +- **Kubernetes Deployment**: We also provide a complete [Kubernetes Deployment Guide](/docs/k8s-en.md) to help you go cloud-native easily. + +## 🧩 Plugin System + +The plugin system is a core feature of DjangoBlog. It allows you to add new functionalities to your blog without modifying the core codebase by writing standalone plugins. + +- **How it Works**: Plugins operate by registering callback functions to predefined "hooks". For instance, when an article is rendered, the `after_article_body_get` hook is triggered, and all functions registered to this hook are executed. +- **Existing Plugins**: Features like `view_count` and `seo_optimizer` are implemented through this plugin system. +- **Develop Your Own Plugin**: Simply create a new folder under the `plugins` directory and write your `plugin.py`. We welcome you to explore and contribute your creative ideas to the DjangoBlog community! + +## 🤝 Contributing + +We warmly welcome contributions of any kind! If you have great ideas or have found a bug, please feel free to open an issue or submit a pull request. + +## 📄 License + +This project is open-sourced under the [MIT License](LICENSE). + +--- + +## ❤️ Support & Sponsorship + +If you find this project helpful and wish to support its continued maintenance and development, please consider buying me a coffee! Your support is my greatest motivation. + +

+ Alipay Sponsorship + WeChat Sponsorship +

+

+ (Left) Alipay / (Right) WeChat +

+ +## 🙏 Acknowledgements + +A special thanks to **JetBrains** for providing a free open-source license for this project. + +

+ + JetBrains Logo + +

+ +--- +> If this project has helped you, please leave your website URL [here](https://github.com/liangliangyy/DjangoBlog/issues/214) to let more people see it. Your feedback is the driving force for my continued updates and maintenance. diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/docs/config-en.md b/src/DjangoBlog-NEW/DjangoBlog-master/docs/config-en.md new file mode 100644 index 00000000..b877efbd --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/docs/config-en.md @@ -0,0 +1,64 @@ +# Introduction to main features settings + +## Cache: +Cache using `memcache` for default. If you don't have `memcache` environment, you can remove the `default` setting in `CACHES` and change `locmemcache` to `default`. +```python +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', + 'LOCATION': '127.0.0.1:11211', + 'KEY_PREFIX': 'django_test' if TESTING else 'djangoblog', + 'TIMEOUT': 60 * 60 * 10 + }, + 'locmemcache': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'TIMEOUT': 10800, + 'LOCATION': 'unique-snowflake', + } +} +``` + +## OAuth Login: +QQ, Weibo, Google, GitHub and Facebook are now supported for OAuth login. Fetch OAuth login permissions from the corresponding open platform, and save them with `appkey`, `appsecret` and callback address in **Backend->OAuth** configuration. + +### Callback address examples: +QQ: http://your-domain-name/oauth/authorize?type=qq +Weibo: http://your-domain-name/oauth/authorize?type=weibo +type is in the type field of `oauthmanager`. + +## owntracks: +owntracks is a location tracking application. It will send your locaiton to the server by timing.Simple support owntracks features. Just install owntracks app and set api address as `your-domain-name/owntracks/logtracks`. Visit `your-domain-name/owntracks/show_dates` and you will see the date with latitude and langitude, click it and see the motion track. The map is drawn by AMap. + +## Email feature: +Same as before, Configure your own error msg recvie email information with`ADMINS = [('liangliang', 'liangliangyy@gmail.com')]` in `settings.py`. And modify: +```python +EMAIL_HOST = 'smtp.zoho.com' +EMAIL_PORT = 587 +EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER') +EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD') +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER +SERVER_EMAIL = os.environ.get('DJANGO_EMAIL_USER') +``` +with your email account information. + +## WeChat Official Account +Simple wechat official account features integrated. Set token as `your-domain-name/robot` in wechat backend. Default token is `lylinux`, you can change it to your own in `servermanager/robot.py`. Add a new command in `Backend->Servermanager->command`, in this way, you can manage the system through wechat official account. + +## Introduction to website configuration +You can add website configuration in **Backend->BLOG->WebSiteConfiguration**. Such as: keywords, description, Google Ad, website stats code, case number, etc. +OAuth user avatar path is saved in *StaticFileSavedAddress*. Please input absolute path, code directory for default. + +## Source code highlighting +If the code block in your article didn't show hightlight, please write the code blocks as following: + +![](https://resource.lylinux.net/image/codelang.png) + +That is, you should add the corresponding language name before the code block. + +## Update +If you get errors as following while executing database migrations: +```python +django.db.migrations.exceptions.MigrationSchemaMissing: Unable to create the django_migrations table ((1064, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '(6) NOT NULL)' at line 1")) +``` +This problem may cause by the mysql version under 5.6, a new version( >= 5.6 ) mysql is needed. + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/docs/config.md b/src/DjangoBlog-NEW/DjangoBlog-master/docs/config.md new file mode 100644 index 00000000..24673a37 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/docs/config.md @@ -0,0 +1,58 @@ +# 主要功能配置介绍: + +## 缓存: +缓存默认使用`localmem`缓存,如果你有`redis`环境,可以设置`DJANGO_REDIS_URL`环境变量,则会自动使用该redis来作为缓存,或者你也可以直接修改如下代码来使用。 +https://github.com/liangliangyy/DjangoBlog/blob/ffcb2c3711de805f2067dd3c1c57449cd24d84ee/djangoblog/settings.py#L185-L199 + + +## oauth登录: + +现在已经支持QQ,微博,Google,GitHub,Facebook登录,需要在其对应的开放平台申请oauth登录权限,然后在 +**后台->Oauth** 配置中新增配置,填写对应的`appkey`和`appsecret`以及回调地址。 +### 回调地址示例: +qq:http://你的域名/oauth/authorize?type=qq +微博:http://你的域名/oauth/authorize?type=weibo +type对应在`oauthmanager`中的type字段。 + +## owntracks: +owntracks是一个位置追踪软件,可以定时的将你的坐标提交到你的服务器上,现在简单的支持owntracks功能,需要安装owntracks的app,然后将api地址设置为: +`你的域名/owntracks/logtracks`就可以了。然后访问`你的域名/owntracks/show_dates`就可以看到有经纬度记录的日期,点击之后就可以看到运动轨迹了。地图是使用高德地图绘制。 + +## 邮件功能: +同样,将`settings.py`中的`ADMINS = [('liangliang', 'liangliangyy@gmail.com')]`配置为你自己的错误接收邮箱,另外修改: +```python +EMAIL_HOST = 'smtp.zoho.com' +EMAIL_PORT = 587 +EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER') +EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD') +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER +SERVER_EMAIL = os.environ.get('DJANGO_EMAIL_USER') +``` +为你自己的邮箱配置。 + +## 微信公众号 +集成了简单的微信公众号功能,在微信后台将token地址设置为:`你的域名/robot` 即可,默认token为`lylinux`,当然你可以修改为你自己的,在`servermanager/robot.py`中。 +然后在**后台->Servermanager->命令**中新增命令,这样就可以使用微信公众号来管理了。 +## 网站配置介绍 +在**后台->BLOG->网站配置**中,可以新增网站配置,比如关键字,描述等,以及谷歌广告,网站统计代码及备案号等等。 +其中的*静态文件保存地址*是保存oauth用户登录的头像路径,填写绝对路径,默认是代码目录。 +## 代码高亮 +如果你发现你文章的代码没有高亮,请这样书写代码块: + +![](https://resource.lylinux.net/image/codelang.png) + + +也就是说,需要在代码块开始位置加入这段代码对应的语言。 + +## update +如果你发现执行数据库迁移的时候出现如下报错: +```python +django.db.migrations.exceptions.MigrationSchemaMissing: Unable to create the django_migrations table ((1064, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '(6) NOT NULL)' at line 1")) +``` +可能是因为你的mysql版本低于5.6,需要升级mysql版本>=5.6即可。 + + +django 4.0登录可能会报错CSRF,需要配置下`settings.py`中的`CSRF_TRUSTED_ORIGINS` + +https://github.com/liangliangyy/DjangoBlog/blob/master/djangoblog/settings.py#L39 + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/docs/docker-en.md b/src/DjangoBlog-NEW/DjangoBlog-master/docs/docker-en.md new file mode 100644 index 00000000..8d5d59ed --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/docs/docker-en.md @@ -0,0 +1,114 @@ +# Deploying DjangoBlog with Docker + +![Docker Pulls](https://img.shields.io/docker/pulls/liangliangyy/djangoblog) +![Docker Image Version (latest by date)](https://img.shields.io/docker/v/liangliangyy/djangoblog?sort=date) +![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/liangliangyy/djangoblog) + +This project fully supports containerized deployment using Docker, providing you with a fast, consistent, and isolated runtime environment. We recommend using `docker-compose` to launch the entire blog service stack with a single command. + +## 1. Prerequisites + +Before you begin, please ensure you have the following software installed on your system: +- [Docker Engine](https://docs.docker.com/engine/install/) +- [Docker Compose](https://docs.docker.com/compose/install/) (Included with Docker Desktop for Mac and Windows) + +## 2. Recommended Method: Using `docker-compose` (One-Click Deployment) + +This is the simplest and most recommended way to deploy. It automatically creates and manages the Django application, a MySQL database, and an optional Elasticsearch service for you. + +### Step 1: Start the Basic Services + +From the project's root directory, run the following command: + +```bash +# Build and start the containers in detached mode (includes Django app and MySQL) +docker-compose up -d --build +``` + +`docker-compose` will read the `docker-compose.yml` file, pull the necessary images, build the project image, and start all services. + +- **Access Your Blog**: Once the services are up, you can access the blog by navigating to `http://127.0.0.1` in your browser. +- **Data Persistence**: MySQL data files will be stored in the `data/mysql` directory within the project root, ensuring that your data persists across container restarts. + +### Step 2: (Optional) Enable Elasticsearch for Full-Text Search + +If you want to use Elasticsearch for more powerful full-text search capabilities, you can include the `docker-compose.es.yml` configuration file: + +```bash +# Build and start all services in detached mode (Django, MySQL, Elasticsearch) +docker-compose -f docker-compose.yml -f deploy/docker-compose/docker-compose.es.yml up -d --build +``` +- **Data Persistence**: Elasticsearch data will be stored in the `data/elasticsearch` directory. + +### Step 3: First-Time Initialization + +After the containers start for the first time, you'll need to execute some initialization commands inside the application container. + +```bash +# Get a shell inside the djangoblog application container (named 'web') +docker-compose exec web bash + +# Inside the container, run the following commands: +# Create a superuser account (follow the prompts to set username, email, and password) +python manage.py createsuperuser + +# (Optional) Create some test data +python manage.py create_testdata + +# (Optional, if ES is enabled) Create the search index +python manage.py rebuild_index + +# Exit the container +exit +``` + +## 3. Alternative Method: Using the Standalone Docker Image + +If you already have an external MySQL database running, you can run the DjangoBlog application image by itself. + +```bash +# Pull the latest image from Docker Hub +docker pull liangliangyy/djangoblog:latest + +# Run the container and connect it to your external database +docker run -d \ + -p 8000:8000 \ + -e DJANGO_SECRET_KEY='your-strong-secret-key' \ + -e DJANGO_MYSQL_HOST='your-mysql-host' \ + -e DJANGO_MYSQL_USER='your-mysql-user' \ + -e DJANGO_MYSQL_PASSWORD='your-mysql-password' \ + -e DJANGO_MYSQL_DATABASE='djangoblog' \ + --name djangoblog \ + liangliangyy/djangoblog:latest +``` + +- **Access Your Blog**: After startup, visit `http://127.0.0.1:8000`. +- **Create Superuser**: `docker exec -it djangoblog python manage.py createsuperuser` + +## 4. Configuration (Environment Variables) + +Most of the project's configuration is managed through environment variables. You can modify them in the `docker-compose.yml` file or pass them using the `-e` flag with the `docker run` command. + +| Environment Variable | Default/Example Value | Notes | +|---------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------| +| `DJANGO_SECRET_KEY` | `your-strong-secret-key` | **Must be changed to a random, complex string!** | +| `DJANGO_DEBUG` | `False` | Toggles Django's debug mode. | +| `DJANGO_MYSQL_HOST` | `mysql` | Database hostname. | +| `DJANGO_MYSQL_PORT` | `3306` | Database port. | +| `DJANGO_MYSQL_DATABASE` | `djangoblog` | Database name. | +| `DJANGO_MYSQL_USER` | `root` | Database username. | +| `DJANGO_MYSQL_PASSWORD` | `djangoblog_123` | Database password. | +| `DJANGO_REDIS_URL` | `redis:6379/0` | Redis connection URL (for caching). | +| `DJANGO_ELASTICSEARCH_HOST`| `elasticsearch:9200` | Elasticsearch host address. | +| `DJANGO_EMAIL_HOST` | `smtp.example.org` | Email server address. | +| `DJANGO_EMAIL_PORT` | `465` | Email server port. | +| `DJANGO_EMAIL_USER` | `user@example.org` | Email account username. | +| `DJANGO_EMAIL_PASSWORD` | `your-email-password` | Email account password. | +| `DJANGO_EMAIL_USE_SSL` | `True` | Whether to use SSL. | +| `DJANGO_EMAIL_USE_TLS` | `False` | Whether to use TLS. | +| `DJANGO_ADMIN_EMAIL` | `admin@example.org` | Admin email for receiving error reports. | +| `DJANGO_BAIDU_NOTIFY_URL` | `http://data.zz.baidu.com/...` | Push API from [Baidu Webmaster Tools](https://ziyuan.baidu.com/linksubmit/index). | + +--- + +After deployment, please review and adjust these environment variables according to your needs, especially `DJANGO_SECRET_KEY` and the database and email settings. \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/docs/docker.md b/src/DjangoBlog-NEW/DjangoBlog-master/docs/docker.md new file mode 100644 index 00000000..e7c255aa --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/docs/docker.md @@ -0,0 +1,114 @@ +# 使用 Docker 部署 DjangoBlog + +![Docker Pulls](https://img.shields.io/docker/pulls/liangliangyy/djangoblog) +![Docker Image Version (latest by date)](https://img.shields.io/docker/v/liangliangyy/djangoblog?sort=date) +![Docker Image Size (latest by date)](https://img.shields.io/docker/image-size/liangliangyy/djangoblog) + +本项目全面支持使用 Docker 进行容器化部署,为您提供了快速、一致且隔离的运行环境。我们推荐使用 `docker-compose` 来一键启动整个博客服务栈。 + +## 1. 环境准备 + +在开始之前,请确保您的系统中已经安装了以下软件: +- [Docker Engine](https://docs.docker.com/engine/install/) +- [Docker Compose](https://docs.docker.com/compose/install/) (对于 Docker Desktop 用户,它已内置) + +## 2. 推荐方式:使用 `docker-compose` (一键部署) + +这是最简单、最推荐的部署方式。它会自动为您创建并管理 Django 应用、MySQL 数据库,以及可选的 Elasticsearch 服务。 + +### 步骤 1: 启动基础服务 + +在项目根目录下,执行以下命令: + +```bash +# 构建并以后台模式启动容器 (包含 Django 应用和 MySQL) +docker-compose up -d --build +``` + +`docker-compose` 会读取 `docker-compose.yml` 文件,自动拉取所需镜像、构建项目镜像,并启动所有服务。 + +- **访问您的博客**: 服务启动后,在浏览器中访问 `http://127.0.0.1` 即可看到博客首页。 +- **数据持久化**: MySQL 的数据文件将存储在项目根目录下的 `data/mysql` 文件夹中,确保数据在容器重启后不丢失。 + +### 步骤 2: (可选) 启用 Elasticsearch 全文搜索 + +如果您希望使用 Elasticsearch 提供更强大的全文搜索功能,可以额外加载 `docker-compose.es.yml` 配置文件: + +```bash +# 构建并以后台模式启动所有服务 (Django, MySQL, Elasticsearch) +docker-compose -f docker-compose.yml -f deploy/docker-compose/docker-compose.es.yml up -d --build +``` +- **数据持久化**: Elasticsearch 的数据将存储在 `data/elasticsearch` 文件夹中。 + +### 步骤 3: 首次运行的初始化操作 + +当容器首次启动后,您需要进入容器来执行一些初始化命令。 + +```bash +# 进入 djangoblog 应用容器 +docker-compose exec web bash + +# 在容器内执行以下命令: +# 创建超级管理员账户 (请按照提示设置用户名、邮箱和密码) +python manage.py createsuperuser + +# (可选) 创建一些测试数据 +python manage.py create_testdata + +# (可选,如果启用了 ES) 创建索引 +python manage.py rebuild_index + +# 退出容器 +exit +``` + +## 3. 备选方式:使用独立的 Docker 镜像 + +如果您已经拥有一个正在运行的外部 MySQL 数据库,您也可以只运行 DjangoBlog 的应用镜像。 + +```bash +# 从 Docker Hub 拉取最新镜像 +docker pull liangliangyy/djangoblog:latest + +# 运行容器,并链接到您的外部数据库 +docker run -d \ + -p 8000:8000 \ + -e DJANGO_SECRET_KEY='your-strong-secret-key' \ + -e DJANGO_MYSQL_HOST='your-mysql-host' \ + -e DJANGO_MYSQL_USER='your-mysql-user' \ + -e DJANGO_MYSQL_PASSWORD='your-mysql-password' \ + -e DJANGO_MYSQL_DATABASE='djangoblog' \ + --name djangoblog \ + liangliangyy/djangoblog:latest +``` + +- **访问您的博客**: 启动完成后,访问 `http://127.0.0.1:8000`。 +- **创建管理员**: `docker exec -it djangoblog python manage.py createsuperuser` + +## 4. 配置说明 (环境变量) + +本项目的大部分配置都通过环境变量来管理。您可以在 `docker-compose.yml` 文件中修改它们,或者在使用 `docker run` 命令时通过 `-e` 参数传入。 + +| 环境变量名称 | 默认值/示例 | 备注 | +|-------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------| +| `DJANGO_SECRET_KEY` | `your-strong-secret-key` | **请务必修改为一个随机且复杂的字符串!** | +| `DJANGO_DEBUG` | `False` | 是否开启 Django 的调试模式 | +| `DJANGO_MYSQL_HOST` | `mysql` | 数据库主机名 | +| `DJANGO_MYSQL_PORT` | `3306` | 数据库端口 | +| `DJANGO_MYSQL_DATABASE` | `djangoblog` | 数据库名称 | +| `DJANGO_MYSQL_USER` | `root` | 数据库用户名 | +| `DJANGO_MYSQL_PASSWORD` | `djangoblog_123` | 数据库密码 | +| `DJANGO_REDIS_URL` | `redis:6379/0` | Redis 连接地址 (用于缓存) | +| `DJANGO_ELASTICSEARCH_HOST` | `elasticsearch:9200` | Elasticsearch 主机地址 | +| `DJANGO_EMAIL_HOST` | `smtp.example.org` | 邮件服务器地址 | +| `DJANGO_EMAIL_PORT` | `465` | 邮件服务器端口 | +| `DJANGO_EMAIL_USER` | `user@example.org` | 邮件账户 | +| `DJANGO_EMAIL_PASSWORD` | `your-email-password` | 邮件密码 | +| `DJANGO_EMAIL_USE_SSL` | `True` | 是否使用 SSL | +| `DJANGO_EMAIL_USE_TLS` | `False` | 是否使用 TLS | +| `DJANGO_ADMIN_EMAIL` | `admin@example.org` | 接收异常报告的管理员邮箱 | +| `DJANGO_BAIDU_NOTIFY_URL` | `http://data.zz.baidu.com/...` | [百度站长平台](https://ziyuan.baidu.com/linksubmit/index) 的推送接口 | + +--- + +部署完成后,请务必检查并根据您的实际需求调整这些环境变量,特别是 `DJANGO_SECRET_KEY` 和数据库、邮件相关的配置。 diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/docs/es.md b/src/DjangoBlog-NEW/DjangoBlog-master/docs/es.md new file mode 100644 index 00000000..97226c53 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/docs/es.md @@ -0,0 +1,28 @@ +# 集成Elasticsearch +如果你已经有了`Elasticsearch`环境,那么可以将搜索从`Whoosh`换成`Elasticsearch`,集成方式也很简单, +首先需要注意如下几点: +1. 你的`Elasticsearch`支持`ik`中文分词 +2. 你的`Elasticsearch`版本>=7.3.0 + +接下来在`settings.py`做如下改动即可: +- 增加es链接,如下所示: +```python +ELASTICSEARCH_DSL = { + 'default': { + 'hosts': '127.0.0.1:9200' + }, +} +``` +- 修改`HAYSTACK`配置: +```python +HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine', + }, +} +``` +然后终端执行: +```shell script +./manage.py build_index +``` +这将会在你的es中创建两个索引,分别是`blog`和`performance`,其中`blog`索引就是搜索所使用的,而`performance`会记录每个请求的响应时间,以供将来优化使用。 \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/docs/k8s-en.md b/src/DjangoBlog-NEW/DjangoBlog-master/docs/k8s-en.md new file mode 100644 index 00000000..20e95272 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/docs/k8s-en.md @@ -0,0 +1,141 @@ +# Deploying DjangoBlog with Kubernetes + +This document guides you through deploying the DjangoBlog application on a Kubernetes (K8s) cluster. We provide a complete set of `.yaml` configuration files in the `deploy/k8s` directory to deploy a full service stack, including the DjangoBlog application, Nginx, MySQL, Redis, and Elasticsearch. + +## Architecture Overview + +This deployment utilizes a microservices-based, cloud-native architecture: + +- **Core Components**: Each core service (DjangoBlog, Nginx, MySQL, Redis, Elasticsearch) runs as a separate `Deployment`. +- **Configuration Management**: Nginx configurations and Django application environment variables are managed via `ConfigMap`. **Note: For sensitive information like passwords, using `Secret` is highly recommended.** +- **Service Discovery**: All services are exposed internally within the cluster as `ClusterIP` type `Service`, enabling communication via service names. +- **External Access**: An `Ingress` resource is used to route external HTTP traffic to the Nginx service, which acts as the single entry point for the entire blog application. +- **Data Persistence**: A `local-storage` solution based on node-local paths is used. This requires you to manually create storage directories on a specific K8s node and statically bind them using `PersistentVolume` (PV) and `PersistentVolumeClaim` (PVC). + +## 1. Prerequisites + +Before you begin, please ensure you have the following: + +- A running Kubernetes cluster. +- The `kubectl` command-line tool configured to connect to your cluster. +- An [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/deploy/) installed and configured in your cluster. +- Filesystem access to one of the nodes in your cluster (defaulted to `master` in the configs) to create local storage directories. + +## 2. Deployment Steps + +### Step 1: Create a Namespace + +We recommend deploying all DjangoBlog-related resources in a dedicated namespace for better management. + +```bash +# Create a namespace named 'djangoblog' +kubectl create namespace djangoblog +``` + +### Step 2: Configure Persistent Storage + +This setup uses Local Persistent Volumes. You need to create the data storage directories on a node within your cluster (the default is the `master` node in `pv.yaml`). + +```bash +# Log in to your master node +ssh user@master-node + +# Create the required storage directories +sudo mkdir -p /mnt/local-storage-db +sudo mkdir -p /mnt/local-storage-djangoblog +sudo mkdir -p /mnt/resource/ +sudo mkdir -p /mnt/local-storage-elasticsearch + +# Log out from the node +exit +``` +**Note**: If you wish to store data on a different node or use different paths, you must modify the `nodeAffinity` and `local.path` settings in the `deploy/k8s/pv.yaml` file. + +After creating the directories, apply the storage-related configurations: + +```bash +# Apply the StorageClass +kubectl apply -f deploy/k8s/storageclass.yaml + +# Apply the PersistentVolumes (PVs) +kubectl apply -f deploy/k8s/pv.yaml + +# Apply the PersistentVolumeClaims (PVCs) +kubectl apply -f deploy/k8s/pvc.yaml +``` + +### Step 3: Configure the Application + +Before deploying the application, you need to edit the `deploy/k8s/configmap.yaml` file to modify sensitive information and custom settings. + +**It is strongly recommended to change the following fields:** +- `DJANGO_SECRET_KEY`: Change to a random, complex string. +- `DJANGO_MYSQL_PASSWORD` and `MYSQL_ROOT_PASSWORD`: Change to your own secure database password. + +```bash +# Edit the ConfigMap file +vim deploy/k8s/configmap.yaml + +# Apply the configuration +kubectl apply -f deploy/k8s/configmap.yaml +``` + +### Step 4: Deploy the Application Stack + +Now, we can deploy all the core services. + +```bash +# Deploy the Deployments (DjangoBlog, MySQL, Redis, Nginx, ES) +kubectl apply -f deploy/k8s/deployment.yaml + +# Deploy the Services (to create internal endpoints for the Deployments) +kubectl apply -f deploy/k8s/service.yaml +``` + +The deployment may take some time. You can run the following command to check if all Pods are running successfully (STATUS should be `Running`): + +```bash +kubectl get pods -n djangoblog -w +``` + +### Step 5: Expose the Application Externally + +Finally, expose the Nginx service to external traffic by applying the `Ingress` rule. + +```bash +# Apply the Ingress rule +kubectl apply -f deploy/k8s/gateway.yaml +``` + +Once deployed, you can access your blog via the external IP address of your Ingress Controller. Use the following command to find the address: + +```bash +kubectl get ingress -n djangoblog +``` + +### Step 6: First-Time Initialization + +Similar to the Docker deployment, you need to get a shell into the DjangoBlog application Pod to perform database initialization and create a superuser on the first run. + +```bash +# First, get the name of a djangoblog pod +kubectl get pods -n djangoblog | grep djangoblog + +# Exec into one of the Pods (replace [pod-name] with the name from the previous step) +kubectl exec -it [pod-name] -n djangoblog -- bash + +# Inside the Pod, run the following commands: +# Create a superuser account (follow the prompts) +python manage.py createsuperuser + +# (Optional) Create some test data +python manage.py create_testdata + +# (Optional, if ES is enabled) Create the search index +python manage.py rebuild_index + +# Exit the Pod +exit +``` + +Congratulations! You have successfully deployed DjangoBlog on your Kubernetes cluster. \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/docs/k8s.md b/src/DjangoBlog-NEW/DjangoBlog-master/docs/k8s.md new file mode 100644 index 00000000..9da3c289 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/docs/k8s.md @@ -0,0 +1,141 @@ +# 使用 Kubernetes 部署 DjangoBlog + +本文档将指导您如何在 Kubernetes (K8s) 集群上部署 DjangoBlog 应用。我们提供了一套完整的 `.yaml` 配置文件,位于 `deploy/k8s` 目录下,用于部署一个包含 DjangoBlog 应用、Nginx、MySQL、Redis 和 Elasticsearch 的完整服务栈。 + +## 架构概览 + +本次部署采用的是微服务化的云原生架构: + +- **核心组件**: 每个核心服务 (DjangoBlog, Nginx, MySQL, Redis, Elasticsearch) 都将作为独立的 `Deployment` 运行。 +- **配置管理**: Nginx 的配置文件和 Django 应用的环境变量通过 `ConfigMap` 进行管理。**注意:敏感信息(如密码)建议使用 `Secret` 进行管理。** +- **服务发现**: 所有服务都通过 `ClusterIP` 类型的 `Service` 在集群内部暴露,并通过服务名相互通信。 +- **外部访问**: 使用 `Ingress` 资源将外部的 HTTP 流量路由到 Nginx 服务,作为整个博客应用的统一入口。 +- **数据持久化**: 采用基于节点本地路径的 `local-storage` 方案。这需要您在指定的 K8s 节点上手动创建存储目录,并通过 `PersistentVolume` (PV) 和 `PersistentVolumeClaim` (PVC) 进行静态绑定。 + +## 1. 环境准备 + +在开始之前,请确保您已具备以下环境: + +- 一个正在运行的 Kubernetes 集群。 +- `kubectl` 命令行工具已配置并能够连接到您的集群。 +- 集群中已安装并配置好 [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/deploy/)。 +- 对集群中的一个节点(默认为 `master`)拥有文件系统访问权限,用于创建本地存储目录。 + +## 2. 部署步骤 + +### 步骤 1: 创建命名空间 + +我们建议将 DjangoBlog 相关的所有资源都部署在一个独立的命名空间中,便于管理。 + +```bash +# 创建一个名为 djangoblog 的命名空间 +kubectl create namespace djangoblog +``` + +### 步骤 2: 配置持久化存储 + +此方案使用本地持久卷 (Local Persistent Volume)。您需要在集群的一个节点上(在 `pv.yaml` 文件中默认为 `master` 节点)创建用于数据存储的目录。 + +```bash +# 登录到您的 master 节点 +ssh user@master-node + +# 创建所需的存储目录 +sudo mkdir -p /mnt/local-storage-db +sudo mkdir -p /mnt/local-storage-djangoblog +sudo mkdir -p /mnt/resource/ +sudo mkdir -p /mnt/local-storage-elasticsearch + +# 退出节点 +exit +``` +**注意**: 如果您希望将数据存储在其他节点或使用不同的路径,请务必修改 `deploy/k8s/pv.yaml` 文件中 `nodeAffinity` 和 `local.path` 的配置。 + +创建目录后,应用存储相关的配置文件: + +```bash +# 应用 StorageClass +kubectl apply -f deploy/k8s/storageclass.yaml + +# 应用 PersistentVolume (PV) +kubectl apply -f deploy/k8s/pv.yaml + +# 应用 PersistentVolumeClaim (PVC) +kubectl apply -f deploy/k8s/pvc.yaml +``` + +### 步骤 3: 配置应用 + +在部署应用之前,您需要编辑 `deploy/k8s/configmap.yaml` 文件,修改其中的敏感信息和个性化配置。 + +**强烈建议修改以下字段:** +- `DJANGO_SECRET_KEY`: 修改为一个随机且复杂的字符串。 +- `DJANGO_MYSQL_PASSWORD` 和 `MYSQL_ROOT_PASSWORD`: 修改为您自己的数据库密码。 + +```bash +# 编辑 ConfigMap 文件 +vim deploy/k8s/configmap.yaml + +# 应用配置 +kubectl apply -f deploy/k8s/configmap.yaml +``` + +### 步骤 4: 部署应用服务栈 + +现在,我们可以部署所有的核心服务了。 + +```bash +# 部署 Deployments (DjangoBlog, MySQL, Redis, Nginx, ES) +kubectl apply -f deploy/k8s/deployment.yaml + +# 部署 Services (为 Deployments 创建内部访问端点) +kubectl apply -f deploy/k8s/service.yaml +``` + +部署需要一些时间,您可以运行以下命令检查所有 Pod 是否都已成功运行 (STATUS 为 `Running`): + +```bash +kubectl get pods -n djangoblog -w +``` + +### 步骤 5: 暴露应用到外部 + +最后,通过应用 `Ingress` 规则来将外部流量引导至我们的 Nginx 服务。 + +```bash +# 应用 Ingress 规则 +kubectl apply -f deploy/k8s/gateway.yaml +``` + +部署完成后,您可以通过 Ingress Controller 的外部 IP 地址来访问您的博客。执行以下命令获取地址: + +```bash +kubectl get ingress -n djangoblog +``` + +### 步骤 6: 首次运行的初始化操作 + +与 Docker 部署类似,首次运行时,您需要进入 DjangoBlog 应用的 Pod 来执行数据库初始化和创建管理员账户。 + +```bash +# 首先,获取 djangoblog pod 的名称 +kubectl get pods -n djangoblog | grep djangoblog + +# 进入其中一个 Pod (将 [pod-name] 替换为上一步获取到的名称) +kubectl exec -it [pod-name] -n djangoblog -- bash + +# 在 Pod 内部执行以下命令: +# 创建超级管理员账户 (请按照提示操作) +python manage.py createsuperuser + +# (可选) 创建测试数据 +python manage.py create_testdata + +# (可选,如果启用了 ES) 创建索引 +python manage.py rebuild_index + +# 退出 Pod +exit +``` + +至此,您已成功在 Kubernetes 集群上完成了 DjangoBlog 的部署! \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/locale/en/LC_MESSAGES/django.mo b/src/DjangoBlog-NEW/DjangoBlog-master/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 00000000..f63669f4 Binary files /dev/null and b/src/DjangoBlog-NEW/DjangoBlog-master/locale/en/LC_MESSAGES/django.mo differ diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/locale/en/LC_MESSAGES/django.po b/src/DjangoBlog-NEW/DjangoBlog-master/locale/en/LC_MESSAGES/django.po new file mode 100644 index 00000000..c80b30ac --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,685 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-09-13 16:02+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: .\accounts\admin.py:12 +msgid "password" +msgstr "password" + +#: .\accounts\admin.py:13 +msgid "Enter password again" +msgstr "Enter password again" + +#: .\accounts\admin.py:24 .\accounts\forms.py:89 +msgid "passwords do not match" +msgstr "passwords do not match" + +#: .\accounts\forms.py:36 +msgid "email already exists" +msgstr "email already exists" + +#: .\accounts\forms.py:46 .\accounts\forms.py:50 +msgid "New password" +msgstr "New password" + +#: .\accounts\forms.py:60 +msgid "Confirm password" +msgstr "Confirm password" + +#: .\accounts\forms.py:70 .\accounts\forms.py:116 +msgid "Email" +msgstr "Email" + +#: .\accounts\forms.py:76 .\accounts\forms.py:80 +msgid "Code" +msgstr "Code" + +#: .\accounts\forms.py:100 .\accounts\tests.py:194 +msgid "email does not exist" +msgstr "email does not exist" + +#: .\accounts\models.py:12 .\oauth\models.py:17 +msgid "nick name" +msgstr "nick name" + +#: .\accounts\models.py:13 .\blog\models.py:29 .\blog\models.py:266 +#: .\blog\models.py:284 .\comments\models.py:13 .\oauth\models.py:23 +#: .\oauth\models.py:53 +msgid "creation time" +msgstr "creation time" + +#: .\accounts\models.py:14 .\comments\models.py:14 .\oauth\models.py:24 +#: .\oauth\models.py:54 +msgid "last modify time" +msgstr "last modify time" + +#: .\accounts\models.py:15 +msgid "create source" +msgstr "create source" + +#: .\accounts\models.py:33 .\djangoblog\logentryadmin.py:81 +msgid "user" +msgstr "user" + +#: .\accounts\tests.py:216 .\accounts\utils.py:39 +msgid "Verification code error" +msgstr "Verification code error" + +#: .\accounts\utils.py:13 +msgid "Verify Email" +msgstr "Verify Email" + +#: .\accounts\utils.py:21 +#, python-format +msgid "" +"You are resetting the password, the verification code is:%(code)s, valid " +"within 5 minutes, please keep it properly" +msgstr "" +"You are resetting the password, the verification code is:%(code)s, valid " +"within 5 minutes, please keep it properly" + +#: .\blog\admin.py:13 .\blog\models.py:92 .\comments\models.py:17 +#: .\oauth\models.py:12 +msgid "author" +msgstr "author" + +#: .\blog\admin.py:53 +msgid "Publish selected articles" +msgstr "Publish selected articles" + +#: .\blog\admin.py:54 +msgid "Draft selected articles" +msgstr "Draft selected articles" + +#: .\blog\admin.py:55 +msgid "Close article comments" +msgstr "Close article comments" + +#: .\blog\admin.py:56 +msgid "Open article comments" +msgstr "Open article comments" + +#: .\blog\admin.py:89 .\blog\models.py:101 .\blog\models.py:183 +#: .\templates\blog\tags\sidebar.html:40 +msgid "category" +msgstr "category" + +#: .\blog\models.py:20 .\blog\models.py:179 .\templates\share_layout\nav.html:8 +msgid "index" +msgstr "index" + +#: .\blog\models.py:21 +msgid "list" +msgstr "list" + +#: .\blog\models.py:22 +msgid "post" +msgstr "post" + +#: .\blog\models.py:23 +msgid "all" +msgstr "all" + +#: .\blog\models.py:24 +msgid "slide" +msgstr "slide" + +#: .\blog\models.py:30 .\blog\models.py:267 .\blog\models.py:285 +msgid "modify time" +msgstr "modify time" + +#: .\blog\models.py:63 +msgid "Draft" +msgstr "Draft" + +#: .\blog\models.py:64 +msgid "Published" +msgstr "Published" + +#: .\blog\models.py:67 +msgid "Open" +msgstr "Open" + +#: .\blog\models.py:68 +msgid "Close" +msgstr "Close" + +#: .\blog\models.py:71 .\comments\admin.py:47 +msgid "Article" +msgstr "Article" + +#: .\blog\models.py:72 +msgid "Page" +msgstr "Page" + +#: .\blog\models.py:74 .\blog\models.py:280 +msgid "title" +msgstr "title" + +#: .\blog\models.py:75 +msgid "body" +msgstr "body" + +#: .\blog\models.py:77 +msgid "publish time" +msgstr "publish time" + +#: .\blog\models.py:79 +msgid "status" +msgstr "status" + +#: .\blog\models.py:84 +msgid "comment status" +msgstr "comment status" + +#: .\blog\models.py:88 .\oauth\models.py:43 +msgid "type" +msgstr "type" + +#: .\blog\models.py:89 +msgid "views" +msgstr "views" + +#: .\blog\models.py:97 .\blog\models.py:258 .\blog\models.py:282 +msgid "order" +msgstr "order" + +#: .\blog\models.py:98 +msgid "show toc" +msgstr "show toc" + +#: .\blog\models.py:105 .\blog\models.py:249 +msgid "tag" +msgstr "tag" + +#: .\blog\models.py:115 .\comments\models.py:21 +msgid "article" +msgstr "article" + +#: .\blog\models.py:171 +msgid "category name" +msgstr "category name" + +#: .\blog\models.py:174 +msgid "parent category" +msgstr "parent category" + +#: .\blog\models.py:234 +msgid "tag name" +msgstr "tag name" + +#: .\blog\models.py:256 +msgid "link name" +msgstr "link name" + +#: .\blog\models.py:257 .\blog\models.py:271 +msgid "link" +msgstr "link" + +#: .\blog\models.py:260 +msgid "is show" +msgstr "is show" + +#: .\blog\models.py:262 +msgid "show type" +msgstr "show type" + +#: .\blog\models.py:281 +msgid "content" +msgstr "content" + +#: .\blog\models.py:283 .\oauth\models.py:52 +msgid "is enable" +msgstr "is enable" + +#: .\blog\models.py:289 +msgid "sidebar" +msgstr "sidebar" + +#: .\blog\models.py:299 +msgid "site name" +msgstr "site name" + +#: .\blog\models.py:305 +msgid "site description" +msgstr "site description" + +#: .\blog\models.py:311 +msgid "site seo description" +msgstr "site seo description" + +#: .\blog\models.py:313 +msgid "site keywords" +msgstr "site keywords" + +#: .\blog\models.py:318 +msgid "article sub length" +msgstr "article sub length" + +#: .\blog\models.py:319 +msgid "sidebar article count" +msgstr "sidebar article count" + +#: .\blog\models.py:320 +msgid "sidebar comment count" +msgstr "sidebar comment count" + +#: .\blog\models.py:321 +msgid "article comment count" +msgstr "article comment count" + +#: .\blog\models.py:322 +msgid "show adsense" +msgstr "show adsense" + +#: .\blog\models.py:324 +msgid "adsense code" +msgstr "adsense code" + +#: .\blog\models.py:325 +msgid "open site comment" +msgstr "open site comment" + +#: .\blog\models.py:352 +msgid "Website configuration" +msgstr "Website configuration" + +#: .\blog\models.py:360 +msgid "There can only be one configuration" +msgstr "There can only be one configuration" + +#: .\blog\views.py:348 +msgid "" +"Sorry, the page you requested is not found, please click the home page to " +"see other?" +msgstr "" +"Sorry, the page you requested is not found, please click the home page to " +"see other?" + +#: .\blog\views.py:356 +msgid "Sorry, the server is busy, please click the home page to see other?" +msgstr "Sorry, the server is busy, please click the home page to see other?" + +#: .\blog\views.py:369 +msgid "Sorry, you do not have permission to access this page?" +msgstr "Sorry, you do not have permission to access this page?" + +#: .\comments\admin.py:15 +msgid "Disable comments" +msgstr "Disable comments" + +#: .\comments\admin.py:16 +msgid "Enable comments" +msgstr "Enable comments" + +#: .\comments\admin.py:46 +msgid "User" +msgstr "User" + +#: .\comments\models.py:25 +msgid "parent comment" +msgstr "parent comment" + +#: .\comments\models.py:29 +msgid "enable" +msgstr "enable" + +#: .\comments\models.py:34 .\templates\blog\tags\article_info.html:30 +msgid "comment" +msgstr "comment" + +#: .\comments\utils.py:13 +msgid "Thanks for your comment" +msgstr "Thanks for your comment" + +#: .\comments\utils.py:15 +#, python-format +msgid "" +"

Thank you very much for your comments on this site

\n" +" You can visit %(article_title)s\n" +" to review your comments,\n" +" Thank you again!\n" +"
\n" +" If the link above cannot be opened, please copy this " +"link to your browser.\n" +" %(article_url)s" +msgstr "" +"

Thank you very much for your comments on this site

\n" +" You can visit %(article_title)s\n" +" to review your comments,\n" +" Thank you again!\n" +"
\n" +" If the link above cannot be opened, please copy this " +"link to your browser.\n" +" %(article_url)s" + +#: .\comments\utils.py:26 +#, python-format +msgid "" +"Your comment on " +"%(article_title)s
has \n" +" received a reply.
%(comment_body)s\n" +"
\n" +" go check it out!\n" +"
\n" +" If the link above cannot be opened, please copy this " +"link to your browser.\n" +" %(article_url)s\n" +" " +msgstr "" +"Your comment on " +"%(article_title)s
has \n" +" received a reply.
%(comment_body)s\n" +"
\n" +" go check it out!\n" +"
\n" +" If the link above cannot be opened, please copy this " +"link to your browser.\n" +" %(article_url)s\n" +" " + +#: .\djangoblog\logentryadmin.py:63 +msgid "object" +msgstr "object" + +#: .\djangoblog\settings.py:140 +msgid "English" +msgstr "English" + +#: .\djangoblog\settings.py:141 +msgid "Simplified Chinese" +msgstr "Simplified Chinese" + +#: .\djangoblog\settings.py:142 +msgid "Traditional Chinese" +msgstr "Traditional Chinese" + +#: .\oauth\models.py:30 +msgid "oauth user" +msgstr "oauth user" + +#: .\oauth\models.py:37 +msgid "weibo" +msgstr "weibo" + +#: .\oauth\models.py:38 +msgid "google" +msgstr "google" + +#: .\oauth\models.py:48 +msgid "callback url" +msgstr "callback url" + +#: .\oauth\models.py:59 +msgid "already exists" +msgstr "already exists" + +#: .\oauth\views.py:154 +#, python-format +msgid "" +"\n" +"

Congratulations, you have successfully bound your email address. You " +"can use\n" +" %(oauthuser_type)s to directly log in to this website without a " +"password.

\n" +" You are welcome to continue to follow this site, the address is\n" +" %(site)s\n" +" Thank you again!\n" +"
\n" +" If the link above cannot be opened, please copy this link to your " +"browser.\n" +" %(site)s\n" +" " +msgstr "" +"\n" +"

Congratulations, you have successfully bound your email address. You " +"can use\n" +" %(oauthuser_type)s to directly log in to this website without a " +"password.

\n" +" You are welcome to continue to follow this site, the address is\n" +" %(site)s\n" +" Thank you again!\n" +"
\n" +" If the link above cannot be opened, please copy this link to your " +"browser.\n" +" %(site)s\n" +" " + +#: .\oauth\views.py:165 +msgid "Congratulations on your successful binding!" +msgstr "Congratulations on your successful binding!" + +#: .\oauth\views.py:217 +#, python-format +msgid "" +"\n" +"

Please click the link below to bind your email

\n" +"\n" +" %(url)s\n" +"\n" +" Thank you again!\n" +"
\n" +" If the link above cannot be opened, please copy this link " +"to your browser.\n" +"
\n" +" %(url)s\n" +" " +msgstr "" +"\n" +"

Please click the link below to bind your email

\n" +"\n" +" %(url)s\n" +"\n" +" Thank you again!\n" +"
\n" +" If the link above cannot be opened, please copy this link " +"to your browser.\n" +"
\n" +" %(url)s\n" +" " + +#: .\oauth\views.py:228 .\oauth\views.py:240 +msgid "Bind your email" +msgstr "Bind your email" + +#: .\oauth\views.py:242 +msgid "" +"Congratulations, the binding is just one step away. Please log in to your " +"email to check the email to complete the binding. Thank you." +msgstr "" +"Congratulations, the binding is just one step away. Please log in to your " +"email to check the email to complete the binding. Thank you." + +#: .\oauth\views.py:245 +msgid "Binding successful" +msgstr "Binding successful" + +#: .\oauth\views.py:247 +#, python-format +msgid "" +"Congratulations, you have successfully bound your email address. You can use " +"%(oauthuser_type)s to directly log in to this website without a password. " +"You are welcome to continue to follow this site." +msgstr "" +"Congratulations, you have successfully bound your email address. You can use " +"%(oauthuser_type)s to directly log in to this website without a password. " +"You are welcome to continue to follow this site." + +#: .\templates\account\forget_password.html:7 +msgid "forget the password" +msgstr "forget the password" + +#: .\templates\account\forget_password.html:18 +msgid "get verification code" +msgstr "get verification code" + +#: .\templates\account\forget_password.html:19 +msgid "submit" +msgstr "submit" + +#: .\templates\account\login.html:36 +msgid "Create Account" +msgstr "Create Account" + +#: .\templates\account\login.html:42 +#, fuzzy +#| msgid "forget the password" +msgid "Forget Password" +msgstr "forget the password" + +#: .\templates\account\result.html:18 .\templates\blog\tags\sidebar.html:126 +msgid "login" +msgstr "login" + +#: .\templates\account\result.html:22 +msgid "back to the homepage" +msgstr "back to the homepage" + +#: .\templates\blog\article_archives.html:7 +#: .\templates\blog\article_archives.html:24 +msgid "article archive" +msgstr "article archive" + +#: .\templates\blog\article_archives.html:32 +msgid "year" +msgstr "year" + +#: .\templates\blog\article_archives.html:36 +msgid "month" +msgstr "month" + +#: .\templates\blog\tags\article_info.html:12 +msgid "pin to top" +msgstr "pin to top" + +#: .\templates\blog\tags\article_info.html:28 +msgid "comments" +msgstr "comments" + +#: .\templates\blog\tags\article_info.html:58 +msgid "toc" +msgstr "toc" + +#: .\templates\blog\tags\article_meta_info.html:6 +msgid "posted in" +msgstr "posted in" + +#: .\templates\blog\tags\article_meta_info.html:14 +msgid "and tagged" +msgstr "and tagged" + +#: .\templates\blog\tags\article_meta_info.html:25 +msgid "by " +msgstr "by" + +#: .\templates\blog\tags\article_meta_info.html:29 +#, python-format +msgid "" +"\n" +" title=\"View all articles published by " +"%(article.author.username)s\"\n" +" " +msgstr "" +"\n" +" title=\"View all articles published by " +"%(article.author.username)s\"\n" +" " + +#: .\templates\blog\tags\article_meta_info.html:44 +msgid "on" +msgstr "on" + +#: .\templates\blog\tags\article_meta_info.html:54 +msgid "edit" +msgstr "edit" + +#: .\templates\blog\tags\article_pagination.html:4 +msgid "article navigation" +msgstr "article navigation" + +#: .\templates\blog\tags\article_pagination.html:9 +msgid "earlier articles" +msgstr "earlier articles" + +#: .\templates\blog\tags\article_pagination.html:12 +msgid "newer articles" +msgstr "newer articles" + +#: .\templates\blog\tags\article_tag_list.html:5 +msgid "tags" +msgstr "tags" + +#: .\templates\blog\tags\sidebar.html:7 +msgid "search" +msgstr "search" + +#: .\templates\blog\tags\sidebar.html:50 +msgid "recent comments" +msgstr "recent comments" + +#: .\templates\blog\tags\sidebar.html:57 +msgid "published on" +msgstr "published on" + +#: .\templates\blog\tags\sidebar.html:65 +msgid "recent articles" +msgstr "recent articles" + +#: .\templates\blog\tags\sidebar.html:77 +msgid "bookmark" +msgstr "bookmark" + +#: .\templates\blog\tags\sidebar.html:96 +msgid "Tag Cloud" +msgstr "Tag Cloud" + +#: .\templates\blog\tags\sidebar.html:107 +msgid "Welcome to star or fork the source code of this site" +msgstr "Welcome to star or fork the source code of this site" + +#: .\templates\blog\tags\sidebar.html:118 +msgid "Function" +msgstr "Function" + +#: .\templates\blog\tags\sidebar.html:120 +msgid "management site" +msgstr "management site" + +#: .\templates\blog\tags\sidebar.html:122 +msgid "logout" +msgstr "logout" + +#: .\templates\blog\tags\sidebar.html:129 +msgid "Track record" +msgstr "Track record" + +#: .\templates\blog\tags\sidebar.html:135 +msgid "Click me to return to the top" +msgstr "Click me to return to the top" + +#: .\templates\oauth\oauth_applications.html:5 +#| msgid "login" +msgid "quick login" +msgstr "quick login" + +#: .\templates\share_layout\nav.html:26 +msgid "Article archive" +msgstr "Article archive" diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/locale/zh_Hans/LC_MESSAGES/django.mo b/src/DjangoBlog-NEW/DjangoBlog-master/locale/zh_Hans/LC_MESSAGES/django.mo new file mode 100644 index 00000000..a2d36e98 Binary files /dev/null and b/src/DjangoBlog-NEW/DjangoBlog-master/locale/zh_Hans/LC_MESSAGES/django.mo differ diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/locale/zh_Hans/LC_MESSAGES/django.po b/src/DjangoBlog-NEW/DjangoBlog-master/locale/zh_Hans/LC_MESSAGES/django.po new file mode 100644 index 00000000..200b7e6c --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/locale/zh_Hans/LC_MESSAGES/django.po @@ -0,0 +1,667 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-09-13 16:02+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: .\accounts\admin.py:12 +msgid "password" +msgstr "密码" + +#: .\accounts\admin.py:13 +msgid "Enter password again" +msgstr "再次输入密码" + +#: .\accounts\admin.py:24 .\accounts\forms.py:89 +msgid "passwords do not match" +msgstr "密码不匹配" + +#: .\accounts\forms.py:36 +msgid "email already exists" +msgstr "邮箱已存在" + +#: .\accounts\forms.py:46 .\accounts\forms.py:50 +msgid "New password" +msgstr "新密码" + +#: .\accounts\forms.py:60 +msgid "Confirm password" +msgstr "确认密码" + +#: .\accounts\forms.py:70 .\accounts\forms.py:116 +msgid "Email" +msgstr "邮箱" + +#: .\accounts\forms.py:76 .\accounts\forms.py:80 +msgid "Code" +msgstr "验证码" + +#: .\accounts\forms.py:100 .\accounts\tests.py:194 +msgid "email does not exist" +msgstr "邮箱不存在" + +#: .\accounts\models.py:12 .\oauth\models.py:17 +msgid "nick name" +msgstr "昵称" + +#: .\accounts\models.py:13 .\blog\models.py:29 .\blog\models.py:266 +#: .\blog\models.py:284 .\comments\models.py:13 .\oauth\models.py:23 +#: .\oauth\models.py:53 +msgid "creation time" +msgstr "创建时间" + +#: .\accounts\models.py:14 .\comments\models.py:14 .\oauth\models.py:24 +#: .\oauth\models.py:54 +msgid "last modify time" +msgstr "最后修改时间" + +#: .\accounts\models.py:15 +msgid "create source" +msgstr "来源" + +#: .\accounts\models.py:33 .\djangoblog\logentryadmin.py:81 +msgid "user" +msgstr "用户" + +#: .\accounts\tests.py:216 .\accounts\utils.py:39 +msgid "Verification code error" +msgstr "验证码错误" + +#: .\accounts\utils.py:13 +msgid "Verify Email" +msgstr "验证邮箱" + +#: .\accounts\utils.py:21 +#, python-format +msgid "" +"You are resetting the password, the verification code is:%(code)s, valid " +"within 5 minutes, please keep it properly" +msgstr "您正在重置密码,验证码为:%(code)s,5分钟内有效 请妥善保管." + +#: .\blog\admin.py:13 .\blog\models.py:92 .\comments\models.py:17 +#: .\oauth\models.py:12 +msgid "author" +msgstr "作者" + +#: .\blog\admin.py:53 +msgid "Publish selected articles" +msgstr "发布选中的文章" + +#: .\blog\admin.py:54 +msgid "Draft selected articles" +msgstr "选中文章设为草稿" + +#: .\blog\admin.py:55 +msgid "Close article comments" +msgstr "关闭文章评论" + +#: .\blog\admin.py:56 +msgid "Open article comments" +msgstr "打开文章评论" + +#: .\blog\admin.py:89 .\blog\models.py:101 .\blog\models.py:183 +#: .\templates\blog\tags\sidebar.html:40 +msgid "category" +msgstr "分类目录" + +#: .\blog\models.py:20 .\blog\models.py:179 .\templates\share_layout\nav.html:8 +msgid "index" +msgstr "首页" + +#: .\blog\models.py:21 +msgid "list" +msgstr "列表" + +#: .\blog\models.py:22 +msgid "post" +msgstr "文章" + +#: .\blog\models.py:23 +msgid "all" +msgstr "所有" + +#: .\blog\models.py:24 +msgid "slide" +msgstr "侧边栏" + +#: .\blog\models.py:30 .\blog\models.py:267 .\blog\models.py:285 +msgid "modify time" +msgstr "修改时间" + +#: .\blog\models.py:63 +msgid "Draft" +msgstr "草稿" + +#: .\blog\models.py:64 +msgid "Published" +msgstr "发布" + +#: .\blog\models.py:67 +msgid "Open" +msgstr "打开" + +#: .\blog\models.py:68 +msgid "Close" +msgstr "关闭" + +#: .\blog\models.py:71 .\comments\admin.py:47 +msgid "Article" +msgstr "文章" + +#: .\blog\models.py:72 +msgid "Page" +msgstr "页面" + +#: .\blog\models.py:74 .\blog\models.py:280 +msgid "title" +msgstr "标题" + +#: .\blog\models.py:75 +msgid "body" +msgstr "内容" + +#: .\blog\models.py:77 +msgid "publish time" +msgstr "发布时间" + +#: .\blog\models.py:79 +msgid "status" +msgstr "状态" + +#: .\blog\models.py:84 +msgid "comment status" +msgstr "评论状态" + +#: .\blog\models.py:88 .\oauth\models.py:43 +msgid "type" +msgstr "类型" + +#: .\blog\models.py:89 +msgid "views" +msgstr "阅读量" + +#: .\blog\models.py:97 .\blog\models.py:258 .\blog\models.py:282 +msgid "order" +msgstr "排序" + +#: .\blog\models.py:98 +msgid "show toc" +msgstr "显示目录" + +#: .\blog\models.py:105 .\blog\models.py:249 +msgid "tag" +msgstr "标签" + +#: .\blog\models.py:115 .\comments\models.py:21 +msgid "article" +msgstr "文章" + +#: .\blog\models.py:171 +msgid "category name" +msgstr "分类名" + +#: .\blog\models.py:174 +msgid "parent category" +msgstr "上级分类" + +#: .\blog\models.py:234 +msgid "tag name" +msgstr "标签名" + +#: .\blog\models.py:256 +msgid "link name" +msgstr "链接名" + +#: .\blog\models.py:257 .\blog\models.py:271 +msgid "link" +msgstr "链接" + +#: .\blog\models.py:260 +msgid "is show" +msgstr "是否显示" + +#: .\blog\models.py:262 +msgid "show type" +msgstr "显示类型" + +#: .\blog\models.py:281 +msgid "content" +msgstr "内容" + +#: .\blog\models.py:283 .\oauth\models.py:52 +msgid "is enable" +msgstr "是否启用" + +#: .\blog\models.py:289 +msgid "sidebar" +msgstr "侧边栏" + +#: .\blog\models.py:299 +msgid "site name" +msgstr "站点名称" + +#: .\blog\models.py:305 +msgid "site description" +msgstr "站点描述" + +#: .\blog\models.py:311 +msgid "site seo description" +msgstr "站点SEO描述" + +#: .\blog\models.py:313 +msgid "site keywords" +msgstr "关键字" + +#: .\blog\models.py:318 +msgid "article sub length" +msgstr "文章摘要长度" + +#: .\blog\models.py:319 +msgid "sidebar article count" +msgstr "侧边栏文章数目" + +#: .\blog\models.py:320 +msgid "sidebar comment count" +msgstr "侧边栏评论数目" + +#: .\blog\models.py:321 +msgid "article comment count" +msgstr "文章页面默认显示评论数目" + +#: .\blog\models.py:322 +msgid "show adsense" +msgstr "是否显示广告" + +#: .\blog\models.py:324 +msgid "adsense code" +msgstr "广告内容" + +#: .\blog\models.py:325 +msgid "open site comment" +msgstr "公共头部" + +#: .\blog\models.py:352 +msgid "Website configuration" +msgstr "网站配置" + +#: .\blog\models.py:360 +msgid "There can only be one configuration" +msgstr "只能有一个配置" + +#: .\blog\views.py:348 +msgid "" +"Sorry, the page you requested is not found, please click the home page to " +"see other?" +msgstr "抱歉,你所访问的页面找不到,请点击首页看看别的?" + +#: .\blog\views.py:356 +msgid "Sorry, the server is busy, please click the home page to see other?" +msgstr "抱歉,服务出错了,请点击首页看看别的?" + +#: .\blog\views.py:369 +msgid "Sorry, you do not have permission to access this page?" +msgstr "抱歉,你没用权限访问此页面。" + +#: .\comments\admin.py:15 +msgid "Disable comments" +msgstr "禁用评论" + +#: .\comments\admin.py:16 +msgid "Enable comments" +msgstr "启用评论" + +#: .\comments\admin.py:46 +msgid "User" +msgstr "用户" + +#: .\comments\models.py:25 +msgid "parent comment" +msgstr "上级评论" + +#: .\comments\models.py:29 +msgid "enable" +msgstr "启用" + +#: .\comments\models.py:34 .\templates\blog\tags\article_info.html:30 +msgid "comment" +msgstr "评论" + +#: .\comments\utils.py:13 +msgid "Thanks for your comment" +msgstr "感谢你的评论" + +#: .\comments\utils.py:15 +#, python-format +msgid "" +"

Thank you very much for your comments on this site

\n" +" You can visit %(article_title)s\n" +" to review your comments,\n" +" Thank you again!\n" +"
\n" +" If the link above cannot be opened, please copy this " +"link to your browser.\n" +" %(article_url)s" +msgstr "" +"

非常感谢您对此网站的评论

\n" +" 您可以访问%(article_title)s\n" +"查看您的评论,\n" +"再次感谢您!\n" +"
\n" +" 如果上面的链接打不开,请复制此链接链接到您的浏览器。\n" +"%(article_url)s" + +#: .\comments\utils.py:26 +#, python-format +msgid "" +"Your comment on " +"%(article_title)s
has \n" +" received a reply.
%(comment_body)s\n" +"
\n" +" go check it out!\n" +"
\n" +" If the link above cannot be opened, please copy this " +"link to your browser.\n" +" %(article_url)s\n" +" " +msgstr "" +"您对 %(article_title)s
" +"的评论有\n" +" 收到回复。
%(comment_body)s\n" +"
\n" +"快去看看吧!\n" +"
\n" +" 如果上面的链接打不开,请复制此链接链接到您的浏览器。\n" +" %(article_url)s\n" +" " + +#: .\djangoblog\logentryadmin.py:63 +msgid "object" +msgstr "对象" + +#: .\djangoblog\settings.py:140 +msgid "English" +msgstr "英文" + +#: .\djangoblog\settings.py:141 +msgid "Simplified Chinese" +msgstr "简体中文" + +#: .\djangoblog\settings.py:142 +msgid "Traditional Chinese" +msgstr "繁体中文" + +#: .\oauth\models.py:30 +msgid "oauth user" +msgstr "第三方用户" + +#: .\oauth\models.py:37 +msgid "weibo" +msgstr "微博" + +#: .\oauth\models.py:38 +msgid "google" +msgstr "谷歌" + +#: .\oauth\models.py:48 +msgid "callback url" +msgstr "回调地址" + +#: .\oauth\models.py:59 +msgid "already exists" +msgstr "已经存在" + +#: .\oauth\views.py:154 +#, python-format +msgid "" +"\n" +"

Congratulations, you have successfully bound your email address. You " +"can use\n" +" %(oauthuser_type)s to directly log in to this website without a " +"password.

\n" +" You are welcome to continue to follow this site, the address is\n" +" %(site)s\n" +" Thank you again!\n" +"
\n" +" If the link above cannot be opened, please copy this link to your " +"browser.\n" +" %(site)s\n" +" " +msgstr "" +"\n" +"

恭喜你已经绑定成功 你可以使用\n" +" %(oauthuser_type)s 来免密登录本站

\n" +" 欢迎继续关注本站, 地址是\n" +" %(site)s\n" +" 再次感谢你\n" +"
\n" +" 如果上面链接无法打开,请复制此链接到你的浏览器 \n" +" %(site)s\n" +" " + +#: .\oauth\views.py:165 +msgid "Congratulations on your successful binding!" +msgstr "恭喜你绑定成功" + +#: .\oauth\views.py:217 +#, python-format +msgid "" +"\n" +"

Please click the link below to bind your email

\n" +"\n" +" %(url)s\n" +"\n" +" Thank you again!\n" +"
\n" +" If the link above cannot be opened, please copy this link " +"to your browser.\n" +"
\n" +" %(url)s\n" +" " +msgstr "" +"\n" +"

请点击下面的链接绑定您的邮箱

\n" +"\n" +" %(url)s\n" +"\n" +"再次感谢您!\n" +"
\n" +"如果上面的链接打不开,请复制此链接到您的浏览器。\n" +"%(url)s\n" +" " + +#: .\oauth\views.py:228 .\oauth\views.py:240 +msgid "Bind your email" +msgstr "绑定邮箱" + +#: .\oauth\views.py:242 +msgid "" +"Congratulations, the binding is just one step away. Please log in to your " +"email to check the email to complete the binding. Thank you." +msgstr "恭喜您,还差一步就绑定成功了,请登录您的邮箱查看邮件完成绑定,谢谢。" + +#: .\oauth\views.py:245 +msgid "Binding successful" +msgstr "绑定成功" + +#: .\oauth\views.py:247 +#, python-format +msgid "" +"Congratulations, you have successfully bound your email address. You can use " +"%(oauthuser_type)s to directly log in to this website without a password. " +"You are welcome to continue to follow this site." +msgstr "" +"恭喜您绑定成功,您以后可以使用%(oauthuser_type)s来直接免密码登录本站啦,感谢" +"您对本站对关注。" + +#: .\templates\account\forget_password.html:7 +msgid "forget the password" +msgstr "忘记密码" + +#: .\templates\account\forget_password.html:18 +msgid "get verification code" +msgstr "获取验证码" + +#: .\templates\account\forget_password.html:19 +msgid "submit" +msgstr "提交" + +#: .\templates\account\login.html:36 +msgid "Create Account" +msgstr "创建账号" + +#: .\templates\account\login.html:42 +#| msgid "forget the password" +msgid "Forget Password" +msgstr "忘记密码" + +#: .\templates\account\result.html:18 .\templates\blog\tags\sidebar.html:126 +msgid "login" +msgstr "登录" + +#: .\templates\account\result.html:22 +msgid "back to the homepage" +msgstr "返回首页吧" + +#: .\templates\blog\article_archives.html:7 +#: .\templates\blog\article_archives.html:24 +msgid "article archive" +msgstr "文章归档" + +#: .\templates\blog\article_archives.html:32 +msgid "year" +msgstr "年" + +#: .\templates\blog\article_archives.html:36 +msgid "month" +msgstr "月" + +#: .\templates\blog\tags\article_info.html:12 +msgid "pin to top" +msgstr "置顶" + +#: .\templates\blog\tags\article_info.html:28 +msgid "comments" +msgstr "评论" + +#: .\templates\blog\tags\article_info.html:58 +msgid "toc" +msgstr "目录" + +#: .\templates\blog\tags\article_meta_info.html:6 +msgid "posted in" +msgstr "发布于" + +#: .\templates\blog\tags\article_meta_info.html:14 +msgid "and tagged" +msgstr "并标记为" + +#: .\templates\blog\tags\article_meta_info.html:25 +msgid "by " +msgstr "由" + +#: .\templates\blog\tags\article_meta_info.html:29 +#, python-format +msgid "" +"\n" +" title=\"View all articles published by " +"%(article.author.username)s\"\n" +" " +msgstr "" +"\n" +" title=\"查看所有由 %(article.author.username)s\"发布的文章\n" +" " + +#: .\templates\blog\tags\article_meta_info.html:44 +msgid "on" +msgstr "在" + +#: .\templates\blog\tags\article_meta_info.html:54 +msgid "edit" +msgstr "编辑" + +#: .\templates\blog\tags\article_pagination.html:4 +msgid "article navigation" +msgstr "文章导航" + +#: .\templates\blog\tags\article_pagination.html:9 +msgid "earlier articles" +msgstr "早期文章" + +#: .\templates\blog\tags\article_pagination.html:12 +msgid "newer articles" +msgstr "较新文章" + +#: .\templates\blog\tags\article_tag_list.html:5 +msgid "tags" +msgstr "标签" + +#: .\templates\blog\tags\sidebar.html:7 +msgid "search" +msgstr "搜索" + +#: .\templates\blog\tags\sidebar.html:50 +msgid "recent comments" +msgstr "近期评论" + +#: .\templates\blog\tags\sidebar.html:57 +msgid "published on" +msgstr "发表于" + +#: .\templates\blog\tags\sidebar.html:65 +msgid "recent articles" +msgstr "近期文章" + +#: .\templates\blog\tags\sidebar.html:77 +msgid "bookmark" +msgstr "书签" + +#: .\templates\blog\tags\sidebar.html:96 +msgid "Tag Cloud" +msgstr "标签云" + +#: .\templates\blog\tags\sidebar.html:107 +msgid "Welcome to star or fork the source code of this site" +msgstr "欢迎您STAR或者FORK本站源代码" + +#: .\templates\blog\tags\sidebar.html:118 +msgid "Function" +msgstr "功能" + +#: .\templates\blog\tags\sidebar.html:120 +msgid "management site" +msgstr "管理站点" + +#: .\templates\blog\tags\sidebar.html:122 +msgid "logout" +msgstr "登出" + +#: .\templates\blog\tags\sidebar.html:129 +msgid "Track record" +msgstr "运动轨迹记录" + +#: .\templates\blog\tags\sidebar.html:135 +msgid "Click me to return to the top" +msgstr "点我返回顶部" + +#: .\templates\oauth\oauth_applications.html:5 +#| msgid "login" +msgid "quick login" +msgstr "快捷登录" + +#: .\templates\share_layout\nav.html:26 +msgid "Article archive" +msgstr "文章归档" diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/locale/zh_Hant/LC_MESSAGES/django.mo b/src/DjangoBlog-NEW/DjangoBlog-master/locale/zh_Hant/LC_MESSAGES/django.mo new file mode 100644 index 00000000..fe2ea17d Binary files /dev/null and b/src/DjangoBlog-NEW/DjangoBlog-master/locale/zh_Hant/LC_MESSAGES/django.mo differ diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/locale/zh_Hant/LC_MESSAGES/django.po b/src/DjangoBlog-NEW/DjangoBlog-master/locale/zh_Hant/LC_MESSAGES/django.po new file mode 100644 index 00000000..a2920ce5 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/locale/zh_Hant/LC_MESSAGES/django.po @@ -0,0 +1,668 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-09-13 16:02+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: .\accounts\admin.py:12 +msgid "password" +msgstr "密碼" + +#: .\accounts\admin.py:13 +msgid "Enter password again" +msgstr "再次輸入密碼" + +#: .\accounts\admin.py:24 .\accounts\forms.py:89 +msgid "passwords do not match" +msgstr "密碼不匹配" + +#: .\accounts\forms.py:36 +msgid "email already exists" +msgstr "郵箱已存在" + +#: .\accounts\forms.py:46 .\accounts\forms.py:50 +msgid "New password" +msgstr "新密碼" + +#: .\accounts\forms.py:60 +msgid "Confirm password" +msgstr "確認密碼" + +#: .\accounts\forms.py:70 .\accounts\forms.py:116 +msgid "Email" +msgstr "郵箱" + +#: .\accounts\forms.py:76 .\accounts\forms.py:80 +msgid "Code" +msgstr "驗證碼" + +#: .\accounts\forms.py:100 .\accounts\tests.py:194 +msgid "email does not exist" +msgstr "郵箱不存在" + +#: .\accounts\models.py:12 .\oauth\models.py:17 +msgid "nick name" +msgstr "昵稱" + +#: .\accounts\models.py:13 .\blog\models.py:29 .\blog\models.py:266 +#: .\blog\models.py:284 .\comments\models.py:13 .\oauth\models.py:23 +#: .\oauth\models.py:53 +msgid "creation time" +msgstr "創建時間" + +#: .\accounts\models.py:14 .\comments\models.py:14 .\oauth\models.py:24 +#: .\oauth\models.py:54 +msgid "last modify time" +msgstr "最後修改時間" + +#: .\accounts\models.py:15 +msgid "create source" +msgstr "來源" + +#: .\accounts\models.py:33 .\djangoblog\logentryadmin.py:81 +msgid "user" +msgstr "用戶" + +#: .\accounts\tests.py:216 .\accounts\utils.py:39 +msgid "Verification code error" +msgstr "驗證碼錯誤" + +#: .\accounts\utils.py:13 +msgid "Verify Email" +msgstr "驗證郵箱" + +#: .\accounts\utils.py:21 +#, python-format +msgid "" +"You are resetting the password, the verification code is:%(code)s, valid " +"within 5 minutes, please keep it properly" +msgstr "您正在重置密碼,驗證碼為:%(code)s,5分鐘內有效 請妥善保管." + +#: .\blog\admin.py:13 .\blog\models.py:92 .\comments\models.py:17 +#: .\oauth\models.py:12 +msgid "author" +msgstr "作者" + +#: .\blog\admin.py:53 +msgid "Publish selected articles" +msgstr "發布選中的文章" + +#: .\blog\admin.py:54 +msgid "Draft selected articles" +msgstr "選中文章設為草稿" + +#: .\blog\admin.py:55 +msgid "Close article comments" +msgstr "關閉文章評論" + +#: .\blog\admin.py:56 +msgid "Open article comments" +msgstr "打開文章評論" + +#: .\blog\admin.py:89 .\blog\models.py:101 .\blog\models.py:183 +#: .\templates\blog\tags\sidebar.html:40 +msgid "category" +msgstr "分類目錄" + +#: .\blog\models.py:20 .\blog\models.py:179 .\templates\share_layout\nav.html:8 +msgid "index" +msgstr "首頁" + +#: .\blog\models.py:21 +msgid "list" +msgstr "列表" + +#: .\blog\models.py:22 +msgid "post" +msgstr "文章" + +#: .\blog\models.py:23 +msgid "all" +msgstr "所有" + +#: .\blog\models.py:24 +msgid "slide" +msgstr "側邊欄" + +#: .\blog\models.py:30 .\blog\models.py:267 .\blog\models.py:285 +msgid "modify time" +msgstr "修改時間" + +#: .\blog\models.py:63 +msgid "Draft" +msgstr "草稿" + +#: .\blog\models.py:64 +msgid "Published" +msgstr "發布" + +#: .\blog\models.py:67 +msgid "Open" +msgstr "打開" + +#: .\blog\models.py:68 +msgid "Close" +msgstr "關閉" + +#: .\blog\models.py:71 .\comments\admin.py:47 +msgid "Article" +msgstr "文章" + +#: .\blog\models.py:72 +msgid "Page" +msgstr "頁面" + +#: .\blog\models.py:74 .\blog\models.py:280 +msgid "title" +msgstr "標題" + +#: .\blog\models.py:75 +msgid "body" +msgstr "內容" + +#: .\blog\models.py:77 +msgid "publish time" +msgstr "發布時間" + +#: .\blog\models.py:79 +msgid "status" +msgstr "狀態" + +#: .\blog\models.py:84 +msgid "comment status" +msgstr "評論狀態" + +#: .\blog\models.py:88 .\oauth\models.py:43 +msgid "type" +msgstr "類型" + +#: .\blog\models.py:89 +msgid "views" +msgstr "閱讀量" + +#: .\blog\models.py:97 .\blog\models.py:258 .\blog\models.py:282 +msgid "order" +msgstr "排序" + +#: .\blog\models.py:98 +msgid "show toc" +msgstr "顯示目錄" + +#: .\blog\models.py:105 .\blog\models.py:249 +msgid "tag" +msgstr "標簽" + +#: .\blog\models.py:115 .\comments\models.py:21 +msgid "article" +msgstr "文章" + +#: .\blog\models.py:171 +msgid "category name" +msgstr "分類名" + +#: .\blog\models.py:174 +msgid "parent category" +msgstr "上級分類" + +#: .\blog\models.py:234 +msgid "tag name" +msgstr "標簽名" + +#: .\blog\models.py:256 +msgid "link name" +msgstr "鏈接名" + +#: .\blog\models.py:257 .\blog\models.py:271 +msgid "link" +msgstr "鏈接" + +#: .\blog\models.py:260 +msgid "is show" +msgstr "是否顯示" + +#: .\blog\models.py:262 +msgid "show type" +msgstr "顯示類型" + +#: .\blog\models.py:281 +msgid "content" +msgstr "內容" + +#: .\blog\models.py:283 .\oauth\models.py:52 +msgid "is enable" +msgstr "是否啟用" + +#: .\blog\models.py:289 +msgid "sidebar" +msgstr "側邊欄" + +#: .\blog\models.py:299 +msgid "site name" +msgstr "站點名稱" + +#: .\blog\models.py:305 +msgid "site description" +msgstr "站點描述" + +#: .\blog\models.py:311 +msgid "site seo description" +msgstr "站點SEO描述" + +#: .\blog\models.py:313 +msgid "site keywords" +msgstr "關鍵字" + +#: .\blog\models.py:318 +msgid "article sub length" +msgstr "文章摘要長度" + +#: .\blog\models.py:319 +msgid "sidebar article count" +msgstr "側邊欄文章數目" + +#: .\blog\models.py:320 +msgid "sidebar comment count" +msgstr "側邊欄評論數目" + +#: .\blog\models.py:321 +msgid "article comment count" +msgstr "文章頁面默認顯示評論數目" + +#: .\blog\models.py:322 +msgid "show adsense" +msgstr "是否顯示廣告" + +#: .\blog\models.py:324 +msgid "adsense code" +msgstr "廣告內容" + +#: .\blog\models.py:325 +msgid "open site comment" +msgstr "公共頭部" + +#: .\blog\models.py:352 +msgid "Website configuration" +msgstr "網站配置" + +#: .\blog\models.py:360 +msgid "There can only be one configuration" +msgstr "只能有一個配置" + +#: .\blog\views.py:348 +msgid "" +"Sorry, the page you requested is not found, please click the home page to " +"see other?" +msgstr "抱歉,你所訪問的頁面找不到,請點擊首頁看看別的?" + +#: .\blog\views.py:356 +msgid "Sorry, the server is busy, please click the home page to see other?" +msgstr "抱歉,服務出錯了,請點擊首頁看看別的?" + +#: .\blog\views.py:369 +msgid "Sorry, you do not have permission to access this page?" +msgstr "抱歉,你沒用權限訪問此頁面。" + +#: .\comments\admin.py:15 +msgid "Disable comments" +msgstr "禁用評論" + +#: .\comments\admin.py:16 +msgid "Enable comments" +msgstr "啟用評論" + +#: .\comments\admin.py:46 +msgid "User" +msgstr "用戶" + +#: .\comments\models.py:25 +msgid "parent comment" +msgstr "上級評論" + +#: .\comments\models.py:29 +msgid "enable" +msgstr "啟用" + +#: .\comments\models.py:34 .\templates\blog\tags\article_info.html:30 +msgid "comment" +msgstr "評論" + +#: .\comments\utils.py:13 +msgid "Thanks for your comment" +msgstr "感謝你的評論" + +#: .\comments\utils.py:15 +#, python-format +msgid "" +"

Thank you very much for your comments on this site

\n" +" You can visit %(article_title)s\n" +" to review your comments,\n" +" Thank you again!\n" +"
\n" +" If the link above cannot be opened, please copy this " +"link to your browser.\n" +" %(article_url)s" +msgstr "" +"

非常感謝您對此網站的評論

\n" +" 您可以訪問%(article_title)s\n" +"查看您的評論,\n" +"再次感謝您!\n" +"
\n" +" 如果上面的鏈接打不開,請復製此鏈接鏈接到您的瀏覽器。\n" +"%(article_url)s" + +#: .\comments\utils.py:26 +#, python-format +msgid "" +"Your comment on " +"%(article_title)s
has \n" +" received a reply.
%(comment_body)s\n" +"
\n" +" go check it out!\n" +"
\n" +" If the link above cannot be opened, please copy this " +"link to your browser.\n" +" %(article_url)s\n" +" " +msgstr "" +"您對 %(article_title)s
" +"的評論有\n" +" 收到回復。
%(comment_body)s\n" +"
\n" +"快去看看吧!\n" +"
\n" +" 如果上面的鏈接打不開,請復製此鏈接鏈接到您的瀏覽器。\n" +" %(article_url)s\n" +" " + +#: .\djangoblog\logentryadmin.py:63 +msgid "object" +msgstr "對象" + +#: .\djangoblog\settings.py:140 +msgid "English" +msgstr "英文" + +#: .\djangoblog\settings.py:141 +msgid "Simplified Chinese" +msgstr "簡體中文" + +#: .\djangoblog\settings.py:142 +msgid "Traditional Chinese" +msgstr "繁體中文" + +#: .\oauth\models.py:30 +msgid "oauth user" +msgstr "第三方用戶" + +#: .\oauth\models.py:37 +msgid "weibo" +msgstr "微博" + +#: .\oauth\models.py:38 +msgid "google" +msgstr "谷歌" + +#: .\oauth\models.py:48 +msgid "callback url" +msgstr "回調地址" + +#: .\oauth\models.py:59 +msgid "already exists" +msgstr "已經存在" + +#: .\oauth\views.py:154 +#, python-format +msgid "" +"\n" +"

Congratulations, you have successfully bound your email address. You " +"can use\n" +" %(oauthuser_type)s to directly log in to this website without a " +"password.

\n" +" You are welcome to continue to follow this site, the address is\n" +" %(site)s\n" +" Thank you again!\n" +"
\n" +" If the link above cannot be opened, please copy this link to your " +"browser.\n" +" %(site)s\n" +" " +msgstr "" +"\n" +"

恭喜你已經綁定成功 你可以使用\n" +" %(oauthuser_type)s 來免密登錄本站

\n" +" 歡迎繼續關註本站, 地址是\n" +" %(site)s\n" +" 再次感謝你\n" +"
\n" +" 如果上面鏈接無法打開,請復製此鏈接到你的瀏覽器 \n" +" %(site)s\n" +" " + +#: .\oauth\views.py:165 +msgid "Congratulations on your successful binding!" +msgstr "恭喜你綁定成功" + +#: .\oauth\views.py:217 +#, python-format +msgid "" +"\n" +"

Please click the link below to bind your email

\n" +"\n" +" %(url)s\n" +"\n" +" Thank you again!\n" +"
\n" +" If the link above cannot be opened, please copy this link " +"to your browser.\n" +"
\n" +" %(url)s\n" +" " +msgstr "" +"\n" +"

請點擊下面的鏈接綁定您的郵箱

\n" +"\n" +" %(url)s\n" +"\n" +"再次感謝您!\n" +"
\n" +"如果上面的鏈接打不開,請復製此鏈接到您的瀏覽器。\n" +"%(url)s\n" +" " + +#: .\oauth\views.py:228 .\oauth\views.py:240 +msgid "Bind your email" +msgstr "綁定郵箱" + +#: .\oauth\views.py:242 +msgid "" +"Congratulations, the binding is just one step away. Please log in to your " +"email to check the email to complete the binding. Thank you." +msgstr "恭喜您,還差一步就綁定成功了,請登錄您的郵箱查看郵件完成綁定,謝謝。" + +#: .\oauth\views.py:245 +msgid "Binding successful" +msgstr "綁定成功" + +#: .\oauth\views.py:247 +#, python-format +msgid "" +"Congratulations, you have successfully bound your email address. You can use " +"%(oauthuser_type)s to directly log in to this website without a password. " +"You are welcome to continue to follow this site." +msgstr "" +"恭喜您綁定成功,您以後可以使用%(oauthuser_type)s來直接免密碼登錄本站啦,感謝" +"您對本站對關註。" + +#: .\templates\account\forget_password.html:7 +msgid "forget the password" +msgstr "忘記密碼" + +#: .\templates\account\forget_password.html:18 +msgid "get verification code" +msgstr "獲取驗證碼" + +#: .\templates\account\forget_password.html:19 +msgid "submit" +msgstr "提交" + +#: .\templates\account\login.html:36 +msgid "Create Account" +msgstr "創建賬號" + +#: .\templates\account\login.html:42 +#, fuzzy +#| msgid "forget the password" +msgid "Forget Password" +msgstr "忘記密碼" + +#: .\templates\account\result.html:18 .\templates\blog\tags\sidebar.html:126 +msgid "login" +msgstr "登錄" + +#: .\templates\account\result.html:22 +msgid "back to the homepage" +msgstr "返回首頁吧" + +#: .\templates\blog\article_archives.html:7 +#: .\templates\blog\article_archives.html:24 +msgid "article archive" +msgstr "文章歸檔" + +#: .\templates\blog\article_archives.html:32 +msgid "year" +msgstr "年" + +#: .\templates\blog\article_archives.html:36 +msgid "month" +msgstr "月" + +#: .\templates\blog\tags\article_info.html:12 +msgid "pin to top" +msgstr "置頂" + +#: .\templates\blog\tags\article_info.html:28 +msgid "comments" +msgstr "評論" + +#: .\templates\blog\tags\article_info.html:58 +msgid "toc" +msgstr "目錄" + +#: .\templates\blog\tags\article_meta_info.html:6 +msgid "posted in" +msgstr "發布於" + +#: .\templates\blog\tags\article_meta_info.html:14 +msgid "and tagged" +msgstr "並標記為" + +#: .\templates\blog\tags\article_meta_info.html:25 +msgid "by " +msgstr "由" + +#: .\templates\blog\tags\article_meta_info.html:29 +#, python-format +msgid "" +"\n" +" title=\"View all articles published by " +"%(article.author.username)s\"\n" +" " +msgstr "" +"\n" +" title=\"查看所有由 %(article.author.username)s\"發布的文章\n" +" " + +#: .\templates\blog\tags\article_meta_info.html:44 +msgid "on" +msgstr "在" + +#: .\templates\blog\tags\article_meta_info.html:54 +msgid "edit" +msgstr "編輯" + +#: .\templates\blog\tags\article_pagination.html:4 +msgid "article navigation" +msgstr "文章導航" + +#: .\templates\blog\tags\article_pagination.html:9 +msgid "earlier articles" +msgstr "早期文章" + +#: .\templates\blog\tags\article_pagination.html:12 +msgid "newer articles" +msgstr "較新文章" + +#: .\templates\blog\tags\article_tag_list.html:5 +msgid "tags" +msgstr "標簽" + +#: .\templates\blog\tags\sidebar.html:7 +msgid "search" +msgstr "搜索" + +#: .\templates\blog\tags\sidebar.html:50 +msgid "recent comments" +msgstr "近期評論" + +#: .\templates\blog\tags\sidebar.html:57 +msgid "published on" +msgstr "發表於" + +#: .\templates\blog\tags\sidebar.html:65 +msgid "recent articles" +msgstr "近期文章" + +#: .\templates\blog\tags\sidebar.html:77 +msgid "bookmark" +msgstr "書簽" + +#: .\templates\blog\tags\sidebar.html:96 +msgid "Tag Cloud" +msgstr "標簽雲" + +#: .\templates\blog\tags\sidebar.html:107 +msgid "Welcome to star or fork the source code of this site" +msgstr "歡迎您STAR或者FORK本站源代碼" + +#: .\templates\blog\tags\sidebar.html:118 +msgid "Function" +msgstr "功能" + +#: .\templates\blog\tags\sidebar.html:120 +msgid "management site" +msgstr "管理站點" + +#: .\templates\blog\tags\sidebar.html:122 +msgid "logout" +msgstr "登出" + +#: .\templates\blog\tags\sidebar.html:129 +msgid "Track record" +msgstr "運動軌跡記錄" + +#: .\templates\blog\tags\sidebar.html:135 +msgid "Click me to return to the top" +msgstr "點我返回頂部" + +#: .\templates\oauth\oauth_applications.html:5 +#| msgid "login" +msgid "quick login" +msgstr "快捷登錄" + +#: .\templates\share_layout\nav.html:26 +msgid "Article archive" +msgstr "文章歸檔" diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/manage.py b/src/DjangoBlog-NEW/DjangoBlog-master/manage.py new file mode 100644 index 00000000..919ba740 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings") + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/oauth/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/oauth/admin.py b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/admin.py new file mode 100644 index 00000000..57eab5f5 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/admin.py @@ -0,0 +1,54 @@ +import logging + +from django.contrib import admin +# Register your models here. +from django.urls import reverse +from django.utils.html import format_html + +logger = logging.getLogger(__name__) + + +class OAuthUserAdmin(admin.ModelAdmin): + search_fields = ('nickname', 'email') + list_per_page = 20 + list_display = ( + 'id', + 'nickname', + 'link_to_usermodel', + 'show_user_image', + 'type', + 'email', + ) + list_display_links = ('id', 'nickname') + list_filter = ('author', 'type',) + readonly_fields = [] + + def get_readonly_fields(self, request, obj=None): + return list(self.readonly_fields) + \ + [field.name for field in obj._meta.fields] + \ + [field.name for field in obj._meta.many_to_many] + + def has_add_permission(self, request): + return False + + def link_to_usermodel(self, obj): + if obj.author: + 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 show_user_image(self, obj): + img = obj.picture + return format_html( + u'' % + (img)) + + link_to_usermodel.short_description = '用户' + show_user_image.short_description = '用户头像' + + +class OAuthConfigAdmin(admin.ModelAdmin): + list_display = ('type', 'appkey', 'appsecret', 'is_enable') + list_filter = ('type',) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/oauth/apps.py b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/apps.py new file mode 100644 index 00000000..17fcea24 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class OauthConfig(AppConfig): + name = 'oauth' diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/oauth/forms.py b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/forms.py new file mode 100644 index 00000000..0e4ede34 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/forms.py @@ -0,0 +1,12 @@ +from django.contrib.auth.forms import forms +from django.forms import widgets + + +class RequireEmailForm(forms.Form): + email = forms.EmailField(label='电子邮箱', required=True) + oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False) + + def __init__(self, *args, **kwargs): + super(RequireEmailForm, self).__init__(*args, **kwargs) + self.fields['email'].widget = widgets.EmailInput( + attrs={'placeholder': "email", "class": "form-control"}) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/oauth/migrations/0001_initial.py b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/migrations/0001_initial.py new file mode 100644 index 00000000..3aa3e031 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/migrations/0001_initial.py @@ -0,0 +1,57 @@ +# Generated by Django 4.1.7 on 2023-03-07 09:53 + +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 = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OAuthConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='类型')), + ('appkey', models.CharField(max_length=200, verbose_name='AppKey')), + ('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')), + ('callback_url', models.CharField(default='http://www.baidu.com', max_length=200, 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': 'oauth配置', + 'verbose_name_plural': 'oauth配置', + 'ordering': ['-created_time'], + }, + ), + migrations.CreateModel( + name='OAuthUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('openid', models.CharField(max_length=50)), + ('nickname', models.CharField(max_length=50, verbose_name='昵称')), + ('token', models.CharField(blank=True, max_length=150, null=True)), + ('picture', models.CharField(blank=True, max_length=350, null=True)), + ('type', models.CharField(max_length=50)), + ('email', models.CharField(blank=True, max_length=50, null=True)), + ('metadata', models.TextField(blank=True, null=True)), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')), + ], + options={ + 'verbose_name': 'oauth用户', + 'verbose_name_plural': 'oauth用户', + 'ordering': ['-created_time'], + }, + ), + ] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py new file mode 100644 index 00000000..d5cc70ef --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py @@ -0,0 +1,86 @@ +# 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), + ('oauth', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='oauthconfig', + options={'ordering': ['-creation_time'], 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置'}, + ), + migrations.AlterModelOptions( + name='oauthuser', + options={'ordering': ['-creation_time'], 'verbose_name': 'oauth user', 'verbose_name_plural': 'oauth user'}, + ), + migrations.RemoveField( + model_name='oauthconfig', + name='created_time', + ), + migrations.RemoveField( + model_name='oauthconfig', + name='last_mod_time', + ), + migrations.RemoveField( + model_name='oauthuser', + name='created_time', + ), + migrations.RemoveField( + model_name='oauthuser', + name='last_mod_time', + ), + migrations.AddField( + model_name='oauthconfig', + name='creation_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + ), + migrations.AddField( + model_name='oauthconfig', + name='last_modify_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), + ), + migrations.AddField( + model_name='oauthuser', + name='creation_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + ), + migrations.AddField( + model_name='oauthuser', + name='last_modify_time', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), + ), + migrations.AlterField( + model_name='oauthconfig', + name='callback_url', + field=models.CharField(default='', max_length=200, verbose_name='callback url'), + ), + migrations.AlterField( + model_name='oauthconfig', + name='is_enable', + field=models.BooleanField(default=True, verbose_name='is enable'), + ), + migrations.AlterField( + model_name='oauthconfig', + name='type', + field=models.CharField(choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='type'), + ), + migrations.AlterField( + model_name='oauthuser', + name='author', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), + ), + migrations.AlterField( + model_name='oauthuser', + name='nickname', + field=models.CharField(max_length=50, verbose_name='nickname'), + ), + ] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/oauth/migrations/0003_alter_oauthuser_nickname.py b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/migrations/0003_alter_oauthuser_nickname.py new file mode 100644 index 00000000..6af08ebb --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/migrations/0003_alter_oauthuser_nickname.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2024-01-26 02:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='oauthuser', + name='nickname', + field=models.CharField(max_length=50, verbose_name='nick name'), + ), + ] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/oauth/migrations/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/oauth/models.py b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/models.py new file mode 100644 index 00000000..be838edd --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/models.py @@ -0,0 +1,67 @@ +# Create your models here. +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ + + +class OAuthUser(models.Model): + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_('author'), + blank=True, + null=True, + on_delete=models.CASCADE) + openid = models.CharField(max_length=50) + nickname = models.CharField(max_length=50, verbose_name=_('nick name')) + token = models.CharField(max_length=150, null=True, blank=True) + picture = models.CharField(max_length=350, blank=True, null=True) + type = models.CharField(blank=False, null=False, max_length=50) + email = models.CharField(max_length=50, null=True, blank=True) + metadata = models.TextField(null=True, blank=True) + creation_time = models.DateTimeField(_('creation time'), default=now) + last_modify_time = models.DateTimeField(_('last modify time'), default=now) + + def __str__(self): + return self.nickname + + class Meta: + verbose_name = _('oauth user') + verbose_name_plural = verbose_name + ordering = ['-creation_time'] + + +class OAuthConfig(models.Model): + TYPE = ( + ('weibo', _('weibo')), + ('google', _('google')), + ('github', 'GitHub'), + ('facebook', 'FaceBook'), + ('qq', 'QQ'), + ) + type = models.CharField(_('type'), max_length=10, choices=TYPE, default='a') + appkey = models.CharField(max_length=200, verbose_name='AppKey') + appsecret = models.CharField(max_length=200, verbose_name='AppSecret') + callback_url = models.CharField( + max_length=200, + verbose_name=_('callback url'), + blank=False, + default='') + is_enable = models.BooleanField( + _('is enable'), default=True, blank=False, null=False) + creation_time = models.DateTimeField(_('creation time'), default=now) + last_modify_time = models.DateTimeField(_('last modify time'), default=now) + + def clean(self): + if OAuthConfig.objects.filter( + type=self.type).exclude(id=self.id).count(): + raise ValidationError(_(self.type + _('already exists'))) + + def __str__(self): + return self.type + + class Meta: + verbose_name = 'oauth配置' + verbose_name_plural = verbose_name + ordering = ['-creation_time'] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/oauth/oauthmanager.py b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/oauthmanager.py new file mode 100644 index 00000000..2e7ceef2 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/oauthmanager.py @@ -0,0 +1,504 @@ +import json +import logging +import os +import urllib.parse +from abc import ABCMeta, abstractmethod + +import requests + +from djangoblog.utils import cache_decorator +from oauth.models import OAuthUser, OAuthConfig + +logger = logging.getLogger(__name__) + + +class OAuthAccessTokenException(Exception): + ''' + oauth授权失败异常 + ''' + + +class BaseOauthManager(metaclass=ABCMeta): + """获取用户授权""" + AUTH_URL = None + """获取token""" + TOKEN_URL = None + """获取用户信息""" + API_URL = None + '''icon图标名''' + ICON_NAME = None + + def __init__(self, access_token=None, openid=None): + self.access_token = access_token + self.openid = openid + + @property + def is_access_token_set(self): + return self.access_token is not None + + @property + def is_authorized(self): + return self.is_access_token_set and self.access_token is not None and self.openid is not None + + @abstractmethod + def get_authorization_url(self, nexturl='/'): + pass + + @abstractmethod + def get_access_token_by_code(self, code): + pass + + @abstractmethod + def get_oauth_userinfo(self): + pass + + @abstractmethod + def get_picture(self, metadata): + pass + + def do_get(self, url, params, headers=None): + rsp = requests.get(url=url, params=params, headers=headers) + logger.info(rsp.text) + return rsp.text + + def do_post(self, url, params, headers=None): + rsp = requests.post(url, params, headers=headers) + logger.info(rsp.text) + return rsp.text + + def get_config(self): + value = OAuthConfig.objects.filter(type=self.ICON_NAME) + return value[0] if value else None + + +class WBOauthManager(BaseOauthManager): + AUTH_URL = 'https://api.weibo.com/oauth2/authorize' + TOKEN_URL = 'https://api.weibo.com/oauth2/access_token' + API_URL = 'https://api.weibo.com/2/users/show.json' + ICON_NAME = 'weibo' + + def __init__(self, access_token=None, openid=None): + config = self.get_config() + self.client_id = config.appkey if config else '' + self.client_secret = config.appsecret if config else '' + self.callback_url = config.callback_url if config else '' + super( + WBOauthManager, + self).__init__( + access_token=access_token, + openid=openid) + + def get_authorization_url(self, nexturl='/'): + params = { + 'client_id': self.client_id, + 'response_type': 'code', + 'redirect_uri': self.callback_url + '&next_url=' + nexturl + } + url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) + return url + + def get_access_token_by_code(self, code): + + params = { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': self.callback_url + } + rsp = self.do_post(self.TOKEN_URL, params) + + obj = json.loads(rsp) + if 'access_token' in obj: + self.access_token = str(obj['access_token']) + self.openid = str(obj['uid']) + return self.get_oauth_userinfo() + else: + raise OAuthAccessTokenException(rsp) + + def get_oauth_userinfo(self): + if not self.is_authorized: + return None + params = { + 'uid': self.openid, + 'access_token': self.access_token + } + rsp = self.do_get(self.API_URL, params) + try: + datas = json.loads(rsp) + user = OAuthUser() + user.metadata = rsp + user.picture = datas['avatar_large'] + user.nickname = datas['screen_name'] + user.openid = datas['id'] + user.type = 'weibo' + user.token = self.access_token + if 'email' in datas and datas['email']: + user.email = datas['email'] + return user + except Exception as e: + logger.error(e) + logger.error('weibo oauth error.rsp:' + rsp) + return None + + def get_picture(self, metadata): + datas = json.loads(metadata) + return datas['avatar_large'] + + +class ProxyManagerMixin: + def __init__(self, *args, **kwargs): + if os.environ.get("HTTP_PROXY"): + self.proxies = { + "http": os.environ.get("HTTP_PROXY"), + "https": os.environ.get("HTTP_PROXY") + } + else: + self.proxies = None + + def do_get(self, url, params, headers=None): + rsp = requests.get(url=url, params=params, headers=headers, proxies=self.proxies) + logger.info(rsp.text) + return rsp.text + + def do_post(self, url, params, headers=None): + rsp = requests.post(url, params, headers=headers, proxies=self.proxies) + logger.info(rsp.text) + return rsp.text + + +class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): + AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' + TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token' + API_URL = 'https://www.googleapis.com/oauth2/v3/userinfo' + ICON_NAME = 'google' + + def __init__(self, access_token=None, openid=None): + config = self.get_config() + self.client_id = config.appkey if config else '' + self.client_secret = config.appsecret if config else '' + self.callback_url = config.callback_url if config else '' + super( + GoogleOauthManager, + self).__init__( + access_token=access_token, + openid=openid) + + def get_authorization_url(self, nexturl='/'): + params = { + 'client_id': self.client_id, + 'response_type': 'code', + 'redirect_uri': self.callback_url, + 'scope': 'openid email', + } + url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) + return url + + def get_access_token_by_code(self, code): + params = { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'grant_type': 'authorization_code', + 'code': code, + + 'redirect_uri': self.callback_url + } + rsp = self.do_post(self.TOKEN_URL, params) + + obj = json.loads(rsp) + + if 'access_token' in obj: + self.access_token = str(obj['access_token']) + self.openid = str(obj['id_token']) + logger.info(self.ICON_NAME + ' oauth ' + rsp) + return self.access_token + else: + raise OAuthAccessTokenException(rsp) + + def get_oauth_userinfo(self): + if not self.is_authorized: + return None + params = { + 'access_token': self.access_token + } + rsp = self.do_get(self.API_URL, params) + try: + + datas = json.loads(rsp) + user = OAuthUser() + user.metadata = rsp + user.picture = datas['picture'] + user.nickname = datas['name'] + user.openid = datas['sub'] + user.token = self.access_token + user.type = 'google' + if datas['email']: + user.email = datas['email'] + return user + except Exception as e: + logger.error(e) + logger.error('google oauth error.rsp:' + rsp) + return None + + def get_picture(self, metadata): + datas = json.loads(metadata) + return datas['picture'] + + +class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): + AUTH_URL = 'https://github.com/login/oauth/authorize' + TOKEN_URL = 'https://github.com/login/oauth/access_token' + API_URL = 'https://api.github.com/user' + ICON_NAME = 'github' + + def __init__(self, access_token=None, openid=None): + config = self.get_config() + self.client_id = config.appkey if config else '' + self.client_secret = config.appsecret if config else '' + self.callback_url = config.callback_url if config else '' + super( + GitHubOauthManager, + self).__init__( + access_token=access_token, + openid=openid) + + def get_authorization_url(self, next_url='/'): + params = { + 'client_id': self.client_id, + 'response_type': 'code', + 'redirect_uri': f'{self.callback_url}&next_url={next_url}', + 'scope': 'user' + } + url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) + return url + + def get_access_token_by_code(self, code): + params = { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'grant_type': 'authorization_code', + 'code': code, + + 'redirect_uri': self.callback_url + } + rsp = self.do_post(self.TOKEN_URL, params) + + from urllib import parse + r = parse.parse_qs(rsp) + if 'access_token' in r: + self.access_token = (r['access_token'][0]) + return self.access_token + else: + raise OAuthAccessTokenException(rsp) + + def get_oauth_userinfo(self): + + rsp = self.do_get(self.API_URL, params={}, headers={ + "Authorization": "token " + self.access_token + }) + try: + datas = json.loads(rsp) + user = OAuthUser() + user.picture = datas['avatar_url'] + user.nickname = datas['name'] + user.openid = datas['id'] + user.type = 'github' + user.token = self.access_token + user.metadata = rsp + if 'email' in datas and datas['email']: + user.email = datas['email'] + return user + except Exception as e: + logger.error(e) + logger.error('github oauth error.rsp:' + rsp) + return None + + def get_picture(self, metadata): + datas = json.loads(metadata) + return datas['avatar_url'] + + +class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): + AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth' + TOKEN_URL = 'https://graph.facebook.com/v16.0/oauth/access_token' + API_URL = 'https://graph.facebook.com/me' + ICON_NAME = 'facebook' + + def __init__(self, access_token=None, openid=None): + config = self.get_config() + self.client_id = config.appkey if config else '' + self.client_secret = config.appsecret if config else '' + self.callback_url = config.callback_url if config else '' + super( + FaceBookOauthManager, + self).__init__( + access_token=access_token, + openid=openid) + + def get_authorization_url(self, next_url='/'): + params = { + 'client_id': self.client_id, + 'response_type': 'code', + 'redirect_uri': self.callback_url, + 'scope': 'email,public_profile' + } + url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) + return url + + def get_access_token_by_code(self, code): + params = { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + # 'grant_type': 'authorization_code', + 'code': code, + + 'redirect_uri': self.callback_url + } + rsp = self.do_post(self.TOKEN_URL, params) + + obj = json.loads(rsp) + if 'access_token' in obj: + token = str(obj['access_token']) + self.access_token = token + return self.access_token + else: + raise OAuthAccessTokenException(rsp) + + def get_oauth_userinfo(self): + params = { + 'access_token': self.access_token, + 'fields': 'id,name,picture,email' + } + try: + rsp = self.do_get(self.API_URL, params) + datas = json.loads(rsp) + user = OAuthUser() + user.nickname = datas['name'] + user.openid = datas['id'] + user.type = 'facebook' + user.token = self.access_token + user.metadata = rsp + if 'email' in datas and datas['email']: + user.email = datas['email'] + if 'picture' in datas and datas['picture'] and datas['picture']['data'] and datas['picture']['data']['url']: + user.picture = str(datas['picture']['data']['url']) + return user + except Exception as e: + logger.error(e) + return None + + def get_picture(self, metadata): + datas = json.loads(metadata) + return str(datas['picture']['data']['url']) + + +class QQOauthManager(BaseOauthManager): + AUTH_URL = 'https://graph.qq.com/oauth2.0/authorize' + TOKEN_URL = 'https://graph.qq.com/oauth2.0/token' + API_URL = 'https://graph.qq.com/user/get_user_info' + OPEN_ID_URL = 'https://graph.qq.com/oauth2.0/me' + ICON_NAME = 'qq' + + def __init__(self, access_token=None, openid=None): + config = self.get_config() + self.client_id = config.appkey if config else '' + self.client_secret = config.appsecret if config else '' + self.callback_url = config.callback_url if config else '' + super( + QQOauthManager, + self).__init__( + access_token=access_token, + openid=openid) + + def get_authorization_url(self, next_url='/'): + params = { + 'response_type': 'code', + 'client_id': self.client_id, + 'redirect_uri': self.callback_url + '&next_url=' + next_url, + } + url = self.AUTH_URL + "?" + urllib.parse.urlencode(params) + return url + + def get_access_token_by_code(self, code): + params = { + 'grant_type': 'authorization_code', + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'redirect_uri': self.callback_url + } + rsp = self.do_get(self.TOKEN_URL, params) + if rsp: + d = urllib.parse.parse_qs(rsp) + if 'access_token' in d: + token = d['access_token'] + self.access_token = token[0] + return token + else: + raise OAuthAccessTokenException(rsp) + + def get_open_id(self): + if self.is_access_token_set: + params = { + 'access_token': self.access_token + } + rsp = self.do_get(self.OPEN_ID_URL, params) + if rsp: + rsp = rsp.replace( + 'callback(', '').replace( + ')', '').replace( + ';', '') + obj = json.loads(rsp) + openid = str(obj['openid']) + self.openid = openid + return openid + + def get_oauth_userinfo(self): + openid = self.get_open_id() + if openid: + params = { + 'access_token': self.access_token, + 'oauth_consumer_key': self.client_id, + 'openid': self.openid + } + rsp = self.do_get(self.API_URL, params) + logger.info(rsp) + obj = json.loads(rsp) + user = OAuthUser() + user.nickname = obj['nickname'] + user.openid = openid + user.type = 'qq' + user.token = self.access_token + user.metadata = rsp + if 'email' in obj: + user.email = obj['email'] + if 'figureurl' in obj: + user.picture = str(obj['figureurl']) + return user + + def get_picture(self, metadata): + datas = json.loads(metadata) + return str(datas['figureurl']) + + +@cache_decorator(expiration=100 * 60) +def get_oauth_apps(): + configs = OAuthConfig.objects.filter(is_enable=True).all() + if not configs: + return [] + configtypes = [x.type for x in configs] + applications = BaseOauthManager.__subclasses__() + apps = [x() for x in applications if x().ICON_NAME.lower() in configtypes] + return apps + + +def get_manager_by_type(type): + applications = get_oauth_apps() + if applications: + finds = list( + filter( + lambda x: x.ICON_NAME.lower() == type.lower(), + applications)) + if finds: + return finds[0] + return None diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/oauth/templatetags/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/templatetags/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/templatetags/__init__.py @@ -0,0 +1 @@ + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/oauth/templatetags/oauth_tags.py b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/templatetags/oauth_tags.py new file mode 100644 index 00000000..7b687d58 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/templatetags/oauth_tags.py @@ -0,0 +1,22 @@ +from django import template +from django.urls import reverse + +from oauth.oauthmanager import get_oauth_apps + +register = template.Library() + + +@register.inclusion_tag('oauth/oauth_applications.html') +def load_oauth_applications(request): + applications = get_oauth_apps() + if applications: + baseurl = reverse('oauth:oauthlogin') + path = request.get_full_path() + + apps = list(map(lambda x: (x.ICON_NAME, '{baseurl}?type={type}&next_url={next}'.format( + baseurl=baseurl, type=x.ICON_NAME, next=path)), applications)) + else: + apps = [] + return { + 'apps': apps + } diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/oauth/tests.py b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/tests.py new file mode 100644 index 00000000..bb23b9ba --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/tests.py @@ -0,0 +1,249 @@ +import json +from unittest.mock import patch + +from django.conf import settings +from django.contrib import auth +from django.test import Client, RequestFactory, TestCase +from django.urls import reverse + +from djangoblog.utils import get_sha256 +from oauth.models import OAuthConfig +from oauth.oauthmanager import BaseOauthManager + + +# Create your tests here. +class OAuthConfigTest(TestCase): + def setUp(self): + self.client = Client() + self.factory = RequestFactory() + + def test_oauth_login_test(self): + c = OAuthConfig() + c.type = 'weibo' + c.appkey = 'appkey' + c.appsecret = 'appsecret' + c.save() + + response = self.client.get('/oauth/oauthlogin?type=weibo') + self.assertEqual(response.status_code, 302) + self.assertTrue("api.weibo.com" in response.url) + + response = self.client.get('/oauth/authorize?type=weibo&code=code') + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, '/') + + +class OauthLoginTest(TestCase): + def setUp(self) -> None: + self.client = Client() + self.factory = RequestFactory() + self.apps = self.init_apps() + + def init_apps(self): + applications = [p() for p in BaseOauthManager.__subclasses__()] + for application in applications: + c = OAuthConfig() + c.type = application.ICON_NAME.lower() + c.appkey = 'appkey' + c.appsecret = 'appsecret' + c.save() + return applications + + def get_app_by_type(self, type): + for app in self.apps: + if app.ICON_NAME.lower() == type: + return app + + @patch("oauth.oauthmanager.WBOauthManager.do_post") + @patch("oauth.oauthmanager.WBOauthManager.do_get") + def test_weibo_login(self, mock_do_get, mock_do_post): + weibo_app = self.get_app_by_type('weibo') + assert weibo_app + url = weibo_app.get_authorization_url() + mock_do_post.return_value = json.dumps({"access_token": "access_token", + "uid": "uid" + }) + mock_do_get.return_value = json.dumps({ + "avatar_large": "avatar_large", + "screen_name": "screen_name", + "id": "id", + "email": "email", + }) + userinfo = weibo_app.get_access_token_by_code('code') + self.assertEqual(userinfo.token, 'access_token') + self.assertEqual(userinfo.openid, 'id') + + @patch("oauth.oauthmanager.GoogleOauthManager.do_post") + @patch("oauth.oauthmanager.GoogleOauthManager.do_get") + def test_google_login(self, mock_do_get, mock_do_post): + google_app = self.get_app_by_type('google') + assert google_app + url = google_app.get_authorization_url() + mock_do_post.return_value = json.dumps({ + "access_token": "access_token", + "id_token": "id_token", + }) + mock_do_get.return_value = json.dumps({ + "picture": "picture", + "name": "name", + "sub": "sub", + "email": "email", + }) + token = google_app.get_access_token_by_code('code') + userinfo = google_app.get_oauth_userinfo() + self.assertEqual(userinfo.token, 'access_token') + self.assertEqual(userinfo.openid, 'sub') + + @patch("oauth.oauthmanager.GitHubOauthManager.do_post") + @patch("oauth.oauthmanager.GitHubOauthManager.do_get") + def test_github_login(self, mock_do_get, mock_do_post): + github_app = self.get_app_by_type('github') + assert github_app + url = github_app.get_authorization_url() + self.assertTrue("github.com" in url) + self.assertTrue("client_id" in url) + mock_do_post.return_value = "access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer" + mock_do_get.return_value = json.dumps({ + "avatar_url": "avatar_url", + "name": "name", + "id": "id", + "email": "email", + }) + token = github_app.get_access_token_by_code('code') + userinfo = github_app.get_oauth_userinfo() + self.assertEqual(userinfo.token, 'gho_16C7e42F292c6912E7710c838347Ae178B4a') + self.assertEqual(userinfo.openid, 'id') + + @patch("oauth.oauthmanager.FaceBookOauthManager.do_post") + @patch("oauth.oauthmanager.FaceBookOauthManager.do_get") + def test_facebook_login(self, mock_do_get, mock_do_post): + facebook_app = self.get_app_by_type('facebook') + assert facebook_app + url = facebook_app.get_authorization_url() + self.assertTrue("facebook.com" in url) + mock_do_post.return_value = json.dumps({ + "access_token": "access_token", + }) + mock_do_get.return_value = json.dumps({ + "name": "name", + "id": "id", + "email": "email", + "picture": { + "data": { + "url": "url" + } + } + }) + token = facebook_app.get_access_token_by_code('code') + userinfo = facebook_app.get_oauth_userinfo() + self.assertEqual(userinfo.token, 'access_token') + + @patch("oauth.oauthmanager.QQOauthManager.do_get", side_effect=[ + 'access_token=access_token&expires_in=3600', + 'callback({"client_id":"appid","openid":"openid"} );', + json.dumps({ + "nickname": "nickname", + "email": "email", + "figureurl": "figureurl", + "openid": "openid", + }) + ]) + def test_qq_login(self, mock_do_get): + qq_app = self.get_app_by_type('qq') + assert qq_app + url = qq_app.get_authorization_url() + self.assertTrue("qq.com" in url) + token = qq_app.get_access_token_by_code('code') + userinfo = qq_app.get_oauth_userinfo() + self.assertEqual(userinfo.token, 'access_token') + + @patch("oauth.oauthmanager.WBOauthManager.do_post") + @patch("oauth.oauthmanager.WBOauthManager.do_get") + def test_weibo_authoriz_login_with_email(self, mock_do_get, mock_do_post): + + mock_do_post.return_value = json.dumps({"access_token": "access_token", + "uid": "uid" + }) + mock_user_info = { + "avatar_large": "avatar_large", + "screen_name": "screen_name1", + "id": "id", + "email": "email", + } + mock_do_get.return_value = json.dumps(mock_user_info) + + response = self.client.get('/oauth/oauthlogin?type=weibo') + self.assertEqual(response.status_code, 302) + self.assertTrue("api.weibo.com" in response.url) + + response = self.client.get('/oauth/authorize?type=weibo&code=code') + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, '/') + + user = auth.get_user(self.client) + assert user.is_authenticated + self.assertTrue(user.is_authenticated) + self.assertEqual(user.username, mock_user_info['screen_name']) + self.assertEqual(user.email, mock_user_info['email']) + self.client.logout() + + response = self.client.get('/oauth/authorize?type=weibo&code=code') + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, '/') + + user = auth.get_user(self.client) + assert user.is_authenticated + self.assertTrue(user.is_authenticated) + self.assertEqual(user.username, mock_user_info['screen_name']) + self.assertEqual(user.email, mock_user_info['email']) + + @patch("oauth.oauthmanager.WBOauthManager.do_post") + @patch("oauth.oauthmanager.WBOauthManager.do_get") + def test_weibo_authoriz_login_without_email(self, mock_do_get, mock_do_post): + + mock_do_post.return_value = json.dumps({"access_token": "access_token", + "uid": "uid" + }) + mock_user_info = { + "avatar_large": "avatar_large", + "screen_name": "screen_name1", + "id": "id", + } + mock_do_get.return_value = json.dumps(mock_user_info) + + response = self.client.get('/oauth/oauthlogin?type=weibo') + self.assertEqual(response.status_code, 302) + self.assertTrue("api.weibo.com" in response.url) + + response = self.client.get('/oauth/authorize?type=weibo&code=code') + + self.assertEqual(response.status_code, 302) + + oauth_user_id = int(response.url.split('/')[-1].split('.')[0]) + self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html') + + response = self.client.post(response.url, {'email': 'test@gmail.com', 'oauthid': oauth_user_id}) + + self.assertEqual(response.status_code, 302) + sign = get_sha256(settings.SECRET_KEY + + str(oauth_user_id) + settings.SECRET_KEY) + + url = reverse('oauth:bindsuccess', kwargs={ + 'oauthid': oauth_user_id, + }) + self.assertEqual(response.url, f'{url}?type=email') + + path = reverse('oauth:email_confirm', kwargs={ + 'id': oauth_user_id, + 'sign': sign + }) + response = self.client.get(path) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success') + user = auth.get_user(self.client) + from oauth.models import OAuthUser + oauth_user = OAuthUser.objects.get(author=user) + self.assertTrue(user.is_authenticated) + self.assertEqual(user.username, mock_user_info['screen_name']) + self.assertEqual(user.email, 'test@gmail.com') + self.assertEqual(oauth_user.pk, oauth_user_id) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/oauth/urls.py b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/urls.py new file mode 100644 index 00000000..c4a12a0f --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/urls.py @@ -0,0 +1,25 @@ +from django.urls import path + +from . import views + +app_name = "oauth" +urlpatterns = [ + path( + r'oauth/authorize', + views.authorize), + path( + r'oauth/requireemail/.html', + views.RequireEmailView.as_view(), + name='require_email'), + path( + r'oauth/emailconfirm//.html', + views.emailconfirm, + name='email_confirm'), + path( + r'oauth/bindsuccess/.html', + views.bindsuccess, + name='bindsuccess'), + path( + r'oauth/oauthlogin', + views.oauthlogin, + name='oauthlogin')] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/oauth/views.py b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/views.py new file mode 100644 index 00000000..12e3a6ea --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/oauth/views.py @@ -0,0 +1,253 @@ +import logging +# Create your views here. +from urllib.parse import urlparse + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth import login +from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction +from django.http import HttpResponseForbidden +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404 +from django.shortcuts import render +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django.views.generic import FormView + +from djangoblog.blog_signals import oauth_user_login_signal +from djangoblog.utils import get_current_site +from djangoblog.utils import send_email, get_sha256 +from oauth.forms import RequireEmailForm +from .models import OAuthUser +from .oauthmanager import get_manager_by_type, OAuthAccessTokenException + +logger = logging.getLogger(__name__) + + +def get_redirecturl(request): + nexturl = request.GET.get('next_url', None) + if not nexturl or nexturl == '/login/' or nexturl == '/login': + nexturl = '/' + return nexturl + p = urlparse(nexturl) + if p.netloc: + site = get_current_site().domain + if not p.netloc.replace('www.', '') == site.replace('www.', ''): + logger.info('非法url:' + nexturl) + return "/" + return nexturl + + +def oauthlogin(request): + type = request.GET.get('type', None) + if not type: + return HttpResponseRedirect('/') + manager = get_manager_by_type(type) + if not manager: + return HttpResponseRedirect('/') + nexturl = get_redirecturl(request) + authorizeurl = manager.get_authorization_url(nexturl) + return HttpResponseRedirect(authorizeurl) + + +def authorize(request): + type = request.GET.get('type', None) + if not type: + return HttpResponseRedirect('/') + manager = get_manager_by_type(type) + if not manager: + return HttpResponseRedirect('/') + code = request.GET.get('code', None) + try: + rsp = manager.get_access_token_by_code(code) + except OAuthAccessTokenException as e: + logger.warning("OAuthAccessTokenException:" + str(e)) + return HttpResponseRedirect('/') + except Exception as e: + logger.error(e) + rsp = None + nexturl = get_redirecturl(request) + if not rsp: + return HttpResponseRedirect(manager.get_authorization_url(nexturl)) + user = manager.get_oauth_userinfo() + if user: + if not user.nickname or not user.nickname.strip(): + user.nickname = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') + try: + temp = OAuthUser.objects.get(type=type, openid=user.openid) + temp.picture = user.picture + temp.metadata = user.metadata + temp.nickname = user.nickname + user = temp + except ObjectDoesNotExist: + pass + # facebook的token过长 + if type == 'facebook': + user.token = '' + if user.email: + with transaction.atomic(): + author = None + try: + author = get_user_model().objects.get(id=user.author_id) + except ObjectDoesNotExist: + pass + if not author: + result = get_user_model().objects.get_or_create(email=user.email) + author = result[0] + if result[1]: + try: + get_user_model().objects.get(username=user.nickname) + except ObjectDoesNotExist: + author.username = user.nickname + else: + author.username = "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') + author.source = 'authorize' + author.save() + + user.author = author + user.save() + + oauth_user_login_signal.send( + sender=authorize.__class__, id=user.id) + login(request, author) + return HttpResponseRedirect(nexturl) + else: + user.save() + url = reverse('oauth:require_email', kwargs={ + 'oauthid': user.id + }) + + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(nexturl) + + +def emailconfirm(request, id, sign): + if not sign: + return HttpResponseForbidden() + if not get_sha256(settings.SECRET_KEY + + str(id) + + settings.SECRET_KEY).upper() == sign.upper(): + return HttpResponseForbidden() + oauthuser = get_object_or_404(OAuthUser, pk=id) + with transaction.atomic(): + if oauthuser.author: + author = get_user_model().objects.get(pk=oauthuser.author_id) + else: + result = get_user_model().objects.get_or_create(email=oauthuser.email) + author = result[0] + if result[1]: + author.source = 'emailconfirm' + author.username = oauthuser.nickname.strip() if oauthuser.nickname.strip( + ) else "djangoblog" + timezone.now().strftime('%y%m%d%I%M%S') + author.save() + oauthuser.author = author + oauthuser.save() + oauth_user_login_signal.send( + sender=emailconfirm.__class__, + id=oauthuser.id) + login(request, author) + + site = 'http://' + get_current_site().domain + content = _(''' +

Congratulations, you have successfully bound your email address. You can use + %(oauthuser_type)s to directly log in to this website without a password.

+ You are welcome to continue to follow this site, the address is + %(site)s + Thank you again! +
+ If the link above cannot be opened, please copy this link to your browser. + %(site)s + ''') % {'oauthuser_type': oauthuser.type, 'site': site} + + send_email(emailto=[oauthuser.email, ], title=_('Congratulations on your successful binding!'), content=content) + url = reverse('oauth:bindsuccess', kwargs={ + 'oauthid': id + }) + url = url + '?type=success' + return HttpResponseRedirect(url) + + +class RequireEmailView(FormView): + form_class = RequireEmailForm + template_name = 'oauth/require_email.html' + + def get(self, request, *args, **kwargs): + oauthid = self.kwargs['oauthid'] + oauthuser = get_object_or_404(OAuthUser, pk=oauthid) + if oauthuser.email: + pass + # return HttpResponseRedirect('/') + + return super(RequireEmailView, self).get(request, *args, **kwargs) + + def get_initial(self): + oauthid = self.kwargs['oauthid'] + return { + 'email': '', + 'oauthid': oauthid + } + + def get_context_data(self, **kwargs): + oauthid = self.kwargs['oauthid'] + oauthuser = get_object_or_404(OAuthUser, pk=oauthid) + if oauthuser.picture: + kwargs['picture'] = oauthuser.picture + return super(RequireEmailView, self).get_context_data(**kwargs) + + def form_valid(self, form): + email = form.cleaned_data['email'] + oauthid = form.cleaned_data['oauthid'] + oauthuser = get_object_or_404(OAuthUser, pk=oauthid) + oauthuser.email = email + oauthuser.save() + sign = get_sha256(settings.SECRET_KEY + + str(oauthuser.id) + settings.SECRET_KEY) + site = get_current_site().domain + if settings.DEBUG: + site = '127.0.0.1:8000' + path = reverse('oauth:email_confirm', kwargs={ + 'id': oauthid, + 'sign': sign + }) + url = "http://{site}{path}".format(site=site, path=path) + + content = _(""" +

Please click the link below to bind your email

+ + %(url)s + + Thank you again! +
+ If the link above cannot be opened, please copy this link to your browser. +
+ %(url)s + """) % {'url': url} + send_email(emailto=[email, ], title=_('Bind your email'), content=content) + url = reverse('oauth:bindsuccess', kwargs={ + 'oauthid': oauthid + }) + url = url + '?type=email' + return HttpResponseRedirect(url) + + +def bindsuccess(request, oauthid): + type = request.GET.get('type', None) + oauthuser = get_object_or_404(OAuthUser, pk=oauthid) + if type == 'email': + title = _('Bind your email') + content = _( + 'Congratulations, the binding is just one step away. ' + 'Please log in to your email to check the email to complete the binding. Thank you.') + else: + title = _('Binding successful') + content = _( + "Congratulations, you have successfully bound your email address. You can use %(oauthuser_type)s" + " to directly log in to this website without a password. You are welcome to continue to follow this site." % { + 'oauthuser_type': oauthuser.type}) + return render(request, 'oauth/bindsuccess.html', { + 'title': title, + 'content': content + }) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/admin.py b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/admin.py new file mode 100644 index 00000000..655b5358 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin + +# Register your models here. + + +class OwnTrackLogsAdmin(admin.ModelAdmin): + pass diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/apps.py b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/apps.py new file mode 100644 index 00000000..1bc5f129 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class OwntracksConfig(AppConfig): + name = 'owntracks' diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/migrations/0001_initial.py b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/migrations/0001_initial.py new file mode 100644 index 00000000..9eee55c0 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 4.1.7 on 2023-03-02 07:14 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='OwnTrackLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tid', models.CharField(max_length=100, verbose_name='用户')), + ('lat', models.FloatField(verbose_name='纬度')), + ('lon', models.FloatField(verbose_name='经度')), + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + ], + options={ + 'verbose_name': 'OwnTrackLogs', + 'verbose_name_plural': 'OwnTrackLogs', + 'ordering': ['created_time'], + 'get_latest_by': 'created_time', + }, + ), + ] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/migrations/0002_alter_owntracklog_options_and_more.py b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/migrations/0002_alter_owntracklog_options_and_more.py new file mode 100644 index 00000000..b4f8decc --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/migrations/0002_alter_owntracklog_options_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.5 on 2023-09-06 13:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('owntracks', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='owntracklog', + options={'get_latest_by': 'creation_time', 'ordering': ['creation_time'], 'verbose_name': 'OwnTrackLogs', 'verbose_name_plural': 'OwnTrackLogs'}, + ), + migrations.RenameField( + model_name='owntracklog', + old_name='created_time', + new_name='creation_time', + ), + ] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/migrations/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/models.py b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/models.py new file mode 100644 index 00000000..760942c6 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/models.py @@ -0,0 +1,20 @@ +from django.db import models +from django.utils.timezone import now + + +# Create your models here. + +class OwnTrackLog(models.Model): + tid = models.CharField(max_length=100, null=False, verbose_name='用户') + lat = models.FloatField(verbose_name='纬度') + lon = models.FloatField(verbose_name='经度') + creation_time = models.DateTimeField('创建时间', default=now) + + def __str__(self): + return self.tid + + class Meta: + ordering = ['creation_time'] + verbose_name = "OwnTrackLogs" + verbose_name_plural = verbose_name + get_latest_by = 'creation_time' diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/tests.py b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/tests.py new file mode 100644 index 00000000..3b4b9d8f --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/tests.py @@ -0,0 +1,64 @@ +import json + +from django.test import Client, RequestFactory, TestCase + +from accounts.models import BlogUser +from .models import OwnTrackLog + + +# Create your tests here. + +class OwnTrackLogTest(TestCase): + def setUp(self): + self.client = Client() + self.factory = RequestFactory() + + def test_own_track_log(self): + o = { + 'tid': 12, + 'lat': 123.123, + 'lon': 134.341 + } + + self.client.post( + '/owntracks/logtracks', + json.dumps(o), + content_type='application/json') + length = len(OwnTrackLog.objects.all()) + self.assertEqual(length, 1) + + o = { + 'tid': 12, + 'lat': 123.123 + } + + self.client.post( + '/owntracks/logtracks', + json.dumps(o), + content_type='application/json') + length = len(OwnTrackLog.objects.all()) + self.assertEqual(length, 1) + + rsp = self.client.get('/owntracks/show_maps') + self.assertEqual(rsp.status_code, 302) + + user = BlogUser.objects.create_superuser( + email="liangliangyy1@gmail.com", + username="liangliangyy1", + password="liangliangyy1") + + self.client.login(username='liangliangyy1', password='liangliangyy1') + s = OwnTrackLog() + s.tid = 12 + s.lon = 123.234 + s.lat = 34.234 + s.save() + + rsp = self.client.get('/owntracks/show_dates') + self.assertEqual(rsp.status_code, 200) + rsp = self.client.get('/owntracks/show_maps') + self.assertEqual(rsp.status_code, 200) + rsp = self.client.get('/owntracks/get_datas') + self.assertEqual(rsp.status_code, 200) + rsp = self.client.get('/owntracks/get_datas?date=2018-02-26') + self.assertEqual(rsp.status_code, 200) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/urls.py b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/urls.py new file mode 100644 index 00000000..c19ada87 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views + +app_name = "owntracks" + +urlpatterns = [ + path('owntracks/logtracks', views.manage_owntrack_log, name='logtracks'), + path('owntracks/show_maps', views.show_maps, name='show_maps'), + path('owntracks/get_datas', views.get_datas, name='get_datas'), + path('owntracks/show_dates', views.show_log_dates, name='show_dates') +] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/views.py b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/views.py new file mode 100644 index 00000000..4c72bdd1 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/owntracks/views.py @@ -0,0 +1,127 @@ +# Create your views here. +import datetime +import itertools +import json +import logging +from datetime import timezone +from itertools import groupby + +import django +import requests +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse +from django.http import JsonResponse +from django.shortcuts import render +from django.views.decorators.csrf import csrf_exempt + +from .models import OwnTrackLog + +logger = logging.getLogger(__name__) + + +@csrf_exempt +def manage_owntrack_log(request): + try: + s = json.loads(request.read().decode('utf-8')) + tid = s['tid'] + lat = s['lat'] + lon = s['lon'] + + logger.info( + 'tid:{tid}.lat:{lat}.lon:{lon}'.format( + tid=tid, lat=lat, lon=lon)) + if tid and lat and lon: + m = OwnTrackLog() + m.tid = tid + m.lat = lat + m.lon = lon + m.save() + return HttpResponse('ok') + else: + return HttpResponse('data error') + except Exception as e: + logger.error(e) + return HttpResponse('error') + + +@login_required +def show_maps(request): + if request.user.is_superuser: + defaultdate = str(datetime.datetime.now(timezone.utc).date()) + date = request.GET.get('date', defaultdate) + context = { + 'date': date + } + return render(request, 'owntracks/show_maps.html', context) + else: + from django.http import HttpResponseForbidden + return HttpResponseForbidden() + + +@login_required +def show_log_dates(request): + dates = OwnTrackLog.objects.values_list('creation_time', flat=True) + results = list(sorted(set(map(lambda x: x.strftime('%Y-%m-%d'), dates)))) + + context = { + 'results': results + } + return render(request, 'owntracks/show_log_dates.html', context) + + +def convert_to_amap(locations): + convert_result = [] + it = iter(locations) + + item = list(itertools.islice(it, 30)) + while item: + datas = ';'.join( + set(map(lambda x: str(x.lon) + ',' + str(x.lat), item))) + + key = '8440a376dfc9743d8924bf0ad141f28e' + api = 'http://restapi.amap.com/v3/assistant/coordinate/convert' + query = { + 'key': key, + 'locations': datas, + 'coordsys': 'gps' + } + rsp = requests.get(url=api, params=query) + result = json.loads(rsp.text) + if "locations" in result: + convert_result.append(result['locations']) + item = list(itertools.islice(it, 30)) + + return ";".join(convert_result) + + +@login_required +def get_datas(request): + now = django.utils.timezone.now().replace(tzinfo=timezone.utc) + querydate = django.utils.timezone.datetime( + now.year, now.month, now.day, 0, 0, 0) + if request.GET.get('date', None): + date = list(map(lambda x: int(x), request.GET.get('date').split('-'))) + querydate = django.utils.timezone.datetime( + date[0], date[1], date[2], 0, 0, 0) + nextdate = querydate + datetime.timedelta(days=1) + models = OwnTrackLog.objects.filter( + creation_time__range=(querydate, nextdate)) + result = list() + if models and len(models): + for tid, item in groupby( + sorted(models, key=lambda k: k.tid), key=lambda k: k.tid): + + d = dict() + d["name"] = tid + paths = list() + # 使用高德转换后的经纬度 + # locations = convert_to_amap( + # sorted(item, key=lambda x: x.creation_time)) + # for i in locations.split(';'): + # paths.append(i.split(',')) + # 使用GPS原始经纬度 + for location in sorted(item, key=lambda x: x.creation_time): + paths.append([str(location.lon), str(location.lat)]) + d["path"] = paths + result.append(d) + return JsonResponse(result, safe=False) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/plugins/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/__init__.py new file mode 100644 index 00000000..e88afca2 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/plugins/article_copyright/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/article_copyright/__init__.py new file mode 100644 index 00000000..e88afca2 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/article_copyright/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/plugins/article_copyright/plugin.py b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/article_copyright/plugin.py new file mode 100644 index 00000000..317fed27 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/article_copyright/plugin.py @@ -0,0 +1,32 @@ +from djangoblog.plugin_manage.base_plugin import BasePlugin +from djangoblog.plugin_manage import hooks +from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME + + +class ArticleCopyrightPlugin(BasePlugin): + PLUGIN_NAME = '文章结尾版权声明' + PLUGIN_DESCRIPTION = '一个在文章正文末尾添加版权声明的插件。' + PLUGIN_VERSION = '0.2.0' + PLUGIN_AUTHOR = 'liangliangyy' + + # 2. 实现 register_hooks 方法,专门用于注册钩子 + def register_hooks(self): + # 在这里将插件的方法注册到指定的钩子上 + hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_copyright_to_content) + + def add_copyright_to_content(self, content, *args, **kwargs): + """ + 这个方法会被注册到 'the_content' 过滤器钩子上。 + 它接收原始内容,并返回添加了版权信息的新内容。 + """ + article = kwargs.get('article') + if not article: + return content + + copyright_info = f"\n

本文由 {article.author.username} 原创,转载请注明出处。

" + return content + copyright_info + + +# 3. 实例化插件。 +# 这会自动调用 BasePlugin.__init__,然后 BasePlugin.__init__ 会调用我们上面定义的 register_hooks 方法。 +plugin = ArticleCopyrightPlugin() diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/plugins/external_links/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/external_links/__init__.py new file mode 100644 index 00000000..e88afca2 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/external_links/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/plugins/external_links/plugin.py b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/external_links/plugin.py new file mode 100644 index 00000000..5b2ef14f --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/external_links/plugin.py @@ -0,0 +1,48 @@ +import re +from urllib.parse import urlparse +from djangoblog.plugin_manage.base_plugin import BasePlugin +from djangoblog.plugin_manage import hooks +from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME + + +class ExternalLinksPlugin(BasePlugin): + PLUGIN_NAME = '外部链接处理器' + PLUGIN_DESCRIPTION = '自动为文章中的外部链接添加 target="_blank" 和 rel="noopener noreferrer" 属性。' + PLUGIN_VERSION = '0.1.0' + PLUGIN_AUTHOR = 'liangliangyy' + + def register_hooks(self): + hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.process_external_links) + + def process_external_links(self, content, *args, **kwargs): + from djangoblog.utils import get_current_site + site_domain = get_current_site().domain + + # 正则表达式查找所有 标签 + link_pattern = re.compile(r'(]*?\s+)?href=")([^"]*)(".*?/a>)', re.IGNORECASE) + + def replacer(match): + # match.group(1) 是 ... + href = match.group(2) + + # 如果链接已经有 target 属性,则不处理 + if 'target=' in match.group(0).lower(): + return match.group(0) + + # 解析链接 + parsed_url = urlparse(href) + + # 如果链接是外部的 (有域名且域名不等于当前网站域名) + if parsed_url.netloc and parsed_url.netloc != site_domain: + # 添加 target 和 rel 属性 + return f'{match.group(1)}{href}" target="_blank" rel="noopener noreferrer"{match.group(3)}' + + # 否则返回原样 + return match.group(0) + + return link_pattern.sub(replacer, content) + + +plugin = ExternalLinksPlugin() diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/plugins/reading_time/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/reading_time/__init__.py new file mode 100644 index 00000000..e88afca2 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/reading_time/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/plugins/reading_time/plugin.py b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/reading_time/plugin.py new file mode 100644 index 00000000..35f9db12 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/reading_time/plugin.py @@ -0,0 +1,43 @@ +import math +import re +from djangoblog.plugin_manage.base_plugin import BasePlugin +from djangoblog.plugin_manage import hooks +from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME + + +class ReadingTimePlugin(BasePlugin): + PLUGIN_NAME = '阅读时间预测' + PLUGIN_DESCRIPTION = '估算文章阅读时间并显示在文章开头。' + PLUGIN_VERSION = '0.1.0' + PLUGIN_AUTHOR = 'liangliangyy' + + def register_hooks(self): + hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_reading_time) + + def add_reading_time(self, content, *args, **kwargs): + """ + 计算阅读时间并添加到内容开头。 + """ + # 移除HTML标签和空白字符,以获得纯文本 + clean_content = re.sub(r'<[^>]*>', '', content) + clean_content = clean_content.strip() + + # 中文和英文单词混合计数的一个简单方法 + # 匹配中文字符或连续的非中文字符(视为单词) + words = re.findall(r'[\u4e00-\u9fa5]|\w+', clean_content) + word_count = len(words) + + # 按平均每分钟200字的速度计算 + reading_speed = 200 + reading_minutes = math.ceil(word_count / reading_speed) + + # 如果阅读时间少于1分钟,则显示为1分钟 + if reading_minutes < 1: + reading_minutes = 1 + + reading_time_html = f'

预计阅读时间:{reading_minutes} 分钟

' + + return reading_time_html + content + + +plugin = ReadingTimePlugin() \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/plugins/seo_optimizer/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/seo_optimizer/__init__.py new file mode 100644 index 00000000..e88afca2 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/seo_optimizer/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/plugins/seo_optimizer/plugin.py b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/seo_optimizer/plugin.py new file mode 100644 index 00000000..b5b19a33 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/seo_optimizer/plugin.py @@ -0,0 +1,142 @@ +import json +from django.utils.html import strip_tags +from django.template.defaultfilters import truncatewords +from djangoblog.plugin_manage.base_plugin import BasePlugin +from djangoblog.plugin_manage import hooks +from blog.models import Article, Category, Tag +from djangoblog.utils import get_blog_setting + + +class SeoOptimizerPlugin(BasePlugin): + PLUGIN_NAME = 'SEO 优化器' + PLUGIN_DESCRIPTION = '为文章、页面等提供 SEO 优化,动态生成 meta 标签和 JSON-LD 结构化数据。' + PLUGIN_VERSION = '0.2.0' + PLUGIN_AUTHOR = 'liuangliangyy' + + def register_hooks(self): + hooks.register('head_meta', self.dispatch_seo_generation) + + def _get_article_seo_data(self, context, request, blog_setting): + article = context.get('article') + if not isinstance(article, Article): + return None + + description = strip_tags(article.body)[:150] + keywords = ",".join([tag.name for tag in article.tags.all()]) or blog_setting.site_keywords + + meta_tags = f''' + + + + + + + + + ''' + for tag in article.tags.all(): + meta_tags += f'' + meta_tags += f'' + + structured_data = { + "@context": "https://schema.org", + "@type": "Article", + "mainEntityOfPage": {"@type": "WebPage", "@id": request.build_absolute_uri()}, + "headline": article.title, + "description": description, + "image": request.build_absolute_uri(article.get_first_image_url()), + "datePublished": article.pub_time.isoformat(), + "dateModified": article.last_modify_time.isoformat(), + "author": {"@type": "Person", "name": article.author.username}, + "publisher": {"@type": "Organization", "name": blog_setting.site_name} + } + if not structured_data.get("image"): + del structured_data["image"] + + return { + "title": f"{article.title} | {blog_setting.site_name}", + "description": description, + "keywords": keywords, + "meta_tags": meta_tags, + "json_ld": structured_data + } + + def _get_category_seo_data(self, context, request, blog_setting): + category_name = context.get('tag_name') + if not category_name: + return None + + category = Category.objects.filter(name=category_name).first() + if not category: + return None + + title = f"{category.name} | {blog_setting.site_name}" + description = strip_tags(category.name) or blog_setting.site_description + keywords = category.name + + # BreadcrumbList structured data for category page + breadcrumb_items = [{"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')}] + breadcrumb_items.append({"@type": "ListItem", "position": 2, "name": category.name, "item": request.build_absolute_uri()}) + + structured_data = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + "itemListElement": breadcrumb_items + } + + return { + "title": title, + "description": description, + "keywords": keywords, + "meta_tags": "", + "json_ld": structured_data + } + + def _get_default_seo_data(self, context, request, blog_setting): + # Homepage and other default pages + structured_data = { + "@context": "https://schema.org", + "@type": "WebSite", + "url": request.build_absolute_uri('/'), + "potentialAction": { + "@type": "SearchAction", + "target": f"{request.build_absolute_uri('/search/')}?q={{search_term_string}}", + "query-input": "required name=search_term_string" + } + } + return { + "title": f"{blog_setting.site_name} | {blog_setting.site_description}", + "description": blog_setting.site_description, + "keywords": blog_setting.site_keywords, + "meta_tags": "", + "json_ld": structured_data + } + + def dispatch_seo_generation(self, metas, context): + request = context.get('request') + if not request: + return metas + + view_name = request.resolver_match.view_name + blog_setting = get_blog_setting() + + seo_data = None + if view_name == 'blog:detailbyid': + seo_data = self._get_article_seo_data(context, request, blog_setting) + elif view_name == 'blog:category_detail': + seo_data = self._get_category_seo_data(context, request, blog_setting) + + if not seo_data: + seo_data = self._get_default_seo_data(context, request, blog_setting) + + json_ld_script = f'' + + return f""" + {seo_data.get("title", "")} + + + {seo_data.get("meta_tags", "")} + {json_ld_script} + """ + +plugin = SeoOptimizerPlugin() diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/plugins/view_count/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/view_count/__init__.py new file mode 100644 index 00000000..8804fdf8 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/view_count/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/plugins/view_count/plugin.py b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/view_count/plugin.py new file mode 100644 index 00000000..15e9d94e --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/plugins/view_count/plugin.py @@ -0,0 +1,18 @@ +from djangoblog.plugin_manage.base_plugin import BasePlugin +from djangoblog.plugin_manage import hooks + + +class ViewCountPlugin(BasePlugin): + PLUGIN_NAME = '文章浏览次数统计' + PLUGIN_DESCRIPTION = '统计文章的浏览次数' + PLUGIN_VERSION = '0.1.0' + PLUGIN_AUTHOR = 'liangliangyy' + + def register_hooks(self): + hooks.register('after_article_body_get', self.record_view) + + def record_view(self, article, *args, **kwargs): + article.viewed() + + +plugin = ViewCountPlugin() \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/requirements.txt b/src/DjangoBlog-NEW/DjangoBlog-master/requirements.txt new file mode 100644 index 00000000..9dc5c935 Binary files /dev/null and b/src/DjangoBlog-NEW/DjangoBlog-master/requirements.txt differ diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/MemcacheStorage.py b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/MemcacheStorage.py new file mode 100644 index 00000000..38a7990b --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/MemcacheStorage.py @@ -0,0 +1,32 @@ +from werobot.session import SessionStorage +from werobot.utils import json_loads, json_dumps + +from djangoblog.utils import cache + + +class MemcacheStorage(SessionStorage): + def __init__(self, prefix='ws_'): + self.prefix = prefix + self.cache = cache + + @property + def is_available(self): + value = "1" + self.set('checkavaliable', value=value) + return value == self.get('checkavaliable') + + def key_name(self, s): + return '{prefix}{s}'.format(prefix=self.prefix, s=s) + + def get(self, id): + id = self.key_name(id) + session_json = self.cache.get(id) or '{}' + return json_loads(session_json) + + def set(self, id, value): + id = self.key_name(id) + self.cache.set(id, json_dumps(value)) + + def delete(self, id): + id = self.key_name(id) + self.cache.delete(id) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/admin.py b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/admin.py new file mode 100644 index 00000000..f26f4f6b --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin +# Register your models here. + + +class CommandsAdmin(admin.ModelAdmin): + list_display = ('title', 'command', 'describe') + + +class EmailSendLogAdmin(admin.ModelAdmin): + list_display = ('title', 'emailto', 'send_result', 'creation_time') + readonly_fields = ( + 'title', + 'emailto', + 'send_result', + 'creation_time', + 'content') + + def has_add_permission(self, request): + return False diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/api/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/api/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/api/__init__.py @@ -0,0 +1 @@ + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/api/blogapi.py b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/api/blogapi.py new file mode 100644 index 00000000..8a4d6ac4 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/api/blogapi.py @@ -0,0 +1,27 @@ +from haystack.query import SearchQuerySet + +from blog.models import Article, Category + + +class BlogApi: + def __init__(self): + self.searchqueryset = SearchQuerySet() + self.searchqueryset.auto_query('') + self.__max_takecount__ = 8 + + def search_articles(self, query): + sqs = self.searchqueryset.auto_query(query) + sqs = sqs.load_all() + return sqs[:self.__max_takecount__] + + def get_category_lists(self): + return Category.objects.all() + + def get_category_articles(self, categoryname): + articles = Article.objects.filter(category__name=categoryname) + if articles: + return articles[:self.__max_takecount__] + return None + + def get_recent_articles(self): + return Article.objects.all()[:self.__max_takecount__] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/api/commonapi.py b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/api/commonapi.py new file mode 100644 index 00000000..83ad9ff2 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/api/commonapi.py @@ -0,0 +1,64 @@ +import logging +import os + +import openai + +from servermanager.models import commands + +logger = logging.getLogger(__name__) + +openai.api_key = os.environ.get('OPENAI_API_KEY') +if os.environ.get('HTTP_PROXY'): + openai.proxy = os.environ.get('HTTP_PROXY') + + +class ChatGPT: + + @staticmethod + def chat(prompt): + try: + completion = openai.ChatCompletion.create(model="gpt-3.5-turbo", + messages=[{"role": "user", "content": prompt}]) + return completion.choices[0].message.content + except Exception as e: + logger.error(e) + return "服务器出错了" + + +class CommandHandler: + def __init__(self): + self.commands = commands.objects.all() + + def run(self, title): + """ + 运行命令 + :param title: 命令 + :return: 返回命令执行结果 + """ + cmd = list( + filter( + lambda x: x.title.upper() == title.upper(), + self.commands)) + if cmd: + return self.__run_command__(cmd[0].command) + else: + return "未找到相关命令,请输入hepme获得帮助。" + + def __run_command__(self, cmd): + try: + res = os.popen(cmd).read() + return res + except BaseException: + return '命令执行出错!' + + def get_help(self): + rsp = '' + for cmd in self.commands: + rsp += '{c}:{d}\n'.format(c=cmd.title, d=cmd.describe) + return rsp + + +if __name__ == '__main__': + chatbot = ChatGPT() + prompt = "写一篇1000字关于AI的论文" + print(chatbot.chat(prompt)) diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/apps.py b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/apps.py new file mode 100644 index 00000000..03cc38d5 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ServermanagerConfig(AppConfig): + name = 'servermanager' diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/migrations/0001_initial.py b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/migrations/0001_initial.py new file mode 100644 index 00000000..bbdbf775 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 4.1.7 on 2023-03-02 07:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='commands', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=300, verbose_name='命令标题')), + ('command', models.CharField(max_length=2000, verbose_name='命令')), + ('describe', models.CharField(max_length=300, verbose_name='命令描述')), + ('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('last_mod_time', models.DateTimeField(auto_now=True, verbose_name='修改时间')), + ], + options={ + 'verbose_name': '命令', + 'verbose_name_plural': '命令', + }, + ), + migrations.CreateModel( + name='EmailSendLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('emailto', models.CharField(max_length=300, verbose_name='收件人')), + ('title', models.CharField(max_length=2000, verbose_name='邮件标题')), + ('content', models.TextField(verbose_name='邮件内容')), + ('send_result', models.BooleanField(default=False, verbose_name='结果')), + ('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ], + options={ + 'verbose_name': '邮件发送log', + 'verbose_name_plural': '邮件发送log', + 'ordering': ['-created_time'], + }, + ), + ] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py new file mode 100644 index 00000000..48588574 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.5 on 2023-09-06 13:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('servermanager', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='emailsendlog', + options={'ordering': ['-creation_time'], 'verbose_name': '邮件发送log', 'verbose_name_plural': '邮件发送log'}, + ), + migrations.RenameField( + model_name='commands', + old_name='created_time', + new_name='creation_time', + ), + migrations.RenameField( + model_name='commands', + old_name='last_mod_time', + new_name='last_modify_time', + ), + migrations.RenameField( + model_name='emailsendlog', + old_name='created_time', + new_name='creation_time', + ), + ] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/migrations/__init__.py b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/models.py b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/models.py new file mode 100644 index 00000000..4326c658 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/models.py @@ -0,0 +1,33 @@ +from django.db import models + + +# Create your models here. +class commands(models.Model): + title = models.CharField('命令标题', max_length=300) + command = models.CharField('命令', max_length=2000) + describe = models.CharField('命令描述', max_length=300) + creation_time = models.DateTimeField('创建时间', auto_now_add=True) + last_modify_time = models.DateTimeField('修改时间', auto_now=True) + + def __str__(self): + return self.title + + class Meta: + verbose_name = '命令' + verbose_name_plural = verbose_name + + +class EmailSendLog(models.Model): + emailto = models.CharField('收件人', max_length=300) + title = models.CharField('邮件标题', max_length=2000) + content = models.TextField('邮件内容') + send_result = models.BooleanField('结果', default=False) + creation_time = models.DateTimeField('创建时间', auto_now_add=True) + + def __str__(self): + return self.title + + class Meta: + verbose_name = '邮件发送log' + verbose_name_plural = verbose_name + ordering = ['-creation_time'] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/robot.py b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/robot.py new file mode 100644 index 00000000..7b457364 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/robot.py @@ -0,0 +1,187 @@ +import logging +import os +import re + +import jsonpickle +from django.conf import settings +from werobot import WeRoBot +from werobot.replies import ArticlesReply, Article +from werobot.session.filestorage import FileStorage + +from djangoblog.utils import get_sha256 +from servermanager.api.blogapi import BlogApi +from servermanager.api.commonapi import ChatGPT, CommandHandler +from .MemcacheStorage import MemcacheStorage + +robot = WeRoBot(token=os.environ.get('DJANGO_WEROBOT_TOKEN') + or 'lylinux', enable_session=True) +memstorage = MemcacheStorage() +if memstorage.is_available: + robot.config['SESSION_STORAGE'] = memstorage +else: + if os.path.exists(os.path.join(settings.BASE_DIR, 'werobot_session')): + os.remove(os.path.join(settings.BASE_DIR, 'werobot_session')) + robot.config['SESSION_STORAGE'] = FileStorage(filename='werobot_session') + +blogapi = BlogApi() +cmd_handler = CommandHandler() +logger = logging.getLogger(__name__) + + +def convert_to_article_reply(articles, message): + reply = ArticlesReply(message=message) + from blog.templatetags.blog_tags import truncatechars_content + for post in articles: + imgs = re.findall(r'(?:http\:|https\:)?\/\/.*\.(?:png|jpg)', post.body) + imgurl = '' + if imgs: + imgurl = imgs[0] + article = Article( + title=post.title, + description=truncatechars_content(post.body), + img=imgurl, + url=post.get_full_url() + ) + reply.add_article(article) + return reply + + +@robot.filter(re.compile(r"^\?.*")) +def search(message, session): + s = message.content + searchstr = str(s).replace('?', '') + result = blogapi.search_articles(searchstr) + if result: + articles = list(map(lambda x: x.object, result)) + reply = convert_to_article_reply(articles, message) + return reply + else: + return '没有找到相关文章。' + + +@robot.filter(re.compile(r'^category\s*$', re.I)) +def category(message, session): + categorys = blogapi.get_category_lists() + content = ','.join(map(lambda x: x.name, categorys)) + return '所有文章分类目录:' + content + + +@robot.filter(re.compile(r'^recent\s*$', re.I)) +def recents(message, session): + articles = blogapi.get_recent_articles() + if articles: + reply = convert_to_article_reply(articles, message) + return reply + else: + return "暂时还没有文章" + + +@robot.filter(re.compile('^help$', re.I)) +def help(message, session): + return '''欢迎关注! + 默认会与图灵机器人聊天~~ + 你可以通过下面这些命令来获得信息 + ?关键字搜索文章. + 如?python. + category获得文章分类目录及文章数. + category-***获得该分类目录文章 + 如category-python + recent获得最新文章 + help获得帮助. + weather:获得天气 + 如weather:西安 + idcard:获得身份证信息 + 如idcard:61048119xxxxxxxxxx + music:音乐搜索 + 如music:阴天快乐 + PS:以上标点符号都不支持中文标点~~ + ''' + + +@robot.filter(re.compile(r'^weather\:.*$', re.I)) +def weather(message, session): + return "建设中..." + + +@robot.filter(re.compile(r'^idcard\:.*$', re.I)) +def idcard(message, session): + return "建设中..." + + +@robot.handler +def echo(message, session): + handler = MessageHandler(message, session) + return handler.handler() + + +class MessageHandler: + def __init__(self, message, session): + userid = message.source + self.message = message + self.session = session + self.userid = userid + try: + info = session[userid] + self.userinfo = jsonpickle.decode(info) + except Exception as e: + userinfo = WxUserInfo() + self.userinfo = userinfo + + @property + def is_admin(self): + return self.userinfo.isAdmin + + @property + def is_password_set(self): + return self.userinfo.isPasswordSet + + def save_session(self): + info = jsonpickle.encode(self.userinfo) + self.session[self.userid] = info + + def handler(self): + info = self.message.content + + if self.userinfo.isAdmin and info.upper() == 'EXIT': + self.userinfo = WxUserInfo() + self.save_session() + return "退出成功" + if info.upper() == 'ADMIN': + self.userinfo.isAdmin = True + self.save_session() + return "输入管理员密码" + if self.userinfo.isAdmin and not self.userinfo.isPasswordSet: + passwd = settings.WXADMIN + if settings.TESTING: + passwd = '123' + if passwd.upper() == get_sha256(get_sha256(info)).upper(): + self.userinfo.isPasswordSet = True + self.save_session() + return "验证通过,请输入命令或者要执行的命令代码:输入helpme获得帮助" + else: + if self.userinfo.Count >= 3: + self.userinfo = WxUserInfo() + self.save_session() + return "超过验证次数" + self.userinfo.Count += 1 + self.save_session() + return "验证失败,请重新输入管理员密码:" + if self.userinfo.isAdmin and self.userinfo.isPasswordSet: + if self.userinfo.Command != '' and info.upper() == 'Y': + return cmd_handler.run(self.userinfo.Command) + else: + if info.upper() == 'HELPME': + return cmd_handler.get_help() + self.userinfo.Command = info + self.save_session() + return "确认执行: " + info + " 命令?" + + return ChatGPT.chat(info) + + +class WxUserInfo(): + def __init__(self): + self.isAdmin = False + self.isPasswordSet = False + self.Count = 0 + self.Command = '' diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/tests.py b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/tests.py new file mode 100644 index 00000000..22a66892 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/tests.py @@ -0,0 +1,79 @@ +from django.test import Client, RequestFactory, TestCase +from django.utils import timezone +from werobot.messages.messages import TextMessage + +from accounts.models import BlogUser +from blog.models import Category, Article +from servermanager.api.commonapi import ChatGPT +from .models import commands +from .robot import MessageHandler, CommandHandler +from .robot import search, category, recents + + +# Create your tests here. +class ServerManagerTest(TestCase): + def setUp(self): + self.client = Client() + self.factory = RequestFactory() + + def test_chat_gpt(self): + content = ChatGPT.chat("你好") + self.assertIsNotNone(content) + + def test_validate_comment(self): + user = BlogUser.objects.create_superuser( + email="liangliangyy1@gmail.com", + username="liangliangyy1", + password="liangliangyy1") + + self.client.login(username='liangliangyy1', password='liangliangyy1') + + c = Category() + c.name = "categoryccc" + c.save() + + article = Article() + article.title = "nicetitleccc" + article.body = "nicecontentccc" + article.author = user + article.category = c + article.type = 'a' + article.status = 'p' + article.save() + s = TextMessage([]) + s.content = "nice" + rsp = search(s, None) + rsp = category(None, None) + self.assertIsNotNone(rsp) + rsp = recents(None, None) + self.assertTrue(rsp != '暂时还没有文章') + + cmd = commands() + cmd.title = "test" + cmd.command = "ls" + cmd.describe = "test" + cmd.save() + + cmdhandler = CommandHandler() + rsp = cmdhandler.run('test') + self.assertIsNotNone(rsp) + s.source = 'u' + s.content = 'test' + msghandler = MessageHandler(s, {}) + + # msghandler.userinfo.isPasswordSet = True + # msghandler.userinfo.isAdmin = True + msghandler.handler() + s.content = 'y' + msghandler.handler() + s.content = 'idcard:12321233' + msghandler.handler() + s.content = 'weather:上海' + msghandler.handler() + s.content = 'admin' + msghandler.handler() + s.content = '123' + msghandler.handler() + + s.content = 'exit' + msghandler.handler() diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/urls.py b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/urls.py new file mode 100644 index 00000000..8d134d27 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from werobot.contrib.django import make_view + +from .robot import robot + +app_name = "servermanager" +urlpatterns = [ + path(r'robot', make_view(robot)), + +] diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/views.py b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/views.py new file mode 100644 index 00000000..60f00ef0 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/servermanager/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/account/forget_password.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/account/forget_password.html new file mode 100644 index 00000000..33845315 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/account/forget_password.html @@ -0,0 +1,30 @@ +{% extends 'share_layout/base_account.html' %} +{% load i18n %} +{% load static %} +{% block content %} +
+ + + + + +

+ Home Page + | + login page +

+ +
+{% endblock %} \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/account/login.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/account/login.html new file mode 100644 index 00000000..ec111a91 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/account/login.html @@ -0,0 +1,78 @@ +{% extends 'share_layout/base_account.html' %} +{% load static %} +{% load i18n %} +{% block content %} +
+ + + + + +

+ + {% trans 'Create Account' %} + + | + Home Page + | + + {% trans 'Forget Password' %} + +

+ +
+ + +{% endblock %} \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/account/registration_form.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/account/registration_form.html new file mode 100644 index 00000000..65e7549b --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/account/registration_form.html @@ -0,0 +1,29 @@ +{% extends 'share_layout/base_account.html' %} +{% load static %} +{% block content %} +
+ + + + + +

+ Sign In +

+ +
+{% endblock %} \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/account/result.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/account/result.html new file mode 100644 index 00000000..23c90943 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/account/result.html @@ -0,0 +1,27 @@ +{% extends 'share_layout/base.html' %} +{% load i18n %} +{% block header %} + {{ title }} +{% endblock %} +{% block content %} +
+ +
+{% endblock %} \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/article_archives.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/article_archives.html new file mode 100644 index 00000000..959319ee --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/article_archives.html @@ -0,0 +1,60 @@ +{% extends 'share_layout/base.html' %} +{% load blog_tags %} +{% load cache %} +{% load i18n %} +{% block header %} + + {% trans 'article archive' %} | {{ SITE_DESCRIPTION }} + + + + + + + + + +{% endblock %} +{% block content %} +
+
+ +
+ +

{% trans 'article archive' %}

+
+ +
+ + {% regroup article_list by pub_time.year as year_post_group %} +
    + {% for year in year_post_group %} +
  • {{ year.grouper }} {% trans 'year' %} + {% regroup year.list by pub_time.month as month_post_group %} +
      + {% for month in month_post_group %} +
    • {{ month.grouper }} {% trans 'month' %} + +
    • + {% endfor %} +
    +
  • + {% endfor %} +
+
+
+
+ +{% endblock %} + + +{% block sidebar %} + {% load_sidebar user 'i' %} +{% endblock %} + + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/article_detail.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/article_detail.html new file mode 100644 index 00000000..4da08964 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/article_detail.html @@ -0,0 +1,94 @@ +{% extends 'share_layout/base.html' %} +{% load blog_tags %} +{# 新增(第3-5行):加载静态和国际化标签 #} +{% load static %} +{% load i18n %} + +{% block header %} +{% endblock %} + +{# 新增(第9-12行):加载评论系统样式文件 #} +{% block compress_css %} + {{ block.super }} + + +{% endblock %} +{% block content %} +
+
+ {% load_article_detail article False user %} + +
+
+

{% trans 'Ask this article' %}

+ {% trans 'AI assistant ready' %} +
+
+ +
+ +
+
+ +
+ + {% if article.type == 'a' %} + + {% endif %} + +
+ {% if article.comment_status == "o" and OPEN_SITE_COMMENT %} + + + {% include 'comments/tags/comment_list.html' %} + {% if user.is_authenticated %} + {% include 'comments/tags/post_comment.html' %} + {% else %} +
+

您还没有登录,请您登录后发表评论。 +

+ + {% load oauth_tags %} + {% load_oauth_applications request %} + +
+ {% endif %} + {% endif %} +
+ +{% endblock %} + +{% block sidebar %} + {% load_sidebar user "p" %} +{% endblock %} + +{% block compress_js %} + {{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/article_index.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/article_index.html new file mode 100644 index 00000000..0ee6150f --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/article_index.html @@ -0,0 +1,42 @@ +{% extends 'share_layout/base.html' %} +{% load blog_tags %} +{% load cache %} +{% block header %} + {% if tag_name %} + {{ page_type }}:{{ tag_name }} | {{ SITE_DESCRIPTION }} + {% comment %}{% endcomment %} + {% else %} + {{ SITE_NAME }} | {{ SITE_DESCRIPTION }} + {% endif %} + + + + + + + +{% endblock %} +{% block content %} +
+
+ {% if page_type and tag_name %} +
+ +

{{ page_type }}:{{ tag_name }}

+
+ {% endif %} + + {% for article in article_list %} + {% load_article_detail article True user %} + {% endfor %} + {% if is_paginated %} + {% load_pagination_info page_obj page_type tag_name %} + + {% endif %} +
+
+ +{% endblock %} +{% block sidebar %} + {% load_sidebar user linktype %} +{% endblock %} \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/error_page.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/error_page.html new file mode 100644 index 00000000..d41cfb60 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/error_page.html @@ -0,0 +1,45 @@ +{% extends 'share_layout/base.html' %} +{% load blog_tags %} +{% load cache %} +{% block header %} + {% if tag_name %} + {% if statuscode == '404' %} + 404 NotFound + {% elif statuscode == '403' %} + Permission Denied + {% elif statuscode == '500' %} + 500 Error + {% else %} + + {% endif %} + {% comment %}{% endcomment %} + {% else %} + {{ SITE_NAME }} | {{ SITE_DESCRIPTION }} + {% endif %} + + + + + + + +{% endblock %} +{% block content %} +
+
+ +
+

{{ message }}

+
+ +
+
+ +{% endblock %} + + +{% block sidebar %} + {% load_sidebar user 'i' %} +{% endblock %} + + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/links_list.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/links_list.html new file mode 100644 index 00000000..ccecbea7 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/links_list.html @@ -0,0 +1,44 @@ +{% extends 'share_layout/base.html' %} +{% load blog_tags %} +{% load cache %} +{% block header %} + + 友情链接 | {{ SITE_DESCRIPTION }} + + + + + + + + + +{% endblock %} +{% block content %} +
+
+ +
+ +

友情链接

+
+ +
+ +
+
+
+ +{% endblock %} + + +{% block sidebar %} + {% load_sidebar user 'i' %} +{% endblock %} + + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/article_info.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/article_info.html new file mode 100644 index 00000000..3deec44f --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/article_info.html @@ -0,0 +1,74 @@ +{% load blog_tags %} +{% load cache %} +{% load i18n %} +
+
+ +

+ {% if isindex %} + {% if article.article_order > 0 %} + 【{% trans 'pin to top' %}】{{ article.title }} + {% else %} + {{ article.title }} + {% endif %} + + {% else %} + {{ article.title }} + {% endif %} +

+ +
+ {% if article.type == 'a' %} + {% if not isindex %} + {% cache 36000 breadcrumb article.pk %} + {% load_breadcrumb article %} + {% endcache %} + {% endif %} + {% endif %} +
+ +
+ {% if isindex %} + {{ article.body|custom_markdown|escape|truncatechars_content }} +

Read more

+ {% else %} + + {% if article.show_toc %} + {% get_markdown_toc article.body as toc %} + {% trans 'toc' %}: + {{ toc|safe }} + +
+ {% endif %} +
+ + {{ article.body|custom_markdown|escape }} + +
+ {% endif %} + +
+ + {% load_article_metas article user %} + +
\ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/article_meta_info.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/article_meta_info.html new file mode 100644 index 00000000..cb6111c7 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/article_meta_info.html @@ -0,0 +1,59 @@ +{% load i18n %} +{% load blog_tags %} + + + + + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/article_pagination.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/article_pagination.html new file mode 100644 index 00000000..95514ff3 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/article_pagination.html @@ -0,0 +1,17 @@ +{% load i18n %} + \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/article_tag_list.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/article_tag_list.html new file mode 100644 index 00000000..c8ba4740 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/article_tag_list.html @@ -0,0 +1,19 @@ +{% load i18n %} +{% if article_tags_list %} +
+
+ {% trans 'tags' %} +
+
+ + {% for url,count,tag,color in article_tags_list %} + + {{ tag.name }} + {{ count }} + + {% endfor %} + +
+
+{% endif %} diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/breadcrumb.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/breadcrumb.html new file mode 100644 index 00000000..67087d5d --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/breadcrumb.html @@ -0,0 +1,19 @@ + + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/sidebar.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/sidebar.html new file mode 100644 index 00000000..f70544c6 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/blog/tags/sidebar.html @@ -0,0 +1,136 @@ +{% load blog_tags %} +{% load i18n %} + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/comments/tags/comment_item.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/comments/tags/comment_item.html new file mode 100644 index 00000000..ebb03888 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/comments/tags/comment_item.html @@ -0,0 +1,34 @@ +{% load blog_tags %} +
  • +
    + + + +

    {{ comment_item.body|escape|comment_markdown }}

    + +
    + +
  • \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/comments/tags/comment_item_tree.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/comments/tags/comment_item_tree.html new file mode 100644 index 00000000..a0bd18c7 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/comments/tags/comment_item_tree.html @@ -0,0 +1,78 @@ +{% load blog_tags %} +
  • +
    + + + +

    + {% if comment_item.parent_comment %} +

    + {% endif %} +

    + +

    {{ comment_item.body|escape|comment_markdown }}

    + + {# 新增(第37-63行):评论操作按钮(回复、点赞、举报) #} +
    +
    + 回复 +
    + {# 新增(第44-53行):点赞按钮 #} + + {# 新增(第54-62行):举报按钮 #} + +
    +
    + +
  • +{% query article_comments parent_comment=comment_item as cc_comments %} +{% for cc in cc_comments %} + {% with comment_item=cc template_name="comments/tags/comment_item_tree.html" %} + {% if depth >= 1 %} + {% include template_name %} + {% else %} + {% with depth=depth|add:1 %} + {% include template_name %} + {% endwith %} + {% endif %} + {% endwith %} +{% endfor %} \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/comments/tags/comment_list.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/comments/tags/comment_list.html new file mode 100644 index 00000000..0892dbab --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/comments/tags/comment_list.html @@ -0,0 +1,60 @@ + +
    + {% load blog_tags %} + {% load comments_tags %} + {% load cache %} + + + {# 新增(第11-26行):评论排序选项 #} +
    + 排序方式: + + 最新 + + + 最早 + + + 最热 + +
    + {% if article_comments %} +
    +
      + {# {% query article_comments parent_comment=None as parent_comments %}#} + {% for comment_item in p_comments %} + + {% with 0 as depth %} + {% include "comments/tags/comment_item_tree.html" %} + {% endwith %} + {% endfor %} + +
    + +
    +
    + {% endif %} +
    + +
    \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/comments/tags/post_comment.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/comments/tags/post_comment.html new file mode 100644 index 00000000..71043dbb --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/comments/tags/post_comment.html @@ -0,0 +1,203 @@ +
    + +
    +

    发表评论 + +

    +
    {% csrf_token %} +

    + {{ form.body.label_tag }} + + {{ form.body }} + {{ form.body.errors }} +

    + {# 新增(第16-185行):表情包选择器 #} +

    + + +

    + {{ form.parent_comment_id }} +
    + {% if COMMENT_NEED_REVIEW %} + 支持markdown,评论经审核后才会显示。 + {% else %} + 支持markdown。 + {% endif %} + + +
    +
    +
    + +
    + + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/oauth/bindsuccess.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/oauth/bindsuccess.html new file mode 100644 index 00000000..4bee77c6 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/oauth/bindsuccess.html @@ -0,0 +1,22 @@ +{% extends 'share_layout/base.html' %} +{% block header %} + {{ title }} +{% endblock %} +{% block content %} +
    +
    + +
    + +

    {{ content }}

    +
    +
    +
    + + 登录 + | + 回到首页 +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/oauth/oauth_applications.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/oauth/oauth_applications.html new file mode 100644 index 00000000..a841ad26 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/oauth/oauth_applications.html @@ -0,0 +1,13 @@ +{% load i18n %} + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/oauth/require_email.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/oauth/require_email.html new file mode 100644 index 00000000..3adef121 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/oauth/require_email.html @@ -0,0 +1,46 @@ +{% extends 'share_layout/base_account.html' %} + +{% load static %} +{% block content %} +
    + + + + + +

    + 登录 +

    + +
    +{% endblock %} \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/owntracks/show_log_dates.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/owntracks/show_log_dates.html new file mode 100644 index 00000000..7dbba218 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/owntracks/show_log_dates.html @@ -0,0 +1,17 @@ + + + + + 记录日期 + + + +
      + {% for date in results %} +
    • + {{ date }} +
    • + {% endfor %} +
    + + \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/owntracks/show_maps.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/owntracks/show_maps.html new file mode 100644 index 00000000..3aeda362 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/owntracks/show_maps.html @@ -0,0 +1,135 @@ + + + + + + + 运动轨迹 + + + +
    + + + + + + + + \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/search/indexes/blog/article_text.txt b/src/DjangoBlog-NEW/DjangoBlog-master/templates/search/indexes/blog/article_text.txt new file mode 100644 index 00000000..4f9ca767 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/search/indexes/blog/article_text.txt @@ -0,0 +1,3 @@ +{{ object.title }} +{{ object.author.username }} +{{ object.body }} \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/search/search.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/search/search.html new file mode 100644 index 00000000..1404c604 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/search/search.html @@ -0,0 +1,66 @@ +{% extends 'share_layout/base.html' %} +{% load blog_tags %} +{% block header %} + {{ SITE_NAME }} | {{ SITE_DESCRIPTION }} + + + + + + + +{% endblock %} +{% block content %} +
    +
    + {% if query %} +
    + {% if suggestion %} +

    + 已显示 “{{ suggestion }}” 的搜索结果。   + 仍然搜索:{{ query }}
    +

    + {% else %} +

    + 搜索:{{ query }}    +

    + {% endif %} +
    + {% endif %} + {% if query and page.object_list %} + {% for article in page.object_list %} + {% load_article_detail article.object True user %} + {% endfor %} + {% if page.has_previous or page.has_next %} + + + {% endif %} + {% else %} +
    + +

    哎呀,关键字:{{ query }}没有找到结果,要不换个词再试试?

    +
    + {% endif %} +
    +
    +{% endblock %} + + +{% block sidebar %} + {% load_sidebar request.user 'i' %} +{% endblock %} + + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/adsense.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/adsense.html new file mode 100644 index 00000000..8f99c55a --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/adsense.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/base.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/base.html new file mode 100644 index 00000000..da7a75ce --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/base.html @@ -0,0 +1,129 @@ +{% load static %} +{% load cache %} +{% load i18n %} +{% load compress %} + + + + + + + + + + {% block header %} + {% block title %}{{ SITE_NAME }}{% endblock %} + + + {% endblock %} + {% load blog_tags %} + {% head_meta %} + + + + + + + + + + + {% compress css %} + + + + {% comment %}{% endcomment %} + + + + + + {% block compress_css %} + {% endblock %} + {% endcompress %} + {% if GLOBAL_HEADER %} + {{ GLOBAL_HEADER|safe }} + {% endif %} + + + +
    + +
    + + {% block content %} + {% endblock %} + + + {% block sidebar %} + {% endblock %} + + +
    + {% include 'share_layout/footer.html' %} +
    + + +
    + + {% compress js %} + + + + + + + + {% block compress_js %} + {% endblock %} + {% endcompress %} + {% block footer %} + {% endblock %} +
    + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/base_account.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/base_account.html new file mode 100644 index 00000000..73c934d7 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/base_account.html @@ -0,0 +1,53 @@ + + + + {% load static %} + + + + + + + + + {{ SITE_NAME }} | {{ SITE_DESCRIPTION }} + + + + {% load compress %} + {% compress css %} + + + + + + + + + + {% endcompress %} + {% compress js %} + + + {% endcompress %} + + + + + + +{% include 'share_layout/theme_switcher.html' %} +{% block content %} +{% endblock %} + + + + + + + + + \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/footer.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/footer.html new file mode 100644 index 00000000..cd86a295 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/footer.html @@ -0,0 +1,56 @@ + + + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/nav.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/nav.html new file mode 100644 index 00000000..24d4da63 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/nav.html @@ -0,0 +1,30 @@ +{% load i18n %} + + \ No newline at end of file diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/nav_node.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/nav_node.html new file mode 100644 index 00000000..c2668807 --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/nav_node.html @@ -0,0 +1,19 @@ + + + diff --git a/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/theme_switcher.html b/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/theme_switcher.html new file mode 100644 index 00000000..1dec52cb --- /dev/null +++ b/src/DjangoBlog-NEW/DjangoBlog-master/templates/share_layout/theme_switcher.html @@ -0,0 +1,35 @@ +{# 功能:主题切换器组件(行1-20) #} +{% load i18n %} +
    + +
    + + + + + +
    +
    + diff --git a/src/accounts/accounts/__init__.py b/src/accounts/accounts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/accounts/accounts/__pycache__/__init__.cpython-310.pyc b/src/accounts/accounts/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 00000000..b1777587 Binary files /dev/null and b/src/accounts/accounts/__pycache__/__init__.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/admin.cpython-310.pyc b/src/accounts/accounts/__pycache__/admin.cpython-310.pyc new file mode 100644 index 00000000..14e3399a Binary files /dev/null and b/src/accounts/accounts/__pycache__/admin.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/apps.cpython-310.pyc b/src/accounts/accounts/__pycache__/apps.cpython-310.pyc new file mode 100644 index 00000000..f5bfa32b Binary files /dev/null and b/src/accounts/accounts/__pycache__/apps.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/forms.cpython-310.pyc b/src/accounts/accounts/__pycache__/forms.cpython-310.pyc new file mode 100644 index 00000000..1ee6bd7d Binary files /dev/null and b/src/accounts/accounts/__pycache__/forms.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/models.cpython-310.pyc b/src/accounts/accounts/__pycache__/models.cpython-310.pyc new file mode 100644 index 00000000..9420b88f Binary files /dev/null and b/src/accounts/accounts/__pycache__/models.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/urls.cpython-310.pyc b/src/accounts/accounts/__pycache__/urls.cpython-310.pyc new file mode 100644 index 00000000..06f8bc05 Binary files /dev/null and b/src/accounts/accounts/__pycache__/urls.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/user_login_backend.cpython-310.pyc b/src/accounts/accounts/__pycache__/user_login_backend.cpython-310.pyc new file mode 100644 index 00000000..92350098 Binary files /dev/null and b/src/accounts/accounts/__pycache__/user_login_backend.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/utils.cpython-310.pyc b/src/accounts/accounts/__pycache__/utils.cpython-310.pyc new file mode 100644 index 00000000..72874a87 Binary files /dev/null and b/src/accounts/accounts/__pycache__/utils.cpython-310.pyc differ diff --git a/src/accounts/accounts/__pycache__/views.cpython-310.pyc b/src/accounts/accounts/__pycache__/views.cpython-310.pyc new file mode 100644 index 00000000..d2002e2b Binary files /dev/null and b/src/accounts/accounts/__pycache__/views.cpython-310.pyc differ diff --git a/src/accounts/accounts/admin.py b/src/accounts/accounts/admin.py new file mode 100644 index 00000000..1a6c0345 --- /dev/null +++ b/src/accounts/accounts/admin.py @@ -0,0 +1,101 @@ +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 _ + +# 模块级注释——accounts应用的Admin后台表单配置文件, +# 自定义BlogUser(自定义用户模型)的创建表单、修改表单,以及Admin后台管理配置, +# 适配Django Admin的用户管理逻辑,支持自定义字段(如nickname、source)的后台操作 +from .models import BlogUser + + +class BlogUserCreationForm(forms.ModelForm): + """ + 自定义用户创建表单(用于Django Admin后台添加新用户) + 扩展默认表单,增加密码二次验证逻辑,适配BlogUser模型的字段要求 + """ + # 密码输入字段:label支持国际化,使用密码输入控件(隐藏输入内容) + password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput) + # 密码二次确认字段:用于验证两次输入密码一致 + password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput) + + class Meta: + model = BlogUser # 关联的模型:accounts应用的BlogUser(自定义用户模型) + fields = ('email',) # 后台创建用户时,必填的核心字段(仅邮箱,用户名可后续补充或自动生成) + + def clean_password2(self): + """ + 密码二次验证的清洁方法(Django表单验证机制) + 检查两次输入的密码是否一致,不一致则抛出验证错误 + """ + # 获取第一次和第二次输入的密码(已通过表单基础验证的清洁数据) + 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): + """ + 重写保存方法,实现密码哈希存储和创建来源标记 + Django默认会对密码进行哈希处理,此处明确调用set_password确保安全性 + """ + # 调用父类save方法,先不提交到数据库(commit=False) + user = super().save(commit=False) + # 对密码进行哈希处理后存储(避免明文存储,符合Django安全规范) + user.set_password(self.cleaned_data["password1"]) + # 若需要提交到数据库(默认commit=True) + if commit: + user.source = 'adminsite' # 标记用户创建来源:Django Admin后台 + user.save() # 最终保存用户数据到数据库 + return user + + +class BlogUserChangeForm(UserChangeForm): + """ + 自定义用户修改表单(用于Django Admin后台编辑用户信息) + 继承Django内置UserChangeForm,适配BlogUser模型的所有字段 + """ + class Meta: + model = BlogUser # 关联的模型:accounts应用的BlogUser + fields = '__all__' # 后台可修改的字段:所有模型字段(支持自定义扩展字段) + field_classes = {'username': UsernameField} # 用户名字段的类:使用Django内置UsernameField(确保符合用户名验证规则) + + def __init__(self, *args, **kwargs): + """ + 初始化表单,调用父类构造方法保持默认逻辑 + 若后续需要扩展表单初始化行为(如隐藏字段、设置默认值),可在此方法中添加 + """ + super().__init__(*args, **kwargs) + + +class BlogUserAdmin(UserAdmin): + """ + 自定义UserAdmin配置(用于Django Admin后台管理BlogUser模型) + 配置后台显示字段、排序规则、搜索字段等,优化用户管理体验 + """ + form = BlogUserChangeForm # 关联用户修改表单:使用自定义的BlogUserChangeForm + add_form = BlogUserCreationForm # 关联用户创建表单:使用自定义的BlogUserCreationForm + + # 后台列表页显示的字段(按业务优先级排序) + list_display = ( + 'id', # 用户ID(唯一标识) + 'nickname', # 用户昵称(自定义扩展字段) + 'username', # 用户名(Django用户模型核心字段) + 'email', # 邮箱(用于登录和通知,核心字段) + 'last_login', # 最后登录时间(安全审计字段) + 'date_joined', # 注册时间(业务统计字段) + 'source' # 创建来源(区分后台创建/前台注册等,自定义扩展字段) + ) + + # 后台列表页可点击跳转的字段(用于快速进入编辑页) + list_display_links = ('id', 'username') + + # 后台列表页默认排序规则:按ID倒序(新创建的用户排在前面) + ordering = ('-id',) + + # 后台搜索支持的字段(支持模糊查询,提升管理效率) + search_fields = ('username', 'nickname', 'email') \ No newline at end of file diff --git a/src/accounts/accounts/apps.py b/src/accounts/accounts/apps.py new file mode 100644 index 00000000..92080f74 --- /dev/null +++ b/src/accounts/accounts/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + +# 模块级注释——accounts应用的核心配置类文件, +# 继承Django内置的AppConfig,用于定义应用的基本元信息, +# 是Django识别和加载accounts应用的关键配置 +class AccountsConfig(AppConfig): + """ + accounts应用的配置类,用于注册应用的核心信息 + 遵循Django应用配置规范,定义应用的唯一标识名称 + """ + name = 'accounts' # 应用的唯一标识名称,与应用目录名一致, + # Django通过该名称识别并加载应用,关联应用内的模型、视图等组件 \ No newline at end of file diff --git a/src/accounts/accounts/forms.py b/src/accounts/accounts/forms.py new file mode 100644 index 00000000..62f5a88f --- /dev/null +++ b/src/accounts/accounts/forms.py @@ -0,0 +1,166 @@ +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 # 导入自定义用户模型 + +# 模块级注释——accounts应用的前台用户交互表单配置文件, +# 包含登录、注册、忘记密码、验证码获取等核心业务表单, +# 负责用户输入数据的验证、前端样式适配(如表单控件class、占位符), +# 确保用户输入合法且符合业务规则(如邮箱唯一性、密码强度、验证码有效性) + + +class LoginForm(AuthenticationForm): + """ + 前台用户登录表单,继承Django内置AuthenticationForm + 重写表单控件样式和占位符,适配前端页面布局,提升用户体验 + """ + def __init__(self, *args, **kwargs): + """ + 初始化登录表单,重写用户名和密码字段的控件配置 + """ + super(LoginForm, self).__init__(*args, **kwargs) + # 用户名输入框:设置占位符和Bootstrap表单样式类,适配前端页面 + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + # 密码输入框:设置占位符和Bootstrap表单样式类,使用密码隐藏控件 + self.fields['password'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + + +class RegisterForm(UserCreationForm): + """ + 前台用户注册表单,继承Django内置UserCreationForm + 扩展邮箱字段验证,重写表单控件样式,确保注册数据合法(用户名、邮箱唯一) + """ + def __init__(self, *args, **kwargs): + """ + 初始化注册表单,重写用户名、邮箱、密码字段的控件配置 + """ + super(RegisterForm, self).__init__(*args, **kwargs) + + # 用户名输入框:占位符+Bootstrap样式 + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + # 邮箱输入框:使用EmailInput控件,占位符+Bootstrap样式 + self.fields['email'].widget = widgets.EmailInput( + attrs={'placeholder': "email", "class": "form-control"}) + # 密码输入框1:密码隐藏控件,占位符+Bootstrap样式 + self.fields['password1'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + # 密码确认框:密码隐藏控件,占位符+Bootstrap样式 + 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() # 关联Django当前激活的用户模型(此处为BlogUser) + fields = ("username", "email") # 注册表单需填写的核心字段:用户名、邮箱(密码字段由父类提供) + + +class ForgetPasswordForm(forms.Form): + """ + 前台用户忘记密码重置表单 + 包含新密码、密码确认、邮箱、验证码字段,实现密码重置的全流程验证 + """ + new_password1 = forms.CharField( + label=_("New password"), # 字段标签(支持国际化) + widget=forms.PasswordInput( + attrs={ + "class": "form-control", # Bootstrap表单样式类 + '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): + """ + 密码确认字段清洁验证: + 1. 检查两次输入的新密码是否一致 + 2. 验证密码是否符合Django密码强度规则(如长度、复杂度) + """ + 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")) # 抛出密码不匹配错误 + # 调用Django内置密码验证器,检查密码强度 + password_validation.validate_password(password2) + return password2 # 验证通过,返回确认密码 + + def clean_email(self): + """ + 邮箱字段清洁验证:检查输入的邮箱是否已注册 + 若未注册则抛出错误,确保只有已注册用户能重置密码 + """ + user_email = self.cleaned_data.get("email") # 获取经过基础验证的邮箱 + # 查询数据库,判断邮箱是否关联BlogUser + if not BlogUser.objects.filter(email=user_email).exists(): + # todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改(原代码注释保留,提示后续优化隐私保护) + raise ValidationError(_("email does not exist")) # 抛出邮箱未注册错误 + return user_email # 验证通过,返回邮箱 + + def clean_code(self): + """ + 验证码字段清洁验证:调用utils模块的verify方法验证验证码有效性 + 若验证码无效(如过期、不匹配)则抛出错误 + """ + code = self.cleaned_data.get("code") # 获取用户输入的验证码 + # 调用工具函数验证验证码(传入邮箱和验证码,返回错误信息或None) + 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'), # 字段标签(支持国际化) + ) \ No newline at end of file diff --git a/src/accounts/accounts/migrations/0001_initial.py b/src/accounts/accounts/migrations/0001_initial.py new file mode 100644 index 00000000..23ba59f7 --- /dev/null +++ b/src/accounts/accounts/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 + +#模块级注释——comments应用的初始数据库迁移文件,用于创建`Comment`模型,实现文章评论功能,支持评论层级、文章关联、用户关联等业务逻辑 +class Migration(migrations.Migration): + + initial = True # xxx: 标记该迁移为应用的初始迁移 + + dependencies = [ + ('blog', '0001_initial'), # xxx: 依赖`blog`应用的`0001_initial`迁移,确保`Article`模型已存在 + migrations.swappable_dependency(settings.AUTH_USER_MODEL), # xxx: 依赖Django可交换的用户模型,支持自定义用户模型场景 + ] + + operations = [ + migrations.CreateModel( + name='Comment', # xxx: 定义`Comment`模型,用于存储文章评论数据 + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), # 主键字段,自增大整数类型 + ('body', models.TextField(max_length=300, verbose_name='正文')), # xxx: 评论正文字段,文本类型,最大长度300 + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), # 评论创建时间字段,默认当前时间 + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), # xxx: 评论修改时间字段,默认当前时间 + ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), # xxx: 控制评论是否显示的布尔字段,默认显示 + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')), # xxx: 外键关联`blog`应用的`Article`模型,文章删除时评论级联删除 + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')), # xxx: 外键关联用户模型,用户删除时评论级联删除 + ('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')), # xxx: 自外键关联,支持评论层级结构,允许空值,上级评论删除时当前评论级联删除 + ], + options={ + 'verbose_name': '评论', # xxx: 模型单数显示名称 + 'verbose_name_plural': '评论', # xxx: 模型复数显示名称 + 'ordering': ['-id'], # xxx: 数据查询时按ID倒序排列 + 'get_latest_by': 'id', # xxx: 按ID字段获取最新记录 + }, + ), + ] \ No newline at end of file diff --git a/src/accounts/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/src/accounts/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py new file mode 100644 index 00000000..0c8bccc9 --- /dev/null +++ b/src/accounts/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.5 on 2023-09-06 13:13 + +from django.db import migrations, models +import django.utils.timezone + +# 模块级注释——accounts应用的模型更新迁移文件,用于调整`BlogUser`模型的选项、字段名称及属性, +# 优化字段命名规范(如时间字段命名统一)、完善字段配置(如允许空值),确保模型设计更规范 +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), # 依赖`accounts`应用的初始迁移`0001_initial`,确保`BlogUser`模型已创建 + ] + + operations = [ + migrations.AlterModelOptions( + name='bloguser', # 目标模型:`accounts`应用的`BlogUser`(自定义用户模型) + options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'}, + # 调整模型选项: + # 1. get_latest_by: 按`id`字段获取最新记录 + # 2. ordering: 查询时按`id`倒序排列(新用户在前) + # 3. verbose_name/verbose_name_plural: 模型单复数显示名称均为"user" + ), + migrations.RemoveField( + model_name='bloguser', + name='created_time', # 删除原有的"创建时间"字段(字段名称规范调整,后续用`creation_time`替代) + ), + migrations.RemoveField( + model_name='bloguser', + name='last_mod_time', # 删除原有的"修改时间"字段(字段名称规范调整,后续用`last_modify_time`替代) + ), + migrations.AddField( + model_name='bloguser', + name='creation_time', # 新增标准化的"创建时间"字段(替代原`created_time`) + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), + # 字段配置:默认值为当前时间,后台显示名称为"creation time" + ), + migrations.AddField( + model_name='bloguser', + name='last_modify_time', # 新增标准化的"修改时间"字段(替代原`last_mod_time`) + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), + # 字段配置:默认值为当前时间,后台显示名称为"last modify time" + ), + migrations.AlterField( + model_name='bloguser', + name='nickname', # 调整`nickname`(昵称)字段属性 + field=models.CharField(blank=True, max_length=100, verbose_name='nick name'), + # 调整内容:允许空值(blank=True),最大长度100,后台显示名称为"nick name" + ), + migrations.AlterField( + model_name='bloguser', + name='source', # 调整`source`(创建来源)字段属性 + field=models.CharField(blank=True, max_length=100, verbose_name='create source'), + # 调整内容:允许空值(blank=True),最大长度100,后台显示名称为"create source" + ), + ] \ No newline at end of file diff --git a/src/accounts/accounts/migrations/__init__.py b/src/accounts/accounts/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/accounts/accounts/migrations/__pycache__/0001_initial.cpython-310.pyc b/src/accounts/accounts/migrations/__pycache__/0001_initial.cpython-310.pyc new file mode 100644 index 00000000..7f3ec25b Binary files /dev/null and b/src/accounts/accounts/migrations/__pycache__/0001_initial.cpython-310.pyc differ diff --git a/src/accounts/accounts/migrations/__pycache__/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.cpython-310.pyc b/src/accounts/accounts/migrations/__pycache__/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.cpython-310.pyc new file mode 100644 index 00000000..75303bdb Binary files /dev/null and b/src/accounts/accounts/migrations/__pycache__/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.cpython-310.pyc differ diff --git a/src/accounts/accounts/migrations/__pycache__/__init__.cpython-310.pyc b/src/accounts/accounts/migrations/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 00000000..9dcf2880 Binary files /dev/null and b/src/accounts/accounts/migrations/__pycache__/__init__.cpython-310.pyc differ diff --git a/src/accounts/accounts/models.py b/src/accounts/accounts/models.py new file mode 100644 index 00000000..d194f1b3 --- /dev/null +++ b/src/accounts/accounts/models.py @@ -0,0 +1,60 @@ +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 # 导入项目公共工具函数(获取当前站点域名) + +# 模块级注释——accounts应用的核心数据模型文件, +# 定义自定义用户模型`BlogUser`,继承Django内置`AbstractUser`, +# 扩展业务所需的自定义字段(如昵称、创建时间、创建来源), +# 并重写核心方法以适配项目业务逻辑(如用户URL生成、字符串表示) +# Create your models here. + + +class BlogUser(AbstractUser): + """ + 自定义用户模型,继承Django内置`AbstractUser`(保留用户名、密码、邮箱等核心字段) + 扩展项目所需的业务字段,适配博客系统的用户管理需求,支持国际化配置 + """ + # 昵称字段:支持国际化标签,最大长度100,允许空值(用户可选择不设置昵称) + 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) + # 创建来源字段:支持国际化标签,最大长度100,允许空值,用于标记用户注册渠道(如"adminsite"/"frontend"/"oauth") + source = models.CharField(_('create source'), max_length=100, blank=True) + + def get_absolute_url(self): + """ + 重写Django模型的`get_absolute_url`方法,获取用户的绝对路径URL + 关联博客系统的"作者详情页"路由,用于直接访问用户的个人主页 + """ + return reverse( + 'blog:author_detail', # 路由名称(对应blog应用的作者详情页路由) + kwargs={'author_name': self.username} # 路由参数:用户名(作为作者标识) + ) + + def __str__(self): + """ + 重写模型的字符串表示方法,返回用户邮箱作为标识 + 相比默认的用户名,邮箱更具唯一性,便于后台管理和日志输出时识别用户 + """ + return self.email + + def get_full_url(self): + """ + 扩展方法:获取用户个人主页的完整URL(包含站点域名) + 用于需要分享用户主页的场景(如邮件通知、第三方分享) + """ + site = get_current_site().domain # 通过公共工具函数获取当前站点的域名(如"example.com") + # 拼接域名和用户绝对路径,生成完整URL(支持HTTPS协议) + url = "https://{site}{path}".format(site=site, path=self.get_absolute_url()) + return url + + class Meta: + ordering = ['-id'] # 数据查询时默认按ID倒序排列(新注册用户优先展示) + verbose_name = _('user') # 模型单数显示名称(支持国际化) + verbose_name_plural = verbose_name # 模型复数显示名称(与单数一致,简化管理) + get_latest_by = 'id' # 按ID字段获取最新创建的用户记录 \ No newline at end of file diff --git a/src/accounts/accounts/templatetags/__init__.py b/src/accounts/accounts/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/accounts/accounts/templatetags/__pycache__/__init__.cpython-310.pyc b/src/accounts/accounts/templatetags/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 00000000..56ee8dec Binary files /dev/null and b/src/accounts/accounts/templatetags/__pycache__/__init__.cpython-310.pyc differ diff --git a/src/accounts/accounts/tests.py b/src/accounts/accounts/tests.py new file mode 100644 index 00000000..23e437db --- /dev/null +++ b/src/accounts/accounts/tests.py @@ -0,0 +1,296 @@ +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 # 导入accounts应用自定义工具函数(如验证码设置/验证、邮件发送) + + +# 模块级注释——accounts应用的单元测试文件, +# 覆盖用户核心业务流程的测试场景:用户登录、注册、邮箱验证码验证、忘记密码重置等, +# 基于Django TestCase框架,通过模拟HTTP请求和数据库操作,验证业务逻辑的正确性, +# 确保用户相关功能稳定可用(如权限控制、数据一致性、错误处理) +# Create your tests here. + + +class AccountTest(TestCase): + """ + 用户相关核心功能测试类,继承Django TestCase + 集中测试用户登录、注册、验证码验证、忘记密码等关键流程, + 每个测试方法对应一个独立的业务场景,确保测试隔离性 + """ + + def setUp(self): + """ + 测试初始化方法(每个测试方法执行前自动调用) + 初始化测试所需的核心对象和测试数据,避免重复代码 + """ + self.client = Client() # 模拟HTTP客户端,用于发送GET/POST请求 + self.factory = RequestFactory() # 请求工厂,用于构建自定义请求对象(本测试未直接使用) + # 创建普通测试用户(用于后续登录、密码重置等测试) + self.blog_user = BlogUser.objects.create_user( + username="test", # 用户名 + email="admin@admin.com", # 邮箱 + password="12345678" # 密码(明文,create_user会自动哈希存储) + ) + self.new_test = "xxx123--=" # 测试用新密码(用于忘记密码重置场景) + + def test_validate_account(self): + """ + 测试用户登录、管理员权限、文章管理访问流程 + 验证点:超级用户登录成功、管理员页面访问权限、文章创建后管理页访问权限 + """ + # 获取当前站点域名(用于URL生成,本测试未直接使用) + 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) # 断言登录成功 + + # 访问管理员首页,验证是否有权限(状态码200表示访问成功) + 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"普通文章) + article.status = 'p' # 文章状态(推测为"published"已发布) + article.save() # 保存到数据库 + + # 访问文章的管理员编辑页,验证超级用户是否有权限 + response = self.client.get(article.get_admin_url()) + self.assertEqual(response.status_code, 200) # 断言访问成功 + + def test_validate_register(self): + """ + 测试用户注册完整流程 + 验证点:注册提交、用户创建、验证邮件链接访问、登录后权限升级、文章创建、退出登录、错误密码登录 + """ + # 注册前检查目标邮箱是否存在(预期不存在,计数为0) + self.assertEquals( + 0, len( + BlogUser.objects.filter( + email='user123@user.com'))) + + # 模拟POST请求提交注册表单(调用注册路由) + 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', # 密码确认(与密码一致) + }) + + # 注册后检查目标邮箱是否创建成功(预期计数为1) + self.assertEquals( + 1, len( + BlogUser.objects.filter( + email='user123@user.com'))) + + # 获取刚注册的用户,生成邮箱验证链接(基于用户ID和加密签名) + user = BlogUser.objects.filter(email='user123@user.com')[0] + # 生成验证签名(双重SHA256加密,结合SECRET_KEY和用户ID,确保链接安全性) + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + path = reverse('accounts:result') # 验证结果页路由 + # 拼接完整的验证链接(包含用户ID和签名) + url = '{path}?type=validation&id={id}&sign={sign}'.format( + path=path, id=user.id, sign=sign) + # 访问验证链接,验证页面是否正常响应(状态码200) + 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 # 允许访问admin后台 + 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')) + # 断言退出登录响应状态码(301/302为跳转,200为成功响应,均符合预期) + 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' # 错误密码(原密码为password123!q@wE#R$T) + }) + # 断言登录失败后的响应状态码(跳转或重新显示登录页) + 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" # 测试邮箱(已在setUp中创建关联用户) + code = generate_code() # 生成随机验证码(项目工具函数) + utils.set_code(to_email, code) # 存储验证码(推测基于缓存或数据库存储) + utils.send_verify_email(to_email, code) # 发送验证邮件(模拟邮件发送流程) + + # 验证:正确邮箱+正确验证码 → 预期无错误(返回None) + 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): + """ + 测试获取忘记密码验证码的成功场景 + 验证点:输入已注册邮箱,成功获取验证码(响应"ok") + """ + # 模拟POST请求提交邮箱,获取忘记密码验证码 + 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") # 断言响应内容为"ok"(表示验证码发送成功) + + def test_forget_password_email_code_fail(self): + """ + 测试获取忘记密码验证码的失败场景 + 验证点:无邮箱输入、邮箱格式错误,均返回"错误的邮箱" + """ + # 场景1:不传入邮箱参数 → 预期返回"错误的邮箱" + resp = self.client.post( + path=reverse("account:forget_password_code"), + data=dict() # 空数据(无邮箱) + ) + self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱") + + # 场景2:传入格式错误的邮箱(无@后的域名后缀)→ 预期返回"错误的邮箱" + 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, # 正确的验证码 + ) + # 模拟POST请求提交重置密码表单 + resp = self.client.post( + path=reverse("account:forget_password"), # 忘记密码重置路由 + data=data + ) + self.assertEqual(resp.status_code, 302) # 断言重置成功后跳转(302为重定向) + + # 验证密码是否真正修改成功 + blog_user = BlogUser.objects.filter( + email=self.blog_user.email, + ).first() # 获取测试用户 + self.assertNotEqual(blog_user, None) # 断言用户存在 + # 验证新密码是否匹配(check_password会自动哈希比对) + self.assertEqual(blog_user.check_password(data["new_password1"]), True) + + def test_forget_password_email_not_user(self): + """ + 测试忘记密码重置的失败场景:输入未注册的邮箱 + 验证点:未注册邮箱提交重置请求,返回200状态码(表单重新显示错误) + """ + # 构建重置密码表单数据(邮箱未注册) + data = dict( + new_password1=self.new_test, + new_password2=self.new_test, + email="123@123.com", # 未注册的邮箱 + code="123456", # 任意验证码 + ) + # 模拟POST请求提交重置密码表单 + 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): + """ + 测试忘记密码重置的失败场景:验证码错误 + 验证点:输入正确邮箱、错误验证码,返回200状态码(表单重新显示错误) + """ + 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", # 错误验证码(与绑定的code不一致) + ) + # 模拟POST请求提交重置密码表单 + resp = self.client.post( + path=reverse("account:forget_password"), + data=data + ) + + self.assertEqual(resp.status_code, 200) # 断言返回表单页面(显示验证码错误) \ No newline at end of file diff --git a/src/accounts/accounts/urls.py b/src/accounts/accounts/urls.py new file mode 100644 index 00000000..22ec0df8 --- /dev/null +++ b/src/accounts/accounts/urls.py @@ -0,0 +1,36 @@ +from django.urls import path +from django.urls import re_path + +from . import views # 导入accounts应用的视图模块(包含登录、注册等核心视图) +from .forms import LoginForm # 导入自定义登录表单(适配前端样式和验证规则) + +app_name = "accounts" # 定义应用命名空间,用于反向解析URL时区分不同应用的路由(如`reverse('accounts:login')`) + +# URL路由配置:映射用户核心业务的URL路径到对应视图,覆盖登录、注册、退出、密码重置等功能 +urlpatterns = [ + # 登录路由:使用正则表达式匹配`/login/`路径 + re_path(r'^login/$', + views.LoginView.as_view(success_url='/'), # 关联视图类`LoginView`,登录成功后重定向到网站根目录 + name='login', # 路由名称,用于反向解析(如模板中`{% url 'accounts:login' %}`) + kwargs={'authentication_form': LoginForm}), # 传入自定义登录表单`LoginForm`,替代默认表单 + # 注册路由:匹配`/register/`路径 + re_path(r'^register/$', + views.RegisterView.as_view(success_url="/"), # 关联视图类`RegisterView`,注册成功后重定向到网站根目录 + name='register'), # 路由名称,用于反向解析注册页面URL + # 退出登录路由:匹配`/logout/`路径 + re_path(r'^logout/$', + views.LogoutView.as_view(), # 关联视图类`LogoutView`(Django内置或自定义,处理退出登录逻辑) + name='logout'), # 路由名称,用于反向解析退出登录URL + # 账号操作结果页路由:精确匹配`/account/result.html`路径 + path(r'account/result.html', + views.account_result, # 关联函数视图`account_result`,展示账号操作结果(如邮箱验证成功/失败) + name='result'), # 路由名称,用于反向解析结果页URL + # 忘记密码重置路由:匹配`/forget_password/`路径 + re_path(r'^forget_password/$', + views.ForgetPasswordView.as_view(), # 关联视图类`ForgetPasswordView`,处理密码重置表单提交和验证 + name='forget_password'), # 路由名称,用于反向解析忘记密码页面URL + # 忘记密码验证码路由:匹配`/forget_password_code/`路径 + re_path(r'^forget_password_code/$', + views.ForgetPasswordEmailCode.as_view(), # 关联视图类`ForgetPasswordEmailCode`,处理验证码发送逻辑 + name='forget_password_code'), # 路由名称,用于反向解析获取验证码的URL +] \ No newline at end of file diff --git a/src/accounts/accounts/user_login_backend.py b/src/accounts/accounts/user_login_backend.py new file mode 100644 index 00000000..7b6a58ca --- /dev/null +++ b/src/accounts/accounts/user_login_backend.py @@ -0,0 +1,55 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend + + +# 模块级注释——accounts应用的自定义认证后端文件, +# 继承Django内置ModelBackend,扩展支持"用户名或邮箱"双登录方式, +# 适配项目中用户可能使用邮箱作为登录凭证的业务需求,保持与Django认证系统的兼容性 +class EmailOrUsernameModelBackend(ModelBackend): + """ + 自定义用户认证后端,继承Django内置ModelBackend + 核心功能:允许用户使用「用户名」或「邮箱」两种方式登录, + 兼容Django默认认证流程,不破坏原有用户模型和权限体系 + """ + + def authenticate(self, request, username=None, password=None, **kwargs): + """ + 重写认证核心方法,实现用户名/邮箱双登录逻辑 + :param request: HTTP请求对象(用于传递请求上下文,本方法未直接使用) + :param username: 登录输入的标识(可能是用户名或邮箱) + :param password: 登录输入的密码(明文,需通过check_password验证) + :param kwargs: 额外关键字参数(兼容Django认证系统的扩展需求) + :return: 认证成功返回BlogUser对象,失败返回None + """ + # 判断输入的标识是否包含@符号:含@则视为邮箱登录,否则视为用户名登录 + if '@' in username: + kwargs = {'email': username} # 构造邮箱查询条件 + else: + kwargs = {'username': username} # 构造用户名查询条件 + + try: + # 根据查询条件(用户名/邮箱)从数据库获取用户对象 + # get_user_model():动态获取当前项目激活的用户模型(此处为accounts.BlogUser) + user = get_user_model().objects.get(**kwargs) + + # 验证输入的密码是否正确(check_password会自动比对明文与数据库中哈希后的密码) + if user.check_password(password): + return user # 密码验证通过,返回用户对象(认证成功) + + # 捕获用户不存在的异常(查询不到时触发) + except get_user_model().DoesNotExist: + return None # 用户不存在,返回None(认证失败) + + def get_user(self, username): + """ + 重写用户获取方法,根据用户主键(pk)查询用户对象 + 是Django认证后端必须实现的方法,用于认证成功后获取完整用户信息 + :param username: 实际为用户的主键ID(Django认证系统默认传递pk作为参数) + :return: 查询成功返回BlogUser对象,失败返回None + """ + try: + # 根据主键ID查询用户(get_user_model()确保兼容自定义用户模型) + return get_user_model().objects.get(pk=username) + # 捕获用户不存在的异常 + except get_user_model().DoesNotExist: + return None # 用户不存在,返回None \ No newline at end of file diff --git a/src/accounts/accounts/utils.py b/src/accounts/accounts/utils.py new file mode 100644 index 00000000..4513ef9e --- /dev/null +++ b/src/accounts/accounts/utils.py @@ -0,0 +1,79 @@ +import typing +from datetime import timedelta + +from django.core.cache import cache # Django缓存框架,用于存储验证码(内存/Redis等,由项目配置决定) +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) # 验证码有效期:5分钟(全局常量,统一控制验证码过期时间) + + +def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")): + """ + 发送密码重置验证邮件(核心功能:向目标邮箱发送含验证码的邮件) + 邮件内容支持国际化,验证码有效期与全局常量`_code_ttl`保持一致(5分钟) + + Args: + to_mail: 接收邮件的目标邮箱地址(字符串类型,需符合邮箱格式) + code: 生成的随机验证码(字符串类型,用于后续密码重置验证) + subject: 邮件主题(可选参数,默认值为国际化的"Verify Email",支持多语言切换) + """ + # 构建邮件HTML内容(国际化模板,通过占位符注入验证码,明确告知有效期) + html_content = _( + "You are resetting the password, the verification code is:%(code)s, valid within 5 minutes, please keep it " + "properly") % {'code': code} + # 调用项目公共邮件发送函数,发送验证码邮件(收件人列表、主题、HTML内容) + send_email([to_mail], subject, html_content) + + +def verify(email: str, code: str) -> typing.Optional[str]: + """ + 验证验证码的有效性(核心功能:校验用户输入的验证码与缓存中存储的是否一致) + + Args: + email: 待验证的邮箱地址(与验证码绑定的唯一标识,确保验证码针对性) + code: 用户输入的验证码(需与缓存中存储的验证码比对) + + Return: + 验证失败返回错误提示字符串(支持国际化),验证成功返回None + + Note: + 1. 原代码注释保留:当前错误处理逻辑不合理,建议改用raise抛出异常(而非返回错误字符串), + 便于调用方统一捕获和处理,减少错误处理冗余; + 2. 验证码校验逻辑:通过邮箱作为缓存key,获取存储的验证码,与输入值直接比对(大小写敏感); + 3. 若缓存中无该邮箱对应的验证码(如过期、未发送),则默认返回验证码错误提示。 + """ + # 从缓存中获取该邮箱对应的验证码(缓存key为邮箱地址,value为之前存储的验证码) + cache_code = get_code(email) + # 比对缓存中的验证码与用户输入的验证码,不一致则返回错误提示 + if cache_code != code: + return gettext("Verification code error") # 国际化的验证码错误提示 + + +def set_code(email: str, code: str): + """ + 将邮箱与验证码绑定并存储到缓存中(核心功能:为后续验证提供数据支持) + 存储时自动设置过期时间(与全局`_code_ttl`一致,5分钟),避免验证码永久有效 + + Args: + email: 验证码绑定的邮箱地址(作为缓存的唯一key,确保一对一关联) + code: 需存储的随机验证码(字符串类型,建议由`generate_code`等工具函数生成) + """ + # 缓存存储:key=邮箱地址,value=验证码,timeout=有效期(秒数,由_timedelta转换) + cache.set(email, code, _code_ttl.seconds) + + +def get_code(email: str) -> typing.Optional[str]: + """ + 从缓存中获取指定邮箱对应的验证码(核心功能:为验证码校验提供数据源) + + Args: + email: 目标邮箱地址(缓存key,用于精准获取绑定的验证码) + + Return: + 缓存中存在该邮箱对应的验证码则返回字符串类型的验证码,否则返回None(如过期、未存储) + """ + # 从缓存中获取值:key为邮箱地址,不存在或过期时返回None + return cache.get(email) \ No newline at end of file diff --git a/src/accounts/accounts/views.py b/src/accounts/accounts/views.py new file mode 100644 index 00000000..c4e956af --- /dev/null +++ b/src/accounts/accounts/views.py @@ -0,0 +1,306 @@ +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 # 导入accounts应用自定义工具(验证码发送/存储) +from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm # 导入用户交互表单 +from .models import BlogUser # 导入自定义用户模型 + +logger = logging.getLogger(__name__) # 初始化日志对象,用于记录视图层操作日志 + +# 模块级注释——accounts应用的核心视图文件, +# 包含用户注册、登录、退出、邮箱验证、密码重置等核心业务的视图实现, +# 基于Django类视图(FormView/RedirectView/View)和函数视图, +# 整合表单验证、邮件发送、缓存操作、权限控制等逻辑,处理用户交互的全流程 +# Create your views here. + + +class RegisterView(FormView): + """ + 用户注册视图,继承Django FormView(专门处理表单提交的类视图) + 核心功能:接收注册表单数据、验证合法性、创建未激活用户、发送邮箱验证链接、重定向到结果页 + """ + form_class = RegisterForm # 关联注册表单类(处理用户名、邮箱、密码验证) + template_name = 'account/registration_form.html' # 注册页面模板路径 + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + """ + 重写分发方法,添加CSRF防护装饰器 + 防止跨站请求伪造攻击,确保注册请求来自本网站合法表单 + """ + return super(RegisterView, self).dispatch(*args, **kwargs) + + def form_valid(self, form): + """ + 表单验证通过后的核心处理逻辑(FormView的核心方法) + 流程:创建未激活用户 → 生成邮箱验证链接 → 发送验证邮件 → 重定向到注册结果页 + """ + if form.is_valid(): + # 1. 表单验证通过,先不提交到数据库(commit=False),后续补充字段 + user = form.save(False) + user.is_active = False # 默认设置用户为未激活状态(需邮箱验证后激活) + user.source = 'Register' # 标记用户创建来源:前台注册(区别于后台创建/第三方登录) + user.save(True) # 最终保存用户数据到数据库 + + # 2. 生成邮箱验证链接(包含站点域名、用户ID、加密签名,确保链接安全性) + site = get_current_site().domain # 获取当前站点域名(如"example.com") + # 双重SHA256加密:结合SECRET_KEY和用户ID,防止链接被篡改 + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + + # 开发环境适配:DEBUG模式下使用本地测试域名(127.0.0.1:8000) + if settings.DEBUG: + site = '127.0.0.1:8000' + path = reverse('account:result') # 验证结果页路由 + # 拼接完整的验证链接(HTTP协议,开发环境可用;生产环境建议改为HTTPS) + url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( + site=site, path=path, id=user.id, sign=sign) + + # 3. 构建验证邮件内容(HTML格式,包含验证链接) + content = """ +

    请点击下面链接验证您的邮箱

    + + {url} + + 再次感谢您! +
    + 如果上面链接无法打开,请将此链接复制至浏览器。 + {url} + """.format(url=url) + # 发送验证邮件到用户注册邮箱 + send_email( + emailto=[user.email], # 收件人(注册时填写的邮箱) + title='验证您的电子邮箱', # 邮件标题 + content=content) # 邮件HTML内容 + + # 4. 重定向到注册结果页(告知用户验证邮件已发送) + url = reverse('accounts:result') + '?type=register&id=' + str(user.id) + return HttpResponseRedirect(url) + else: + # 表单验证失败(如用户名已存在、密码不匹配),重新渲染注册页面并显示错误 + return self.render_to_response({'form': form}) + + +class LogoutView(RedirectView): + """ + 用户退出登录视图,继承Django RedirectView(专门处理重定向的类视图) + 核心功能:执行退出登录逻辑、清理缓存、重定向到登录页 + """ + url = '/login/' # 退出后重定向的目标URL(登录页) + + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + """ + 重写分发方法,添加never_cache装饰器 + 禁止浏览器缓存退出页,避免用户后退到已登录状态 + """ + return super(LogoutView, self).dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + """ + 处理GET请求(退出登录请求) + 流程:执行退出登录 → 清理侧边栏缓存 → 重定向到登录页 + """ + logout(request) # Django内置logout函数:清除用户会话,销毁登录状态 + delete_sidebar_cache() # 清除侧边栏缓存(避免缓存中保留用户相关信息) + return super(LogoutView, self).get(request, *args, **kwargs) + + +class LoginView(FormView): + """ + 用户登录视图,继承Django FormView + 核心功能:接收登录表单数据、验证合法性、执行登录逻辑、处理"记住我"、重定向到目标页面 + """ + form_class = LoginForm # 关联自定义登录表单(适配前端样式) + template_name = 'account/login.html' # 登录页面模板路径 + success_url = '/' # 登录成功后的默认重定向URL(网站根目录) + redirect_field_name = REDIRECT_FIELD_NAME # 重定向字段名(默认'redirect_to',用于跳转前的目标页面) + login_ttl = 2626560 # "记住我"的会话有效期:2626560秒 ≈ 1个月 + + @method_decorator(sensitive_post_parameters('password')) + @method_decorator(csrf_protect) + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + """ + 重写分发方法,添加3个核心装饰器: + 1. sensitive_post_parameters('password'):保护密码字段,避免在错误报告中泄露 + 2. csrf_protect:防跨站请求伪造攻击 + 3. never_cache:禁止浏览器缓存登录页,确保每次请求都是最新状态 + """ + return super(LoginView, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + """ + 补充模板上下文数据(传递重定向地址给模板) + 用于登录成功后跳转到登录前的目标页面(如访问需要登录的页面时,先跳转登录,登录后返回原页面) + """ + # 从GET请求中获取重定向地址(如?redirect_to=/article/1/) + 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): + """ + 表单验证通过后的核心登录逻辑 + 流程:验证表单 → 清理缓存 → 执行登录 → 处理"记住我" → 重定向 + """ + # 重新初始化Django内置AuthenticationForm(确保使用正确的认证逻辑) + 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()) + # 处理"记住我"选项:若勾选,则设置会话有效期为1个月 + if self.request.POST.get("remember"): + self.request.session.set_expiry(self.login_ttl) + # 调用父类form_valid方法,执行重定向 + return super(LoginView, self).form_valid(form) + else: + # 表单验证失败(如用户名/密码错误),重新渲染登录页并显示错误 + return self.render_to_response({'form': form}) + + def get_success_url(self): + """ + 自定义登录成功后的重定向URL + 核心:校验重定向地址的合法性,避免恶意重定向攻击 + """ + # 从POST请求中获取目标重定向地址(登录表单中隐藏字段传递) + 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): + """ + 账号操作结果展示函数视图 + 处理两种场景:1. 注册成功后的提示 2. 邮箱验证后的激活与提示 + 核心:根据URL参数区分场景,验证链接合法性,激活用户,渲染结果页面 + """ + # 从GET请求中获取操作类型(register/validation)和用户ID + type = request.GET.get('type') + id = request.GET.get('id') + + # 根据用户ID查询用户,不存在则返回404页面 + 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': + # 场景1:注册成功提示(告知用户验证邮件已发送) + content = ''' + 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。 + ''' + title = '注册成功' + else: + # 场景2:邮箱验证(校验链接签名合法性,激活用户) + # 重新计算签名(与注册时的加密规则一致) + c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + sign = request.GET.get('sign') # 从URL中获取传递的签名 + + # 签名不匹配则返回403禁止访问(防止恶意篡改链接) + if sign != c_sign: + return HttpResponseForbidden() + + # 签名匹配,激活用户(设置is_active为True) + user.is_active = True + user.save() + # 验证成功提示 + content = ''' + 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。 + ''' + title = '验证成功' + # 渲染结果页面,传递标题和内容 + return render(request, 'account/result.html', { + 'title': title, + 'content': content + }) + else: + # 非法操作类型,重定向到首页 + return HttpResponseRedirect('/') + + +class ForgetPasswordView(FormView): + """ + 忘记密码重置视图,继承Django 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() + # 哈希处理新密码(make_password自动使用Django配置的哈希算法,避免明文存储) + 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): + """ + 忘记密码验证码发送视图,继承Django View + 核心功能:接收邮箱 → 验证邮箱合法性 → 生成验证码 → 发送验证邮件 → 存储验证码到缓存 + """ + + def post(self, request: HttpRequest): + """ + 处理POST请求(接收邮箱,发送验证码) + """ + # 初始化忘记密码验证码表单,验证请求数据 + form = ForgetPasswordCodeForm(request.POST) + if not form.is_valid(): + return HttpResponse("错误的邮箱") # 表单验证失败(如邮箱格式错误),返回错误提示 + + # 表单验证通过,获取目标邮箱 + to_email = form.cleaned_data["email"] + + # 生成随机验证码(项目工具函数) + code = generate_code() + # 发送验证码邮件(调用accounts应用自定义工具函数) + utils.send_verify_email(to_email, code) + # 存储验证码到缓存(key=邮箱,value=验证码,有效期5分钟,由utils模块的_code_ttl控制) + utils.set_code(to_email, code) + + return HttpResponse("ok") # 验证码发送成功,返回"ok"提示n \ No newline at end of file diff --git a/src/blog/blog/__init__.py b/src/blog/blog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/blog/blog/__pycache__/__init__.cpython-312.pyc b/src/blog/blog/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..75eab84f Binary files /dev/null and b/src/blog/blog/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/blog/blog/__pycache__/admin.cpython-312.pyc b/src/blog/blog/__pycache__/admin.cpython-312.pyc new file mode 100644 index 00000000..8a1384bb Binary files /dev/null and b/src/blog/blog/__pycache__/admin.cpython-312.pyc differ diff --git a/src/blog/blog/__pycache__/apps.cpython-312.pyc b/src/blog/blog/__pycache__/apps.cpython-312.pyc new file mode 100644 index 00000000..539488c3 Binary files /dev/null and b/src/blog/blog/__pycache__/apps.cpython-312.pyc differ diff --git a/src/blog/blog/__pycache__/context_processors.cpython-312.pyc b/src/blog/blog/__pycache__/context_processors.cpython-312.pyc new file mode 100644 index 00000000..9ac05ddb Binary files /dev/null and b/src/blog/blog/__pycache__/context_processors.cpython-312.pyc differ diff --git a/src/blog/blog/__pycache__/documents.cpython-312.pyc b/src/blog/blog/__pycache__/documents.cpython-312.pyc new file mode 100644 index 00000000..58b7155b Binary files /dev/null and b/src/blog/blog/__pycache__/documents.cpython-312.pyc differ diff --git a/src/blog/blog/__pycache__/middleware.cpython-312.pyc b/src/blog/blog/__pycache__/middleware.cpython-312.pyc new file mode 100644 index 00000000..9c3c974d Binary files /dev/null and b/src/blog/blog/__pycache__/middleware.cpython-312.pyc differ diff --git a/src/blog/blog/__pycache__/models.cpython-312.pyc b/src/blog/blog/__pycache__/models.cpython-312.pyc new file mode 100644 index 00000000..91a38357 Binary files /dev/null and b/src/blog/blog/__pycache__/models.cpython-312.pyc differ diff --git a/src/blog/blog/__pycache__/urls.cpython-312.pyc b/src/blog/blog/__pycache__/urls.cpython-312.pyc new file mode 100644 index 00000000..5da770c5 Binary files /dev/null and b/src/blog/blog/__pycache__/urls.cpython-312.pyc differ diff --git a/src/blog/blog/__pycache__/views.cpython-312.pyc b/src/blog/blog/__pycache__/views.cpython-312.pyc new file mode 100644 index 00000000..95a72d8d Binary files /dev/null and b/src/blog/blog/__pycache__/views.cpython-312.pyc differ diff --git a/src/blog/blog/admin.py b/src/blog/blog/admin.py new file mode 100644 index 00000000..8b0d9346 --- /dev/null +++ b/src/blog/blog/admin.py @@ -0,0 +1,189 @@ +# 导入Django表单模块,用于创建自定义表单 +from django import forms +# 导入Django admin模块,用于注册模型到后台管理系统 +from django.contrib import admin +# 导入获取用户模型的函数,用于处理作者关联 +from django.contrib.auth import get_user_model +# 导入reverse函数,用于生成URL +from django.urls import reverse +# 导入format_html,用于在admin中生成HTML代码 +from django.utils.html import format_html +# 导入国际化工具,用于翻译后台显示文本 +from django.utils.translation import gettext_lazy as _ + +# 导入当前应用的Article模型 +from .models import Article + + +class ArticleForm(forms.ModelForm): + """ + 自定义文章表单,用于在admin中自定义文章的编辑界面 + + 可以在这里添加自定义字段验证、 widgets 或修改表单行为 + 目前注释掉了pagedown编辑器的配置,如需使用可取消注释 + """ + + # body = forms.CharField(widget=AdminPagedownWidget()) # 富文本编辑器配置 + + class Meta: + model = Article # 关联的模型 + fields = '__all__' # 包含模型的所有字段 + + +# 自定义批量操作:发布选中的文章 +def makr_article_publish(modeladmin, request, queryset): + # 将选中文章的状态更新为'p'(published) + queryset.update(status='p') + + +# 自定义批量操作:将选中的文章设为草稿 +def draft_article(modeladmin, request, queryset): + # 将选中文章的状态更新为'd'(draft) + queryset.update(status='d') + + +# 自定义批量操作:关闭选中文章的评论 +def close_article_commentstatus(modeladmin, request, queryset): + # 将选中文章的评论状态更新为'c'(closed) + queryset.update(comment_status='c') + + +# 自定义批量操作:开启选中文章的评论 +def open_article_commentstatus(modeladmin, request, queryset): + # 将选中文章的评论状态更新为'o'(open) + 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): + """ + 文章模型的Admin配置类,自定义文章在后台的显示和操作方式 + """ + list_per_page = 20 # 每页显示20条记录 + search_fields = ('body', 'title') # 可搜索的字段 + form = ArticleForm # 使用自定义的表单 + # 列表页显示的字段 + list_display = ( + 'id', # 文章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): + """ + 自定义列表字段:显示分类并添加跳转链接到分类编辑页 + + Args: + obj: 当前文章对象 + + Returns: + HTML代码:带链接的分类名称 + """ + # 获取分类模型的元数据,用于生成URL + info = (obj.category._meta.app_label, obj.category._meta.model_name) + # 生成分类编辑页的URL + link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,)) + # 返回带链接的HTML + 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): + """ + 自定义"在站点上查看"的链接 + + Args: + obj: 文章对象 + + Returns: + 文章的前台访问URL或网站首页 + """ + if obj: + # 如果有文章对象,返回文章的完整URL + 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): + """标签模型的Admin配置""" + # 编辑页排除的字段(自动生成) + exclude = ('slug', 'last_mod_time', 'creation_time') + + +class CategoryAdmin(admin.ModelAdmin): + """分类模型的Admin配置""" + # 列表页显示的字段 + list_display = ('name', 'parent_category', 'index') + # 编辑页排除的字段 + exclude = ('slug', 'last_mod_time', 'creation_time') + + +class LinksAdmin(admin.ModelAdmin): + """链接模型的Admin配置""" + exclude = ('last_mod_time', 'creation_time') + + +class SideBarAdmin(admin.ModelAdmin): + """侧边栏模型的Admin配置""" + list_display = ('name', 'content', 'is_enable', 'sequence') + exclude = ('last_mod_time', 'creation_time') + + +class BlogSettingsAdmin(admin.ModelAdmin): + """博客设置模型的Admin配置""" + pass # 使用默认配置 diff --git a/src/blog/blog/apps.py b/src/blog/blog/apps.py new file mode 100644 index 00000000..4bd78485 --- /dev/null +++ b/src/blog/blog/apps.py @@ -0,0 +1,15 @@ +# 从Django的apps模块导入AppConfig类,用于定义应用的配置 +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + """ + 博客应用(blog)的配置类 + + Django通过此类识别和配置应用的基本信息, + 包括应用名称、默认自动生成的主键类型等。 + 当项目启动时,Django会加载每个应用的AppConfig子类。 + """ + # 定义应用的名称,必须与应用的实际目录名一致 + # 这个名称用于Django内部识别应用,例如在INSTALLED_APPS中注册时使用 + name = 'blog' diff --git a/src/blog/blog/context_processors.py b/src/blog/blog/context_processors.py new file mode 100644 index 00000000..f2acba47 --- /dev/null +++ b/src/blog/blog/context_processors.py @@ -0,0 +1,73 @@ +# 导入日志模块,用于记录系统运行时的信息和错误 +import logging + +# 从django.utils导入timezone,用于获取当前时间 +from django.utils import timezone + +# 导入自定义的缓存工具和获取博客设置的工具函数 +from djangoblog.utils import cache, get_blog_setting +# 导入当前应用下的Category(分类)和Article(文章)模型 +from .models import Category, Article + +# 创建日志记录器,用于记录当前模块的日志信息 +logger = logging.getLogger(__name__) + + +def seo_processor(requests): + """ + 自定义上下文处理器,用于在所有模板中全局共享SEO相关的配置和数据 + + 上下文处理器是Django的一个功能,允许你在所有模板中自动添加变量, + 无需在每个视图函数中单独传递,特别适合网站全局配置信息的共享。 + + Args: + requests: Django请求对象,包含当前请求的相关信息(如域名、协议等) + + Returns: + dict: 包含网站配置、分类、页面等信息的字典,将被注入到所有模板中 + """ + # 定义缓存键,用于标识当前处理器的缓存数据 + 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, # 网站SEO描述(用于搜索引擎) + 'SITE_DESCRIPTION': setting.site_description, # 网站描述 + 'SITE_KEYWORDS': setting.site_keywords, # 网站关键词(用于SEO) + # 网站基础URL(如https://example.com/) + 'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/', + 'ARTICLE_SUB_LENGTH': setting.article_sub_length, # 文章摘要长度 + 'nav_category_list': Category.objects.all(), # 导航栏显示的所有分类 + # 导航栏显示的页面(类型为'p'即page,状态为'p'即published) + '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, # 网站统计代码(如Google Analytics) + "BEIAN_CODE_GONGAN": setting.gongan_beiancode, # 公安备案号 + "SHOW_GONGAN_CODE": setting.show_gongan_code, # 是否显示公安备案号 + "CURRENT_YEAR": timezone.now().year, # 当前年份(用于页脚版权信息等) + "GLOBAL_HEADER": setting.global_header, # 全局页眉代码(如额外的CSS/JS) + "GLOBAL_FOOTER": setting.global_footer, # 全局页脚代码 + "COMMENT_NEED_REVIEW": setting.comment_need_review, # 评论是否需要审核 + } + + # 将数据存入缓存,有效期为10小时(60秒*60分*10小时) + cache.set(key, value, 60 * 60 * 10) + # 返回构建的全局变量字典 + return value \ No newline at end of file diff --git a/src/blog/blog/documents.py b/src/blog/blog/documents.py new file mode 100644 index 00000000..c9ba1285 --- /dev/null +++ b/src/blog/blog/documents.py @@ -0,0 +1,267 @@ +# 导入时间处理模块 +import time + +# 导入Elasticsearch客户端相关模块 +import elasticsearch.client +# 导入Django配置模块 +from django.conf import settings +# 导入Elasticsearch DSL相关组件,用于定义文档结构 +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(通过判断配置中是否存在ELASTICSEARCH_DSL) +ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL') + +# 如果启用了Elasticsearch,则进行初始化配置 +if ELASTICSEARCH_ENABLED: + # 创建Elasticsearch连接(从Django配置中获取主机地址) + connections.create_connection( + hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']]) + # 导入Elasticsearch客户端并初始化 + from elasticsearch import Elasticsearch + + es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts']) + + # 初始化IngestClient(用于处理数据预处理管道) + from elasticsearch.client import IngestClient + + c = IngestClient(es) + + # 尝试获取名为'geoip'的管道,如果不存在则创建 + try: + c.get_pipeline('geoip') + except elasticsearch.exceptions.NotFoundError: + # 创建geoip管道:通过ip地址解析地理位置信息 + c.put_pipeline('geoip', body='''{ + "description" : "Add geoip info", # 管道描述:添加地理信息 + "processors" : [ + { + "geoip" : { + "field" : "ip" # 基于ip字段解析地理信息 + } + } + ] + }''') + + +# 定义地理位置信息内部文档(嵌套在主文档中) +class GeoIp(InnerDoc): + continent_name = Keyword() # 大洲名称( Keyword类型:不分词,适合精确查询) + country_iso_code = Keyword() # 国家ISO代码(如CN、US) + country_name = Keyword() # 国家名称 + location = GeoPoint() # 经纬度坐标(Elasticsearch地理点类型) + + +# 定义用户代理浏览器信息内部文档 +class UserAgentBrowser(InnerDoc): + Family = Keyword() # 浏览器家族(如Chrome、Firefox) + Version = Keyword() # 浏览器版本 + + +# 定义用户代理操作系统信息内部文档(继承浏览器结构,字段相同) +class UserAgentOS(UserAgentBrowser): + pass + + +# 定义用户代理设备信息内部文档 +class UserAgentDevice(InnerDoc): + Family = Keyword() # 设备家族(如iPhone、Windows) + Brand = Keyword() # 设备品牌(如Apple、Samsung) + Model = Keyword() # 设备型号(如iPhone 13) + + +# 定义用户代理整体信息内部文档(整合浏览器、系统、设备信息) +class UserAgent(InnerDoc): + browser = Object(UserAgentBrowser, required=False) # 浏览器信息(可选) + os = Object(UserAgentOS, required=False) # 操作系统信息(可选) + device = Object(UserAgentDevice, required=False) # 设备信息(可选) + string = Text() # 原始用户代理字符串(如"Mozilla/5.0...") + is_bot = Boolean() # 是否为爬虫机器人 + + +# 定义性能日志文档(记录访问性能数据) +class ElapsedTimeDocument(Document): + url = Keyword() # 访问的URL(精确匹配) + time_taken = Long() # 页面加载耗时(毫秒) + log_datetime = Date() # 日志记录时间 + ip = Keyword() # 访问者IP地址 + geoip = Object(GeoIp, required=False) # 地理位置信息(由geoip管道生成) + useragent = Object(UserAgent, required=False) # 用户代理信息 + + # 索引配置 + class Index: + name = 'performance' # 索引名称:performance(性能日志) + settings = { + "number_of_shards": 1, # 主分片数量 + "number_of_replicas": 0 # 副本分片数量(单节点环境设为0) + } + + # 文档类型配置(Elasticsearch 7+后逐渐废弃,但DSL仍保留兼容) + class Meta: + doc_type = 'ElapsedTime' + + +# 性能日志文档管理器(处理索引创建、删除、数据写入) +class ElaspedTimeDocumentManager: + @staticmethod + def build_index(): + """创建performance索引(如果不存在)""" + from elasticsearch import Elasticsearch + # 连接Elasticsearch + client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts']) + # 检查索引是否存在 + res = client.indices.exists(index="performance") + if not res: + # 初始化索引(根据ElapsedTimeDocument的定义创建映射) + ElapsedTimeDocument.init() + + @staticmethod + def delete_index(): + """删除performance索引""" + from elasticsearch import Elasticsearch + es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts']) + # 忽略400(索引不存在)和404(请求错误) + es.indices.delete(index='performance', ignore=[400, 404]) + + @staticmethod + def create(url, time_taken, log_datetime, useragent, ip): + """创建一条性能日志记录并写入Elasticsearch""" + # 确保索引存在 + 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 + 'id': int(round(time.time() * 1000)) + }, + url=url, # 访问URL + time_taken=time_taken, # 耗时 + log_datetime=log_datetime, # 日志时间 + useragent=ua, # 用户代理信息 + ip=ip # IP地址 + ) + # 保存文档时应用geoip管道(自动解析IP对应的地理位置) + doc.save(pipeline="geoip") + + +# 定义文章文档(用于博客文章的搜索索引) +class ArticleDocument(Document): + # 文章内容(使用ik分词器:max_word最大化分词,smart智能分词) + 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() # 作者ID + }) + # 分类信息(嵌套对象) + category = Object(properties={ + 'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), # 分类名称 + 'id': Integer() # 分类ID + }) + # 标签信息(嵌套对象列表) + tags = Object(properties={ + 'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'), # 标签名称 + 'id': Integer() # 标签ID + }) + pub_time = Date() # 发布时间 + status = Text() # 文章状态(如发布、草稿) + comment_status = Text() # 评论状态(如开启、关闭) + type = Text() # 文章类型(如原创、转载) + views = Integer() # 浏览量 + article_order = Integer() # 文章排序权重 + + # 索引配置 + class Index: + name = 'blog' # 索引名称: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): + """创建blog索引(根据ArticleDocument定义初始化映射)""" + ArticleDocument.init() + + def delete_index(self): + """删除blog索引""" + 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): + """将Django模型对象列表转换为ArticleDocument列表""" + return [ + ArticleDocument( + meta={'id': article.id}, # 用文章ID作为文档ID + body=article.body, # 文章内容 + title=article.title, # 文章标题 + author={ + 'nickname': article.author.username, # 作者用户名 + 'id': article.author.id # 作者ID + }, + category={ + 'name': article.category.name, # 分类名称 + 'id': article.category.id # 分类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() \ No newline at end of file diff --git a/src/blog/blog/forms.py b/src/blog/blog/forms.py new file mode 100644 index 00000000..690d1dd5 --- /dev/null +++ b/src/blog/blog/forms.py @@ -0,0 +1,41 @@ +# 导入日志模块,用于记录搜索相关日志 +import logging + +# 导入Django的表单模块,用于构建自定义表单 +from django import forms +# 导入Haystack的搜索表单基类,用于扩展搜索功能 +from haystack.forms import SearchForm + +# 创建日志记录器,使用当前模块名作为日志器名称 +logger = logging.getLogger(__name__) + + +class BlogSearchForm(SearchForm): + """ + 博客搜索表单类,继承自Haystack的SearchForm + 用于自定义博客搜索的表单验证和搜索逻辑 + """ + # 定义搜索查询字段,required=True表示该字段为必填项 + # 用户输入的搜索关键词将通过该字段传递 + querydata = forms.CharField(required=True) + + def search(self): + """ + 重写父类的search方法,实现自定义搜索逻辑 + 该方法会处理搜索请求并返回搜索结果 + """ + # 调用父类的search方法,获取初始搜索结果集 + # 父类方法会处理Haystack的核心搜索逻辑 + datas = super(BlogSearchForm, self).search() + + # 检查表单数据是否有效,若无效则返回无查询结果的默认响应 + if not self.is_valid(): + return self.no_query_found() + + # 如果表单验证通过且存在查询数据(querydata) + if self.cleaned_data['querydata']: + # 记录搜索关键词到日志,方便后续分析用户搜索行为 + logger.info(self.cleaned_data['querydata']) + + # 返回处理后的搜索结果集 + return datas \ No newline at end of file diff --git a/src/blog/blog/management/__init__.py b/src/blog/blog/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/blog/blog/management/commands/__init__.py b/src/blog/blog/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/blog/blog/management/commands/build_index.py b/src/blog/blog/management/commands/build_index.py new file mode 100644 index 00000000..22369c44 --- /dev/null +++ b/src/blog/blog/management/commands/build_index.py @@ -0,0 +1,45 @@ +# 导入Django命令基类,用于创建自定义管理命令 +from django.core.management.base import BaseCommand + +# 导入博客相关的Elasticsearch文档管理器和配置 +from blog.documents import ( + ElapsedTimeDocument, # 耗时统计文档模型 + ArticleDocumentManager, # 文章文档管理器 + ElaspedTimeDocumentManager, # 耗时统计文档管理器(注:原拼写可能存在笔误,应为Elapsed) + ELASTICSEARCH_ENABLED # Elasticsearch启用状态标记 +) + + +# TODO: 后续可优化为支持参数化(如指定重建的索引类型等) +class Command(BaseCommand): + """ + Django自定义管理命令:构建Elasticsearch搜索索引 + 用于初始化或重建文章和耗时统计相关的搜索索引 + """ + # 命令的帮助信息(使用python manage.py help build_index时显示) + help = 'build search index' + + def handle(self, *args, **options): + """ + 命令核心执行方法 + 当运行python manage.py build_index时调用 + """ + # 仅在Elasticsearch启用时执行索引构建 + if ELASTICSEARCH_ENABLED: + # 构建耗时统计文档的索引 + ElaspedTimeDocumentManager.build_index() + + # 初始化耗时统计文档的索引结构 + elapsed_manager = ElapsedTimeDocument() + elapsed_manager.init() # 创建索引映射 + + # 处理文章文档索引:先删除旧索引,再重建 + article_manager = ArticleDocumentManager() + article_manager.delete_index() # 删除现有文章索引 + article_manager.rebuild() # 重新创建索引并同步数据 + + # 输出成功信息到控制台 + self.stdout.write(self.style.SUCCESS('Successfully built search indexes')) + else: + # 当Elasticsearch未启用时,提示用户 + self.stdout.write(self.style.WARNING('Elasticsearch is not enabled, skipping index build')) \ No newline at end of file diff --git a/src/blog/blog/management/commands/build_search_words.py b/src/blog/blog/management/commands/build_search_words.py new file mode 100644 index 00000000..b0d807e5 --- /dev/null +++ b/src/blog/blog/management/commands/build_search_words.py @@ -0,0 +1,32 @@ +# 导入Django命令基类,用于创建自定义管理命令 +from django.core.management.base import BaseCommand + +# 导入博客应用中的标签和分类模型 +from blog.models import Tag, Category + + +# TODO: 后续可优化为支持参数化(如指定输出格式、过滤条件等) +class Command(BaseCommand): + """ + Django自定义管理命令:生成搜索关键词列表 + 提取所有标签和分类的名称,用于构建搜索提示词或关键词库 + """ + # 命令的帮助信息(执行python manage.py help build_search_words时显示) + help = 'build search words' + + def handle(self, *args, **options): + """ + 命令核心执行逻辑 + 当运行python manage.py build_search_words时调用 + """ + # 1. 提取所有标签(Tag)的名称并转换为列表 + # 2. 提取所有分类(Category)的名称并转换为列表 + # 3. 合并两个列表并通过set去重(确保关键词唯一) + datas = set( + [tag.name for tag in Tag.objects.all()] + # 标签名称列表 + [category.name for category in Category.objects.all()] # 分类名称列表 + ) + + # 将去重后的关键词按行打印输出 + # 格式为每个关键词单独一行,便于后续处理(如写入文件或导入搜索提示库) + print('\n'.join(datas)) \ No newline at end of file diff --git a/src/blog/blog/management/commands/clear_cache.py b/src/blog/blog/management/commands/clear_cache.py new file mode 100644 index 00000000..73803c40 --- /dev/null +++ b/src/blog/blog/management/commands/clear_cache.py @@ -0,0 +1,26 @@ +# 导入Django命令基类,用于创建自定义管理命令 +from django.core.management.base import BaseCommand + +# 导入项目自定义的缓存工具(封装自djangoblog.utils) +from djangoblog.utils import cache + + +class Command(BaseCommand): + """ + Django自定义管理命令:清除系统所有缓存 + 用于手动触发缓存清理,确保缓存数据与数据库同步 + """ + # 命令的帮助信息(执行python manage.py help clear_cache时显示) + help = 'clear the whole cache' + + def handle(self, *args, **options): + """ + 命令核心执行逻辑 + 当运行python manage.py clear_cache时调用 + """ + # 调用缓存工具的clear()方法,清除所有缓存数据 + # 这里的cache是项目自定义的缓存实例(可能封装了Django原生缓存或其他缓存后端) + cache.clear() + + # 向控制台输出成功信息(使用Django命令的样式工具,显示绿色成功提示) + self.stdout.write(self.style.SUCCESS('Cleared cache\n')) \ No newline at end of file diff --git a/src/blog/blog/management/commands/create_testdata.py b/src/blog/blog/management/commands/create_testdata.py new file mode 100644 index 00000000..8d472bf6 --- /dev/null +++ b/src/blog/blog/management/commands/create_testdata.py @@ -0,0 +1,76 @@ +# 导入Django用户模型、密码加密、命令基类 +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): + """ + Django自定义管理命令:创建测试数据 + 用于快速生成用户、分类、标签和文章等测试数据,方便开发和测试 + """ + # 命令的帮助信息(执行python manage.py help create_testdata时显示) + help = 'create test datas' + + def handle(self, *args, **options): + """ + 命令核心执行逻辑 + 当运行python manage.py create_testdata时调用,生成测试数据 + """ + # 1. 创建或获取测试用户 + # get_or_create:存在则获取,不存在则创建(避免重复生成) + # make_password:加密密码(安全存储) + user = get_user_model().objects.get_or_create( + email='test@test.com', # 测试邮箱 + username='测试用户', # 用户名 + password=make_password('test!q@w#eTYU') # 加密后的密码 + )[0] # [0]取返回元组中的用户对象 + + # 2. 创建分类(含层级关系) + # 创建父分类(无上级分类) + pcategory = Category.objects.get_or_create( + name='我是父类目', + parent_category=None # 顶级分类 + )[0] + + # 创建子分类(关联父分类) + category = Category.objects.get_or_create( + name='子类目', + parent_category=pcategory # 关联到父分类 + )[0] + category.save() # 保存子分类 + + # 3. 创建基础标签(供所有测试文章共用) + basetag = Tag() + basetag.name = "标签" # 标签名称 + basetag.save() + + # 4. 批量创建测试文章(1-19共19篇) + for i in range(1, 20): + # 创建或获取文章 + article = Article.objects.get_or_create( + category=category, # 关联到子分类 + title=f'nice title {i}', # 文章标题(带序号) + body=f'nice content {i}', # 文章内容(带序号) + author=user # 关联到测试用户 + )[0] + + # 为每篇文章创建专属标签 + tag = Tag() + tag.name = f"标签{i}" # 标签名称(带序号) + tag.save() + + # 给文章添加标签(专属标签 + 基础标签) + article.tags.add(tag) + article.tags.add(basetag) + article.save() # 保存文章(更新标签关联) + + # 5. 清除缓存(确保新生成的测试数据能立即生效) + from djangoblog.utils import cache + cache.clear() + + # 输出成功信息到控制台 + self.stdout.write(self.style.SUCCESS('created test datas \n')) \ No newline at end of file diff --git a/src/blog/blog/management/commands/ping_baidu.py b/src/blog/blog/management/commands/ping_baidu.py new file mode 100644 index 00000000..98addbf4 --- /dev/null +++ b/src/blog/blog/management/commands/ping_baidu.py @@ -0,0 +1,88 @@ +# 导入Django命令基类,用于创建自定义管理命令 +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 # 博客核心模型 + +# 获取当前站点的域名(用于生成完整URL) +site = get_current_site().domain + + +class Command(BaseCommand): + """ + Django自定义管理命令:向百度搜索引擎推送URL + 用于主动告知百度爬虫网站的更新内容,加速收录 + """ + # 命令的帮助信息(执行python manage.py help ping_baidu时显示) + help = 'notify baidu url' + + def add_arguments(self, parser): + """ + 定义命令参数:指定需要推送的URL类型 + 通过parser添加命令行参数,限制可选值 + """ + parser.add_argument( + 'data_type', # 参数名称 + type=str, + choices=[ # 可选参数值 + 'all', # 推送所有类型(文章、标签、分类) + 'article', # 仅推送文章 + 'tag', # 仅推送标签页 + 'category' # 仅推送分类页 + ], + help='指定推送类型:article(所有文章)、tag(所有标签)、category(所有分类)、all(全部)' + ) + + def get_full_url(self, path): + """ + 生成完整的URL(域名+相对路径) + :param path: 模型实例的相对路径(如/article/1.html) + :return: 完整的URL字符串(如https://example.com/article/1.html) + """ + return f"https://{site}{path}" + + def handle(self, *args, **options): + """ + 命令核心执行逻辑 + 根据参数类型收集URL,推送给百度搜索引擎 + """ + # 获取用户指定的推送类型 + data_type = options['data_type'] + self.stdout.write(f'开始收集{data_type}类型的URL...') + + # 存储待推送的URL列表 + urls = [] + + # 1. 收集文章URL(已发布状态) + if data_type == 'article' or data_type == 'all': + # 筛选所有已发布的文章 + for article in Article.objects.filter(status='p'): + # 调用文章模型的get_full_url方法获取完整URL + urls.append(article.get_full_url()) + + # 2. 收集标签页URL + if data_type == 'tag' or data_type == 'all': + for tag in Tag.objects.all(): + # 获取标签页的相对路径,再生成完整URL + relative_url = tag.get_absolute_url() + urls.append(self.get_full_url(relative_url)) + + # 3. 收集分类页URL + if data_type == 'category' or data_type == 'all': + for category in Category.objects.all(): + # 获取分类页的相对路径,再生成完整URL + relative_url = category.get_absolute_url() + urls.append(self.get_full_url(relative_url)) + + # 输出待推送的URL数量 + self.stdout.write( + self.style.SUCCESS(f'准备推送{len(urls)}条URL...') + ) + + # 调用工具类向百度推送URL + SpiderNotify.baidu_notify(urls) + + # 推送完成,输出成功信息 + self.stdout.write(self.style.SUCCESS('URL推送完成!')) \ No newline at end of file diff --git a/src/blog/blog/management/commands/sync_user_avatar.py b/src/blog/blog/management/commands/sync_user_avatar.py new file mode 100644 index 00000000..6bade0d6 --- /dev/null +++ b/src/blog/blog/management/commands/sync_user_avatar.py @@ -0,0 +1,86 @@ +import requests # 用于发送HTTP请求,验证图片URL有效性 +from django.core.management.base import BaseCommand +from django.templatetags.static import static # 生成静态文件URL + +# 导入项目工具和模型:用户头像保存、OAuth用户模型、OAuth管理工具 +from djangoblog.utils import save_user_avatar # 保存用户头像到本地的工具函数 +from oauth.models import OAuthUser # OAuth关联用户模型(存储第三方登录用户信息) +from oauth.oauthmanager import get_manager_by_type # 根据 OAuth 类型获取对应管理器 + + +class Command(BaseCommand): + """ + Django自定义管理命令:同步用户头像 + 用于检查并更新OAuth用户的头像URL,确保头像可访问(无效则重新获取或使用默认头像) + """ + # 命令的帮助信息(执行python manage.py help sync_user_avatar时显示) + help = 'sync user avatar' + + def test_picture(self, url): + """ + 验证图片URL是否有效(可访问且返回200状态码) + :param url: 头像图片的URL + :return: 有效则返回True,否则返回False + """ + try: + # 发送GET请求,超时2秒,检查状态码是否为200 + if requests.get(url, timeout=2).status_code == 200: + return True + except: + # 任何异常(超时、连接错误等)均视为无效 + pass + return False + + def handle(self, *args, **options): + """ + 命令核心执行逻辑 + 遍历所有OAuth用户,检查并同步头像URL + """ + # 获取项目静态文件的基础URL(用于判断头像是否为本地静态文件) + static_url = static("../") + + # 获取所有OAuth用户 + users = OAuthUser.objects.all() + self.stdout.write(f'开始同步{len(users)}个用户头像') + + # 遍历每个用户处理头像 + for u in users: + self.stdout.write(f'开始同步:{u.nickname}') # 输出当前处理的用户名 + url = u.picture # 获取用户当前的头像URL + + if url: # 如果用户已有头像URL + # 情况1:头像URL是本地静态文件(以static_url开头) + if url.startswith(static_url): + # 验证本地头像是否有效 + if self.test_picture(url): + self.stdout.write(f' 头像有效,跳过:{url}') + continue # 有效则跳过处理 + else: + # 本地头像无效,尝试重新获取 + self.stdout.write(f' 本地头像无效,尝试重新获取') + if u.metadata: # 如果存在第三方平台返回的元数据(可能包含头像信息) + # 根据OAuth类型(如qq、weibo)获取对应的管理器 + manage = get_manager_by_type(u.type) + # 从元数据中提取最新头像URL + url = manage.get_picture(u.metadata) + # 保存头像到本地并返回新的URL + url = save_user_avatar(url) + else: + # 无元数据,使用默认头像 + url = static('blog/img/avatar.png') + else: + # 情况2:头像URL是第三方链接(非本地文件),保存到本地 + self.stdout.write(f' 第三方头像,保存到本地') + url = save_user_avatar(url) + else: + # 情况3:用户无头像URL,使用默认头像 + self.stdout.write(f' 无头像,使用默认头像') + url = static('blog/img/avatar.png') + + # 更新用户头像并保存 + if url: + self.stdout.write(f' 结束同步:{u.nickname}.url:{url}') + u.picture = url + u.save() # 保存更新后的头像URL + + self.stdout.write('所有用户头像同步完成') \ No newline at end of file diff --git a/src/blog/blog/middleware.py b/src/blog/blog/middleware.py new file mode 100644 index 00000000..6e496e1a --- /dev/null +++ b/src/blog/blog/middleware.py @@ -0,0 +1,90 @@ +# 导入日志模块,用于记录中间件运行过程中的日志信息 +import logging +# 导入时间模块,用于计算页面渲染耗时 +import time + +# 从ipware工具导入获取客户端IP的函数 +from ipware import get_client_ip +# 从user_agents工具导入解析用户代理的函数 +from user_agents import parse + +# 导入博客相关的ES配置和文档管理器(用于记录页面加载时间) +from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager + +# 创建当前模块的日志记录器 +logger = logging.getLogger(__name__) + + +class OnlineMiddleware(object): + """ + 自定义Django中间件,用于: + 1. 计算页面渲染耗时 + 2. 收集客户端信息(IP、用户代理) + 3. 在启用Elasticsearch时记录访问性能数据 + 4. 替换响应中的特定标记为实际加载时间 + """ + + def __init__(self, get_response=None): + """ + 中间件初始化方法 + :param get_response: Django框架传入的处理响应的函数,用于链式调用中间件 + """ + self.get_response = get_response + # 调用父类初始化方法(Python 2兼容写法,在Python 3中可省略) + super().__init__() + + def __call__(self, request): + """ + 中间件核心处理方法,在请求到达视图前和响应返回客户端前执行 + :param request: Django的请求对象,包含客户端请求信息 + :return: 处理后的响应对象 + """ + # 记录请求处理开始时间(用于计算耗时) + start_time = time.time() + + # 调用下一个中间件或视图函数,获取响应对象 + response = self.get_response(request) + + # 从请求头中获取用户代理字符串(如浏览器型号、系统等信息) + http_user_agent = request.META.get('HTTP_USER_AGENT', '') + # 获取客户端IP地址(第二个返回值为是否是公开IP,此处暂不使用) + ip, _ = get_client_ip(request) + # 解析用户代理字符串,转换为可操作的对象(方便提取浏览器、系统等信息) + user_agent = parse(http_user_agent) + + # 判断响应是否为非流式响应(流式响应无法修改内容,如文件下载) + if not response.streaming: + try: + # 计算页面渲染总耗时(当前时间 - 开始时间) + cast_time = time.time() - start_time + + # 如果启用了Elasticsearch,记录性能数据 + if ELASTICSEARCH_ENABLED: + # 将耗时转换为毫秒并保留2位小数 + time_taken = round((cast_time) * 1000, 2) + # 获取当前请求的URL路径 + url = request.path + # 导入Django的时区工具,用于记录当前时间 + from django.utils import timezone + # 通过文档管理器向Elasticsearch插入一条性能记录 + ElaspedTimeDocumentManager.create( + url=url, # 访问的URL + time_taken=time_taken, # 页面加载耗时(毫秒) + log_datetime=timezone.now(), # 记录时间(当前时区) + useragent=user_agent, # 解析后的用户代理信息 + ip=ip # 客户端IP地址 + ) + + # 将响应内容中的标记替换为实际耗时(保留前5位字符) + # 注:需确保响应内容为bytes类型,因此使用str.encode转换 + response.content = response.content.replace( + b'', str.encode(str(cast_time)[:5]) + ) + + # 捕获所有异常,避免中间件错误导致请求失败 + except Exception as e: + # 记录异常信息到日志 + logger.error("Error in OnlineMiddleware: %s" % e) + + # 返回处理后的响应对象 + return response \ No newline at end of file diff --git a/src/blog/blog/migrations/0001_initial.py b/src/blog/blog/migrations/0001_initial.py new file mode 100644 index 00000000..66b3230d --- /dev/null +++ b/src/blog/blog/migrations/0001_initial.py @@ -0,0 +1,202 @@ +# 生成信息:由Django 4.1.7在2023-03-02 07:14自动生成的迁移文件 +# 迁移文件用于定义数据库表结构,通过Django的迁移系统创建或修改数据库表 +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion # 用于定义外键删除时的行为 +import django.utils.timezone # 用于处理时间字段的默认值 +import mdeditor.fields # 导入Markdown编辑器字段(用于文章正文) + + +class Migration(migrations.Migration): + """ + 数据库迁移类:定义博客系统初始表结构的迁移操作 + 所有模型的首次迁移,会创建对应的数据库表 + """ + # 标记为初始迁移(首次创建表结构) + initial = True + + # 依赖关系:当前迁移依赖于Django用户模型的迁移 + # 因为Article模型关联了用户表(作者),需确保用户表先创建 + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + # 迁移操作:创建所有模型对应的数据库表 + operations = [ + # 创建"网站配置"表(BlogSettings) + migrations.CreateModel( + name='BlogSettings', + fields=[ + # 自增主键(BigAutoField支持更大的数值范围,适合大数据量) + ('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='网站关键字')), # SEO关键字,多个用逗号分隔 + ('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='备案号')), # 网站备案号(ICP备案) + ('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': '网站配置', # 模型的复数显示名称(因配置通常只有一条记录,复数同单数) + }, + ), + + # 创建"友情链接"表(Links) + 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='链接地址')), # 链接的URL地址 + ('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'], # 默认按排序序号升序排列 + }, + ), + + # 创建"侧边栏"表(SideBar) + 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='内容')), # 侧边栏内容(支持HTML) + ('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'], # 按排序序号升序排列 + }, + ), + + # 创建"标签"表(Tag) + 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)), # URL友好的标识符(用于生成标签页URL) + ], + options={ + 'verbose_name': '标签', + 'verbose_name_plural': '标签', + 'ordering': ['name'], # 按标签名升序排列 + }, + ), + + # 创建"分类"表(Category) + 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)), # 用于生成分类页URL的标识符 + ('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')), # 权重值,控制分类在页面的显示优先级 + # 自关联外键:支持分类层级(父分类->子分类) + # on_delete=models.CASCADE表示:若父分类删除,子分类也会被删除 + ('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'], # 按权重降序排列(权重越大越靠前) + }, + ), + + # 创建"文章"表(Article) + 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='正文')), # 文章正文(使用Markdown编辑器) + ('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')), # 发布时间 + # 文章状态:草稿(d)/发表(p) + ('status', models.CharField( + choices=[('d', '草稿'), ('p', '发表')], + default='p', + max_length=1, + verbose_name='文章状态' + )), + # 评论状态:打开(o)/关闭(c) + ('comment_status', models.CharField( + choices=[('o', '打开'), ('c', '关闭')], + default='o', + max_length=1, + verbose_name='评论状态' + )), + # 内容类型:文章(a)/页面(p,如关于页、联系页) + ('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目录')), # 开关:是否显示文章目录 + # 外键:关联作者(Django用户模型) + # on_delete=models.CASCADE表示:若作者账号删除,其文章也会被删除 + ('author', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name='作者' + )), + # 外键:关联分类 + # on_delete=models.CASCADE表示:若分类删除,该分类下的文章也会被删除 + ('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', # 按ID获取最新记录(ID自增,越大越新) + }, + ), + ] \ No newline at end of file diff --git a/src/blog/blog/migrations/0002_blogsettings_global_footer_and_more.py b/src/blog/blog/migrations/0002_blogsettings_global_footer_and_more.py new file mode 100644 index 00000000..4bb685d9 --- /dev/null +++ b/src/blog/blog/migrations/0002_blogsettings_global_footer_and_more.py @@ -0,0 +1,45 @@ +# 生成信息:由Django 4.1.7在2023-03-29 06:08自动生成的迁移文件 +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + 数据库迁移类:为网站配置表添加新字段 + 用于扩展网站配置功能,支持全局头部和尾部内容的设置 + """ + + # 依赖关系:当前迁移依赖于博客应用的初始迁移(0001_initial) + # 确保在初始表结构创建之后再执行此迁移 + dependencies = [ + ('blog', '0001_initial'), # 依赖blog应用的第一个迁移文件 + ] + + # 迁移操作:为BlogSettings模型添加两个新字段 + operations = [ + # 为BlogSettings添加"公共尾部"字段 + migrations.AddField( + model_name='blogsettings', # 目标模型:网站配置表 + name='global_footer', # 新字段名称 + field=models.TextField( + blank=True, # 允许表单提交为空 + default='', # 默认值为空字符串 + null=True, # 数据库中允许为NULL + verbose_name='公共尾部' # 管理界面显示的字段名称 + ), + # 字段作用:存储网站全局共用的尾部HTML内容(如版权信息、备案号等) + # 可在所有页面底部统一显示,避免重复开发 + ), + # 为BlogSettings添加"公共头部"字段 + migrations.AddField( + model_name='blogsettings', # 目标模型:网站配置表 + name='global_header', # 新字段名称 + field=models.TextField( + blank=True, + default='', + null=True, + verbose_name='公共头部' + ), + # 字段作用:存储网站全局共用的头部HTML内容(如公共导航、统计代码等) + # 可在所有页面顶部统一显示,方便全局修改 + ), + ] \ No newline at end of file diff --git a/src/blog/blog/migrations/0003_blogsettings_comment_need_review.py b/src/blog/blog/migrations/0003_blogsettings_comment_need_review.py new file mode 100644 index 00000000..eb6e36a3 --- /dev/null +++ b/src/blog/blog/migrations/0003_blogsettings_comment_need_review.py @@ -0,0 +1,31 @@ +# 生成信息:由Django 4.2.1版本在2023-05-09 07:45自动生成的迁移文件 +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + 数据库迁移类:为网站配置表添加评论审核开关字段 + 用于控制用户评论是否需要管理员审核后才显示,增强内容管理能力 + """ + + # 依赖关系:当前迁移依赖于博客应用的上一个迁移文件(0002_...) + # 确保在之前的表结构变更完成后再执行本次迁移 + dependencies = [ + ('blog', '0002_blogsettings_global_footer_and_more'), + ] + + # 迁移操作:为BlogSettings模型添加评论审核开关字段 + operations = [ + migrations.AddField( + model_name='blogsettings', # 目标模型:网站配置表(BlogSettings) + name='comment_need_review', # 新字段名称:评论是否需要审核 + field=models.BooleanField( + default=False, # 默认值为False:评论无需审核,提交后直接显示 + verbose_name='评论是否需要审核' # 管理后台显示的字段名称 + ), + # 字段作用: + # - 当值为True时:用户提交的评论需管理员在后台审核通过后才会在前端显示 + # - 当值为False时:评论提交后立即显示,无需审核 + # 用于防止垃圾评论或违规内容直接展示,提升网站内容安全性 + ), + ] \ No newline at end of file diff --git a/src/blog/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py b/src/blog/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py new file mode 100644 index 00000000..8f8c1ab1 --- /dev/null +++ b/src/blog/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py @@ -0,0 +1,40 @@ +# 生成信息:由Django 4.2.1版本在2023-05-09 07:51自动生成的迁移文件 +from django.db import migrations + + +class Migration(migrations.Migration): + """ + 数据库迁移类:重命名BlogSettings模型中的多个字段 + 目的是统一字段命名规范(采用下划线命名法),提升代码可读性和一致性 + """ + + # 依赖关系:当前迁移依赖于上一个迁移文件(0003_...) + # 确保在添加评论审核字段之后执行字段重命名操作 + dependencies = [ + ('blog', '0003_blogsettings_comment_need_review'), + ] + + # 迁移操作:批量重命名BlogSettings模型的字段 + operations = [ + # 重命名"analyticscode"字段为"analytics_code" + migrations.RenameField( + model_name='blogsettings', # 目标模型:网站配置表 + old_name='analyticscode', # 旧字段名(驼峰式命名,不规范) + new_name='analytics_code', # 新字段名(下划线命名,符合Python规范) + # 字段含义:存储网站统计代码(如百度统计、Google Analytics) + ), + # 重命名"beiancode"字段为"beian_code" + migrations.RenameField( + model_name='blogsettings', + old_name='beiancode', # 旧字段名(连写,不规范) + new_name='beian_code', # 新字段名(下划线分隔,更清晰) + # 字段含义:存储网站ICP备案号 + ), + # 重命名"sitename"字段为"site_name" + migrations.RenameField( + model_name='blogsettings', + old_name='sitename', # 旧字段名(连写,不规范) + new_name='site_name', # 新字段名(下划线分隔,符合命名习惯) + # 字段含义:存储网站名称 + ), + ] \ No newline at end of file diff --git a/src/blog/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py b/src/blog/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py new file mode 100644 index 00000000..18f8d4f3 --- /dev/null +++ b/src/blog/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py @@ -0,0 +1,107 @@ +# 生成信息:由Django 4.2.5版本在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 # Markdown编辑器字段 + + +class Migration(migrations.Migration): + """ + 数据库迁移类:统一模型的字段命名和 verbose_name 为英文 + 可能是为了国际化适配或代码规范统一,将中文标识改为英文 + """ + + # 依赖关系:依赖用户模型和上一个迁移文件 + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'), + ] + + operations = [ + # 1. 修改模型的元数据选项(verbose_name 改为英文) + 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'}, + ), + + # 2. 删除旧的时间字段(中文命名相关) + 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'), + + # 3. 添加新的时间字段(英文命名) + 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')), + + # 4. 修改所有字段的 verbose_name 为英文(仅列举部分代表性字段) + migrations.AlterField( + model_name='article', + name='article_order', + field=models.IntegerField(default=0, verbose_name='order'), # 原"排序"改为"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'), # 原"作者"改为"author" + ), + migrations.AlterField( + model_name='article', + name='body', + field=mdeditor.fields.MDTextField(verbose_name='body'), # 原"正文"改为"body" + ), + 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'), # 状态选项和描述均改为英文 + ), + # ... 省略其他字段的AlterField(均为verbose_name改为英文) + + # 友情链接模型的显示类型选项修改(中文场景改为英文场景) + 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' + ), + ), + ] \ No newline at end of file diff --git a/src/blog/blog/migrations/0006_alter_blogsettings_options.py b/src/blog/blog/migrations/0006_alter_blogsettings_options.py new file mode 100644 index 00000000..eec61592 --- /dev/null +++ b/src/blog/blog/migrations/0006_alter_blogsettings_options.py @@ -0,0 +1,30 @@ +# 生成信息:由Django 4.2.7版本在2024-01-26 02:41自动生成的迁移文件 +from django.db import migrations + + +class Migration(migrations.Migration): + """ + 数据库迁移类:修改BlogSettings模型的元数据选项 + 将模型的显示名称从之前的命名(可能为中文或其他语言)统一改为英文,适配国际化需求 + """ + + # 依赖关系:当前迁移依赖于博客应用的上一个迁移文件(0005_...) + # 确保在之前的模型结构调整完成后再执行本次元数据修改 + dependencies = [ + ('blog', '0005_alter_article_options_alter_category_options_and_more'), + ] + + # 迁移操作:修改BlogSettings模型的元数据选项 + operations = [ + migrations.AlterModelOptions( + name='blogsettings', # 目标模型:网站配置表(BlogSettings) + options={ + 'verbose_name': 'Website configuration', # 模型单数显示名称(改为英文) + 'verbose_name_plural': 'Website configuration' # 模型复数显示名称(改为英文,因配置通常为单条记录,复数同单数) + }, + # 修改目的: + # 1. 统一模型显示名称为英文,适配国际化场景(如多语言网站后台) + # 2. 使模型名称更符合英文开发环境的命名习惯,提升代码一致性 + # 3. 之前的版本可能使用中文(如"网站配置")或其他命名,此处统一规范化 + ), + ] \ No newline at end of file diff --git a/src/blog/blog/migrations/__init__.py b/src/blog/blog/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/blog/blog/migrations/__pycache__/0001_initial.cpython-312.pyc b/src/blog/blog/migrations/__pycache__/0001_initial.cpython-312.pyc new file mode 100644 index 00000000..6dbcf70d Binary files /dev/null and b/src/blog/blog/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/src/blog/blog/migrations/__pycache__/0002_blogsettings_global_footer_and_more.cpython-312.pyc b/src/blog/blog/migrations/__pycache__/0002_blogsettings_global_footer_and_more.cpython-312.pyc new file mode 100644 index 00000000..eb650fa8 Binary files /dev/null and b/src/blog/blog/migrations/__pycache__/0002_blogsettings_global_footer_and_more.cpython-312.pyc differ diff --git a/src/blog/blog/migrations/__pycache__/0003_blogsettings_comment_need_review.cpython-312.pyc b/src/blog/blog/migrations/__pycache__/0003_blogsettings_comment_need_review.cpython-312.pyc new file mode 100644 index 00000000..90ae061c Binary files /dev/null and b/src/blog/blog/migrations/__pycache__/0003_blogsettings_comment_need_review.cpython-312.pyc differ diff --git a/src/blog/blog/migrations/__pycache__/0004_rename_analyticscode_blogsettings_analytics_code_and_more.cpython-312.pyc b/src/blog/blog/migrations/__pycache__/0004_rename_analyticscode_blogsettings_analytics_code_and_more.cpython-312.pyc new file mode 100644 index 00000000..3fd37da2 Binary files /dev/null and b/src/blog/blog/migrations/__pycache__/0004_rename_analyticscode_blogsettings_analytics_code_and_more.cpython-312.pyc differ diff --git a/src/blog/blog/migrations/__pycache__/0005_alter_article_options_alter_category_options_and_more.cpython-312.pyc b/src/blog/blog/migrations/__pycache__/0005_alter_article_options_alter_category_options_and_more.cpython-312.pyc new file mode 100644 index 00000000..5c811d2b Binary files /dev/null and b/src/blog/blog/migrations/__pycache__/0005_alter_article_options_alter_category_options_and_more.cpython-312.pyc differ diff --git a/src/blog/blog/migrations/__pycache__/0006_alter_blogsettings_options.cpython-312.pyc b/src/blog/blog/migrations/__pycache__/0006_alter_blogsettings_options.cpython-312.pyc new file mode 100644 index 00000000..d142fe11 Binary files /dev/null and b/src/blog/blog/migrations/__pycache__/0006_alter_blogsettings_options.cpython-312.pyc differ diff --git a/src/blog/blog/migrations/__pycache__/__init__.cpython-312.pyc b/src/blog/blog/migrations/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..d2586ad6 Binary files /dev/null and b/src/blog/blog/migrations/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/blog/blog/models.py b/src/blog/blog/models.py new file mode 100644 index 00000000..38a5c161 --- /dev/null +++ b/src/blog/blog/models.py @@ -0,0 +1,415 @@ +# 导入日志模块,用于记录系统运行日志 +import logging +# 导入正则表达式模块,用于处理文本中的匹配(如提取图片URL) +import re +# 导入抽象基类相关工具,用于定义抽象方法 +from abc import abstractmethod + +# 导入Django配置、异常、模型、URL反转等核心功能 +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 _ # 国际化翻译工具 +# 导入markdown编辑器字段,用于文章内容编辑 +from mdeditor.fields import MDTextField +# 导入slug生成工具,用于生成URL友好的标识符 +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 + 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): + """ + 重写保存方法,扩展功能: + 1. 单独处理文章阅读量更新(避免更新其他字段) + 2. 自动生成slug(URL友好标识符) + """ + # 判断是否是更新文章阅读量的操作 + 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: + # 若模型包含slug字段,则自动生成slug(基于title或name字段) + if 'slug' in self.__dict__: + # 优先使用title字段,否则使用name字段作为slug源 + slug_source = getattr(self, 'title') if 'title' in self.__dict__ else getattr(self, 'name') + setattr(self, 'slug', slugify(slug_source)) # 生成URL友好的slug + # 调用父类保存方法 + super().save(*args, **kwargs) + + def get_full_url(self): + """生成包含域名的完整URL(用于外部链接或分享)""" + site_domain = get_current_site().domain # 获取当前站点域名 + full_url = f"https://{site_domain}{self.get_absolute_url()}" + return full_url + + class Meta: + abstract = True # 声明为抽象基类,不生成数据库表 + + @abstractmethod + def get_absolute_url(self): + """ + 抽象方法:获取模型实例的相对URL + 子类必须实现,用于生成详情页链接 + """ + 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')) # 文章内容(markdown格式) + 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' # 按id获取最新记录 + + def get_absolute_url(self): + """生成文章详情页的相对URL""" + 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) # 缓存10小时 + def get_category_tree(self): + """获取当前文章所属分类的层级树(含父级分类)""" + tree = self.category.get_category_tree() + # 转换为 (分类名称, 分类URL) 的列表 + return [(c.name, c.get_absolute_url()) for c in tree] + + def save(self, *args, **kwargs): + """重写保存方法(可扩展,此处直接调用父类方法)""" + super().save(*args, **kwargs) + + def viewed(self): + """增加阅读量并保存(仅更新views字段)""" + self.views += 1 + self.save(update_fields=['views']) # 只更新views字段,优化性能 + + def comment_list(self): + """获取当前文章的有效评论列表(带缓存)""" + cache_key = f'article_comments_{self.id}' + # 尝试从缓存获取 + cached_comments = cache.get(cache_key) + if cached_comments: + logger.info(f'从缓存获取文章评论: {self.id}') + return cached_comments + # 缓存未命中,从数据库查询 + comments = self.comment_set.filter(is_enable=True).order_by('-id') # 按ID降序(最新在前) + cache.set(cache_key, comments, 60 * 100) # 缓存100分钟 + logger.info(f'缓存文章评论: {self.id}') + return comments + + def get_admin_url(self): + """获取后台管理编辑页面的URL""" + # 自动获取模型的app标签和模型名称 + info = (self._meta.app_label, self._meta.model_name) + return reverse(f'admin:{info[0]}_{info[1]}_change', args=(self.pk,)) + + @cache_decorator(expiration=60 * 100) # 缓存100分钟 + def next_article(self): + """获取下一篇文章(ID更大、已发布的第一篇)""" + return Article.objects.filter(id__gt=self.id, status='p').order_by('id').first() + + @cache_decorator(expiration=60 * 100) # 缓存100分钟 + def prev_article(self): + """获取上一篇文章(ID更小、已发布的最后一篇)""" + return Article.objects.filter(id__lt=self.id, status='p').first() + + def get_first_image_url(self): + """从文章内容中提取第一张图片的URL(用于封面图等场景)""" + # 正则匹配markdown图片格式: ![描述](URL) + match = re.search(r'!\[.*?\]\((.+?)\)', self.body) + if match: + return match.group(1) # 返回匹配到的URL + 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) # 分类的URL标识符 + 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): + """生成分类详情页的相对URL""" + return reverse('blog:category_detail', kwargs={'category_name': self.slug}) + + def __str__(self): + return self.name + + @cache_decorator(60 * 60 * 10) # 缓存10小时 + def get_category_tree(self): + """ + 递归获取当前分类的层级树(含所有父级分类) + 例如:子分类 -> 父分类 -> 顶级分类 + """ + categorys = [] + + def parse(category): + categorys.append(category) + if category.parent_category: # 若存在父分类,继续递归 + parse(category.parent_category) + + parse(self) + return categorys + + @cache_decorator(60 * 60 * 10) # 缓存10小时 + def get_sub_categorys(self): + """ + 递归获取当前分类的所有子分类(含多级子分类) + """ + 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 child 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) # 标签的URL标识符 + + def __str__(self): + return self.name + + def get_absolute_url(self): + """生成标签详情页的相对URL""" + return reverse('blog:tag_detail', kwargs={'tag_name': self.slug}) + + @cache_decorator(60 * 60 * 10) # 缓存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')) # 链接URL + 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')) # 侧边栏内容(HTML格式) + 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): + """ + 博客配置模型 + 存储网站的全局设置(单例模式,仅允许一条记录) + """ + 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='') # SEO描述 + site_keywords = models.TextField( + _('site keywords'), max_length=1000, null=False, blank=False, default='') # 网站关键词(SEO) + 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='') # 全局头部HTML代码 + global_footer = models.TextField("公共尾部", null=True, blank=True, default='') # 全局尾部HTML代码 + 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() # 清除所有缓存 \ No newline at end of file diff --git a/src/blog/blog/search_indexes.py b/src/blog/blog/search_indexes.py new file mode 100644 index 00000000..70c4e08e --- /dev/null +++ b/src/blog/blog/search_indexes.py @@ -0,0 +1,32 @@ +# 导入Haystack的索引模块,用于定义搜索索引 +from haystack import indexes + +# 导入博客文章模型,作为搜索索引的数据源 +from blog.models import Article + + +class ArticleIndex(indexes.SearchIndex, indexes.Indexable): + """ + 文章搜索索引类,用于配置Haystack搜索的索引规则 + 继承自Haystack的SearchIndex(搜索索引基类)和Indexable(可索引接口) + """ + # 定义主搜索字段: + # - document=True:标记为主要搜索字段(Haystack默认以此字段作为全文检索的基础) + # - use_template=True:指定使用模板来构建索引内容(模板通常存放于templates/search/indexes/[app名]/[模型名]_text.txt) + text = indexes.CharField(document=True, use_template=True) + + def get_model(self): + """ + 必须实现的方法:指定该索引对应的模型 + 返回值为需要被索引的Django模型类 + """ + return Article + + def index_queryset(self, using=None): + """ + 定义需要被索引的数据集 + 筛选出状态为"已发布"(status='p')的文章,仅对这些文章建立搜索索引 + :param using: 可选参数,指定搜索引擎(多引擎场景下使用) + :return: 查询集(QuerySet),包含需要被索引的模型实例 + """ + return self.get_model().objects.filter(status='p') \ No newline at end of file diff --git a/src/blog/blog/templatetags/__init__.py b/src/blog/blog/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/blog/blog/templatetags/__pycache__/__init__.cpython-312.pyc b/src/blog/blog/templatetags/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..a73bf388 Binary files /dev/null and b/src/blog/blog/templatetags/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/blog/blog/templatetags/__pycache__/blog_tags.cpython-312.pyc b/src/blog/blog/templatetags/__pycache__/blog_tags.cpython-312.pyc new file mode 100644 index 00000000..f17209d5 Binary files /dev/null and b/src/blog/blog/templatetags/__pycache__/blog_tags.cpython-312.pyc differ diff --git a/src/blog/blog/templatetags/blog_tags.py b/src/blog/blog/templatetags/blog_tags.py new file mode 100644 index 00000000..683a9d34 --- /dev/null +++ b/src/blog/blog/templatetags/blog_tags.py @@ -0,0 +1,408 @@ +import hashlib # 用于Gravatar头像的MD5哈希计算 +import logging # 日志记录 +import random # 随机选择样式 +import urllib # URL编码处理 + +from django import template # 模板标签核心模块 +from django.conf import settings # 项目配置 +from django.db.models import Q # 数据库查询条件 +from django.shortcuts import get_object_or_404 # 获取对象或返回404 +from django.template.defaultfilters import stringfilter # 字符串过滤器装饰器 +from django.templatetags.static import static # 静态文件URL生成 +from django.urls import reverse # URL反向解析 +from django.utils.safestring import mark_safe # 标记安全HTML字符串 + +# 导入项目模型 +from blog.models import Article, Category, Tag, Links, SideBar, LinkShowType +from comments.models import Comment +# 导入工具类和插件 +from djangoblog.utils import CommonMarkdown, sanitize_html # Markdown处理和HTML净化 +from djangoblog.utils import cache # 缓存工具 +from djangoblog.utils import get_current_site # 获取当前站点信息 +from oauth.models import OAuthUser # OAuth用户模型 +from djangoblog.plugin_manage import hooks # 插件钩子 + +# 日志配置 +logger = logging.getLogger(__name__) + +# 注册模板标签库 +register = template.Library() + + +@register.simple_tag(takes_context=True) +def head_meta(context): + """ + 页面头部元信息标签(通过插件钩子扩展) + 用于动态生成SEO相关的meta标签(如title、keywords等) + :param context: 模板上下文 + :return: 经过插件处理的安全HTML字符串 + """ + return mark_safe(hooks.apply_filters('head_meta', '', context)) + + +@register.simple_tag +def timeformat(data): + """ + 时间格式化标签 + 将 datetime 对象格式化为 settings.TIME_FORMAT 定义的样式 + :param data: datetime对象 + :return: 格式化后的时间字符串,失败返回空 + """ + try: + return data.strftime(settings.TIME_FORMAT) + except Exception as e: + logger.error(e) + return "" + + +@register.simple_tag +def datetimeformat(data): + """ + 日期时间格式化标签 + 将 datetime 对象格式化为 settings.DATE_TIME_FORMAT 定义的样式 + :param data: datetime对象 + :return: 格式化后的日期时间字符串,失败返回空 + """ + try: + return data.strftime(settings.DATE_TIME_FORMAT) + except Exception as e: + logger.error(e) + return "" + + +@register.filter() +@stringfilter +def custom_markdown(content): + """ + Markdown渲染过滤器 + 将Markdown格式的文本转换为HTML并标记为安全 + :param content: Markdown文本 + :return: 安全的HTML字符串 + """ + return mark_safe(CommonMarkdown.get_markdown(content)) + + +@register.simple_tag +def get_markdown_toc(content): + """ + 获取Markdown内容的目录(TOC) + 用于生成文章目录导航 + :param content: Markdown文本 + :return: 目录的HTML字符串 + """ + from djangoblog.utils import CommonMarkdown + body, toc = CommonMarkdown.get_markdown_with_toc(content) + return mark_safe(toc) + + +@register.filter() +@stringfilter +def comment_markdown(content): + """ + 评论内容的Markdown渲染过滤器 + 先转换为HTML,再通过sanitize_html净化(过滤危险标签) + :param content: 评论的Markdown文本 + :return: 安全的HTML字符串 + """ + content = CommonMarkdown.get_markdown(content) + return mark_safe(sanitize_html(content)) + + +@register.filter(is_safe=True) +@stringfilter +def truncatechars_content(content): + """ + 文章内容摘要过滤器 + 根据网站配置的摘要长度截断HTML内容(保留标签结构) + :param content: 文章HTML内容 + :return: 截断后的安全HTML字符串 + """ + 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): + """ + 简单截断过滤器(纯文本) + 去除HTML标签后截断前150个字符 + :param content: 带HTML的文本 + :return: 截断后的纯文本 + """ + from django.utils.html import strip_tags + return strip_tags(content)[:150] + + +@register.inclusion_tag('blog/tags/breadcrumb.html') +def load_breadcrumb(article): + """ + 面包屑导航标签 + 生成文章的分类层级导航(如:首页 > 技术 > Python > 文章标题) + :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): + """ + 文章标签列表标签 + 生成文章关联的标签列表,包含标签URL、文章数量和随机样式 + :param article: 文章对象 + :return: 包含标签信息的上下文 + """ + tags = article.tags.all() + tags_list = [] + for tag in tags: + url = tag.get_absolute_url() # 标签页URL + count = tag.get_article_count() # 标签关联的文章数 + # 随机选择Bootstrap样式(如primary、success等) + 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): + """ + 侧边栏内容标签 + 加载侧边栏所需数据(热门文章、分类、标签云等),并使用缓存优化性能 + :param user: 当前用户 + :param linktype: 链接显示类型(控制友情链接显示场景) + :return: 侧边栏数据上下文 + """ + # 缓存键:区分不同链接类型的侧边栏 + cachekey = "sidebar" + linktype + value = cache.get(cachekey) + 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] + + # 标签云(根据文章数量计算字体大小) + sidebar_tags = None + tags = Tag.objects.all() + if tags and len(tags) > 0: + # 过滤有文章的标签 + tag_with_count = [(t, t.get_article_count()) for t in tags if t.get_article_count()] + if tag_with_count: + total = sum([t[1] for t in tag_with_count]) + avg = total / len(tag_with_count) # 平均文章数 + increment = 5 # 字体大小增量 + # 计算每个标签的字体大小(与平均数量成正比) + sidebar_tags = [ + (t[0], t[1], (t[1] / avg) * increment + 10) + for t in tag_with_count + ] + 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 + } + # 缓存3小时 + cache.set(cachekey, value, 60 * 60 * 3) + logger.info(f'set sidebar cache.key:{cachekey}') + value['user'] = user + return value + + +@register.inclusion_tag('blog/tags/article_meta_info.html') +def load_article_metas(article, user): + """ + 文章元信息标签 + 加载文章的元数据(作者、发布时间、分类等) + :param article: 文章对象 + :param user: 当前用户 + :return: 包含文章和用户的上下文 + """ + return {'article': article, 'user': user} + + +@register.inclusion_tag('blog/tags/article_pagination.html') +def load_pagination_info(page_obj, page_type, tag_name): + """ + 分页导航标签 + 根据不同页面类型(首页、标签页、分类页等)生成上一页/下一页链接 + :param page_obj: Django分页对象 + :param page_type: 页面类型(如分类标签归档、作者文章归档等) + :param tag_name: 标签/分类/作者名称(用于URL参数) + :return: 包含分页链接的上下文 + """ + previous_url = '' + next_url = '' + + # 首页分页 + if page_type == '': + if page_obj.has_next(): + next_url = reverse('blog:index_page', kwargs={'page': page_obj.next_page_number()}) + if page_obj.has_previous(): + previous_url = reverse('blog:index_page', kwargs={'page': page_obj.previous_page_number()}) + + # 标签页分页 + elif page_type == '分类标签归档': + tag = get_object_or_404(Tag, name=tag_name) + if page_obj.has_next(): + next_url = reverse('blog:tag_detail_page', + kwargs={'page': page_obj.next_page_number(), 'tag_name': tag.slug}) + if page_obj.has_previous(): + previous_url = reverse('blog:tag_detail_page', + kwargs={'page': page_obj.previous_page_number(), 'tag_name': tag.slug}) + + # 作者文章分页 + elif page_type == '作者文章归档': + if page_obj.has_next(): + next_url = reverse('blog:author_detail_page', + kwargs={'page': page_obj.next_page_number(), 'author_name': tag_name}) + if page_obj.has_previous(): + previous_url = reverse('blog:author_detail_page', + kwargs={'page': page_obj.previous_page_number(), 'author_name': tag_name}) + + # 分类页分页 + elif page_type == '分类目录归档': + category = get_object_or_404(Category, name=tag_name) + if page_obj.has_next(): + next_url = reverse('blog:category_detail_page', + kwargs={'page': page_obj.next_page_number(), 'category_name': category.slug}) + if page_obj.has_previous(): + previous_url = reverse('blog:category_detail_page', + kwargs={'page': page_obj.previous_page_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: 是否为列表页(True/False) + :param user: 当前用户 + :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, # 是否允许评论 + } + + +@register.filter +def gravatar_url(email, size=40): + """ + Gravatar头像URL过滤器 + 生成用户的Gravatar头像URL(优先使用OAuth用户的头像) + :param email: 用户邮箱 + :param size: 头像尺寸 + :return: 头像URL字符串 + """ + cachekey = f'gravatat/{email}' + url = cache.get(cachekey) + if url: # 缓存命中 + return url + else: # 缓存未命中 + # 优先使用OAuth用户的头像 + oauth_users = OAuthUser.objects.filter(email=email) + if oauth_users: + valid_avatars = [user for user in oauth_users if user.picture] + if valid_avatars: + return valid_avatars[0].picture + + # 生成Gravatar URL(邮箱MD5哈希 + 尺寸 + 默认头像) + email = email.encode('utf-8') + default_avatar = static('blog/img/avatar.png') # 本地默认头像 + url = f"https://www.gravatar.com/avatar/{hashlib.md5(email.lower()).hexdigest()}?{urllib.parse.urlencode({'d': default_avatar, 's': str(size)})}" + + # 缓存10小时 + cache.set(cachekey, url, 60 * 60 * 10) + logger.info(f'set gravatar cache.key:{cachekey}') + return url + + +@register.filter +def gravatar(email, size=40): + """ + Gravatar头像标签 + 生成包含头像图片的HTML标签 + :param email: 用户邮箱 + :param size: 头像尺寸 + :return: 安全的img标签HTML字符串 + """ + url = gravatar_url(email, size) + return mark_safe(f'') + + +@register.simple_tag +def query(qs, **kwargs): + """ + 查询集过滤标签 + 在模板中对查询集进行过滤(如{% query books author=author as mybooks %}) + :param qs: Django查询集 + :param kwargs: 过滤条件(键值对) + :return: 过滤后的查询集 + """ + return qs.filter(**kwargs) + + +@register.filter +def addstr(arg1, arg2): + """ + 字符串拼接过滤器 + 将两个参数转换为字符串并拼接 + :param arg1: 第一个参数 + :param arg2: 第二个参数 + :return: 拼接后的字符串 + """ + return str(arg1) + str(arg2) \ No newline at end of file diff --git a/src/blog/blog/tests.py b/src/blog/blog/tests.py new file mode 100644 index 00000000..8eb99a81 --- /dev/null +++ b/src/blog/blog/tests.py @@ -0,0 +1,331 @@ +import os +import requests + +# 导入Django核心模块:配置、文件上传、命令调用、分页、静态文件、测试工具、URL反转、时区 +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 + +# 导入项目相关模型和工具:用户、博客模型、表单、模板标签、工具函数、OAuth相关 +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 + + +class ArticleTest(TestCase): + """ + 博客核心功能测试类 + 测试文章、分类、标签、搜索、权限等核心业务逻辑 + """ + + def setUp(self): + """ + 测试前的初始化方法 + 创建测试客户端和请求工厂,用于模拟HTTP请求 + """ + self.client = Client() # 模拟用户浏览器的客户端 + self.factory = RequestFactory() # 用于构造请求对象的工厂 + + def test_validate_article(self): + """ + 测试文章相关核心功能: + - 用户模型操作 + - 分类、标签、侧边栏、链接等模型CRUD + - 文章发布、分页、搜索、评论等流程 + - 页面访问状态码验证 + """ + # 获取当前站点域名 + 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 # 允许登录admin + user.is_superuser = True # 超级管理员权限 + user.save() + + # 测试用户个人页面访问 + response = self.client.get(user.get_absolute_url()) + self.assertEqual(response.status_code, 200) # 验证页面正常访问 + + # 测试admin后台页面访问(未登录状态,实际会跳转登录页) + self.client.get('/admin/servermanager/emailsendlog/') + 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()) + + # 批量创建20篇测试文章(用于测试分页) + for i in range(20): + article = Article() + article.title = f"nicetitle{i}" + article.body = f"nicetitle{i}" + article.author = user + article.category = category + article.type = 'a' + article.status = 'p' + article.save() + article.tags.add(tag) + article.save() + + # 测试Elasticsearch搜索(如果启用) + 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()) # 推送文章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) + + # 测试各种场景下的分页功能 + # 1. 所有文章分页 + p = Paginator(Article.objects.all(), settings.PAGINATE_BY) + self.check_pagination(p, '', '') + + # 2. 标签筛选分页 + p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY) + self.check_pagination(p, '分类标签归档', tag.slug) + + # 3. 作者筛选分页 + p = Paginator( + Article.objects.filter(author__username='liangliangyy'), + settings.PAGINATE_BY + ) + self.check_pagination(p, '作者文章归档', 'liangliangyy') + + # 4. 分类筛选分页 + p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY) + self.check_pagination(p, '分类目录归档', category.slug) + + # 测试搜索表单 + f = BlogSearchForm() + f.search() # 调用搜索方法 + + # 测试百度搜索引擎推送 + SpiderNotify.baidu_notify([article.get_full_url()]) + + # 测试头像相关模板标签 + from blog.templatetags.blog_tags import gravatar_url, gravatar + u = gravatar_url('liangliangyy@gmail.com') # 获取头像URL + u = gravatar('liangliangyy@gmail.com') # 生成头像HTML + + # 测试友情链接 + 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) + + # 测试RSS订阅 + response = self.client.get('/feed/') + self.assertEqual(response.status_code, 200) + + # 测试站点地图 + response = self.client.get('/sitemap.xml') + self.assertEqual(response.status_code, 200) + + # 测试admin后台操作(删除文章、访问日志) + 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): + """ + 测试分页功能的辅助方法 + 验证分页控件生成的URL是否可正常访问 + """ + # 遍历所有分页页面 + 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): + """ + 测试图片上传功能: + - 未授权上传 + - 授权上传 + - 头像保存工具函数 + - 邮件发送工具函数 + """ + # 下载测试图片(Python官方logo) + 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) + + # 测试未授权上传(预期403禁止访问) + rsp = self.client.post('/upload') + self.assertEqual(rsp.status_code, 403) + + # 生成上传签名(基于SECRET_KEY的双重SHA256加密) + 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( + f'/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): + """测试错误页面(404页面)""" + rsp = self.client.get('/eee') # 访问不存在的URL + self.assertEqual(rsp.status_code, 404) # 验证返回404 + + def test_commands(self): + """ + 测试Django自定义命令: + - 索引构建、缓存清理、数据同步等 + """ + # 创建测试用户 + 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() + + # 创建OAuth配置 + c = OAuthConfig() + c.type = 'qq' # QQ登录 + c.appkey = 'appkey' + c.appsecret = 'appsecret' + c.save() + + # 创建关联用户的OAuth账号 + 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() + + # 创建未关联本地用户的OAuth账号 + 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() + + # 测试Elasticsearch索引构建命令 + 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") # 构建搜索关键词 \ No newline at end of file diff --git a/src/blog/blog/urls.py b/src/blog/blog/urls.py new file mode 100644 index 00000000..f4314778 --- /dev/null +++ b/src/blog/blog/urls.py @@ -0,0 +1,98 @@ +# 导入Django URL路径处理和缓存装饰器 +from django.urls import path +from django.views.decorators.cache import cache_page + +# 导入当前应用的视图模块 +from . import views + +# 定义应用命名空间,用于模板中URL反向解析(如{% url 'blog:index' %}) +app_name = "blog" + +# URL路由配置列表,映射URL路径到对应的视图 +urlpatterns = [ + # 首页路由 + path( + r'', # 匹配根路径(如域名/) + views.IndexView.as_view(), # 关联首页视图(基于类的视图) + name='index' # 路由名称,用于反向解析 + ), + # 首页分页路由(带页码参数) + path( + r'page//', # 匹配带页码的路径(如/page/2/) + views.IndexView.as_view(), # 复用首页视图处理分页 + name='index_page' # 路由名称 + ), + # 文章详情页路由(按日期和ID) + path( + r'article////.html', + # 匹配路径格式:article/年/月/日/文章ID.html(如article/2023/10/01/1.html) + views.ArticleDetailView.as_view(), # 关联文章详情视图 + name='detailbyid' # 路由名称 + ), + # 分类详情页路由 + path( + r'category/.html', + # 匹配路径:category/分类别名.html(如category/tech.html),slug表示URL友好的字符串 + views.CategoryDetailView.as_view(), # 关联分类详情视图 + name='category_detail' # 路由名称 + ), + # 分类详情页分页路由 + path( + r'category//.html', + # 匹配带页码的分类路径(如category/tech/2.html) + views.CategoryDetailView.as_view(), # 复用分类视图处理分页 + name='category_detail_page' # 路由名称 + ), + # 作者文章列表路由 + path( + r'author/.html', + # 匹配路径:author/用户名.html(如author/admin.html) + views.AuthorDetailView.as_view(), # 关联作者文章列表视图 + name='author_detail' # 路由名称 + ), + # 作者文章列表分页路由 + path( + r'author//.html', + # 匹配带页码的作者路径(如author/admin/2.html) + views.AuthorDetailView.as_view(), # 复用作者视图处理分页 + name='author_detail_page' # 路由名称 + ), + # 标签详情页路由 + path( + r'tag/.html', + # 匹配路径:tag/标签别名.html(如tag/python.html) + views.TagDetailView.as_view(), # 关联标签详情视图 + name='tag_detail' # 路由名称 + ), + # 标签详情页分页路由 + path( + r'tag//.html', + # 匹配带页码的标签路径(如tag/python/2.html) + views.TagDetailView.as_view(), # 复用标签视图处理分页 + name='tag_detail_page' # 路由名称 + ), + # 文章归档页路由(带缓存) + path( + 'archives.html', # 匹配路径:archives.html + cache_page(60 * 60)(views.ArchivesView.as_view()), # 缓存60分钟(60秒*60) + name='archives' # 路由名称 + ), + # 友情链接页路由 + path( + 'links.html', # 匹配路径:links.html + views.LinkListView.as_view(), # 关联友情链接视图 + name='links' # 路由名称 + ), + # 文件上传接口路由 + path( + r'upload', # 匹配路径:upload + views.fileupload, # 关联文件上传视图函数(基于函数的视图) + name='upload' # 路由名称 + ), + # 清理缓存接口路由 + path( + r'clean', # 匹配路径:clean + views.clean_cache_view, # 关联清理缓存视图函数 + name='clean' # 路由名称 + ), +] diff --git a/src/blog/blog/views.py b/src/blog/blog/views.py new file mode 100644 index 00000000..09c7c520 --- /dev/null +++ b/src/blog/blog/views.py @@ -0,0 +1,498 @@ +import logging +import os +import uuid # 用于生成唯一文件名 + +# 导入Django核心模块:配置、分页、HTTP响应、视图工具、翻译等 +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, render +from django.templatetags.static import static # 生成静态文件URL +from django.utils import timezone # 处理时间 +from django.utils.translation import gettext_lazy as _ # 国际化翻译 +from django.views.decorators.csrf import csrf_exempt # 豁免CSRF验证(用于文件上传) +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 = 'blog/article_index.html' + + # 上下文变量名:模板中用{{ article_list }}访问列表数据 + context_object_name = 'article_list' + + # 页面类型描述(如"分类目录归档"),子类需重写 + page_type = '' + # 分页大小:从配置中获取 + paginate_by = settings.PAGINATE_BY + # 分页参数名:URL中页码的参数名(如?page=2) + page_kwarg = 'page' + # 友情链接显示类型:默认为列表页(L) + link_type = LinkShowType.L + + def get_view_cache_key(self): + """获取视图缓存的key(未实际使用,预留扩展)""" + return self.request.get['pages'] + + @property + def page_number(self): + """获取当前页码(从URL参数或kwargs中提取)""" + page_kwarg = self.page_kwarg + # 优先从URL路径参数获取,再从GET参数获取,默认1 + page = self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 1 + return page + + def get_queryset_cache_key(self): + """ + 抽象方法:获取查询集的缓存key + 子类必须实现,用于区分不同页面的缓存 + """ + raise NotImplementedError() + + def get_queryset_data(self): + """ + 抽象方法:获取查询集数据 + 子类必须实现,定义具体的文章筛选逻辑 + """ + raise NotImplementedError() + + def get_queryset_from_cache(self, cache_key): + """ + 从缓存获取或生成查询集数据 + :param cache_key: 缓存唯一标识 + :return: 文章查询集 + """ + # 尝试从缓存获取 + value = cache.get(cache_key) + if value: + logger.info(f'从缓存获取数据,key: {cache_key}') + return value + else: + # 缓存未命中,执行查询并缓存 + article_list = self.get_queryset_data() + cache.set(cache_key, article_list) + logger.info(f'设置缓存,key: {cache_key}') + return article_list + + def get_queryset(self): + """ + 重写父类方法:从缓存获取查询集 + 优化性能,减少数据库查询 + """ + cache_key = self.get_queryset_cache_key() + return self.get_queryset_from_cache(cache_key) + + def get_context_data(self, **kwargs): + """ + 扩展上下文数据:添加友情链接显示类型 + """ + kwargs['linktype'] = self.link_type + return super().get_context_data(** kwargs) + + +class IndexView(ArticleListView): + """ + 首页视图 + 继承文章列表基类,展示所有已发布的文章 + """ + # 友情链接显示类型:首页(I) + link_type = LinkShowType.I + + def get_queryset_data(self): + """获取首页文章列表:已发布的普通文章(type='a')""" + return Article.objects.filter(type='a', status='p') + + def get_queryset_cache_key(self): + """生成首页缓存key,包含页码""" + return f'index_{self.page_number}' + + +class ArticleDetailView(DetailView): + """ + 文章详情页视图 + 展示单篇文章的详细内容、评论等 + """ + template_name = 'blog/article_detail.html' # 详情页模板 + model = Article # 关联的模型 + pk_url_kwarg = 'article_id' # URL中主键的参数名 + 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) + page = max(1, min(page, paginator.num_pages)) # 限制页码范围 + + # 获取当前页的评论 + p_comments = paginator.page(page) + + # 生成上下页评论的URL + 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'] = f'{self.object.get_absolute_url()}?comment_page={next_page}#commentlist-container' + if prev_page: + kwargs['comment_prev_page_url'] = f'{self.object.get_absolute_url()}?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().get_context_data(**kwargs) + + # 获取当前文章对象 + article = self.object + + # 执行插件动作钩子:通知插件"文章详情已获取" + hooks.run_action('after_article_body_get', article=article, request=self.request) + + # 执行插件过滤钩子:允许插件修改文章正文(如添加水印、解析特殊标签等) + 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): + """ + 获取分类下的文章列表: + 1. 根据URL中的分类slug获取分类对象 + 2. 包含所有子分类的文章 + 3. 仅展示已发布状态 + """ + slug = self.kwargs['category_name'] + category = get_object_or_404(Category, slug=slug) # 获取分类,不存在则404 + + # 记录分类名称(用于上下文) + self.categoryname = category.name + # 获取当前分类及所有子分类的名称列表 + categorynames = [c.name for c in category.get_sub_categorys()] + + # 筛选属于这些分类且已发布的文章 + return Article.objects.filter(category__name__in=categorynames, status='p') + + def get_queryset_cache_key(self): + """生成分类页面的缓存key,包含分类名和页码""" + slug = self.kwargs['category_name'] + category = get_object_or_404(Category, slug=slug) + self.categoryname = category.name + return f'category_list_{self.categoryname}_{self.page_number}' + + def get_context_data(self, **kwargs): + """扩展上下文:添加页面类型和分类名称""" + # 处理分类名称(去除路径前缀,仅保留最后一级) + try: + categoryname = self.categoryname.split('/')[-1] + except: + categoryname = self.categoryname + + kwargs['page_type'] = self.page_type + kwargs['tag_name'] = categoryname # 模板中统一用tag_name显示当前分类/标签/作者名 + return super().get_context_data(** kwargs) + + +class AuthorDetailView(ArticleListView): + """ + 作者详情页视图 + 展示指定作者发布的所有文章 + """ + page_type = '作者文章归档' # 页面类型描述 + + def get_queryset_cache_key(self): + """生成作者页面的缓存key,包含作者名和页码""" + from uuslug import slugify # 确保作者名URL友好 + author_name = slugify(self.kwargs['author_name']) + return f'author_{author_name}_{self.page_number}' + + def get_queryset_data(self): + """获取指定作者的已发布文章""" + author_name = self.kwargs['author_name'] + return Article.objects.filter(author__username=author_name, type='a', status='p') + + def get_context_data(self, **kwargs): + """扩展上下文:添加页面类型和作者名""" + kwargs['page_type'] = self.page_type + kwargs['tag_name'] = self.kwargs['author_name'] + return super().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) # 获取标签,不存在则404 + self.name = tag.name # 记录标签名 + return Article.objects.filter(tags__name=self.name, type='a', status='p') + + def get_queryset_cache_key(self): + """生成标签页面的缓存key,包含标签名和页码""" + slug = self.kwargs['tag_name'] + tag = get_object_or_404(Tag, slug=slug) + self.name = tag.name + return f'tag_{self.name}_{self.page_number}' + + def get_context_data(self, **kwargs): + """扩展上下文:添加页面类型和标签名""" + kwargs['page_type'] = self.page_type + kwargs['tag_name'] = self.name + return super().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): + """归档页缓存key(固定值,因不分页)""" + return 'archives' + + +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): + """ + 搜索视图(基于Haystack) + 处理全文搜索请求并返回结果 + """ + 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 # 豁免CSRF验证(用于外部调用上传) +def fileupload(request): + """ + 文件上传接口(图床功能) + 仅允许POST请求,且需验证签名 + """ + if request.method == 'POST': + # 获取签名参数 + sign = request.GET.get('sign', None) + if not sign: + return HttpResponseForbidden() # 无签名则禁止 + + # 验证签名(双重SHA256加密,基于SECRET_KEY) + if sign != get_sha256(get_sha256(settings.SECRET_KEY)): + return HttpResponseForbidden() # 签名无效则禁止 + + # 存储上传文件的URL + response = [] + + # 处理每个上传的文件 + for filename in request.FILES: + # 生成时间目录(按年/月/日) + timestr = timezone.now().strftime('%Y/%m/%d') + # 图片文件扩展名 + imgextensions = ['jpg', 'png', 'jpeg', 'bmp'] + # 检查是否为图片 + fname = str(filename) + isimage = any(ext in fname.lower() for ext in imgextensions) + + # 确定存储目录(图片和普通文件分开) + base_dir = os.path.join( + settings.STATICFILES, + "image" if isimage else "files", + timestr + ) + # 确保目录存在 + if not os.path.exists(base_dir): + os.makedirs(base_dir) + + # 生成唯一文件名(UUID+原扩展名) + file_ext = os.path.splitext(filename)[-1] + savepath = os.path.normpath( + os.path.join(base_dir, f"{uuid.uuid4().hex}{file_ext}") + ) + + # 安全检查:防止路径穿越 + if not savepath.startswith(base_dir): + return HttpResponse("Invalid path") + + # 保存文件 + with open(savepath, 'wb+') as wfile: + for chunk in request.FILES[filename].chunks(): + wfile.write(chunk) + + # 压缩图片(如果是图片文件) + if isimage: + from PIL import Image + try: + with Image.open(savepath) as image: + # 优化图片质量(20%质量,启用优化) + image.save(savepath, quality=20, optimize=True) + except Exception as e: + logger.error(f"图片压缩失败: {e}") + + # 生成文件的访问URL + url = static(savepath) + response.append(url) + + # 返回所有上传文件的URL + return HttpResponse(response) + else: + # 仅允许POST请求 + return HttpResponse("only for post") + + +def page_not_found_view(request, exception, template_name='blog/error_page.html'): + """ + 404错误页面视图 + 处理页面未找到的情况 + """ + if exception: + logger.error(exception) # 记录错误详情 + url = request.get_full_path() # 获取请求的URL + return render( + request, + template_name, + { + 'message': _('Sorry, the page you requested is not found. Please click the home page to see others.'), + 'statuscode': '404' + }, + status=404 + ) + + +def server_error_view(request, template_name='blog/error_page.html'): + """ + 500错误页面视图 + 处理服务器内部错误 + """ + return render( + request, + template_name, + { + 'message': _('Sorry, the server is busy. Please click the home page to see others.'), + 'statuscode': '500' + }, + status=500 + ) + + +def permission_denied_view(request, exception, template_name='blog/error_page.html'): + """ + 403错误页面视图 + 处理权限不足的情况 + """ + 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') \ No newline at end of file diff --git a/src/comments/comments/__init__.py b/src/comments/comments/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/comments/comments/admin.py b/src/comments/comments/admin.py new file mode 100644 index 00000000..969e829b --- /dev/null +++ b/src/comments/comments/admin.py @@ -0,0 +1,84 @@ +# gst: 导入Django admin模块,用于自定义后台管理功能 +from django.contrib import admin +# gst: 导入reverse用于生成后台管理页面的URL +from django.urls import reverse +# gst: 导入format_html用于在管理界面生成HTML链接 +from django.utils.html import format_html +# gst: 导入gettext_lazy用于国际化翻译(别名_) +from django.utils.translation import gettext_lazy as _ + + +def disable_commentstatus(modeladmin, request, queryset): + """ + gst: 定义admin动作函数,批量将选中的评论设置为“不显示” + :param modeladmin: 模型管理类实例 + :param request: 请求对象 + :param queryset: 选中的评论查询集 + """ + queryset.update(is_enable=False) + + +def enable_commentstatus(modeladmin, request, queryset): + """ + gst: 定义admin动作函数,批量将选中的评论设置为“显示” + :param modeladmin: 模型管理类实例 + :param request: 请求对象 + :param queryset: 选中的评论查询集 + """ + queryset.update(is_enable=True) + + +# gst: 为动作函数设置后台显示名称(支持国际化) +disable_commentstatus.short_description = _('Disable comments') +enable_commentstatus.short_description = _('Enable comments') + + +class CommentAdmin(admin.ModelAdmin): + """ + gst: 自定义Comment模型的后台管理类,配置列表显示、过滤、动作等功能 + """ + list_per_page = 20 # gst: 后台列表每页显示20条数据 + list_display = ( + 'id', + 'body', + 'link_to_userinfo', # gst: 自定义列,显示评论作者的可点击链接 + 'link_to_article', # gst: 自定义列,显示评论关联文章的可点击链接 + 'is_enable', + 'creation_time' + ) + list_display_links = ('id', 'body', 'is_enable') # gst: 这些字段可点击进入详情页 + list_filter = ('is_enable',) # gst: 后台列表过滤条件(按是否显示过滤) + exclude = ('creation_time', 'last_modify_time') # gst: 编辑页排除这些字段(不允许编辑) + actions = [disable_commentstatus, enable_commentstatus] # gst: 注册批量动作 + + def link_to_userinfo(self, obj): + """ + gst: 生成评论作者的后台管理链接(点击可进入用户详情页) + :param obj: Comment模型实例 + :return: 带HTML链接的作者信息 + """ + # 获取用户模型的app_label和model_name,用于生成URL + info = (obj.author._meta.app_label, obj.author._meta.model_name) + # 生成用户详情页的URL(如admin/auth/user/1/change/) + link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) + # 优先显示用户昵称,无昵称则显示邮箱 + display_text = obj.author.nickname if obj.author.nickname else obj.author.email + # 生成带链接的HTML内容 + return format_html(u'%s' % (link, display_text)) + + def link_to_article(self, obj): + """ + gst: 生成评论关联文章的后台管理链接(点击可进入文章详情页) + :param obj: Comment模型实例 + :return: 带HTML链接的文章标题 + """ + # 获取文章模型的app_label和model_name,用于生成URL + info = (obj.article._meta.app_label, obj.article._meta.model_name) + # 生成文章详情页的URL(如admin/blog/article/1/change/) + link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,)) + # 生成带链接的HTML内容(显示文章标题) + return format_html(u'%s' % (link, obj.article.title)) + + # gst: 为自定义列设置后台显示名称(支持国际化) + link_to_userinfo.short_description = _('User') + link_to_article.short_description = _('Article') \ No newline at end of file diff --git a/src/comments/comments/apps.py b/src/comments/comments/apps.py new file mode 100644 index 00000000..31ed1b2d --- /dev/null +++ b/src/comments/comments/apps.py @@ -0,0 +1,11 @@ +# gst: 导入Django的AppConfig类,用于配置应用的元数据和初始化行为 +from django.apps import AppConfig + + +class CommentsConfig(AppConfig): + """ + gst: 评论应用(comments)的配置类,继承自Django的AppConfig + 用于定义应用的核心配置信息,Django启动时会自动加载该配置 + """ + # gst: 应用的唯一标识名称(必须与应用目录名一致),用于Django识别和引用该应用 + name = 'comments' \ No newline at end of file diff --git a/src/comments/comments/forms.py b/src/comments/comments/forms.py new file mode 100644 index 00000000..308da938 --- /dev/null +++ b/src/comments/comments/forms.py @@ -0,0 +1,23 @@ +# gst: 导入Django表单核心模块,用于构建表单基础功能 +from django import forms +# gst: 导入ModelForm(模型表单),可直接关联数据模型快速生成表单 +from django.forms import ModelForm + +# gst: 导入当前应用的Comment模型,表单将与该模型关联 +from .models import Comment + + +class CommentForm(ModelForm): + """ + gst: 评论表单类,继承自ModelForm,用于处理评论提交(含回复功能) + 自动关联Comment模型字段,简化表单验证和数据保存逻辑 + """ + # gst: 父评论ID字段,用于实现评论回复功能 + # 隐藏输入(HiddenInput),用户不可见,仅用于传递上级评论ID + # required=False 表示非必填(普通评论无父评论时可不传) + parent_comment_id = forms.IntegerField( + widget=forms.HiddenInput, required=False) + + class Meta: + model = Comment # gst: 关联的数据库模型(Comment) + fields = ['body'] # gst: 表单需要渲染的模型字段(仅评论正文body) \ No newline at end of file diff --git a/src/comments/comments/migrations/0001_initial.py b/src/comments/comments/migrations/0001_initial.py new file mode 100644 index 00000000..64d36348 --- /dev/null +++ b/src/comments/comments/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 4.1.7 on 2023-03-02 07:14 + +from django.conf import settings # gst: 导入Django项目配置(包含用户模型等核心配置) +from django.db import migrations, models # gst: 导入数据库迁移与模型字段相关模块 +import django.db.models.deletion # gst: 导入外键删除行为处理模块 +import django.utils.timezone # gst: 导入Django时区工具(用于时间字段默认值) + + +class Migration(migrations.Migration): # gst: 定义迁移类,管理数据库模型的迁移操作 + initial = True # gst: 标记为初始迁移(首次创建该模型的迁移) + + dependencies = [ # gst: 迁移依赖项,指定执行当前迁移前需完成的其他迁移 + ('blog', '0001_initial'), # gst: 依赖blog应用的0001号初始迁移(确保Article模型已存在) + migrations.swappable_dependency(settings.AUTH_USER_MODEL), # gst: 依赖可替换的用户模型(支持自定义用户扩展) + ] + + operations = [ # gst: 迁移操作列表,定义当前迁移要执行的数据库操作 + migrations.CreateModel( # gst: 执行“创建模型”的迁移操作 + name='Comment', # gst: 模型名称(对应数据库表:comments_comment) + fields=[ # gst: 模型字段定义集合 + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), # gst: 自增主键字段,适用于大数据量场景 + ('body', models.TextField(max_length=300, verbose_name='正文')), # gst: 评论正文字段,文本类型且限制最大长度300,后台显示“正文” + ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), # gst: 创建时间字段,默认值为当前时区时间,后台显示“创建时间” + ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), # gst: 修改时间字段,默认值为当前时区时间,后台显示“修改时间” + ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), # gst: 布尔字段,控制评论是否显示(默认显示),后台显示“是否显示” + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')), # gst: 外键关联blog应用的Article模型,删除文章时级联删除评论,后台显示“文章” + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')), # gst: 外键关联用户模型,删除用户时级联删除评论,后台显示“作者” + ('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')), # gst: 自关联外键(支持评论回复),允许为空,删除上级评论时级联删除子评论,后台显示“上级评论” + ], + options={ # gst: 模型额外配置项 + 'verbose_name': '评论', # gst: 后台管理中模型的单数显示名称 + 'verbose_name_plural': '评论', # gst: 后台管理中模型的复数显示名称 + 'ordering': ['-id'], # gst: 数据查询默认排序(按主键倒序,最新评论在前) + 'get_latest_by': 'id', # gst: 调用latest()方法时按id字段获取最新记录 + }, + ), + ] diff --git a/src/comments/comments/migrations/0002_alter_comment_is_enable.py b/src/comments/comments/migrations/0002_alter_comment_is_enable.py new file mode 100644 index 00000000..f53c7de0 --- /dev/null +++ b/src/comments/comments/migrations/0002_alter_comment_is_enable.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.7 on 2023-04-24 13:48 +from django.db import migrations, models # gst: 导入数据库迁移和模型字段相关模块 + + +class Migration(migrations.Migration): # gst: 定义迁移类,管理数据库模型的修改操作 + + dependencies = [ # gst: 迁移依赖项,指定执行当前迁移前需完成的其他迁移 + ('comments', '0001_initial'), # gst: 依赖comments应用的0001号初始迁移(确保Comment模型已存在) + ] + + operations = [ # gst: 迁移操作列表,定义当前迁移要执行的数据库操作 + migrations.AlterField( # gst: 执行“修改字段”的迁移操作 + model_name='comment', # gst: 要修改的模型名称(Comment) + name='is_enable', # gst: 要修改的字段名称(是否显示字段) + field=models.BooleanField(default=False, verbose_name='是否显示'), # gst: 修改后字段类型为布尔型,默认值改为False,后台显示名称为“是否显示” + ), + ] \ No newline at end of file diff --git a/src/comments/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py b/src/comments/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py new file mode 100644 index 00000000..bd06cd8f --- /dev/null +++ b/src/comments/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py @@ -0,0 +1,64 @@ +# Generated by Django 4.2.5 on 2023-09-06 13:13 +from django.conf import settings # gst: 导入Django项目配置(含用户模型配置) +from django.db import migrations, models # gst: 导入数据库迁移和模型字段相关模块 +import django.db.models.deletion # gst: 导入外键删除行为相关模块 +import django.utils.timezone # gst: 导入Django时区工具(用于时间字段默认值) + + +class Migration(migrations.Migration): # gst: 定义迁移类,管理数据库模型的修改操作 + + dependencies = [ # gst: 迁移依赖项,指定执行当前迁移前需完成的其他迁移 + migrations.swappable_dependency(settings.AUTH_USER_MODEL), # gst: 依赖可替换的用户模型(支持自定义用户) + ('blog', '0005_alter_article_options_alter_category_options_and_more'), # gst: 依赖blog应用的0005号迁移(确保Article模型结构最新) + ('comments', '0002_alter_comment_is_enable'), # gst: 依赖comments应用的0002号迁移(确保Comment模型已有基础修改) + ] + + operations = [ # gst: 迁移操作列表,定义当前迁移要执行的数据库操作 + migrations.AlterModelOptions( # gst: 执行“修改模型选项”的迁移操作 + model_name='comment', # gst: 要修改的模型名称(Comment) + options={ # gst: 模型选项的新配置 + 'get_latest_by': 'id', # gst: 使用latest()方法时按id字段获取最新记录 + 'ordering': ['-id'], # gst: 数据查询默认排序:按主键倒序(最新评论在前) + 'verbose_name': 'comment', # gst: 后台管理中模型的单数显示名称(改为英文) + 'verbose_name_plural': 'comment', # gst: 后台管理中模型的复数显示名称(改为英文) + }, + ), + migrations.RemoveField( # gst: 执行“删除字段”的迁移操作 + model_name='comment', # gst: 要操作的模型(Comment) + name='created_time', # gst: 要删除的字段名称(原创建时间字段) + ), + migrations.RemoveField( # gst: 执行“删除字段”的迁移操作 + model_name='comment', # gst: 要操作的模型(Comment) + name='last_mod_time', # gst: 要删除的字段名称(原修改时间字段) + ), + migrations.AddField( # gst: 执行“添加字段”的迁移操作 + model_name='comment', # gst: 要操作的模型(Comment) + name='creation_time', # gst: 新增字段名称(创建时间,替换原created_time) + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), # gst: 字段类型为日期时间,默认值为当前时区时间,后台显示名称为“creation time” + ), + migrations.AddField( # gst: 执行“添加字段”的迁移操作 + model_name='comment', # gst: 要操作的模型(Comment) + name='last_modify_time', # gst: 新增字段名称(修改时间,替换原last_mod_time) + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), # gst: 字段类型为日期时间,默认值为当前时区时间,后台显示名称为“last modify time” + ), + migrations.AlterField( # gst: 执行“修改字段”的迁移操作 + model_name='comment', # gst: 要操作的模型(Comment) + name='article', # gst: 要修改的字段名称(关联文章字段) + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'), # gst: 外键关联blog应用的Article模型,删除文章时级联删除评论,后台显示名称为“article” + ), + migrations.AlterField( # gst: 执行“修改字段”的迁移操作 + model_name='comment', # gst: 要操作的模型(Comment) + name='author', # gst: 要修改的字段名称(关联作者字段) + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), # gst: 外键关联用户模型,删除用户时级联删除评论,后台显示名称为“author” + ), + migrations.AlterField( # gst: 执行“修改字段”的迁移操作 + model_name='comment', # gst: 要操作的模型(Comment) + name='is_enable', # gst: 要修改的字段名称(是否显示字段) + field=models.BooleanField(default=False, verbose_name='enable'), # gst: 字段类型为布尔型,默认值为False,后台显示名称为“enable” + ), + migrations.AlterField( # gst: 执行“修改字段”的迁移操作 + model_name='comment', # gst: 要操作的模型(Comment) + name='parent_comment', # gst: 要修改的字段名称(上级评论字段) + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'), # gst: 自关联外键(支持评论回复),允许为空,删除上级评论时级联删除子评论,后台显示名称为“parent comment” + ), + ] \ No newline at end of file diff --git a/src/comments/comments/migrations/__init__.py b/src/comments/comments/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/comments/comments/models.py b/src/comments/comments/models.py new file mode 100644 index 00000000..f5e9270a --- /dev/null +++ b/src/comments/comments/models.py @@ -0,0 +1,61 @@ +# gst: 导入Django项目配置(含用户模型等核心配置) +from django.conf import settings +# gst: 导入Django数据库模型相关模块,用于定义数据模型 +from django.db import models +# gst: 导入Django时区工具,用于时间字段默认值 +from django.utils.timezone import now +# gst: 导入国际化翻译工具(别名_),支持多语言显示 +from django.utils.translation import gettext_lazy as _ + +# gst: 导入blog应用的Article模型,用于关联评论与文章 +from blog.models import Article + + +# Create your models here. + +class Comment(models.Model): + """ + gst: 评论数据模型,存储博客文章的评论信息(支持评论回复功能) + 关联用户、文章模型,包含评论正文、时间、状态等核心字段 + """ + # 评论正文:文本字段,最大长度300字符,后台管理显示名称为“正文” + body = models.TextField('正文', max_length=300) + # 创建时间:日期时间字段,支持国际化翻译,默认值为当前时间 + creation_time = models.DateTimeField(_('creation time'), default=now) + # 最后修改时间:日期时间字段,支持国际化翻译,默认值为当前时间 + last_modify_time = models.DateTimeField(_('last modify time'), default=now) + # 关联作者:外键关联Django用户模型,支持国际化翻译 + # 删除用户时,级联删除该用户的所有评论(CASCADE) + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_('author'), + on_delete=models.CASCADE) + # 关联文章:外键关联blog应用的Article模型,支持国际化翻译 + # 删除文章时,级联删除该文章的所有评论(CASCADE) + article = models.ForeignKey( + Article, + verbose_name=_('article'), + on_delete=models.CASCADE) + # 父评论:自关联外键,支持评论回复功能,支持国际化翻译 + # blank=True 允许表单提交为空,null=True 允许数据库存储NULL值 + # 删除父评论时,级联删除所有子评论(CASCADE) + parent_comment = models.ForeignKey( + 'self', + verbose_name=_('parent comment'), + blank=True, + null=True, + on_delete=models.CASCADE) + # 是否启用:布尔字段,支持国际化翻译,默认值为False(不显示) + # blank=False 和 null=False 表示该字段为必填项,不允许为空 + 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' # 使用latest()方法时,按id字段获取最新记录 + + def __str__(self): + """gst: 模型实例的字符串表示,返回评论正文(方便后台管理查看)""" + return self.body \ No newline at end of file diff --git a/src/comments/comments/templatetags/__init__.py b/src/comments/comments/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/comments/comments/templatetags/comments_tags.py b/src/comments/comments/templatetags/comments_tags.py new file mode 100644 index 00000000..7b40d3fe --- /dev/null +++ b/src/comments/comments/templatetags/comments_tags.py @@ -0,0 +1,46 @@ +from django import template + +# gst: 注册模板标签库,使自定义标签可在模板中使用 +register = template.Library() + + +@register.simple_tag +def parse_commenttree(commentlist, comment): + """ + gst: 定义simple_tag,用于获取当前评论的所有子评论列表(递归遍历) + 用法: {% parse_commenttree article_comments comment as childcomments %} + :param commentlist: 评论查询集(需包含所有待筛选的评论) + :param comment: 父评论对象,以此为根节点查找子评论 + :return: 子评论列表(包含多级嵌套的子评论) + """ + datas = [] + + def parse(c): + """ + gst: 递归函数,用于遍历父评论的所有子评论并加入结果列表 + :param c: 当前父评论对象 + """ + # 筛选出当前父评论的有效子评论(is_enable=True) + childs = commentlist.filter(parent_comment=c, is_enable=True) + for child in childs: + datas.append(child) # 将子评论加入结果列表 + parse(child) # 递归遍历该子评论的子评论 + + parse(comment) # 从传入的comment开始递归解析 + return datas + + +@register.inclusion_tag('comments/tags/comment_item.html') +def show_comment_item(comment, ischild): + """ + gst: 定义inclusion_tag,用于渲染评论项模板(区分父子评论的显示层级) + :param comment: 要渲染的评论对象 + :param ischild: 是否为子评论(布尔值) + :return: 模板上下文,包含评论对象和显示层级 + """ + # 根据是否为子评论设置深度(父评论深度为2,子评论深度为1) + depth = 1 if ischild else 2 + return { + 'comment_item': comment, # 传递评论对象到模板 + 'depth': depth # 传递显示层级到模板 + } \ No newline at end of file diff --git a/src/comments/comments/tests.py b/src/comments/comments/tests.py new file mode 100644 index 00000000..0696e377 --- /dev/null +++ b/src/comments/comments/tests.py @@ -0,0 +1,165 @@ +# gst: 导入Django测试相关核心模块,用于模拟请求和执行测试 +from django.test import Client, RequestFactory, TransactionTestCase +# gst: 导入reverse,用于反向解析URL路径 +from django.urls import reverse + +# gst: 导入关联数据模型,为测试准备基础数据 +from accounts.models import BlogUser +from blog.models import Category, Article +from comments.models import Comment +# gst: 导入自定义评论模板标签,测试其功能可用性 +from comments.templatetags.comments_tags import * +# gst: 导入工具函数,测试辅助功能 +from djangoblog.utils import get_max_articleid_commentid + + +# Create your tests here. + +class CommentsTest(TransactionTestCase): + """ + gst: 评论功能集成测试类,继承TransactionTestCase以支持事务管理 + 主要测试评论提交、审核、回复、模板标签及工具函数等核心流程 + """ + + def setUp(self): + """ + gst: 测试前置初始化方法,每个测试用例执行前自动执行 + 初始化测试客户端、博客设置和测试用户 + """ + # gst: 初始化Django测试客户端,用于模拟用户发起HTTP请求 + self.client = Client() + # gst: 初始化RequestFactory,用于构造自定义请求对象 + self.factory = RequestFactory() + + # gst: 导入博客设置模型,配置评论需要审核的规则 + from blog.models import BlogSettings + value = BlogSettings() + value.comment_need_review = True # gst: 开启评论审核,提交后需审核才显示 + value.save() + + # gst: 创建超级用户,用于测试登录状态下的评论操作 + self.user = BlogUser.objects.create_superuser( + email="liangliangyy1@gmail.com", + username="liangliangyy1", + password="liangliangyy1") + + def update_article_comment_status(self, article): + """ + gst: 辅助测试方法,用于批量启用指定文章的所有评论 + 模拟评论审核通过的场景,方便后续测试评论显示逻辑 + :param article: 接收文章对象,对其关联的评论进行状态更新 + """ + # gst: 获取当前文章下的所有评论记录 + comments = article.comment_set.all() + # gst: 遍历评论,将每条评论的is_enable设为True(启用状态) + for comment in comments: + comment.is_enable = True + comment.save() # gst: 保存修改后的评论状态 + + def test_validate_comment(self): + """ + gst: 核心测试用例,验证评论相关完整流程 + 涵盖登录、创建测试文章、提交评论、审核、回复、标签功能等场景 + """ + # gst: 模拟用户登录,使用 setUp 中创建的超级用户账号 + self.client.login(username='liangliangyy1', password='liangliangyy1') + + # gst: 创建测试分类,用于关联测试文章 + category = Category() + category.name = "categoryccc" + category.save() # gst: 保存分类到测试数据库 + + # gst: 创建测试文章,作为评论的关联对象 + article = Article() + article.title = "nicetitleccc" + article.body = "nicecontentccc" + article.author = self.user # gst: 关联文章作者为测试超级用户 + article.category = category # gst: 关联文章到测试分类 + article.type = 'a' + article.status = 'p' # gst: 设置文章状态为已发布(可评论状态) + article.save() # gst: 保存文章到测试数据库 + + # gst: 反向解析评论提交接口的URL,传入文章ID参数 + comment_url = reverse( + 'comments:postcomment', kwargs={ + 'article_id': article.id}) + + # gst: 第一次提交普通评论(无父评论,非回复场景) + response = self.client.post(comment_url, + { + 'body': '123ffffffffff' # gst: 评论正文内容 + }) + + # gst: 验证评论提交后是否重定向(预期HTTP状态码302) + self.assertEqual(response.status_code, 302) + + # gst: 重新查询文章,获取最新关联数据 + article = Article.objects.get(pk=article.pk) + # gst: 验证未审核的评论是否不显示(此时评论is_enable为False) + self.assertEqual(len(article.comment_list()), 0) + + # gst: 调用辅助方法,启用该文章的所有评论(模拟审核通过) + self.update_article_comment_status(article) + + # gst: 验证审核通过后,评论是否正常显示(预期1条评论) + self.assertEqual(len(article.comment_list()), 1) + + # gst: 第二次提交普通评论,验证多评论提交功能 + response = self.client.post(comment_url, + { + 'body': '123ffffffffff', # gst: 重复正文,测试重复提交允许性 + }) + + # gst: 验证第二次提交是否成功重定向 + self.assertEqual(response.status_code, 302) + # gst: 启用第二次提交的评论 + self.update_article_comment_status(article) + # gst: 重新查询文章,刷新评论数据 + article = Article.objects.get(pk=article.pk) + # gst: 验证两次提交的评论是否都正常显示(预期2条评论) + self.assertEqual(len(article.comment_list()), 2) + + # gst: 获取第一条评论的ID,作为父评论ID,用于测试回复功能 + parent_comment_id = article.comment_list()[0].id + + # gst: 提交带Markdown格式、链接和代码块的回复评论 + 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 + }) + #gst: 验证回复评论提交是否成功重定向 + self.assertEqual(response.status_code, 302) + self.update_article_comment_status(article) + #gst: 重新查询文章,获取最新评论数据 + article = Article.objects.get(pk=article.pk) + #gst: 验证三次提交(2 条普通 + 1 条回复)是否都显示(预期 3 条评论) + self.assertEqual(len(article.comment_list()), 3) + #gst: 测试 parse_commenttree 模板标签,获取父评论的子评论链 + comment = Comment.objects.get(id=parent_comment_id) + tree = parse_commenttree(article.comment_list(), comment) + #gst: 验证子评论数量是否正确(预期 1 条回复) + self.assertEqual(len(tree), 1) + #gst: 测试 show_comment_item 模板标签,验证评论项渲染数据 + data = show_comment_item(comment, True) + #gst: 验证标签是否返回有效数据(非空) + self.assertIsNotNone(data) + #gst: 测试 get_max_articleid_commentid 工具函数 + s = get_max_articleid_commentid() + #gst: 验证工具函数是否返回有效结果(非空) + self.assertIsNotNone(s) + #gst: 测试评论邮件发送功能,导入工具函数并调用 + from comments.utils import send_comment_email + send_comment_email(comment) diff --git a/src/comments/comments/urls.py b/src/comments/comments/urls.py new file mode 100644 index 00000000..e59d757f --- /dev/null +++ b/src/comments/comments/urls.py @@ -0,0 +1,17 @@ +# gst: 导入Django的path函数,用于定义URL路由规则 +from django.urls import path + +# gst: 导入当前应用(comments)的视图模块,关联URL与视图逻辑 +from . import views + +# gst: 定义应用命名空间,避免不同应用间URL名称冲突 +app_name = "comments" + +# gst: URL路由配置列表,映射URL路径到对应的视图处理类 +urlpatterns = [ + path( + 'article//postcomment', # gst: 评论提交URL路径,为文章ID路径参数(整数类型) + views.CommentPostView.as_view(), # gst: 关联评论提交处理视图(基于类的视图) + name='postcomment' # gst: URL名称,用于模板反向解析(如reverse('comments:postcomment')) + ), +] \ No newline at end of file diff --git a/src/comments/comments/utils.py b/src/comments/comments/utils.py new file mode 100644 index 00000000..c2cb1ffa --- /dev/null +++ b/src/comments/comments/utils.py @@ -0,0 +1,65 @@ +# gst: 导入日志模块,用于记录函数执行过程中的异常信息 +import logging + +# gst: 导入国际化翻译工具(别名_),支持邮件内容多语言显示 +from django.utils.translation import gettext_lazy as _ + +# gst: 导入项目工具函数,用于获取当前站点信息和发送邮件 +from djangoblog.utils import get_current_site # 获取当前站点域名 +from djangoblog.utils import send_email # 邮件发送工具函数 + +# gst: 初始化日志器,日志名称为当前模块名(便于定位日志来源) +logger = logging.getLogger(__name__) + + +def send_comment_email(comment): + """ + gst: 评论邮件通知函数,发送两类邮件: + 1. 给评论者的感谢邮件(确认评论提交成功) + 2. 给父评论者的回复通知邮件(告知其评论收到回复) + :param comment: 评论对象(包含评论者、关联文章、父评论等信息) + """ + # gst: 获取当前站点域名(用于拼接文章访问链接) + site = get_current_site().domain + # gst: 邮件主题(支持国际化翻译) + subject = _('Thanks for your comment') + # gst: 拼接文章的完整访问URL(HTTPS协议 + 站点域名 + 文章绝对路径) + article_url = f"https://{site}{comment.article.get_absolute_url()}" + + # gst: 构造给评论者的感谢邮件HTML内容 + 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} + + # gst: 获取评论者的邮箱地址(邮件接收人) + tomail = comment.author.email + # gst: 发送感谢邮件(接收人列表、主题、HTML内容) + send_email([tomail], subject, html_content) + + try: + # gst: 判断当前评论是否为回复(存在父评论) + if comment.parent_comment: + # gst: 构造给父评论者的回复通知邮件HTML内容 + 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 # 父评论的正文内容 + } + # gst: 获取父评论者的邮箱地址(回复通知接收人) + tomail = comment.parent_comment.author.email + # gst: 发送回复通知邮件 + send_email([tomail], subject, html_content) + except Exception as e: + # gst: 捕获邮件发送过程中的异常,记录错误日志(不中断程序执行) + logger.error(e) \ No newline at end of file diff --git a/src/comments/comments/views.py b/src/comments/comments/views.py new file mode 100644 index 00000000..94056678 --- /dev/null +++ b/src/comments/comments/views.py @@ -0,0 +1,91 @@ +# gst: 导入Django核心模块,用于异常处理、HTTP响应、数据查询等 +from django.core.exceptions import ValidationError # 数据验证异常(用于评论关闭时抛出错误) +from django.http import HttpResponseRedirect # 重定向响应类 +from django.shortcuts import get_object_or_404 # 查找数据,不存在则返回404 +from django.utils.decorators import method_decorator # 用于给类视图方法添加装饰器 +from django.views.decorators.csrf import csrf_protect # CSRF保护装饰器(防止跨站请求伪造) +from django.views.generic.edit import FormView # 基于类的表单处理视图(简化表单提交逻辑) + +# gst: 导入关联模型和表单,用于视图数据处理 +from accounts.models import BlogUser # 用户模型(评论作者) +from blog.models import Article # 文章模型(评论关联对象) +from .forms import CommentForm # 评论表单(用于数据验证和提交) +from .models import Comment # 评论模型(用于保存评论数据) + + +class CommentPostView(FormView): + """ + gst: 评论提交视图类,继承FormView(专门处理表单提交的通用视图) + 负责评论表单的展示、数据验证、提交保存及跳转逻辑 + """ + form_class = CommentForm # gst: 关联的表单类(CommentForm),用于验证提交数据 + template_name = 'blog/article_detail.html' # gst: 表单验证失败时渲染的模板(文章详情页) + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + """ + gst: 重写dispatch方法,添加CSRF保护装饰器 + 确保所有通过该视图的请求都经过CSRF验证,防止跨站请求伪造攻击 + """ + return super(CommentPostView, self).dispatch(*args, **kwargs) # gst: 调用父类dispatch方法,保持原有逻辑 + + def get(self, request, *args, **kwargs): + """ + gst: 处理GET请求(直接访问评论提交URL时) + 重定向到对应文章详情页的评论区,避免直接访问表单提交接口 + """ + article_id = self.kwargs['article_id'] # gst: 从URL路径参数中获取文章ID + article = get_object_or_404(Article, pk=article_id) # gst: 查找文章,不存在则返回404 + url = article.get_absolute_url() # gst: 获取文章的绝对路径(详情页URL) + return HttpResponseRedirect(url + "#comments") # gst: 重定向到文章详情页的评论区锚点 + + def form_invalid(self, form): + """ + gst: 表单数据验证失败时的处理逻辑 + 渲染文章详情页,携带错误表单数据,展示验证失败信息 + """ + article_id = self.kwargs['article_id'] # gst: 从URL参数获取文章ID + article = get_object_or_404(Article, pk=article_id) # gst: 获取对应的文章对象 + + # gst: 返回文章详情页模板,传递错误表单和文章对象(用于页面渲染错误信息) + return self.render_to_response({ + 'form': form, # 验证失败的表单(含错误信息) + 'article': article # 关联的文章对象 + }) + + def form_valid(self, form): + """ + gst: 表单数据验证合法后的核心逻辑 + 处理评论保存、状态设置、回复关联等业务,最后重定向到评论位置 + """ + user = self.request.user # gst: 获取当前登录用户(评论提交者) + author = BlogUser.objects.get(pk=user.pk) # gst: 通过用户ID获取对应的BlogUser对象(评论作者) + article_id = self.kwargs['article_id'] # gst: 从URL参数获取文章ID + article = get_object_or_404(Article, pk=article_id) # gst: 获取评论关联的文章,不存在则404 + + # gst: 检查文章状态:评论关闭(comment_status='c')或文章草稿(status='c')时,禁止评论 + if article.comment_status == 'c' or article.status == 'c': + raise ValidationError("该文章评论已关闭.") # gst: 抛出验证错误,终止评论提交 + + comment = form.save(False) # gst: 表单数据暂存(不立即保存到数据库) + comment.article = article # gst: 关联评论到当前文章 + + # gst: 获取博客全局设置,判断评论是否需要审核 + from djangoblog.utils import get_blog_setting + settings = get_blog_setting() + if not settings.comment_need_review: # gst: 若不需要审核,直接启用评论 + comment.is_enable = True + comment.author = author # gst: 关联评论作者 + + # gst: 处理回复功能:若表单提交了父评论ID,关联到对应的父评论 + if form.cleaned_data['parent_comment_id']: + parent_comment = Comment.objects.get( + pk=form.cleaned_data['parent_comment_id']) # gst: 根据ID获取父评论对象 + comment.parent_comment = parent_comment # gst: 关联当前评论到父评论 + + comment.save(True) # gst: 最终保存评论数据到数据库(True表示执行完整保存逻辑) + + # gst: 重定向到文章详情页的当前评论位置(锚点定位到具体评论) + return HttpResponseRedirect( + "%s#div-comment-%d" % + (article.get_absolute_url(), comment.pk)) # 拼接URL:文章绝对路径 + 评论ID锚点 \ No newline at end of file diff --git a/src/djangoblog/djangoblog/__init__.py b/src/djangoblog/djangoblog/__init__.py new file mode 100644 index 00000000..c4944bb9 --- /dev/null +++ b/src/djangoblog/djangoblog/__init__.py @@ -0,0 +1,8 @@ +# Django应用配置指定模块 +# 该模块的主要功能是定义当前Django应用的默认配置类, +# 当Django加载应用时,会根据此配置类进行应用的初始化设置, +# 包括应用名称、信号注册、权限配置等应用级别的配置项 + +# 指定当前Django应用的默认配置类为'djangoblog.apps.DjangoblogAppConfig' +# Django在启动时会自动加载该配置类,执行其中的初始化逻辑(如ready()方法) +default_app_config = 'djangoblog.apps.DjangoblogAppConfig' \ No newline at end of file diff --git a/src/djangoblog/djangoblog/__pycache__/__init__.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 00000000..40906a49 Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/__init__.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/admin_site.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/admin_site.cpython-310.pyc new file mode 100644 index 00000000..398e03fa Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/admin_site.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/apps.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/apps.cpython-310.pyc new file mode 100644 index 00000000..b4c01a7e Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/apps.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/blog_signals.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/blog_signals.cpython-310.pyc new file mode 100644 index 00000000..c54f77ce Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/blog_signals.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/elasticsearch_backend.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/elasticsearch_backend.cpython-310.pyc new file mode 100644 index 00000000..ec4ebdf1 Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/elasticsearch_backend.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/feeds.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/feeds.cpython-310.pyc new file mode 100644 index 00000000..4f231cbb Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/feeds.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/logentryadmin.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/logentryadmin.cpython-310.pyc new file mode 100644 index 00000000..be411b59 Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/logentryadmin.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/settings.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/settings.cpython-310.pyc new file mode 100644 index 00000000..7c9350d1 Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/settings.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/sitemap.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/sitemap.cpython-310.pyc new file mode 100644 index 00000000..b2aefec5 Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/sitemap.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/spider_notify.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/spider_notify.cpython-310.pyc new file mode 100644 index 00000000..d5e9f55b Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/spider_notify.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/urls.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/urls.cpython-310.pyc new file mode 100644 index 00000000..9b2bb3d7 Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/urls.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/utils.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/utils.cpython-310.pyc new file mode 100644 index 00000000..b6f1d15c Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/utils.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/whoosh_cn_backend.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/whoosh_cn_backend.cpython-310.pyc new file mode 100644 index 00000000..9589c62f Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/whoosh_cn_backend.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/wsgi.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/wsgi.cpython-310.pyc new file mode 100644 index 00000000..cdb3d5d6 Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/wsgi.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/admin_site.py b/src/djangoblog/djangoblog/admin_site.py new file mode 100644 index 00000000..1f0130db --- /dev/null +++ b/src/djangoblog/djangoblog/admin_site.py @@ -0,0 +1,89 @@ +# Django博客系统的Admin配置模块 +# 该模块用于自定义Django管理后台(Admin Site),包括管理员站点的属性设置、权限控制, +# 以及注册系统中各模型到管理后台,实现对数据的可视化管理 + +# 导入Django内置的AdminSite及相关模型、管理类 +from django.contrib.admin import AdminSite +from django.contrib.admin.models import LogEntry +from django.contrib.sites.admin import SiteAdmin +from django.contrib.sites.models import Site + +# 导入各应用的Admin配置和模型(账号、博客、评论、OAuth等模块) +from accounts.admin import * +from blog.admin import * +from blog.models import * +from comments.admin import * +from comments.models import * +from djangoblog.logentryadmin import LogEntryAdmin +from oauth.admin import * +from oauth.models import * +from owntracks.admin import * +from owntracks.models import * +from servermanager.admin import * +from servermanager.models import * + + +class DjangoBlogAdminSite(AdminSite): + """ + 自定义的Django管理站点类,继承自AdminSite + 用于个性化管理后台的显示信息和权限控制 + """ + # 管理后台页面顶部的标题 + site_header = 'djangoblog administration' + # 浏览器标签页的标题 + site_title = 'djangoblog site admin' + + def __init__(self, name='admin'): + """初始化方法,调用父类构造函数""" + super().__init__(name) + + def has_permission(self, request): + """ + 重写权限检查方法,控制谁可以访问管理后台 + 仅允许超级用户(is_superuser)访问 + """ + return request.user.is_superuser + + # 以下为注释掉的自定义URL示例(如需扩展管理后台URL可启用) + # def get_urls(self): + # urls = super().get_urls() + # from django.urls import path + # from blog.views import refresh_memcache + # + # my_urls = [ + # path('refresh/', self.admin_view(refresh_memcache), name="refresh"), + # ] + # return urls + my_urls + + +# 实例化自定义的管理站点 +admin_site = DjangoBlogAdminSite(name='admin') + +# 注册博客核心模型到管理站点,关联对应的Admin配置类 +admin_site.register(Article, ArticlelAdmin) # 文章模型 +admin_site.register(Category, CategoryAdmin) # 分类模型 +admin_site.register(Tag, TagAdmin) # 标签模型 +admin_site.register(Links, LinksAdmin) # 链接模型 +admin_site.register(SideBar, SideBarAdmin) # 侧边栏模型 +admin_site.register(BlogSettings, BlogSettingsAdmin) # 博客设置模型 + +# 注册服务器管理相关模型 +admin_site.register(commands, CommandsAdmin) # 命令模型 +admin_site.register(EmailSendLog, EmailSendLogAdmin) # 邮件发送日志模型 + +# 注册用户模型 +admin_site.register(BlogUser, BlogUserAdmin) # 自定义用户模型 + +# 注册评论模型 +admin_site.register(Comment, CommentAdmin) # 评论模型 + +# 注册OAuth相关模型 +admin_site.register(OAuthUser, OAuthUserAdmin) # OAuth用户模型 +admin_site.register(OAuthConfig, OAuthConfigAdmin) # OAuth配置模型 + +# 注册位置追踪相关模型 +admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # 位置日志模型 + +# 注册站点和日志模型(Django内置) +admin_site.register(Site, SiteAdmin) # 站点模型 +admin_site.register(LogEntry, LogEntryAdmin) # 操作日志模型 \ No newline at end of file diff --git a/src/djangoblog/djangoblog/apps.py b/src/djangoblog/djangoblog/apps.py new file mode 100644 index 00000000..6316b6c8 --- /dev/null +++ b/src/djangoblog/djangoblog/apps.py @@ -0,0 +1,29 @@ +# Django博客应用配置类模块 +# 该模块定义了Django博客应用(djangoblog)的配置类,用于设置应用的核心属性和初始化逻辑 +# 主要功能包括:指定默认的自增字段类型、定义应用名称、以及在应用就绪时加载插件 + +from django.apps import AppConfig + + +class DjangoblogAppConfig(AppConfig): + """ + Django博客应用的配置类,继承自Django的AppConfig + 用于配置应用的元数据和生命周期钩子 + """ + # 指定模型默认的自增主键字段类型为BigAutoField(支持更大范围的整数) + default_auto_field = 'django.db.models.BigAutoField' + # 应用的名称,对应项目中的应用目录名 + name = 'djangoblog' + + def ready(self): + """ + 应用就绪时执行的方法(Django生命周期钩子) + 当应用加载完成并准备好处理请求时调用,通常用于初始化操作 + """ + # 调用父类的ready()方法,确保基类的初始化逻辑执行 + super().ready() + # 导入并加载插件:在应用就绪后加载所有激活的插件 + # 从当前应用的plugin_manage.loader模块导入load_plugins函数 + from .plugin_manage.loader import load_plugins + # 执行插件加载函数,完成插件的动态导入和初始化 + load_plugins() \ No newline at end of file diff --git a/src/djangoblog/djangoblog/blog_signals.py b/src/djangoblog/djangoblog/blog_signals.py new file mode 100644 index 00000000..065de3ae --- /dev/null +++ b/src/djangoblog/djangoblog/blog_signals.py @@ -0,0 +1,177 @@ +# Django博客系统信号处理模块 +# 该模块用于注册和处理Django内置信号及自定义信号,实现事件驱动的业务逻辑 +# 核心功能包括:邮件发送、OAuth用户登录处理、模型保存后缓存清理/搜索引擎通知、用户登录登出缓存处理等 +# 通过信号机制解耦业务逻辑,当特定事件触发时自动执行对应处理函数 + +import _thread +import logging + +import django.dispatch +from django.conf import settings +from django.contrib.admin.models import LogEntry +from django.contrib.auth.signals import user_logged_in, user_logged_out +from django.core.mail import EmailMultiAlternatives +from django.db.models.signals import post_save +from django.dispatch import receiver + +# 导入项目内部模块:评论相关、插件通知、缓存工具、站点工具、OAuth模型 +from comments.models import Comment +from comments.utils import send_comment_email +from djangoblog.spider_notify import SpiderNotify +from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, delete_view_cache +from djangoblog.utils import get_current_site +from oauth.models import OAuthUser + +# 初始化日志记录器,记录信号处理过程中的信息和错误 +logger = logging.getLogger(__name__) + +# 自定义信号:OAuth用户登录信号,携带参数'id'(OAuthUser的主键) +oauth_user_login_signal = django.dispatch.Signal(['id']) +# 自定义信号:邮件发送信号,携带参数'emailto'(收件人列表)、'title'(邮件标题)、'content'(邮件内容) +send_email_signal = django.dispatch.Signal( + ['emailto', 'title', 'content']) + + +@receiver(send_email_signal) +def send_email_signal_handler(sender, **kwargs): + """ + 邮件发送信号的处理函数 + 当send_email_signal信号触发时,自动发送HTML格式邮件并记录发送日志 + """ + # 从信号参数中提取邮件相关信息 + emailto = kwargs['emailto'] + title = kwargs['title'] + content = kwargs['content'] + + # 构建HTML格式邮件(content_subtype设为html支持富文本) + msg = EmailMultiAlternatives( + title, + content, + from_email=settings.DEFAULT_FROM_EMAIL, # 发件人从Django配置中读取 + to=emailto) # 收件人列表 + msg.content_subtype = "html" # 指定邮件内容为HTML格式 + + # 初始化邮件发送日志模型,记录发送详情 + from servermanager.models import EmailSendLog + log = EmailSendLog() + log.title = title + log.content = content + log.emailto = ','.join(emailto) # 多个收件人用逗号拼接存储 + + try: + # 发送邮件,result为成功发送的邮件数量 + result = msg.send() + log.send_result = result > 0 # 发送数量>0表示发送成功 + except Exception as e: + # 捕获发送异常,记录错误日志 + logger.error(f"失败邮箱号: {emailto}, {e}") + log.send_result = False # 标记发送失败 + log.save() # 保存日志到数据库 + + +@receiver(oauth_user_login_signal) +def oauth_user_login_signal_handler(sender, **kwargs): + """ + OAuth用户登录信号的处理函数 + 当OAuth用户登录成功后,处理用户头像(如跨域头像本地化存储)并清理侧边栏缓存 + """ + # 从信号参数中提取OAuthUser的id,查询对应的用户实例 + id = kwargs['id'] + oauthuser = OAuthUser.objects.get(id=id) + # 获取当前站点域名(用于判断头像是否为本地地址) + site = get_current_site().domain + + # 若用户有头像且头像地址不包含当前站点域名(跨域头像),则本地化存储 + if oauthuser.picture and not oauthuser.picture.find(site) >= 0: + from djangoblog.utils import save_user_avatar + oauthuser.picture = save_user_avatar(oauthuser.picture) # 保存头像到本地并更新地址 + oauthuser.save() # 保存更新后的用户信息 + + # 清理侧边栏缓存(确保登录后侧边栏展示最新数据) + delete_sidebar_cache() + + +@receiver(post_save) +def model_post_save_callback( + sender, + instance, + created, + raw, + using, + update_fields, + **kwargs): + """ + Django模型保存后信号的处理函数(post_save) + 触发时机:任何模型执行save()方法后(新增/更新) + 主要处理:搜索引擎通知、缓存清理、评论审核通过后的联动操作 + """ + clearcache = False # 标记是否需要清理全局缓存 + + # 跳过Admin操作日志模型(LogEntry)的处理,无需触发后续逻辑 + if isinstance(instance, LogEntry): + return + + # 若模型实例有get_full_url方法(通常是博客文章等需要对外展示的模型) + if 'get_full_url' in dir(instance): + # 判断是否仅更新浏览量字段(views) + is_update_views = update_fields == {'views'} + # 非测试环境且不是仅更新浏览量时,通知搜索引擎(如百度)收录新链接 + if not settings.TESTING and not is_update_views: + try: + notify_url = instance.get_full_url() # 获取模型实例的完整访问链接 + SpiderNotify.baidu_notify([notify_url]) # 通知百度搜索引擎 + except Exception as ex: + logger.error("notify sipder", ex) # 记录搜索引擎通知失败的错误 + # 非浏览量更新时,标记需要清理缓存 + if not is_update_views: + clearcache = True + + # 若保存的是评论模型实例 + if isinstance(instance, Comment): + # 仅处理审核通过的评论(is_enable为True) + if instance.is_enable: + # 获取评论对应的文章绝对路径 + path = instance.article.get_absolute_url() + site = get_current_site().domain + # 处理带端口的域名(如localhost:8000),仅保留主域名部分 + if site.find(':') > 0: + site = site[0:site.find(':')] + + # 清理文章详情页的视图缓存(确保评论实时展示) + expire_view_cache( + path, + servername=site, + serverport=80, + key_prefix='blogdetail') + # 清理SEO相关缓存 + if cache.get('seo_processor'): + cache.delete('seo_processor') + # 清理该文章的评论列表缓存 + comment_cache_key = 'article_comments_{id}'.format( + id=instance.article.id) + cache.delete(comment_cache_key) + # 清理侧边栏缓存和评论相关视图缓存 + delete_sidebar_cache() + delete_view_cache('article_comments', [str(instance.article.pk)]) + + # 启动新线程发送评论通知邮件(避免阻塞主线程) + _thread.start_new_thread(send_comment_email, (instance,)) + + # 若标记需要清理缓存,则执行全局缓存清理 + if clearcache: + cache.clear() + + +@receiver(user_logged_in) +@receiver(user_logged_out) +def user_auth_callback(sender, request, user, **kwargs): + """ + 用户登录/登出信号的处理函数 + 触发时机:用户登录(user_logged_in)或登出(user_logged_out)后 + 主要处理:记录日志并清理侧边栏缓存(确保登录状态变化后展示最新数据) + """ + # 若用户存在且用户名有效 + if user and user.username: + logger.info(user) # 记录用户登录/登出日志 + delete_sidebar_cache() # 清理侧边栏缓存 + # cache.clear() # 全局缓存清理(按需启用) \ No newline at end of file diff --git a/src/djangoblog/djangoblog/elasticsearch_backend.py b/src/djangoblog/djangoblog/elasticsearch_backend.py new file mode 100644 index 00000000..2ccc3f80 --- /dev/null +++ b/src/djangoblog/djangoblog/elasticsearch_backend.py @@ -0,0 +1,183 @@ +from django.utils.encoding import force_str +from elasticsearch_dsl import Q +from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query +from haystack.forms import ModelSearchForm +from haystack.models import SearchResult +from haystack.utils import log as logging + +from blog.documents import ArticleDocument, ArticleDocumentManager +from blog.models import Article + +logger = logging.getLogger(__name__) + + +class ElasticSearchBackend(BaseSearchBackend): + def __init__(self, connection_alias, **connection_options): + super( + ElasticSearchBackend, + self).__init__( + connection_alias, + **connection_options) + self.manager = ArticleDocumentManager() + self.include_spelling = True + + def _get_models(self, iterable): + models = iterable if iterable and iterable[0] else Article.objects.all() + docs = self.manager.convert_to_doc(models) + return docs + + def _create(self, models): + self.manager.create_index() + docs = self._get_models(models) + self.manager.rebuild(docs) + + def _delete(self, models): + for m in models: + m.delete() + return True + + def _rebuild(self, models): + models = models if models else Article.objects.all() + docs = self.manager.convert_to_doc(models) + self.manager.update_docs(docs) + + def update(self, index, iterable, commit=True): + + models = self._get_models(iterable) + self.manager.update_docs(models) + + def remove(self, obj_or_string): + models = self._get_models([obj_or_string]) + self._delete(models) + + def clear(self, models=None, commit=True): + self.remove(None) + + @staticmethod + def get_suggestion(query: str) -> str: + """获取推荐词, 如果没有找到添加原搜索词""" + + search = ArticleDocument.search() \ + .query("match", body=query) \ + .suggest('suggest_search', query, term={'field': 'body'}) \ + .execute() + + keywords = [] + for suggest in search.suggest.suggest_search: + if suggest["options"]: + keywords.append(suggest["options"][0]["text"]) + else: + keywords.append(suggest["text"]) + + return ' '.join(keywords) + + @log_query + def search(self, query_string, **kwargs): + logger.info('search query_string:' + query_string) + + start_offset = kwargs.get('start_offset') + end_offset = kwargs.get('end_offset') + + # 推荐词搜索 + if getattr(self, "is_suggest", None): + suggestion = self.get_suggestion(query_string) + else: + suggestion = query_string + + q = Q('bool', + should=[Q('match', body=suggestion), Q('match', title=suggestion)], + minimum_should_match="70%") + + search = ArticleDocument.search() \ + .query('bool', filter=[q]) \ + .filter('term', status='p') \ + .filter('term', type='a') \ + .source(False)[start_offset: end_offset] + + results = search.execute() + hits = results['hits'].total + raw_results = [] + for raw_result in results['hits']['hits']: + app_label = 'blog' + model_name = 'Article' + additional_fields = {} + + result_class = SearchResult + + result = result_class( + app_label, + model_name, + raw_result['_id'], + raw_result['_score'], + **additional_fields) + raw_results.append(result) + facets = {} + spelling_suggestion = None if query_string == suggestion else suggestion + + return { + 'results': raw_results, + 'hits': hits, + 'facets': facets, + 'spelling_suggestion': spelling_suggestion, + } + + +class ElasticSearchQuery(BaseSearchQuery): + def _convert_datetime(self, date): + if hasattr(date, 'hour'): + return force_str(date.strftime('%Y%m%d%H%M%S')) + else: + return force_str(date.strftime('%Y%m%d000000')) + + def clean(self, query_fragment): + """ + Provides a mechanism for sanitizing user input before presenting the + value to the backend. + + Whoosh 1.X differs here in that you can no longer use a backslash + to escape reserved characters. Instead, the whole word should be + quoted. + """ + words = query_fragment.split() + cleaned_words = [] + + for word in words: + if word in self.backend.RESERVED_WORDS: + word = word.replace(word, word.lower()) + + for char in self.backend.RESERVED_CHARACTERS: + if char in word: + word = "'%s'" % word + break + + cleaned_words.append(word) + + return ' '.join(cleaned_words) + + def build_query_fragment(self, field, filter_type, value): + return value.query_string + + def get_count(self): + results = self.get_results() + return len(results) if results else 0 + + def get_spelling_suggestion(self, preferred_query=None): + return self._spelling_suggestion + + def build_params(self, spelling_query=None): + kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query) + return kwargs + + +class ElasticSearchModelSearchForm(ModelSearchForm): + + def search(self): + # 是否建议搜索 + self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no" + sqs = super().search() + return sqs + + +class ElasticSearchEngine(BaseEngine): + backend = ElasticSearchBackend + query = ElasticSearchQuery \ No newline at end of file diff --git a/src/djangoblog/djangoblog/feeds.py b/src/djangoblog/djangoblog/feeds.py new file mode 100644 index 00000000..ee07797f --- /dev/null +++ b/src/djangoblog/djangoblog/feeds.py @@ -0,0 +1,72 @@ +#姜雨菲:导入Django用户模型获取工具 +from django.contrib.auth import get_user_model +#姜雨菲: 导入Django的Feed基类,用于创建RSS/Atom订阅源 +from django.contrib.syndication.views import Feed +#姜雨菲: 导入时区处理工具 +from django.utils import timezone +#姜雨菲: 导入RSS 2.0版本的生成器 +from django.utils.feedgenerator import Rss201rev2Feed + +#姜雨菲: 导入博客文章模型 +from blog.models import Article +#姜雨菲: 导入自定义的Markdown处理工具 +from djangoblog.utils import CommonMarkdown + + +class DjangoBlogFeed(Feed): + """博客网站的RSS订阅源类,继承自Django的Feed基类""" + + #姜雨菲: 指定订阅源类型为RSS 2.0版本 + feed_type = Rss201rev2Feed + + # 订阅源的描述信息 + description = '大巧无工,重剑无锋.' + # 订阅源的标题 + title = "且听风吟 大巧无工,重剑无锋. " + # 订阅源的链接(相对路径) + link = "/feed/" + + def author_name(self): + """返回订阅源作者名称""" + # 获取第一个用户的昵称作为作者名 + return get_user_model().objects.first().nickname + + def author_link(self): + """返回订阅源作者的链接""" + # 获取第一个用户的绝对URL + return get_user_model().objects.first().get_absolute_url() + + def items(self): + """ + 定义订阅源包含的项目列表 + 返回最新发布的5篇文章 + """ + # 筛选类型为'article'(a)且状态为'published'(p)的文章 + # 按发布时间倒序排列,取前5篇 + return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5] + + def item_title(self, item): + """返回单个项目(文章)的标题""" + return item.title + + def item_description(self, item): + """返回单个项目(文章)的描述""" + # 将文章正文从Markdown格式转换为HTML + return CommonMarkdown.get_markdown(item.body) + + def feed_copyright(self): + """返回订阅源的版权信息""" + # 获取当前时间,并格式化版权信息 + now = timezone.now() + return "Copyright© {year} 且听风吟".format(year=now.year) + + def item_link(self, item): + """返回单个项目(文章)的链接""" + return item.get_absolute_url() + + def item_guid(self, item): + """ + 返回单个项目的唯一标识符(guid) + 此处返回空值,实际应用中通常应返回唯一标识如文章ID等 + """ + return \ No newline at end of file diff --git a/src/djangoblog/djangoblog/logentryadmin.py b/src/djangoblog/djangoblog/logentryadmin.py new file mode 100644 index 00000000..4ac7f9b8 --- /dev/null +++ b/src/djangoblog/djangoblog/logentryadmin.py @@ -0,0 +1,136 @@ +#姜雨菲: 导入Django管理后台核心模块 +from django.contrib import admin +#姜雨菲: 导入日志相关常量和模型 +from django.contrib.admin.models import DELETION # 表示"删除"操作的常量 +from django.contrib.contenttypes.models import ContentType # 内容类型模型,用于关联不同模型 +#姜雨菲: 导入URL反向解析和异常处理 +from django.urls import reverse, NoReverseMatch +# 导入字符串处理工具 +from django.utils.encoding import force_str +# 导入HTML转义工具 +from django.utils.html import escape +# 导入安全字符串标记工具(用于渲染HTML) +from django.utils.safestring import mark_safe +# 导入国际化翻译工具 +from django.utils.translation import gettext_lazy as _ + + +class LogEntryAdmin(admin.ModelAdmin): + """ + 自定义管理员日志(LogEntry)的管理类 + 用于在Django admin后台展示和管理系统操作日志 + """ + + # 列表页的筛选器:按内容类型筛选 + list_filter = [ + 'content_type' + ] + + # 搜索字段:支持按对象表示和变更消息搜索 + search_fields = [ + 'object_repr', # 对象的字符串表示 + 'change_message' # 操作变更的描述信息 + ] + + # 列表页中可点击的链接字段 + list_display_links = [ + 'action_time', # 操作时间 + 'get_change_message', # 变更消息 + ] + + # 列表页展示的字段 + list_display = [ + 'action_time', # 操作时间 + 'user_link', # 操作用户(带链接) + 'content_type', # 操作的内容类型(模型) + 'object_link', # 操作的对象(带链接) + 'get_change_message', # 变更消息 + ] + + def has_add_permission(self, request): + """禁用添加权限:不允许手动添加日志记录""" + return False + + def has_change_permission(self, request, obj=None): + """ + 限制修改权限: + - 仅超级用户或拥有change_logentry权限的用户可查看 + - 禁止POST请求(即不允许修改日志内容) + """ + return ( + request.user.is_superuser or + request.user.has_perm('admin.change_logentry') + ) and request.method != 'POST' + + def has_delete_permission(self, request, obj=None): + """禁用删除权限:不允许删除日志记录""" + return False + + def object_link(self, obj): + """ + 生成操作对象的链接(若对象存在) + 对于已删除的对象,仅显示文本;对于存在的对象,显示可点击的链接 + """ + # 先对对象的字符串表示进行HTML转义,防止XSS攻击 + object_link = escape(obj.object_repr) + # 获取操作对象的内容类型 + content_type = obj.content_type + + # 如果不是删除操作且内容类型存在,尝试生成编辑链接 + if obj.action_flag != DELETION and content_type is not None: + try: + # 反向解析对象的编辑页面URL + url = reverse( + # 生成admin的URL名称格式:app_label_model_change + 'admin:{}_{}_change'.format(content_type.app_label, + content_type.model), + args=[obj.object_id] # 传递对象ID作为参数 + ) + # 生成带链接的HTML + object_link = '{}'.format(url, object_link) + except NoReverseMatch: + # 若无法解析URL(如模型未注册到admin),则只显示文本 + pass + # 标记为安全字符串,允许Django渲染HTML + return mark_safe(object_link) + + # 配置列表页字段的排序和显示名称 + object_link.admin_order_field = 'object_repr' # 允许按对象表示排序 + object_link.short_description = _('object') # 列表页显示的列名(支持国际化) + + def user_link(self, obj): + """生成操作用户的链接(指向用户编辑页面)""" + # 获取用户模型的内容类型 + content_type = ContentType.objects.get_for_model(type(obj.user)) + # 对用户名进行HTML转义 + user_link = escape(force_str(obj.user)) + try: + # 反向解析用户编辑页面的URL + url = reverse( + 'admin:{}_{}_change'.format(content_type.app_label, + content_type.model), + args=[obj.user.pk] # 传递用户ID作为参数 + ) + # 生成带链接的HTML + user_link = '{}'.format(url, user_link) + except NoReverseMatch: + # 若无法解析URL,只显示用户名 + pass + # 标记为安全字符串,允许渲染HTML + return mark_safe(user_link) + + # 配置用户链接字段的排序和显示名称 + user_link.admin_order_field = 'user' # 允许按用户排序 + user_link.short_description = _('user') # 列表页显示的列名(支持国际化) + + def get_queryset(self, request): + """优化查询集:预加载content_type,减少数据库查询次数""" + queryset = super(LogEntryAdmin, self).get_queryset(request) + return queryset.prefetch_related('content_type') # 使用预加载优化性能 + + def get_actions(self, request): + """移除批量删除操作:不允许批量删除日志""" + actions = super(LogEntryAdmin, self).get_actions(request) + if 'delete_selected' in actions: + del actions['delete_selected'] + return actions \ No newline at end of file diff --git a/src/djangoblog/djangoblog/plugin_manage/__pycache__/base_plugin.cpython-310.pyc b/src/djangoblog/djangoblog/plugin_manage/__pycache__/base_plugin.cpython-310.pyc new file mode 100644 index 00000000..a7f82f33 Binary files /dev/null and b/src/djangoblog/djangoblog/plugin_manage/__pycache__/base_plugin.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/plugin_manage/__pycache__/hook_constants.cpython-310.pyc b/src/djangoblog/djangoblog/plugin_manage/__pycache__/hook_constants.cpython-310.pyc new file mode 100644 index 00000000..829013a7 Binary files /dev/null and b/src/djangoblog/djangoblog/plugin_manage/__pycache__/hook_constants.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/plugin_manage/__pycache__/hooks.cpython-310.pyc b/src/djangoblog/djangoblog/plugin_manage/__pycache__/hooks.cpython-310.pyc new file mode 100644 index 00000000..4e716db3 Binary files /dev/null and b/src/djangoblog/djangoblog/plugin_manage/__pycache__/hooks.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/plugin_manage/__pycache__/loader.cpython-310.pyc b/src/djangoblog/djangoblog/plugin_manage/__pycache__/loader.cpython-310.pyc new file mode 100644 index 00000000..1ea44af0 Binary files /dev/null and b/src/djangoblog/djangoblog/plugin_manage/__pycache__/loader.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/plugin_manage/base_plugin.py b/src/djangoblog/djangoblog/plugin_manage/base_plugin.py new file mode 100644 index 00000000..71c51aac --- /dev/null +++ b/src/djangoblog/djangoblog/plugin_manage/base_plugin.py @@ -0,0 +1,247 @@ +import logging # 用于日志记录 +from pathlib import Path # 用于文件路径处理 + +from django.template import TemplateDoesNotExist # Django模板不存在异常 +from django.template.loader import render_to_string # 用于渲染模板为字符串 + +# 创建日志记录器,用于记录插件相关日志 +logger = logging.getLogger(__name__) + + +class BasePlugin: + """ + 插件基类,所有自定义插件需继承此类并实现特定方法。 + 提供插件元数据管理、位置渲染、模板渲染、静态资源处理等基础功能。 + """ + + # 插件元数据(子类必须重写这些属性) + PLUGIN_NAME = None # 插件名称(如"天气插件") + PLUGIN_DESCRIPTION = None # 插件描述(功能说明) + PLUGIN_VERSION = None # 插件版本(如"1.0.0") + PLUGIN_AUTHOR = None # 插件作者 + + # 插件配置(子类可根据需求重写) + SUPPORTED_POSITIONS = [] # 支持的显示位置(如['sidebar', 'footer']表示支持侧边栏和页脚) + DEFAULT_PRIORITY = 100 # 默认优先级(数字越小优先级越高,用于多个插件在同一位置排序) + POSITION_PRIORITIES = {} # 各位置的优先级(覆盖默认值,如{'sidebar': 50}表示侧边栏优先级为50) + + def __init__(self): + """初始化插件,验证元数据并设置基础属性""" + # 校验必须的元数据是否完整,不完整则抛出异常 + if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]): + raise ValueError("插件元数据(PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION)必须定义。") + + # 设置插件路径和唯一标识 + self.plugin_dir = self._get_plugin_directory() # 插件所在目录路径 + self.plugin_slug = self._get_plugin_slug() # 插件唯一标识(默认使用目录名) + + # 初始化插件并注册钩子 + self.init_plugin() + self.register_hooks() + + def _get_plugin_directory(self): + """获取插件所在的目录路径(内部方法)""" + import inspect + # 通过inspect模块获取当前类的定义文件路径,再获取其所在目录 + plugin_file = inspect.getfile(self.__class__) + return Path(plugin_file).parent + + def _get_plugin_slug(self): + """获取插件的唯一标识符(默认使用插件目录名,内部方法)""" + return self.plugin_dir.name + + def init_plugin(self): + """ + 插件初始化逻辑(钩子方法) + 子类可重写此方法实现自定义初始化操作(如加载配置、连接数据库等) + """ + logger.info(f'{self.PLUGIN_NAME} 初始化完成。') + + def register_hooks(self): + """ + 注册插件钩子(钩子方法) + 子类可重写此方法注册自定义钩子(如响应Django信号、注册URL路由等) + """ + pass + + # === 位置渲染系统 === + def render_position_widget(self, position, context, **kwargs): + """ + 根据指定位置渲染插件组件(核心方法) + + Args: + position: 位置标识(如'sidebar'表示侧边栏) + context: 模板上下文(包含当前请求、用户等信息) + **kwargs: 额外参数(如文章ID、页面类型等) + + Returns: + dict: 包含渲染结果的字典,格式为: + {'html': 'HTML内容', 'priority': 优先级, 'plugin_name': 插件名} + 若不支持该位置或不满足显示条件,返回None + """ + # 检查当前位置是否在插件支持的位置列表中 + if position not in self.SUPPORTED_POSITIONS: + return None + + # 检查是否满足显示条件(调用should_display方法) + if not self.should_display(position, context, **kwargs): + return None + + # 动态调用对应位置的渲染方法(如position为'sidebar'则调用render_sidebar_widget) + method_name = f'render_{position}_widget' + if hasattr(self, method_name): + # 调用具体位置的渲染方法获取HTML内容 + html = getattr(self, method_name)(context, **kwargs) + if html: # 若渲染成功(有HTML内容) + # 确定当前位置的优先级(优先使用POSITION_PRIORITIES,否则用默认值) + priority = self.POSITION_PRIORITIES.get(position, self.DEFAULT_PRIORITY) + return { + 'html': html, + 'priority': priority, + 'plugin_name': self.PLUGIN_NAME + } + + return None + + def should_display(self, position, context, **kwargs): + """ + 判断插件是否应该在指定位置显示(钩子方法) + 子类可重写此方法实现条件显示逻辑(如只在特定页面/用户组显示) + + Args: + position: 位置标识 + context: 模板上下文 + **kwargs: 额外参数 + + Returns: + bool: True表示显示,False表示不显示 + """ + return True # 默认始终显示 + + # === 各位置渲染方法 - 子类需根据支持的位置重写 === + def render_sidebar_widget(self, context, **kwargs): + """渲染侧边栏组件(钩子方法),子类重写此方法实现侧边栏内容""" + return None + + def render_article_bottom_widget(self, context, **kwargs): + """渲染文章底部组件(钩子方法),子类重写此方法实现文章底部内容""" + return None + + def render_article_top_widget(self, context, **kwargs): + """渲染文章顶部组件(钩子方法),子类重写此方法实现文章顶部内容""" + return None + + def render_header_widget(self, context, **kwargs): + """渲染页头组件(钩子方法),子类重写此方法实现页头内容""" + return None + + def render_footer_widget(self, context, **kwargs): + """渲染页脚组件(钩子方法),子类重写此方法实现页脚内容""" + return None + + def render_comment_before_widget(self, context, **kwargs): + """渲染评论前组件(钩子方法),子类重写此方法实现评论区前内容""" + return None + + def render_comment_after_widget(self, context, **kwargs): + """渲染评论后组件(钩子方法),子类重写此方法实现评论区后内容""" + return None + + # === 模板系统 === + def render_template(self, template_name, context=None): + """ + 渲染插件自带的模板文件 + + Args: + template_name: 模板文件名(如"sidebar.html") + context: 模板上下文(传递给模板的变量) + + Returns: + str: 渲染后的HTML字符串(模板不存在则返回空字符串) + """ + if context is None: + context = {} # 默认为空上下文 + + # 构建模板路径:plugins/插件标识/模板名(遵循Django模板查找规则) + template_path = f"plugins/{self.plugin_slug}/{template_name}" + + try: + # 调用Django的render_to_string渲染模板 + return render_to_string(template_path, context) + except TemplateDoesNotExist: + # 模板不存在时记录警告日志 + logger.warning(f"插件模板不存在:{template_path}") + return "" + + # === 静态资源系统 === + def get_static_url(self, static_file): + """ + 获取插件静态文件的URL(如CSS、JS、图片等) + + Args: + static_file: 静态文件相对路径(如"css/style.css") + + Returns: + str: 静态文件的完整URL(如"/static/myplugin/static/myplugin/css/style.css") + """ + from django.templatetags.static import static # 导入Django的static标签 + # 构建静态文件路径:插件标识/static/插件标识/文件路径(遵循Django静态文件规则) + return static(f"{self.plugin_slug}/static/{self.plugin_slug}/{static_file}") + + def get_css_files(self): + """ + 获取插件需要加载的CSS文件列表(钩子方法) + 子类重写此方法返回CSS文件路径列表,框架会自动在页面加载这些CSS + + Returns: + list: CSS文件路径列表(如["css/style.css"]) + """ + return [] + + def get_js_files(self): + """ + 获取插件需要加载的JavaScript文件列表(钩子方法) + 子类重写此方法返回JS文件路径列表,框架会自动在页面加载这些JS + + Returns: + list: JS文件路径列表(如["js/script.js"]) + """ + return [] + + def get_head_html(self, context=None): + """ + 获取需要插入到HTML头部(标签内)的内容(钩子方法) + 子类重写此方法返回自定义HTML(如额外的CSS链接、meta标签等) + + Returns: + str: 要插入的HTML字符串 + """ + return "" + + def get_body_html(self, context=None): + """ + 获取需要插入到HTML body底部的内容(钩子方法) + 子类重写此方法返回自定义HTML(如额外的JS脚本) + + Returns: + str: 要插入底部的HTML字符串 + """ + return "" + + def get_plugin_info(self): + """ + 获取插件的详细信息(用于插件管理、展示等) + + Returns: + dict: 包含插件元数据和配置的字典 + """ + return { + 'name': self.PLUGIN_NAME, + 'description': self.PLUGIN_DESCRIPTION, + 'version': self.PLUGIN_VERSION, + 'author': self.PLUGIN_AUTHOR, + 'slug': self.plugin_slug, + 'directory': str(self.plugin_dir), + 'supported_positions': self.SUPPORTED_POSITIONS, + 'priorities': self.POSITION_PRIORITIES + } \ No newline at end of file diff --git a/src/djangoblog/djangoblog/plugin_manage/hook_constants.py b/src/djangoblog/djangoblog/plugin_manage/hook_constants.py new file mode 100644 index 00000000..56d4d858 --- /dev/null +++ b/src/djangoblog/djangoblog/plugin_manage/hook_constants.py @@ -0,0 +1,35 @@ +# 文章相关事件常量模块 +# 该模块定义了与文章操作相关的事件常量、内容钩子常量、位置钩子常量以及资源注入钩子常量 +# 这些常量用于在系统中统一标识不同的操作事件和钩子位置,便于模块间的交互和扩展 + +# 文章操作事件常量 +# 用于标识文章详情加载事件,当加载文章详情时触发相关处理逻辑 +ARTICLE_DETAIL_LOAD = 'article_detail_load' +# 用于标识文章创建事件,当创建新文章时触发相关处理逻辑 +ARTICLE_CREATE = 'article_create' +# 用于标识文章更新事件,当更新已有文章时触发相关处理逻辑 +ARTICLE_UPDATE = 'article_update' +# 用于标识文章删除事件,当删除文章时触发相关处理逻辑 +ARTICLE_DELETE = 'article_delete' + +# 文章内容钩子常量 +# 定义文章内容处理的钩子名称,用于在文章内容渲染前后插入自定义处理逻辑 +ARTICLE_CONTENT_HOOK_NAME = "the_content" + +# 位置钩子常量字典 +# 键为位置标识,值为对应的钩子名称,用于在页面不同位置挂载自定义组件或逻辑 +POSITION_HOOKS = { + 'article_top': 'article_top_widgets', # 文章顶部位置的钩子,用于挂载顶部组件 + 'article_bottom': 'article_bottom_widgets', # 文章底部位置的钩子,用于挂载底部组件 + 'sidebar': 'sidebar_widgets', # 侧边栏位置的钩子,用于挂载侧边栏组件 + 'header': 'header_widgets', # 页头位置的钩子,用于挂载页头组件 + 'footer': 'footer_widgets', # 页脚位置的钩子,用于挂载页脚组件 + 'comment_before': 'comment_before_widgets', # 评论区之前位置的钩子,用于在评论前插入内容 + 'comment_after': 'comment_after_widgets', # 评论区之后位置的钩子,用于在评论后插入内容 +} + +# 资源注入钩子常量 +# 用于标识在HTML头部注入资源(如CSS、JS)的钩子,可通过该钩子添加头部资源 +HEAD_RESOURCES_HOOK = 'head_resources' +# 用于标识在HTML body部分注入资源(如JS)的钩子,可通过该钩子添加body资源 +BODY_RESOURCES_HOOK = 'body_resources' diff --git a/src/djangoblog/djangoblog/plugin_manage/hooks.py b/src/djangoblog/djangoblog/plugin_manage/hooks.py new file mode 100644 index 00000000..f89071ba --- /dev/null +++ b/src/djangoblog/djangoblog/plugin_manage/hooks.py @@ -0,0 +1,89 @@ +# 钩子系统核心模块 +# 该模块实现了一个轻量级的钩子(Hook)机制,支持注册回调函数、执行动作钩子(Action Hook)和过滤钩子(Filter Hook) +# 主要功能包括:管理钩子与回调函数的映射关系、按顺序执行钩子回调、处理回调执行过程中的异常并记录日志 +# 适用于需要模块解耦、灵活扩展的场景,通过钩子机制实现不同组件间的间接交互 + +import logging + +# 初始化日志记录器,用于记录钩子注册、执行过程中的调试信息和错误信息 +logger = logging.getLogger(__name__) + +# 私有字典,用于存储钩子名称与回调函数列表的映射关系 +# 键为钩子名称(字符串),值为注册到该钩子的回调函数列表 +_hooks = {} + + +def register(hook_name: str, callback: callable): + """ + 注册一个回调函数到指定的钩子上。 + 同一钩子可以注册多个回调函数,执行时将按注册顺序依次调用。 + + 参数: + hook_name: 钩子名称,用于标识一组相关的回调函数 + callback: 可调用对象(函数、方法等),将在钩子触发时执行 + """ + # 如果钩子名称不在映射表中,初始化一个空列表用于存储回调 + if hook_name not in _hooks: + _hooks[hook_name] = [] + # 将回调函数添加到对应钩子的列表中 + _hooks[hook_name].append(callback) + # 记录调试日志,说明已成功注册回调 + logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'") + + +def run_action(hook_name: str, *args, **kwargs): + """ + 执行指定名称的动作钩子(Action Hook)。 + 动作钩子用于触发一系列操作,不关注返回值,按注册顺序依次执行所有回调函数。 + + 参数: + hook_name: 要执行的钩子名称 + *args: 传递给回调函数的位置参数 + **kwargs: 传递给回调函数的关键字参数 + """ + # 检查该钩子是否有注册的回调函数 + if hook_name in _hooks: + logger.debug(f"Running action hook '{hook_name}'") + # 遍历执行该钩子下的所有回调函数 + for callback in _hooks[hook_name]: + try: + # 调用回调函数并传递参数 + callback(*args, **kwargs) + except Exception as e: + # 捕获并记录回调执行过程中的异常,不中断后续回调 + logger.error( + f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", + exc_info=True # 记录完整的异常堆栈信息,便于调试 + ) + + +def apply_filters(hook_name: str, value, *args, **kwargs): + """ + 执行指定名称的过滤钩子(Filter Hook)。 + 过滤钩子用于对某个值进行一系列处理,将值依次传递给所有回调函数,最终返回处理后的结果。 + + 参数: + hook_name: 要执行的钩子名称 + value: 初始值,将被回调函数依次处理 + *args: 传递给回调函数的额外位置参数 + **kwargs: 传递给回调函数的额外关键字参数 + + 返回: + 经过所有回调函数处理后的最终值 + """ + # 检查该钩子是否有注册的回调函数 + if hook_name in _hooks: + logger.debug(f"Applying filter hook '{hook_name}'") + # 遍历执行该钩子下的所有回调函数,依次处理值 + for callback in _hooks[hook_name]: + try: + # 调用回调函数,传入当前值和其他参数,并用返回值更新当前值 + value = callback(value, *args, **kwargs) + except Exception as e: + # 捕获并记录回调执行过程中的异常,不中断后续回调 + logger.error( + f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", + exc_info=True # 记录完整的异常堆栈信息,便于调试 + ) + # 返回最终处理后的值 + return value \ No newline at end of file diff --git a/src/djangoblog/djangoblog/plugin_manage/loader.py b/src/djangoblog/djangoblog/plugin_manage/loader.py new file mode 100644 index 00000000..63814822 --- /dev/null +++ b/src/djangoblog/djangoblog/plugin_manage/loader.py @@ -0,0 +1,124 @@ +# 插件加载与管理模块 +# 该模块提供了Django应用中插件的动态加载、注册和查询功能 +# 主要功能包括:从指定目录加载激活的插件、维护插件注册表、提供多种插件查询接口 +# 插件需符合特定结构(包含plugin.py及插件实例),通过Django配置指定激活的插件和插件目录 + +import os +import logging +from django.conf import settings + +# 初始化日志记录器,用于记录插件加载过程中的信息和错误 +logger = logging.getLogger(__name__) + +# 全局插件注册表,存储所有已成功加载的插件实例 +_loaded_plugins = [] + + +def load_plugins(): + """ + 从'plugins'目录动态加载并初始化激活的插件。 + 该函数应在Django应用注册表就绪后调用(确保Django配置已加载)。 + 加载逻辑:遍历配置中激活的插件列表,检查插件目录结构完整性,导入并初始化插件实例。 + """ + global _loaded_plugins + # 重置插件注册表,避免重复加载 + _loaded_plugins = [] + + # 遍历配置中激活的插件名称列表(settings.ACTIVE_PLUGINS定义需加载的插件) + for plugin_name in settings.ACTIVE_PLUGINS: + # 构建插件目录的绝对路径(settings.PLUGINS_DIR为插件根目录) + plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name) + # 检查插件目录是否存在且包含必要的plugin.py文件(插件入口) + if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')): + try: + # 动态导入插件模块:从plugins.插件名.plugin导入模块 + # fromlist=['plugin']确保导入子模块而非父模块 + plugin_module = __import__(f'plugins.{plugin_name}.plugin', fromlist=['plugin']) + + # 检查导入的模块是否包含'plugin'属性(插件实例) + if hasattr(plugin_module, 'plugin'): + # 获取插件实例并添加到全局注册表 + plugin_instance = plugin_module.plugin + _loaded_plugins.append(plugin_instance) + # 记录成功加载日志,包含插件名称和插件定义的名称 + logger.info(f"Successfully loaded plugin: {plugin_name} - {plugin_instance.PLUGIN_NAME}") + else: + # 插件模块结构不完整(缺少plugin实例)时记录警告 + logger.warning(f"Plugin {plugin_name} does not have 'plugin' instance") + + except ImportError as e: + # 捕获导入错误(如模块不存在、依赖缺失等) + logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) + except AttributeError as e: + # 捕获属性错误(如插件实例缺少必要属性) + logger.error(f"Failed to get plugin instance: {plugin_name}", exc_info=e) + except Exception as e: + # 捕获其他未预期的错误 + logger.error(f"Unexpected error loading plugin: {plugin_name}", exc_info=e) + + +def get_loaded_plugins(): + """ + 获取所有已加载的插件实例列表。 + + 返回: + list: 包含所有成功加载的插件实例的列表 + """ + return _loaded_plugins + + +def get_plugin_by_name(plugin_name): + """ + 根据插件名称查询插件实例(实际查询的是plugin_slug属性,可能与函数名存在命名兼容)。 + + 参数: + plugin_name: 要查询的插件slug名称 + + 返回: + 匹配的插件实例,若未找到则返回None + """ + for plugin in _loaded_plugins: + if plugin.plugin_slug == plugin_name: + return plugin + return None + + +def get_plugin_by_slug(plugin_slug): + """ + 根据插件slug查询插件实例(与plugin_slug属性精确匹配)。 + + 参数: + plugin_slug: 要查询的插件slug标识 + + 返回: + 匹配的插件实例,若未找到则返回None + """ + for plugin in _loaded_plugins: + if plugin.plugin_slug == plugin_slug: + return plugin + return None + + +def get_plugins_info(): + """ + 获取所有已加载插件的信息字典列表。 + 信息由插件的get_plugin_info()方法提供,通常包含名称、描述、版本等元数据。 + + 返回: + list: 每个元素为一个插件的信息字典 + """ + return [plugin.get_plugin_info() for plugin in _loaded_plugins] + + +def get_plugins_by_position(position): + """ + 获取支持指定位置的所有插件实例(基于插件的SUPPORTED_POSITIONS属性筛选)。 + 用于在页面特定位置渲染插件内容(如侧边栏、页头等)。 + + 参数: + position: 位置标识(如'sidebar'、'header'等,对应POSITION_HOOKS中的键) + + 返回: + list: 所有支持该位置的插件实例 + """ + return [plugin for plugin in _loaded_plugins if position in plugin.SUPPORTED_POSITIONS] \ No newline at end of file diff --git a/src/djangoblog/djangoblog/settings.py b/src/djangoblog/djangoblog/settings.py new file mode 100644 index 00000000..416148e3 --- /dev/null +++ b/src/djangoblog/djangoblog/settings.py @@ -0,0 +1,384 @@ +""" +Django settings for djangoblog project. + +Generated by 'django-admin startproject' using Django 1.10.2. + +For more information on this file, see +https://docs.djangoproject.com/en/1.10/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.10/ref/settings/ +""" +import os +import sys +from pathlib import Path + +from django.utils.translation import gettext_lazy as _ + + +def env_to_bool(env, default): + """将环境变量值转换为布尔值的工具函数""" + str_val = os.environ.get(env) + return default if str_val is None else str_val == 'True' + + +#姜雨菲: 构建项目路径,BASE_DIR为项目根目录 +BASE_DIR = Path(__file__).resolve().parent.parent + +#姜雨菲: 快速开发设置 - 不适用于生产环境 +# 安全警告:生产环境中请保持SECRET_KEY的机密性! +SECRET_KEY = os.environ.get( + 'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6' +# 安全警告:生产环境中请关闭DEBUG模式 +DEBUG = env_to_bool('DJANGO_DEBUG', True) +# 测试环境标识,当执行测试命令时为True +TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test' + +# 允许访问的主机,生产环境需配置具体域名 +ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com'] +# Django 4.0新增配置,指定可信任的CSRF来源 +CSRF_TRUSTED_ORIGINS = ['http://example.com'] + +# 应用定义 +INSTALLED_APPS = [ + # 自定义的Admin配置(简化版) + 'django.contrib.admin.apps.SimpleAdminConfig', + 'django.contrib.auth', # 用户认证应用 + 'django.contrib.contenttypes', # 内容类型框架 + 'django.contrib.sessions', # 会话框架 + 'django.contrib.messages', # 消息框架 + 'django.contrib.staticfiles', # 静态文件管理 + 'django.contrib.sites', # 站点框架(用于多站点管理) + 'django.contrib.sitemaps', # 站点地图框架 + 'mdeditor', # Markdown编辑器应用 + 'haystack', # 搜索框架 + 'blog', # 博客应用 + 'accounts', # 用户账户应用 + 'comments', # 评论应用 + 'oauth', # 第三方登录应用 + 'servermanager', # 服务器管理应用 + 'owntracks', # 位置追踪应用 + 'compressor', # 静态文件压缩应用 + 'djangoblog' # 项目主应用 +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', # 安全中间件(处理HTTPS等安全相关) + 'django.contrib.sessions.middleware.SessionMiddleware', # 会话中间件 + 'django.middleware.locale.LocaleMiddleware', # 国际化中间件(语言切换) + 'django.middleware.gzip.GZipMiddleware', # GZip压缩中间件 + 'django.middleware.common.CommonMiddleware', # 通用中间件(处理请求/响应) + 'django.middleware.csrf.CsrfViewMiddleware', # CSRF保护中间件 + 'django.contrib.auth.middleware.AuthenticationMiddleware', # 认证中间件 + 'django.contrib.messages.middleware.MessageMiddleware', # 消息中间件 + 'django.middleware.clickjacking.XFrameOptionsMiddleware', # 点击劫持保护中间件 + 'django.middleware.http.ConditionalGetMiddleware', # 条件获取中间件(处理304响应) + 'blog.middleware.OnlineMiddleware' # 自定义在线用户统计中间件 +] + +ROOT_URLCONF = 'djangoblog.urls' # 项目URL配置入口 + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', # 模板引擎 + 'DIRS': [os.path.join(BASE_DIR, 'templates')], # 自定义模板目录 + 'APP_DIRS': True, # 是否自动搜索应用内的templates目录 + 'OPTIONS': { + 'context_processors': [ # 模板上下文处理器 + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'blog.context_processors.seo_processor' # 自定义SEO上下文处理器 + ], + }, + }, +] + +WSGI_APPLICATION = 'djangoblog.wsgi.application' # WSGI应用入口 + +# 数据库配置 +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', # 使用MySQL数据库引擎 + 'NAME': 'djangoblog', # 数据库名 + 'USER': 'root', # 数据库用户名 + 'PASSWORD': '050807', # 数据库密码 + 'HOST': '127.0.0.1', # 数据库主机 + 'PORT': 3306, # 数据库端口 + } +} + +# 密码验证配置 +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# 支持的语言列表(国际化配置) +LANGUAGES = ( + ('en', _('English')), + ('zh-hans', _('Simplified Chinese')), + ('zh-hant', _('Traditional Chinese')), +) +# 语言文件路径 +LOCALE_PATHS = ( + os.path.join(BASE_DIR, 'locale'), +) + +LANGUAGE_CODE = 'zh-hans' # 默认语言 +TIME_ZONE = 'Asia/Shanghai' # 时区 +USE_I18N = True # 启用国际化 +USE_L10N = True # 启用本地化格式 +USE_TZ = False # 不使用时区感知模型 + +# 搜索框架Haystack配置 +HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', # 使用Whoosh搜索引擎(中文适配版) + 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), # 索引存储路径 + }, +} +HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor' # 实时信号处理器(自动更新索引) + +# 认证后端配置(支持用户名或邮箱登录) +AUTHENTICATION_BACKENDS = [ + 'accounts.user_login_backend.EmailOrUsernameModelBackend'] + +# 静态文件配置 +STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic') # 静态文件收集目录(生产环境用) +STATIC_URL = '/static/' # 静态文件URL前缀 +STATICFILES = os.path.join(BASE_DIR, 'static') # 静态文件源目录 + +# 插件静态文件目录 +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, 'plugins'), # 插件静态文件目录 +] + +AUTH_USER_MODEL = 'accounts.BlogUser' # 自定义用户模型 +LOGIN_URL = '/login/' # 登录URL + +# 时间格式定义 +TIME_FORMAT = '%Y-%m-%d %H:%M:%S' +DATE_TIME_FORMAT = '%Y-%m-%d' + +# Bootstrap颜色样式 +BOOTSTRAP_COLOR_TYPES = [ + 'default', 'primary', 'success', 'info', 'warning', 'danger' +] + +# 分页配置 +PAGINATE_BY = 10 +# HTTP缓存超时时间(秒) +CACHE_CONTROL_MAX_AGE = 2592000 + +# 缓存配置 +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', # 本地内存缓存 + 'TIMEOUT': 10800, # 缓存超时时间(秒) + 'LOCATION': 'unique-snowflake', # 缓存位置标识 + } +} +# 若存在环境变量,则使用Redis作为缓存 +if os.environ.get("DJANGO_REDIS_URL"): + CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}', + } + } + +SITE_ID = 1 # 站点ID(多站点时使用) +# 百度链接提交通知URL +BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \ + or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn' + +# 邮件配置 +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' # SMTP邮件后端 +EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False) # 是否使用TLS +EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True) # 是否使用SSL +EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com' # 邮件服务器 +EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465) # 邮件端口 +EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER') # 邮件用户名 +EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD') # 邮件密码 +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER # 默认发件人 +SERVER_EMAIL = EMAIL_HOST_USER # 服务器邮件发件人 +# 管理员邮件通知配置 +ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')] +# 微信管理密码(两次MD5加密) +WXADMIN = os.environ.get( + 'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7' + +# 日志配置 +LOG_PATH = os.path.join(BASE_DIR, 'logs') +if not os.path.exists(LOG_PATH): + os.makedirs(LOG_PATH, exist_ok=True) + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'root': { + 'level': 'INFO', + 'handlers': ['console', 'log_file'], + }, + 'formatters': { + 'verbose': { + 'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s', + } + }, + 'filters': { + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse', + }, + 'require_debug_true': { + '()': 'django.utils.log.RequireDebugTrue', + }, + }, + 'handlers': { + 'log_file': { + 'level': 'INFO', + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': os.path.join(LOG_PATH, 'djangoblog.log'), + 'when': 'D', + 'formatter': 'verbose', + 'interval': 1, + 'delay': True, + 'backupCount': 5, + 'encoding': 'utf-8' + }, + 'console': { + 'level': 'DEBUG', + 'filters': ['require_debug_true'], + 'class': 'logging.StreamHandler', + 'formatter': 'verbose' + }, + 'null': { + 'class': 'logging.NullHandler', + }, + 'mail_admins': { + 'level': 'ERROR', + 'filters': ['require_debug_false'], + 'class': 'django.utils.log.AdminEmailHandler' + } + }, + 'loggers': { + 'djangoblog': { + 'handlers': ['log_file', 'console'], + 'level': 'INFO', + 'propagate': True, + }, + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': False, + } + } +} + +# 静态文件查找器(用于Compressor) +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + 'compressor.finders.CompressorFinder', +) +COMPRESS_ENABLED = True # 启用静态文件压缩 +# 根据环境变量决定是否启用离线压缩 +COMPRESS_OFFLINE = os.environ.get('COMPRESS_OFFLINE', 'False').lower() == 'true' + +COMPRESS_OUTPUT_DIR = 'compressed' # 压缩文件输出目录 +COMPRESS_CSS_HASHING_METHOD = 'mtime' # CSS哈希生成方式(基于修改时间) +COMPRESS_JS_HASHING_METHOD = 'mtime' # JS哈希生成方式(基于修改时间) + +# CSS压缩过滤器 +COMPRESS_CSS_FILTERS = [ + 'compressor.filters.css_default.CssAbsoluteFilter', # 处理CSS中的绝对URL + 'compressor.filters.cssmin.CSSCompressorFilter', # CSS压缩器 +] + +# JS压缩过滤器 +COMPRESS_JS_FILTERS = [ + 'compressor.filters.jsmin.SlimItFilter', # JS压缩器 +] + +COMPRESS_CACHE_BACKEND = 'default' # 压缩缓存后端 +COMPRESS_CACHE_KEY_FUNCTION = 'compressor.cache.simple_cachekey' # 缓存键生成函数 + +# 预编译器配置(支持SCSS/SASS) +COMPRESS_PRECOMPILERS = ( + ('text/x-scss', 'django_libsass.SassCompiler'), + ('text/x-sass', 'django_libsass.SassCompiler'), +) + +# 压缩性能优化配置 +COMPRESS_MINT_DELAY = 30 +COMPRESS_MTIME_DELAY = 10 +COMPRESS_REBUILD_TIMEOUT = 2592000 + +COMPRESS_CSS_COMPRESSOR = 'compressor.css.CssCompressor' +COMPRESS_JS_COMPRESSOR = 'compressor.js.JsCompressor' + +# 静态文件存储(带Manifest用于缓存破坏) +STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' + +COMPRESS_URL = STATIC_URL +COMPRESS_ROOT = STATIC_ROOT + +# 媒体文件(用户上传文件)配置 +MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') +MEDIA_URL = '/media/' + +# XFrameOptions配置(允许同域iframe) +X_FRAME_OPTIONS = 'SAMEORIGIN' + +# 安全头部配置 +SECURE_BROWSER_XSS_FILTER = True # 启用XSS过滤 +SECURE_CONTENT_TYPE_NOSNIFF = True # 禁止内容类型嗅探 +SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin' # Referrer策略 + +# 内容安全策略(CSP) +CSP_DEFAULT_SRC = ["'self'"] +CSP_SCRIPT_SRC = ["'self'", "'unsafe-inline'", "cdn.mathjax.org", "*.googleapis.com"] +CSP_STYLE_SRC = ["'self'", "'unsafe-inline'", "*.googleapis.com", "*.gstatic.com"] +CSP_IMG_SRC = ["'self'", "data:", "*.lylinux.net", "*.gravatar.com", "*.githubusercontent.com"] +CSP_FONT_SRC = ["'self'", "*.googleapis.com", "*.gstatic.com"] +CSP_CONNECT_SRC = ["'self'"] +CSP_FRAME_SRC = ["'none'"] +CSP_OBJECT_SRC = ["'none'"] + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # 默认自增字段类型 + +# 若存在环境变量,则使用Elasticsearch作为搜索后端 +if os.environ.get('DJANGO_ELASTICSEARCH_HOST'): + ELASTICSEARCH_DSL = { + 'default': { + 'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST') + }, + } + HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine', + }, + } + +# 插件系统配置 +PLUGINS_DIR = BASE_DIR / 'plugins' +ACTIVE_PLUGINS = [ + 'article_copyright', # 文章版权插件 + 'reading_time', # 阅读时间插件 + 'external_links', # 外部链接插件 + 'view_count', # 阅读计数插件 + 'seo_optimizer', # SEO优化插件 + 'image_lazy_loading', # 图片懒加载插件 + 'article_recommendation', # 文章推荐插件 +] \ No newline at end of file diff --git a/src/djangoblog/djangoblog/sitemap.py b/src/djangoblog/djangoblog/sitemap.py new file mode 100644 index 00000000..75d22e09 --- /dev/null +++ b/src/djangoblog/djangoblog/sitemap.py @@ -0,0 +1,82 @@ +from django.contrib.sitemaps import Sitemap +from django.urls import reverse + +from blog.models import Article, Category, Tag + + +class StaticViewSitemap(Sitemap): + """ + 静态页面的站点地图类 + 用于生成网站中固定URL的页面(如首页)的站点地图条目 + """ + # 页面优先级(0.0-1.0,1.0表示最高优先级) + priority = 0.5 + # 页面内容更新频率(可选值:always, hourly, daily, weekly, monthly, yearly, never) + changefreq = 'daily' + + def items(self): + """返回需要包含在站点地图中的静态URL名称列表""" + # 这里仅包含博客首页的URL名称(对应urls.py中定义的name='blog:index') + return ['blog:index', ] + + def location(self, item): + """根据items返回的URL名称生成完整URL""" + return reverse(item) + + +class ArticleSiteMap(Sitemap): + """文章页面的站点地图类""" + changefreq = "monthly" # 文章内容更新频率为每月 + priority = "0.6" # 文章页面优先级 + + def items(self): + """返回需要包含的文章对象列表""" + # 仅包含状态为已发布(status='p')的文章 + return Article.objects.filter(status='p') + + def lastmod(self, obj): + """返回文章的最后修改时间(用于搜索引擎判断内容是否更新)""" + return obj.last_modify_time + + +class CategorySiteMap(Sitemap): + """分类页面的站点地图类""" + changefreq = "Weekly" # 分类页面更新频率为每周 + priority = "0.6" # 分类页面优先级 + + def items(self): + """返回所有分类对象""" + return Category.objects.all() + + def lastmod(self, obj): + """返回分类的最后修改时间""" + return obj.last_modify_time + + +class TagSiteMap(Sitemap): + """标签页面的站点地图类""" + changefreq = "Weekly" # 标签页面更新频率为每周 + priority = "0.3" # 标签页面优先级(低于文章和分类) + + def items(self): + """返回所有标签对象""" + return Tag.objects.all() + + def lastmod(self, obj): + """返回标签的最后修改时间""" + return obj.last_modify_time + + +class UserSiteMap(Sitemap): + """用户页面的站点地图类""" + changefreq = "Weekly" # 用户页面更新频率为每周 + priority = "0.3" # 用户页面优先级 + + def items(self): + """返回所有发布过文章的作者(去重处理)""" + # 通过文章作者去重,获取所有有发布文章的用户 + return list(set(map(lambda x: x.author, Article.objects.all()))) + + def lastmod(self, obj): + """返回用户的注册时间(作为最后修改时间的替代)""" + return obj.date_joined \ No newline at end of file diff --git a/src/djangoblog/djangoblog/spider_notify.py b/src/djangoblog/djangoblog/spider_notify.py new file mode 100644 index 00000000..c8f9f8c1 --- /dev/null +++ b/src/djangoblog/djangoblog/spider_notify.py @@ -0,0 +1,45 @@ +import logging + +import requests +from django.conf import settings + +# 创建当前模块的日志记录器,用于记录通知相关的日志信息 +logger = logging.getLogger(__name__) + + +class SpiderNotify(): + """ + 搜索引擎爬虫通知类 + 用于向搜索引擎(目前支持百度)提交网站URL,告知内容更新,便于爬虫抓取 + """ + + @staticmethod + def baidu_notify(urls): + """ + 向百度搜索引擎提交URL的静态方法 + 通过百度链接提交通知接口,告知百度新增/更新的页面URL + + Args: + urls (list): 需要提交的URL列表,每个元素为完整的页面URL字符串 + """ + try: + # 将URL列表用换行符拼接,符合百度接口的数据格式要求 + data = '\n'.join(urls) + # 向百度通知接口发送POST请求,提交URL数据 + result = requests.post(settings.BAIDU_NOTIFY_URL, data=data) + # 记录接口返回的响应信息(日志级别:INFO) + logger.info(result.text) + except Exception as e: + # 捕获所有异常并记录错误信息(日志级别:ERROR) + logger.error(e) + + @staticmethod + def notify(url): + """ + 通用通知入口静态方法 + 统一调用百度通知方法,便于后续扩展支持其他搜索引擎 + + Args: + url (list): 需要提交的URL列表,与baidu_notify方法的urls参数格式一致 + """ + SpiderNotify.baidu_notify(url) \ No newline at end of file diff --git a/src/djangoblog/djangoblog/tests.py b/src/djangoblog/djangoblog/tests.py new file mode 100644 index 00000000..586b837c --- /dev/null +++ b/src/djangoblog/djangoblog/tests.py @@ -0,0 +1,51 @@ +from django.test import TestCase + +#姜雨菲: 导入项目工具模块中的所有工具函数/类 +from djangoblog.utils import * + + +class DjangoBlogTest(TestCase): + """ + 博客项目核心工具类的单元测试类 + 用于验证工具函数的功能正确性 + """ + + def setUp(self): + """ + 测试前置方法 + 在每个测试方法执行前调用,可用于初始化测试数据 + 此处暂无需初始化操作,保持空实现 + """ + pass + + def test_utils(self): + """ + 测试工具函数的功能 + 包括SHA256加密、Markdown解析和字典转URL参数功能 + """ + # 测试SHA256加密函数 + md5 = get_sha256('test') # 对字符串'test'进行SHA256加密 + self.assertIsNotNone(md5) # 断言加密结果不为空 + + # 测试Markdown解析功能 + # 定义一段包含标题、代码块、链接的Markdown文本 + c = CommonMarkdown.get_markdown(''' + # Title1 + + ```python + import os + ``` + + [url](https://www.lylinux.net/) + + [ddd](http://www.baidu.com) + + + ''') + self.assertIsNotNone(c) + d = { + 'd': 'key1', + 'd2': 'key2' + } + data = parse_dict_to_url(d) + self.assertIsNotNone(data) diff --git a/src/djangoblog/djangoblog/urls.py b/src/djangoblog/djangoblog/urls.py new file mode 100644 index 00000000..9e2274d4 --- /dev/null +++ b/src/djangoblog/djangoblog/urls.py @@ -0,0 +1,89 @@ +"""djangoblog URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.10/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf import settings +from django.conf.urls.i18n import i18n_patterns +from django.conf.urls.static import static +from django.contrib.sitemaps.views import sitemap +from django.urls import path, include +from django.urls import re_path +from haystack.views import search_view_factory +from django.http import JsonResponse +import time + +from blog.views import EsSearchView +from djangoblog.admin_site import admin_site # 自定义的admin站点 +from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm # ElasticSearch搜索表单 +from djangoblog.feeds import DjangoBlogFeed # RSS订阅源 +from djangoblog.sitemap import ( # 站点地图相关类 + ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap +) + +#姜雨菲: 站点地图配置:将不同类型的页面分别映射到对应的站点地图类 +sitemaps = { + 'blog': ArticleSiteMap, # 文章页面 + 'Category': CategorySiteMap, # 分类页面 + 'Tag': TagSiteMap, # 标签页面 + 'User': UserSiteMap, # 用户页面 + 'static': StaticViewSitemap # 静态页面 +} + +#姜雨菲: 自定义错误页面处理视图 +handler404 = 'blog.views.page_not_found_view' # 404页面未找到 +handler500 = 'blog.views.server_error_view' # 500服务器错误 +handle403 = 'blog.views.permission_denied_view' # 403权限拒绝 + + +def health_check(request): + """ + 健康检查接口 + 用于监控服务是否正常运行,简单返回服务健康状态和时间戳 + """ + return JsonResponse({ + 'status': 'healthy', # 健康状态标识 + 'timestamp': time.time() # 当前时间戳 + }) + +# 基础URL配置(不包含国际化前缀) +urlpatterns = [ + path('i18n/', include('django.conf.urls.i18n')), # 国际化配置入口 + path('health/', health_check, name='health_check'), # 健康检查接口 +] + +# 包含国际化前缀的URL配置(会自动添加语言代码前缀,如/en/、/zh-hans/) +urlpatterns += i18n_patterns( + re_path(r'^admin/', admin_site.urls), # 自定义admin后台URL + re_path(r'', include('blog.urls', namespace='blog')), # 博客应用URL,命名空间blog + re_path(r'mdeditor/', include('mdeditor.urls')), # Markdown编辑器URL + re_path(r'', include('comments.urls', namespace='comment')), # 评论应用URL,命名空间comment + re_path(r'', include('accounts.urls', namespace='account')), # 账户应用URL,命名空间account + re_path(r'', include('oauth.urls', namespace='oauth')), # 第三方登录应用URL,命名空间oauth + # 站点地图XML + re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, + name='django.contrib.sitemaps.views.sitemap'), + re_path(r'^feed/$', DjangoBlogFeed()), # RSS订阅源URL + re_path(r'^rss/$', DjangoBlogFeed()), # 另一个RSS订阅源URL(与feed功能相同) + # 搜索功能URL,使用自定义的EsSearchView和搜索表单 + re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm), + name='search'), + re_path(r'', include('servermanager.urls', namespace='servermanager')), # 服务器管理应用URL + re_path(r'', include('owntracks.urls', namespace='owntracks')), # 位置追踪应用URL + prefix_default_language=False # 不为主语言添加前缀 +) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) # 静态文件URL配置 + +# 开发环境下添加媒体文件(用户上传文件)的URL配置 +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, + document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/src/djangoblog/djangoblog/utils.py b/src/djangoblog/djangoblog/utils.py new file mode 100644 index 00000000..fb8b1f72 --- /dev/null +++ b/src/djangoblog/djangoblog/utils.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python +# encoding: utf-8 + + +import logging +import os +import random +import string +import uuid +from hashlib import sha256 + +import bleach +import markdown +import requests +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.cache import cache +from django.templatetags.static import static + +#姜雨菲: 创建当前模块的日志记录器 +logger = logging.getLogger(__name__) + + +def get_max_articleid_commentid(): + """ + 获取最新文章和评论的ID + 用于获取当前系统中最新发布的文章ID和最新的评论ID + """ + from blog.models import Article + from comments.models import Comment + return (Article.objects.latest().pk, Comment.objects.latest().pk) + + +def get_sha256(str): + """ + 对字符串进行SHA256加密 + :param str: 需要加密的字符串 + :return: 加密后的十六进制字符串 + """ + m = sha256(str.encode('utf-8')) + return m.hexdigest() + + +def cache_decorator(expiration=3 * 60): + """ + 缓存装饰器 + 用于缓存函数返回结果,减少重复计算,默认缓存3分钟 + :param expiration: 缓存过期时间(秒) + :return: 装饰器函数 + """ + def wrapper(func): + def news(*args, **kwargs): + try: + # 尝试从第一个参数(通常是视图实例)获取缓存键 + view = args[0] + key = view.get_cache_key() + except: + # 获取失败时自动生成缓存键 + key = None + if not key: + # 根据函数和参数生成唯一字符串,用于创建缓存键 + unique_str = repr((func, args, kwargs)) + m = sha256(unique_str.encode('utf-8')) + key = m.hexdigest() + # 尝试从缓存获取数据 + value = cache.get(key) + if value is not None: + # 缓存命中时返回结果(过滤默认占位值) + if str(value) == '__default_cache_value__': + return None + else: + return value + else: + # 缓存未命中时执行原函数并缓存结果 + logger.debug( + 'cache_decorator set cache:%s key:%s' % + (func.__name__, key)) + value = func(*args, **kwargs) + if value is None: + cache.set(key, '__default_cache_value__', expiration) + else: + cache.set(key, value, expiration) + return value + + return news + + return wrapper + + +def expire_view_cache(path, servername, serverport, key_prefix=None): + ''' + 刷新视图缓存 + 手动删除指定URL路径的视图缓存 + :param path: URL路径 + :param servername: 主机名 + :param serverport: 端口号 + :param key_prefix: 缓存键前缀 + :return: 是否刷新成功(布尔值) + ''' + from django.http import HttpRequest + from django.utils.cache import get_cache_key + + # 构造模拟请求对象 + request = HttpRequest() + request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport} + request.path = path + + # 获取缓存键并删除缓存 + key = get_cache_key(request, key_prefix=key_prefix, cache=cache) + if key: + logger.info('expire_view_cache:get key:{path}'.format(path=path)) + if cache.get(key): + cache.delete(key) + return True + return False + + +@cache_decorator() +def get_current_site(): + """ + 获取当前站点信息(带缓存) + 从Django的Site模型获取当前站点配置,结果缓存3分钟 + :return: Site模型实例 + """ + site = Site.objects.get_current() + return site + + +class CommonMarkdown: + """ + Markdown解析工具类 + 提供Markdown文本转HTML的功能,支持代码高亮、目录生成等 + """ + @staticmethod + def _convert_markdown(value): + """ + 内部Markdown转换方法 + 配置Markdown解析器并转换文本 + :param value: Markdown格式文本 + :return: (转换后的HTML内容, 目录HTML) + """ + md = markdown.Markdown( + extensions=[ + 'extra', # 额外功能(表格、脚注等) + 'codehilite', # 代码高亮 + 'toc', # 目录生成 + 'tables', # 表格支持 + ] + ) + body = md.convert(value) + toc = md.toc + return body, toc + + @staticmethod + def get_markdown_with_toc(value): + """ + Markdown转HTML(带目录) + :param value: Markdown格式文本 + :return: (HTML内容, 目录HTML) + """ + body, toc = CommonMarkdown._convert_markdown(value) + return body, toc + + @staticmethod + def get_markdown(value): + """ + Markdown转HTML(仅内容,不含目录) + :param value: Markdown格式文本 + :return: 转换后的HTML内容 + """ + body, toc = CommonMarkdown._convert_markdown(value) + return body + + +def send_email(emailto, title, content): + """ + 发送邮件(通过信号机制) + 触发邮件发送信号,解耦邮件发送逻辑 + :param emailto: 收件人邮箱 + :param title: 邮件标题 + :param content: 邮件内容 + """ + from djangoblog.blog_signals import send_email_signal + send_email_signal.send( + send_email.__class__, + emailto=emailto, + title=title, + content=content) + + +def generate_code() -> str: + """生成6位随机数字验证码""" + return ''.join(random.sample(string.digits, 6)) + + +def parse_dict_to_url(dict): + """ + 将字典转换为URL查询字符串 + 对键值对进行URL编码,避免特殊字符问题 + :param dict: 待转换的字典 + :return: URL查询字符串(格式:key1=value1&key2=value2) + """ + from urllib.parse import quote + url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/')) + for k, v in dict.items()]) + return url + + +def get_blog_setting(): + """ + 获取博客系统配置(带缓存) + 从数据库获取博客全局配置,无配置时创建默认配置,结果缓存 + :return: BlogSettings模型实例 + """ + value = cache.get('get_blog_setting') + if value: + return value + else: + from blog.models import BlogSettings + # 无配置时初始化默认配置 + if not BlogSettings.objects.count(): + setting = BlogSettings() + setting.site_name = 'djangoblog' + setting.site_description = '基于Django的博客系统' + setting.site_seo_description = '基于Django的博客系统' + setting.site_keywords = 'Django,Python' + setting.article_sub_length = 300 + setting.sidebar_article_count = 10 + setting.sidebar_comment_count = 5 + setting.show_google_adsense = False + setting.open_site_comment = True + setting.analytics_code = '' + setting.beian_code = '' + setting.show_gongan_code = False + setting.comment_need_review = False + setting.save() + value = BlogSettings.objects.first() + logger.info('set cache get_blog_setting') + cache.set('get_blog_setting', value) + return value + + +def save_user_avatar(url): + ''' + 保存用户头像到本地静态文件目录 + 从URL下载头像并保存,支持常见图片格式,失败时返回默认头像 + :param url: 头像图片URL + :return: 本地头像的静态文件URL + ''' + logger.info(url) + + try: + # 定义头像保存目录 + basedir = os.path.join(settings.STATICFILES, 'avatar') + # 下载头像图片 + rsp = requests.get(url, timeout=2) + if rsp.status_code == 200: + # 目录不存在时创建 + if not os.path.exists(basedir): + os.makedirs(basedir) + + # 验证图片格式 + image_extensions = ['.jpg', '.png', 'jpeg', '.gif'] + isimage = len([i for i in image_extensions if url.endswith(i)]) > 0 + ext = os.path.splitext(url)[1] if isimage else '.jpg' + # 生成唯一文件名 + save_filename = str(uuid.uuid4().hex) + ext + logger.info('保存用户头像:' + basedir + save_filename) + # 写入文件 + with open(os.path.join(basedir, save_filename), 'wb+') as file: + file.write(rsp.content) + # 返回静态文件URL + return static('avatar/' + save_filename) + except Exception as e: + logger.error(e) + # 异常时返回默认头像 + return static('blog/img/avatar.png') + + +def delete_sidebar_cache(): + """ + 删除侧边栏缓存 + 根据LinkShowType的所有值生成缓存键并删除 + """ + from blog.models import LinkShowType + keys = ["sidebar" + x for x in LinkShowType.values] + for k in keys: + logger.info('delete sidebar key:' + k) + cache.delete(k) + + +def delete_view_cache(prefix, keys): + """ + 删除模板片段缓存 + :param prefix: 缓存前缀 + :param keys: 缓存键列表 + """ + from django.core.cache.utils import make_template_fragment_key + key = make_template_fragment_key(prefix, keys) + cache.delete(key) + + +def get_resource_url(): + """ + 获取静态资源基础URL + 优先使用settings中的STATIC_URL,无配置时使用站点域名拼接/static/ + :return: 静态资源基础URL + """ + if settings.STATIC_URL: + return settings.STATIC_URL + else: + site = get_current_site() + return 'http://' + site.domain + '/static/' + + +# HTML清理配置 - 防止XSS攻击 +# 允许的HTML标签白名单 +ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1', + 'h2', 'p', 'span', 'div'] + +# 允许的CSS类白名单(主要用于代码高亮) +ALLOWED_CLASSES = [ + 'codehilite', 'highlight', 'hll', 'c', 'err', 'k', 'l', 'n', 'o', 'p', 'cm', 'cp', 'c1', 'cs', + 'gd', 'ge', 'gr', 'gh', 'gi', 'go', 'gp', 'gs', 'gu', 'gt', 'kc', 'kd', 'kn', 'kp', 'kr', 'kt', + 'ld', 'm', 'mf', 'mh', 'mi', 'mo', 'na', 'nb', 'nc', 'no', 'nd', 'ni', 'ne', 'nf', 'nl', 'nn', + 'nt', 'nv', 'ow', 'w', 'mb', 'mh', 'mi', 'mo', 'sb', 'sc', 'sd', 'se', 'sh', 'si', 'sx', 's2', + 's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il' +] + +def class_filter(tag, name, value): + """ + 自定义class属性过滤器 + 只保留ALLOWED_CLASSES中的CSS类,过滤危险或未授权的类名 + :param tag: HTML标签名 + :param name: 属性名 + :param value: 属性值 + :return: 过滤后的属性值,无合法类时返回False + """ + if name == 'class': + allowed_classes = [cls for cls in value.split() if cls in ALLOWED_CLASSES] + return ' '.join(allowed_classes) if allowed_classes else False + return value + +# 允许的HTML属性白名单 +ALLOWED_ATTRIBUTES = { + 'a': ['href', 'title'], # 链接标签允许的属性 + 'abbr': ['title'], # 缩写标签允许的属性 + 'acronym': ['title'], # 首字母缩写标签允许的属性 + 'span': class_filter, # span标签的class属性使用自定义过滤器 + 'div': class_filter, # div标签的class属性使用自定义过滤器 + 'pre': class_filter, # pre标签的class属性使用自定义过滤器 + 'code': class_filter # code标签的class属性使用自定义过滤器 +} + +# 允许的URL协议白名单(防止javascript:等危险协议) +ALLOWED_PROTOCOLS = ['http', 'https', 'mailto'] + +def sanitize_html(html): + """ + 安全的HTML清理函数 + 使用bleach库过滤危险HTML内容,防止XSS攻击 + :param html: 需要清理的HTML字符串 + :return: 安全的HTML字符串 + """ + return bleach.clean( + html, + tags=ALLOWED_TAGS, # 只允许白名单中的标签 + attributes=ALLOWED_ATTRIBUTES, # 只允许白名单中的属性 + protocols=ALLOWED_PROTOCOLS, # 限制URL协议 + strip=True, # 移除不允许的标签(而非转义) + strip_comments=True # 移除HTML注释 + ) \ No newline at end of file diff --git a/src/djangoblog/djangoblog/whoosh_cn_backend.py b/src/djangoblog/djangoblog/whoosh_cn_backend.py new file mode 100644 index 00000000..b885613f --- /dev/null +++ b/src/djangoblog/djangoblog/whoosh_cn_backend.py @@ -0,0 +1,1044 @@ +# encoding: utf-8 + +from __future__ import absolute_import, division, print_function, unicode_literals + +import json +import os +import re +import shutil +import threading +import warnings + +import six +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from datetime import datetime +from django.utils.encoding import force_str +from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query +from haystack.constants import DJANGO_CT, DJANGO_ID, ID +from haystack.exceptions import MissingDependency, SearchBackendError, SkipDocument +from haystack.inputs import Clean, Exact, PythonData, Raw +from haystack.models import SearchResult +from haystack.utils import get_identifier, get_model_ct +from haystack.utils import log as logging +from haystack.utils.app_loading import haystack_get_model +from jieba.analyse import ChineseAnalyzer +from whoosh import index +from whoosh.analysis import StemmingAnalyzer +from whoosh.fields import BOOLEAN, DATETIME, IDLIST, KEYWORD, NGRAM, NGRAMWORDS, NUMERIC, Schema, TEXT +from whoosh.fields import ID as WHOOSH_ID +from whoosh.filedb.filestore import FileStorage, RamStorage +from whoosh.highlight import ContextFragmenter, HtmlFormatter +from whoosh.highlight import highlight as whoosh_highlight +from whoosh.qparser import QueryParser +from whoosh.searching import ResultsPage +from whoosh.writing import AsyncWriter + +try: + import whoosh +except ImportError: + raise MissingDependency( + "The 'whoosh' backend requires the installation of 'Whoosh'. Please refer to the documentation.") + +#姜雨菲: 检查Whoosh版本,要求2.5.0及以上 +if not hasattr(whoosh, '__version__') or whoosh.__version__ < (2, 5, 0): + raise MissingDependency( + "The 'whoosh' backend requires version 2.5.0 or greater.") + +#姜雨菲:日期时间格式正则表达式,用于解析Whoosh返回的日期时间字符串 +DATETIME_REGEX = re.compile( + '^(?P\d{4})-(?P\d{2})-(?P\d{2})T(?P\d{2}):(?P\d{2}):(?P\d{2})(\.\d{3,6}Z?)?$') +LOCALS = threading.local() +LOCALS.RAM_STORE = None # 线程本地存储,用于内存存储模式 + + + +class WhooshHtmlFormatter(HtmlFormatter): + """ + This is a HtmlFormatter simpler than the whoosh.HtmlFormatter. + We use it to have consistent results across backends. Specifically, + Solr, Xapian and Elasticsearch are using this formatting. + """ + template = '<%(tag)s>%(t)s' + + +class WhooshSearchBackend(BaseSearchBackend): + # Word reserved by Whoosh for special use. + RESERVED_WORDS = ( + 'AND', + 'NOT', + 'OR', + 'TO', + ) + + # Characters reserved by Whoosh for special use. + # The '\\' must come first, so as not to overwrite the other slash + # replacements. + RESERVED_CHARACTERS = ( + '\\', '+', '-', '&&', '||', '!', '(', ')', '{', '}', + '[', ']', '^', '"', '~', '*', '?', ':', '.', + ) + + def __init__(self, connection_alias, **connection_options): + super( + WhooshSearchBackend, + self).__init__( + connection_alias, + **connection_options) + self.setup_complete = False + self.use_file_storage = True + self.post_limit = getattr( + connection_options, + 'POST_LIMIT', + 128 * 1024 * 1024) + self.path = connection_options.get('PATH') + + if connection_options.get('STORAGE', 'file') != 'file': + self.use_file_storage = False + + if self.use_file_storage and not self.path: + raise ImproperlyConfigured( + "You must specify a 'PATH' in your settings for connection '%s'." % + connection_alias) + + self.log = logging.getLogger('haystack') + + def setup(self): + """ + Defers loading until needed. + """ + from haystack import connections + new_index = False + + # Make sure the index is there. + if self.use_file_storage and not os.path.exists(self.path): + os.makedirs(self.path) + new_index = True + + if self.use_file_storage and not os.access(self.path, os.W_OK): + raise IOError( + "The path to your Whoosh index '%s' is not writable for the current user/group." % + self.path) + + if self.use_file_storage: + self.storage = FileStorage(self.path) + else: + global LOCALS + + if getattr(LOCALS, 'RAM_STORE', None) is None: + LOCALS.RAM_STORE = RamStorage() + + self.storage = LOCALS.RAM_STORE + + self.content_field_name, self.schema = self.build_schema( + connections[self.connection_alias].get_unified_index().all_searchfields()) + self.parser = QueryParser(self.content_field_name, schema=self.schema) + + if new_index is True: + self.index = self.storage.create_index(self.schema) + else: + try: + self.index = self.storage.open_index(schema=self.schema) + except index.EmptyIndexError: + self.index = self.storage.create_index(self.schema) + + self.setup_complete = True + + def build_schema(self, fields): + schema_fields = { + ID: WHOOSH_ID(stored=True, unique=True), + DJANGO_CT: WHOOSH_ID(stored=True), + DJANGO_ID: WHOOSH_ID(stored=True), + } + # Grab the number of keys that are hard-coded into Haystack. + # We'll use this to (possibly) fail slightly more gracefully later. + initial_key_count = len(schema_fields) + content_field_name = '' + + for field_name, field_class in fields.items(): + if field_class.is_multivalued: + if field_class.indexed is False: + schema_fields[field_class.index_fieldname] = IDLIST( + stored=True, field_boost=field_class.boost) + else: + schema_fields[field_class.index_fieldname] = KEYWORD( + stored=True, commas=True, scorable=True, field_boost=field_class.boost) + elif field_class.field_type in ['date', 'datetime']: + schema_fields[field_class.index_fieldname] = DATETIME( + stored=field_class.stored, sortable=True) + elif field_class.field_type == 'integer': + schema_fields[field_class.index_fieldname] = NUMERIC( + stored=field_class.stored, numtype=int, field_boost=field_class.boost) + elif field_class.field_type == 'float': + schema_fields[field_class.index_fieldname] = NUMERIC( + stored=field_class.stored, numtype=float, field_boost=field_class.boost) + elif field_class.field_type == 'boolean': + # Field boost isn't supported on BOOLEAN as of 1.8.2. + schema_fields[field_class.index_fieldname] = BOOLEAN( + stored=field_class.stored) + elif field_class.field_type == 'ngram': + schema_fields[field_class.index_fieldname] = NGRAM( + minsize=3, maxsize=15, stored=field_class.stored, field_boost=field_class.boost) + elif field_class.field_type == 'edge_ngram': + schema_fields[field_class.index_fieldname] = NGRAMWORDS(minsize=2, maxsize=15, at='start', + stored=field_class.stored, + field_boost=field_class.boost) + else: + # schema_fields[field_class.index_fieldname] = TEXT(stored=True, analyzer=StemmingAnalyzer(), field_boost=field_class.boost, sortable=True) + schema_fields[field_class.index_fieldname] = TEXT( + stored=True, analyzer=ChineseAnalyzer(), field_boost=field_class.boost, sortable=True) + if field_class.document is True: + content_field_name = field_class.index_fieldname + schema_fields[field_class.index_fieldname].spelling = True + + # Fail more gracefully than relying on the backend to die if no fields + # are found. + if len(schema_fields) <= initial_key_count: + raise SearchBackendError( + "No fields were found in any search_indexes. Please correct this before attempting to search.") + + return (content_field_name, Schema(**schema_fields)) + + def update(self, index, iterable, commit=True): + if not self.setup_complete: + self.setup() + + self.index = self.index.refresh() + writer = AsyncWriter(self.index) + + for obj in iterable: + try: + doc = index.full_prepare(obj) + except SkipDocument: + self.log.debug(u"Indexing for object `%s` skipped", obj) + else: + # Really make sure it's unicode, because Whoosh won't have it any + # other way. + for key in doc: + doc[key] = self._from_python(doc[key]) + + # Document boosts aren't supported in Whoosh 2.5.0+. + if 'boost' in doc: + del doc['boost'] + + try: + writer.update_document(**doc) + except Exception as e: + if not self.silently_fail: + raise + + # We'll log the object identifier but won't include the actual object + # to avoid the possibility of that generating encoding errors while + # processing the log message: + self.log.error( + u"%s while preparing object for update" % + e.__class__.__name__, + exc_info=True, + extra={ + "data": { + "index": index, + "object": get_identifier(obj)}}) + + if len(iterable) > 0: + # For now, commit no matter what, as we run into locking issues + # otherwise. + writer.commit() + + def remove(self, obj_or_string, commit=True): + if not self.setup_complete: + self.setup() + + self.index = self.index.refresh() + whoosh_id = get_identifier(obj_or_string) + + try: + self.index.delete_by_query( + q=self.parser.parse( + u'%s:"%s"' % + (ID, whoosh_id))) + except Exception as e: + if not self.silently_fail: + raise + + self.log.error( + "Failed to remove document '%s' from Whoosh: %s", + whoosh_id, + e, + exc_info=True) + + def clear(self, models=None, commit=True): + if not self.setup_complete: + self.setup() + + self.index = self.index.refresh() + + if models is not None: + assert isinstance(models, (list, tuple)) + + try: + if models is None: + self.delete_index() + else: + models_to_delete = [] + + for model in models: + models_to_delete.append( + u"%s:%s" % + (DJANGO_CT, get_model_ct(model))) + + self.index.delete_by_query( + q=self.parser.parse( + u" OR ".join(models_to_delete))) + except Exception as e: + if not self.silently_fail: + raise + + if models is not None: + self.log.error( + "Failed to clear Whoosh index of models '%s': %s", + ','.join(models_to_delete), + e, + exc_info=True) + else: + self.log.error( + "Failed to clear Whoosh index: %s", e, exc_info=True) + + def delete_index(self): + # Per the Whoosh mailing list, if wiping out everything from the index, + # it's much more efficient to simply delete the index files. + if self.use_file_storage and os.path.exists(self.path): + shutil.rmtree(self.path) + elif not self.use_file_storage: + self.storage.clean() + + # Recreate everything. + self.setup() + + def optimize(self): + if not self.setup_complete: + self.setup() + + self.index = self.index.refresh() + self.index.optimize() + + def calculate_page(self, start_offset=0, end_offset=None): + # Prevent against Whoosh throwing an error. Requires an end_offset + # greater than 0. + if end_offset is not None and end_offset <= 0: + end_offset = 1 + + # Determine the page. + page_num = 0 + + if end_offset is None: + end_offset = 1000000 + + if start_offset is None: + start_offset = 0 + + page_length = end_offset - start_offset + + if page_length and page_length > 0: + page_num = int(start_offset / page_length) + + # Increment because Whoosh uses 1-based page numbers. + page_num += 1 + return page_num, page_length + + @log_query + def search( + self, + query_string, + sort_by=None, + start_offset=0, + end_offset=None, + fields='', + highlight=False, + facets=None, + date_facets=None, + query_facets=None, + narrow_queries=None, + spelling_query=None, + within=None, + dwithin=None, + distance_point=None, + models=None, + limit_to_registered_models=None, + result_class=None, + **kwargs): + if not self.setup_complete: + self.setup() + + # A zero length query should return no results. + if len(query_string) == 0: + return { + 'results': [], + 'hits': 0, + } + + query_string = force_str(query_string) + + # A one-character query (non-wildcard) gets nabbed by a stopwords + # filter and should yield zero results. + if len(query_string) <= 1 and query_string != u'*': + return { + 'results': [], + 'hits': 0, + } + + reverse = False + + if sort_by is not None: + # Determine if we need to reverse the results and if Whoosh can + # handle what it's being asked to sort by. Reversing is an + # all-or-nothing action, unfortunately. + sort_by_list = [] + reverse_counter = 0 + + for order_by in sort_by: + if order_by.startswith('-'): + reverse_counter += 1 + + if reverse_counter and reverse_counter != len(sort_by): + raise SearchBackendError("Whoosh requires all order_by fields" + " to use the same sort direction") + + for order_by in sort_by: + if order_by.startswith('-'): + sort_by_list.append(order_by[1:]) + + if len(sort_by_list) == 1: + reverse = True + else: + sort_by_list.append(order_by) + + if len(sort_by_list) == 1: + reverse = False + + sort_by = sort_by_list[0] + + if facets is not None: + warnings.warn( + "Whoosh does not handle faceting.", + Warning, + stacklevel=2) + + if date_facets is not None: + warnings.warn( + "Whoosh does not handle date faceting.", + Warning, + stacklevel=2) + + if query_facets is not None: + warnings.warn( + "Whoosh does not handle query faceting.", + Warning, + stacklevel=2) + + narrowed_results = None + self.index = self.index.refresh() + + if limit_to_registered_models is None: + limit_to_registered_models = getattr( + settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True) + + if models and len(models): + model_choices = sorted(get_model_ct(model) for model in models) + elif limit_to_registered_models: + # Using narrow queries, limit the results to only models handled + # with the current routers. + model_choices = self.build_models_list() + else: + model_choices = [] + + if len(model_choices) > 0: + if narrow_queries is None: + narrow_queries = set() + + narrow_queries.add(' OR '.join( + ['%s:%s' % (DJANGO_CT, rm) for rm in model_choices])) + + narrow_searcher = None + + if narrow_queries is not None: + # Potentially expensive? I don't see another way to do it in + # Whoosh... + narrow_searcher = self.index.searcher() + + for nq in narrow_queries: + recent_narrowed_results = narrow_searcher.search( + self.parser.parse(force_str(nq)), limit=None) + + if len(recent_narrowed_results) <= 0: + return { + 'results': [], + 'hits': 0, + } + + if narrowed_results: + narrowed_results.filter(recent_narrowed_results) + else: + narrowed_results = recent_narrowed_results + + self.index = self.index.refresh() + + if self.index.doc_count(): + searcher = self.index.searcher() + parsed_query = self.parser.parse(query_string) + + # In the event of an invalid/stopworded query, recover gracefully. + if parsed_query is None: + return { + 'results': [], + 'hits': 0, + } + + page_num, page_length = self.calculate_page( + start_offset, end_offset) + + search_kwargs = { + 'pagelen': page_length, + 'sortedby': sort_by, + 'reverse': reverse, + } + + # Handle the case where the results have been narrowed. + if narrowed_results is not None: + search_kwargs['filter'] = narrowed_results + + try: + raw_page = searcher.search_page( + parsed_query, + page_num, + **search_kwargs + ) + except ValueError: + if not self.silently_fail: + raise + + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': None, + } + + # Because as of Whoosh 2.5.1, it will return the wrong page of + # results if you request something too high. :( + if raw_page.pagenum < page_num: + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': None, + } + + results = self._process_results( + raw_page, + highlight=highlight, + query_string=query_string, + spelling_query=spelling_query, + result_class=result_class) + searcher.close() + + if hasattr(narrow_searcher, 'close'): + narrow_searcher.close() + + return results + else: + if self.include_spelling: + if spelling_query: + spelling_suggestion = self.create_spelling_suggestion( + spelling_query) + else: + spelling_suggestion = self.create_spelling_suggestion( + query_string) + else: + spelling_suggestion = None + + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': spelling_suggestion, + } + + def more_like_this( + self, + model_instance, + additional_query_string=None, + start_offset=0, + end_offset=None, + models=None, + limit_to_registered_models=None, + result_class=None, + **kwargs): + if not self.setup_complete: + self.setup() + + # Deferred models will have a different class ("RealClass_Deferred_fieldname") + # which won't be in our registry: + model_klass = model_instance._meta.concrete_model + + field_name = self.content_field_name + narrow_queries = set() + narrowed_results = None + self.index = self.index.refresh() + + if limit_to_registered_models is None: + limit_to_registered_models = getattr( + settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True) + + if models and len(models): + model_choices = sorted(get_model_ct(model) for model in models) + elif limit_to_registered_models: + # Using narrow queries, limit the results to only models handled + # with the current routers. + model_choices = self.build_models_list() + else: + model_choices = [] + + if len(model_choices) > 0: + if narrow_queries is None: + narrow_queries = set() + + narrow_queries.add(' OR '.join( + ['%s:%s' % (DJANGO_CT, rm) for rm in model_choices])) + + if additional_query_string and additional_query_string != '*': + narrow_queries.add(additional_query_string) + + narrow_searcher = None + + if narrow_queries is not None: + # Potentially expensive? I don't see another way to do it in + # Whoosh... + narrow_searcher = self.index.searcher() + + for nq in narrow_queries: + recent_narrowed_results = narrow_searcher.search( + self.parser.parse(force_str(nq)), limit=None) + + if len(recent_narrowed_results) <= 0: + return { + 'results': [], + 'hits': 0, + } + + if narrowed_results: + narrowed_results.filter(recent_narrowed_results) + else: + narrowed_results = recent_narrowed_results + + page_num, page_length = self.calculate_page(start_offset, end_offset) + + self.index = self.index.refresh() + raw_results = EmptyResults() + + if self.index.doc_count(): + query = "%s:%s" % (ID, get_identifier(model_instance)) + searcher = self.index.searcher() + parsed_query = self.parser.parse(query) + results = searcher.search(parsed_query) + + if len(results): + raw_results = results[0].more_like_this( + field_name, top=end_offset) + + # Handle the case where the results have been narrowed. + if narrowed_results is not None and hasattr(raw_results, 'filter'): + raw_results.filter(narrowed_results) + + try: + raw_page = ResultsPage(raw_results, page_num, page_length) + except ValueError: + if not self.silently_fail: + raise + + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': None, + } + + # Because as of Whoosh 2.5.1, it will return the wrong page of + # results if you request something too high. :( + if raw_page.pagenum < page_num: + return { + 'results': [], + 'hits': 0, + 'spelling_suggestion': None, + } + + results = self._process_results(raw_page, result_class=result_class) + searcher.close() + + if hasattr(narrow_searcher, 'close'): + narrow_searcher.close() + + return results + + def _process_results( + self, + raw_page, + highlight=False, + query_string='', + spelling_query=None, + result_class=None): + from haystack import connections + results = [] + + # It's important to grab the hits first before slicing. Otherwise, this + # can cause pagination failures. + hits = len(raw_page) + + if result_class is None: + result_class = SearchResult + + facets = {} + spelling_suggestion = None + unified_index = connections[self.connection_alias].get_unified_index() + indexed_models = unified_index.get_indexed_models() + + for doc_offset, raw_result in enumerate(raw_page): + score = raw_page.score(doc_offset) or 0 + app_label, model_name = raw_result[DJANGO_CT].split('.') + additional_fields = {} + model = haystack_get_model(app_label, model_name) + + if model and model in indexed_models: + for key, value in raw_result.items(): + index = unified_index.get_index(model) + string_key = str(key) + + if string_key in index.fields and hasattr( + index.fields[string_key], 'convert'): + # Special-cased due to the nature of KEYWORD fields. + if index.fields[string_key].is_multivalued: + if value is None or len(value) == 0: + additional_fields[string_key] = [] + else: + additional_fields[string_key] = value.split( + ',') + else: + additional_fields[string_key] = index.fields[string_key].convert( + value) + else: + additional_fields[string_key] = self._to_python(value) + + del (additional_fields[DJANGO_CT]) + del (additional_fields[DJANGO_ID]) + + if highlight: + sa = StemmingAnalyzer() + formatter = WhooshHtmlFormatter('em') + terms = [token.text for token in sa(query_string)] + + whoosh_result = whoosh_highlight( + additional_fields.get(self.content_field_name), + terms, + sa, + ContextFragmenter(), + formatter + ) + additional_fields['highlighted'] = { + self.content_field_name: [whoosh_result], + } + + result = result_class( + app_label, + model_name, + raw_result[DJANGO_ID], + score, + **additional_fields) + results.append(result) + else: + hits -= 1 + + if self.include_spelling: + if spelling_query: + spelling_suggestion = self.create_spelling_suggestion( + spelling_query) + else: + spelling_suggestion = self.create_spelling_suggestion( + query_string) + + return { + 'results': results, + 'hits': hits, + 'facets': facets, + 'spelling_suggestion': spelling_suggestion, + } + + def create_spelling_suggestion(self, query_string): + spelling_suggestion = None + reader = self.index.reader() + corrector = reader.corrector(self.content_field_name) + cleaned_query = force_str(query_string) + + if not query_string: + return spelling_suggestion + + # Clean the string. + for rev_word in self.RESERVED_WORDS: + cleaned_query = cleaned_query.replace(rev_word, '') + + for rev_char in self.RESERVED_CHARACTERS: + cleaned_query = cleaned_query.replace(rev_char, '') + + # Break it down. + query_words = cleaned_query.split() + suggested_words = [] + + for word in query_words: + suggestions = corrector.suggest(word, limit=1) + + if len(suggestions) > 0: + suggested_words.append(suggestions[0]) + + spelling_suggestion = ' '.join(suggested_words) + return spelling_suggestion + + def _from_python(self, value): + """ + Converts Python values to a string for Whoosh. + + Code courtesy of pysolr. + """ + if hasattr(value, 'strftime'): + if not hasattr(value, 'hour'): + value = datetime(value.year, value.month, value.day, 0, 0, 0) + elif isinstance(value, bool): + if value: + value = 'true' + else: + value = 'false' + elif isinstance(value, (list, tuple)): + value = u','.join([force_str(v) for v in value]) + elif isinstance(value, (six.integer_types, float)): + # Leave it alone. + pass + else: + value = force_str(value) + return value + + def _to_python(self, value): + """ + Converts values from Whoosh to native Python values. + + A port of the same method in pysolr, as they deal with data the same way. + """ + if value == 'true': + return True + elif value == 'false': + return False + + if value and isinstance(value, six.string_types): + possible_datetime = DATETIME_REGEX.search(value) + + if possible_datetime: + date_values = possible_datetime.groupdict() + + for dk, dv in date_values.items(): + date_values[dk] = int(dv) + + return datetime( + date_values['year'], + date_values['month'], + date_values['day'], + date_values['hour'], + date_values['minute'], + date_values['second']) + + try: + # Attempt to use json to load the values. + converted_value = json.loads(value) + + # Try to handle most built-in types. + if isinstance( + converted_value, + (list, + tuple, + set, + dict, + six.integer_types, + float, + complex)): + return converted_value + except BaseException: + # If it fails (SyntaxError or its ilk) or we don't trust it, + # continue on. + pass + + return value + + +class WhooshSearchQuery(BaseSearchQuery): + def _convert_datetime(self, date): + if hasattr(date, 'hour'): + return force_str(date.strftime('%Y%m%d%H%M%S')) + else: + return force_str(date.strftime('%Y%m%d000000')) + + def clean(self, query_fragment): + """ + Provides a mechanism for sanitizing user input before presenting the + value to the backend. + + Whoosh 1.X differs here in that you can no longer use a backslash + to escape reserved characters. Instead, the whole word should be + quoted. + """ + words = query_fragment.split() + cleaned_words = [] + + for word in words: + if word in self.backend.RESERVED_WORDS: + word = word.replace(word, word.lower()) + + for char in self.backend.RESERVED_CHARACTERS: + if char in word: + word = "'%s'" % word + break + + cleaned_words.append(word) + + return ' '.join(cleaned_words) + + def build_query_fragment(self, field, filter_type, value): + from haystack import connections + query_frag = '' + is_datetime = False + + if not hasattr(value, 'input_type_name'): + # Handle when we've got a ``ValuesListQuerySet``... + if hasattr(value, 'values_list'): + value = list(value) + + if hasattr(value, 'strftime'): + is_datetime = True + + if isinstance(value, six.string_types) and value != ' ': + # It's not an ``InputType``. Assume ``Clean``. + value = Clean(value) + else: + value = PythonData(value) + + # Prepare the query using the InputType. + prepared_value = value.prepare(self) + + if not isinstance(prepared_value, (set, list, tuple)): + # Then convert whatever we get back to what pysolr wants if needed. + prepared_value = self.backend._from_python(prepared_value) + + # 'content' is a special reserved word, much like 'pk' in + # Django's ORM layer. It indicates 'no special field'. + if field == 'content': + index_fieldname = '' + else: + index_fieldname = u'%s:' % connections[self._using].get_unified_index( + ).get_index_fieldname(field) + + filter_types = { + 'content': '%s', + 'contains': '*%s*', + 'endswith': "*%s", + 'startswith': "%s*", + 'exact': '%s', + 'gt': "{%s to}", + 'gte': "[%s to]", + 'lt': "{to %s}", + 'lte': "[to %s]", + 'fuzzy': u'%s~', + } + + if value.post_process is False: + query_frag = prepared_value + else: + if filter_type in [ + 'content', + 'contains', + 'startswith', + 'endswith', + 'fuzzy']: + if value.input_type_name == 'exact': + query_frag = prepared_value + else: + # Iterate over terms & incorportate the converted form of + # each into the query. + terms = [] + + if isinstance(prepared_value, six.string_types): + possible_values = prepared_value.split(' ') + else: + if is_datetime is True: + prepared_value = self._convert_datetime( + prepared_value) + + possible_values = [prepared_value] + + for possible_value in possible_values: + terms.append( + filter_types[filter_type] % + self.backend._from_python(possible_value)) + + if len(terms) == 1: + query_frag = terms[0] + else: + query_frag = u"(%s)" % " AND ".join(terms) + elif filter_type == 'in': + in_options = [] + + for possible_value in prepared_value: + is_datetime = False + + if hasattr(possible_value, 'strftime'): + is_datetime = True + + pv = self.backend._from_python(possible_value) + + if is_datetime is True: + pv = self._convert_datetime(pv) + + if isinstance(pv, six.string_types) and not is_datetime: + in_options.append('"%s"' % pv) + else: + in_options.append('%s' % pv) + + query_frag = "(%s)" % " OR ".join(in_options) + elif filter_type == 'range': + start = self.backend._from_python(prepared_value[0]) + end = self.backend._from_python(prepared_value[1]) + + if hasattr(prepared_value[0], 'strftime'): + start = self._convert_datetime(start) + + if hasattr(prepared_value[1], 'strftime'): + end = self._convert_datetime(end) + + query_frag = u"[%s to %s]" % (start, end) + elif filter_type == 'exact': + if value.input_type_name == 'exact': + query_frag = prepared_value + else: + prepared_value = Exact(prepared_value).prepare(self) + query_frag = filter_types[filter_type] % prepared_value + else: + if is_datetime is True: + prepared_value = self._convert_datetime(prepared_value) + + query_frag = filter_types[filter_type] % prepared_value + + if len(query_frag) and not isinstance(value, Raw): + if not query_frag.startswith('(') and not query_frag.endswith(')'): + query_frag = "(%s)" % query_frag + + return u"%s%s" % (index_fieldname, query_frag) + + # if not filter_type in ('in', 'range'): + # # 'in' is a bit of a special case, as we don't want to + # # convert a valid list/tuple to string. Defer handling it + # # until later... + # value = self.backend._from_python(value) + + +class WhooshEngine(BaseEngine): + backend = WhooshSearchBackend + query = WhooshSearchQuery diff --git a/src/djangoblog/djangoblog/wsgi.py b/src/djangoblog/djangoblog/wsgi.py new file mode 100644 index 00000000..89d4b9ec --- /dev/null +++ b/src/djangoblog/djangoblog/wsgi.py @@ -0,0 +1,21 @@ +""" +WSGI config for djangoblog project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ +""" + +import os + +# 导入Django的WSGI应用获取函数 +from django.core.wsgi import get_wsgi_application + +#姜雨菲: 设置Django默认的配置模块环境变量 +#姜雨菲: 告诉Django使用哪个settings.py文件(这里指定为项目的djangoblog.settings) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings") + +#姜雨菲: 创建WSGI应用实例 +#姜雨菲: 这个application变量会被WSGI服务器(如Gunicorn、uWSGI)调用,处理HTTP请求 +application = get_wsgi_application() \ No newline at end of file