diff --git a/Django博客项目/.coveragerc b/Django博客项目/.coveragerc new file mode 100644 index 0000000..9757484 --- /dev/null +++ b/Django博客项目/.coveragerc @@ -0,0 +1,10 @@ +[run] +source = . +include = *.py +omit = + *migrations* + *tests* + *.html + *whoosh_cn_backend* + *settings.py* + *venv* diff --git a/Django博客项目/.dockerignore b/Django博客项目/.dockerignore new file mode 100644 index 0000000..2818c38 --- /dev/null +++ b/Django博客项目/.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/Django博客项目/.gitattributes b/Django博客项目/.gitattributes new file mode 100644 index 0000000..fd52ece --- /dev/null +++ b/Django博客项目/.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/Django博客项目/.github/ISSUE_TEMPLATE.md b/Django博客项目/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..2b5b7aa --- /dev/null +++ b/Django博客项目/.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/Django博客项目/.github/workflows/codeql-analysis.yml b/Django博客项目/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..6b76522 --- /dev/null +++ b/Django博客项目/.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/Django博客项目/.github/workflows/django.yml b/Django博客项目/.github/workflows/django.yml new file mode 100644 index 0000000..94baea9 --- /dev/null +++ b/Django博客项目/.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/Django博客项目/.github/workflows/docker.yml b/Django博客项目/.github/workflows/docker.yml new file mode 100644 index 0000000..a312e2f --- /dev/null +++ b/Django博客项目/.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/Django博客项目/.github/workflows/publish-release.yml b/Django博客项目/.github/workflows/publish-release.yml new file mode 100644 index 0000000..5eb0853 --- /dev/null +++ b/Django博客项目/.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/Django博客项目/.gitignore b/Django博客项目/.gitignore new file mode 100644 index 0000000..3015816 --- /dev/null +++ b/Django博客项目/.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/Django博客项目/Dockerfile b/Django博客项目/Dockerfile new file mode 100644 index 0000000..80b46ac --- /dev/null +++ b/Django博客项目/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/Django博客项目/LICENSE b/Django博客项目/LICENSE new file mode 100644 index 0000000..3b08474 --- /dev/null +++ b/Django博客项目/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/Django博客项目/README.md b/Django博客项目/README.md new file mode 100644 index 0000000..56aa4cc --- /dev/null +++ b/Django博客项目/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/Django博客项目/accounts/__init__.py b/Django博客项目/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/accounts/admin.py b/Django博客项目/accounts/admin.py new file mode 100644 index 0000000..583dc4c --- /dev/null +++ b/Django博客项目/accounts/admin.py @@ -0,0 +1,117 @@ +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 _ +from django.contrib import admin +from django.contrib.auth.models import Group +from django.core.exceptions import PermissionDenied +from django.http import HttpResponseRedirect + +# 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): + # 移除或修复 fieldsets 中不存在的字段 + fieldsets = ( + (None, {'fields': ('username', 'password')}), + ('Personal info', {'fields': ('first_name', 'last_name', 'email')}), + ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), + ('Important dates', {'fields': ('last_login', 'date_joined')}), + ) + # 确保没有引用 usable_password 字段 + + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('username', 'password1', 'password2'), + }), + ) + + def get_queryset(self, request): + """限制普通用户只能看到自己的数据""" + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + return qs.filter(id=request.user.id) + + def get_fieldsets(self, request, obj=None): + """限制普通用户只能修改部分字段""" + if request.user.is_superuser: + return self.fieldsets + + # 普通用户只能修改个人信息 + return ( + (None, {'fields': ('username',)}), + ('Personal info', {'fields': ('first_name', 'last_name', 'email')}), + ) + + def get_readonly_fields(self, request, obj=None): + """设置只读字段""" + if request.user.is_superuser: + return self.readonly_fields + + # 普通用户不能修改权限相关字段 + readonly_fields = list(self.readonly_fields) + readonly_fields.extend(['is_staff', 'is_superuser', 'groups', 'user_permissions', 'last_login', 'date_joined']) + return readonly_fields + + def has_add_permission(self, request): + """只有超级用户才能添加用户""" + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + """只有超级用户才能删除用户""" + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + """普通用户只能修改自己的信息""" + if request.user.is_superuser: + return True + if obj and obj.id == request.user.id: + return True + return False + + +# 注册自定义用户管理 +admin.site.register(BlogUser, BlogUserAdmin) + +# 确保普通用户可以访问admin后台(is_staff=True) +# 在创建用户时,需要设置 is_staff = True \ No newline at end of file diff --git a/Django博客项目/accounts/apps.py b/Django博客项目/accounts/apps.py new file mode 100644 index 0000000..9b3fc5a --- /dev/null +++ b/Django博客项目/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = 'accounts' diff --git a/Django博客项目/accounts/forms.py b/Django博客项目/accounts/forms.py new file mode 100644 index 0000000..fce4137 --- /dev/null +++ b/Django博客项目/accounts/forms.py @@ -0,0 +1,117 @@ +from django import forms +from django.contrib.auth import get_user_model, password_validation +from django.contrib.auth.forms import AuthenticationForm, UserCreationForm +from django.core.exceptions import ValidationError +from django.forms import widgets +from django.utils.translation import gettext_lazy as _ +from . import utils +from .models import BlogUser + + +class LoginForm(AuthenticationForm): + def __init__(self, *args, **kwargs): + super(LoginForm, self).__init__(*args, **kwargs) + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + self.fields['password'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + + +class RegisterForm(UserCreationForm): + def __init__(self, *args, **kwargs): + super(RegisterForm, self).__init__(*args, **kwargs) + + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + self.fields['email'].widget = widgets.EmailInput( + attrs={'placeholder': "email", "class": "form-control"}) + self.fields['password1'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + self.fields['password2'].widget = widgets.PasswordInput( + attrs={'placeholder': "repeat password", "class": "form-control"}) + + def clean_email(self): + email = self.cleaned_data['email'] + if get_user_model().objects.filter(email=email).exists(): + raise ValidationError(_("email already exists")) + return email + + class Meta: + model = get_user_model() + fields = ("username", "email") + + +class ForgetPasswordForm(forms.Form): + new_password1 = forms.CharField( + label=_("New password"), + widget=forms.PasswordInput( + attrs={ + "class": "form-control", + 'placeholder': _("New password") + } + ), + ) + + new_password2 = forms.CharField( + label="确认密码", + widget=forms.PasswordInput( + attrs={ + "class": "form-control", + 'placeholder': _("Confirm password") + } + ), + ) + + email = forms.EmailField( + label='邮箱', + widget=forms.TextInput( + attrs={ + 'class': 'form-control', + 'placeholder': _("Email") + } + ), + ) + + code = forms.CharField( + label=_('Code'), + widget=forms.TextInput( + attrs={ + 'class': 'form-control', + 'placeholder': _("Code") + } + ), + ) + + def clean_new_password2(self): + password1 = self.data.get("new_password1") + password2 = self.data.get("new_password2") + if password1 and password2 and password1 != password2: + raise ValidationError(_("passwords do not match")) + password_validation.validate_password(password2) + + return password2 + + def clean_email(self): + user_email = self.cleaned_data.get("email") + if not BlogUser.objects.filter( + email=user_email + ).exists(): + # todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改 + raise ValidationError(_("email does not exist")) + return user_email + + def clean_code(self): + code = self.cleaned_data.get("code") + error = utils.verify( + email=self.cleaned_data.get("email"), + code=code, + ) + if error: + raise ValidationError(error) + return code + + +class ForgetPasswordCodeForm(forms.Form): + email = forms.EmailField( + label=_('Email'), + ) diff --git a/Django博客项目/accounts/migrations/0001_initial.py b/Django博客项目/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..d2fbcab --- /dev/null +++ b/Django博客项目/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/Django博客项目/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/Django博客项目/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py new file mode 100644 index 0000000..1a9f509 --- /dev/null +++ b/Django博客项目/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/Django博客项目/accounts/migrations/__init__.py b/Django博客项目/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/accounts/models.py b/Django博客项目/accounts/models.py new file mode 100644 index 0000000..3baddbb --- /dev/null +++ b/Django博客项目/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/Django博客项目/accounts/templatetags/__init__.py b/Django博客项目/accounts/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/accounts/tests.py b/Django博客项目/accounts/tests.py new file mode 100644 index 0000000..6893411 --- /dev/null +++ b/Django博客项目/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/Django博客项目/accounts/urls.py b/Django博客项目/accounts/urls.py new file mode 100644 index 0000000..fd39c57 --- /dev/null +++ b/Django博客项目/accounts/urls.py @@ -0,0 +1,28 @@ +from django.urls import path +from django.urls import re_path + +from . import views +from .forms import LoginForm +app_name = "accounts" + +urlpatterns = [re_path(r'^login/$', + views.LoginView.as_view(success_url='/'), + name='login', + kwargs={'authentication_form': LoginForm}), + re_path(r'^register/$', + views.RegisterView.as_view(success_url="/"), + name='register'), + re_path(r'^logout/$', + views.LogoutView.as_view(), + name='logout'), + path(r'account/result.html', + views.account_result, + name='result'), + re_path(r'^forget_password/$', + views.ForgetPasswordView.as_view(), + name='forget_password'), + re_path(r'^forget_password_code/$', + views.ForgetPasswordEmailCode.as_view(), + name='forget_password_code'), + path("visitor-login/", views.visitor_login, name="visitor_login"), + ] diff --git a/Django博客项目/accounts/user_login_backend.py b/Django博客项目/accounts/user_login_backend.py new file mode 100644 index 0000000..73cdca1 --- /dev/null +++ b/Django博客项目/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/Django博客项目/accounts/utils.py b/Django博客项目/accounts/utils.py new file mode 100644 index 0000000..4b94bdf --- /dev/null +++ b/Django博客项目/accounts/utils.py @@ -0,0 +1,49 @@ +import typing +from datetime import timedelta + +from django.core.cache import cache +from django.utils.translation import gettext +from django.utils.translation import gettext_lazy as _ + +from djangoblog.utils import send_email + +_code_ttl = timedelta(minutes=5) + + +def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")): + """发送重设密码验证码 + Args: + to_mail: 接受邮箱 + subject: 邮件主题 + code: 验证码 + """ + html_content = _( + "You are resetting the password, the verification code is:%(code)s, valid within 5 minutes, please keep it " + "properly") % {'code': code} + send_email([to_mail], subject, html_content) + + +def verify(email: str, code: str) -> typing.Optional[str]: + """验证code是否有效 + Args: + email: 请求邮箱 + code: 验证码 + Return: + 如果有错误就返回错误str + Node: + 这里的错误处理不太合理,应该采用raise抛出 + 否测调用方也需要对error进行处理 + """ + cache_code = get_code(email) + if cache_code != code: + return gettext("Verification code error") + + +def set_code(email: str, code: str): + """设置code""" + cache.set(email, code, _code_ttl.seconds) + + +def get_code(email: str) -> typing.Optional[str]: + """获取code""" + return cache.get(email) diff --git a/Django博客项目/accounts/views.py b/Django博客项目/accounts/views.py new file mode 100644 index 0000000..fdac582 --- /dev/null +++ b/Django博客项目/accounts/views.py @@ -0,0 +1,213 @@ +import logging +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from django.contrib import auth +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth import get_user_model +from django.contrib.auth import logout +from django.contrib.auth.forms import AuthenticationForm +from django.contrib.auth.hashers import make_password +from django.http import HttpResponseRedirect, HttpResponseForbidden +from django.http.request import HttpRequest +from django.http.response import HttpResponse +from django.shortcuts import get_object_or_404 +from django.shortcuts import render +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.utils.http import url_has_allowed_host_and_scheme +from django.views import View +from django.views.decorators.cache import never_cache +from django.views.decorators.csrf import csrf_protect +from django.views.decorators.debug import sensitive_post_parameters +from django.views.generic import FormView, RedirectView + +from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache +from . import utils +from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm +from .models import BlogUser + +from django.contrib.auth import authenticate, login, logout +from django.shortcuts import redirect + +def visitor_login(request): + # 先登出已有用户 + logout(request) + + # 登录 visitor 访客账号 + user = authenticate(username="visitor", password="visitor123") + if user: + login(request, user) + + return redirect("/") + + +logger = logging.getLogger(__name__) + + +# Create your views here. + +class RegisterView(FormView): + form_class = RegisterForm + template_name = 'account/registration_form.html' + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + return super(RegisterView, self).dispatch(*args, **kwargs) + + def form_valid(self, form): + if form.is_valid(): + user = form.save(False) + user.is_active = False + user.source = 'Register' + user.save(True) + site = get_current_site().domain + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + + if settings.DEBUG: + site = '127.0.0.1:8000' + path = reverse('account:result') + url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( + site=site, path=path, id=user.id, sign=sign) + + content = """ +

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

+ + {url} + + 再次感谢您! +
+ 如果上面链接无法打开,请将此链接复制至浏览器。 + {url} + """.format(url=url) + send_email( + emailto=[ + user.email, + ], + title='验证您的电子邮箱', + content=content) + + url = reverse('accounts:result') + \ + '?type=register&id=' + str(user.id) + return HttpResponseRedirect(url) + else: + return self.render_to_response({ + 'form': form + }) + + +class LogoutView(RedirectView): + url = '/login/' + + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + return super(LogoutView, self).dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + logout(request) + delete_sidebar_cache() + return super(LogoutView, self).get(request, *args, **kwargs) + + +class LoginView(FormView): + form_class = LoginForm + template_name = 'account/login.html' + success_url = '/' + redirect_field_name = REDIRECT_FIELD_NAME + login_ttl = 2626560 # 一个月的时间 + + @method_decorator(sensitive_post_parameters('password')) + @method_decorator(csrf_protect) + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + + return super(LoginView, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + redirect_to = self.request.GET.get(self.redirect_field_name) + if redirect_to is None: + redirect_to = '/' + kwargs['redirect_to'] = redirect_to + + return super(LoginView, self).get_context_data(**kwargs) + + def form_valid(self, form): + # 不要重新实例化 AuthenticationForm + delete_sidebar_cache() + logger.info(self.redirect_field_name) + + auth.login(self.request, form.get_user()) + if self.request.POST.get("remember"): + self.request.session.set_expiry(self.login_ttl) + return super(LoginView, self).form_valid(form) + + + def get_success_url(self): + + redirect_to = self.request.POST.get(self.redirect_field_name) + if not url_has_allowed_host_and_scheme( + url=redirect_to, allowed_hosts=[ + self.request.get_host()]): + redirect_to = self.success_url + return redirect_to + + +def account_result(request): + type = request.GET.get('type') + id = request.GET.get('id') + + user = get_object_or_404(get_user_model(), id=id) + logger.info(type) + if user.is_active: + return HttpResponseRedirect('/') + if type and type in ['register', 'validation']: + if type == 'register': + content = ''' + 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。 + ''' + title = '注册成功' + else: + c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + sign = request.GET.get('sign') + if sign != c_sign: + return HttpResponseForbidden() + user.is_active = True + user.save() + content = ''' + 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。 + ''' + title = '验证成功' + return render(request, 'account/result.html', { + 'title': title, + 'content': content + }) + else: + return HttpResponseRedirect('/') + + +class ForgetPasswordView(FormView): + form_class = ForgetPasswordForm + template_name = 'account/forget_password.html' + + def form_valid(self, form): + if form.is_valid(): + blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get() + blog_user.password = make_password(form.cleaned_data["new_password2"]) + blog_user.save() + return HttpResponseRedirect('/login/') + else: + return self.render_to_response({'form': form}) + + +class ForgetPasswordEmailCode(View): + + def post(self, request: HttpRequest): + form = ForgetPasswordCodeForm(request.POST) + if not form.is_valid(): + return HttpResponse("错误的邮箱") + to_email = form.cleaned_data["email"] + + code = generate_code() + utils.send_verify_email(to_email, code) + utils.set_code(to_email, code) + + return HttpResponse("ok") diff --git a/Django博客项目/blog/__init__.py b/Django博客项目/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/blog/admin.py b/Django博客项目/blog/admin.py new file mode 100644 index 0000000..b90bed3 --- /dev/null +++ b/Django博客项目/blog/admin.py @@ -0,0 +1,226 @@ +from django import forms +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +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, Tag, Category, Links, SideBar + +# 获取User模型 - 这是Django推荐的方式 +User = get_user_model() + +class ArticleForm(forms.ModelForm): + class Meta: + model = Article + fields = '__all__' + +def makr_article_publish(modeladmin, request, queryset): + queryset.update(status='p') + +def draft_article(modeladmin, request, queryset): + queryset.update(status='d') + +def close_article_commentstatus(modeladmin, request, queryset): + queryset.update(comment_status='c') + +def open_article_commentstatus(modeladmin, request, queryset): + queryset.update(comment_status='o') + +makr_article_publish.short_description = _('Publish selected articles') +draft_article.short_description = _('Draft selected articles') +close_article_commentstatus.short_description = _('Close article comments') +open_article_commentstatus.short_description = _('Open article comments') + +class ArticlelAdmin(admin.ModelAdmin): + list_per_page = 20 + search_fields = ('body', 'title') + form = ArticleForm + list_display = ( + 'id', + 'title', + 'author', + 'link_to_category', + 'creation_time', + 'views', + 'status', + 'type', + 'article_order') + list_display_links = ('id', 'title') + list_filter = ('status', 'type', 'category') + filter_horizontal = ('tags',) + exclude = ('creation_time', 'last_modify_time') + view_on_site = True + actions = [ + makr_article_publish, + draft_article, + close_article_commentstatus, + open_article_commentstatus] + + def link_to_category(self, obj): + if obj.category: + 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)) + return "-" + + link_to_category.short_description = _('category') + + def get_form(self, request, obj=None, **kwargs): + form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs) + + if request.user.is_superuser: + form.base_fields['author'].queryset = get_user_model().objects.filter(is_staff=True) + else: + form.base_fields['author'].queryset = get_user_model().objects.filter(id=request.user.id) + if not obj: + form.base_fields['author'].initial = request.user + form.base_fields['author'].disabled = True + + return form + + def get_queryset(self, request): + qs = super(ArticlelAdmin, self).get_queryset(request) + if request.user.is_superuser: + return qs + return qs.filter(author=request.user) + + def save_model(self, request, obj, form, change): + if not change and not request.user.is_superuser: + obj.author = request.user + super(ArticlelAdmin, self).save_model(request, obj, form, change) + + def has_change_permission(self, request, obj=None): + if obj is not None and not request.user.is_superuser: + return obj.author == request.user + return super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + if obj is not None and not request.user.is_superuser: + return obj.author == request.user + return super().has_delete_permission(request, obj) + + 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') + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs + + def has_change_permission(self, request, obj=None): + return request.user.is_staff + + def has_add_permission(self, request): + return request.user.is_staff + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + +class CategoryAdmin(admin.ModelAdmin): + list_display = ('name', 'parent_category', 'index') + exclude = ('slug', 'last_mod_time', 'creation_time') + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + + def has_add_permission(self, request): + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + +class LinksAdmin(admin.ModelAdmin): + exclude = ('last_mod_time', 'creation_time') + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + + def has_add_permission(self, request): + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + +class SideBarAdmin(admin.ModelAdmin): + list_display = ('name', 'content', 'is_enable', 'sequence') + exclude = ('last_mod_time', 'creation_time') + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + + def has_add_permission(self, request): + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + +class BlogSettingsAdmin(admin.ModelAdmin): + pass + +class UserAdmin(BaseUserAdmin): + def get_queryset(self, request): + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + return qs.filter(id=request.user.id) + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + + if not request.user.is_superuser: + disabled_fields = { + 'is_superuser', 'is_staff', 'is_active', + 'user_permissions', 'groups', 'last_login', 'date_joined' + } + + for field_name in disabled_fields: + if field_name in form.base_fields: + form.base_fields[field_name].disabled = True + + if 'username' in form.base_fields: + form.base_fields['username'].disabled = True + + return form + + def has_add_permission(self, request): + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + if obj is not None and not request.user.is_superuser: + return False + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + if obj is not None and not request.user.is_superuser: + return obj == request.user + return super().has_change_permission(request, obj) + +# 注册博客模型 +admin.site.register(Article, ArticlelAdmin) +admin.site.register(Tag, TagAdmin) +admin.site.register(Category, CategoryAdmin) +admin.site.register(Links, LinksAdmin) +admin.site.register(SideBar, SideBarAdmin) + +# 安全地重新注册User模型 +try: + admin.site.unregister(User) +except admin.sites.NotRegistered: + pass + +admin.site.register(User, UserAdmin) \ No newline at end of file diff --git a/Django博客项目/blog/apps.py b/Django博客项目/blog/apps.py new file mode 100644 index 0000000..7930587 --- /dev/null +++ b/Django博客项目/blog/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + name = 'blog' diff --git a/Django博客项目/blog/context_processors.py b/Django博客项目/blog/context_processors.py new file mode 100644 index 0000000..73e3088 --- /dev/null +++ b/Django博客项目/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/Django博客项目/blog/documents.py b/Django博客项目/blog/documents.py new file mode 100644 index 0000000..6b53625 --- /dev/null +++ b/Django博客项目/blog/documents.py @@ -0,0 +1,64 @@ +# 禁用Elasticsearch功能 +ELASTICSEARCH_ENABLED = False + +class ElapsedTimeDocumentManager: + """ + 简单的文档管理器替代品 + 不依赖Elasticsearch,提供空实现 + """ + + @staticmethod + def add(instance, *args, **kwargs): + """添加文档 - 空实现""" + return None + + @staticmethod + def update(instance, *args, **kwargs): + """更新文档 - 空实现""" + return None + + @staticmethod + def delete(instance, *args, **kwargs): + """删除文档 - 空实现""" + return None + + @staticmethod + def search(query, *args, **kwargs): + """搜索文档 - 返回空结果""" + return [] + + @staticmethod + def bulk_update(instances, *args, **kwargs): + """批量更新 - 空实现""" + return None + +# 为可能被导入的Elasticsearch DSL类创建空定义 +class Document: + pass + +class InnerDoc: + pass + +class Date: + pass + +class Integer: + pass + +class Long: + pass + +class Text: + pass + +class Object: + pass + +class GeoPoint: + pass + +class Keyword: + pass + +class Boolean: + pass \ No newline at end of file diff --git a/Django博客项目/blog/forms.py b/Django博客项目/blog/forms.py new file mode 100644 index 0000000..715be76 --- /dev/null +++ b/Django博客项目/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/Django博客项目/blog/management/__init__.py b/Django博客项目/blog/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/blog/management/commands/__init__.py b/Django博客项目/blog/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/blog/management/commands/build_index.py b/Django博客项目/blog/management/commands/build_index.py new file mode 100644 index 0000000..3c4acd7 --- /dev/null +++ b/Django博客项目/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/Django博客项目/blog/management/commands/build_search_words.py b/Django博客项目/blog/management/commands/build_search_words.py new file mode 100644 index 0000000..cfe7e0d --- /dev/null +++ b/Django博客项目/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/Django博客项目/blog/management/commands/clear_cache.py b/Django博客项目/blog/management/commands/clear_cache.py new file mode 100644 index 0000000..0d66172 --- /dev/null +++ b/Django博客项目/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/Django博客项目/blog/management/commands/create_testdata.py b/Django博客项目/blog/management/commands/create_testdata.py new file mode 100644 index 0000000..675d2ba --- /dev/null +++ b/Django博客项目/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/Django博客项目/blog/management/commands/ping_baidu.py b/Django博客项目/blog/management/commands/ping_baidu.py new file mode 100644 index 0000000..2c7fbdd --- /dev/null +++ b/Django博客项目/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/Django博客项目/blog/management/commands/sync_user_avatar.py b/Django博客项目/blog/management/commands/sync_user_avatar.py new file mode 100644 index 0000000..d0f4612 --- /dev/null +++ b/Django博客项目/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/Django博客项目/blog/middleware.py b/Django博客项目/blog/middleware.py new file mode 100644 index 0000000..6f624f4 --- /dev/null +++ b/Django博客项目/blog/middleware.py @@ -0,0 +1,48 @@ +import logging +import time + +def get_client_ip(request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip +from user_agents import parse + +from blog.documents import ELASTICSEARCH_ENABLED, ElapsedTimeDocumentManager + +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 + ElapsedTimeDocumentManager.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/Django博客项目/blog/migrations/__init__.py b/Django博客项目/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/blog/models.py b/Django博客项目/blog/models.py new file mode 100644 index 0000000..dbadd79 --- /dev/null +++ b/Django博客项目/blog/models.py @@ -0,0 +1,431 @@ +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 django.utils.text 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) +#新增 + likes = models.PositiveIntegerField(_('likes'), default=0) # 新增点赞计数字段 + liked_users = models.ManyToManyField( + settings.AUTH_USER_MODEL, + through='LikeRecord', + related_name='liked_articles', + blank=True, + verbose_name=_('liked users') + ) + + def like_count(self): + return self.liked_users.count() + + + 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( max_length=60, blank=True,unique=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(max_length=60, unique=True) # 移除default,添加unique=True + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + # 自动生成slug + if not self.slug or self.slug == 'no-slug': + self.slug = self.generate_unique_slug() + super().save(*args, **kwargs) + + def generate_unique_slug(self): + """生成唯一的slug""" + original_slug = slugify(self.name) + slug = original_slug + counter = 1 + + # 确保slug唯一 + while Tag.objects.filter(slug=slug).exclude(id=self.id).exists(): + slug = f"{original_slug}-{counter}" + counter += 1 + + return slug + + 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() + # 在文件末尾添加 +class LikeRecord(models.Model): + """点赞记录模型""" + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_('user'), + on_delete=models.CASCADE + ) + article = models.ForeignKey( + Article, + verbose_name=_('article'), + on_delete=models.CASCADE + ) + created_time = models.DateTimeField(_('creation time'), auto_now_add=True) + + class Meta: + verbose_name = _('like record') + verbose_name_plural = _('like records') + unique_together = ('user', 'article') # 防止重复点赞 + + def __str__(self): + return f"{self.user.username} liked {self.article.title}" diff --git a/Django博客项目/blog/search_indexes.py b/Django博客项目/blog/search_indexes.py new file mode 100644 index 0000000..8e6277c --- /dev/null +++ b/Django博客项目/blog/search_indexes.py @@ -0,0 +1,14 @@ +from haystack import indexes + +from blog.models import Article + +from jieba.analyse import ChineseAnalyzer + +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.all() diff --git a/Django博客项目/blog/templatetags/__init__.py b/Django博客项目/blog/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/blog/templatetags/blog_tags.py b/Django博客项目/blog/templatetags/blog_tags.py new file mode 100644 index 0000000..d6cd5d5 --- /dev/null +++ b/Django博客项目/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/Django博客项目/blog/tests.py b/Django博客项目/blog/tests.py new file mode 100644 index 0000000..ee13505 --- /dev/null +++ b/Django博客项目/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/Django博客项目/blog/urls.py b/Django博客项目/blog/urls.py new file mode 100644 index 0000000..df66d0a --- /dev/null +++ b/Django博客项目/blog/urls.py @@ -0,0 +1,75 @@ +from django.urls import path +from django.views.decorators.cache import cache_page + +from . import views +from blog.views import like_article + +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'), + #搜索功能优化(模糊搜索 + 排序),注册搜索路由 + path( + r'search/', + views.search, + name='search'), + #点赞功能路由 + path( + 'article//like/', + views.like_article, + name='like_article'), + + +] diff --git a/Django博客项目/blog/views.py b/Django博客项目/blog/views.py new file mode 100644 index 0000000..fd6c51d --- /dev/null +++ b/Django博客项目/blog/views.py @@ -0,0 +1,517 @@ +import logging +import os +import uuid + +from django.conf import settings +from django.core.paginator import Paginator +from django.http import HttpResponse, HttpResponseForbidden +from django.shortcuts import get_object_or_404 +from django.shortcuts import render +from django.templatetags.static import static +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.csrf import csrf_exempt +from django.views.generic.detail import DetailView +from django.views.generic.list import ListView +from haystack.views import SearchView +from django.contrib.auth import authenticate, login, logout +from django.shortcuts import redirect + +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 +#搜索功能优化(模糊搜索 + 排序) +from django.db.models import Q +from .models import Article +#点赞 +from django.http import JsonResponse +from django.contrib.auth.decorators import login_required + +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) + context = super(ArticleListView, self).get_context_data(**kwargs) + user = self.request.user + + # ⭐ 给模板传递当前用户点赞过的文章 ID 列表 + if user.is_authenticated: + context["liked_ids"] = user.liked_articles.values_list('id', flat=True) + else: + context["liked_ids"] = [] + + return context + + +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): + return None + + +class ArticleDetailView(DetailView): + ''' + 文章详情页面 + ''' + template_name = 'blog/article_detail.html' + model = Article + pk_url_kwarg = 'article_id' + context_object_name = "article" + + def get_context_data(self, **kwargs): + comment_form = CommentForm() + + article_comments = self.object.comment_list() + parent_comments = article_comments.filter(parent_comment=None) + blog_setting = get_blog_setting() + paginator = Paginator(parent_comments, blog_setting.article_comment_count) + page = self.request.GET.get('comment_page', '1') + if not page.isnumeric(): + page = 1 + else: + page = int(page) + if page < 1: + page = 1 + if page > paginator.num_pages: + page = paginator.num_pages + + p_comments = paginator.page(page) + next_page = p_comments.next_page_number() if p_comments.has_next() else None + prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None + + if next_page: + kwargs[ + 'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container' + if prev_page: + kwargs[ + 'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container' + kwargs['form'] = comment_form + kwargs['article_comments'] = article_comments + kwargs['p_comments'] = p_comments + kwargs['comment_count'] = len( + article_comments) if article_comments else 0 + + kwargs['next_article'] = self.object.next_article + kwargs['prev_article'] = self.object.prev_article + + user = self.request.user + if user.is_authenticated: + kwargs['user_has_liked'] = self.object.liked_users.filter(id=user.id).exists() + else: + kwargs['user_has_liked'] = False + + # ⭐ 修复:安全地获取点赞数 + if hasattr(self.object, 'likes'): + kwargs['like_count'] = self.object.likes + else: + kwargs['like_count'] = self.object.liked_users.count() + + 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) + + for c in article_comments: + c.is_liked = c.is_liked_by(self.request.user) + + + 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 article_archive(request): + """ + 文章归档页面视图 - 详细调试版 + """ + try: + # 方法1:最基础的查询,确保没有额外过滤 + articles = Article.objects.filter(status='p').order_by('-pub_time') + + print("=== 详细调试信息 ===") + print(f"总文章数: {articles.count()}") + + # 显示每篇文章的详细信息 + for article in articles: + print(f"文章: {article.title}") + print(f" - 状态: {article.status}") + print(f" - 发布时间: {article.pub_time}") + print(f" - 分类: {article.category}") # 检查分类 + print(f" - 分类ID: {getattr(article.category, 'id', '无分类')}") + print("---") + + context = { + 'articles': articles, + } + return render(request, 'blog/archive.html', context) + + except Exception as e: + print(f"归档页面错误: {e}") + return render(request, 'blog/archive.html', {'articles': []}) + +def clean_cache_view(request): + cache.clear() + return HttpResponse('ok') + +#搜索功能优化(模糊搜索 + 排序) +def search(request): + query = request.GET.get('q', '').strip() + sort = request.GET.get('sort', 'time') + + results = [] + + if query: + results = Article.objects.filter( + Q(title__icontains=query) | + Q(body__icontains=query) | + Q(category__name__icontains=query) | + Q(tags__name__icontains=query) + ).filter(status='p').distinct() + + # 排序 + if sort == 'views': + results = results.order_by('-views') + else: + results = results.order_by('-pub_time') + + # 分页 + paginator = Paginator(results, 10) + page = paginator.get_page(request.GET.get('page', 1)) + + context = { + 'query': query, + 'results': results, + 'page': page, + } + + return render(request, 'search/search.html', context) + +# 点赞 - 完整修复版本 +@csrf_exempt +@login_required +def like_article(request, article_id): + """文章点赞/取消点赞 - 使用liked_users计数""" + if request.method == "POST": + try: + article = get_object_or_404(Article, id=article_id) + user = request.user + + print(f"点赞操作: 用户 {user.username} 对文章 {article.title}") + + # 检查用户是否已经点赞 + if article.liked_users.filter(id=user.id).exists(): + # 取消点赞 + article.liked_users.remove(user) + liked = False + print("取消点赞") + else: + # 点赞 + article.liked_users.add(user) + liked = True + print("点赞") + + # 直接使用liked_users的计数 + likes_count = article.liked_users.count() + + print(f"当前点赞数: {likes_count}") + + return JsonResponse({ + 'success': True, + 'liked': liked, + 'likes_count': likes_count + }) + + except Exception as e: + import traceback + print(f"点赞操作错误详情: {traceback.format_exc()}") + return JsonResponse({'success': False, 'error': str(e)}) + + return JsonResponse({'success': False, 'error': '只支持POST请求'}) \ No newline at end of file diff --git a/Django博客项目/comments/__init__.py b/Django博客项目/comments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/comments/admin.py b/Django博客项目/comments/admin.py new file mode 100644 index 0000000..a814f3f --- /dev/null +++ b/Django博客项目/comments/admin.py @@ -0,0 +1,47 @@ +from django.contrib import admin +from django.urls import reverse +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ + + +def disable_commentstatus(modeladmin, request, queryset): + queryset.update(is_enable=False) + + +def enable_commentstatus(modeladmin, request, queryset): + queryset.update(is_enable=True) + + +disable_commentstatus.short_description = _('Disable comments') +enable_commentstatus.short_description = _('Enable comments') + + +class CommentAdmin(admin.ModelAdmin): + list_per_page = 20 + list_display = ( + 'id', + 'body', + 'link_to_userinfo', + 'link_to_article', + 'is_enable', + 'creation_time') + list_display_links = ('id', 'body', 'is_enable') + list_filter = ('is_enable',) + exclude = ('creation_time', 'last_modify_time') + actions = [disable_commentstatus, enable_commentstatus] + + def link_to_userinfo(self, obj): + info = (obj.author._meta.app_label, obj.author._meta.model_name) + link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) + return format_html( + u'%s' % + (link, obj.author.nickname if obj.author.nickname else obj.author.email)) + + def link_to_article(self, obj): + info = (obj.article._meta.app_label, obj.article._meta.model_name) + link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,)) + return format_html( + u'%s' % (link, obj.article.title)) + + link_to_userinfo.short_description = _('User') + link_to_article.short_description = _('Article') diff --git a/Django博客项目/comments/apps.py b/Django博客项目/comments/apps.py new file mode 100644 index 0000000..ff01b77 --- /dev/null +++ b/Django博客项目/comments/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CommentsConfig(AppConfig): + name = 'comments' diff --git a/Django博客项目/comments/forms.py b/Django博客项目/comments/forms.py new file mode 100644 index 0000000..e83737d --- /dev/null +++ b/Django博客项目/comments/forms.py @@ -0,0 +1,13 @@ +from django import forms +from django.forms import ModelForm + +from .models import Comment + + +class CommentForm(ModelForm): + parent_comment_id = forms.IntegerField( + widget=forms.HiddenInput, required=False) + + class Meta: + model = Comment + fields = ['body'] diff --git a/Django博客项目/comments/migrations/__init__.py b/Django博客项目/comments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/comments/models.py b/Django博客项目/comments/models.py new file mode 100644 index 0000000..22856b7 --- /dev/null +++ b/Django博客项目/comments/models.py @@ -0,0 +1,56 @@ +from django.conf import settings +from django.db import models +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.models import User + + +from blog.models import Article + + +# Create your models here. + +class Comment(models.Model): + body = models.TextField('正文', max_length=300) + creation_time = models.DateTimeField(_('creation time'), default=now) + last_modify_time = models.DateTimeField(_('last modify time'), default=now) + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_('author'), + on_delete=models.CASCADE) + article = models.ForeignKey( + Article, + verbose_name=_('article'), + on_delete=models.CASCADE) + parent_comment = models.ForeignKey( + 'self', + verbose_name=_('parent comment'), + blank=True, + null=True, + on_delete=models.CASCADE) + is_enable = models.BooleanField(_('enable'), + default=False, blank=False, null=False) + + class Meta: + ordering = ['-id'] + verbose_name = _('comment') + verbose_name_plural = verbose_name + get_latest_by = 'id' + + # 新增:点赞用户 + liked = models.ManyToManyField( + settings.AUTH_USER_MODEL, + related_name='liked_comments', + blank=True + ) + def like_count(self): + return self.liked.count() + + def is_liked_by(self, user): + if user.is_authenticated: + return self.liked.filter(id=user.id).exists() + return False + + + def __str__(self): + return self.body diff --git a/Django博客项目/comments/templatetags/__init__.py b/Django博客项目/comments/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/comments/templatetags/comments_tags.py b/Django博客项目/comments/templatetags/comments_tags.py new file mode 100644 index 0000000..fde02b4 --- /dev/null +++ b/Django博客项目/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/Django博客项目/comments/tests.py b/Django博客项目/comments/tests.py new file mode 100644 index 0000000..2a7f55f --- /dev/null +++ b/Django博客项目/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/Django博客项目/comments/urls.py b/Django博客项目/comments/urls.py new file mode 100644 index 0000000..02c9168 --- /dev/null +++ b/Django博客项目/comments/urls.py @@ -0,0 +1,15 @@ +from django.urls import path + +from . import views + +app_name = "comments" +urlpatterns = [ + path( + 'article//postcomment', + views.CommentPostView.as_view(), + name='postcomment'), + + path('comment//like/', + views.like_comment, + name='like_comment'), +] diff --git a/Django博客项目/comments/utils.py b/Django博客项目/comments/utils.py new file mode 100644 index 0000000..f01dba7 --- /dev/null +++ b/Django博客项目/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/Django博客项目/comments/views.py b/Django博客项目/comments/views.py new file mode 100644 index 0000000..8cd9da1 --- /dev/null +++ b/Django博客项目/comments/views.py @@ -0,0 +1,85 @@ +# Create your views here. +from django.core.exceptions import ValidationError +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_protect +from django.views.generic.edit import FormView +from django.http import JsonResponse +from django.contrib.auth.decorators import login_required + +from accounts.models import BlogUser +from blog.models import Article +from .forms import CommentForm +from .models import Comment + + +class CommentPostView(FormView): + form_class = CommentForm + template_name = 'blog/article_detail.html' + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + return super(CommentPostView, self).dispatch(*args, **kwargs) + + def get(self, request, *args, **kwargs): + article_id = self.kwargs['article_id'] + article = get_object_or_404(Article, pk=article_id) + url = article.get_absolute_url() + return HttpResponseRedirect(url + "#comments") + + def form_invalid(self, form): + article_id = self.kwargs['article_id'] + article = get_object_or_404(Article, pk=article_id) + + return self.render_to_response({ + 'form': form, + 'article': article + }) + + def form_valid(self, form): + """提交的数据验证合法后的逻辑""" + user = self.request.user + author = BlogUser.objects.get(pk=user.pk) + article_id = self.kwargs['article_id'] + article = get_object_or_404(Article, pk=article_id) + + if article.comment_status == 'c' or article.status == 'c': + raise ValidationError("该文章评论已关闭.") + comment = form.save(False) + comment.article = article + from djangoblog.utils import get_blog_setting + settings = get_blog_setting() + if not settings.comment_need_review: + comment.is_enable = True + comment.author = author + + if form.cleaned_data['parent_comment_id']: + parent_comment = Comment.objects.get( + pk=form.cleaned_data['parent_comment_id']) + comment.parent_comment = parent_comment + + comment.save(True) + return HttpResponseRedirect( + "%s#div-comment-%d" % + (article.get_absolute_url(), comment.pk)) + +@login_required +def like_comment(request, comment_id): + comment = Comment.objects.get(pk=comment_id) + user = request.user + + if user in comment.liked.all(): + # 取消点赞 + comment.liked.remove(user) + liked = False + else: + # 添加点赞 + comment.liked.add(user) + liked = True + + return JsonResponse({ + "status": "ok", + "liked": liked, + "like_count": comment.liked.count() + }) diff --git a/Django博客项目/deploy/docker-compose/docker-compose.es.yml b/Django博客项目/deploy/docker-compose/docker-compose.es.yml new file mode 100644 index 0000000..83e35ff --- /dev/null +++ b/Django博客项目/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/Django博客项目/deploy/docker-compose/docker-compose.yml b/Django博客项目/deploy/docker-compose/docker-compose.yml new file mode 100644 index 0000000..9609af3 --- /dev/null +++ b/Django博客项目/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/Django博客项目/deploy/entrypoint.sh b/Django博客项目/deploy/entrypoint.sh new file mode 100644 index 0000000..2fb6491 --- /dev/null +++ b/Django博客项目/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/Django博客项目/deploy/k8s/configmap.yaml b/Django博客项目/deploy/k8s/configmap.yaml new file mode 100644 index 0000000..835d4ad --- /dev/null +++ b/Django博客项目/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/Django博客项目/deploy/k8s/deployment.yaml b/Django博客项目/deploy/k8s/deployment.yaml new file mode 100644 index 0000000..414fdcc --- /dev/null +++ b/Django博客项目/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/Django博客项目/deploy/k8s/gateway.yaml b/Django博客项目/deploy/k8s/gateway.yaml new file mode 100644 index 0000000..a8de073 --- /dev/null +++ b/Django博客项目/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/Django博客项目/deploy/k8s/pv.yaml b/Django博客项目/deploy/k8s/pv.yaml new file mode 100644 index 0000000..874b72f --- /dev/null +++ b/Django博客项目/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/Django博客项目/deploy/k8s/pvc.yaml b/Django博客项目/deploy/k8s/pvc.yaml new file mode 100644 index 0000000..ef238c5 --- /dev/null +++ b/Django博客项目/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/Django博客项目/deploy/k8s/service.yaml b/Django博客项目/deploy/k8s/service.yaml new file mode 100644 index 0000000..4ef2931 --- /dev/null +++ b/Django博客项目/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/Django博客项目/deploy/k8s/storageclass.yaml b/Django博客项目/deploy/k8s/storageclass.yaml new file mode 100644 index 0000000..5d5a14c --- /dev/null +++ b/Django博客项目/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/Django博客项目/deploy/nginx.conf b/Django博客项目/deploy/nginx.conf new file mode 100644 index 0000000..32161d8 --- /dev/null +++ b/Django博客项目/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/Django博客项目/djangoblog/__init__.py b/Django博客项目/djangoblog/__init__.py new file mode 100644 index 0000000..66d588a --- /dev/null +++ b/Django博客项目/djangoblog/__init__.py @@ -0,0 +1,19 @@ +# 当该包(通常是Django应用)被导入时,打印初始化信息 +# 主要用于开发调试,确认包的加载时机和状态 +print("__init__.py is running") + +# 定义Django应用的默认配置类 +# Django在启动时会根据此配置加载应用,包括应用名称、信号注册、初始化逻辑等 +# 'djangoblog.apps.DjangoblogAppConfig'表示配置类的完整路径: +# - djangoblog:应用所在的包名 +# - apps:存放配置类的模块名 +# - DjangoblogAppConfig:具体的配置类(继承自django.apps.AppConfig) +default_app_config = 'djangoblog.apps.DjangoblogAppConfig' + +# 导入pymysql库(Python连接MySQL的第三方库) +import pymysql + +# 将pymysql伪装成MySQLdb库 +# 背景:早期Django默认使用MySQLdb库连接MySQL,但MySQLdb不支持Python3 +# 作用:让Django在使用`import MySQLdb`时实际导入pymysql,实现Python3环境下的MySQL连接兼容 +pymysql.install_as_MySQLdb() \ No newline at end of file diff --git a/Django博客项目/djangoblog/admin_site.py b/Django博客项目/djangoblog/admin_site.py new file mode 100644 index 0000000..6cc3f4d --- /dev/null +++ b/Django博客项目/djangoblog/admin_site.py @@ -0,0 +1,103 @@ +# 导入Django Admin相关核心组件 +from django.contrib.admin import AdminSite # Django Admin站点基类 +from django.contrib.admin.models import LogEntry # 管理员操作日志模型 +from django.contrib.sites.admin import SiteAdmin # 站点管理的默认Admin配置 +from django.contrib.sites.models import Site # Django内置的站点模型(用于多站点管理) + +# 导入各应用的Admin配置和数据模型 +from accounts.admin import * # 账户相关的Admin配置 +from blog.admin import * # 博客核心功能(文章、分类等)的Admin配置 +from blog.models import * # 博客核心数据模型 +from comments.admin import * # 评论功能的Admin配置 +from comments.models import * # 评论相关数据模型 +from djangoblog.logentryadmin import LogEntryAdmin # 自定义的操作日志Admin配置 +from oauth.admin import * # 第三方登录(OAuth)的Admin配置 +from oauth.models import * # OAuth相关数据模型 +from owntracks.admin import * # 位置追踪(OwnTracks)的Admin配置 +from owntracks.models import *# OwnTracks相关数据模型 +from servermanager.admin import * # 服务器管理的Admin配置 +from servermanager.models import * # 服务器管理相关数据模型 + + +class DjangoBlogAdminSite(AdminSite): + """ + 自定义的Django Admin站点类,继承自Django内置的AdminSite + + 作用:通过重写基类属性和方法,定制Admin后台的外观和权限控制 + """ + # 定制Admin站点的页面头部标题(显示在登录页和后台顶部导航栏) + site_header = 'djangoblog administration' + # 定制Admin站点的页面标题(显示在浏览器标签页) + site_title = 'djangoblog site admin' + + def __init__(self, name='admin'): + """ + 初始化自定义Admin站点 + + :param name: 站点名称,默认'admin'(与Django默认Admin站点名称保持一致,避免路由冲突) + """ + super().__init__(name) # 调用父类构造方法初始化 + + def has_permission(self, request): + """ + 重写权限检查方法,控制谁能访问Admin后台 + + :param request: HTTP请求对象,包含当前用户信息 + :return: 布尔值,True表示允许访问,False表示拒绝访问 + 此处限制仅超级用户(is_superuser)可访问,比默认的is_staff更严格 + """ + return request.user.is_superuser + + # 以下为注释掉的自定义URL示例(可根据需求启用) + # def get_urls(self): + # """ + # 扩展Admin站点的URL路由,添加自定义功能入口 + # """ + # # 先获取父类默认的URL配置 + # urls = super().get_urls() + # # 导入URL路径处理和自定义视图 + # from django.urls import path + # from blog.views import refresh_memcache # 示例:缓存刷新视图 + # + # # 定义自定义URL规则,使用admin_view()包装确保权限检查 + # my_urls = [ + # path('refresh/', self.admin_view(refresh_memcache), name="refresh"), + # ] + # # 合并默认URL和自定义URL(自定义URL优先) + # return urls + my_urls + + +# 实例化自定义的Admin站点,名称为'admin'(与Django默认Admin站点名称一致,接管后台) +admin_site = DjangoBlogAdminSite(name='admin') + +# 注册数据模型与对应的Admin配置到自定义Admin站点 +# 博客核心内容 +admin_site.register(Article, ArticlelAdmin) # 文章模型 + 其Admin配置 +admin_site.register(Category, CategoryAdmin) # 分类模型 + 其Admin配置 +admin_site.register(Tag, TagAdmin) # 标签模型 + 其Admin配置 +admin_site.register(Links, LinksAdmin) # 友情链接模型 + 其Admin配置 +admin_site.register(SideBar, SideBarAdmin) # 侧边栏模型 + 其Admin配置 +admin_site.register(BlogSettings, BlogSettingsAdmin) # 博客设置模型 + 其Admin配置 + +# 服务器管理 +admin_site.register(commands, CommandsAdmin) # 命令模型 + 其Admin配置 +admin_site.register(EmailSendLog, EmailSendLogAdmin)# 邮件发送日志模型 + 其Admin配置 + +# 账户管理 +admin_site.register(BlogUser, BlogUserAdmin) # 自定义用户模型 + 其Admin配置 + +# 评论管理 +admin_site.register(Comment, CommentAdmin) # 评论模型 + 其Admin配置 + +# OAuth第三方登录 +admin_site.register(OAuthUser, OAuthUserAdmin) # OAuth用户模型 + 其Admin配置 +admin_site.register(OAuthConfig, OAuthConfigAdmin) # OAuth配置模型 + 其Admin配置 + +# 位置追踪 +admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # 位置日志模型 + 其Admin配置 + +# 站点管理(Django内置) +admin_site.register(Site, SiteAdmin) # 站点模型 + Django默认的SiteAdmin配置 + +# 操作日志管理 +admin_site.register(LogEntry, LogEntryAdmin) # 管理员操作日志模型 + 自定义Admin配置 \ No newline at end of file diff --git a/Django博客项目/djangoblog/apps.py b/Django博客项目/djangoblog/apps.py new file mode 100644 index 0000000..415652f --- /dev/null +++ b/Django博客项目/djangoblog/apps.py @@ -0,0 +1,43 @@ +# 导入Django的AppConfig类,用于配置Django应用的生命周期和元数据 +from django.apps import AppConfig + + +class DjangoblogAppConfig(AppConfig): + """ + Django博客应用(djangoblog)的配置类,用于定义应用的核心配置和生命周期钩子 + + 作用: + 1. 配置应用的数据库主键生成规则 + 2. 标识应用的唯一名称 + 3. 定义应用就绪后的初始化逻辑(如加载插件) + """ + # 配置Django模型默认的自增主键字段类型 + # BigAutoField是64位整数型自增字段,支持更大的主键范围(适用于数据量较大的博客) + # 替代默认的AutoField(32位整数),避免数据量增长后主键溢出问题 + default_auto_field = 'django.db.models.BigAutoField' + + # 应用的唯一名称,必须与项目中应用的目录名一致(此处为'djangoblog') + # Django通过该名称识别应用,用于注册路由、加载模型等核心操作 + name = 'djangoblog' + + def ready(self): + """ + Django应用就绪后的钩子方法,在应用完全加载并初始化后自动调用 + + 执行时机: + - 项目启动时(如runserver、celery启动) + - 应用注册表(app registry)完成所有应用加载后 + 注意:该方法可能会被多次调用(如开发环境自动重载时),需确保逻辑可重入 + + 核心功能: + 调用插件加载函数,在应用就绪后初始化所有已激活的插件 + """ + # 调用父类的ready()方法,确保Django默认的应用就绪逻辑正常执行 + super().ready() + + # 导入并执行插件加载函数: + # 1. 从当前应用(djangoblog)的plugin_manage.loader模块中导入load_plugins函数 + # 2. 调用load_plugins()触发插件动态加载(如导入插件模块、初始化插件实例) + # 此处是插件系统与Django应用生命周期的绑定点,确保插件在应用就绪后启动 + from .plugin_manage.loader import load_plugins + load_plugins() \ No newline at end of file diff --git a/Django博客项目/djangoblog/blog_signals.py b/Django博客项目/djangoblog/blog_signals.py new file mode 100644 index 0000000..48eafa3 --- /dev/null +++ b/Django博客项目/djangoblog/blog_signals.py @@ -0,0 +1,207 @@ +# 导入必要模块 +import _thread # 用于创建多线程,处理异步任务(如发送邮件) +import logging # 日志记录模块,记录信号处理过程中的关键信息和错误 + +import django.dispatch # Django信号系统,用于定义和发送自定义信号 +from django.conf import settings # 导入Django项目配置 +from django.contrib.admin.models import LogEntry # 管理员操作日志模型 +from django.contrib.auth.signals import user_logged_in, user_logged_out # Django内置的用户登录/登出信号 +from django.core.mail import EmailMultiAlternatives # 用于发送HTML格式的邮件 +from django.db.models.signals import post_save # Django模型保存后的信号 +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 # OAuth用户模型 + +# 创建当前模块的日志记录器,用于记录信号处理相关日志 +logger = logging.getLogger(__name__) + +# 定义自定义信号:OAuth用户登录信号 +# 触发时机:当用户通过OAuth(第三方登录)成功登录时 +# 参数:['id'] 表示信号会携带OAuthUser的id +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): + """ + 发送邮件信号的接收器,处理实际的邮件发送逻辑 + + :param sender: 信号发送者(通常无需关注) + :param kwargs: 信号携带的参数(emailto, title, content) + """ + # 从信号参数中提取邮件信息 + emailto = kwargs['emailto'] # 收件人列表 + title = kwargs['title'] # 邮件标题 + content = kwargs['content'] # 邮件内容(HTML格式) + + # 创建HTML格式邮件对象 + # from_email:发件人(从项目配置中获取) + # to:收件人列表 + msg = EmailMultiAlternatives( + title, + content, + from_email=settings.DEFAULT_FROM_EMAIL, + 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 = 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用户登录信号的接收器,处理第三方登录后的后续操作 + + :param sender: 信号发送者 + :param kwargs: 信号携带的参数(id:OAuthUser的id) + """ + id = kwargs['id'] # 获取OAuth用户ID + oauthuser = OAuthUser.objects.get(id=id) # 查询对应的OAuth用户对象 + + # 获取当前站点域名(用于判断头像是否为本站资源) + site = get_current_site().domain + + # 若用户头像存在且不是本站资源(如第三方平台的头像URL),则下载并保存到本地 + if oauthuser.picture and not oauthuser.picture.find(site) >= 0: + from djangoblog.utils import save_user_avatar # 导入保存用户头像的工具函数 + oauthuser.picture = save_user_avatar(oauthuser.picture) # 下载并替换头像URL为本地路径 + oauthuser.save() # 保存更新后的用户信息 + + # 清除侧边栏缓存(用户登录状态可能影响侧边栏展示内容) + delete_sidebar_cache() + + +@receiver(post_save) +def model_post_save_callback( + sender, + instance, + created, + raw, + using, + update_fields, + **kwargs): + """ + Django模型保存后(post_save)信号的接收器,处理模型保存后的联动操作 + + :param sender: 发送信号的模型类 + :param instance: 被保存的模型实例 + :param created: 是否为新创建的记录(True表示新建,False表示更新) + :param raw: 是否为原始保存(如通过loaddata导入数据时为True) + :param using: 使用的数据库别名 + :param update_fields: 被更新的字段列表(None表示全量更新) + :param kwargs: 其他参数 + """ + clearcache = False # 标记是否需要清除缓存 + + # 若保存的是管理员操作日志(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() # 获取内容的完整URL + SpiderNotify.baidu_notify([notify_url]) # 向百度搜索引擎推送更新 + except Exception as ex: + logger.error("notify spider", ex) # 推送失败时记录错误日志 + + # 若不是仅更新浏览量,标记需要清除缓存 + if not is_update_views: + clearcache = True + + # 若保存的是评论(Comment)实例 + if isinstance(instance, Comment): + # 仅处理已启用的评论(is_enable=True) + if instance.is_enable: + # 获取评论所属文章的URL路径 + 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' + ) + + # 清除SEO处理器缓存(评论可能影响页面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): + """ + 用户登录/登出信号的接收器,处理认证状态变化后的操作 + + :param sender: 信号发送者 + :param request: HTTP请求对象 + :param user: 当前用户对象 + :param kwargs: 其他参数 + """ + # 若用户存在且用户名有效 + if user and user.username: + logger.info(user) # 记录用户登录/登出日志 + delete_sidebar_cache() # 清除侧边栏缓存(登录状态可能影响侧边栏内容,如显示用户名) + # cache.clear() # 注释:可根据需求开启全量缓存清除(通常侧边栏缓存足够) \ No newline at end of file diff --git a/Django博客项目/djangoblog/elasticsearch_backend.py b/Django博客项目/djangoblog/elasticsearch_backend.py new file mode 100644 index 0000000..336f8f9 --- /dev/null +++ b/Django博客项目/djangoblog/elasticsearch_backend.py @@ -0,0 +1,316 @@ +# 导入必要模块 +from django.utils.encoding import force_str # 用于将数据转换为字符串(兼容Python 2/3) +"""from elasticsearch_dsl import Q """ # Elasticsearch DSL的查询构建工具 +from haystack.backends import ( # Haystack搜索框架的基础类 + BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query +) +from haystack.forms import ModelSearchForm # Haystack默认的模型搜索表单 +from haystack.models import SearchResult # Haystack的搜索结果封装类 +from haystack.utils import log as logging # Haystack的日志工具 + +# 导入项目内部模块 +from blog.documents import ArticleDocument, ArticleDocumentManager # 文章的Elasticsearch文档定义及管理器 +from blog.models import Article # 博客文章模型 + +# 创建当前模块的日志记录器,用于记录搜索相关日志 +logger = logging.getLogger(__name__) + + +class ElasticSearchBackend(BaseSearchBackend): + """ + 基于Elasticsearch的搜索后端实现,继承自Haystack的BaseSearchBackend + + 作用:实现与Elasticsearch的交互逻辑,包括索引的创建、更新、删除, + 以及搜索查询的执行、拼写建议等功能 + """ + def __init__(self, connection_alias, **connection_options): + """ + 初始化搜索后端 + + :param connection_alias: 数据库连接别名(用于多后端配置) + :param connection_options: 连接参数(如主机、端口等) + """ + super(ElasticSearchBackend, self).__init__(connection_alias,** connection_options) + self.manager = ArticleDocumentManager() # 初始化文章文档管理器(处理索引操作) + self.include_spelling = True # 启用拼写建议功能 + + def _get_models(self, iterable): + """ + 将模型实例列表转换为Elasticsearch文档对象 + + :param iterable: 模型实例列表(如Article对象列表) + :return: 转换后的Elasticsearch文档列表 + """ + # 若输入为空,默认使用所有已发布的文章 + models = iterable if iterable and iterable[0] else Article.objects.all() + # 通过管理器将模型转换为文档 + docs = self.manager.convert_to_doc(models) + return docs + + def _create(self, models): + """ + 创建索引并初始化文档(全量重建索引时使用) + + :param models: 模型实例列表 + """ + self.manager.create_index() # 创建Elasticsearch索引(若不存在) + docs = self._get_models(models) # 转换模型为文档 + self.manager.rebuild(docs) # 全量重建索引(清空旧数据后插入新数据) + + def _delete(self, models): + """ + 从索引中删除指定模型对应的文档 + + :param models: 要删除的模型实例列表 + :return: 操作是否成功(始终返回True) + """ + for m in models: + m.delete() # 调用文档的删除方法 + return True + + def _rebuild(self, models): + """ + 增量更新索引(适用于部分数据更新) + + :param models: 需要更新的模型实例列表(若为空则更新所有文章) + """ + models = models if models else Article.objects.all() # 处理空输入 + docs = self._get_models(models) # 转换模型为文档 + self.manager.update_docs(docs) # 增量更新文档 + + def update(self, index, iterable, commit=True): + """ + Haystack标准接口:更新索引(用于实时同步模型变更) + + :param index: 索引名称(当前实现未使用,由管理器处理) + :param iterable: 模型实例列表 + :param commit: 是否立即提交(当前实现未使用) + """ + models = self._get_models(iterable) # 转换模型为文档 + self.manager.update_docs(models) # 执行更新 + + def remove(self, obj_or_string): + """ + Haystack标准接口:从索引中移除指定对象 + + :param obj_or_string: 模型实例或ID字符串 + """ + models = self._get_models([obj_or_string]) # 转换为文档 + self._delete(models) # 执行删除 + + def clear(self, models=None, commit=True): + """ + Haystack标准接口:清空索引(或指定模型的索引) + + :param models: 可选,指定要清空的模型类(当前实现忽略,清空所有) + :param commit: 是否立即提交(当前实现未使用) + """ + self.remove(None) # 调用删除方法清空所有 + + @staticmethod + def get_suggestion(query: str) -> str: + """ + 获取搜索建议词(基于Elasticsearch的拼写纠错功能) + + :param query: 用户输入的搜索词 + :return: 建议的修正词(多个词用空格拼接) + """ + # 构建搜索查询:匹配文章内容,并启用拼写建议 + 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 # Haystack装饰器:记录查询日志 + def search(self, query_string, **kwargs): + """ + 执行搜索查询的核心方法 + + :param query_string: 用户输入的搜索字符串 + :param kwargs: 额外参数(如分页偏移量start_offset/end_offset) + :return: 搜索结果字典(包含结果列表、命中数、拼写建议等) + """ + logger.info('search query_string:' + query_string) # 记录搜索词 + + # 获取分页参数(用于限制返回结果范围) + start_offset = kwargs.get('start_offset', 0) + end_offset = kwargs.get('end_offset', 10) # 默认返回前10条 + + # 判断是否需要启用拼写建议(通过is_suggest参数控制) + if getattr(self, "is_suggest", None): + suggestion = self.get_suggestion(query_string) # 获取建议词 + else: + suggestion = query_string # 不启用建议,使用原搜索词 + + # 构建Elasticsearch查询条件: + # 1. 布尔查询(should):匹配标题或内容,至少满足70%的条件 + q = Q('bool', + should=[Q('match', body=suggestion), Q('match', title=suggestion)], + minimum_should_match="70%") + + # 构建完整搜索: + # - 应用上述查询条件 + # - 过滤:仅包含已发布(status='p')的文章(type='a') + # - 不返回原始文档内容(source=False) + # - 应用分页偏移 + 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 = [] + + # 遍历搜索结果,转换为Haystack的SearchResult格式 + for raw_result in results['hits']['hits']: + app_label = 'blog' # 应用标签(固定为博客应用) + model_name = 'Article' # 模型名称(固定为文章模型) + additional_fields = {} # 额外字段(当前未使用) + + # 创建SearchResult实例(适配Haystack的结果格式) + result = SearchResult( + app_label, + model_name, + raw_result['_id'], # 文档ID(对应文章ID) + raw_result['_score'], # 匹配得分 + **additional_fields + ) + raw_results.append(result) + + # 构建返回结果字典 + facets = {} # 分面搜索结果(当前未实现) + # 若建议词与原词不同,则返回建议词;否则为None + spelling_suggestion = None if query_string == suggestion else suggestion + + return { + 'results': raw_results, # 搜索结果列表(SearchResult实例) + 'hits': hits, # 总命中数 + 'facets': facets, # 分面数据 + 'spelling_suggestion': spelling_suggestion, # 拼写建议 + } + + +class ElasticSearchQuery(BaseSearchQuery): + """ + 自定义搜索查询类,继承自Haystack的BaseSearchQuery + + 作用:处理搜索查询的构建逻辑,包括查询字符串清洗、参数转换等 + """ + def _convert_datetime(self, date): + """ + 转换日期时间为Elasticsearch兼容的字符串格式 + + :param date: 日期时间对象 + :return: 格式化的字符串(如'20231018123000') + """ + if hasattr(date, 'hour'): # 若包含时间信息(datetime对象) + return force_str(date.strftime('%Y%m%d%H%M%S')) + else: # 仅日期(date对象),时间部分设为00:00:00 + return force_str(date.strftime('%Y%m%d000000')) + + def clean(self, query_fragment): + """ + 清洗用户输入的查询片段,处理保留字和特殊字符 + + :param query_fragment: 用户输入的查询字符串片段 + :return: 清洗后的查询字符串 + """ + words = query_fragment.split() # 按空格拆分词语 + cleaned_words = [] + + for word in words: + # 处理Elasticsearch保留字(转为小写) + 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): + """ + 构建查询片段(适配Elasticsearch的查询语法) + + :param field: 搜索字段 + :param filter_type: 过滤类型 + :param value: 查询值 + :return: 构建的查询字符串 + """ + return value.query_string # 直接使用查询字符串(由value提供) + + def get_count(self): + """ + 获取搜索结果总数 + + :return: 结果数量 + """ + results = self.get_results() + return len(results) if results else 0 + + def get_spelling_suggestion(self, preferred_query=None): + """ + 获取拼写建议(适配Haystack接口) + + :param preferred_query: 优先使用的查询(未使用) + :return: 拼写建议词 + """ + return self._spelling_suggestion + + def build_params(self, spelling_query=None): + """ + 构建查询参数(适配Haystack接口) + + :param spelling_query: 拼写建议查询(未使用) + :return: 构建的参数字典 + """ + kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query) + return kwargs + + +class ElasticSearchModelSearchForm(ModelSearchForm): + """ + 自定义搜索表单,继承自Haystack的ModelSearchForm + + 作用:扩展默认搜索表单,支持控制是否启用拼写建议 + """ + def search(self): + """ + 执行搜索,根据表单参数控制拼写建议 + + :return: 搜索结果集(SearchQuerySet) + """ + # 通过表单数据中的"is_suggest"参数控制是否启用拼写建议 + # 若"is_suggest"为"no",则禁用建议 + self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no" + # 调用父类方法执行搜索 + sqs = super().search() + return sqs + + +class ElasticSearchEngine(BaseEngine): + """ + Elasticsearch搜索引擎入口类,继承自Haystack的BaseEngine + + 作用:绑定后端实现和查询类,作为Haystack的引擎配置入口 + """ + backend = ElasticSearchBackend # 指定使用的搜索后端 + query = ElasticSearchQuery # 指定使用的查询类 \ No newline at end of file diff --git a/Django博客项目/djangoblog/feeds.py b/Django博客项目/djangoblog/feeds.py new file mode 100644 index 0000000..8c4e851 --- /dev/null +++ b/Django博客项目/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/Django博客项目/djangoblog/logentryadmin.py b/Django博客项目/djangoblog/logentryadmin.py new file mode 100644 index 0000000..2f6a535 --- /dev/null +++ b/Django博客项目/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/Django博客项目/djangoblog/plugin_manage/base_plugin.py b/Django博客项目/djangoblog/plugin_manage/base_plugin.py new file mode 100644 index 0000000..f7f22c2 --- /dev/null +++ b/Django博客项目/djangoblog/plugin_manage/base_plugin.py @@ -0,0 +1,95 @@ +# 导入Python标准库中的logging模块,用于实现插件运行过程中的日志记录功能 +# 日志可以帮助开发者追踪插件的运行状态、排查错误等 +import logging + +# 创建一个日志记录器实例,其名称与当前模块(__name__)绑定 +# 这样可以确保日志信息能够准确关联到插件模块,便于日志的分类和筛选 +logger = logging.getLogger(__name__) + + +class BasePlugin: + """ + 插件系统的基类(抽象基类角色),所有自定义插件都必须继承此类 + + 该类的核心作用是: + 1. 定义插件必须包含的元数据规范(名称、描述、版本) + 2. 提供插件初始化和钩子注册的统一流程 + 3. 封装获取插件信息的通用方法 + 子类通过继承此类并实现特定方法,即可快速接入插件系统 + """ + + # 插件元数据字段(子类必须显式赋值,否则初始化会失败) + PLUGIN_NAME = None # 插件的唯一标识名称,用于在系统中区分不同插件,例如"DataCleanPlugin" + PLUGIN_DESCRIPTION = None # 插件功能的详细描述,说明插件的作用和使用场景,例如"用于清洗CSV格式的原始数据" + PLUGIN_VERSION = None # 插件的版本号,遵循语义化版本规范(如"1.0.0"),用于版本管理和兼容性判断 + + def __init__(self): + """ + 插件实例的构造方法,负责插件的初始化流程控制 + + 执行逻辑: + 1. 首先验证子类是否完整实现了元数据(名称、描述、版本) + 2. 若元数据不完整,抛出ValueError异常阻止实例化 + 3. 元数据验证通过后,依次调用初始化方法和钩子注册方法 + 确保插件在使用前完成必要的准备工作 + """ + # 使用all()函数检查三个元数据字段是否都有值(非None) + # 若存在任何一个未定义的字段,触发异常 + 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): + """ + 插件初始化的具体实现方法,用于执行插件启动前的准备工作 + + 基类默认实现: + - 输出一条INFO级别的日志,提示插件已完成初始化 + 子类可重写此方法实现特定逻辑,例如: + - 加载配置文件 + - 建立数据库连接 + - 初始化缓存数据结构等 + 注意:重写时若需要保留默认日志,可通过super().init_plugin()调用父类方法 + """ + # 记录插件初始化成功的日志,包含插件名称便于追踪 + logger.info(f'{self.PLUGIN_NAME} initialized.') + + def register_hooks(self): + """ + 插件钩子注册方法,用于将插件功能与系统事件关联 + + 钩子(Hook)机制说明: + 系统在运行过程中会触发一系列事件(如"数据处理前"、"任务完成后"等), + 插件通过注册钩子,可以在特定事件发生时自动执行对应逻辑。 + + 基类默认实现:空方法(pass) + 子类需根据自身功能重写此方法,例如: + - 调用系统提供的register_hook()方法注册事件回调 + - 定义需要监听的事件类型和对应的处理函数 + """ + pass + + def get_plugin_info(self): + """ + 获取插件元数据的统一接口,用于系统展示或管理插件信息 + + 返回值说明: + - 字典类型,包含三个键值对 + - 'name':对应PLUGIN_NAME + - 'description':对应PLUGIN_DESCRIPTION + - 'version':对应PLUGIN_VERSION + + 应用场景: + - 插件管理界面展示插件列表 + - 系统启动时收集所有插件信息进行校验 + - 插件间依赖关系判断时获取版本信息 + """ + return { + 'name': self.PLUGIN_NAME, + 'description': self.PLUGIN_DESCRIPTION, + 'version': self.PLUGIN_VERSION + } \ No newline at end of file diff --git a/Django博客项目/djangoblog/plugin_manage/hook_constants.py b/Django博客项目/djangoblog/plugin_manage/hook_constants.py new file mode 100644 index 0000000..be2ca8a --- /dev/null +++ b/Django博客项目/djangoblog/plugin_manage/hook_constants.py @@ -0,0 +1,30 @@ +# 文章相关系统事件常量定义 +# 用途:统一管理插件系统中与文章操作相关的事件名称,避免硬编码导致的不一致问题 +# 所有事件名称均采用大写蛇形命名法(UPPER_SNAKE_CASE),符合Python常量命名规范 + +# 事件:文章详情页加载完成 +# 触发时机:当用户访问某篇文章的详情页,页面内容加载完成后触发 +# 应用场景:插件可监听此事件,执行详情页相关的自定义逻辑(如添加页面统计代码、注入额外内容等) +ARTICLE_DETAIL_LOAD = 'article_detail_load' + +# 事件:文章创建完成 +# 触发时机:当一篇新文章在系统中创建成功(如数据库写入完成、状态设为"已发布"或"草稿")后触发 +# 应用场景:插件可监听此事件,执行创建后的后续操作(如自动生成文章摘要、同步到外部平台、发送通知等) +ARTICLE_CREATE = 'article_create' + +# 事件:文章更新完成 +# 触发时机:当已存在的文章内容、属性(如标题、分类、状态)修改并保存成功后触发 +# 应用场景:插件可监听此事件,执行更新后的联动操作(如更新文章索引、记录修改日志、重新生成相关统计数据等) +ARTICLE_UPDATE = 'article_update' + +# 事件:文章删除完成 +# 触发时机:当一篇文章从系统中删除(物理删除或逻辑删除,如标记为"已删除"状态)后触发 +# 应用场景:插件可监听此事件,执行删除后的清理操作(如删除关联的评论、移除相关缓存、同步删除外部存储的附件等) +ARTICLE_DELETE = 'article_delete' + + +# 文章内容钩子名称常量 +# 用途:定义专门用于拦截、修改文章内容的钩子标识,与上述"操作事件"区分(事件侧重流程节点,钩子侧重内容处理) +# 命名格式与事件常量保持一致,确保插件系统中钩子名称的唯一性和可识别性 +# 应用场景:插件可注册此钩子,在文章内容渲染前(如详情页展示、导出为PDF)对内容进行自定义处理(如过滤敏感词、替换关键词、添加水印等) +ARTICLE_CONTENT_HOOK_NAME = "the_content" \ No newline at end of file diff --git a/Django博客项目/djangoblog/plugin_manage/hooks.py b/Django博客项目/djangoblog/plugin_manage/hooks.py new file mode 100644 index 0000000..62ad78f --- /dev/null +++ b/Django博客项目/djangoblog/plugin_manage/hooks.py @@ -0,0 +1,92 @@ +# 导入logging模块,用于记录钩子系统运行过程中的日志(如注册信息、错误信息等) +import logging + +# 创建当前模块的日志记录器,日志名称与模块绑定,便于区分不同组件的日志输出 +logger = logging.getLogger(__name__) + +# 全局钩子存储字典,用于保存所有注册的钩子及其对应的回调函数 +# 键:钩子名称(字符串,如"article_create") +# 值:回调函数列表(所有注册到该钩子的可调用对象将按注册顺序存储) +_hooks = {} + + +def register(hook_name: str, callback: callable): + """ + 注册一个钩子回调函数,将其添加到指定钩子名称对应的回调列表中 + + 核心作用:建立"钩子名称"与"处理逻辑(回调函数)"的映射关系, + 使后续触发钩子时能自动执行所有注册的回调 + + :param hook_name: 钩子名称(字符串),需与触发时使用的名称一致(如ARTICLE_CREATE) + :param callback: 可调用对象(函数、方法等),当钩子被触发时会执行此对象 + 回调函数的参数需与钩子触发时传递的参数匹配 + """ + # 若钩子名称尚未在全局字典中,初始化一个空列表用于存储回调 + if hook_name not in _hooks: + _hooks[hook_name] = [] + # 将回调函数添加到对应钩子的列表中(按注册顺序存储,触发时也按此顺序执行) + _hooks[hook_name].append(callback) + # 记录DEBUG级日志,说明钩子注册成功(包含钩子名称和回调函数名,便于调试) + logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'") + + +def run_action(hook_name: str, *args, **kwargs): + """ + 执行指定名称的"动作钩子(Action Hook)",按注册顺序调用所有关联的回调函数 + + 动作钩子特性:用于触发一系列操作,不关注返回值,仅执行回调逻辑 + 典型场景:文章创建后发送通知、记录日志等(执行动作但无需修改数据) + + :param hook_name: 要触发的钩子名称(需已被注册过) + :param *args: 传递给回调函数的位置参数(可变参数,根据钩子场景定义) + :param **kwargs: 传递给回调函数的关键字参数(可变参数,根据钩子场景定义) + """ + # 检查该钩子是否有已注册的回调函数 + if hook_name in _hooks: + # 记录DEBUG级日志,说明开始执行该动作钩子 + logger.debug(f"Running action hook '{hook_name}'") + # 按注册顺序遍历所有回调函数并执行 + for callback in _hooks[hook_name]: + try: + # 传递位置参数和关键字参数给回调函数 + callback(*args, **kwargs) + except Exception as e: + # 若回调执行出错,记录ERROR级日志(包含详细异常信息) + # exc_info=True 会在日志中附带堆栈跟踪,便于排查错误 + 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)",通过回调函数链式处理初始值并返回最终结果 + + 过滤钩子特性:用于对数据进行加工处理,每个回调函数接收上一个函数的输出作为输入, + 最终返回经过所有回调处理后的值 + 典型场景:文章内容过滤敏感词、格式化文本等(修改数据并返回新值) + + :param hook_name: 要触发的钩子名称(需已被注册过) + :param value: 初始值(需要被过滤/处理的数据,如文章内容字符串) + :param *args: 传递给回调函数的额外位置参数 + :param **kwargs: 传递给回调函数的额外关键字参数 + :return: 经过所有回调函数处理后的最终值 + """ + # 检查该钩子是否有已注册的回调函数 + if hook_name in _hooks: + # 记录DEBUG级日志,说明开始执行该过滤钩子 + logger.debug(f"Applying filter hook '{hook_name}'") + # 按注册顺序遍历所有回调函数,链式处理初始值 + for callback in _hooks[hook_name]: + try: + # 调用回调函数,将当前值和额外参数传入,更新值为回调返回的结果 + value = callback(value, *args, **kwargs) + except Exception as e: + # 若回调执行出错,记录ERROR级日志(包含详细异常信息) + 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/Django博客项目/djangoblog/plugin_manage/loader.py b/Django博客项目/djangoblog/plugin_manage/loader.py new file mode 100644 index 0000000..1e5157f --- /dev/null +++ b/Django博客项目/djangoblog/plugin_manage/loader.py @@ -0,0 +1,51 @@ +# 导入必要的模块 +# os: 用于处理文件路径和目录操作 +# logging: 用于记录插件加载过程中的日志信息(成功/失败状态) +# django.conf.settings: 用于获取Django项目的配置信息(如插件目录、激活的插件列表) +import os +import logging +from django.conf import settings + +# 创建当前模块的日志记录器,日志名称与模块绑定,便于追踪插件加载相关的日志 +logger = logging.getLogger(__name__) + + +def load_plugins(): + """ + 动态加载并初始化位于'plugins'目录中的插件 + + 功能说明: + - 从Django配置中读取激活的插件列表(settings.ACTIVE_PLUGINS) + - 检查每个插件的目录结构是否合法(是否存在plugin.py文件) + - 动态导入插件的核心模块(plugin.py),触发插件的初始化流程 + - 通过日志记录每个插件的加载结果(成功/失败及原因) + + 调用时机: + 该函数应在Django应用注册表(app registry)准备就绪后调用, + 通常在项目启动时(如通过AppConfig.ready()方法触发),确保Django环境已初始化完成。 + """ + # 遍历配置中激活的所有插件名称(settings.ACTIVE_PLUGINS是一个插件名称列表) + for plugin_name in settings.ACTIVE_PLUGINS: + # 构建插件的完整目录路径: + # settings.PLUGINS_DIR是项目中存放所有插件的根目录(如"project_root/plugins") + # 拼接根目录与当前插件名称,得到具体插件的目录路径(如"project_root/plugins/my_plugin") + plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name) + + # 验证插件目录的合法性: + # 1. 必须是一个存在的目录(os.path.isdir(plugin_path)) + # 2. 目录中必须包含核心文件plugin.py(插件的入口模块) + if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')): + try: + # 动态导入插件的plugin.py模块: + # 导入路径格式为"plugins.{插件名称}.plugin"(基于Python包结构) + # 导入后会自动执行plugin.py中的顶级代码(如插件类的定义和注册逻辑) + __import__(f'plugins.{plugin_name}.plugin') + + # 记录INFO级日志,提示插件加载成功 + logger.info(f"Successfully loaded plugin: {plugin_name}") + + # 捕获导入过程中的异常(如模块不存在、语法错误、依赖缺失等) + except ImportError as e: + # 记录ERROR级日志,提示插件导入失败,并附带异常信息 + # exc_info=e 会将异常堆栈信息写入日志,便于排查问题 + logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) \ No newline at end of file diff --git a/Django博客项目/djangoblog/settings.py b/Django博客项目/djangoblog/settings.py new file mode 100644 index 0000000..d818556 --- /dev/null +++ b/Django博客项目/djangoblog/settings.py @@ -0,0 +1,347 @@ +""" +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': os.environ.get('DJANGO_MYSQL_DATABASE') or 'djangoblog', + 'USER': os.environ.get('DJANGO_MYSQL_USER') or 'root', + 'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or '050322yyd*', + 'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1', + 'PORT': int( + os.environ.get('DJANGO_MYSQL_PORT') or 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': 'haystack.backends.whoosh_backend.WhooshEngine', + 'PATH': os.path.join(BASE_DIR, 'whoosh_index'), + 'ANALYZER': 'utils.jieba_analyzer.jieba_analyzer', + }, +} +# 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_DIRS = [ + 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' +] diff --git a/Django博客项目/djangoblog/sitemap.py b/Django博客项目/djangoblog/sitemap.py new file mode 100644 index 0000000..8b7d446 --- /dev/null +++ b/Django博客项目/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/Django博客项目/djangoblog/spider_notify.py b/Django博客项目/djangoblog/spider_notify.py new file mode 100644 index 0000000..7b909e9 --- /dev/null +++ b/Django博客项目/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/Django博客项目/djangoblog/tests.py b/Django博客项目/djangoblog/tests.py new file mode 100644 index 0000000..01237d9 --- /dev/null +++ b/Django博客项目/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/Django博客项目/djangoblog/urls.py b/Django博客项目/djangoblog/urls.py new file mode 100644 index 0000000..824630c --- /dev/null +++ b/Django博客项目/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 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')), + #path('search/', include('haystack.urls')), +] +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/Django博客项目/djangoblog/utils.py b/Django博客项目/djangoblog/utils.py new file mode 100644 index 0000000..57f63dc --- /dev/null +++ b/Django博客项目/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/Django博客项目/djangoblog/whoosh_cn_backend.py b/Django博客项目/djangoblog/whoosh_cn_backend.py new file mode 100644 index 0000000..be70775 --- /dev/null +++ b/Django博客项目/djangoblog/whoosh_cn_backend.py @@ -0,0 +1,1047 @@ +# 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 +try: + from jieba.analyse import ChineseAnalyzer +except ImportError: + ChineseAnalyzer = None +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/Django博客项目/djangoblog/wsgi.py b/Django博客项目/djangoblog/wsgi.py new file mode 100644 index 0000000..2295efd --- /dev/null +++ b/Django博客项目/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/Django博客项目/djangoblog_complete.sql b/Django博客项目/djangoblog_complete.sql new file mode 100644 index 0000000..423b3a4 Binary files /dev/null and b/Django博客项目/djangoblog_complete.sql differ diff --git a/Django博客项目/docs/README-en.md b/Django博客项目/docs/README-en.md new file mode 100644 index 0000000..37ea069 --- /dev/null +++ b/Django博客项目/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/Django博客项目/docs/config-en.md b/Django博客项目/docs/config-en.md new file mode 100644 index 0000000..b877efb --- /dev/null +++ b/Django博客项目/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/Django博客项目/docs/config.md b/Django博客项目/docs/config.md new file mode 100644 index 0000000..24673a3 --- /dev/null +++ b/Django博客项目/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/Django博客项目/docs/docker-en.md b/Django博客项目/docs/docker-en.md new file mode 100644 index 0000000..8d5d59e --- /dev/null +++ b/Django博客项目/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/Django博客项目/docs/docker.md b/Django博客项目/docs/docker.md new file mode 100644 index 0000000..e7c255a --- /dev/null +++ b/Django博客项目/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/Django博客项目/docs/es.md b/Django博客项目/docs/es.md new file mode 100644 index 0000000..97226c5 --- /dev/null +++ b/Django博客项目/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/Django博客项目/docs/imgs/alipay.jpg b/Django博客项目/docs/imgs/alipay.jpg new file mode 100644 index 0000000..424d70a Binary files /dev/null and b/Django博客项目/docs/imgs/alipay.jpg differ diff --git a/Django博客项目/docs/imgs/pycharm_logo.png b/Django博客项目/docs/imgs/pycharm_logo.png new file mode 100644 index 0000000..7f2a4b0 Binary files /dev/null and b/Django博客项目/docs/imgs/pycharm_logo.png differ diff --git a/Django博客项目/docs/imgs/wechat.jpg b/Django博客项目/docs/imgs/wechat.jpg new file mode 100644 index 0000000..7edf525 Binary files /dev/null and b/Django博客项目/docs/imgs/wechat.jpg differ diff --git a/Django博客项目/docs/k8s-en.md b/Django博客项目/docs/k8s-en.md new file mode 100644 index 0000000..20e9527 --- /dev/null +++ b/Django博客项目/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/Django博客项目/docs/k8s.md b/Django博客项目/docs/k8s.md new file mode 100644 index 0000000..9da3c28 --- /dev/null +++ b/Django博客项目/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/Django博客项目/fix_wsgi.py b/Django博客项目/fix_wsgi.py new file mode 100644 index 0000000..ba47dc8 --- /dev/null +++ b/Django博客项目/fix_wsgi.py @@ -0,0 +1,19 @@ +# fix_wsgi.py +import os +import sys + +# 添加项目路径 +project_path = r'D:\Django\src\DjangoBlog-master' +sys.path.append(project_path) + +# 设置环境变量 +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangoblog.settings') + +# 初始化 Django +import django +django.setup() + +# 测试 WSGI +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() +print("✅ WSGI 应用创建成功!") \ No newline at end of file diff --git a/Django博客项目/foodBlog/food_categories.json b/Django博客项目/foodBlog/food_categories.json new file mode 100644 index 0000000..d254c4e --- /dev/null +++ b/Django博客项目/foodBlog/food_categories.json @@ -0,0 +1,30 @@ +[ + { + "model": "blog.category", + "pk": 1, + "fields": { + "name": "中式菜肴" + } + }, + { + "model": "blog.category", + "pk": 2, + "fields": { + "name": "西式美食" + } + }, + { + "model": "blog.category", + "pk": 3, + "fields": { + "name": "甜点烘焙" + } + }, + { + "model": "blog.category", + "pk": 4, + "fields": { + "name": "特色小吃" + } + } +] \ No newline at end of file diff --git a/Django博客项目/foodBlog/food_posts.json b/Django博客项目/foodBlog/food_posts.json new file mode 100644 index 0000000..db79962 --- /dev/null +++ b/Django博客项目/foodBlog/food_posts.json @@ -0,0 +1,59 @@ +[ + { + "model": "blog.Article", + "pk": 1, + "fields": { + "title": "北京胡同里的炸酱面探店", + "body": "在北京,胡同是这座城市的脉络,藏着老北京最地道的烟火气,也藏着不少令人惊艳的美食小店。这次,我就寻到了一家隐匿在胡同深处的炸酱面店,那味道,堪称老北京炸酱面的\"天花板\"。这家店位于东四附近的一条小胡同里,没有醒目的招牌,只有一块略显斑驳的木牌,上面用毛笔字写着\"老北京炸酱面\"。若不是本地朋友极力推荐,我大概率会与它擦肩而过。跟着导航七拐八绕,穿过两侧是灰墙灰瓦的狭窄胡同,耳边是自行车铃铛声、街坊邻里的谈笑声,仿佛一下子穿越回了老北京的旧时光,而那家炸酱面店,就安静地坐落在胡同中段。推开略显厚重的木门,店内空间不大,摆着几张木质方桌和长条凳,桌面上擦得干干净净。墙壁上挂着老北京的黑白照片,有胡同的全景,有孩童嬉戏的场景,还有老北京人吃炸酱面的模样,瞬间就把人拉进了充满回忆的氛围里。店里的食客不少,有附近的老街坊,也有像我一样慕名而来的游客,大家挤在一起,却丝毫不觉得拥挤,反而因为这浓浓的烟火气,多了几分亲近感。老板是位地道的北京大爷,穿着白色的厨师服,脸上总是挂着憨厚的笑容,看到新客进来,会热情地招呼:\"里面坐,炸酱面马上好!\"等了没几分钟,一碗热腾腾的炸酱面就端上了桌。先看那炸酱,盛在一个小巧的青花瓷碗里,酱色红亮,上面还点缀着星星点点的葱花。用筷子挑起一点,能看到酱里肥瘦相间的五花肉丁,颗粒饱满,大小均匀。老板说,这酱是用五花肉丁先煸炒出油,再加入黄酱和甜面酱慢慢熬制而成,期间要不断搅拌,这样才能让酱变得浓稠,而且不会糊锅。凑近闻一闻,酱香混合着肉香,直往鼻子里钻,却一点都不觉得腻。再看面条,是店家手工制作的手擀面。面条粗细均匀,色泽洁白,根根分明。用筷子夹起一筷子,能感觉到面条很有韧性,放进嘴里一尝,果然筋道弹牙,咀嚼起来充满了小麦的清香。老板告诉我,他们的面条都是当天现做的,和面时还加了适量的盐,这样能让面条更有嚼劲。炸酱面的配菜也很丰富,一小盘里装着黄瓜丝、胡萝卜丝、豆芽、青豆、心里美萝卜丝,色彩鲜艳,看着就很有食欲。这些配菜都切得很精细,黄瓜丝脆嫩,胡萝卜丝清甜,豆芽爽脆,和筋道的面条、香浓的炸酱搭配在一起,口感层次十分丰富。按照老北京的吃法,先把配菜倒进面条里,再挖上一大勺炸酱,然后用筷子快速地搅拌均匀。随着搅拌,面条被均匀地裹上了红亮的炸酱,配菜也与面条充分融合。夹起一筷子,送进嘴里,面条的筋道、炸酱的香浓、配菜的清爽,在口腔里交织碰撞,那味道,真是绝了!每一口都能感受到老北京美食的独特魅力,仿佛能尝到时光的味道。吃完一碗,意犹未尽,连碗底的酱汁都舍不得剩下,恨不得用馒头蘸着吃掉。这家藏在胡同里的炸酱面店,没有华丽的装修,没有精致的摆盘,却用最地道的味道,征服了食客的味蕾。在这里,吃的不仅是一碗炸酱面,更是老北京的烟火气,是胡同里的人情味。如果你也喜欢老北京美食,不妨来这条胡同里,寻找这家\"宝藏小店\",相信你也会和我一样,被这碗炸酱面深深吸引。", + "pub_time": "2025-10-08 12:00:00", + "status": "p", + "comment_status": "o", + "type": "a", + "views": 0, + "author": 1, + "article_order": 0, + "show_toc": false, + "category": 1, + "tags": [1] + } + }, + + { + "model": "blog.Article", + "pk":2, + "fields": { + "title": "《意式餐厅里的味蕾漫游》", + "body": "在城市喧嚣的角落,藏着一家充满意式风情的餐厅,推开那扇雕花木门,仿佛瞬间穿越到了亚平宁半岛。暖黄的灯光洒在铺着格子桌布的餐桌上,墙上挂着意大利各地的风景照,空气中弥漫着橄榄油、芝士与香草交融的香气,一场味蕾的漫游就此开启。首先上桌的是经典的意式番茄肉酱面。手工制作的意面粗细均匀,如丝绸般顺滑,裹满了浓郁的肉酱。肉酱由新鲜番茄慢炖而成,酸甜的汁水充分渗入肉质鲜嫩的牛肉末中,每一口都能感受到番茄的清新与牛肉的醇厚在舌尖碰撞,搭配帕玛森芝士碎,咸香与酸甜交织,层次丰富得让人着迷。接着是意式烤千层面,这简直是芝士爱好者的天堂。层层叠叠的面皮间,夹着细腻的牛肉馅、奶香十足的 béchamel 酱,最上层铺满了马苏里拉芝士。经烤箱烤制后,芝士呈现出诱人的焦黄色,轻轻用叉子一挑,芝士拉出长长的丝,入口即化,面皮的柔韧、馅料的鲜美与芝士的浓郁完美融合,每一层都带来不同的味觉惊喜。最后以一份提拉米苏收尾再合适不过。手指饼干浸润了咖啡酒,带着微微的苦涩,与上层绵密的马斯卡彭芝士奶油完美平衡,顶部撒的可可粉更添了一丝醇厚。用小勺挖上一口,冰凉丝滑的口感在口中散开,咖啡的苦、芝士的甜、饼干的绵,交织出意式甜品独有的浪漫与馥郁。在这家意式餐厅,每一道菜都像是一位意大利友人,用最地道的风味,诉说着亚平宁半岛的阳光与浪漫,让我在城市里,也能拥有一场酣畅淋漓的味蕾漫游。", + "pub_time": "2025-10-08 12:00:00", + "status": "p", + "comment_status": "o", + "type": "a", + "views": 0, + "author": 1, + "article_order": 0, + "show_toc": false, + "category": 2, + "tags": [3] + } + }, + { + "model": "blog.Article", + "pk": 3, + "fields": { + "title": "《法式甜品店的午后邂逅》", + "body": "在繁华街区的转角处,一家名为『甜蜜时光』的法式甜品店静静伫立。推开那扇镶嵌着琉璃的蓝色木门,清脆的风铃声伴随着扑面而来的黄油香气,瞬间将人带入一个甜蜜的梦境。店内装饰着复古镜框与蕾丝窗帘,大理石台面上陈列着琳琅满目的甜品,每一款都如同精雕细琢的艺术品,令人目不暇接。首先品尝的是经典的拿破仑千层酥。三层金黄酥脆的派皮之间,均匀地夹着香草籽点缀的卡仕达酱。用叉子轻轻一压,酥皮应声碎裂,发出悦耳的咔嚓声。入口时,奶油的丝滑与派皮的酥脆在口中交织,香草的芬芳缓缓释放,甜而不腻,层次分明,仿佛在舌尖演奏了一首优雅的法国香颂。接下来是外形精致的覆盆子慕斯。光滑的镜面淋面下,是轻盈如云的慕斯体,中间包裹着酸甜的覆盆子果酱夹心。用小勺挖开,慕斯的绵密与果酱的流动感形成美妙对比。覆盆子的微酸恰到好处地平衡了慕斯的甜度,入口即化,余味带着一丝果香的清新,令人回味无穷。最后以一杯手冲伯爵茶搭配柠檬玛德琳收尾。现烤的玛德琳蛋糕散发着柠檬的清香,标志性的贝壳造型可爱迷人。蛋糕体湿润柔软,边缘带着微微的焦脆。轻咬一口,黄油的浓郁与柠檬的清爽相得益彰,再啜饮一口佛手柑香气萦绕的伯爵茶,茶香与蛋糕的甜味在口中完美融合,为这个午后画上了一个圆满的句号。在这家法式甜品店,每一道甜品都是一首诗,用细腻的口感与精致的造型,诉说着法式浪漫的甜蜜絮语,让我在忙碌的生活中,寻得了一份难得的惬意与治愈。", + "pub_time": "2025-10-09 14:30:00", + "status": "p", + "comment_status": "o", + "type": "a", + "views": 0, + "author": 1, + "article_order": 0, + "show_toc": false, + "category": 3, + "tags": [3] + } + } + + + ] diff --git a/Django博客项目/foodBlog/food_tags.json b/Django博客项目/foodBlog/food_tags.json new file mode 100644 index 0000000..3459489 --- /dev/null +++ b/Django博客项目/foodBlog/food_tags.json @@ -0,0 +1,30 @@ +[ + { + "model": "blog.tag", + "pk": 1, + "fields": { + "name": "探店" + } + }, + { + "model": "blog.tag", + "pk": 2, + "fields": { + "name": "家常菜" + } + }, + { + "model": "blog.tag", + "pk": 3, + "fields": { + "name": "烘焙" + } + }, + { + "model": "blog.tag", + "pk": 4, + "fields": { + "name": "小吃" + } + } +] \ No newline at end of file diff --git a/Django博客项目/foodBlog/food_users.json b/Django博客项目/foodBlog/food_users.json new file mode 100644 index 0000000..9996baf --- /dev/null +++ b/Django博客项目/foodBlog/food_users.json @@ -0,0 +1,13 @@ +[ + { + "model": "auth.user", + "pk": 1, + "fields": { + "username": "王希", + "password": "pbkdf2_sha256$600000$oCcoRHDsETbZPV41L4cTwB$6zR8e4rwSsG6HStNokpXlrV7ynvRvnZTEej7gNKro2Y=", + "email": "yyd0322@qq.com", + "is_staff": false, + "is_superuser": false + } + } +] \ No newline at end of file diff --git a/Django博客项目/locale/en/LC_MESSAGES/django.mo b/Django博客项目/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..f63669f Binary files /dev/null and b/Django博客项目/locale/en/LC_MESSAGES/django.mo differ diff --git a/Django博客项目/locale/en/LC_MESSAGES/django.po b/Django博客项目/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c80b30a --- /dev/null +++ b/Django博客项目/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/Django博客项目/locale/zh_Hans/LC_MESSAGES/django.mo b/Django博客项目/locale/zh_Hans/LC_MESSAGES/django.mo new file mode 100644 index 0000000..a2d36e9 Binary files /dev/null and b/Django博客项目/locale/zh_Hans/LC_MESSAGES/django.mo differ diff --git a/Django博客项目/locale/zh_Hans/LC_MESSAGES/django.po b/Django博客项目/locale/zh_Hans/LC_MESSAGES/django.po new file mode 100644 index 0000000..200b7e6 --- /dev/null +++ b/Django博客项目/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/Django博客项目/locale/zh_Hant/LC_MESSAGES/django.mo b/Django博客项目/locale/zh_Hant/LC_MESSAGES/django.mo new file mode 100644 index 0000000..fe2ea17 Binary files /dev/null and b/Django博客项目/locale/zh_Hant/LC_MESSAGES/django.mo differ diff --git a/Django博客项目/locale/zh_Hant/LC_MESSAGES/django.po b/Django博客项目/locale/zh_Hant/LC_MESSAGES/django.po new file mode 100644 index 0000000..a2920ce --- /dev/null +++ b/Django博客项目/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/Django博客项目/manage.py b/Django博客项目/manage.py new file mode 100644 index 0000000..fc7c243 --- /dev/null +++ b/Django博客项目/manage.py @@ -0,0 +1,15 @@ +#!/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 as exc: + 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?" + ) from exc + execute_from_command_line(sys.argv) \ No newline at end of file diff --git a/Django博客项目/oauth/__init__.py b/Django博客项目/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/oauth/admin.py b/Django博客项目/oauth/admin.py new file mode 100644 index 0000000..57eab5f --- /dev/null +++ b/Django博客项目/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/Django博客项目/oauth/apps.py b/Django博客项目/oauth/apps.py new file mode 100644 index 0000000..17fcea2 --- /dev/null +++ b/Django博客项目/oauth/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class OauthConfig(AppConfig): + name = 'oauth' diff --git a/Django博客项目/oauth/forms.py b/Django博客项目/oauth/forms.py new file mode 100644 index 0000000..0e4ede3 --- /dev/null +++ b/Django博客项目/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/Django博客项目/oauth/migrations/0001_initial.py b/Django博客项目/oauth/migrations/0001_initial.py new file mode 100644 index 0000000..3aa3e03 --- /dev/null +++ b/Django博客项目/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/Django博客项目/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py b/Django博客项目/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py new file mode 100644 index 0000000..d5cc70e --- /dev/null +++ b/Django博客项目/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/Django博客项目/oauth/migrations/0003_alter_oauthuser_nickname.py b/Django博客项目/oauth/migrations/0003_alter_oauthuser_nickname.py new file mode 100644 index 0000000..6af08eb --- /dev/null +++ b/Django博客项目/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/Django博客项目/oauth/migrations/__init__.py b/Django博客项目/oauth/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/oauth/models.py b/Django博客项目/oauth/models.py new file mode 100644 index 0000000..be838ed --- /dev/null +++ b/Django博客项目/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/Django博客项目/oauth/oauthmanager.py b/Django博客项目/oauth/oauthmanager.py new file mode 100644 index 0000000..2e7ceef --- /dev/null +++ b/Django博客项目/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/Django博客项目/oauth/templatetags/__init__.py b/Django博客项目/oauth/templatetags/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Django博客项目/oauth/templatetags/__init__.py @@ -0,0 +1 @@ + diff --git a/Django博客项目/oauth/templatetags/oauth_tags.py b/Django博客项目/oauth/templatetags/oauth_tags.py new file mode 100644 index 0000000..7b687d5 --- /dev/null +++ b/Django博客项目/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/Django博客项目/oauth/tests.py b/Django博客项目/oauth/tests.py new file mode 100644 index 0000000..bb23b9b --- /dev/null +++ b/Django博客项目/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/Django博客项目/oauth/urls.py b/Django博客项目/oauth/urls.py new file mode 100644 index 0000000..c4a12a0 --- /dev/null +++ b/Django博客项目/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/Django博客项目/oauth/views.py b/Django博客项目/oauth/views.py new file mode 100644 index 0000000..12e3a6e --- /dev/null +++ b/Django博客项目/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/Django博客项目/owntracks/__init__.py b/Django博客项目/owntracks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/owntracks/admin.py b/Django博客项目/owntracks/admin.py new file mode 100644 index 0000000..655b535 --- /dev/null +++ b/Django博客项目/owntracks/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin + +# Register your models here. + + +class OwnTrackLogsAdmin(admin.ModelAdmin): + pass diff --git a/Django博客项目/owntracks/apps.py b/Django博客项目/owntracks/apps.py new file mode 100644 index 0000000..1bc5f12 --- /dev/null +++ b/Django博客项目/owntracks/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class OwntracksConfig(AppConfig): + name = 'owntracks' diff --git a/Django博客项目/owntracks/migrations/0001_initial.py b/Django博客项目/owntracks/migrations/0001_initial.py new file mode 100644 index 0000000..9eee55c --- /dev/null +++ b/Django博客项目/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/Django博客项目/owntracks/migrations/0002_alter_owntracklog_options_and_more.py b/Django博客项目/owntracks/migrations/0002_alter_owntracklog_options_and_more.py new file mode 100644 index 0000000..b4f8dec --- /dev/null +++ b/Django博客项目/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/Django博客项目/owntracks/migrations/__init__.py b/Django博客项目/owntracks/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/owntracks/models.py b/Django博客项目/owntracks/models.py new file mode 100644 index 0000000..760942c --- /dev/null +++ b/Django博客项目/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/Django博客项目/owntracks/tests.py b/Django博客项目/owntracks/tests.py new file mode 100644 index 0000000..3b4b9d8 --- /dev/null +++ b/Django博客项目/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/Django博客项目/owntracks/urls.py b/Django博客项目/owntracks/urls.py new file mode 100644 index 0000000..c19ada8 --- /dev/null +++ b/Django博客项目/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/Django博客项目/owntracks/views.py b/Django博客项目/owntracks/views.py new file mode 100644 index 0000000..4c72bdd --- /dev/null +++ b/Django博客项目/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/Django博客项目/plugins/__init__.py b/Django博客项目/plugins/__init__.py new file mode 100644 index 0000000..e88afca --- /dev/null +++ b/Django博客项目/plugins/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/Django博客项目/plugins/article_copyright/__init__.py b/Django博客项目/plugins/article_copyright/__init__.py new file mode 100644 index 0000000..e88afca --- /dev/null +++ b/Django博客项目/plugins/article_copyright/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/Django博客项目/plugins/article_copyright/plugin.py b/Django博客项目/plugins/article_copyright/plugin.py new file mode 100644 index 0000000..317fed2 --- /dev/null +++ b/Django博客项目/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/Django博客项目/plugins/external_links/__init__.py b/Django博客项目/plugins/external_links/__init__.py new file mode 100644 index 0000000..e88afca --- /dev/null +++ b/Django博客项目/plugins/external_links/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/Django博客项目/plugins/external_links/plugin.py b/Django博客项目/plugins/external_links/plugin.py new file mode 100644 index 0000000..5b2ef14 --- /dev/null +++ b/Django博客项目/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/Django博客项目/plugins/reading_time/__init__.py b/Django博客项目/plugins/reading_time/__init__.py new file mode 100644 index 0000000..e88afca --- /dev/null +++ b/Django博客项目/plugins/reading_time/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/Django博客项目/plugins/reading_time/plugin.py b/Django博客项目/plugins/reading_time/plugin.py new file mode 100644 index 0000000..35f9db1 --- /dev/null +++ b/Django博客项目/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/Django博客项目/plugins/seo_optimizer/__init__.py b/Django博客项目/plugins/seo_optimizer/__init__.py new file mode 100644 index 0000000..e88afca --- /dev/null +++ b/Django博客项目/plugins/seo_optimizer/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/Django博客项目/plugins/seo_optimizer/plugin.py b/Django博客项目/plugins/seo_optimizer/plugin.py new file mode 100644 index 0000000..b5b19a3 --- /dev/null +++ b/Django博客项目/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/Django博客项目/plugins/view_count/__init__.py b/Django博客项目/plugins/view_count/__init__.py new file mode 100644 index 0000000..8804fdf --- /dev/null +++ b/Django博客项目/plugins/view_count/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package \ No newline at end of file diff --git a/Django博客项目/plugins/view_count/plugin.py b/Django博客项目/plugins/view_count/plugin.py new file mode 100644 index 0000000..15e9d94 --- /dev/null +++ b/Django博客项目/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/Django博客项目/requirements.txt b/Django博客项目/requirements.txt new file mode 100644 index 0000000..9dc5c93 Binary files /dev/null and b/Django博客项目/requirements.txt differ diff --git a/Django博客项目/servermanager/MemcacheStorage.py b/Django博客项目/servermanager/MemcacheStorage.py new file mode 100644 index 0000000..78e656f --- /dev/null +++ b/Django博客项目/servermanager/MemcacheStorage.py @@ -0,0 +1,87 @@ +#yyd: +# coding: utf-8 + +# 导入WeRoBot框架的会话存储基类和工具函数 +from werobot.session import SessionStorage +from werobot.utils import json_loads, json_dumps + +# 导入Django项目的缓存工具 +from djangoblog.utils import cache + + +class MemcacheStorage(SessionStorage): + """ + 基于Memcached的WeRoBot会话存储实现 + 继承自WeRoBot的SessionStorage基类,用于将会话数据存储到Memcached中 + """ + + def __init__(self, prefix='ws_'): + """ + 初始化方法 + + Args: + prefix (str): 缓存键的前缀,默认为'ws_',用于区分其他缓存数据 + """ + self.prefix = prefix # 设置缓存键前缀 + self.cache = cache # Django缓存实例(通常是Memcached) + + @property + def is_available(self): + """ + 检查缓存存储是否可用的属性 + + Returns: + bool: 如果缓存系统工作正常返回True,否则返回False + """ + value = "1" # 测试值 + # 尝试设置一个测试键值对 + self.set('check_available', value=value) + # 读取测试值并比较,验证缓存系统是否正常工作 + return value == self.get('check_available') + + def key_name(self, session_id): + """ + 生成完整的缓存键名 + + Args: + session_id (str): 原始会话ID + + Returns: + str: 添加前缀后的完整缓存键名 + """ + return f'{self.prefix}{session_id}' + + def get(self, session_id): + """ + 根据会话ID获取会话数据 + + Args: + session_id (str): 会话ID + + Returns: + dict: 解析后的会话数据字典,如果不存在返回空字典 + """ + key = self.key_name(session_id) # 生成完整缓存键 + session_json = self.cache.get(key) or '{}' # 从缓存获取数据 + return json_loads(session_json) + + def set(self, session_id, value): + """ + 设置会话数据 + + Args: + session_id (str): 会话ID + value (dict): 要存储的会话数据字典 + """ + key = self.key_name(session_id) + self.cache.set(key, json_dumps(value)) + + def delete(self, session_id): + """ + 删除指定会话ID的数据 + + Args: + session_id (str): 要删除的会话ID + """ + key = self.key_name(session_id) + self.cache.delete(key) diff --git a/Django博客项目/servermanager/__init__.py b/Django博客项目/servermanager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/servermanager/admin.py b/Django博客项目/servermanager/admin.py new file mode 100644 index 0000000..d18ca95 --- /dev/null +++ b/Django博客项目/servermanager/admin.py @@ -0,0 +1,66 @@ +#yyd: +# coding: utf-8 + +# 导入Django管理模块 +from django.contrib import admin +# 导入自定义模型(假设已在同一目录的models.py中定义) +from .models import commands, EmailSendLog + + +# 注册Commands模型到Django管理后台 +class CommandsAdmin(admin.ModelAdmin): + """ + 命令模型的管理界面配置类 + 用于自定义commands模型在Django admin中的显示和行为 + """ + + # 设置在管理列表页面显示的字段 + list_display = ('title', 'command', 'describe') + # 字段说明: + # title: 命令标题 + # command: 具体命令内容 + # describe: 命令描述 + + +# 注册EmailSendLog模型到Django管理后台 +class EmailSendLogAdmin(admin.ModelAdmin): + """ + 邮件发送日志模型的管理界面配置类 + 用于自定义EmailSendLog模型在Django admin中的显示和行为 + """ + + # 设置在管理列表页面显示的字段 + list_display = ('title', 'emailto', 'send_result', 'creation_time') + # 字段说明: + # title: 邮件标题 + # emailto: 收件人 + # send_result: 发送结果 + # creation_time: 创建时间 + + # 设置只读字段,这些字段在编辑页面不可修改 + readonly_fields = ( + 'title', # 邮件标题(只读) + 'emailto', # 收件人(只读) + 'send_result', # 发送结果(只读) + 'creation_time', # 创建时间(只读) + 'content' # 邮件内容(只读) + ) + + def has_add_permission(self, request): + """ + 重写添加权限方法,禁止在管理后台手动添加邮件日志记录 + + Args: + request: HTTP请求对象 + + Returns: + bool: 总是返回False,禁止添加操作 + """ + return False + # 这样设计是因为邮件发送日志应该由系统自动创建1 + # 而不是手动添加,保证日志的真实性和完整性. + + +# 将模型注册到Django管理后台 +admin.site.register(commands, CommandsAdmin) +admin.site.register(EmailSendLog, EmailSendLogAdmin) \ No newline at end of file diff --git a/Django博客项目/servermanager/api/__init__.py b/Django博客项目/servermanager/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Django博客项目/servermanager/api/__init__.py @@ -0,0 +1 @@ + diff --git a/Django博客项目/servermanager/api/blogapi.py b/Django博客项目/servermanager/api/blogapi.py new file mode 100644 index 0000000..6ba09ef --- /dev/null +++ b/Django博客项目/servermanager/api/blogapi.py @@ -0,0 +1,81 @@ +#yyd: +# coding: utf-8 + +# 导入Haystack搜索查询集 +from haystack.query import SearchQuerySet + +# 导入博客模型 +from blog.models import Article, Category + + +class BlogApi: + """ + 博客API类 + 提供博客文章的搜索、分类获取和最近文章等功能 + """ + + def __init__(self): + """ + 初始化方法 + 创建搜索查询集实例并设置最大返回数量 + """ + # 创建搜索查询集实例 + self.searchqueryset = SearchQuerySet() + # 初始化空查询,准备后续搜索 + self.searchqueryset.auto_query('') + # 设置最大返回文章数量 + self.__max_takecount__ = 8 + + def search_articles(self, query): + """ + 搜索文章方法 + + Args: + query (str): 搜索关键词 + + Returns: + SearchQuerySet: 包含搜索结果的查询集,最多返回__max_takecount__条结果 + """ + # 使用Haystack进行自动搜索查询 + sqs = self.searchqueryset.auto_query(query) + # 加载所有相关对象(避免N+1查询问题) + sqs = sqs.load_all() + # 返回前__max_takecount__条结果 + return sqs[:self.__max_takecount__] + + def get_category_lists(self): + """ + 获取所有分类列表 + + Returns: + QuerySet: 包含所有分类的查询集 + """ + return Category.objects.all() + + def get_category_articles(self, categoryname): + """ + 根据分类名称获取该分类下的文章 + + Args: + categoryname (str): 分类名称 + + Returns: + QuerySet or None: 包含该分类文章的查询集(最多__max_takecount__条),如果分类不存在返回None + """ + # 根据分类名称过滤文章 + articles = Article.objects.filter(category__name=categoryname) + # 如果找到文章,返回前__max_takecount__条 + if articles: + return articles[:self.__max_takecount__] + # 没有找到文章返回None + return None + + def get_recent_articles(self): + """ + 获取最近的文章 + + Returns: + QuerySet: 包含最近文章的查询集,最多返回__max_takecount__条.1 + """ + # 获取所有文章并按时间排序(假设Article模型的默认排序是按时间倒序) + return Article.objects.all()[:self.__max_takecount__] \ No newline at end of file diff --git a/Django博客项目/servermanager/api/commonapi.py b/Django博客项目/servermanager/api/commonapi.py new file mode 100644 index 0000000..7ba0a43 --- /dev/null +++ b/Django博客项目/servermanager/api/commonapi.py @@ -0,0 +1,133 @@ +#yyd: +# coding: utf-8 + +# 导入必要的模块 +import logging +import os + +import openai # OpenAI API客户端 + +# 导入自定义的Django模型 +from servermanager.models import commands + +# 获取当前模块的日志记录器 +logger = logging.getLogger(__name__) + +# 配置OpenAI API密钥,从环境变量中获取 +openai.api_key = os.environ.get('OPENAI_API_KEY') +# 如果设置了HTTP代理,配置OpenAI使用代理 +if os.environ.get('HTTP_PROXY'): + openai.proxy = os.environ.get('HTTP_PROXY') + + +class ChatGPT: + """ + ChatGPT API封装类 + 提供与OpenAI GPT模型交互的静态方法 + """ + + @staticmethod + def chat(prompt): + """ + 与ChatGPT进行对话的静态方法 + + Args: + prompt (str): 用户输入的提示词 + + Returns: + str: ChatGPT的回复内容,如果出错返回错误信息 + """ + try: + # 调用OpenAI ChatCompletion API创建对话完成 + completion = openai.ChatCompletion.create( + model="gpt-3.5-turbo", # 指定使用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): + """ + 运行指定标题的命令 + + Args: + title (str): 命令标题 + + Returns: + str: 命令执行结果或帮助信息 + """ + # 使用filter和lambda函数查找标题匹配的命令(不区分大小写) + cmd = list( + filter( + lambda x: x.title.upper() == title.upper(), # 不区分大小写比较 + self.commands)) + + # 如果找到匹配的命令 + if cmd: + # 执行找到的第一个命令 + return self.__run_command__(cmd[0].command) + else: + # 未找到命令时返回帮助提示 + return "未找到相关命令,请输入helpme获得帮助。" + + def __run_command__(self, cmd): + """ + 私有方法:实际执行系统命令 + + Args: + cmd (str): 要执行的系统命令 + + Returns: + str: 命令执行结果或错误信息 + """ + try: + # 使用os.popen执行系统命令并读取输出 + res = os.popen(cmd).read() + return res + except Exception as e: + logger.error(f"命令执行错误:{e}") + return '命令执行出错!' + + + def get_help(self): + """ + 获取所有命令的帮助信息 + + Returns: + str: 格式化的命令帮助信息,包含所有命令的标题和描述1 + """ + rsp = '' + # 遍历所有命令,格式化输出 + for cmd in self.commands: + rsp += '{c}:{d}\n'.format(c=cmd.title, d=cmd.describe) + return rsp + + +# 主程序入口. +if __name__ == '__main__': + # 创建ChatGPT实例 + chatbot = ChatGPT() + # 测试提示词 + prompt = "写一篇1000字关于AI的论文" + # 调用chat方法并打印结果 + print(chatbot.chat(prompt)) \ No newline at end of file diff --git a/Django博客项目/servermanager/apps.py b/Django博客项目/servermanager/apps.py new file mode 100644 index 0000000..abbb713 --- /dev/null +++ b/Django博客项目/servermanager/apps.py @@ -0,0 +1,22 @@ +#yyd: +# coding: utf-8 + +# 导入 Django 的应用配置基类 +from django.apps import AppConfig + + +class ServermanagerConfig(AppConfig): + """ + Servermanager 应用配置类 + ------------------------------------- + 用于配置 servermanager 应用的元数据和初始化行为。 + + 主要功能: + 1. 定义应用的基本配置信息; + 2. 在 Django 启动时进行应用级初始化; + 3. 提供应用的可读名称和默认主键类型等可选设置。 + """ + + # 应用在项目中的 Python 路径(必填项) + # 该名称必须与 settings.INSTALLED_APPS 中一致。 + name = 'servermanager' diff --git a/Django博客项目/servermanager/migrations/0001_initial.py b/Django博客项目/servermanager/migrations/0001_initial.py new file mode 100644 index 0000000..2fa1be1 --- /dev/null +++ b/Django博客项目/servermanager/migrations/0001_initial.py @@ -0,0 +1,70 @@ +#yyd: +# coding: utf-8 + +# Generated by Django 4.1.7 on 2023-03-02 07:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + Django数据库迁移类 + 迁移用于创建数据库表结构,这是初始迁移(initial=True) + """ + + initial = True # 标记这是初始迁移 + + dependencies = [ + # 依赖关系列表,此迁移不依赖其他迁移 + ] + + operations = [ + # 迁移操作列表 + + # 创建命令表 + migrations.CreateModel( + name='commands', # 模型名称 + fields=[ + # 主键字段,自增BigInteger类型 + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 命令标题字段,最大长度300字符 + ('title', models.CharField(max_length=300, verbose_name='命令标题')), + # 命令字段,存储具体命令内容,最大长度2000字符 + ('command', models.CharField(max_length=2000, verbose_name='命令')), + # 命令描述字段,最大长度300字符 + ('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=[ + # 主键字段,自增BigInteger类型 + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 收件人字段,存储收件人邮箱地址,最大长度300字符 + ('emailto', models.CharField(max_length=300, verbose_name='收件人')), + # 邮件标题字段,最大长度2000字符 + ('title', models.CharField(max_length=2000, verbose_name='邮件标题')), + # 邮件内容字段,文本类型,无长度限制 + ('content', models.TextField(verbose_name='邮件内容')), + # 发送结果字段,布尔类型,默认值为False(发送失败) + ('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', # 复数形式的可读名称1 + 'ordering': ['-created_time'], # 默认按创建时间降序排列 + }, + ), + ] \ No newline at end of file diff --git a/Django博客项目/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py b/Django博客项目/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py new file mode 100644 index 0000000..38caa17 --- /dev/null +++ b/Django博客项目/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py @@ -0,0 +1,52 @@ +#yyd: +# coding: utf-8 +# Generated by Django 4.2.5 on 2023-09-06 13:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + """ + Django数据库迁移类 + 这是一个数据模型变更的迁移文件,用于修改已有表结构 + """ + + dependencies = [ + # 依赖关系:此迁移依赖于servermanager应用的0001_initial初始迁移 + ('servermanager', '0001_initial'), + ] + + operations = [ + # 迁移操作列表 + + # 修改EmailSendLog模型的元选项 + migrations.AlterModelOptions( + name='emailsendlog', # 模型名称 + options={ + 'ordering': ['-creation_time'], # 改为按creation_time字段降序排列 + 'verbose_name': '邮件发送log', # 单数形式的可读名称 + 'verbose_name_plural': '邮件发送log', # 复数形式的可读名称 + }, + ), + + # 重命名commands模型的.created_time字段为creation_time + migrations.RenameField( + model_name='commands', # 模型名称 + old_name='created_time', # 原字段名 + new_name='creation_time', # 新字段名 + ), + + # 重命名commands模型的last_mod_time字段为last_modify_time + migrations.RenameField( + model_name='commands', # 模型名称 + old_name='last_mod_time', # 原字段名 + new_name='last_modify_time', # 新字段名 + ), + + # 重命名emailsendlog模型的created_time字段为creation_time1 + migrations.RenameField( + model_name='emailsendlog', # 模型名称 + old_name='created_time', # 原字段名 + new_name='creation_time', # 新字段名 + ), + ] \ No newline at end of file diff --git a/Django博客项目/servermanager/migrations/__init__.py b/Django博客项目/servermanager/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/servermanager/models.py b/Django博客项目/servermanager/models.py new file mode 100644 index 0000000..b5d70f0 --- /dev/null +++ b/Django博客项目/servermanager/models.py @@ -0,0 +1,85 @@ +#yyd: +# coding: utf-8 + +# 导入Django的模型模块 +from django.db import models + + +# 创建命令模型(数据库表) +class commands(models.Model): + """ + 命令模型类 + 用于存储系统命令的相关信息 + """ + + # 命令标题字段,CharField类型,最大长度300,在admin中显示为'命令标题' + title = models.CharField('命令标题', max_length=300) + + # 命令内容字段,CharField类型,最大长度2000,在admin中显示为'命令' + command = models.CharField('命令', max_length=2000) + + # 命令描述字段,CharField类型,最大长度300,在admin中显示为'命令描述' + describe = models.CharField('命令描述', max_length=300) + + # 创建时间字段,DateTimeField类型,自动设置为对象创建时的时间 + creation_time = models.DateTimeField('创建时间', auto_now_add=True) + + # 最后修改时间字段,DateTimeField类型,自动更新为对象最后修改的时间 + last_modify_time = models.DateTimeField('修改时间', auto_now=True) + + def __str__(self): + """ + 定义对象的字符串表示形式 + 在Django admin和shell中显示时使用 + + Returns: + str: 命令标题 + """ + return self.title + + class Meta: + """ + 模型的元数据配置类 + 用于定义模型的其他选项 + """ + verbose_name = '命令' # 单数形式的可读名称 + verbose_name_plural = verbose_name # 复数形式的可读名称(与单数相同) + + +class EmailSendLog(models.Model): + """ + 邮件发送日志模型类 + 用于记录邮件发送的历史记录和结果 + """ + + # 收件人字段,CharField类型,最大长度300,在admin中显示为'收件人' + emailto = models.CharField('收件人', max_length=300) + + # 邮件标题字段,CharField类型,最大长度2000,在admin中显示为'邮件标题' + title = models.CharField('邮件标题', max_length=2000) + + # 邮件内容字段,TextField类型,无长度限制,在admin中显示为'邮件内容' + content = models.TextField('邮件内容') + + # 发送结果字段,BooleanField类型,默认值为False(发送失败),在admin中显示为'结果' + send_result = models.BooleanField('结果', default=False) + + # 创建时间字段,DateTimeField类型,自动设置为对象创建时的时间 + creation_time = models.DateTimeField('创建时间', auto_now_add=True) + + def __str__(self): + """ + 定义对象的字符串表示形式 + + Returns: + str: 邮件标题 + """ + return self.title + + class Meta: + """ + 模型的元数据配置类 + """ + verbose_name = '邮件发送log' # 单数形式的可读名称. + verbose_name_plural = verbose_name # 复数形式的可读名称 + ordering = ['-creation_time'] # 默认按创建时间降序排列(最新的在前)1 \ No newline at end of file diff --git a/Django博客项目/servermanager/robot.py b/Django博客项目/servermanager/robot.py new file mode 100644 index 0000000..878e84e --- /dev/null +++ b/Django博客项目/servermanager/robot.py @@ -0,0 +1,288 @@ +#yyd: +# coding: utf-8 + +# 导入必要的模块 +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 # 博客API +from servermanager.api.commonapi import ChatGPT, CommandHandler # 聊天和命令处理 +from .MemcacheStorage import MemcacheStorage # 自定义缓存存储 + +# 初始化微信机器人,从环境变量获取token,默认使用'lylinux' +robot = WeRoBot(token=os.environ.get('DJANGO_WEROBOT_TOKEN') or 'lylinux', enable_session=True) + +# 初始化Memcached会话存储 +memstorage = MemcacheStorage() +# 检查Memcached是否可用,如果可用则使用,否则使用文件存储 +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') + +# 初始化API处理器 +blogapi = BlogApi() +cmd_handler = CommandHandler() +logger = logging.getLogger(__name__) + + +def convert_to_article_reply(articles, message): + """ + 将文章列表转换为微信图文回复格式 + + Args: + articles: 文章对象列表 + message: 微信消息对象 + + Returns: + ArticlesReply: 图文回复对象 + """ + 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 + url=post.get_full_url() # 文章完整URL + ) + reply.add_article(article) + return reply + + +@robot.filter(re.compile(r"^\?.*")) +def search(message, session): + """ + 文章搜索功能 + 触发方式: ?关键字 + 例如: ?python + """ + s = message.content + searchstr = str(s).replace('?', '') # 移除问号获取搜索关键词 + result = blogapi.search_articles(searchstr) # 调用搜索API + + if result: + # 将搜索结果转换为文章对象列表 + articles = [x.object for x in 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): + """ + 获取所有文章分类 + 触发方式:category + """ + categorys = blogapi.get_category_lists() + content = ','.join([x.name for x in categorys]) + return '所有文章分类目录: ' + content + + + +@robot.filter(re.compile(r'^recent\s*$', re.I)) +def recents(message, session): + """ + 获取最新文章 + 触发方式: recent + """ + 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): + """ + 获取帮助信息 + 触发方式: help + """ + 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: + # 如果会话中没有用户信息,创建新的用户信息对象 + 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.strip().upper() + + if self._handle_exit(info): + return "退出成功" + + if self._handle_admin_entry(info): + return "输入管理员密码" + + if self._needs_password_verification(): + return self._handle_password_verify(info) + + if self._can_execute_admin_command(): + return self._handle_admin_command(info) + + return self._handle_default_chat(info) + +# ------------------ 以下为子逻辑模块 ------------------ + +def _handle_exit(self, info: str) -> bool: + """管理员退出命令""" + if self.userinfo.isAdmin and info == 'EXIT': + self.userinfo = WxUserInfo() + self.save_session() + return True + return False + +def _handle_admin_entry(self, info: str) -> bool: + """进入管理员模式""" + if info == 'ADMIN': + self.userinfo.isAdmin = True + self.save_session() + return True + return False + +def _needs_password_verification(self) -> bool: + """判断是否需要进行密码验证""" + return self.userinfo.isAdmin and not self.userinfo.isPasswordSet + +def _handle_password_verify(self, info: str) -> str: + """管理员密码验证""" + 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获得帮助" + + # 密码错误次数限制 + if self.userinfo.Count >= 3: + self.userinfo = WxUserInfo() + self.save_session() + return "超过验证次数" + + self.userinfo.Count += 1 + self.save_session() + return "验证失败,请重新输入管理员密码:" + +def _can_execute_admin_command(self) -> bool: + """是否处于可执行管理员命令状态""" + return self.userinfo.isAdmin and self.userinfo.isPasswordSet + +def _handle_admin_command(self, info: str) -> str: + """管理员命令执行""" + if self.userinfo.Command and info == 'Y': + return cmd_handler.run(self.userinfo.Command) + + if info == 'HELPME': + return cmd_handler.get_help() + + self.userinfo.Command = info + self.save_session() + return f"确认执行: {info} 命令?" + +def _handle_default_chat(self, info: str) -> str: + """普通模式下使用 ChatGPT 回复""" + return ChatGPT.chat(info) + + +class WxUserInfo(): + """微信用户信息类,用于存储用户会话状态""" + + def __init__(self): + self.is_admin = False # 是否为管理员. + self.is_password_set = False # 是否通过密码验证1 + self.count = 0 # 密码尝试次数 + self.command = '' # 待确认的命令 \ No newline at end of file diff --git a/Django博客项目/servermanager/tests.py b/Django博客项目/servermanager/tests.py new file mode 100644 index 0000000..c42d9fe --- /dev/null +++ b/Django博客项目/servermanager/tests.py @@ -0,0 +1,119 @@ +#yyd: +# coding: utf-8 + +# 导入Django测试相关模块 +from django.test import Client, RequestFactory, TestCase +from django.utils import timezone +# 导入WeRoBot文本消息类 +from werobot.messages.messages import TextMessage + +# 导入应用模型 +from accounts.models import BlogUser +from blog.models import Category, Article +# 导入要测试的API类 +from servermanager.api.commonapi import ChatGPT +from .models import commands +# 导入要测试的机器人处理类和方法 +from .robot import MessageHandler, CommandHandler +from .robot import search, category, recents + + +# 创建服务器管理器测试类 +class ServerManagerTest(TestCase): + """ + 服务器管理器功能测试类 + 测试servermanager应用的各项功能 + """ + + def setUp(self): + """ + 测试初始化方法 + 在每个测试方法执行前运行,用于设置测试环境 + """ + # 创建测试客户端,用于模拟HTTP请求 + self.client = Client() + # 创建请求工厂,用于构建请求对象 + self.factory = RequestFactory() + + def test_chat_gpt(self): + """ + 测试ChatGPT功能 + 验证ChatGPT API是否能正常返回响应 + """ + 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 = category(None, None) # 调用分类函数 + self.assertIsNotNone(rsp) # 断言响应不为空 + + # 测试最近文章功能 + rsp = recents(None, None) # 调用最近文章函数 + self.assertTrue(rsp != '暂时还没有文章') # 断言有文章返回 + + # 创建测试命令 + cmd = commands() + cmd.title = "test" + cmd.command = "ls" # Linux列表命令 + cmd.describe = "test" + cmd.save() + + # 测试命令处理器 + cmdhandler = CommandHandler() + rsp = cmdhandler.run('test') # 执行测试命令 + self.assertIsNotNone(rsp) # 断言命令执行结果不为空 + + # 测试消息处理器 + s.source = 'u' # 设置消息来源 + s.content = 'test' # 设置消息内容 + msghandler = MessageHandler(s, {}) # 创建消息处理器 + + + # 测试各种消息处理场景 + msghandler.handler() # 处理'test'消息 + 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' # 退出管理员模式.1 + msghandler.handler() \ No newline at end of file diff --git a/Django博客项目/servermanager/urls.py b/Django博客项目/servermanager/urls.py new file mode 100644 index 0000000..8ffc494 --- /dev/null +++ b/Django博客项目/servermanager/urls.py @@ -0,0 +1,25 @@ +#yyd: +# coding: utf-8 + +# 导入Django的URL路由模块 +from django.urls import path +# 导入WeRoBot的Django视图创建工具 +from werobot.contrib.django import make_view + +# 从当前目录的robot模块导入robot实例 +from .robot import robot + +# 定义应用的命名空间,用于URL反向解析 +app_name = "servermanager" + +# 定义URL模式列表 +urlpatterns = [ + # 微信机器人消息接收接口 + # 路径: /servermanager/robot + # 使用WeRoBot的make_view将robot实例转换为Django视图 + path(r'robot', make_view(robot)), + # 说明: + # - r'robot': URL路径模式,匹配"robot"路径 + # - make_view(robot): 将WeRoBot机器人实例包装成Django视图函数 + # - 这个端点用于接收微信服务器发送的消息和事件.1 +] \ No newline at end of file diff --git a/Django博客项目/servermanager/views.py b/Django博客项目/servermanager/views.py new file mode 100644 index 0000000..c84b472 --- /dev/null +++ b/Django博客项目/servermanager/views.py @@ -0,0 +1 @@ +# Create your views here..1 diff --git a/Django博客项目/templates/account/forget_password.html b/Django博客项目/templates/account/forget_password.html new file mode 100644 index 0000000..112545d --- /dev/null +++ b/Django博客项目/templates/account/forget_password.html @@ -0,0 +1,35 @@ +//这个文件用于实现"忘记密码"功能页面 +{% 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/Django博客项目/templates/account/login.html b/Django博客项目/templates/account/login.html new file mode 100644 index 0000000..d1677cd --- /dev/null +++ b/Django博客项目/templates/account/login.html @@ -0,0 +1,52 @@ +//这个文件是用于实现用户登录页面 +{% 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/Django博客项目/templates/account/registration_form.html b/Django博客项目/templates/account/registration_form.html new file mode 100644 index 0000000..65e7549 --- /dev/null +++ b/Django博客项目/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/Django博客项目/templates/account/result.html b/Django博客项目/templates/account/result.html new file mode 100644 index 0000000..23c9094 --- /dev/null +++ b/Django博客项目/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/Django博客项目/templates/blog/archive.html b/Django博客项目/templates/blog/archive.html new file mode 100644 index 0000000..6e220ec --- /dev/null +++ b/Django博客项目/templates/blog/archive.html @@ -0,0 +1,95 @@ + +{% extends "blog/base.html" %} + +{% block title %}文章归档 - {{ block.super }}{% endblock %} + +{% block content %} +
+

📚 文章归档

+

共 {{ articles.count }} 篇文章

+ + {% if articles %} + +
+

所有文章

+
    + {% for article in articles %} +
  • + + + {{ article.title }} + + 👁 {{ article.views }} 浏览 +
  • + {% endfor %} +
+
+ {% else %} +
+

暂无文章

+
+ {% endif %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/Django博客项目/templates/blog/article_archives.html b/Django博客项目/templates/blog/article_archives.html new file mode 100644 index 0000000..959319e --- /dev/null +++ b/Django博客项目/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/Django博客项目/templates/blog/article_detail.html b/Django博客项目/templates/blog/article_detail.html new file mode 100644 index 0000000..bf21fe0 --- /dev/null +++ b/Django博客项目/templates/blog/article_detail.html @@ -0,0 +1,53 @@ +文章详情页 +{% extends 'share_layout/base.html' %} +{% load blog_tags %} + +{% block header %} +{% endblock %} +{% block content %} +
+
+ {% load_article_detail article False user %} + {% 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 %} + diff --git a/Django博客项目/templates/blog/tags/article_meta_info.html b/Django博客项目/templates/blog/tags/article_meta_info.html new file mode 100644 index 0000000..cb6111c --- /dev/null +++ b/Django博客项目/templates/blog/tags/article_meta_info.html @@ -0,0 +1,59 @@ +{% load i18n %} +{% load blog_tags %} + + + + + diff --git a/Django博客项目/templates/blog/tags/article_pagination.html b/Django博客项目/templates/blog/tags/article_pagination.html new file mode 100644 index 0000000..95514ff --- /dev/null +++ b/Django博客项目/templates/blog/tags/article_pagination.html @@ -0,0 +1,17 @@ +{% load i18n %} + \ No newline at end of file diff --git a/Django博客项目/templates/blog/tags/article_tag_list.html b/Django博客项目/templates/blog/tags/article_tag_list.html new file mode 100644 index 0000000..c8ba474 --- /dev/null +++ b/Django博客项目/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/Django博客项目/templates/blog/tags/breadcrumb.html b/Django博客项目/templates/blog/tags/breadcrumb.html new file mode 100644 index 0000000..67087d5 --- /dev/null +++ b/Django博客项目/templates/blog/tags/breadcrumb.html @@ -0,0 +1,19 @@ + + diff --git a/Django博客项目/templates/blog/tags/sidebar.html b/Django博客项目/templates/blog/tags/sidebar.html new file mode 100644 index 0000000..58025a6 --- /dev/null +++ b/Django博客项目/templates/blog/tags/sidebar.html @@ -0,0 +1,131 @@ +{% load blog_tags %} +{% load i18n %} + diff --git a/Django博客项目/templates/comments/tags/comment_item.html b/Django博客项目/templates/comments/tags/comment_item.html new file mode 100644 index 0000000..37e1bc2 --- /dev/null +++ b/Django博客项目/templates/comments/tags/comment_item.html @@ -0,0 +1,56 @@ +{% load blog_tags %} +
  • +
    + + + +

    {{ comment_item.body|escape|comment_markdown }}

    + + {% comment %} +
    + + +
    + + + + {% endcomment %} + +
  • \ No newline at end of file diff --git a/Django博客项目/templates/comments/tags/comment_item_tree.html b/Django博客项目/templates/comments/tags/comment_item_tree.html new file mode 100644 index 0000000..f3c792d --- /dev/null +++ b/Django博客项目/templates/comments/tags/comment_item_tree.html @@ -0,0 +1,74 @@ + +{% load blog_tags %} +
  • +
    + + + +

    + {% if comment_item.parent_comment %} +

    + {% endif %} +

    + +

    {{ comment_item.body|escape|comment_markdown }}

    + + +
    +
    + + + +
    + +
    + +
    + +
  • +{% 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 %} + diff --git a/Django博客项目/templates/comments/tags/comment_list.html b/Django博客项目/templates/comments/tags/comment_list.html new file mode 100644 index 0000000..fff71e8 --- /dev/null +++ b/Django博客项目/templates/comments/tags/comment_list.html @@ -0,0 +1,47 @@ + +{% load static %} + +
    + {% load blog_tags %} + {% load comments_tags %} + {% load cache %} + + + {% 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 %} +
    + +
    + diff --git a/Django博客项目/templates/comments/tags/post_comment.html b/Django博客项目/templates/comments/tags/post_comment.html new file mode 100644 index 0000000..3ae5a27 --- /dev/null +++ b/Django博客项目/templates/comments/tags/post_comment.html @@ -0,0 +1,33 @@ +
    + +
    +

    发表评论 + +

    +
    {% csrf_token %} +

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

    + {{ form.parent_comment_id }} +
    + {% if COMMENT_NEED_REVIEW %} + 支持markdown,评论经审核后才会显示。 + {% else %} + 支持markdown。 + {% endif %} + + +
    +
    +
    + +
    + + diff --git a/Django博客项目/templates/oauth/bindsuccess.html b/Django博客项目/templates/oauth/bindsuccess.html new file mode 100644 index 0000000..4bee77c --- /dev/null +++ b/Django博客项目/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/Django博客项目/templates/oauth/oauth_applications.html b/Django博客项目/templates/oauth/oauth_applications.html new file mode 100644 index 0000000..a841ad2 --- /dev/null +++ b/Django博客项目/templates/oauth/oauth_applications.html @@ -0,0 +1,13 @@ +{% load i18n %} + diff --git a/Django博客项目/templates/oauth/require_email.html b/Django博客项目/templates/oauth/require_email.html new file mode 100644 index 0000000..3adef12 --- /dev/null +++ b/Django博客项目/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/Django博客项目/templates/owntracks/show_log_dates.html b/Django博客项目/templates/owntracks/show_log_dates.html new file mode 100644 index 0000000..7dbba21 --- /dev/null +++ b/Django博客项目/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/Django博客项目/templates/owntracks/show_maps.html b/Django博客项目/templates/owntracks/show_maps.html new file mode 100644 index 0000000..3aeda36 --- /dev/null +++ b/Django博客项目/templates/owntracks/show_maps.html @@ -0,0 +1,135 @@ + + + + + + + 运动轨迹 + + + +
    + + + + + + + + \ No newline at end of file diff --git a/Django博客项目/templates/search/indexes/blog/article_text.txt b/Django博客项目/templates/search/indexes/blog/article_text.txt new file mode 100644 index 0000000..4f9ca76 --- /dev/null +++ b/Django博客项目/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/Django博客项目/templates/search/search.html b/Django博客项目/templates/search/search.html new file mode 100644 index 0000000..797d41a --- /dev/null +++ b/Django博客项目/templates/search/search.html @@ -0,0 +1,65 @@ +{% 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 True user %} + {% endfor %} + {% if page.has_previous or page.has_next %} + + + {% endif %} + {% else %} +
    + +

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

    +
    + {% endif %} +
    +
    +{% endblock %} + + + + + + diff --git a/Django博客项目/templates/share_layout/adsense.html b/Django博客项目/templates/share_layout/adsense.html new file mode 100644 index 0000000..8f99c55 --- /dev/null +++ b/Django博客项目/templates/share_layout/adsense.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/Django博客项目/templates/share_layout/base.html b/Django博客项目/templates/share_layout/base.html new file mode 100644 index 0000000..75d0df5 --- /dev/null +++ b/Django博客项目/templates/share_layout/base.html @@ -0,0 +1,123 @@ +{% 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/Django博客项目/templates/share_layout/base_account.html b/Django博客项目/templates/share_layout/base_account.html new file mode 100644 index 0000000..12a6a5e --- /dev/null +++ b/Django博客项目/templates/share_layout/base_account.html @@ -0,0 +1,49 @@ + + + + {% load static %} + + + + + + + + + {{ SITE_NAME }} | {{ SITE_DESCRIPTION }} + + {% load compress %} + {% compress css %} + + + + + + + + + + {% endcompress %} + {% compress js %} + + + {% endcompress %} + + + + + +{% block content %} +{% endblock %} + + + + + + + + + \ No newline at end of file diff --git a/Django博客项目/templates/share_layout/footer.html b/Django博客项目/templates/share_layout/footer.html new file mode 100644 index 0000000..cd86a29 --- /dev/null +++ b/Django博客项目/templates/share_layout/footer.html @@ -0,0 +1,56 @@ + + + diff --git a/Django博客项目/templates/share_layout/nav.html b/Django博客项目/templates/share_layout/nav.html new file mode 100644 index 0000000..79da041 --- /dev/null +++ b/Django博客项目/templates/share_layout/nav.html @@ -0,0 +1,41 @@ +{% load i18n %} + + diff --git a/Django博客项目/templates/share_layout/nav_node.html b/Django博客项目/templates/share_layout/nav_node.html new file mode 100644 index 0000000..c266880 --- /dev/null +++ b/Django博客项目/templates/share_layout/nav_node.html @@ -0,0 +1,19 @@ + + + diff --git a/Django博客项目/test_db.py b/Django博客项目/test_db.py new file mode 100644 index 0000000..dfc22fe --- /dev/null +++ b/Django博客项目/test_db.py @@ -0,0 +1,14 @@ +# test_db.py +import pymysql +pymysql.install_as_MySQLdb() + +try: + import MySQLdb + print("✅ MySQLdb 导入成功!") + + # 测试版本 + from MySQLdb import version_info + print(f"✅ 版本信息: {version_info}") + +except Exception as e: + print(f"❌ 错误: {e}") \ No newline at end of file diff --git a/Django博客项目/utils/jieba_analyzer.py b/Django博客项目/utils/jieba_analyzer.py new file mode 100644 index 0000000..d3baf28 --- /dev/null +++ b/Django博客项目/utils/jieba_analyzer.py @@ -0,0 +1,19 @@ +# utils/jieba_analyzer.py +from whoosh.analysis import Tokenizer, Token +import jieba + + +class JiebaTokenizer(Tokenizer): + def __call__(self, value, positions=False, chars=False, keeporiginal=False, + removestops=True, start_pos=0, start_char=0, mode='', **kwargs): + t = Token(positions, chars, removestops=removestops, mode=mode, **kwargs) + seglist = jieba.cut(value, cut_all=False) + for w in seglist: + t.original = t.text = w + t.boost = 1.0 + yield t + + +def jieba_analyzer(): + """返回 Jieba 分词器""" + return JiebaTokenizer() diff --git a/Django博客项目/whoosh_index/MAIN_WRITELOCK b/Django博客项目/whoosh_index/MAIN_WRITELOCK new file mode 100644 index 0000000..e69de29 diff --git a/Django博客项目/whoosh_index/MAIN_rmyld3kmvhmi0csm.seg b/Django博客项目/whoosh_index/MAIN_rmyld3kmvhmi0csm.seg new file mode 100644 index 0000000..b1949cd Binary files /dev/null and b/Django博客项目/whoosh_index/MAIN_rmyld3kmvhmi0csm.seg differ diff --git a/Django博客项目/whoosh_index/_MAIN_1.toc b/Django博客项目/whoosh_index/_MAIN_1.toc new file mode 100644 index 0000000..c1877d5 Binary files /dev/null and b/Django博客项目/whoosh_index/_MAIN_1.toc differ diff --git a/src/Django博客项目/.coveragerc b/src/Django博客项目/.coveragerc new file mode 100644 index 0000000..9757484 --- /dev/null +++ b/src/Django博客项目/.coveragerc @@ -0,0 +1,10 @@ +[run] +source = . +include = *.py +omit = + *migrations* + *tests* + *.html + *whoosh_cn_backend* + *settings.py* + *venv* diff --git a/src/Django博客项目/.dockerignore b/src/Django博客项目/.dockerignore new file mode 100644 index 0000000..2818c38 --- /dev/null +++ b/src/Django博客项目/.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/Django博客项目/.gitattributes b/src/Django博客项目/.gitattributes new file mode 100644 index 0000000..fd52ece --- /dev/null +++ b/src/Django博客项目/.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/Django博客项目/.github/ISSUE_TEMPLATE.md b/src/Django博客项目/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..2b5b7aa --- /dev/null +++ b/src/Django博客项目/.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/Django博客项目/.github/workflows/codeql-analysis.yml b/src/Django博客项目/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..6b76522 --- /dev/null +++ b/src/Django博客项目/.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/Django博客项目/.github/workflows/django.yml b/src/Django博客项目/.github/workflows/django.yml new file mode 100644 index 0000000..94baea9 --- /dev/null +++ b/src/Django博客项目/.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/Django博客项目/.github/workflows/docker.yml b/src/Django博客项目/.github/workflows/docker.yml new file mode 100644 index 0000000..a312e2f --- /dev/null +++ b/src/Django博客项目/.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/Django博客项目/.github/workflows/publish-release.yml b/src/Django博客项目/.github/workflows/publish-release.yml new file mode 100644 index 0000000..5eb0853 --- /dev/null +++ b/src/Django博客项目/.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/Django博客项目/.gitignore b/src/Django博客项目/.gitignore new file mode 100644 index 0000000..3015816 --- /dev/null +++ b/src/Django博客项目/.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/Django博客项目/Dockerfile b/src/Django博客项目/Dockerfile new file mode 100644 index 0000000..80b46ac --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/LICENSE b/src/Django博客项目/LICENSE new file mode 100644 index 0000000..3b08474 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/README.md b/src/Django博客项目/README.md new file mode 100644 index 0000000..56aa4cc --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/accounts/__init__.py b/src/Django博客项目/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/accounts/admin.py b/src/Django博客项目/accounts/admin.py new file mode 100644 index 0000000..583dc4c --- /dev/null +++ b/src/Django博客项目/accounts/admin.py @@ -0,0 +1,117 @@ +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 _ +from django.contrib import admin +from django.contrib.auth.models import Group +from django.core.exceptions import PermissionDenied +from django.http import HttpResponseRedirect + +# 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): + # 移除或修复 fieldsets 中不存在的字段 + fieldsets = ( + (None, {'fields': ('username', 'password')}), + ('Personal info', {'fields': ('first_name', 'last_name', 'email')}), + ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), + ('Important dates', {'fields': ('last_login', 'date_joined')}), + ) + # 确保没有引用 usable_password 字段 + + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('username', 'password1', 'password2'), + }), + ) + + def get_queryset(self, request): + """限制普通用户只能看到自己的数据""" + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + return qs.filter(id=request.user.id) + + def get_fieldsets(self, request, obj=None): + """限制普通用户只能修改部分字段""" + if request.user.is_superuser: + return self.fieldsets + + # 普通用户只能修改个人信息 + return ( + (None, {'fields': ('username',)}), + ('Personal info', {'fields': ('first_name', 'last_name', 'email')}), + ) + + def get_readonly_fields(self, request, obj=None): + """设置只读字段""" + if request.user.is_superuser: + return self.readonly_fields + + # 普通用户不能修改权限相关字段 + readonly_fields = list(self.readonly_fields) + readonly_fields.extend(['is_staff', 'is_superuser', 'groups', 'user_permissions', 'last_login', 'date_joined']) + return readonly_fields + + def has_add_permission(self, request): + """只有超级用户才能添加用户""" + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + """只有超级用户才能删除用户""" + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + """普通用户只能修改自己的信息""" + if request.user.is_superuser: + return True + if obj and obj.id == request.user.id: + return True + return False + + +# 注册自定义用户管理 +admin.site.register(BlogUser, BlogUserAdmin) + +# 确保普通用户可以访问admin后台(is_staff=True) +# 在创建用户时,需要设置 is_staff = True \ No newline at end of file diff --git a/src/Django博客项目/accounts/apps.py b/src/Django博客项目/accounts/apps.py new file mode 100644 index 0000000..9b3fc5a --- /dev/null +++ b/src/Django博客项目/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = 'accounts' diff --git a/src/Django博客项目/accounts/forms.py b/src/Django博客项目/accounts/forms.py new file mode 100644 index 0000000..fce4137 --- /dev/null +++ b/src/Django博客项目/accounts/forms.py @@ -0,0 +1,117 @@ +from django import forms +from django.contrib.auth import get_user_model, password_validation +from django.contrib.auth.forms import AuthenticationForm, UserCreationForm +from django.core.exceptions import ValidationError +from django.forms import widgets +from django.utils.translation import gettext_lazy as _ +from . import utils +from .models import BlogUser + + +class LoginForm(AuthenticationForm): + def __init__(self, *args, **kwargs): + super(LoginForm, self).__init__(*args, **kwargs) + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + self.fields['password'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + + +class RegisterForm(UserCreationForm): + def __init__(self, *args, **kwargs): + super(RegisterForm, self).__init__(*args, **kwargs) + + self.fields['username'].widget = widgets.TextInput( + attrs={'placeholder': "username", "class": "form-control"}) + self.fields['email'].widget = widgets.EmailInput( + attrs={'placeholder': "email", "class": "form-control"}) + self.fields['password1'].widget = widgets.PasswordInput( + attrs={'placeholder': "password", "class": "form-control"}) + self.fields['password2'].widget = widgets.PasswordInput( + attrs={'placeholder': "repeat password", "class": "form-control"}) + + def clean_email(self): + email = self.cleaned_data['email'] + if get_user_model().objects.filter(email=email).exists(): + raise ValidationError(_("email already exists")) + return email + + class Meta: + model = get_user_model() + fields = ("username", "email") + + +class ForgetPasswordForm(forms.Form): + new_password1 = forms.CharField( + label=_("New password"), + widget=forms.PasswordInput( + attrs={ + "class": "form-control", + 'placeholder': _("New password") + } + ), + ) + + new_password2 = forms.CharField( + label="确认密码", + widget=forms.PasswordInput( + attrs={ + "class": "form-control", + 'placeholder': _("Confirm password") + } + ), + ) + + email = forms.EmailField( + label='邮箱', + widget=forms.TextInput( + attrs={ + 'class': 'form-control', + 'placeholder': _("Email") + } + ), + ) + + code = forms.CharField( + label=_('Code'), + widget=forms.TextInput( + attrs={ + 'class': 'form-control', + 'placeholder': _("Code") + } + ), + ) + + def clean_new_password2(self): + password1 = self.data.get("new_password1") + password2 = self.data.get("new_password2") + if password1 and password2 and password1 != password2: + raise ValidationError(_("passwords do not match")) + password_validation.validate_password(password2) + + return password2 + + def clean_email(self): + user_email = self.cleaned_data.get("email") + if not BlogUser.objects.filter( + email=user_email + ).exists(): + # todo 这里的报错提示可以判断一个邮箱是不是注册过,如果不想暴露可以修改 + raise ValidationError(_("email does not exist")) + return user_email + + def clean_code(self): + code = self.cleaned_data.get("code") + error = utils.verify( + email=self.cleaned_data.get("email"), + code=code, + ) + if error: + raise ValidationError(error) + return code + + +class ForgetPasswordCodeForm(forms.Form): + email = forms.EmailField( + label=_('Email'), + ) diff --git a/src/Django博客项目/accounts/migrations/0001_initial.py b/src/Django博客项目/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..d2fbcab --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/src/Django博客项目/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py new file mode 100644 index 0000000..1a9f509 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/accounts/migrations/__init__.py b/src/Django博客项目/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/accounts/models.py b/src/Django博客项目/accounts/models.py new file mode 100644 index 0000000..3baddbb --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/accounts/templatetags/__init__.py b/src/Django博客项目/accounts/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/accounts/tests.py b/src/Django博客项目/accounts/tests.py new file mode 100644 index 0000000..6893411 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/accounts/urls.py b/src/Django博客项目/accounts/urls.py new file mode 100644 index 0000000..fd39c57 --- /dev/null +++ b/src/Django博客项目/accounts/urls.py @@ -0,0 +1,28 @@ +from django.urls import path +from django.urls import re_path + +from . import views +from .forms import LoginForm +app_name = "accounts" + +urlpatterns = [re_path(r'^login/$', + views.LoginView.as_view(success_url='/'), + name='login', + kwargs={'authentication_form': LoginForm}), + re_path(r'^register/$', + views.RegisterView.as_view(success_url="/"), + name='register'), + re_path(r'^logout/$', + views.LogoutView.as_view(), + name='logout'), + path(r'account/result.html', + views.account_result, + name='result'), + re_path(r'^forget_password/$', + views.ForgetPasswordView.as_view(), + name='forget_password'), + re_path(r'^forget_password_code/$', + views.ForgetPasswordEmailCode.as_view(), + name='forget_password_code'), + path("visitor-login/", views.visitor_login, name="visitor_login"), + ] diff --git a/src/Django博客项目/accounts/user_login_backend.py b/src/Django博客项目/accounts/user_login_backend.py new file mode 100644 index 0000000..73cdca1 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/accounts/utils.py b/src/Django博客项目/accounts/utils.py new file mode 100644 index 0000000..4b94bdf --- /dev/null +++ b/src/Django博客项目/accounts/utils.py @@ -0,0 +1,49 @@ +import typing +from datetime import timedelta + +from django.core.cache import cache +from django.utils.translation import gettext +from django.utils.translation import gettext_lazy as _ + +from djangoblog.utils import send_email + +_code_ttl = timedelta(minutes=5) + + +def send_verify_email(to_mail: str, code: str, subject: str = _("Verify Email")): + """发送重设密码验证码 + Args: + to_mail: 接受邮箱 + subject: 邮件主题 + code: 验证码 + """ + html_content = _( + "You are resetting the password, the verification code is:%(code)s, valid within 5 minutes, please keep it " + "properly") % {'code': code} + send_email([to_mail], subject, html_content) + + +def verify(email: str, code: str) -> typing.Optional[str]: + """验证code是否有效 + Args: + email: 请求邮箱 + code: 验证码 + Return: + 如果有错误就返回错误str + Node: + 这里的错误处理不太合理,应该采用raise抛出 + 否测调用方也需要对error进行处理 + """ + cache_code = get_code(email) + if cache_code != code: + return gettext("Verification code error") + + +def set_code(email: str, code: str): + """设置code""" + cache.set(email, code, _code_ttl.seconds) + + +def get_code(email: str) -> typing.Optional[str]: + """获取code""" + return cache.get(email) diff --git a/src/Django博客项目/accounts/views.py b/src/Django博客项目/accounts/views.py new file mode 100644 index 0000000..fdac582 --- /dev/null +++ b/src/Django博客项目/accounts/views.py @@ -0,0 +1,213 @@ +import logging +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from django.contrib import auth +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth import get_user_model +from django.contrib.auth import logout +from django.contrib.auth.forms import AuthenticationForm +from django.contrib.auth.hashers import make_password +from django.http import HttpResponseRedirect, HttpResponseForbidden +from django.http.request import HttpRequest +from django.http.response import HttpResponse +from django.shortcuts import get_object_or_404 +from django.shortcuts import render +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.utils.http import url_has_allowed_host_and_scheme +from django.views import View +from django.views.decorators.cache import never_cache +from django.views.decorators.csrf import csrf_protect +from django.views.decorators.debug import sensitive_post_parameters +from django.views.generic import FormView, RedirectView + +from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache +from . import utils +from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm +from .models import BlogUser + +from django.contrib.auth import authenticate, login, logout +from django.shortcuts import redirect + +def visitor_login(request): + # 先登出已有用户 + logout(request) + + # 登录 visitor 访客账号 + user = authenticate(username="visitor", password="visitor123") + if user: + login(request, user) + + return redirect("/") + + +logger = logging.getLogger(__name__) + + +# Create your views here. + +class RegisterView(FormView): + form_class = RegisterForm + template_name = 'account/registration_form.html' + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + return super(RegisterView, self).dispatch(*args, **kwargs) + + def form_valid(self, form): + if form.is_valid(): + user = form.save(False) + user.is_active = False + user.source = 'Register' + user.save(True) + site = get_current_site().domain + sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + + if settings.DEBUG: + site = '127.0.0.1:8000' + path = reverse('account:result') + url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format( + site=site, path=path, id=user.id, sign=sign) + + content = """ +

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

    + + {url} + + 再次感谢您! +
    + 如果上面链接无法打开,请将此链接复制至浏览器。 + {url} + """.format(url=url) + send_email( + emailto=[ + user.email, + ], + title='验证您的电子邮箱', + content=content) + + url = reverse('accounts:result') + \ + '?type=register&id=' + str(user.id) + return HttpResponseRedirect(url) + else: + return self.render_to_response({ + 'form': form + }) + + +class LogoutView(RedirectView): + url = '/login/' + + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + return super(LogoutView, self).dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + logout(request) + delete_sidebar_cache() + return super(LogoutView, self).get(request, *args, **kwargs) + + +class LoginView(FormView): + form_class = LoginForm + template_name = 'account/login.html' + success_url = '/' + redirect_field_name = REDIRECT_FIELD_NAME + login_ttl = 2626560 # 一个月的时间 + + @method_decorator(sensitive_post_parameters('password')) + @method_decorator(csrf_protect) + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + + return super(LoginView, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + redirect_to = self.request.GET.get(self.redirect_field_name) + if redirect_to is None: + redirect_to = '/' + kwargs['redirect_to'] = redirect_to + + return super(LoginView, self).get_context_data(**kwargs) + + def form_valid(self, form): + # 不要重新实例化 AuthenticationForm + delete_sidebar_cache() + logger.info(self.redirect_field_name) + + auth.login(self.request, form.get_user()) + if self.request.POST.get("remember"): + self.request.session.set_expiry(self.login_ttl) + return super(LoginView, self).form_valid(form) + + + def get_success_url(self): + + redirect_to = self.request.POST.get(self.redirect_field_name) + if not url_has_allowed_host_and_scheme( + url=redirect_to, allowed_hosts=[ + self.request.get_host()]): + redirect_to = self.success_url + return redirect_to + + +def account_result(request): + type = request.GET.get('type') + id = request.GET.get('id') + + user = get_object_or_404(get_user_model(), id=id) + logger.info(type) + if user.is_active: + return HttpResponseRedirect('/') + if type and type in ['register', 'validation']: + if type == 'register': + content = ''' + 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。 + ''' + title = '注册成功' + else: + c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id))) + sign = request.GET.get('sign') + if sign != c_sign: + return HttpResponseForbidden() + user.is_active = True + user.save() + content = ''' + 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。 + ''' + title = '验证成功' + return render(request, 'account/result.html', { + 'title': title, + 'content': content + }) + else: + return HttpResponseRedirect('/') + + +class ForgetPasswordView(FormView): + form_class = ForgetPasswordForm + template_name = 'account/forget_password.html' + + def form_valid(self, form): + if form.is_valid(): + blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get() + blog_user.password = make_password(form.cleaned_data["new_password2"]) + blog_user.save() + return HttpResponseRedirect('/login/') + else: + return self.render_to_response({'form': form}) + + +class ForgetPasswordEmailCode(View): + + def post(self, request: HttpRequest): + form = ForgetPasswordCodeForm(request.POST) + if not form.is_valid(): + return HttpResponse("错误的邮箱") + to_email = form.cleaned_data["email"] + + code = generate_code() + utils.send_verify_email(to_email, code) + utils.set_code(to_email, code) + + return HttpResponse("ok") diff --git a/src/Django博客项目/blog/__init__.py b/src/Django博客项目/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/blog/admin.py b/src/Django博客项目/blog/admin.py new file mode 100644 index 0000000..b90bed3 --- /dev/null +++ b/src/Django博客项目/blog/admin.py @@ -0,0 +1,226 @@ +from django import forms +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +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, Tag, Category, Links, SideBar + +# 获取User模型 - 这是Django推荐的方式 +User = get_user_model() + +class ArticleForm(forms.ModelForm): + class Meta: + model = Article + fields = '__all__' + +def makr_article_publish(modeladmin, request, queryset): + queryset.update(status='p') + +def draft_article(modeladmin, request, queryset): + queryset.update(status='d') + +def close_article_commentstatus(modeladmin, request, queryset): + queryset.update(comment_status='c') + +def open_article_commentstatus(modeladmin, request, queryset): + queryset.update(comment_status='o') + +makr_article_publish.short_description = _('Publish selected articles') +draft_article.short_description = _('Draft selected articles') +close_article_commentstatus.short_description = _('Close article comments') +open_article_commentstatus.short_description = _('Open article comments') + +class ArticlelAdmin(admin.ModelAdmin): + list_per_page = 20 + search_fields = ('body', 'title') + form = ArticleForm + list_display = ( + 'id', + 'title', + 'author', + 'link_to_category', + 'creation_time', + 'views', + 'status', + 'type', + 'article_order') + list_display_links = ('id', 'title') + list_filter = ('status', 'type', 'category') + filter_horizontal = ('tags',) + exclude = ('creation_time', 'last_modify_time') + view_on_site = True + actions = [ + makr_article_publish, + draft_article, + close_article_commentstatus, + open_article_commentstatus] + + def link_to_category(self, obj): + if obj.category: + 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)) + return "-" + + link_to_category.short_description = _('category') + + def get_form(self, request, obj=None, **kwargs): + form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs) + + if request.user.is_superuser: + form.base_fields['author'].queryset = get_user_model().objects.filter(is_staff=True) + else: + form.base_fields['author'].queryset = get_user_model().objects.filter(id=request.user.id) + if not obj: + form.base_fields['author'].initial = request.user + form.base_fields['author'].disabled = True + + return form + + def get_queryset(self, request): + qs = super(ArticlelAdmin, self).get_queryset(request) + if request.user.is_superuser: + return qs + return qs.filter(author=request.user) + + def save_model(self, request, obj, form, change): + if not change and not request.user.is_superuser: + obj.author = request.user + super(ArticlelAdmin, self).save_model(request, obj, form, change) + + def has_change_permission(self, request, obj=None): + if obj is not None and not request.user.is_superuser: + return obj.author == request.user + return super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + if obj is not None and not request.user.is_superuser: + return obj.author == request.user + return super().has_delete_permission(request, obj) + + 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') + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs + + def has_change_permission(self, request, obj=None): + return request.user.is_staff + + def has_add_permission(self, request): + return request.user.is_staff + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + +class CategoryAdmin(admin.ModelAdmin): + list_display = ('name', 'parent_category', 'index') + exclude = ('slug', 'last_mod_time', 'creation_time') + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + + def has_add_permission(self, request): + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + +class LinksAdmin(admin.ModelAdmin): + exclude = ('last_mod_time', 'creation_time') + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + + def has_add_permission(self, request): + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + +class SideBarAdmin(admin.ModelAdmin): + list_display = ('name', 'content', 'is_enable', 'sequence') + exclude = ('last_mod_time', 'creation_time') + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + + def has_add_permission(self, request): + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + +class BlogSettingsAdmin(admin.ModelAdmin): + pass + +class UserAdmin(BaseUserAdmin): + def get_queryset(self, request): + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + return qs.filter(id=request.user.id) + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + + if not request.user.is_superuser: + disabled_fields = { + 'is_superuser', 'is_staff', 'is_active', + 'user_permissions', 'groups', 'last_login', 'date_joined' + } + + for field_name in disabled_fields: + if field_name in form.base_fields: + form.base_fields[field_name].disabled = True + + if 'username' in form.base_fields: + form.base_fields['username'].disabled = True + + return form + + def has_add_permission(self, request): + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + if obj is not None and not request.user.is_superuser: + return False + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + if obj is not None and not request.user.is_superuser: + return obj == request.user + return super().has_change_permission(request, obj) + +# 注册博客模型 +admin.site.register(Article, ArticlelAdmin) +admin.site.register(Tag, TagAdmin) +admin.site.register(Category, CategoryAdmin) +admin.site.register(Links, LinksAdmin) +admin.site.register(SideBar, SideBarAdmin) + +# 安全地重新注册User模型 +try: + admin.site.unregister(User) +except admin.sites.NotRegistered: + pass + +admin.site.register(User, UserAdmin) \ No newline at end of file diff --git a/src/Django博客项目/blog/apps.py b/src/Django博客项目/blog/apps.py new file mode 100644 index 0000000..7930587 --- /dev/null +++ b/src/Django博客项目/blog/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + name = 'blog' diff --git a/src/Django博客项目/blog/context_processors.py b/src/Django博客项目/blog/context_processors.py new file mode 100644 index 0000000..73e3088 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/blog/documents.py b/src/Django博客项目/blog/documents.py new file mode 100644 index 0000000..6b53625 --- /dev/null +++ b/src/Django博客项目/blog/documents.py @@ -0,0 +1,64 @@ +# 禁用Elasticsearch功能 +ELASTICSEARCH_ENABLED = False + +class ElapsedTimeDocumentManager: + """ + 简单的文档管理器替代品 + 不依赖Elasticsearch,提供空实现 + """ + + @staticmethod + def add(instance, *args, **kwargs): + """添加文档 - 空实现""" + return None + + @staticmethod + def update(instance, *args, **kwargs): + """更新文档 - 空实现""" + return None + + @staticmethod + def delete(instance, *args, **kwargs): + """删除文档 - 空实现""" + return None + + @staticmethod + def search(query, *args, **kwargs): + """搜索文档 - 返回空结果""" + return [] + + @staticmethod + def bulk_update(instances, *args, **kwargs): + """批量更新 - 空实现""" + return None + +# 为可能被导入的Elasticsearch DSL类创建空定义 +class Document: + pass + +class InnerDoc: + pass + +class Date: + pass + +class Integer: + pass + +class Long: + pass + +class Text: + pass + +class Object: + pass + +class GeoPoint: + pass + +class Keyword: + pass + +class Boolean: + pass \ No newline at end of file diff --git a/src/Django博客项目/blog/forms.py b/src/Django博客项目/blog/forms.py new file mode 100644 index 0000000..715be76 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/blog/management/__init__.py b/src/Django博客项目/blog/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/blog/management/commands/__init__.py b/src/Django博客项目/blog/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/blog/management/commands/build_index.py b/src/Django博客项目/blog/management/commands/build_index.py new file mode 100644 index 0000000..3c4acd7 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/blog/management/commands/build_search_words.py b/src/Django博客项目/blog/management/commands/build_search_words.py new file mode 100644 index 0000000..cfe7e0d --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/blog/management/commands/clear_cache.py b/src/Django博客项目/blog/management/commands/clear_cache.py new file mode 100644 index 0000000..0d66172 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/blog/management/commands/create_testdata.py b/src/Django博客项目/blog/management/commands/create_testdata.py new file mode 100644 index 0000000..675d2ba --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/blog/management/commands/ping_baidu.py b/src/Django博客项目/blog/management/commands/ping_baidu.py new file mode 100644 index 0000000..2c7fbdd --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/blog/management/commands/sync_user_avatar.py b/src/Django博客项目/blog/management/commands/sync_user_avatar.py new file mode 100644 index 0000000..d0f4612 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/blog/middleware.py b/src/Django博客项目/blog/middleware.py new file mode 100644 index 0000000..6f624f4 --- /dev/null +++ b/src/Django博客项目/blog/middleware.py @@ -0,0 +1,48 @@ +import logging +import time + +def get_client_ip(request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip +from user_agents import parse + +from blog.documents import ELASTICSEARCH_ENABLED, ElapsedTimeDocumentManager + +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 + ElapsedTimeDocumentManager.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/Django博客项目/blog/migrations/__init__.py b/src/Django博客项目/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/blog/models.py b/src/Django博客项目/blog/models.py new file mode 100644 index 0000000..dbadd79 --- /dev/null +++ b/src/Django博客项目/blog/models.py @@ -0,0 +1,431 @@ +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 django.utils.text 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) +#新增 + likes = models.PositiveIntegerField(_('likes'), default=0) # 新增点赞计数字段 + liked_users = models.ManyToManyField( + settings.AUTH_USER_MODEL, + through='LikeRecord', + related_name='liked_articles', + blank=True, + verbose_name=_('liked users') + ) + + def like_count(self): + return self.liked_users.count() + + + 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( max_length=60, blank=True,unique=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(max_length=60, unique=True) # 移除default,添加unique=True + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + # 自动生成slug + if not self.slug or self.slug == 'no-slug': + self.slug = self.generate_unique_slug() + super().save(*args, **kwargs) + + def generate_unique_slug(self): + """生成唯一的slug""" + original_slug = slugify(self.name) + slug = original_slug + counter = 1 + + # 确保slug唯一 + while Tag.objects.filter(slug=slug).exclude(id=self.id).exists(): + slug = f"{original_slug}-{counter}" + counter += 1 + + return slug + + 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() + # 在文件末尾添加 +class LikeRecord(models.Model): + """点赞记录模型""" + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_('user'), + on_delete=models.CASCADE + ) + article = models.ForeignKey( + Article, + verbose_name=_('article'), + on_delete=models.CASCADE + ) + created_time = models.DateTimeField(_('creation time'), auto_now_add=True) + + class Meta: + verbose_name = _('like record') + verbose_name_plural = _('like records') + unique_together = ('user', 'article') # 防止重复点赞 + + def __str__(self): + return f"{self.user.username} liked {self.article.title}" diff --git a/src/Django博客项目/blog/search_indexes.py b/src/Django博客项目/blog/search_indexes.py new file mode 100644 index 0000000..8e6277c --- /dev/null +++ b/src/Django博客项目/blog/search_indexes.py @@ -0,0 +1,14 @@ +from haystack import indexes + +from blog.models import Article + +from jieba.analyse import ChineseAnalyzer + +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.all() diff --git a/src/Django博客项目/blog/templatetags/__init__.py b/src/Django博客项目/blog/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/blog/templatetags/blog_tags.py b/src/Django博客项目/blog/templatetags/blog_tags.py new file mode 100644 index 0000000..d6cd5d5 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/blog/tests.py b/src/Django博客项目/blog/tests.py new file mode 100644 index 0000000..ee13505 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/blog/urls.py b/src/Django博客项目/blog/urls.py new file mode 100644 index 0000000..df66d0a --- /dev/null +++ b/src/Django博客项目/blog/urls.py @@ -0,0 +1,75 @@ +from django.urls import path +from django.views.decorators.cache import cache_page + +from . import views +from blog.views import like_article + +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'), + #搜索功能优化(模糊搜索 + 排序),注册搜索路由 + path( + r'search/', + views.search, + name='search'), + #点赞功能路由 + path( + 'article//like/', + views.like_article, + name='like_article'), + + +] diff --git a/src/Django博客项目/blog/views.py b/src/Django博客项目/blog/views.py new file mode 100644 index 0000000..fd6c51d --- /dev/null +++ b/src/Django博客项目/blog/views.py @@ -0,0 +1,517 @@ +import logging +import os +import uuid + +from django.conf import settings +from django.core.paginator import Paginator +from django.http import HttpResponse, HttpResponseForbidden +from django.shortcuts import get_object_or_404 +from django.shortcuts import render +from django.templatetags.static import static +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.csrf import csrf_exempt +from django.views.generic.detail import DetailView +from django.views.generic.list import ListView +from haystack.views import SearchView +from django.contrib.auth import authenticate, login, logout +from django.shortcuts import redirect + +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 +#搜索功能优化(模糊搜索 + 排序) +from django.db.models import Q +from .models import Article +#点赞 +from django.http import JsonResponse +from django.contrib.auth.decorators import login_required + +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) + context = super(ArticleListView, self).get_context_data(**kwargs) + user = self.request.user + + # ⭐ 给模板传递当前用户点赞过的文章 ID 列表 + if user.is_authenticated: + context["liked_ids"] = user.liked_articles.values_list('id', flat=True) + else: + context["liked_ids"] = [] + + return context + + +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): + return None + + +class ArticleDetailView(DetailView): + ''' + 文章详情页面 + ''' + template_name = 'blog/article_detail.html' + model = Article + pk_url_kwarg = 'article_id' + context_object_name = "article" + + def get_context_data(self, **kwargs): + comment_form = CommentForm() + + article_comments = self.object.comment_list() + parent_comments = article_comments.filter(parent_comment=None) + blog_setting = get_blog_setting() + paginator = Paginator(parent_comments, blog_setting.article_comment_count) + page = self.request.GET.get('comment_page', '1') + if not page.isnumeric(): + page = 1 + else: + page = int(page) + if page < 1: + page = 1 + if page > paginator.num_pages: + page = paginator.num_pages + + p_comments = paginator.page(page) + next_page = p_comments.next_page_number() if p_comments.has_next() else None + prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None + + if next_page: + kwargs[ + 'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container' + if prev_page: + kwargs[ + 'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container' + kwargs['form'] = comment_form + kwargs['article_comments'] = article_comments + kwargs['p_comments'] = p_comments + kwargs['comment_count'] = len( + article_comments) if article_comments else 0 + + kwargs['next_article'] = self.object.next_article + kwargs['prev_article'] = self.object.prev_article + + user = self.request.user + if user.is_authenticated: + kwargs['user_has_liked'] = self.object.liked_users.filter(id=user.id).exists() + else: + kwargs['user_has_liked'] = False + + # ⭐ 修复:安全地获取点赞数 + if hasattr(self.object, 'likes'): + kwargs['like_count'] = self.object.likes + else: + kwargs['like_count'] = self.object.liked_users.count() + + 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) + + for c in article_comments: + c.is_liked = c.is_liked_by(self.request.user) + + + 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 article_archive(request): + """ + 文章归档页面视图 - 详细调试版 + """ + try: + # 方法1:最基础的查询,确保没有额外过滤 + articles = Article.objects.filter(status='p').order_by('-pub_time') + + print("=== 详细调试信息 ===") + print(f"总文章数: {articles.count()}") + + # 显示每篇文章的详细信息 + for article in articles: + print(f"文章: {article.title}") + print(f" - 状态: {article.status}") + print(f" - 发布时间: {article.pub_time}") + print(f" - 分类: {article.category}") # 检查分类 + print(f" - 分类ID: {getattr(article.category, 'id', '无分类')}") + print("---") + + context = { + 'articles': articles, + } + return render(request, 'blog/archive.html', context) + + except Exception as e: + print(f"归档页面错误: {e}") + return render(request, 'blog/archive.html', {'articles': []}) + +def clean_cache_view(request): + cache.clear() + return HttpResponse('ok') + +#搜索功能优化(模糊搜索 + 排序) +def search(request): + query = request.GET.get('q', '').strip() + sort = request.GET.get('sort', 'time') + + results = [] + + if query: + results = Article.objects.filter( + Q(title__icontains=query) | + Q(body__icontains=query) | + Q(category__name__icontains=query) | + Q(tags__name__icontains=query) + ).filter(status='p').distinct() + + # 排序 + if sort == 'views': + results = results.order_by('-views') + else: + results = results.order_by('-pub_time') + + # 分页 + paginator = Paginator(results, 10) + page = paginator.get_page(request.GET.get('page', 1)) + + context = { + 'query': query, + 'results': results, + 'page': page, + } + + return render(request, 'search/search.html', context) + +# 点赞 - 完整修复版本 +@csrf_exempt +@login_required +def like_article(request, article_id): + """文章点赞/取消点赞 - 使用liked_users计数""" + if request.method == "POST": + try: + article = get_object_or_404(Article, id=article_id) + user = request.user + + print(f"点赞操作: 用户 {user.username} 对文章 {article.title}") + + # 检查用户是否已经点赞 + if article.liked_users.filter(id=user.id).exists(): + # 取消点赞 + article.liked_users.remove(user) + liked = False + print("取消点赞") + else: + # 点赞 + article.liked_users.add(user) + liked = True + print("点赞") + + # 直接使用liked_users的计数 + likes_count = article.liked_users.count() + + print(f"当前点赞数: {likes_count}") + + return JsonResponse({ + 'success': True, + 'liked': liked, + 'likes_count': likes_count + }) + + except Exception as e: + import traceback + print(f"点赞操作错误详情: {traceback.format_exc()}") + return JsonResponse({'success': False, 'error': str(e)}) + + return JsonResponse({'success': False, 'error': '只支持POST请求'}) \ No newline at end of file diff --git a/src/Django博客项目/comments/__init__.py b/src/Django博客项目/comments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/comments/admin.py b/src/Django博客项目/comments/admin.py new file mode 100644 index 0000000..a814f3f --- /dev/null +++ b/src/Django博客项目/comments/admin.py @@ -0,0 +1,47 @@ +from django.contrib import admin +from django.urls import reverse +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ + + +def disable_commentstatus(modeladmin, request, queryset): + queryset.update(is_enable=False) + + +def enable_commentstatus(modeladmin, request, queryset): + queryset.update(is_enable=True) + + +disable_commentstatus.short_description = _('Disable comments') +enable_commentstatus.short_description = _('Enable comments') + + +class CommentAdmin(admin.ModelAdmin): + list_per_page = 20 + list_display = ( + 'id', + 'body', + 'link_to_userinfo', + 'link_to_article', + 'is_enable', + 'creation_time') + list_display_links = ('id', 'body', 'is_enable') + list_filter = ('is_enable',) + exclude = ('creation_time', 'last_modify_time') + actions = [disable_commentstatus, enable_commentstatus] + + def link_to_userinfo(self, obj): + info = (obj.author._meta.app_label, obj.author._meta.model_name) + link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) + return format_html( + u'%s' % + (link, obj.author.nickname if obj.author.nickname else obj.author.email)) + + def link_to_article(self, obj): + info = (obj.article._meta.app_label, obj.article._meta.model_name) + link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,)) + return format_html( + u'%s' % (link, obj.article.title)) + + link_to_userinfo.short_description = _('User') + link_to_article.short_description = _('Article') diff --git a/src/Django博客项目/comments/apps.py b/src/Django博客项目/comments/apps.py new file mode 100644 index 0000000..ff01b77 --- /dev/null +++ b/src/Django博客项目/comments/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CommentsConfig(AppConfig): + name = 'comments' diff --git a/src/Django博客项目/comments/forms.py b/src/Django博客项目/comments/forms.py new file mode 100644 index 0000000..e83737d --- /dev/null +++ b/src/Django博客项目/comments/forms.py @@ -0,0 +1,13 @@ +from django import forms +from django.forms import ModelForm + +from .models import Comment + + +class CommentForm(ModelForm): + parent_comment_id = forms.IntegerField( + widget=forms.HiddenInput, required=False) + + class Meta: + model = Comment + fields = ['body'] diff --git a/src/Django博客项目/comments/migrations/__init__.py b/src/Django博客项目/comments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/comments/models.py b/src/Django博客项目/comments/models.py new file mode 100644 index 0000000..22856b7 --- /dev/null +++ b/src/Django博客项目/comments/models.py @@ -0,0 +1,56 @@ +from django.conf import settings +from django.db import models +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.models import User + + +from blog.models import Article + + +# Create your models here. + +class Comment(models.Model): + body = models.TextField('正文', max_length=300) + creation_time = models.DateTimeField(_('creation time'), default=now) + last_modify_time = models.DateTimeField(_('last modify time'), default=now) + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_('author'), + on_delete=models.CASCADE) + article = models.ForeignKey( + Article, + verbose_name=_('article'), + on_delete=models.CASCADE) + parent_comment = models.ForeignKey( + 'self', + verbose_name=_('parent comment'), + blank=True, + null=True, + on_delete=models.CASCADE) + is_enable = models.BooleanField(_('enable'), + default=False, blank=False, null=False) + + class Meta: + ordering = ['-id'] + verbose_name = _('comment') + verbose_name_plural = verbose_name + get_latest_by = 'id' + + # 新增:点赞用户 + liked = models.ManyToManyField( + settings.AUTH_USER_MODEL, + related_name='liked_comments', + blank=True + ) + def like_count(self): + return self.liked.count() + + def is_liked_by(self, user): + if user.is_authenticated: + return self.liked.filter(id=user.id).exists() + return False + + + def __str__(self): + return self.body diff --git a/src/Django博客项目/comments/templatetags/__init__.py b/src/Django博客项目/comments/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/comments/templatetags/comments_tags.py b/src/Django博客项目/comments/templatetags/comments_tags.py new file mode 100644 index 0000000..fde02b4 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/comments/tests.py b/src/Django博客项目/comments/tests.py new file mode 100644 index 0000000..2a7f55f --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/comments/urls.py b/src/Django博客项目/comments/urls.py new file mode 100644 index 0000000..02c9168 --- /dev/null +++ b/src/Django博客项目/comments/urls.py @@ -0,0 +1,15 @@ +from django.urls import path + +from . import views + +app_name = "comments" +urlpatterns = [ + path( + 'article//postcomment', + views.CommentPostView.as_view(), + name='postcomment'), + + path('comment//like/', + views.like_comment, + name='like_comment'), +] diff --git a/src/Django博客项目/comments/utils.py b/src/Django博客项目/comments/utils.py new file mode 100644 index 0000000..f01dba7 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/comments/views.py b/src/Django博客项目/comments/views.py new file mode 100644 index 0000000..8cd9da1 --- /dev/null +++ b/src/Django博客项目/comments/views.py @@ -0,0 +1,85 @@ +# Create your views here. +from django.core.exceptions import ValidationError +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_protect +from django.views.generic.edit import FormView +from django.http import JsonResponse +from django.contrib.auth.decorators import login_required + +from accounts.models import BlogUser +from blog.models import Article +from .forms import CommentForm +from .models import Comment + + +class CommentPostView(FormView): + form_class = CommentForm + template_name = 'blog/article_detail.html' + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + return super(CommentPostView, self).dispatch(*args, **kwargs) + + def get(self, request, *args, **kwargs): + article_id = self.kwargs['article_id'] + article = get_object_or_404(Article, pk=article_id) + url = article.get_absolute_url() + return HttpResponseRedirect(url + "#comments") + + def form_invalid(self, form): + article_id = self.kwargs['article_id'] + article = get_object_or_404(Article, pk=article_id) + + return self.render_to_response({ + 'form': form, + 'article': article + }) + + def form_valid(self, form): + """提交的数据验证合法后的逻辑""" + user = self.request.user + author = BlogUser.objects.get(pk=user.pk) + article_id = self.kwargs['article_id'] + article = get_object_or_404(Article, pk=article_id) + + if article.comment_status == 'c' or article.status == 'c': + raise ValidationError("该文章评论已关闭.") + comment = form.save(False) + comment.article = article + from djangoblog.utils import get_blog_setting + settings = get_blog_setting() + if not settings.comment_need_review: + comment.is_enable = True + comment.author = author + + if form.cleaned_data['parent_comment_id']: + parent_comment = Comment.objects.get( + pk=form.cleaned_data['parent_comment_id']) + comment.parent_comment = parent_comment + + comment.save(True) + return HttpResponseRedirect( + "%s#div-comment-%d" % + (article.get_absolute_url(), comment.pk)) + +@login_required +def like_comment(request, comment_id): + comment = Comment.objects.get(pk=comment_id) + user = request.user + + if user in comment.liked.all(): + # 取消点赞 + comment.liked.remove(user) + liked = False + else: + # 添加点赞 + comment.liked.add(user) + liked = True + + return JsonResponse({ + "status": "ok", + "liked": liked, + "like_count": comment.liked.count() + }) diff --git a/src/Django博客项目/deploy/docker-compose/docker-compose.es.yml b/src/Django博客项目/deploy/docker-compose/docker-compose.es.yml new file mode 100644 index 0000000..83e35ff --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/deploy/docker-compose/docker-compose.yml b/src/Django博客项目/deploy/docker-compose/docker-compose.yml new file mode 100644 index 0000000..9609af3 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/deploy/entrypoint.sh b/src/Django博客项目/deploy/entrypoint.sh new file mode 100644 index 0000000..2fb6491 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/deploy/k8s/configmap.yaml b/src/Django博客项目/deploy/k8s/configmap.yaml new file mode 100644 index 0000000..835d4ad --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/deploy/k8s/deployment.yaml b/src/Django博客项目/deploy/k8s/deployment.yaml new file mode 100644 index 0000000..414fdcc --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/deploy/k8s/gateway.yaml b/src/Django博客项目/deploy/k8s/gateway.yaml new file mode 100644 index 0000000..a8de073 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/deploy/k8s/pv.yaml b/src/Django博客项目/deploy/k8s/pv.yaml new file mode 100644 index 0000000..874b72f --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/deploy/k8s/pvc.yaml b/src/Django博客项目/deploy/k8s/pvc.yaml new file mode 100644 index 0000000..ef238c5 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/deploy/k8s/service.yaml b/src/Django博客项目/deploy/k8s/service.yaml new file mode 100644 index 0000000..4ef2931 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/deploy/k8s/storageclass.yaml b/src/Django博客项目/deploy/k8s/storageclass.yaml new file mode 100644 index 0000000..5d5a14c --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/deploy/nginx.conf b/src/Django博客项目/deploy/nginx.conf new file mode 100644 index 0000000..32161d8 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/djangoblog/__init__.py b/src/Django博客项目/djangoblog/__init__.py new file mode 100644 index 0000000..66d588a --- /dev/null +++ b/src/Django博客项目/djangoblog/__init__.py @@ -0,0 +1,19 @@ +# 当该包(通常是Django应用)被导入时,打印初始化信息 +# 主要用于开发调试,确认包的加载时机和状态 +print("__init__.py is running") + +# 定义Django应用的默认配置类 +# Django在启动时会根据此配置加载应用,包括应用名称、信号注册、初始化逻辑等 +# 'djangoblog.apps.DjangoblogAppConfig'表示配置类的完整路径: +# - djangoblog:应用所在的包名 +# - apps:存放配置类的模块名 +# - DjangoblogAppConfig:具体的配置类(继承自django.apps.AppConfig) +default_app_config = 'djangoblog.apps.DjangoblogAppConfig' + +# 导入pymysql库(Python连接MySQL的第三方库) +import pymysql + +# 将pymysql伪装成MySQLdb库 +# 背景:早期Django默认使用MySQLdb库连接MySQL,但MySQLdb不支持Python3 +# 作用:让Django在使用`import MySQLdb`时实际导入pymysql,实现Python3环境下的MySQL连接兼容 +pymysql.install_as_MySQLdb() \ No newline at end of file diff --git a/src/Django博客项目/djangoblog/admin_site.py b/src/Django博客项目/djangoblog/admin_site.py new file mode 100644 index 0000000..6cc3f4d --- /dev/null +++ b/src/Django博客项目/djangoblog/admin_site.py @@ -0,0 +1,103 @@ +# 导入Django Admin相关核心组件 +from django.contrib.admin import AdminSite # Django Admin站点基类 +from django.contrib.admin.models import LogEntry # 管理员操作日志模型 +from django.contrib.sites.admin import SiteAdmin # 站点管理的默认Admin配置 +from django.contrib.sites.models import Site # Django内置的站点模型(用于多站点管理) + +# 导入各应用的Admin配置和数据模型 +from accounts.admin import * # 账户相关的Admin配置 +from blog.admin import * # 博客核心功能(文章、分类等)的Admin配置 +from blog.models import * # 博客核心数据模型 +from comments.admin import * # 评论功能的Admin配置 +from comments.models import * # 评论相关数据模型 +from djangoblog.logentryadmin import LogEntryAdmin # 自定义的操作日志Admin配置 +from oauth.admin import * # 第三方登录(OAuth)的Admin配置 +from oauth.models import * # OAuth相关数据模型 +from owntracks.admin import * # 位置追踪(OwnTracks)的Admin配置 +from owntracks.models import *# OwnTracks相关数据模型 +from servermanager.admin import * # 服务器管理的Admin配置 +from servermanager.models import * # 服务器管理相关数据模型 + + +class DjangoBlogAdminSite(AdminSite): + """ + 自定义的Django Admin站点类,继承自Django内置的AdminSite + + 作用:通过重写基类属性和方法,定制Admin后台的外观和权限控制 + """ + # 定制Admin站点的页面头部标题(显示在登录页和后台顶部导航栏) + site_header = 'djangoblog administration' + # 定制Admin站点的页面标题(显示在浏览器标签页) + site_title = 'djangoblog site admin' + + def __init__(self, name='admin'): + """ + 初始化自定义Admin站点 + + :param name: 站点名称,默认'admin'(与Django默认Admin站点名称保持一致,避免路由冲突) + """ + super().__init__(name) # 调用父类构造方法初始化 + + def has_permission(self, request): + """ + 重写权限检查方法,控制谁能访问Admin后台 + + :param request: HTTP请求对象,包含当前用户信息 + :return: 布尔值,True表示允许访问,False表示拒绝访问 + 此处限制仅超级用户(is_superuser)可访问,比默认的is_staff更严格 + """ + return request.user.is_superuser + + # 以下为注释掉的自定义URL示例(可根据需求启用) + # def get_urls(self): + # """ + # 扩展Admin站点的URL路由,添加自定义功能入口 + # """ + # # 先获取父类默认的URL配置 + # urls = super().get_urls() + # # 导入URL路径处理和自定义视图 + # from django.urls import path + # from blog.views import refresh_memcache # 示例:缓存刷新视图 + # + # # 定义自定义URL规则,使用admin_view()包装确保权限检查 + # my_urls = [ + # path('refresh/', self.admin_view(refresh_memcache), name="refresh"), + # ] + # # 合并默认URL和自定义URL(自定义URL优先) + # return urls + my_urls + + +# 实例化自定义的Admin站点,名称为'admin'(与Django默认Admin站点名称一致,接管后台) +admin_site = DjangoBlogAdminSite(name='admin') + +# 注册数据模型与对应的Admin配置到自定义Admin站点 +# 博客核心内容 +admin_site.register(Article, ArticlelAdmin) # 文章模型 + 其Admin配置 +admin_site.register(Category, CategoryAdmin) # 分类模型 + 其Admin配置 +admin_site.register(Tag, TagAdmin) # 标签模型 + 其Admin配置 +admin_site.register(Links, LinksAdmin) # 友情链接模型 + 其Admin配置 +admin_site.register(SideBar, SideBarAdmin) # 侧边栏模型 + 其Admin配置 +admin_site.register(BlogSettings, BlogSettingsAdmin) # 博客设置模型 + 其Admin配置 + +# 服务器管理 +admin_site.register(commands, CommandsAdmin) # 命令模型 + 其Admin配置 +admin_site.register(EmailSendLog, EmailSendLogAdmin)# 邮件发送日志模型 + 其Admin配置 + +# 账户管理 +admin_site.register(BlogUser, BlogUserAdmin) # 自定义用户模型 + 其Admin配置 + +# 评论管理 +admin_site.register(Comment, CommentAdmin) # 评论模型 + 其Admin配置 + +# OAuth第三方登录 +admin_site.register(OAuthUser, OAuthUserAdmin) # OAuth用户模型 + 其Admin配置 +admin_site.register(OAuthConfig, OAuthConfigAdmin) # OAuth配置模型 + 其Admin配置 + +# 位置追踪 +admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # 位置日志模型 + 其Admin配置 + +# 站点管理(Django内置) +admin_site.register(Site, SiteAdmin) # 站点模型 + Django默认的SiteAdmin配置 + +# 操作日志管理 +admin_site.register(LogEntry, LogEntryAdmin) # 管理员操作日志模型 + 自定义Admin配置 \ No newline at end of file diff --git a/src/Django博客项目/djangoblog/apps.py b/src/Django博客项目/djangoblog/apps.py new file mode 100644 index 0000000..415652f --- /dev/null +++ b/src/Django博客项目/djangoblog/apps.py @@ -0,0 +1,43 @@ +# 导入Django的AppConfig类,用于配置Django应用的生命周期和元数据 +from django.apps import AppConfig + + +class DjangoblogAppConfig(AppConfig): + """ + Django博客应用(djangoblog)的配置类,用于定义应用的核心配置和生命周期钩子 + + 作用: + 1. 配置应用的数据库主键生成规则 + 2. 标识应用的唯一名称 + 3. 定义应用就绪后的初始化逻辑(如加载插件) + """ + # 配置Django模型默认的自增主键字段类型 + # BigAutoField是64位整数型自增字段,支持更大的主键范围(适用于数据量较大的博客) + # 替代默认的AutoField(32位整数),避免数据量增长后主键溢出问题 + default_auto_field = 'django.db.models.BigAutoField' + + # 应用的唯一名称,必须与项目中应用的目录名一致(此处为'djangoblog') + # Django通过该名称识别应用,用于注册路由、加载模型等核心操作 + name = 'djangoblog' + + def ready(self): + """ + Django应用就绪后的钩子方法,在应用完全加载并初始化后自动调用 + + 执行时机: + - 项目启动时(如runserver、celery启动) + - 应用注册表(app registry)完成所有应用加载后 + 注意:该方法可能会被多次调用(如开发环境自动重载时),需确保逻辑可重入 + + 核心功能: + 调用插件加载函数,在应用就绪后初始化所有已激活的插件 + """ + # 调用父类的ready()方法,确保Django默认的应用就绪逻辑正常执行 + super().ready() + + # 导入并执行插件加载函数: + # 1. 从当前应用(djangoblog)的plugin_manage.loader模块中导入load_plugins函数 + # 2. 调用load_plugins()触发插件动态加载(如导入插件模块、初始化插件实例) + # 此处是插件系统与Django应用生命周期的绑定点,确保插件在应用就绪后启动 + from .plugin_manage.loader import load_plugins + load_plugins() \ No newline at end of file diff --git a/src/Django博客项目/djangoblog/blog_signals.py b/src/Django博客项目/djangoblog/blog_signals.py new file mode 100644 index 0000000..48eafa3 --- /dev/null +++ b/src/Django博客项目/djangoblog/blog_signals.py @@ -0,0 +1,207 @@ +# 导入必要模块 +import _thread # 用于创建多线程,处理异步任务(如发送邮件) +import logging # 日志记录模块,记录信号处理过程中的关键信息和错误 + +import django.dispatch # Django信号系统,用于定义和发送自定义信号 +from django.conf import settings # 导入Django项目配置 +from django.contrib.admin.models import LogEntry # 管理员操作日志模型 +from django.contrib.auth.signals import user_logged_in, user_logged_out # Django内置的用户登录/登出信号 +from django.core.mail import EmailMultiAlternatives # 用于发送HTML格式的邮件 +from django.db.models.signals import post_save # Django模型保存后的信号 +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 # OAuth用户模型 + +# 创建当前模块的日志记录器,用于记录信号处理相关日志 +logger = logging.getLogger(__name__) + +# 定义自定义信号:OAuth用户登录信号 +# 触发时机:当用户通过OAuth(第三方登录)成功登录时 +# 参数:['id'] 表示信号会携带OAuthUser的id +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): + """ + 发送邮件信号的接收器,处理实际的邮件发送逻辑 + + :param sender: 信号发送者(通常无需关注) + :param kwargs: 信号携带的参数(emailto, title, content) + """ + # 从信号参数中提取邮件信息 + emailto = kwargs['emailto'] # 收件人列表 + title = kwargs['title'] # 邮件标题 + content = kwargs['content'] # 邮件内容(HTML格式) + + # 创建HTML格式邮件对象 + # from_email:发件人(从项目配置中获取) + # to:收件人列表 + msg = EmailMultiAlternatives( + title, + content, + from_email=settings.DEFAULT_FROM_EMAIL, + 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 = 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用户登录信号的接收器,处理第三方登录后的后续操作 + + :param sender: 信号发送者 + :param kwargs: 信号携带的参数(id:OAuthUser的id) + """ + id = kwargs['id'] # 获取OAuth用户ID + oauthuser = OAuthUser.objects.get(id=id) # 查询对应的OAuth用户对象 + + # 获取当前站点域名(用于判断头像是否为本站资源) + site = get_current_site().domain + + # 若用户头像存在且不是本站资源(如第三方平台的头像URL),则下载并保存到本地 + if oauthuser.picture and not oauthuser.picture.find(site) >= 0: + from djangoblog.utils import save_user_avatar # 导入保存用户头像的工具函数 + oauthuser.picture = save_user_avatar(oauthuser.picture) # 下载并替换头像URL为本地路径 + oauthuser.save() # 保存更新后的用户信息 + + # 清除侧边栏缓存(用户登录状态可能影响侧边栏展示内容) + delete_sidebar_cache() + + +@receiver(post_save) +def model_post_save_callback( + sender, + instance, + created, + raw, + using, + update_fields, + **kwargs): + """ + Django模型保存后(post_save)信号的接收器,处理模型保存后的联动操作 + + :param sender: 发送信号的模型类 + :param instance: 被保存的模型实例 + :param created: 是否为新创建的记录(True表示新建,False表示更新) + :param raw: 是否为原始保存(如通过loaddata导入数据时为True) + :param using: 使用的数据库别名 + :param update_fields: 被更新的字段列表(None表示全量更新) + :param kwargs: 其他参数 + """ + clearcache = False # 标记是否需要清除缓存 + + # 若保存的是管理员操作日志(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() # 获取内容的完整URL + SpiderNotify.baidu_notify([notify_url]) # 向百度搜索引擎推送更新 + except Exception as ex: + logger.error("notify spider", ex) # 推送失败时记录错误日志 + + # 若不是仅更新浏览量,标记需要清除缓存 + if not is_update_views: + clearcache = True + + # 若保存的是评论(Comment)实例 + if isinstance(instance, Comment): + # 仅处理已启用的评论(is_enable=True) + if instance.is_enable: + # 获取评论所属文章的URL路径 + 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' + ) + + # 清除SEO处理器缓存(评论可能影响页面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): + """ + 用户登录/登出信号的接收器,处理认证状态变化后的操作 + + :param sender: 信号发送者 + :param request: HTTP请求对象 + :param user: 当前用户对象 + :param kwargs: 其他参数 + """ + # 若用户存在且用户名有效 + if user and user.username: + logger.info(user) # 记录用户登录/登出日志 + delete_sidebar_cache() # 清除侧边栏缓存(登录状态可能影响侧边栏内容,如显示用户名) + # cache.clear() # 注释:可根据需求开启全量缓存清除(通常侧边栏缓存足够) \ No newline at end of file diff --git a/src/Django博客项目/djangoblog/elasticsearch_backend.py b/src/Django博客项目/djangoblog/elasticsearch_backend.py new file mode 100644 index 0000000..336f8f9 --- /dev/null +++ b/src/Django博客项目/djangoblog/elasticsearch_backend.py @@ -0,0 +1,316 @@ +# 导入必要模块 +from django.utils.encoding import force_str # 用于将数据转换为字符串(兼容Python 2/3) +"""from elasticsearch_dsl import Q """ # Elasticsearch DSL的查询构建工具 +from haystack.backends import ( # Haystack搜索框架的基础类 + BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query +) +from haystack.forms import ModelSearchForm # Haystack默认的模型搜索表单 +from haystack.models import SearchResult # Haystack的搜索结果封装类 +from haystack.utils import log as logging # Haystack的日志工具 + +# 导入项目内部模块 +from blog.documents import ArticleDocument, ArticleDocumentManager # 文章的Elasticsearch文档定义及管理器 +from blog.models import Article # 博客文章模型 + +# 创建当前模块的日志记录器,用于记录搜索相关日志 +logger = logging.getLogger(__name__) + + +class ElasticSearchBackend(BaseSearchBackend): + """ + 基于Elasticsearch的搜索后端实现,继承自Haystack的BaseSearchBackend + + 作用:实现与Elasticsearch的交互逻辑,包括索引的创建、更新、删除, + 以及搜索查询的执行、拼写建议等功能 + """ + def __init__(self, connection_alias, **connection_options): + """ + 初始化搜索后端 + + :param connection_alias: 数据库连接别名(用于多后端配置) + :param connection_options: 连接参数(如主机、端口等) + """ + super(ElasticSearchBackend, self).__init__(connection_alias,** connection_options) + self.manager = ArticleDocumentManager() # 初始化文章文档管理器(处理索引操作) + self.include_spelling = True # 启用拼写建议功能 + + def _get_models(self, iterable): + """ + 将模型实例列表转换为Elasticsearch文档对象 + + :param iterable: 模型实例列表(如Article对象列表) + :return: 转换后的Elasticsearch文档列表 + """ + # 若输入为空,默认使用所有已发布的文章 + models = iterable if iterable and iterable[0] else Article.objects.all() + # 通过管理器将模型转换为文档 + docs = self.manager.convert_to_doc(models) + return docs + + def _create(self, models): + """ + 创建索引并初始化文档(全量重建索引时使用) + + :param models: 模型实例列表 + """ + self.manager.create_index() # 创建Elasticsearch索引(若不存在) + docs = self._get_models(models) # 转换模型为文档 + self.manager.rebuild(docs) # 全量重建索引(清空旧数据后插入新数据) + + def _delete(self, models): + """ + 从索引中删除指定模型对应的文档 + + :param models: 要删除的模型实例列表 + :return: 操作是否成功(始终返回True) + """ + for m in models: + m.delete() # 调用文档的删除方法 + return True + + def _rebuild(self, models): + """ + 增量更新索引(适用于部分数据更新) + + :param models: 需要更新的模型实例列表(若为空则更新所有文章) + """ + models = models if models else Article.objects.all() # 处理空输入 + docs = self._get_models(models) # 转换模型为文档 + self.manager.update_docs(docs) # 增量更新文档 + + def update(self, index, iterable, commit=True): + """ + Haystack标准接口:更新索引(用于实时同步模型变更) + + :param index: 索引名称(当前实现未使用,由管理器处理) + :param iterable: 模型实例列表 + :param commit: 是否立即提交(当前实现未使用) + """ + models = self._get_models(iterable) # 转换模型为文档 + self.manager.update_docs(models) # 执行更新 + + def remove(self, obj_or_string): + """ + Haystack标准接口:从索引中移除指定对象 + + :param obj_or_string: 模型实例或ID字符串 + """ + models = self._get_models([obj_or_string]) # 转换为文档 + self._delete(models) # 执行删除 + + def clear(self, models=None, commit=True): + """ + Haystack标准接口:清空索引(或指定模型的索引) + + :param models: 可选,指定要清空的模型类(当前实现忽略,清空所有) + :param commit: 是否立即提交(当前实现未使用) + """ + self.remove(None) # 调用删除方法清空所有 + + @staticmethod + def get_suggestion(query: str) -> str: + """ + 获取搜索建议词(基于Elasticsearch的拼写纠错功能) + + :param query: 用户输入的搜索词 + :return: 建议的修正词(多个词用空格拼接) + """ + # 构建搜索查询:匹配文章内容,并启用拼写建议 + 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 # Haystack装饰器:记录查询日志 + def search(self, query_string, **kwargs): + """ + 执行搜索查询的核心方法 + + :param query_string: 用户输入的搜索字符串 + :param kwargs: 额外参数(如分页偏移量start_offset/end_offset) + :return: 搜索结果字典(包含结果列表、命中数、拼写建议等) + """ + logger.info('search query_string:' + query_string) # 记录搜索词 + + # 获取分页参数(用于限制返回结果范围) + start_offset = kwargs.get('start_offset', 0) + end_offset = kwargs.get('end_offset', 10) # 默认返回前10条 + + # 判断是否需要启用拼写建议(通过is_suggest参数控制) + if getattr(self, "is_suggest", None): + suggestion = self.get_suggestion(query_string) # 获取建议词 + else: + suggestion = query_string # 不启用建议,使用原搜索词 + + # 构建Elasticsearch查询条件: + # 1. 布尔查询(should):匹配标题或内容,至少满足70%的条件 + q = Q('bool', + should=[Q('match', body=suggestion), Q('match', title=suggestion)], + minimum_should_match="70%") + + # 构建完整搜索: + # - 应用上述查询条件 + # - 过滤:仅包含已发布(status='p')的文章(type='a') + # - 不返回原始文档内容(source=False) + # - 应用分页偏移 + 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 = [] + + # 遍历搜索结果,转换为Haystack的SearchResult格式 + for raw_result in results['hits']['hits']: + app_label = 'blog' # 应用标签(固定为博客应用) + model_name = 'Article' # 模型名称(固定为文章模型) + additional_fields = {} # 额外字段(当前未使用) + + # 创建SearchResult实例(适配Haystack的结果格式) + result = SearchResult( + app_label, + model_name, + raw_result['_id'], # 文档ID(对应文章ID) + raw_result['_score'], # 匹配得分 + **additional_fields + ) + raw_results.append(result) + + # 构建返回结果字典 + facets = {} # 分面搜索结果(当前未实现) + # 若建议词与原词不同,则返回建议词;否则为None + spelling_suggestion = None if query_string == suggestion else suggestion + + return { + 'results': raw_results, # 搜索结果列表(SearchResult实例) + 'hits': hits, # 总命中数 + 'facets': facets, # 分面数据 + 'spelling_suggestion': spelling_suggestion, # 拼写建议 + } + + +class ElasticSearchQuery(BaseSearchQuery): + """ + 自定义搜索查询类,继承自Haystack的BaseSearchQuery + + 作用:处理搜索查询的构建逻辑,包括查询字符串清洗、参数转换等 + """ + def _convert_datetime(self, date): + """ + 转换日期时间为Elasticsearch兼容的字符串格式 + + :param date: 日期时间对象 + :return: 格式化的字符串(如'20231018123000') + """ + if hasattr(date, 'hour'): # 若包含时间信息(datetime对象) + return force_str(date.strftime('%Y%m%d%H%M%S')) + else: # 仅日期(date对象),时间部分设为00:00:00 + return force_str(date.strftime('%Y%m%d000000')) + + def clean(self, query_fragment): + """ + 清洗用户输入的查询片段,处理保留字和特殊字符 + + :param query_fragment: 用户输入的查询字符串片段 + :return: 清洗后的查询字符串 + """ + words = query_fragment.split() # 按空格拆分词语 + cleaned_words = [] + + for word in words: + # 处理Elasticsearch保留字(转为小写) + 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): + """ + 构建查询片段(适配Elasticsearch的查询语法) + + :param field: 搜索字段 + :param filter_type: 过滤类型 + :param value: 查询值 + :return: 构建的查询字符串 + """ + return value.query_string # 直接使用查询字符串(由value提供) + + def get_count(self): + """ + 获取搜索结果总数 + + :return: 结果数量 + """ + results = self.get_results() + return len(results) if results else 0 + + def get_spelling_suggestion(self, preferred_query=None): + """ + 获取拼写建议(适配Haystack接口) + + :param preferred_query: 优先使用的查询(未使用) + :return: 拼写建议词 + """ + return self._spelling_suggestion + + def build_params(self, spelling_query=None): + """ + 构建查询参数(适配Haystack接口) + + :param spelling_query: 拼写建议查询(未使用) + :return: 构建的参数字典 + """ + kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query) + return kwargs + + +class ElasticSearchModelSearchForm(ModelSearchForm): + """ + 自定义搜索表单,继承自Haystack的ModelSearchForm + + 作用:扩展默认搜索表单,支持控制是否启用拼写建议 + """ + def search(self): + """ + 执行搜索,根据表单参数控制拼写建议 + + :return: 搜索结果集(SearchQuerySet) + """ + # 通过表单数据中的"is_suggest"参数控制是否启用拼写建议 + # 若"is_suggest"为"no",则禁用建议 + self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no" + # 调用父类方法执行搜索 + sqs = super().search() + return sqs + + +class ElasticSearchEngine(BaseEngine): + """ + Elasticsearch搜索引擎入口类,继承自Haystack的BaseEngine + + 作用:绑定后端实现和查询类,作为Haystack的引擎配置入口 + """ + backend = ElasticSearchBackend # 指定使用的搜索后端 + query = ElasticSearchQuery # 指定使用的查询类 \ No newline at end of file diff --git a/src/Django博客项目/djangoblog/feeds.py b/src/Django博客项目/djangoblog/feeds.py new file mode 100644 index 0000000..8c4e851 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/djangoblog/logentryadmin.py b/src/Django博客项目/djangoblog/logentryadmin.py new file mode 100644 index 0000000..2f6a535 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/djangoblog/plugin_manage/base_plugin.py b/src/Django博客项目/djangoblog/plugin_manage/base_plugin.py new file mode 100644 index 0000000..f7f22c2 --- /dev/null +++ b/src/Django博客项目/djangoblog/plugin_manage/base_plugin.py @@ -0,0 +1,95 @@ +# 导入Python标准库中的logging模块,用于实现插件运行过程中的日志记录功能 +# 日志可以帮助开发者追踪插件的运行状态、排查错误等 +import logging + +# 创建一个日志记录器实例,其名称与当前模块(__name__)绑定 +# 这样可以确保日志信息能够准确关联到插件模块,便于日志的分类和筛选 +logger = logging.getLogger(__name__) + + +class BasePlugin: + """ + 插件系统的基类(抽象基类角色),所有自定义插件都必须继承此类 + + 该类的核心作用是: + 1. 定义插件必须包含的元数据规范(名称、描述、版本) + 2. 提供插件初始化和钩子注册的统一流程 + 3. 封装获取插件信息的通用方法 + 子类通过继承此类并实现特定方法,即可快速接入插件系统 + """ + + # 插件元数据字段(子类必须显式赋值,否则初始化会失败) + PLUGIN_NAME = None # 插件的唯一标识名称,用于在系统中区分不同插件,例如"DataCleanPlugin" + PLUGIN_DESCRIPTION = None # 插件功能的详细描述,说明插件的作用和使用场景,例如"用于清洗CSV格式的原始数据" + PLUGIN_VERSION = None # 插件的版本号,遵循语义化版本规范(如"1.0.0"),用于版本管理和兼容性判断 + + def __init__(self): + """ + 插件实例的构造方法,负责插件的初始化流程控制 + + 执行逻辑: + 1. 首先验证子类是否完整实现了元数据(名称、描述、版本) + 2. 若元数据不完整,抛出ValueError异常阻止实例化 + 3. 元数据验证通过后,依次调用初始化方法和钩子注册方法 + 确保插件在使用前完成必要的准备工作 + """ + # 使用all()函数检查三个元数据字段是否都有值(非None) + # 若存在任何一个未定义的字段,触发异常 + 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): + """ + 插件初始化的具体实现方法,用于执行插件启动前的准备工作 + + 基类默认实现: + - 输出一条INFO级别的日志,提示插件已完成初始化 + 子类可重写此方法实现特定逻辑,例如: + - 加载配置文件 + - 建立数据库连接 + - 初始化缓存数据结构等 + 注意:重写时若需要保留默认日志,可通过super().init_plugin()调用父类方法 + """ + # 记录插件初始化成功的日志,包含插件名称便于追踪 + logger.info(f'{self.PLUGIN_NAME} initialized.') + + def register_hooks(self): + """ + 插件钩子注册方法,用于将插件功能与系统事件关联 + + 钩子(Hook)机制说明: + 系统在运行过程中会触发一系列事件(如"数据处理前"、"任务完成后"等), + 插件通过注册钩子,可以在特定事件发生时自动执行对应逻辑。 + + 基类默认实现:空方法(pass) + 子类需根据自身功能重写此方法,例如: + - 调用系统提供的register_hook()方法注册事件回调 + - 定义需要监听的事件类型和对应的处理函数 + """ + pass + + def get_plugin_info(self): + """ + 获取插件元数据的统一接口,用于系统展示或管理插件信息 + + 返回值说明: + - 字典类型,包含三个键值对 + - 'name':对应PLUGIN_NAME + - 'description':对应PLUGIN_DESCRIPTION + - 'version':对应PLUGIN_VERSION + + 应用场景: + - 插件管理界面展示插件列表 + - 系统启动时收集所有插件信息进行校验 + - 插件间依赖关系判断时获取版本信息 + """ + return { + 'name': self.PLUGIN_NAME, + 'description': self.PLUGIN_DESCRIPTION, + 'version': self.PLUGIN_VERSION + } \ No newline at end of file diff --git a/src/Django博客项目/djangoblog/plugin_manage/hook_constants.py b/src/Django博客项目/djangoblog/plugin_manage/hook_constants.py new file mode 100644 index 0000000..be2ca8a --- /dev/null +++ b/src/Django博客项目/djangoblog/plugin_manage/hook_constants.py @@ -0,0 +1,30 @@ +# 文章相关系统事件常量定义 +# 用途:统一管理插件系统中与文章操作相关的事件名称,避免硬编码导致的不一致问题 +# 所有事件名称均采用大写蛇形命名法(UPPER_SNAKE_CASE),符合Python常量命名规范 + +# 事件:文章详情页加载完成 +# 触发时机:当用户访问某篇文章的详情页,页面内容加载完成后触发 +# 应用场景:插件可监听此事件,执行详情页相关的自定义逻辑(如添加页面统计代码、注入额外内容等) +ARTICLE_DETAIL_LOAD = 'article_detail_load' + +# 事件:文章创建完成 +# 触发时机:当一篇新文章在系统中创建成功(如数据库写入完成、状态设为"已发布"或"草稿")后触发 +# 应用场景:插件可监听此事件,执行创建后的后续操作(如自动生成文章摘要、同步到外部平台、发送通知等) +ARTICLE_CREATE = 'article_create' + +# 事件:文章更新完成 +# 触发时机:当已存在的文章内容、属性(如标题、分类、状态)修改并保存成功后触发 +# 应用场景:插件可监听此事件,执行更新后的联动操作(如更新文章索引、记录修改日志、重新生成相关统计数据等) +ARTICLE_UPDATE = 'article_update' + +# 事件:文章删除完成 +# 触发时机:当一篇文章从系统中删除(物理删除或逻辑删除,如标记为"已删除"状态)后触发 +# 应用场景:插件可监听此事件,执行删除后的清理操作(如删除关联的评论、移除相关缓存、同步删除外部存储的附件等) +ARTICLE_DELETE = 'article_delete' + + +# 文章内容钩子名称常量 +# 用途:定义专门用于拦截、修改文章内容的钩子标识,与上述"操作事件"区分(事件侧重流程节点,钩子侧重内容处理) +# 命名格式与事件常量保持一致,确保插件系统中钩子名称的唯一性和可识别性 +# 应用场景:插件可注册此钩子,在文章内容渲染前(如详情页展示、导出为PDF)对内容进行自定义处理(如过滤敏感词、替换关键词、添加水印等) +ARTICLE_CONTENT_HOOK_NAME = "the_content" \ No newline at end of file diff --git a/src/Django博客项目/djangoblog/plugin_manage/hooks.py b/src/Django博客项目/djangoblog/plugin_manage/hooks.py new file mode 100644 index 0000000..62ad78f --- /dev/null +++ b/src/Django博客项目/djangoblog/plugin_manage/hooks.py @@ -0,0 +1,92 @@ +# 导入logging模块,用于记录钩子系统运行过程中的日志(如注册信息、错误信息等) +import logging + +# 创建当前模块的日志记录器,日志名称与模块绑定,便于区分不同组件的日志输出 +logger = logging.getLogger(__name__) + +# 全局钩子存储字典,用于保存所有注册的钩子及其对应的回调函数 +# 键:钩子名称(字符串,如"article_create") +# 值:回调函数列表(所有注册到该钩子的可调用对象将按注册顺序存储) +_hooks = {} + + +def register(hook_name: str, callback: callable): + """ + 注册一个钩子回调函数,将其添加到指定钩子名称对应的回调列表中 + + 核心作用:建立"钩子名称"与"处理逻辑(回调函数)"的映射关系, + 使后续触发钩子时能自动执行所有注册的回调 + + :param hook_name: 钩子名称(字符串),需与触发时使用的名称一致(如ARTICLE_CREATE) + :param callback: 可调用对象(函数、方法等),当钩子被触发时会执行此对象 + 回调函数的参数需与钩子触发时传递的参数匹配 + """ + # 若钩子名称尚未在全局字典中,初始化一个空列表用于存储回调 + if hook_name not in _hooks: + _hooks[hook_name] = [] + # 将回调函数添加到对应钩子的列表中(按注册顺序存储,触发时也按此顺序执行) + _hooks[hook_name].append(callback) + # 记录DEBUG级日志,说明钩子注册成功(包含钩子名称和回调函数名,便于调试) + logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'") + + +def run_action(hook_name: str, *args, **kwargs): + """ + 执行指定名称的"动作钩子(Action Hook)",按注册顺序调用所有关联的回调函数 + + 动作钩子特性:用于触发一系列操作,不关注返回值,仅执行回调逻辑 + 典型场景:文章创建后发送通知、记录日志等(执行动作但无需修改数据) + + :param hook_name: 要触发的钩子名称(需已被注册过) + :param *args: 传递给回调函数的位置参数(可变参数,根据钩子场景定义) + :param **kwargs: 传递给回调函数的关键字参数(可变参数,根据钩子场景定义) + """ + # 检查该钩子是否有已注册的回调函数 + if hook_name in _hooks: + # 记录DEBUG级日志,说明开始执行该动作钩子 + logger.debug(f"Running action hook '{hook_name}'") + # 按注册顺序遍历所有回调函数并执行 + for callback in _hooks[hook_name]: + try: + # 传递位置参数和关键字参数给回调函数 + callback(*args, **kwargs) + except Exception as e: + # 若回调执行出错,记录ERROR级日志(包含详细异常信息) + # exc_info=True 会在日志中附带堆栈跟踪,便于排查错误 + 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)",通过回调函数链式处理初始值并返回最终结果 + + 过滤钩子特性:用于对数据进行加工处理,每个回调函数接收上一个函数的输出作为输入, + 最终返回经过所有回调处理后的值 + 典型场景:文章内容过滤敏感词、格式化文本等(修改数据并返回新值) + + :param hook_name: 要触发的钩子名称(需已被注册过) + :param value: 初始值(需要被过滤/处理的数据,如文章内容字符串) + :param *args: 传递给回调函数的额外位置参数 + :param **kwargs: 传递给回调函数的额外关键字参数 + :return: 经过所有回调函数处理后的最终值 + """ + # 检查该钩子是否有已注册的回调函数 + if hook_name in _hooks: + # 记录DEBUG级日志,说明开始执行该过滤钩子 + logger.debug(f"Applying filter hook '{hook_name}'") + # 按注册顺序遍历所有回调函数,链式处理初始值 + for callback in _hooks[hook_name]: + try: + # 调用回调函数,将当前值和额外参数传入,更新值为回调返回的结果 + value = callback(value, *args, **kwargs) + except Exception as e: + # 若回调执行出错,记录ERROR级日志(包含详细异常信息) + 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/Django博客项目/djangoblog/plugin_manage/loader.py b/src/Django博客项目/djangoblog/plugin_manage/loader.py new file mode 100644 index 0000000..1e5157f --- /dev/null +++ b/src/Django博客项目/djangoblog/plugin_manage/loader.py @@ -0,0 +1,51 @@ +# 导入必要的模块 +# os: 用于处理文件路径和目录操作 +# logging: 用于记录插件加载过程中的日志信息(成功/失败状态) +# django.conf.settings: 用于获取Django项目的配置信息(如插件目录、激活的插件列表) +import os +import logging +from django.conf import settings + +# 创建当前模块的日志记录器,日志名称与模块绑定,便于追踪插件加载相关的日志 +logger = logging.getLogger(__name__) + + +def load_plugins(): + """ + 动态加载并初始化位于'plugins'目录中的插件 + + 功能说明: + - 从Django配置中读取激活的插件列表(settings.ACTIVE_PLUGINS) + - 检查每个插件的目录结构是否合法(是否存在plugin.py文件) + - 动态导入插件的核心模块(plugin.py),触发插件的初始化流程 + - 通过日志记录每个插件的加载结果(成功/失败及原因) + + 调用时机: + 该函数应在Django应用注册表(app registry)准备就绪后调用, + 通常在项目启动时(如通过AppConfig.ready()方法触发),确保Django环境已初始化完成。 + """ + # 遍历配置中激活的所有插件名称(settings.ACTIVE_PLUGINS是一个插件名称列表) + for plugin_name in settings.ACTIVE_PLUGINS: + # 构建插件的完整目录路径: + # settings.PLUGINS_DIR是项目中存放所有插件的根目录(如"project_root/plugins") + # 拼接根目录与当前插件名称,得到具体插件的目录路径(如"project_root/plugins/my_plugin") + plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name) + + # 验证插件目录的合法性: + # 1. 必须是一个存在的目录(os.path.isdir(plugin_path)) + # 2. 目录中必须包含核心文件plugin.py(插件的入口模块) + if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')): + try: + # 动态导入插件的plugin.py模块: + # 导入路径格式为"plugins.{插件名称}.plugin"(基于Python包结构) + # 导入后会自动执行plugin.py中的顶级代码(如插件类的定义和注册逻辑) + __import__(f'plugins.{plugin_name}.plugin') + + # 记录INFO级日志,提示插件加载成功 + logger.info(f"Successfully loaded plugin: {plugin_name}") + + # 捕获导入过程中的异常(如模块不存在、语法错误、依赖缺失等) + except ImportError as e: + # 记录ERROR级日志,提示插件导入失败,并附带异常信息 + # exc_info=e 会将异常堆栈信息写入日志,便于排查问题 + logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) \ No newline at end of file diff --git a/src/Django博客项目/djangoblog/settings.py b/src/Django博客项目/djangoblog/settings.py new file mode 100644 index 0000000..d818556 --- /dev/null +++ b/src/Django博客项目/djangoblog/settings.py @@ -0,0 +1,347 @@ +""" +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': os.environ.get('DJANGO_MYSQL_DATABASE') or 'djangoblog', + 'USER': os.environ.get('DJANGO_MYSQL_USER') or 'root', + 'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or '050322yyd*', + 'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1', + 'PORT': int( + os.environ.get('DJANGO_MYSQL_PORT') or 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': 'haystack.backends.whoosh_backend.WhooshEngine', + 'PATH': os.path.join(BASE_DIR, 'whoosh_index'), + 'ANALYZER': 'utils.jieba_analyzer.jieba_analyzer', + }, +} +# 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_DIRS = [ + 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' +] diff --git a/src/Django博客项目/djangoblog/sitemap.py b/src/Django博客项目/djangoblog/sitemap.py new file mode 100644 index 0000000..8b7d446 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/djangoblog/spider_notify.py b/src/Django博客项目/djangoblog/spider_notify.py new file mode 100644 index 0000000..7b909e9 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/djangoblog/tests.py b/src/Django博客项目/djangoblog/tests.py new file mode 100644 index 0000000..01237d9 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/djangoblog/urls.py b/src/Django博客项目/djangoblog/urls.py new file mode 100644 index 0000000..824630c --- /dev/null +++ b/src/Django博客项目/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 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')), + #path('search/', include('haystack.urls')), +] +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/Django博客项目/djangoblog/utils.py b/src/Django博客项目/djangoblog/utils.py new file mode 100644 index 0000000..57f63dc --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/djangoblog/whoosh_cn_backend.py b/src/Django博客项目/djangoblog/whoosh_cn_backend.py new file mode 100644 index 0000000..be70775 --- /dev/null +++ b/src/Django博客项目/djangoblog/whoosh_cn_backend.py @@ -0,0 +1,1047 @@ +# 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 +try: + from jieba.analyse import ChineseAnalyzer +except ImportError: + ChineseAnalyzer = None +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/Django博客项目/djangoblog/wsgi.py b/src/Django博客项目/djangoblog/wsgi.py new file mode 100644 index 0000000..2295efd --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/djangoblog_complete.sql b/src/Django博客项目/djangoblog_complete.sql new file mode 100644 index 0000000..423b3a4 Binary files /dev/null and b/src/Django博客项目/djangoblog_complete.sql differ diff --git a/src/Django博客项目/docs/README-en.md b/src/Django博客项目/docs/README-en.md new file mode 100644 index 0000000..37ea069 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/docs/config-en.md b/src/Django博客项目/docs/config-en.md new file mode 100644 index 0000000..b877efb --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/docs/config.md b/src/Django博客项目/docs/config.md new file mode 100644 index 0000000..24673a3 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/docs/docker-en.md b/src/Django博客项目/docs/docker-en.md new file mode 100644 index 0000000..8d5d59e --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/docs/docker.md b/src/Django博客项目/docs/docker.md new file mode 100644 index 0000000..e7c255a --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/docs/es.md b/src/Django博客项目/docs/es.md new file mode 100644 index 0000000..97226c5 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/docs/imgs/alipay.jpg b/src/Django博客项目/docs/imgs/alipay.jpg new file mode 100644 index 0000000..424d70a Binary files /dev/null and b/src/Django博客项目/docs/imgs/alipay.jpg differ diff --git a/src/Django博客项目/docs/imgs/pycharm_logo.png b/src/Django博客项目/docs/imgs/pycharm_logo.png new file mode 100644 index 0000000..7f2a4b0 Binary files /dev/null and b/src/Django博客项目/docs/imgs/pycharm_logo.png differ diff --git a/src/Django博客项目/docs/imgs/wechat.jpg b/src/Django博客项目/docs/imgs/wechat.jpg new file mode 100644 index 0000000..7edf525 Binary files /dev/null and b/src/Django博客项目/docs/imgs/wechat.jpg differ diff --git a/src/Django博客项目/docs/k8s-en.md b/src/Django博客项目/docs/k8s-en.md new file mode 100644 index 0000000..20e9527 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/docs/k8s.md b/src/Django博客项目/docs/k8s.md new file mode 100644 index 0000000..9da3c28 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/fix_wsgi.py b/src/Django博客项目/fix_wsgi.py new file mode 100644 index 0000000..ba47dc8 --- /dev/null +++ b/src/Django博客项目/fix_wsgi.py @@ -0,0 +1,19 @@ +# fix_wsgi.py +import os +import sys + +# 添加项目路径 +project_path = r'D:\Django\src\DjangoBlog-master' +sys.path.append(project_path) + +# 设置环境变量 +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'djangoblog.settings') + +# 初始化 Django +import django +django.setup() + +# 测试 WSGI +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() +print("✅ WSGI 应用创建成功!") \ No newline at end of file diff --git a/src/Django博客项目/foodBlog/food_categories.json b/src/Django博客项目/foodBlog/food_categories.json new file mode 100644 index 0000000..d254c4e --- /dev/null +++ b/src/Django博客项目/foodBlog/food_categories.json @@ -0,0 +1,30 @@ +[ + { + "model": "blog.category", + "pk": 1, + "fields": { + "name": "中式菜肴" + } + }, + { + "model": "blog.category", + "pk": 2, + "fields": { + "name": "西式美食" + } + }, + { + "model": "blog.category", + "pk": 3, + "fields": { + "name": "甜点烘焙" + } + }, + { + "model": "blog.category", + "pk": 4, + "fields": { + "name": "特色小吃" + } + } +] \ No newline at end of file diff --git a/src/Django博客项目/foodBlog/food_posts.json b/src/Django博客项目/foodBlog/food_posts.json new file mode 100644 index 0000000..db79962 --- /dev/null +++ b/src/Django博客项目/foodBlog/food_posts.json @@ -0,0 +1,59 @@ +[ + { + "model": "blog.Article", + "pk": 1, + "fields": { + "title": "北京胡同里的炸酱面探店", + "body": "在北京,胡同是这座城市的脉络,藏着老北京最地道的烟火气,也藏着不少令人惊艳的美食小店。这次,我就寻到了一家隐匿在胡同深处的炸酱面店,那味道,堪称老北京炸酱面的\"天花板\"。这家店位于东四附近的一条小胡同里,没有醒目的招牌,只有一块略显斑驳的木牌,上面用毛笔字写着\"老北京炸酱面\"。若不是本地朋友极力推荐,我大概率会与它擦肩而过。跟着导航七拐八绕,穿过两侧是灰墙灰瓦的狭窄胡同,耳边是自行车铃铛声、街坊邻里的谈笑声,仿佛一下子穿越回了老北京的旧时光,而那家炸酱面店,就安静地坐落在胡同中段。推开略显厚重的木门,店内空间不大,摆着几张木质方桌和长条凳,桌面上擦得干干净净。墙壁上挂着老北京的黑白照片,有胡同的全景,有孩童嬉戏的场景,还有老北京人吃炸酱面的模样,瞬间就把人拉进了充满回忆的氛围里。店里的食客不少,有附近的老街坊,也有像我一样慕名而来的游客,大家挤在一起,却丝毫不觉得拥挤,反而因为这浓浓的烟火气,多了几分亲近感。老板是位地道的北京大爷,穿着白色的厨师服,脸上总是挂着憨厚的笑容,看到新客进来,会热情地招呼:\"里面坐,炸酱面马上好!\"等了没几分钟,一碗热腾腾的炸酱面就端上了桌。先看那炸酱,盛在一个小巧的青花瓷碗里,酱色红亮,上面还点缀着星星点点的葱花。用筷子挑起一点,能看到酱里肥瘦相间的五花肉丁,颗粒饱满,大小均匀。老板说,这酱是用五花肉丁先煸炒出油,再加入黄酱和甜面酱慢慢熬制而成,期间要不断搅拌,这样才能让酱变得浓稠,而且不会糊锅。凑近闻一闻,酱香混合着肉香,直往鼻子里钻,却一点都不觉得腻。再看面条,是店家手工制作的手擀面。面条粗细均匀,色泽洁白,根根分明。用筷子夹起一筷子,能感觉到面条很有韧性,放进嘴里一尝,果然筋道弹牙,咀嚼起来充满了小麦的清香。老板告诉我,他们的面条都是当天现做的,和面时还加了适量的盐,这样能让面条更有嚼劲。炸酱面的配菜也很丰富,一小盘里装着黄瓜丝、胡萝卜丝、豆芽、青豆、心里美萝卜丝,色彩鲜艳,看着就很有食欲。这些配菜都切得很精细,黄瓜丝脆嫩,胡萝卜丝清甜,豆芽爽脆,和筋道的面条、香浓的炸酱搭配在一起,口感层次十分丰富。按照老北京的吃法,先把配菜倒进面条里,再挖上一大勺炸酱,然后用筷子快速地搅拌均匀。随着搅拌,面条被均匀地裹上了红亮的炸酱,配菜也与面条充分融合。夹起一筷子,送进嘴里,面条的筋道、炸酱的香浓、配菜的清爽,在口腔里交织碰撞,那味道,真是绝了!每一口都能感受到老北京美食的独特魅力,仿佛能尝到时光的味道。吃完一碗,意犹未尽,连碗底的酱汁都舍不得剩下,恨不得用馒头蘸着吃掉。这家藏在胡同里的炸酱面店,没有华丽的装修,没有精致的摆盘,却用最地道的味道,征服了食客的味蕾。在这里,吃的不仅是一碗炸酱面,更是老北京的烟火气,是胡同里的人情味。如果你也喜欢老北京美食,不妨来这条胡同里,寻找这家\"宝藏小店\",相信你也会和我一样,被这碗炸酱面深深吸引。", + "pub_time": "2025-10-08 12:00:00", + "status": "p", + "comment_status": "o", + "type": "a", + "views": 0, + "author": 1, + "article_order": 0, + "show_toc": false, + "category": 1, + "tags": [1] + } + }, + + { + "model": "blog.Article", + "pk":2, + "fields": { + "title": "《意式餐厅里的味蕾漫游》", + "body": "在城市喧嚣的角落,藏着一家充满意式风情的餐厅,推开那扇雕花木门,仿佛瞬间穿越到了亚平宁半岛。暖黄的灯光洒在铺着格子桌布的餐桌上,墙上挂着意大利各地的风景照,空气中弥漫着橄榄油、芝士与香草交融的香气,一场味蕾的漫游就此开启。首先上桌的是经典的意式番茄肉酱面。手工制作的意面粗细均匀,如丝绸般顺滑,裹满了浓郁的肉酱。肉酱由新鲜番茄慢炖而成,酸甜的汁水充分渗入肉质鲜嫩的牛肉末中,每一口都能感受到番茄的清新与牛肉的醇厚在舌尖碰撞,搭配帕玛森芝士碎,咸香与酸甜交织,层次丰富得让人着迷。接着是意式烤千层面,这简直是芝士爱好者的天堂。层层叠叠的面皮间,夹着细腻的牛肉馅、奶香十足的 béchamel 酱,最上层铺满了马苏里拉芝士。经烤箱烤制后,芝士呈现出诱人的焦黄色,轻轻用叉子一挑,芝士拉出长长的丝,入口即化,面皮的柔韧、馅料的鲜美与芝士的浓郁完美融合,每一层都带来不同的味觉惊喜。最后以一份提拉米苏收尾再合适不过。手指饼干浸润了咖啡酒,带着微微的苦涩,与上层绵密的马斯卡彭芝士奶油完美平衡,顶部撒的可可粉更添了一丝醇厚。用小勺挖上一口,冰凉丝滑的口感在口中散开,咖啡的苦、芝士的甜、饼干的绵,交织出意式甜品独有的浪漫与馥郁。在这家意式餐厅,每一道菜都像是一位意大利友人,用最地道的风味,诉说着亚平宁半岛的阳光与浪漫,让我在城市里,也能拥有一场酣畅淋漓的味蕾漫游。", + "pub_time": "2025-10-08 12:00:00", + "status": "p", + "comment_status": "o", + "type": "a", + "views": 0, + "author": 1, + "article_order": 0, + "show_toc": false, + "category": 2, + "tags": [3] + } + }, + { + "model": "blog.Article", + "pk": 3, + "fields": { + "title": "《法式甜品店的午后邂逅》", + "body": "在繁华街区的转角处,一家名为『甜蜜时光』的法式甜品店静静伫立。推开那扇镶嵌着琉璃的蓝色木门,清脆的风铃声伴随着扑面而来的黄油香气,瞬间将人带入一个甜蜜的梦境。店内装饰着复古镜框与蕾丝窗帘,大理石台面上陈列着琳琅满目的甜品,每一款都如同精雕细琢的艺术品,令人目不暇接。首先品尝的是经典的拿破仑千层酥。三层金黄酥脆的派皮之间,均匀地夹着香草籽点缀的卡仕达酱。用叉子轻轻一压,酥皮应声碎裂,发出悦耳的咔嚓声。入口时,奶油的丝滑与派皮的酥脆在口中交织,香草的芬芳缓缓释放,甜而不腻,层次分明,仿佛在舌尖演奏了一首优雅的法国香颂。接下来是外形精致的覆盆子慕斯。光滑的镜面淋面下,是轻盈如云的慕斯体,中间包裹着酸甜的覆盆子果酱夹心。用小勺挖开,慕斯的绵密与果酱的流动感形成美妙对比。覆盆子的微酸恰到好处地平衡了慕斯的甜度,入口即化,余味带着一丝果香的清新,令人回味无穷。最后以一杯手冲伯爵茶搭配柠檬玛德琳收尾。现烤的玛德琳蛋糕散发着柠檬的清香,标志性的贝壳造型可爱迷人。蛋糕体湿润柔软,边缘带着微微的焦脆。轻咬一口,黄油的浓郁与柠檬的清爽相得益彰,再啜饮一口佛手柑香气萦绕的伯爵茶,茶香与蛋糕的甜味在口中完美融合,为这个午后画上了一个圆满的句号。在这家法式甜品店,每一道甜品都是一首诗,用细腻的口感与精致的造型,诉说着法式浪漫的甜蜜絮语,让我在忙碌的生活中,寻得了一份难得的惬意与治愈。", + "pub_time": "2025-10-09 14:30:00", + "status": "p", + "comment_status": "o", + "type": "a", + "views": 0, + "author": 1, + "article_order": 0, + "show_toc": false, + "category": 3, + "tags": [3] + } + } + + + ] diff --git a/src/Django博客项目/foodBlog/food_tags.json b/src/Django博客项目/foodBlog/food_tags.json new file mode 100644 index 0000000..3459489 --- /dev/null +++ b/src/Django博客项目/foodBlog/food_tags.json @@ -0,0 +1,30 @@ +[ + { + "model": "blog.tag", + "pk": 1, + "fields": { + "name": "探店" + } + }, + { + "model": "blog.tag", + "pk": 2, + "fields": { + "name": "家常菜" + } + }, + { + "model": "blog.tag", + "pk": 3, + "fields": { + "name": "烘焙" + } + }, + { + "model": "blog.tag", + "pk": 4, + "fields": { + "name": "小吃" + } + } +] \ No newline at end of file diff --git a/src/Django博客项目/foodBlog/food_users.json b/src/Django博客项目/foodBlog/food_users.json new file mode 100644 index 0000000..9996baf --- /dev/null +++ b/src/Django博客项目/foodBlog/food_users.json @@ -0,0 +1,13 @@ +[ + { + "model": "auth.user", + "pk": 1, + "fields": { + "username": "王希", + "password": "pbkdf2_sha256$600000$oCcoRHDsETbZPV41L4cTwB$6zR8e4rwSsG6HStNokpXlrV7ynvRvnZTEej7gNKro2Y=", + "email": "yyd0322@qq.com", + "is_staff": false, + "is_superuser": false + } + } +] \ No newline at end of file diff --git a/src/Django博客项目/locale/en/LC_MESSAGES/django.mo b/src/Django博客项目/locale/en/LC_MESSAGES/django.mo new file mode 100644 index 0000000..f63669f Binary files /dev/null and b/src/Django博客项目/locale/en/LC_MESSAGES/django.mo differ diff --git a/src/Django博客项目/locale/en/LC_MESSAGES/django.po b/src/Django博客项目/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..c80b30a --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/locale/zh_Hans/LC_MESSAGES/django.mo b/src/Django博客项目/locale/zh_Hans/LC_MESSAGES/django.mo new file mode 100644 index 0000000..a2d36e9 Binary files /dev/null and b/src/Django博客项目/locale/zh_Hans/LC_MESSAGES/django.mo differ diff --git a/src/Django博客项目/locale/zh_Hans/LC_MESSAGES/django.po b/src/Django博客项目/locale/zh_Hans/LC_MESSAGES/django.po new file mode 100644 index 0000000..200b7e6 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/locale/zh_Hant/LC_MESSAGES/django.mo b/src/Django博客项目/locale/zh_Hant/LC_MESSAGES/django.mo new file mode 100644 index 0000000..fe2ea17 Binary files /dev/null and b/src/Django博客项目/locale/zh_Hant/LC_MESSAGES/django.mo differ diff --git a/src/Django博客项目/locale/zh_Hant/LC_MESSAGES/django.po b/src/Django博客项目/locale/zh_Hant/LC_MESSAGES/django.po new file mode 100644 index 0000000..a2920ce --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/manage.py b/src/Django博客项目/manage.py new file mode 100644 index 0000000..fc7c243 --- /dev/null +++ b/src/Django博客项目/manage.py @@ -0,0 +1,15 @@ +#!/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 as exc: + 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?" + ) from exc + execute_from_command_line(sys.argv) \ No newline at end of file diff --git a/src/Django博客项目/oauth/__init__.py b/src/Django博客项目/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/oauth/admin.py b/src/Django博客项目/oauth/admin.py new file mode 100644 index 0000000..57eab5f --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/oauth/apps.py b/src/Django博客项目/oauth/apps.py new file mode 100644 index 0000000..17fcea2 --- /dev/null +++ b/src/Django博客项目/oauth/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class OauthConfig(AppConfig): + name = 'oauth' diff --git a/src/Django博客项目/oauth/forms.py b/src/Django博客项目/oauth/forms.py new file mode 100644 index 0000000..0e4ede3 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/oauth/migrations/0001_initial.py b/src/Django博客项目/oauth/migrations/0001_initial.py new file mode 100644 index 0000000..3aa3e03 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py b/src/Django博客项目/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py new file mode 100644 index 0000000..d5cc70e --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/oauth/migrations/0003_alter_oauthuser_nickname.py b/src/Django博客项目/oauth/migrations/0003_alter_oauthuser_nickname.py new file mode 100644 index 0000000..6af08eb --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/oauth/migrations/__init__.py b/src/Django博客项目/oauth/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/oauth/models.py b/src/Django博客项目/oauth/models.py new file mode 100644 index 0000000..be838ed --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/oauth/oauthmanager.py b/src/Django博客项目/oauth/oauthmanager.py new file mode 100644 index 0000000..2e7ceef --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/oauth/templatetags/__init__.py b/src/Django博客项目/oauth/templatetags/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Django博客项目/oauth/templatetags/__init__.py @@ -0,0 +1 @@ + diff --git a/src/Django博客项目/oauth/templatetags/oauth_tags.py b/src/Django博客项目/oauth/templatetags/oauth_tags.py new file mode 100644 index 0000000..7b687d5 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/oauth/tests.py b/src/Django博客项目/oauth/tests.py new file mode 100644 index 0000000..bb23b9b --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/oauth/urls.py b/src/Django博客项目/oauth/urls.py new file mode 100644 index 0000000..c4a12a0 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/oauth/views.py b/src/Django博客项目/oauth/views.py new file mode 100644 index 0000000..12e3a6e --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/owntracks/__init__.py b/src/Django博客项目/owntracks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/owntracks/admin.py b/src/Django博客项目/owntracks/admin.py new file mode 100644 index 0000000..655b535 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/owntracks/apps.py b/src/Django博客项目/owntracks/apps.py new file mode 100644 index 0000000..1bc5f12 --- /dev/null +++ b/src/Django博客项目/owntracks/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class OwntracksConfig(AppConfig): + name = 'owntracks' diff --git a/src/Django博客项目/owntracks/migrations/0001_initial.py b/src/Django博客项目/owntracks/migrations/0001_initial.py new file mode 100644 index 0000000..9eee55c --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/owntracks/migrations/0002_alter_owntracklog_options_and_more.py b/src/Django博客项目/owntracks/migrations/0002_alter_owntracklog_options_and_more.py new file mode 100644 index 0000000..b4f8dec --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/owntracks/migrations/__init__.py b/src/Django博客项目/owntracks/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/owntracks/models.py b/src/Django博客项目/owntracks/models.py new file mode 100644 index 0000000..760942c --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/owntracks/tests.py b/src/Django博客项目/owntracks/tests.py new file mode 100644 index 0000000..3b4b9d8 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/owntracks/urls.py b/src/Django博客项目/owntracks/urls.py new file mode 100644 index 0000000..c19ada8 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/owntracks/views.py b/src/Django博客项目/owntracks/views.py new file mode 100644 index 0000000..4c72bdd --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/plugins/__init__.py b/src/Django博客项目/plugins/__init__.py new file mode 100644 index 0000000..e88afca --- /dev/null +++ b/src/Django博客项目/plugins/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/src/Django博客项目/plugins/article_copyright/__init__.py b/src/Django博客项目/plugins/article_copyright/__init__.py new file mode 100644 index 0000000..e88afca --- /dev/null +++ b/src/Django博客项目/plugins/article_copyright/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/src/Django博客项目/plugins/article_copyright/plugin.py b/src/Django博客项目/plugins/article_copyright/plugin.py new file mode 100644 index 0000000..317fed2 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/plugins/external_links/__init__.py b/src/Django博客项目/plugins/external_links/__init__.py new file mode 100644 index 0000000..e88afca --- /dev/null +++ b/src/Django博客项目/plugins/external_links/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/src/Django博客项目/plugins/external_links/plugin.py b/src/Django博客项目/plugins/external_links/plugin.py new file mode 100644 index 0000000..5b2ef14 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/plugins/reading_time/__init__.py b/src/Django博客项目/plugins/reading_time/__init__.py new file mode 100644 index 0000000..e88afca --- /dev/null +++ b/src/Django博客项目/plugins/reading_time/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/src/Django博客项目/plugins/reading_time/plugin.py b/src/Django博客项目/plugins/reading_time/plugin.py new file mode 100644 index 0000000..35f9db1 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/plugins/seo_optimizer/__init__.py b/src/Django博客项目/plugins/seo_optimizer/__init__.py new file mode 100644 index 0000000..e88afca --- /dev/null +++ b/src/Django博客项目/plugins/seo_optimizer/__init__.py @@ -0,0 +1 @@ +# This file makes this a Python package diff --git a/src/Django博客项目/plugins/seo_optimizer/plugin.py b/src/Django博客项目/plugins/seo_optimizer/plugin.py new file mode 100644 index 0000000..b5b19a3 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/plugins/view_count/__init__.py b/src/Django博客项目/plugins/view_count/__init__.py new file mode 100644 index 0000000..8804fdf --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/plugins/view_count/plugin.py b/src/Django博客项目/plugins/view_count/plugin.py new file mode 100644 index 0000000..15e9d94 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/requirements.txt b/src/Django博客项目/requirements.txt new file mode 100644 index 0000000..9dc5c93 Binary files /dev/null and b/src/Django博客项目/requirements.txt differ diff --git a/src/Django博客项目/servermanager/MemcacheStorage.py b/src/Django博客项目/servermanager/MemcacheStorage.py new file mode 100644 index 0000000..78e656f --- /dev/null +++ b/src/Django博客项目/servermanager/MemcacheStorage.py @@ -0,0 +1,87 @@ +#yyd: +# coding: utf-8 + +# 导入WeRoBot框架的会话存储基类和工具函数 +from werobot.session import SessionStorage +from werobot.utils import json_loads, json_dumps + +# 导入Django项目的缓存工具 +from djangoblog.utils import cache + + +class MemcacheStorage(SessionStorage): + """ + 基于Memcached的WeRoBot会话存储实现 + 继承自WeRoBot的SessionStorage基类,用于将会话数据存储到Memcached中 + """ + + def __init__(self, prefix='ws_'): + """ + 初始化方法 + + Args: + prefix (str): 缓存键的前缀,默认为'ws_',用于区分其他缓存数据 + """ + self.prefix = prefix # 设置缓存键前缀 + self.cache = cache # Django缓存实例(通常是Memcached) + + @property + def is_available(self): + """ + 检查缓存存储是否可用的属性 + + Returns: + bool: 如果缓存系统工作正常返回True,否则返回False + """ + value = "1" # 测试值 + # 尝试设置一个测试键值对 + self.set('check_available', value=value) + # 读取测试值并比较,验证缓存系统是否正常工作 + return value == self.get('check_available') + + def key_name(self, session_id): + """ + 生成完整的缓存键名 + + Args: + session_id (str): 原始会话ID + + Returns: + str: 添加前缀后的完整缓存键名 + """ + return f'{self.prefix}{session_id}' + + def get(self, session_id): + """ + 根据会话ID获取会话数据 + + Args: + session_id (str): 会话ID + + Returns: + dict: 解析后的会话数据字典,如果不存在返回空字典 + """ + key = self.key_name(session_id) # 生成完整缓存键 + session_json = self.cache.get(key) or '{}' # 从缓存获取数据 + return json_loads(session_json) + + def set(self, session_id, value): + """ + 设置会话数据 + + Args: + session_id (str): 会话ID + value (dict): 要存储的会话数据字典 + """ + key = self.key_name(session_id) + self.cache.set(key, json_dumps(value)) + + def delete(self, session_id): + """ + 删除指定会话ID的数据 + + Args: + session_id (str): 要删除的会话ID + """ + key = self.key_name(session_id) + self.cache.delete(key) diff --git a/src/Django博客项目/servermanager/__init__.py b/src/Django博客项目/servermanager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/servermanager/admin.py b/src/Django博客项目/servermanager/admin.py new file mode 100644 index 0000000..d18ca95 --- /dev/null +++ b/src/Django博客项目/servermanager/admin.py @@ -0,0 +1,66 @@ +#yyd: +# coding: utf-8 + +# 导入Django管理模块 +from django.contrib import admin +# 导入自定义模型(假设已在同一目录的models.py中定义) +from .models import commands, EmailSendLog + + +# 注册Commands模型到Django管理后台 +class CommandsAdmin(admin.ModelAdmin): + """ + 命令模型的管理界面配置类 + 用于自定义commands模型在Django admin中的显示和行为 + """ + + # 设置在管理列表页面显示的字段 + list_display = ('title', 'command', 'describe') + # 字段说明: + # title: 命令标题 + # command: 具体命令内容 + # describe: 命令描述 + + +# 注册EmailSendLog模型到Django管理后台 +class EmailSendLogAdmin(admin.ModelAdmin): + """ + 邮件发送日志模型的管理界面配置类 + 用于自定义EmailSendLog模型在Django admin中的显示和行为 + """ + + # 设置在管理列表页面显示的字段 + list_display = ('title', 'emailto', 'send_result', 'creation_time') + # 字段说明: + # title: 邮件标题 + # emailto: 收件人 + # send_result: 发送结果 + # creation_time: 创建时间 + + # 设置只读字段,这些字段在编辑页面不可修改 + readonly_fields = ( + 'title', # 邮件标题(只读) + 'emailto', # 收件人(只读) + 'send_result', # 发送结果(只读) + 'creation_time', # 创建时间(只读) + 'content' # 邮件内容(只读) + ) + + def has_add_permission(self, request): + """ + 重写添加权限方法,禁止在管理后台手动添加邮件日志记录 + + Args: + request: HTTP请求对象 + + Returns: + bool: 总是返回False,禁止添加操作 + """ + return False + # 这样设计是因为邮件发送日志应该由系统自动创建1 + # 而不是手动添加,保证日志的真实性和完整性. + + +# 将模型注册到Django管理后台 +admin.site.register(commands, CommandsAdmin) +admin.site.register(EmailSendLog, EmailSendLogAdmin) \ No newline at end of file diff --git a/src/Django博客项目/servermanager/api/__init__.py b/src/Django博客项目/servermanager/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Django博客项目/servermanager/api/__init__.py @@ -0,0 +1 @@ + diff --git a/src/Django博客项目/servermanager/api/blogapi.py b/src/Django博客项目/servermanager/api/blogapi.py new file mode 100644 index 0000000..6ba09ef --- /dev/null +++ b/src/Django博客项目/servermanager/api/blogapi.py @@ -0,0 +1,81 @@ +#yyd: +# coding: utf-8 + +# 导入Haystack搜索查询集 +from haystack.query import SearchQuerySet + +# 导入博客模型 +from blog.models import Article, Category + + +class BlogApi: + """ + 博客API类 + 提供博客文章的搜索、分类获取和最近文章等功能 + """ + + def __init__(self): + """ + 初始化方法 + 创建搜索查询集实例并设置最大返回数量 + """ + # 创建搜索查询集实例 + self.searchqueryset = SearchQuerySet() + # 初始化空查询,准备后续搜索 + self.searchqueryset.auto_query('') + # 设置最大返回文章数量 + self.__max_takecount__ = 8 + + def search_articles(self, query): + """ + 搜索文章方法 + + Args: + query (str): 搜索关键词 + + Returns: + SearchQuerySet: 包含搜索结果的查询集,最多返回__max_takecount__条结果 + """ + # 使用Haystack进行自动搜索查询 + sqs = self.searchqueryset.auto_query(query) + # 加载所有相关对象(避免N+1查询问题) + sqs = sqs.load_all() + # 返回前__max_takecount__条结果 + return sqs[:self.__max_takecount__] + + def get_category_lists(self): + """ + 获取所有分类列表 + + Returns: + QuerySet: 包含所有分类的查询集 + """ + return Category.objects.all() + + def get_category_articles(self, categoryname): + """ + 根据分类名称获取该分类下的文章 + + Args: + categoryname (str): 分类名称 + + Returns: + QuerySet or None: 包含该分类文章的查询集(最多__max_takecount__条),如果分类不存在返回None + """ + # 根据分类名称过滤文章 + articles = Article.objects.filter(category__name=categoryname) + # 如果找到文章,返回前__max_takecount__条 + if articles: + return articles[:self.__max_takecount__] + # 没有找到文章返回None + return None + + def get_recent_articles(self): + """ + 获取最近的文章 + + Returns: + QuerySet: 包含最近文章的查询集,最多返回__max_takecount__条.1 + """ + # 获取所有文章并按时间排序(假设Article模型的默认排序是按时间倒序) + return Article.objects.all()[:self.__max_takecount__] \ No newline at end of file diff --git a/src/Django博客项目/servermanager/api/commonapi.py b/src/Django博客项目/servermanager/api/commonapi.py new file mode 100644 index 0000000..7ba0a43 --- /dev/null +++ b/src/Django博客项目/servermanager/api/commonapi.py @@ -0,0 +1,133 @@ +#yyd: +# coding: utf-8 + +# 导入必要的模块 +import logging +import os + +import openai # OpenAI API客户端 + +# 导入自定义的Django模型 +from servermanager.models import commands + +# 获取当前模块的日志记录器 +logger = logging.getLogger(__name__) + +# 配置OpenAI API密钥,从环境变量中获取 +openai.api_key = os.environ.get('OPENAI_API_KEY') +# 如果设置了HTTP代理,配置OpenAI使用代理 +if os.environ.get('HTTP_PROXY'): + openai.proxy = os.environ.get('HTTP_PROXY') + + +class ChatGPT: + """ + ChatGPT API封装类 + 提供与OpenAI GPT模型交互的静态方法 + """ + + @staticmethod + def chat(prompt): + """ + 与ChatGPT进行对话的静态方法 + + Args: + prompt (str): 用户输入的提示词 + + Returns: + str: ChatGPT的回复内容,如果出错返回错误信息 + """ + try: + # 调用OpenAI ChatCompletion API创建对话完成 + completion = openai.ChatCompletion.create( + model="gpt-3.5-turbo", # 指定使用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): + """ + 运行指定标题的命令 + + Args: + title (str): 命令标题 + + Returns: + str: 命令执行结果或帮助信息 + """ + # 使用filter和lambda函数查找标题匹配的命令(不区分大小写) + cmd = list( + filter( + lambda x: x.title.upper() == title.upper(), # 不区分大小写比较 + self.commands)) + + # 如果找到匹配的命令 + if cmd: + # 执行找到的第一个命令 + return self.__run_command__(cmd[0].command) + else: + # 未找到命令时返回帮助提示 + return "未找到相关命令,请输入helpme获得帮助。" + + def __run_command__(self, cmd): + """ + 私有方法:实际执行系统命令 + + Args: + cmd (str): 要执行的系统命令 + + Returns: + str: 命令执行结果或错误信息 + """ + try: + # 使用os.popen执行系统命令并读取输出 + res = os.popen(cmd).read() + return res + except Exception as e: + logger.error(f"命令执行错误:{e}") + return '命令执行出错!' + + + def get_help(self): + """ + 获取所有命令的帮助信息 + + Returns: + str: 格式化的命令帮助信息,包含所有命令的标题和描述1 + """ + rsp = '' + # 遍历所有命令,格式化输出 + for cmd in self.commands: + rsp += '{c}:{d}\n'.format(c=cmd.title, d=cmd.describe) + return rsp + + +# 主程序入口. +if __name__ == '__main__': + # 创建ChatGPT实例 + chatbot = ChatGPT() + # 测试提示词 + prompt = "写一篇1000字关于AI的论文" + # 调用chat方法并打印结果 + print(chatbot.chat(prompt)) \ No newline at end of file diff --git a/src/Django博客项目/servermanager/apps.py b/src/Django博客项目/servermanager/apps.py new file mode 100644 index 0000000..abbb713 --- /dev/null +++ b/src/Django博客项目/servermanager/apps.py @@ -0,0 +1,22 @@ +#yyd: +# coding: utf-8 + +# 导入 Django 的应用配置基类 +from django.apps import AppConfig + + +class ServermanagerConfig(AppConfig): + """ + Servermanager 应用配置类 + ------------------------------------- + 用于配置 servermanager 应用的元数据和初始化行为。 + + 主要功能: + 1. 定义应用的基本配置信息; + 2. 在 Django 启动时进行应用级初始化; + 3. 提供应用的可读名称和默认主键类型等可选设置。 + """ + + # 应用在项目中的 Python 路径(必填项) + # 该名称必须与 settings.INSTALLED_APPS 中一致。 + name = 'servermanager' diff --git a/src/Django博客项目/servermanager/migrations/0001_initial.py b/src/Django博客项目/servermanager/migrations/0001_initial.py new file mode 100644 index 0000000..2fa1be1 --- /dev/null +++ b/src/Django博客项目/servermanager/migrations/0001_initial.py @@ -0,0 +1,70 @@ +#yyd: +# coding: utf-8 + +# Generated by Django 4.1.7 on 2023-03-02 07:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + Django数据库迁移类 + 迁移用于创建数据库表结构,这是初始迁移(initial=True) + """ + + initial = True # 标记这是初始迁移 + + dependencies = [ + # 依赖关系列表,此迁移不依赖其他迁移 + ] + + operations = [ + # 迁移操作列表 + + # 创建命令表 + migrations.CreateModel( + name='commands', # 模型名称 + fields=[ + # 主键字段,自增BigInteger类型 + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 命令标题字段,最大长度300字符 + ('title', models.CharField(max_length=300, verbose_name='命令标题')), + # 命令字段,存储具体命令内容,最大长度2000字符 + ('command', models.CharField(max_length=2000, verbose_name='命令')), + # 命令描述字段,最大长度300字符 + ('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=[ + # 主键字段,自增BigInteger类型 + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 收件人字段,存储收件人邮箱地址,最大长度300字符 + ('emailto', models.CharField(max_length=300, verbose_name='收件人')), + # 邮件标题字段,最大长度2000字符 + ('title', models.CharField(max_length=2000, verbose_name='邮件标题')), + # 邮件内容字段,文本类型,无长度限制 + ('content', models.TextField(verbose_name='邮件内容')), + # 发送结果字段,布尔类型,默认值为False(发送失败) + ('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', # 复数形式的可读名称1 + 'ordering': ['-created_time'], # 默认按创建时间降序排列 + }, + ), + ] \ No newline at end of file diff --git a/src/Django博客项目/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py b/src/Django博客项目/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py new file mode 100644 index 0000000..38caa17 --- /dev/null +++ b/src/Django博客项目/servermanager/migrations/0002_alter_emailsendlog_options_and_more.py @@ -0,0 +1,52 @@ +#yyd: +# coding: utf-8 +# Generated by Django 4.2.5 on 2023-09-06 13:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + """ + Django数据库迁移类 + 这是一个数据模型变更的迁移文件,用于修改已有表结构 + """ + + dependencies = [ + # 依赖关系:此迁移依赖于servermanager应用的0001_initial初始迁移 + ('servermanager', '0001_initial'), + ] + + operations = [ + # 迁移操作列表 + + # 修改EmailSendLog模型的元选项 + migrations.AlterModelOptions( + name='emailsendlog', # 模型名称 + options={ + 'ordering': ['-creation_time'], # 改为按creation_time字段降序排列 + 'verbose_name': '邮件发送log', # 单数形式的可读名称 + 'verbose_name_plural': '邮件发送log', # 复数形式的可读名称 + }, + ), + + # 重命名commands模型的.created_time字段为creation_time + migrations.RenameField( + model_name='commands', # 模型名称 + old_name='created_time', # 原字段名 + new_name='creation_time', # 新字段名 + ), + + # 重命名commands模型的last_mod_time字段为last_modify_time + migrations.RenameField( + model_name='commands', # 模型名称 + old_name='last_mod_time', # 原字段名 + new_name='last_modify_time', # 新字段名 + ), + + # 重命名emailsendlog模型的created_time字段为creation_time1 + migrations.RenameField( + model_name='emailsendlog', # 模型名称 + old_name='created_time', # 原字段名 + new_name='creation_time', # 新字段名 + ), + ] \ No newline at end of file diff --git a/src/Django博客项目/servermanager/migrations/__init__.py b/src/Django博客项目/servermanager/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/servermanager/models.py b/src/Django博客项目/servermanager/models.py new file mode 100644 index 0000000..b5d70f0 --- /dev/null +++ b/src/Django博客项目/servermanager/models.py @@ -0,0 +1,85 @@ +#yyd: +# coding: utf-8 + +# 导入Django的模型模块 +from django.db import models + + +# 创建命令模型(数据库表) +class commands(models.Model): + """ + 命令模型类 + 用于存储系统命令的相关信息 + """ + + # 命令标题字段,CharField类型,最大长度300,在admin中显示为'命令标题' + title = models.CharField('命令标题', max_length=300) + + # 命令内容字段,CharField类型,最大长度2000,在admin中显示为'命令' + command = models.CharField('命令', max_length=2000) + + # 命令描述字段,CharField类型,最大长度300,在admin中显示为'命令描述' + describe = models.CharField('命令描述', max_length=300) + + # 创建时间字段,DateTimeField类型,自动设置为对象创建时的时间 + creation_time = models.DateTimeField('创建时间', auto_now_add=True) + + # 最后修改时间字段,DateTimeField类型,自动更新为对象最后修改的时间 + last_modify_time = models.DateTimeField('修改时间', auto_now=True) + + def __str__(self): + """ + 定义对象的字符串表示形式 + 在Django admin和shell中显示时使用 + + Returns: + str: 命令标题 + """ + return self.title + + class Meta: + """ + 模型的元数据配置类 + 用于定义模型的其他选项 + """ + verbose_name = '命令' # 单数形式的可读名称 + verbose_name_plural = verbose_name # 复数形式的可读名称(与单数相同) + + +class EmailSendLog(models.Model): + """ + 邮件发送日志模型类 + 用于记录邮件发送的历史记录和结果 + """ + + # 收件人字段,CharField类型,最大长度300,在admin中显示为'收件人' + emailto = models.CharField('收件人', max_length=300) + + # 邮件标题字段,CharField类型,最大长度2000,在admin中显示为'邮件标题' + title = models.CharField('邮件标题', max_length=2000) + + # 邮件内容字段,TextField类型,无长度限制,在admin中显示为'邮件内容' + content = models.TextField('邮件内容') + + # 发送结果字段,BooleanField类型,默认值为False(发送失败),在admin中显示为'结果' + send_result = models.BooleanField('结果', default=False) + + # 创建时间字段,DateTimeField类型,自动设置为对象创建时的时间 + creation_time = models.DateTimeField('创建时间', auto_now_add=True) + + def __str__(self): + """ + 定义对象的字符串表示形式 + + Returns: + str: 邮件标题 + """ + return self.title + + class Meta: + """ + 模型的元数据配置类 + """ + verbose_name = '邮件发送log' # 单数形式的可读名称. + verbose_name_plural = verbose_name # 复数形式的可读名称 + ordering = ['-creation_time'] # 默认按创建时间降序排列(最新的在前)1 \ No newline at end of file diff --git a/src/Django博客项目/servermanager/robot.py b/src/Django博客项目/servermanager/robot.py new file mode 100644 index 0000000..878e84e --- /dev/null +++ b/src/Django博客项目/servermanager/robot.py @@ -0,0 +1,288 @@ +#yyd: +# coding: utf-8 + +# 导入必要的模块 +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 # 博客API +from servermanager.api.commonapi import ChatGPT, CommandHandler # 聊天和命令处理 +from .MemcacheStorage import MemcacheStorage # 自定义缓存存储 + +# 初始化微信机器人,从环境变量获取token,默认使用'lylinux' +robot = WeRoBot(token=os.environ.get('DJANGO_WEROBOT_TOKEN') or 'lylinux', enable_session=True) + +# 初始化Memcached会话存储 +memstorage = MemcacheStorage() +# 检查Memcached是否可用,如果可用则使用,否则使用文件存储 +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') + +# 初始化API处理器 +blogapi = BlogApi() +cmd_handler = CommandHandler() +logger = logging.getLogger(__name__) + + +def convert_to_article_reply(articles, message): + """ + 将文章列表转换为微信图文回复格式 + + Args: + articles: 文章对象列表 + message: 微信消息对象 + + Returns: + ArticlesReply: 图文回复对象 + """ + 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 + url=post.get_full_url() # 文章完整URL + ) + reply.add_article(article) + return reply + + +@robot.filter(re.compile(r"^\?.*")) +def search(message, session): + """ + 文章搜索功能 + 触发方式: ?关键字 + 例如: ?python + """ + s = message.content + searchstr = str(s).replace('?', '') # 移除问号获取搜索关键词 + result = blogapi.search_articles(searchstr) # 调用搜索API + + if result: + # 将搜索结果转换为文章对象列表 + articles = [x.object for x in 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): + """ + 获取所有文章分类 + 触发方式:category + """ + categorys = blogapi.get_category_lists() + content = ','.join([x.name for x in categorys]) + return '所有文章分类目录: ' + content + + + +@robot.filter(re.compile(r'^recent\s*$', re.I)) +def recents(message, session): + """ + 获取最新文章 + 触发方式: recent + """ + 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): + """ + 获取帮助信息 + 触发方式: help + """ + 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: + # 如果会话中没有用户信息,创建新的用户信息对象 + 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.strip().upper() + + if self._handle_exit(info): + return "退出成功" + + if self._handle_admin_entry(info): + return "输入管理员密码" + + if self._needs_password_verification(): + return self._handle_password_verify(info) + + if self._can_execute_admin_command(): + return self._handle_admin_command(info) + + return self._handle_default_chat(info) + +# ------------------ 以下为子逻辑模块 ------------------ + +def _handle_exit(self, info: str) -> bool: + """管理员退出命令""" + if self.userinfo.isAdmin and info == 'EXIT': + self.userinfo = WxUserInfo() + self.save_session() + return True + return False + +def _handle_admin_entry(self, info: str) -> bool: + """进入管理员模式""" + if info == 'ADMIN': + self.userinfo.isAdmin = True + self.save_session() + return True + return False + +def _needs_password_verification(self) -> bool: + """判断是否需要进行密码验证""" + return self.userinfo.isAdmin and not self.userinfo.isPasswordSet + +def _handle_password_verify(self, info: str) -> str: + """管理员密码验证""" + 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获得帮助" + + # 密码错误次数限制 + if self.userinfo.Count >= 3: + self.userinfo = WxUserInfo() + self.save_session() + return "超过验证次数" + + self.userinfo.Count += 1 + self.save_session() + return "验证失败,请重新输入管理员密码:" + +def _can_execute_admin_command(self) -> bool: + """是否处于可执行管理员命令状态""" + return self.userinfo.isAdmin and self.userinfo.isPasswordSet + +def _handle_admin_command(self, info: str) -> str: + """管理员命令执行""" + if self.userinfo.Command and info == 'Y': + return cmd_handler.run(self.userinfo.Command) + + if info == 'HELPME': + return cmd_handler.get_help() + + self.userinfo.Command = info + self.save_session() + return f"确认执行: {info} 命令?" + +def _handle_default_chat(self, info: str) -> str: + """普通模式下使用 ChatGPT 回复""" + return ChatGPT.chat(info) + + +class WxUserInfo(): + """微信用户信息类,用于存储用户会话状态""" + + def __init__(self): + self.is_admin = False # 是否为管理员. + self.is_password_set = False # 是否通过密码验证1 + self.count = 0 # 密码尝试次数 + self.command = '' # 待确认的命令 \ No newline at end of file diff --git a/src/Django博客项目/servermanager/tests.py b/src/Django博客项目/servermanager/tests.py new file mode 100644 index 0000000..c42d9fe --- /dev/null +++ b/src/Django博客项目/servermanager/tests.py @@ -0,0 +1,119 @@ +#yyd: +# coding: utf-8 + +# 导入Django测试相关模块 +from django.test import Client, RequestFactory, TestCase +from django.utils import timezone +# 导入WeRoBot文本消息类 +from werobot.messages.messages import TextMessage + +# 导入应用模型 +from accounts.models import BlogUser +from blog.models import Category, Article +# 导入要测试的API类 +from servermanager.api.commonapi import ChatGPT +from .models import commands +# 导入要测试的机器人处理类和方法 +from .robot import MessageHandler, CommandHandler +from .robot import search, category, recents + + +# 创建服务器管理器测试类 +class ServerManagerTest(TestCase): + """ + 服务器管理器功能测试类 + 测试servermanager应用的各项功能 + """ + + def setUp(self): + """ + 测试初始化方法 + 在每个测试方法执行前运行,用于设置测试环境 + """ + # 创建测试客户端,用于模拟HTTP请求 + self.client = Client() + # 创建请求工厂,用于构建请求对象 + self.factory = RequestFactory() + + def test_chat_gpt(self): + """ + 测试ChatGPT功能 + 验证ChatGPT API是否能正常返回响应 + """ + 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 = category(None, None) # 调用分类函数 + self.assertIsNotNone(rsp) # 断言响应不为空 + + # 测试最近文章功能 + rsp = recents(None, None) # 调用最近文章函数 + self.assertTrue(rsp != '暂时还没有文章') # 断言有文章返回 + + # 创建测试命令 + cmd = commands() + cmd.title = "test" + cmd.command = "ls" # Linux列表命令 + cmd.describe = "test" + cmd.save() + + # 测试命令处理器 + cmdhandler = CommandHandler() + rsp = cmdhandler.run('test') # 执行测试命令 + self.assertIsNotNone(rsp) # 断言命令执行结果不为空 + + # 测试消息处理器 + s.source = 'u' # 设置消息来源 + s.content = 'test' # 设置消息内容 + msghandler = MessageHandler(s, {}) # 创建消息处理器 + + + # 测试各种消息处理场景 + msghandler.handler() # 处理'test'消息 + 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' # 退出管理员模式.1 + msghandler.handler() \ No newline at end of file diff --git a/src/Django博客项目/servermanager/urls.py b/src/Django博客项目/servermanager/urls.py new file mode 100644 index 0000000..8ffc494 --- /dev/null +++ b/src/Django博客项目/servermanager/urls.py @@ -0,0 +1,25 @@ +#yyd: +# coding: utf-8 + +# 导入Django的URL路由模块 +from django.urls import path +# 导入WeRoBot的Django视图创建工具 +from werobot.contrib.django import make_view + +# 从当前目录的robot模块导入robot实例 +from .robot import robot + +# 定义应用的命名空间,用于URL反向解析 +app_name = "servermanager" + +# 定义URL模式列表 +urlpatterns = [ + # 微信机器人消息接收接口 + # 路径: /servermanager/robot + # 使用WeRoBot的make_view将robot实例转换为Django视图 + path(r'robot', make_view(robot)), + # 说明: + # - r'robot': URL路径模式,匹配"robot"路径 + # - make_view(robot): 将WeRoBot机器人实例包装成Django视图函数 + # - 这个端点用于接收微信服务器发送的消息和事件.1 +] \ No newline at end of file diff --git a/src/Django博客项目/servermanager/views.py b/src/Django博客项目/servermanager/views.py new file mode 100644 index 0000000..c84b472 --- /dev/null +++ b/src/Django博客项目/servermanager/views.py @@ -0,0 +1 @@ +# Create your views here..1 diff --git a/src/Django博客项目/templates/account/forget_password.html b/src/Django博客项目/templates/account/forget_password.html new file mode 100644 index 0000000..112545d --- /dev/null +++ b/src/Django博客项目/templates/account/forget_password.html @@ -0,0 +1,35 @@ +//这个文件用于实现"忘记密码"功能页面 +{% 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/Django博客项目/templates/account/login.html b/src/Django博客项目/templates/account/login.html new file mode 100644 index 0000000..d1677cd --- /dev/null +++ b/src/Django博客项目/templates/account/login.html @@ -0,0 +1,52 @@ +//这个文件是用于实现用户登录页面 +{% 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/Django博客项目/templates/account/registration_form.html b/src/Django博客项目/templates/account/registration_form.html new file mode 100644 index 0000000..65e7549 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/templates/account/result.html b/src/Django博客项目/templates/account/result.html new file mode 100644 index 0000000..23c9094 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/templates/blog/archive.html b/src/Django博客项目/templates/blog/archive.html new file mode 100644 index 0000000..6e220ec --- /dev/null +++ b/src/Django博客项目/templates/blog/archive.html @@ -0,0 +1,95 @@ + +{% extends "blog/base.html" %} + +{% block title %}文章归档 - {{ block.super }}{% endblock %} + +{% block content %} +
    +

    📚 文章归档

    +

    共 {{ articles.count }} 篇文章

    + + {% if articles %} + +
    +

    所有文章

    +
      + {% for article in articles %} +
    • + + + {{ article.title }} + + 👁 {{ article.views }} 浏览 +
    • + {% endfor %} +
    +
    + {% else %} +
    +

    暂无文章

    +
    + {% endif %} +
    + + +{% endblock %} \ No newline at end of file diff --git a/src/Django博客项目/templates/blog/article_archives.html b/src/Django博客项目/templates/blog/article_archives.html new file mode 100644 index 0000000..959319e --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/templates/blog/article_detail.html b/src/Django博客项目/templates/blog/article_detail.html new file mode 100644 index 0000000..bf21fe0 --- /dev/null +++ b/src/Django博客项目/templates/blog/article_detail.html @@ -0,0 +1,53 @@ +文章详情页 +{% extends 'share_layout/base.html' %} +{% load blog_tags %} + +{% block header %} +{% endblock %} +{% block content %} +
    +
    + {% load_article_detail article False user %} + {% 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 %} + diff --git a/src/Django博客项目/templates/blog/tags/article_meta_info.html b/src/Django博客项目/templates/blog/tags/article_meta_info.html new file mode 100644 index 0000000..cb6111c --- /dev/null +++ b/src/Django博客项目/templates/blog/tags/article_meta_info.html @@ -0,0 +1,59 @@ +{% load i18n %} +{% load blog_tags %} + + + + + diff --git a/src/Django博客项目/templates/blog/tags/article_pagination.html b/src/Django博客项目/templates/blog/tags/article_pagination.html new file mode 100644 index 0000000..95514ff --- /dev/null +++ b/src/Django博客项目/templates/blog/tags/article_pagination.html @@ -0,0 +1,17 @@ +{% load i18n %} + \ No newline at end of file diff --git a/src/Django博客项目/templates/blog/tags/article_tag_list.html b/src/Django博客项目/templates/blog/tags/article_tag_list.html new file mode 100644 index 0000000..c8ba474 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/templates/blog/tags/breadcrumb.html b/src/Django博客项目/templates/blog/tags/breadcrumb.html new file mode 100644 index 0000000..67087d5 --- /dev/null +++ b/src/Django博客项目/templates/blog/tags/breadcrumb.html @@ -0,0 +1,19 @@ + + diff --git a/src/Django博客项目/templates/blog/tags/sidebar.html b/src/Django博客项目/templates/blog/tags/sidebar.html new file mode 100644 index 0000000..58025a6 --- /dev/null +++ b/src/Django博客项目/templates/blog/tags/sidebar.html @@ -0,0 +1,131 @@ +{% load blog_tags %} +{% load i18n %} + diff --git a/src/Django博客项目/templates/comments/tags/comment_item.html b/src/Django博客项目/templates/comments/tags/comment_item.html new file mode 100644 index 0000000..37e1bc2 --- /dev/null +++ b/src/Django博客项目/templates/comments/tags/comment_item.html @@ -0,0 +1,56 @@ +{% load blog_tags %} +
  • +
    + + + +

    {{ comment_item.body|escape|comment_markdown }}

    + + {% comment %} +
    + + +
    + + + + {% endcomment %} + +
  • \ No newline at end of file diff --git a/src/Django博客项目/templates/comments/tags/comment_item_tree.html b/src/Django博客项目/templates/comments/tags/comment_item_tree.html new file mode 100644 index 0000000..f3c792d --- /dev/null +++ b/src/Django博客项目/templates/comments/tags/comment_item_tree.html @@ -0,0 +1,74 @@ + +{% load blog_tags %} +
  • +
    + + + +

    + {% if comment_item.parent_comment %} +

    + {% endif %} +

    + +

    {{ comment_item.body|escape|comment_markdown }}

    + + +
    +
    + + + +
    + +
    + +
    + +
  • +{% 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 %} + diff --git a/src/Django博客项目/templates/comments/tags/comment_list.html b/src/Django博客项目/templates/comments/tags/comment_list.html new file mode 100644 index 0000000..fff71e8 --- /dev/null +++ b/src/Django博客项目/templates/comments/tags/comment_list.html @@ -0,0 +1,47 @@ + +{% load static %} + +
    + {% load blog_tags %} + {% load comments_tags %} + {% load cache %} + + + {% 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 %} +
    + +
    + diff --git a/src/Django博客项目/templates/comments/tags/post_comment.html b/src/Django博客项目/templates/comments/tags/post_comment.html new file mode 100644 index 0000000..3ae5a27 --- /dev/null +++ b/src/Django博客项目/templates/comments/tags/post_comment.html @@ -0,0 +1,33 @@ +
    + +
    +

    发表评论 + +

    +
    {% csrf_token %} +

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

    + {{ form.parent_comment_id }} +
    + {% if COMMENT_NEED_REVIEW %} + 支持markdown,评论经审核后才会显示。 + {% else %} + 支持markdown。 + {% endif %} + + +
    +
    +
    + +
    + + diff --git a/src/Django博客项目/templates/oauth/bindsuccess.html b/src/Django博客项目/templates/oauth/bindsuccess.html new file mode 100644 index 0000000..4bee77c --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/templates/oauth/oauth_applications.html b/src/Django博客项目/templates/oauth/oauth_applications.html new file mode 100644 index 0000000..a841ad2 --- /dev/null +++ b/src/Django博客项目/templates/oauth/oauth_applications.html @@ -0,0 +1,13 @@ +{% load i18n %} + diff --git a/src/Django博客项目/templates/oauth/require_email.html b/src/Django博客项目/templates/oauth/require_email.html new file mode 100644 index 0000000..3adef12 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/templates/owntracks/show_log_dates.html b/src/Django博客项目/templates/owntracks/show_log_dates.html new file mode 100644 index 0000000..7dbba21 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/templates/owntracks/show_maps.html b/src/Django博客项目/templates/owntracks/show_maps.html new file mode 100644 index 0000000..3aeda36 --- /dev/null +++ b/src/Django博客项目/templates/owntracks/show_maps.html @@ -0,0 +1,135 @@ + + + + + + + 运动轨迹 + + + +
    + + + + + + + + \ No newline at end of file diff --git a/src/Django博客项目/templates/search/indexes/blog/article_text.txt b/src/Django博客项目/templates/search/indexes/blog/article_text.txt new file mode 100644 index 0000000..4f9ca76 --- /dev/null +++ b/src/Django博客项目/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/Django博客项目/templates/search/search.html b/src/Django博客项目/templates/search/search.html new file mode 100644 index 0000000..797d41a --- /dev/null +++ b/src/Django博客项目/templates/search/search.html @@ -0,0 +1,65 @@ +{% 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 True user %} + {% endfor %} + {% if page.has_previous or page.has_next %} + + + {% endif %} + {% else %} +
    + +

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

    +
    + {% endif %} +
    +
    +{% endblock %} + + + + + + diff --git a/src/Django博客项目/templates/share_layout/adsense.html b/src/Django博客项目/templates/share_layout/adsense.html new file mode 100644 index 0000000..8f99c55 --- /dev/null +++ b/src/Django博客项目/templates/share_layout/adsense.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/src/Django博客项目/templates/share_layout/base.html b/src/Django博客项目/templates/share_layout/base.html new file mode 100644 index 0000000..75d0df5 --- /dev/null +++ b/src/Django博客项目/templates/share_layout/base.html @@ -0,0 +1,123 @@ +{% 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/Django博客项目/templates/share_layout/base_account.html b/src/Django博客项目/templates/share_layout/base_account.html new file mode 100644 index 0000000..12a6a5e --- /dev/null +++ b/src/Django博客项目/templates/share_layout/base_account.html @@ -0,0 +1,49 @@ + + + + {% load static %} + + + + + + + + + {{ SITE_NAME }} | {{ SITE_DESCRIPTION }} + + {% load compress %} + {% compress css %} + + + + + + + + + + {% endcompress %} + {% compress js %} + + + {% endcompress %} + + + + + +{% block content %} +{% endblock %} + + + + + + + + + \ No newline at end of file diff --git a/src/Django博客项目/templates/share_layout/footer.html b/src/Django博客项目/templates/share_layout/footer.html new file mode 100644 index 0000000..cd86a29 --- /dev/null +++ b/src/Django博客项目/templates/share_layout/footer.html @@ -0,0 +1,56 @@ + + + diff --git a/src/Django博客项目/templates/share_layout/nav.html b/src/Django博客项目/templates/share_layout/nav.html new file mode 100644 index 0000000..79da041 --- /dev/null +++ b/src/Django博客项目/templates/share_layout/nav.html @@ -0,0 +1,41 @@ +{% load i18n %} + + diff --git a/src/Django博客项目/templates/share_layout/nav_node.html b/src/Django博客项目/templates/share_layout/nav_node.html new file mode 100644 index 0000000..c266880 --- /dev/null +++ b/src/Django博客项目/templates/share_layout/nav_node.html @@ -0,0 +1,19 @@ + + + diff --git a/src/Django博客项目/test_db.py b/src/Django博客项目/test_db.py new file mode 100644 index 0000000..dfc22fe --- /dev/null +++ b/src/Django博客项目/test_db.py @@ -0,0 +1,14 @@ +# test_db.py +import pymysql +pymysql.install_as_MySQLdb() + +try: + import MySQLdb + print("✅ MySQLdb 导入成功!") + + # 测试版本 + from MySQLdb import version_info + print(f"✅ 版本信息: {version_info}") + +except Exception as e: + print(f"❌ 错误: {e}") \ No newline at end of file diff --git a/src/Django博客项目/utils/jieba_analyzer.py b/src/Django博客项目/utils/jieba_analyzer.py new file mode 100644 index 0000000..d3baf28 --- /dev/null +++ b/src/Django博客项目/utils/jieba_analyzer.py @@ -0,0 +1,19 @@ +# utils/jieba_analyzer.py +from whoosh.analysis import Tokenizer, Token +import jieba + + +class JiebaTokenizer(Tokenizer): + def __call__(self, value, positions=False, chars=False, keeporiginal=False, + removestops=True, start_pos=0, start_char=0, mode='', **kwargs): + t = Token(positions, chars, removestops=removestops, mode=mode, **kwargs) + seglist = jieba.cut(value, cut_all=False) + for w in seglist: + t.original = t.text = w + t.boost = 1.0 + yield t + + +def jieba_analyzer(): + """返回 Jieba 分词器""" + return JiebaTokenizer() diff --git a/src/Django博客项目/whoosh_index/MAIN_WRITELOCK b/src/Django博客项目/whoosh_index/MAIN_WRITELOCK new file mode 100644 index 0000000..e69de29 diff --git a/src/Django博客项目/whoosh_index/MAIN_rmyld3kmvhmi0csm.seg b/src/Django博客项目/whoosh_index/MAIN_rmyld3kmvhmi0csm.seg new file mode 100644 index 0000000..b1949cd Binary files /dev/null and b/src/Django博客项目/whoosh_index/MAIN_rmyld3kmvhmi0csm.seg differ diff --git a/src/Django博客项目/whoosh_index/_MAIN_1.toc b/src/Django博客项目/whoosh_index/_MAIN_1.toc new file mode 100644 index 0000000..c1877d5 Binary files /dev/null and b/src/Django博客项目/whoosh_index/_MAIN_1.toc differ