diff --git a/src/DjangoBlog-master/.dockerignore b/src/DjangoBlog-master/.dockerignore
new file mode 100644
index 0000000..2818c38
--- /dev/null
+++ b/src/DjangoBlog-master/.dockerignore
@@ -0,0 +1,11 @@
+bin/data/
+# virtualenv
+venv/
+collectedstatic/
+djangoblog/whoosh_index/
+uploads/
+settings_production.py
+*.md
+docs/
+logs/
+static/
\ No newline at end of file
diff --git a/src/DjangoBlog-master/.gitattributes b/src/DjangoBlog-master/.gitattributes
new file mode 100644
index 0000000..fd52ece
--- /dev/null
+++ b/src/DjangoBlog-master/.gitattributes
@@ -0,0 +1,6 @@
+blog/static/* linguist-vendored
+*.js linguist-vendored
+*.css linguist-vendored
+* text=auto
+*.sh text eol=lf
+*.conf text eol=lf
\ No newline at end of file
diff --git a/src/DjangoBlog-master/.github/ISSUE_TEMPLATE.md b/src/DjangoBlog-master/.github/ISSUE_TEMPLATE.md
new file mode 100644
index 0000000..2b5b7aa
--- /dev/null
+++ b/src/DjangoBlog-master/.github/ISSUE_TEMPLATE.md
@@ -0,0 +1,18 @@
+
+
+**我确定我已经查看了** (标注`[ ]`为`[x]`)
+
+- [ ] [DjangoBlog的readme](https://github.com/liangliangyy/DjangoBlog/blob/master/README.md)
+- [ ] [配置说明](https://github.com/liangliangyy/DjangoBlog/blob/master/bin/config.md)
+- [ ] [其他 Issues](https://github.com/liangliangyy/DjangoBlog/issues)
+
+----
+
+**我要申请** (标注`[ ]`为`[x]`)
+
+- [ ] BUG 反馈
+- [ ] 添加新的特性或者功能
+- [ ] 请求技术支持
diff --git a/src/DjangoBlog-master/.github/workflows/codeql-analysis.yml b/src/DjangoBlog-master/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..52775e0
--- /dev/null
+++ b/src/DjangoBlog-master/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,49 @@
+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@v3
+ with:
+ languages: python
+
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v3
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
\ No newline at end of file
diff --git a/src/DjangoBlog-master/.github/workflows/django.yml b/src/DjangoBlog-master/.github/workflows/django.yml
new file mode 100644
index 0000000..5ab0423
--- /dev/null
+++ b/src/DjangoBlog-master/.github/workflows/django.yml
@@ -0,0 +1,153 @@
+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@v4
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ 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@v4
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ 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@v4
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ file: ./coverage.xml
+ flags: unittests
+ name: codecov-umbrella
+ fail_ci_if_error: false
+ verbose: true
+
+ - name: Upload coverage to Codecov (fallback)
+ if: failure()
+ uses: codecov/codecov-action@v4
+ with:
+ file: ./coverage.xml
+ flags: unittests
+ name: codecov-umbrella-fallback
+ fail_ci_if_error: false
+ verbose: true
+
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build and push
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: false
+ tags: djangoblog/djangoblog:dev
diff --git a/src/DjangoBlog-master/.github/workflows/docker.yml b/src/DjangoBlog-master/.github/workflows/docker.yml
new file mode 100644
index 0000000..904fef5
--- /dev/null
+++ b/src/DjangoBlog-master/.github/workflows/docker.yml
@@ -0,0 +1,43 @@
+name: docker
+
+on:
+ push:
+ paths-ignore:
+ - '**/*.md'
+ - '**/*.yml'
+ branches:
+ - 'master'
+ - 'dev'
+
+jobs:
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set env to docker dev tag
+ if: endsWith(github.ref, '/dev')
+ run: |
+ echo "DOCKER_TAG=test" >> $GITHUB_ENV
+ - name: Set env to docker latest tag
+ if: endsWith(github.ref, '/master')
+ run: |
+ echo "DOCKER_TAG=latest" >> $GITHUB_ENV
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to DockerHub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Build and push
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: true
+ tags: ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:${{env.DOCKER_TAG}}
+
+
diff --git a/src/DjangoBlog-master/.github/workflows/publish-release.yml b/src/DjangoBlog-master/.github/workflows/publish-release.yml
new file mode 100644
index 0000000..5eb0853
--- /dev/null
+++ b/src/DjangoBlog-master/.github/workflows/publish-release.yml
@@ -0,0 +1,39 @@
+name: publish release
+
+on:
+ release:
+ types: [ published ]
+
+jobs:
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Docker meta
+ id: meta
+ uses: docker/metadata-action@v3
+ with:
+ images: name/app
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v2
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v2
+ - name: Login to DockerHub
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Build and push
+ uses: docker/build-push-action@v3
+ with:
+ context: .
+ push: true
+ platforms: |
+ linux/amd64
+ linux/arm64
+ linux/arm/v7
+ linux/arm/v6
+ linux/386
+ tags: ${{ secrets.DOCKERHUB_USERNAME }}/djangoblog:${{ github.event.release.tag_name }}
diff --git a/src/DjangoBlog-master/.gitignore b/src/DjangoBlog-master/.gitignore
new file mode 100644
index 0000000..3015816
--- /dev/null
+++ b/src/DjangoBlog-master/.gitignore
@@ -0,0 +1,80 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+
+# Translations
+*.pot
+
+# Django stuff:
+*.log
+logs/
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+
+# PyCharm
+# http://www.jetbrains.com/pycharm/webhelp/project.html
+.idea
+.iml
+static/
+# virtualenv
+venv/
+
+collectedstatic/
+djangoblog/whoosh_index/
+google93fd32dbd906620a.html
+baidu_verify_FlHL7cUyC9.html
+BingSiteAuth.xml
+cb9339dbe2ff86a5aa169d28dba5f615.txt
+werobot_session.*
+django.jpg
+uploads/
+settings_production.py
+werobot_session.db
+bin/datas/
diff --git a/src/DjangoBlog-master/DjangoBlog b/src/DjangoBlog-master/DjangoBlog
new file mode 160000
index 0000000..e02cb67
--- /dev/null
+++ b/src/DjangoBlog-master/DjangoBlog
@@ -0,0 +1 @@
+Subproject commit e02cb6723a0ecb5c2e5eb8da42b2589389ed92b5
diff --git a/src/DjangoBlog-master/Dockerfile b/src/DjangoBlog-master/Dockerfile
new file mode 100644
index 0000000..80b46ac
--- /dev/null
+++ b/src/DjangoBlog-master/Dockerfile
@@ -0,0 +1,15 @@
+FROM python:3.11
+ENV PYTHONUNBUFFERED 1
+WORKDIR /code/djangoblog/
+RUN apt-get update && \
+ apt-get install default-libmysqlclient-dev gettext -y && \
+ rm -rf /var/lib/apt/lists/*
+ADD requirements.txt requirements.txt
+RUN pip install --upgrade pip && \
+ pip install --no-cache-dir -r requirements.txt && \
+ pip install --no-cache-dir gunicorn[gevent] && \
+ pip cache purge
+
+ADD . .
+RUN chmod +x /code/djangoblog/deploy/entrypoint.sh
+ENTRYPOINT ["/code/djangoblog/deploy/entrypoint.sh"]
diff --git a/src/DjangoBlog-master/LICENSE b/src/DjangoBlog-master/LICENSE
new file mode 100644
index 0000000..3b08474
--- /dev/null
+++ b/src/DjangoBlog-master/LICENSE
@@ -0,0 +1,20 @@
+The MIT License (MIT)
+
+Copyright (c) 2025 车亮亮
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/src/DjangoBlog-master/README.md b/src/DjangoBlog-master/README.md
new file mode 100644
index 0000000..56aa4cc
--- /dev/null
+++ b/src/DjangoBlog-master/README.md
@@ -0,0 +1,158 @@
+# DjangoBlog
+
+
+
+
+
+
+
+
+
+ 一款功能强大、设计优雅的现代化博客系统
+
+ English • 简体中文
+
+
+---
+
+DjangoBlog 是一款基于 Python 3.10 和 Django 4.0 构建的高性能博客平台。它不仅提供了传统博客的所有核心功能,还通过一个灵活的插件系统,让您可以轻松扩展和定制您的网站。无论您是个人博主、技术爱好者还是内容创作者,DjangoBlog 都旨在为您提供一个稳定、高效且易于维护的写作和发布环境。
+
+## ✨ 特性亮点
+
+- **强大的内容管理**: 支持文章、独立页面、分类和标签的完整管理。内置强大的 Markdown 编辑器,支持代码语法高亮。
+- **全文搜索**: 集成搜索引擎,提供快速、精准的文章内容搜索。
+- **互动评论系统**: 支持回复、邮件提醒等功能,评论内容同样支持 Markdown。
+- **灵活的侧边栏**: 可自定义展示最新文章、最多阅读、标签云等模块。
+- **社交化登录**: 内置 OAuth 支持,已集成 Google, GitHub, Facebook, 微博, QQ 等主流平台。
+- **高性能缓存**: 原生支持 Redis 缓存,并提供自动刷新机制,确保网站高速响应。
+- **SEO 友好**: 具备基础 SEO 功能,新内容发布后可自动通知 Google 和百度。
+- **便捷的插件系统**: 通过创建独立的插件来扩展博客功能,代码解耦,易于维护。我们已经通过插件实现了文章浏览计数、SEO 优化等功能!
+- **集成图床**: 内置简单的图床功能,方便图片上传和管理。
+- **自动化前端**: 集成 `django-compressor`,自动压缩和优化 CSS 及 JavaScript 文件。
+- **健壮的运维**: 内置网站异常邮件提醒和微信公众号管理功能。
+
+## 🛠️ 技术栈
+
+- **后端**: Python 3.10, Django 4.0
+- **数据库**: MySQL, SQLite (可配置)
+- **缓存**: Redis
+- **前端**: HTML5, CSS3, JavaScript
+- **搜索**: Whoosh, Elasticsearch (可配置)
+- **编辑器**: Markdown (mdeditor)
+
+## 🚀 快速开始
+
+### 1. 环境准备
+
+确保您的系统中已安装 Python 3.10+ 和 MySQL/MariaDB。
+
+### 2. 克隆与安装
+
+```bash
+# 克隆项目到本地
+git clone https://github.com/liangliangyy/DjangoBlog.git
+cd DjangoBlog
+
+# 安装依赖
+pip install -r requirements.txt
+```
+
+### 3. 项目配置
+
+- **数据库**:
+ 打开 `djangoblog/settings.py` 文件,找到 `DATABASES` 配置项,修改为您的 MySQL 连接信息。
+
+ ```python
+ DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.mysql',
+ 'NAME': 'djangoblog',
+ 'USER': 'root',
+ 'PASSWORD': 'your_password',
+ 'HOST': '127.0.0.1',
+ 'PORT': 3306,
+ }
+ }
+ ```
+ 在 MySQL 中创建数据库:
+ ```sql
+ CREATE DATABASE `djangoblog` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+ ```
+
+- **更多配置**:
+ 关于邮件发送、OAuth 登录、缓存等更多高级配置,请参阅我们的 [详细配置文档](/docs/config.md)。
+
+### 4. 初始化数据库
+
+```bash
+python manage.py makemigrations
+python manage.py migrate
+
+# 创建一个超级管理员账户
+python manage.py createsuperuser
+```
+
+### 5. 运行项目
+
+```bash
+# (可选) 生成一些测试数据
+python manage.py create_testdata
+
+# (可选) 收集和压缩静态文件
+python manage.py collectstatic --noinput
+python manage.py compress --force
+
+# 启动开发服务器
+python manage.py runserver
+```
+
+现在,在您的浏览器中访问 `http://127.0.0.1:8000/`,您应该能看到 DjangoBlog 的首页了!
+
+## 部署
+
+- **传统部署**: 我们为您准备了非常详细的 [服务器部署教程](https://www.lylinux.net/article/2019/8/5/58.html)。
+- **Docker 部署**: 项目已全面支持 Docker。如果您熟悉容器化技术,请参考 [Docker 部署文档](/docs/docker.md) 来快速启动。
+- **Kubernetes 部署**: 我们也提供了完整的 [Kubernetes 部署指南](/docs/k8s.md),助您轻松上云。
+
+## 🧩 插件系统
+
+插件系统是 DjangoBlog 的核心特色之一。它允许您在不修改核心代码的情况下,通过编写独立的插件来为您的博客添加新功能。
+
+- **工作原理**: 插件通过在预定义的“钩子”上注册回调函数来工作。例如,当一篇文章被渲染时,`after_article_body_get` 钩子会被触发,所有注册到此钩子的函数都会被执行。
+- **现有插件**: `view_count`(浏览计数), `seo_optimizer`(SEO优化)等都是通过插件系统实现的。
+- **开发您自己的插件**: 只需在 `plugins` 目录下创建一个新的文件夹,并编写您的 `plugin.py`。欢迎探索并为 DjangoBlog 社区贡献您的创意!
+
+## 🤝 贡献指南
+
+我们热烈欢迎任何形式的贡献!如果您有好的想法或发现了 Bug,请随时提交 Issue 或 Pull Request。
+
+## 📄 许可证
+
+本项目基于 [MIT License](LICENSE) 开源。
+
+---
+
+## ❤️ 支持与赞助
+
+如果您觉得这个项目对您有帮助,并且希望支持我继续维护和开发新功能,欢迎请我喝杯咖啡!您的每一份支持都是我前进的最大动力。
+
+
+
+
+
+
+ (左) 支付宝 / (右) 微信
+
+
+## 🙏 鸣谢
+
+特别感谢 **JetBrains** 为本项目提供的免费开源许可证。
+
+
+
+
+
+
+
+---
+> 如果本项目帮助到了你,请在[这里](https://github.com/liangliangyy/DjangoBlog/issues/214)留下你的网址,让更多的人看到。您的回复将会是我继续更新维护下去的动力。
diff --git a/src/DjangoBlog-master/accounts/__init__.py b/src/DjangoBlog-master/accounts/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/DjangoBlog-master/accounts/admin.py b/src/DjangoBlog-master/accounts/admin.py
new file mode 100644
index 0000000..29d162a
--- /dev/null
+++ b/src/DjangoBlog-master/accounts/admin.py
@@ -0,0 +1,60 @@
+from django import forms
+from django.contrib.auth.admin import UserAdmin
+from django.contrib.auth.forms import UserChangeForm
+from django.contrib.auth.forms import UsernameField
+from django.utils.translation import gettext_lazy as _
+
+# Register your models here.
+from .models import BlogUser
+
+
+class BlogUserCreationForm(forms.ModelForm):
+ password1 = forms.CharField(label=_('password'), widget=forms.PasswordInput)
+ password2 = forms.CharField(label=_('Enter password again'), widget=forms.PasswordInput)
+
+ class Meta:
+ model = BlogUser
+ fields = ('email',)
+
+ def clean_password2(self):
+ # Check that the two password entries match
+ password1 = self.cleaned_data.get("password1")
+ password2 = self.cleaned_data.get("password2")
+ if password1 and password2 and password1 != password2:
+ raise forms.ValidationError(_("passwords do not match"))
+ return password2
+
+ def save(self, commit=True):
+ # Save the provided password in hashed format
+ user = super().save(commit=False)
+ user.set_password(self.cleaned_data["password1"])
+ if commit:
+ user.source = 'adminsite'
+ user.save()
+ return user
+
+
+class BlogUserChangeForm(UserChangeForm):
+ class Meta:
+ model = BlogUser
+ fields = '__all__'
+ field_classes = {'username': UsernameField}
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+
+class BlogUserAdmin(UserAdmin):
+ form = BlogUserChangeForm
+ add_form = BlogUserCreationForm
+ list_display = (
+ 'id',
+ 'nickname',
+ 'username',
+ 'email',
+ 'last_login',
+ 'date_joined',
+ 'source')
+ list_display_links = ('id', 'username')
+ ordering = ('-id',)
+ search_fields = ('username', 'nickname', 'email')
diff --git a/src/DjangoBlog-master/accounts/apps.py b/src/DjangoBlog-master/accounts/apps.py
new file mode 100644
index 0000000..9b3fc5a
--- /dev/null
+++ b/src/DjangoBlog-master/accounts/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class AccountsConfig(AppConfig):
+ name = 'accounts'
diff --git a/src/DjangoBlog-master/accounts/forms.py b/src/DjangoBlog-master/accounts/forms.py
new file mode 100644
index 0000000..fce4137
--- /dev/null
+++ b/src/DjangoBlog-master/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/DjangoBlog-master/accounts/migrations/0001_initial.py b/src/DjangoBlog-master/accounts/migrations/0001_initial.py
new file mode 100644
index 0000000..d2fbcab
--- /dev/null
+++ b/src/DjangoBlog-master/accounts/migrations/0001_initial.py
@@ -0,0 +1,49 @@
+# Generated by Django 4.1.7 on 2023-03-02 07:14
+
+import django.contrib.auth.models
+import django.contrib.auth.validators
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='BlogUser',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('password', models.CharField(max_length=128, verbose_name='password')),
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
+ ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
+ ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
+ ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
+ ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
+ ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
+ ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
+ ('nickname', models.CharField(blank=True, max_length=100, verbose_name='昵称')),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ('source', models.CharField(blank=True, max_length=100, verbose_name='创建来源')),
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
+ ],
+ options={
+ 'verbose_name': '用户',
+ 'verbose_name_plural': '用户',
+ 'ordering': ['-id'],
+ 'get_latest_by': 'id',
+ },
+ managers=[
+ ('objects', django.contrib.auth.models.UserManager()),
+ ],
+ ),
+ ]
diff --git a/src/DjangoBlog-master/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py b/src/DjangoBlog-master/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py
new file mode 100644
index 0000000..1a9f509
--- /dev/null
+++ b/src/DjangoBlog-master/accounts/migrations/0002_alter_bloguser_options_remove_bloguser_created_time_and_more.py
@@ -0,0 +1,46 @@
+# Generated by Django 4.2.5 on 2023-09-06 13:13
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('accounts', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='bloguser',
+ options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'user', 'verbose_name_plural': 'user'},
+ ),
+ migrations.RemoveField(
+ model_name='bloguser',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='bloguser',
+ name='last_mod_time',
+ ),
+ migrations.AddField(
+ model_name='bloguser',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='bloguser',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
+ ),
+ migrations.AlterField(
+ model_name='bloguser',
+ name='nickname',
+ field=models.CharField(blank=True, max_length=100, verbose_name='nick name'),
+ ),
+ migrations.AlterField(
+ model_name='bloguser',
+ name='source',
+ field=models.CharField(blank=True, max_length=100, verbose_name='create source'),
+ ),
+ ]
diff --git a/src/DjangoBlog-master/accounts/migrations/__init__.py b/src/DjangoBlog-master/accounts/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/DjangoBlog-master/accounts/models.py b/src/DjangoBlog-master/accounts/models.py
new file mode 100644
index 0000000..3baddbb
--- /dev/null
+++ b/src/DjangoBlog-master/accounts/models.py
@@ -0,0 +1,35 @@
+from django.contrib.auth.models import AbstractUser
+from django.db import models
+from django.urls import reverse
+from django.utils.timezone import now
+from django.utils.translation import gettext_lazy as _
+from djangoblog.utils import get_current_site
+
+
+# Create your models here.
+
+class BlogUser(AbstractUser):
+ nickname = models.CharField(_('nick name'), max_length=100, blank=True)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_modify_time = models.DateTimeField(_('last modify time'), default=now)
+ source = models.CharField(_('create source'), max_length=100, blank=True)
+
+ def get_absolute_url(self):
+ return reverse(
+ 'blog:author_detail', kwargs={
+ 'author_name': self.username})
+
+ def __str__(self):
+ return self.email
+
+ def get_full_url(self):
+ site = get_current_site().domain
+ url = "https://{site}{path}".format(site=site,
+ path=self.get_absolute_url())
+ return url
+
+ class Meta:
+ ordering = ['-id']
+ verbose_name = _('user')
+ verbose_name_plural = verbose_name
+ get_latest_by = 'id'
diff --git a/src/DjangoBlog-master/accounts/templatetags/__init__.py b/src/DjangoBlog-master/accounts/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/DjangoBlog-master/accounts/tests.py b/src/DjangoBlog-master/accounts/tests.py
new file mode 100644
index 0000000..6893411
--- /dev/null
+++ b/src/DjangoBlog-master/accounts/tests.py
@@ -0,0 +1,207 @@
+from django.test import Client, RequestFactory, TestCase
+from django.urls import reverse
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+
+from accounts.models import BlogUser
+from blog.models import Article, Category
+from djangoblog.utils import *
+from . import utils
+
+
+# Create your tests here.
+
+class AccountTest(TestCase):
+ def setUp(self):
+ self.client = Client()
+ self.factory = RequestFactory()
+ self.blog_user = BlogUser.objects.create_user(
+ username="test",
+ email="admin@admin.com",
+ password="12345678"
+ )
+ self.new_test = "xxx123--="
+
+ def test_validate_account(self):
+ site = get_current_site().domain
+ user = BlogUser.objects.create_superuser(
+ email="liangliangyy1@gmail.com",
+ username="liangliangyy1",
+ password="qwer!@#$ggg")
+ testuser = BlogUser.objects.get(username='liangliangyy1')
+
+ loginresult = self.client.login(
+ username='liangliangyy1',
+ password='qwer!@#$ggg')
+ self.assertEqual(loginresult, True)
+ response = self.client.get('/admin/')
+ self.assertEqual(response.status_code, 200)
+
+ category = Category()
+ category.name = "categoryaaa"
+ category.creation_time = timezone.now()
+ category.last_modify_time = timezone.now()
+ category.save()
+
+ article = Article()
+ article.title = "nicetitleaaa"
+ article.body = "nicecontentaaa"
+ article.author = user
+ article.category = category
+ article.type = 'a'
+ article.status = 'p'
+ article.save()
+
+ response = self.client.get(article.get_admin_url())
+ self.assertEqual(response.status_code, 200)
+
+ def test_validate_register(self):
+ self.assertEquals(
+ 0, len(
+ BlogUser.objects.filter(
+ email='user123@user.com')))
+ response = self.client.post(reverse('account:register'), {
+ 'username': 'user1233',
+ 'email': 'user123@user.com',
+ 'password1': 'password123!q@wE#R$T',
+ 'password2': 'password123!q@wE#R$T',
+ })
+ self.assertEquals(
+ 1, len(
+ BlogUser.objects.filter(
+ email='user123@user.com')))
+ user = BlogUser.objects.filter(email='user123@user.com')[0]
+ sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
+ path = reverse('accounts:result')
+ url = '{path}?type=validation&id={id}&sign={sign}'.format(
+ path=path, id=user.id, sign=sign)
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ self.client.login(username='user1233', password='password123!q@wE#R$T')
+ user = BlogUser.objects.filter(email='user123@user.com')[0]
+ user.is_superuser = True
+ user.is_staff = True
+ user.save()
+ delete_sidebar_cache()
+ category = Category()
+ category.name = "categoryaaa"
+ category.creation_time = timezone.now()
+ category.last_modify_time = timezone.now()
+ category.save()
+
+ article = Article()
+ article.category = category
+ article.title = "nicetitle333"
+ article.body = "nicecontentttt"
+ article.author = user
+
+ article.type = 'a'
+ article.status = 'p'
+ article.save()
+
+ response = self.client.get(article.get_admin_url())
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get(reverse('account:logout'))
+ self.assertIn(response.status_code, [301, 302, 200])
+
+ response = self.client.get(article.get_admin_url())
+ self.assertIn(response.status_code, [301, 302, 200])
+
+ response = self.client.post(reverse('account:login'), {
+ 'username': 'user1233',
+ 'password': 'password123'
+ })
+ self.assertIn(response.status_code, [301, 302, 200])
+
+ response = self.client.get(article.get_admin_url())
+ self.assertIn(response.status_code, [301, 302, 200])
+
+ def test_verify_email_code(self):
+ to_email = "admin@admin.com"
+ code = generate_code()
+ utils.set_code(to_email, code)
+ utils.send_verify_email(to_email, code)
+
+ err = utils.verify("admin@admin.com", code)
+ self.assertEqual(err, None)
+
+ err = utils.verify("admin@123.com", code)
+ self.assertEqual(type(err), str)
+
+ def test_forget_password_email_code_success(self):
+ resp = self.client.post(
+ path=reverse("account:forget_password_code"),
+ data=dict(email="admin@admin.com")
+ )
+
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(resp.content.decode("utf-8"), "ok")
+
+ def test_forget_password_email_code_fail(self):
+ resp = self.client.post(
+ path=reverse("account:forget_password_code"),
+ data=dict()
+ )
+ self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
+
+ resp = self.client.post(
+ path=reverse("account:forget_password_code"),
+ data=dict(email="admin@com")
+ )
+ self.assertEqual(resp.content.decode("utf-8"), "错误的邮箱")
+
+ def test_forget_password_email_success(self):
+ code = generate_code()
+ utils.set_code(self.blog_user.email, code)
+ data = dict(
+ new_password1=self.new_test,
+ new_password2=self.new_test,
+ email=self.blog_user.email,
+ code=code,
+ )
+ resp = self.client.post(
+ path=reverse("account:forget_password"),
+ data=data
+ )
+ self.assertEqual(resp.status_code, 302)
+
+ # 验证用户密码是否修改成功
+ blog_user = BlogUser.objects.filter(
+ email=self.blog_user.email,
+ ).first() # type: BlogUser
+ self.assertNotEqual(blog_user, None)
+ self.assertEqual(blog_user.check_password(data["new_password1"]), True)
+
+ def test_forget_password_email_not_user(self):
+ data = dict(
+ new_password1=self.new_test,
+ new_password2=self.new_test,
+ email="123@123.com",
+ code="123456",
+ )
+ resp = self.client.post(
+ path=reverse("account:forget_password"),
+ data=data
+ )
+
+ self.assertEqual(resp.status_code, 200)
+
+
+ def test_forget_password_email_code_error(self):
+ code = generate_code()
+ utils.set_code(self.blog_user.email, code)
+ data = dict(
+ new_password1=self.new_test,
+ new_password2=self.new_test,
+ email=self.blog_user.email,
+ code="111111",
+ )
+ resp = self.client.post(
+ path=reverse("account:forget_password"),
+ data=data
+ )
+
+ self.assertEqual(resp.status_code, 200)
+
diff --git a/src/DjangoBlog-master/accounts/urls.py b/src/DjangoBlog-master/accounts/urls.py
new file mode 100644
index 0000000..107a801
--- /dev/null
+++ b/src/DjangoBlog-master/accounts/urls.py
@@ -0,0 +1,28 @@
+from django.urls import path
+from django.urls import re_path
+
+from . import views
+from .forms import LoginForm
+
+app_name = "accounts"
+
+urlpatterns = [re_path(r'^login/$',
+ views.LoginView.as_view(success_url='/'),
+ name='login',
+ kwargs={'authentication_form': LoginForm}),
+ re_path(r'^register/$',
+ views.RegisterView.as_view(success_url="/"),
+ name='register'),
+ re_path(r'^logout/$',
+ views.LogoutView.as_view(),
+ name='logout'),
+ path(r'account/result.html',
+ views.account_result,
+ name='result'),
+ re_path(r'^forget_password/$',
+ views.ForgetPasswordView.as_view(),
+ name='forget_password'),
+ re_path(r'^forget_password_code/$',
+ views.ForgetPasswordEmailCode.as_view(),
+ name='forget_password_code'),
+ ]
diff --git a/src/DjangoBlog-master/accounts/user_login_backend.py b/src/DjangoBlog-master/accounts/user_login_backend.py
new file mode 100644
index 0000000..73cdca1
--- /dev/null
+++ b/src/DjangoBlog-master/accounts/user_login_backend.py
@@ -0,0 +1,26 @@
+from django.contrib.auth import get_user_model
+from django.contrib.auth.backends import ModelBackend
+
+
+class EmailOrUsernameModelBackend(ModelBackend):
+ """
+ 允许使用用户名或邮箱登录
+ """
+
+ def authenticate(self, request, username=None, password=None, **kwargs):
+ if '@' in username:
+ kwargs = {'email': username}
+ else:
+ kwargs = {'username': username}
+ try:
+ user = get_user_model().objects.get(**kwargs)
+ if user.check_password(password):
+ return user
+ except get_user_model().DoesNotExist:
+ return None
+
+ def get_user(self, username):
+ try:
+ return get_user_model().objects.get(pk=username)
+ except get_user_model().DoesNotExist:
+ return None
diff --git a/src/DjangoBlog-master/accounts/utils.py b/src/DjangoBlog-master/accounts/utils.py
new file mode 100644
index 0000000..4b94bdf
--- /dev/null
+++ b/src/DjangoBlog-master/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/DjangoBlog-master/accounts/views.py b/src/DjangoBlog-master/accounts/views.py
new file mode 100644
index 0000000..ae67aec
--- /dev/null
+++ b/src/DjangoBlog-master/accounts/views.py
@@ -0,0 +1,204 @@
+import logging
+from django.utils.translation import gettext_lazy as _
+from django.conf import settings
+from django.contrib import auth
+from django.contrib.auth import REDIRECT_FIELD_NAME
+from django.contrib.auth import get_user_model
+from django.contrib.auth import logout
+from django.contrib.auth.forms import AuthenticationForm
+from django.contrib.auth.hashers import make_password
+from django.http import HttpResponseRedirect, HttpResponseForbidden
+from django.http.request import HttpRequest
+from django.http.response import HttpResponse
+from django.shortcuts import get_object_or_404
+from django.shortcuts import render
+from django.urls import reverse
+from django.utils.decorators import method_decorator
+from django.utils.http import url_has_allowed_host_and_scheme
+from django.views import View
+from django.views.decorators.cache import never_cache
+from django.views.decorators.csrf import csrf_protect
+from django.views.decorators.debug import sensitive_post_parameters
+from django.views.generic import FormView, RedirectView
+
+from djangoblog.utils import send_email, get_sha256, get_current_site, generate_code, delete_sidebar_cache
+from . import utils
+from .forms import RegisterForm, LoginForm, ForgetPasswordForm, ForgetPasswordCodeForm
+from .models import BlogUser
+
+logger = logging.getLogger(__name__)
+
+
+# Create your views here.
+
+class RegisterView(FormView):
+ form_class = RegisterForm
+ template_name = 'account/registration_form.html'
+
+ @method_decorator(csrf_protect)
+ def dispatch(self, *args, **kwargs):
+ return super(RegisterView, self).dispatch(*args, **kwargs)
+
+ def form_valid(self, form):
+ if form.is_valid():
+ user = form.save(False)
+ user.is_active = False
+ user.source = 'Register'
+ user.save(True)
+ site = get_current_site().domain
+ sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
+
+ if settings.DEBUG:
+ site = '127.0.0.1:8000'
+ path = reverse('account:result')
+ url = "http://{site}{path}?type=validation&id={id}&sign={sign}".format(
+ site=site, path=path, id=user.id, sign=sign)
+
+ content = """
+ 请点击下面链接验证您的邮箱
+
+ {url}
+
+ 再次感谢您!
+
+ 如果上面链接无法打开,请将此链接复制至浏览器。
+ {url}
+ """.format(url=url)
+ send_email(
+ emailto=[
+ user.email,
+ ],
+ title='验证您的电子邮箱',
+ content=content)
+
+ url = reverse('accounts:result') + \
+ '?type=register&id=' + str(user.id)
+ return HttpResponseRedirect(url)
+ else:
+ return self.render_to_response({
+ 'form': form
+ })
+
+
+class LogoutView(RedirectView):
+ url = '/login/'
+
+ @method_decorator(never_cache)
+ def dispatch(self, request, *args, **kwargs):
+ return super(LogoutView, self).dispatch(request, *args, **kwargs)
+
+ def get(self, request, *args, **kwargs):
+ logout(request)
+ delete_sidebar_cache()
+ return super(LogoutView, self).get(request, *args, **kwargs)
+
+
+class LoginView(FormView):
+ form_class = LoginForm
+ template_name = 'account/login.html'
+ success_url = '/'
+ redirect_field_name = REDIRECT_FIELD_NAME
+ login_ttl = 2626560 # 一个月的时间
+
+ @method_decorator(sensitive_post_parameters('password'))
+ @method_decorator(csrf_protect)
+ @method_decorator(never_cache)
+ def dispatch(self, request, *args, **kwargs):
+
+ return super(LoginView, self).dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ redirect_to = self.request.GET.get(self.redirect_field_name)
+ if redirect_to is None:
+ redirect_to = '/'
+ kwargs['redirect_to'] = redirect_to
+
+ return super(LoginView, self).get_context_data(**kwargs)
+
+ def form_valid(self, form):
+ form = AuthenticationForm(data=self.request.POST, request=self.request)
+
+ if form.is_valid():
+ delete_sidebar_cache()
+ logger.info(self.redirect_field_name)
+
+ auth.login(self.request, form.get_user())
+ if self.request.POST.get("remember"):
+ self.request.session.set_expiry(self.login_ttl)
+ return super(LoginView, self).form_valid(form)
+ # return HttpResponseRedirect('/')
+ else:
+ return self.render_to_response({
+ 'form': form
+ })
+
+ def get_success_url(self):
+
+ redirect_to = self.request.POST.get(self.redirect_field_name)
+ if not url_has_allowed_host_and_scheme(
+ url=redirect_to, allowed_hosts=[
+ self.request.get_host()]):
+ redirect_to = self.success_url
+ return redirect_to
+
+
+def account_result(request):
+ type = request.GET.get('type')
+ id = request.GET.get('id')
+
+ user = get_object_or_404(get_user_model(), id=id)
+ logger.info(type)
+ if user.is_active:
+ return HttpResponseRedirect('/')
+ if type and type in ['register', 'validation']:
+ if type == 'register':
+ content = '''
+ 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。
+ '''
+ title = '注册成功'
+ else:
+ c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
+ sign = request.GET.get('sign')
+ if sign != c_sign:
+ return HttpResponseForbidden()
+ user.is_active = True
+ user.save()
+ content = '''
+ 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。
+ '''
+ title = '验证成功'
+ return render(request, 'account/result.html', {
+ 'title': title,
+ 'content': content
+ })
+ else:
+ return HttpResponseRedirect('/')
+
+
+class ForgetPasswordView(FormView):
+ form_class = ForgetPasswordForm
+ template_name = 'account/forget_password.html'
+
+ def form_valid(self, form):
+ if form.is_valid():
+ blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get()
+ blog_user.password = make_password(form.cleaned_data["new_password2"])
+ blog_user.save()
+ return HttpResponseRedirect('/login/')
+ else:
+ return self.render_to_response({'form': form})
+
+
+class ForgetPasswordEmailCode(View):
+
+ def post(self, request: HttpRequest):
+ form = ForgetPasswordCodeForm(request.POST)
+ if not form.is_valid():
+ return HttpResponse("错误的邮箱")
+ to_email = form.cleaned_data["email"]
+
+ code = generate_code()
+ utils.send_verify_email(to_email, code)
+ utils.set_code(to_email, code)
+
+ return HttpResponse("ok")
diff --git a/src/DjangoBlog-master/blog/__init__.py b/src/DjangoBlog-master/blog/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/DjangoBlog-master/blog/admin.py b/src/DjangoBlog-master/blog/admin.py
new file mode 100644
index 0000000..69d7f8e
--- /dev/null
+++ b/src/DjangoBlog-master/blog/admin.py
@@ -0,0 +1,114 @@
+from django import forms
+from django.contrib import admin
+from django.contrib.auth import get_user_model
+from django.urls import reverse
+from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
+
+# Register your models here.
+from .models import Article, Category, Tag, Links, SideBar, BlogSettings
+
+
+class ArticleForm(forms.ModelForm):
+ # body = forms.CharField(widget=AdminPagedownWidget())
+
+ class Meta:
+ model = Article
+ fields = '__all__'
+
+
+def makr_article_publish(modeladmin, request, queryset):
+ queryset.update(status='p')
+
+
+def draft_article(modeladmin, request, queryset):
+ queryset.update(status='d')
+
+
+def close_article_commentstatus(modeladmin, request, queryset):
+ queryset.update(comment_status='c')
+
+
+def open_article_commentstatus(modeladmin, request, queryset):
+ queryset.update(comment_status='o')
+
+
+makr_article_publish.short_description = _('Publish selected articles')
+draft_article.short_description = _('Draft selected articles')
+close_article_commentstatus.short_description = _('Close article comments')
+open_article_commentstatus.short_description = _('Open article comments')
+
+
+class ArticlelAdmin(admin.ModelAdmin):
+ list_per_page = 20
+ search_fields = ('body', 'title')
+ form = ArticleForm
+ list_display = (
+ 'id',
+ 'title',
+ 'author',
+ 'link_to_category',
+ 'creation_time',
+ 'views',
+ 'status',
+ 'type',
+ 'article_order')
+ list_display_links = ('id', 'title')
+ list_filter = ('status', 'type', 'category')
+ date_hierarchy = 'creation_time'
+ 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]
+ raw_id_fields = ('author', 'category',)
+
+ def link_to_category(self, obj):
+ info = (obj.category._meta.app_label, obj.category._meta.model_name)
+ link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
+ return format_html(u'%s' % (link, obj.category.name))
+
+ link_to_category.short_description = _('category')
+
+ def get_form(self, request, obj=None, **kwargs):
+ form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
+ form.base_fields['author'].queryset = get_user_model(
+ ).objects.filter(is_superuser=True)
+ return form
+
+ def save_model(self, request, obj, form, change):
+ super(ArticlelAdmin, self).save_model(request, obj, form, change)
+
+ def get_view_on_site_url(self, obj=None):
+ if obj:
+ url = obj.get_full_url()
+ return url
+ else:
+ from djangoblog.utils import get_current_site
+ site = get_current_site().domain
+ return site
+
+
+class TagAdmin(admin.ModelAdmin):
+ exclude = ('slug', 'last_mod_time', 'creation_time')
+
+
+class CategoryAdmin(admin.ModelAdmin):
+ list_display = ('name', 'parent_category', 'index')
+ exclude = ('slug', 'last_mod_time', 'creation_time')
+
+
+class LinksAdmin(admin.ModelAdmin):
+ exclude = ('last_mod_time', 'creation_time')
+
+
+class SideBarAdmin(admin.ModelAdmin):
+ list_display = ('name', 'content', 'is_enable', 'sequence')
+ exclude = ('last_mod_time', 'creation_time')
+
+
+class BlogSettingsAdmin(admin.ModelAdmin):
+ pass
diff --git a/src/DjangoBlog-master/blog/apps.py b/src/DjangoBlog-master/blog/apps.py
new file mode 100644
index 0000000..7930587
--- /dev/null
+++ b/src/DjangoBlog-master/blog/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class BlogConfig(AppConfig):
+ name = 'blog'
diff --git a/src/DjangoBlog-master/blog/context_processors.py b/src/DjangoBlog-master/blog/context_processors.py
new file mode 100644
index 0000000..73e3088
--- /dev/null
+++ b/src/DjangoBlog-master/blog/context_processors.py
@@ -0,0 +1,43 @@
+import logging
+
+from django.utils import timezone
+
+from djangoblog.utils import cache, get_blog_setting
+from .models import Category, Article
+
+logger = logging.getLogger(__name__)
+
+
+def seo_processor(requests):
+ key = 'seo_processor'
+ value = cache.get(key)
+ if value:
+ return value
+ else:
+ logger.info('set processor cache.')
+ setting = get_blog_setting()
+ value = {
+ 'SITE_NAME': setting.site_name,
+ 'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,
+ 'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,
+ 'SITE_SEO_DESCRIPTION': setting.site_seo_description,
+ 'SITE_DESCRIPTION': setting.site_description,
+ 'SITE_KEYWORDS': setting.site_keywords,
+ 'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/',
+ 'ARTICLE_SUB_LENGTH': setting.article_sub_length,
+ 'nav_category_list': Category.objects.all(),
+ 'nav_pages': Article.objects.filter(
+ type='p',
+ status='p'),
+ 'OPEN_SITE_COMMENT': setting.open_site_comment,
+ 'BEIAN_CODE': setting.beian_code,
+ 'ANALYTICS_CODE': setting.analytics_code,
+ "BEIAN_CODE_GONGAN": setting.gongan_beiancode,
+ "SHOW_GONGAN_CODE": setting.show_gongan_code,
+ "CURRENT_YEAR": timezone.now().year,
+ "GLOBAL_HEADER": setting.global_header,
+ "GLOBAL_FOOTER": setting.global_footer,
+ "COMMENT_NEED_REVIEW": setting.comment_need_review,
+ }
+ cache.set(key, value, 60 * 60 * 10)
+ return value
diff --git a/src/DjangoBlog-master/blog/documents.py b/src/DjangoBlog-master/blog/documents.py
new file mode 100644
index 0000000..0f1db7b
--- /dev/null
+++ b/src/DjangoBlog-master/blog/documents.py
@@ -0,0 +1,213 @@
+import time
+
+import elasticsearch.client
+from django.conf import settings
+from elasticsearch_dsl import Document, InnerDoc, Date, Integer, Long, Text, Object, GeoPoint, Keyword, Boolean
+from elasticsearch_dsl.connections import connections
+
+from blog.models import Article
+
+ELASTICSEARCH_ENABLED = hasattr(settings, 'ELASTICSEARCH_DSL')
+
+if ELASTICSEARCH_ENABLED:
+ connections.create_connection(
+ hosts=[settings.ELASTICSEARCH_DSL['default']['hosts']])
+ from elasticsearch import Elasticsearch
+
+ es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
+ from elasticsearch.client import IngestClient
+
+ c = IngestClient(es)
+ try:
+ c.get_pipeline('geoip')
+ except elasticsearch.exceptions.NotFoundError:
+ c.put_pipeline('geoip', body='''{
+ "description" : "Add geoip info",
+ "processors" : [
+ {
+ "geoip" : {
+ "field" : "ip"
+ }
+ }
+ ]
+ }''')
+
+
+class GeoIp(InnerDoc):
+ continent_name = Keyword()
+ country_iso_code = Keyword()
+ country_name = Keyword()
+ location = GeoPoint()
+
+
+class UserAgentBrowser(InnerDoc):
+ Family = Keyword()
+ Version = Keyword()
+
+
+class UserAgentOS(UserAgentBrowser):
+ pass
+
+
+class UserAgentDevice(InnerDoc):
+ Family = Keyword()
+ Brand = Keyword()
+ Model = Keyword()
+
+
+class UserAgent(InnerDoc):
+ browser = Object(UserAgentBrowser, required=False)
+ os = Object(UserAgentOS, required=False)
+ device = Object(UserAgentDevice, required=False)
+ string = Text()
+ is_bot = Boolean()
+
+
+class ElapsedTimeDocument(Document):
+ url = Keyword()
+ time_taken = Long()
+ log_datetime = Date()
+ ip = Keyword()
+ geoip = Object(GeoIp, required=False)
+ useragent = Object(UserAgent, required=False)
+
+ class Index:
+ name = 'performance'
+ settings = {
+ "number_of_shards": 1,
+ "number_of_replicas": 0
+ }
+
+ class Meta:
+ doc_type = 'ElapsedTime'
+
+
+class ElaspedTimeDocumentManager:
+ @staticmethod
+ def build_index():
+ from elasticsearch import Elasticsearch
+ client = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
+ res = client.indices.exists(index="performance")
+ if not res:
+ ElapsedTimeDocument.init()
+
+ @staticmethod
+ def delete_index():
+ from elasticsearch import Elasticsearch
+ es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
+ es.indices.delete(index='performance', ignore=[400, 404])
+
+ @staticmethod
+ def create(url, time_taken, log_datetime, useragent, ip):
+ ElaspedTimeDocumentManager.build_index()
+ ua = UserAgent()
+ ua.browser = UserAgentBrowser()
+ ua.browser.Family = useragent.browser.family
+ ua.browser.Version = useragent.browser.version_string
+
+ ua.os = UserAgentOS()
+ ua.os.Family = useragent.os.family
+ ua.os.Version = useragent.os.version_string
+
+ ua.device = UserAgentDevice()
+ ua.device.Family = useragent.device.family
+ ua.device.Brand = useragent.device.brand
+ ua.device.Model = useragent.device.model
+ ua.string = useragent.ua_string
+ ua.is_bot = useragent.is_bot
+
+ doc = ElapsedTimeDocument(
+ meta={
+ 'id': int(
+ round(
+ time.time() *
+ 1000))
+ },
+ url=url,
+ time_taken=time_taken,
+ log_datetime=log_datetime,
+ useragent=ua, ip=ip)
+ doc.save(pipeline="geoip")
+
+
+class ArticleDocument(Document):
+ body = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
+ title = Text(analyzer='ik_max_word', search_analyzer='ik_smart')
+ author = Object(properties={
+ 'nickname': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
+ 'id': Integer()
+ })
+ category = Object(properties={
+ 'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
+ 'id': Integer()
+ })
+ tags = Object(properties={
+ 'name': Text(analyzer='ik_max_word', search_analyzer='ik_smart'),
+ 'id': Integer()
+ })
+
+ pub_time = Date()
+ status = Text()
+ comment_status = Text()
+ type = Text()
+ views = Integer()
+ article_order = Integer()
+
+ class Index:
+ name = 'blog'
+ settings = {
+ "number_of_shards": 1,
+ "number_of_replicas": 0
+ }
+
+ class Meta:
+ doc_type = 'Article'
+
+
+class ArticleDocumentManager():
+
+ def __init__(self):
+ self.create_index()
+
+ def create_index(self):
+ ArticleDocument.init()
+
+ def delete_index(self):
+ from elasticsearch import Elasticsearch
+ es = Elasticsearch(settings.ELASTICSEARCH_DSL['default']['hosts'])
+ es.indices.delete(index='blog', ignore=[400, 404])
+
+ def convert_to_doc(self, articles):
+ return [
+ ArticleDocument(
+ meta={
+ 'id': article.id},
+ body=article.body,
+ title=article.title,
+ author={
+ 'nickname': article.author.username,
+ 'id': article.author.id},
+ category={
+ 'name': article.category.name,
+ 'id': article.category.id},
+ tags=[
+ {
+ 'name': t.name,
+ 'id': t.id} for t in article.tags.all()],
+ pub_time=article.pub_time,
+ status=article.status,
+ comment_status=article.comment_status,
+ type=article.type,
+ views=article.views,
+ article_order=article.article_order) for article in articles]
+
+ def rebuild(self, articles=None):
+ ArticleDocument.init()
+ articles = articles if articles else Article.objects.all()
+ docs = self.convert_to_doc(articles)
+ for doc in docs:
+ doc.save()
+
+ def update_docs(self, docs):
+ for doc in docs:
+ doc.save()
diff --git a/src/DjangoBlog-master/blog/forms.py b/src/DjangoBlog-master/blog/forms.py
new file mode 100644
index 0000000..715be76
--- /dev/null
+++ b/src/DjangoBlog-master/blog/forms.py
@@ -0,0 +1,19 @@
+import logging
+
+from django import forms
+from haystack.forms import SearchForm
+
+logger = logging.getLogger(__name__)
+
+
+class BlogSearchForm(SearchForm):
+ querydata = forms.CharField(required=True)
+
+ def search(self):
+ datas = super(BlogSearchForm, self).search()
+ if not self.is_valid():
+ return self.no_query_found()
+
+ if self.cleaned_data['querydata']:
+ logger.info(self.cleaned_data['querydata'])
+ return datas
diff --git a/src/DjangoBlog-master/blog/management/__init__.py b/src/DjangoBlog-master/blog/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/DjangoBlog-master/blog/management/commands/__init__.py b/src/DjangoBlog-master/blog/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/DjangoBlog-master/blog/management/commands/build_index.py b/src/DjangoBlog-master/blog/management/commands/build_index.py
new file mode 100644
index 0000000..3c4acd7
--- /dev/null
+++ b/src/DjangoBlog-master/blog/management/commands/build_index.py
@@ -0,0 +1,18 @@
+from django.core.management.base import BaseCommand
+
+from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedTimeDocumentManager, \
+ ELASTICSEARCH_ENABLED
+
+
+# TODO 参数化
+class Command(BaseCommand):
+ help = 'build search index'
+
+ def handle(self, *args, **options):
+ if ELASTICSEARCH_ENABLED:
+ ElaspedTimeDocumentManager.build_index()
+ manager = ElapsedTimeDocument()
+ manager.init()
+ manager = ArticleDocumentManager()
+ manager.delete_index()
+ manager.rebuild()
diff --git a/src/DjangoBlog-master/blog/management/commands/build_search_words.py b/src/DjangoBlog-master/blog/management/commands/build_search_words.py
new file mode 100644
index 0000000..cfe7e0d
--- /dev/null
+++ b/src/DjangoBlog-master/blog/management/commands/build_search_words.py
@@ -0,0 +1,13 @@
+from django.core.management.base import BaseCommand
+
+from blog.models import Tag, Category
+
+
+# TODO 参数化
+class Command(BaseCommand):
+ help = 'build search words'
+
+ def handle(self, *args, **options):
+ datas = set([t.name for t in Tag.objects.all()] +
+ [t.name for t in Category.objects.all()])
+ print('\n'.join(datas))
diff --git a/src/DjangoBlog-master/blog/management/commands/clear_cache.py b/src/DjangoBlog-master/blog/management/commands/clear_cache.py
new file mode 100644
index 0000000..0d66172
--- /dev/null
+++ b/src/DjangoBlog-master/blog/management/commands/clear_cache.py
@@ -0,0 +1,11 @@
+from django.core.management.base import BaseCommand
+
+from djangoblog.utils import cache
+
+
+class Command(BaseCommand):
+ help = 'clear the whole cache'
+
+ def handle(self, *args, **options):
+ cache.clear()
+ self.stdout.write(self.style.SUCCESS('Cleared cache\n'))
diff --git a/src/DjangoBlog-master/blog/management/commands/create_testdata.py b/src/DjangoBlog-master/blog/management/commands/create_testdata.py
new file mode 100644
index 0000000..675d2ba
--- /dev/null
+++ b/src/DjangoBlog-master/blog/management/commands/create_testdata.py
@@ -0,0 +1,40 @@
+from django.contrib.auth import get_user_model
+from django.contrib.auth.hashers import make_password
+from django.core.management.base import BaseCommand
+
+from blog.models import Article, Tag, Category
+
+
+class Command(BaseCommand):
+ help = 'create test datas'
+
+ def handle(self, *args, **options):
+ user = get_user_model().objects.get_or_create(
+ email='test@test.com', username='测试用户', password=make_password('test!q@w#eTYU'))[0]
+
+ pcategory = Category.objects.get_or_create(
+ name='我是父类目', parent_category=None)[0]
+
+ category = Category.objects.get_or_create(
+ name='子类目', parent_category=pcategory)[0]
+
+ category.save()
+ basetag = Tag()
+ basetag.name = "标签"
+ basetag.save()
+ for i in range(1, 20):
+ article = Article.objects.get_or_create(
+ category=category,
+ title='nice title ' + str(i),
+ body='nice content ' + str(i),
+ author=user)[0]
+ tag = Tag()
+ tag.name = "标签" + str(i)
+ tag.save()
+ article.tags.add(tag)
+ article.tags.add(basetag)
+ article.save()
+
+ from djangoblog.utils import cache
+ cache.clear()
+ self.stdout.write(self.style.SUCCESS('created test datas \n'))
diff --git a/src/DjangoBlog-master/blog/management/commands/ping_baidu.py b/src/DjangoBlog-master/blog/management/commands/ping_baidu.py
new file mode 100644
index 0000000..2c7fbdd
--- /dev/null
+++ b/src/DjangoBlog-master/blog/management/commands/ping_baidu.py
@@ -0,0 +1,50 @@
+from django.core.management.base import BaseCommand
+
+from djangoblog.spider_notify import SpiderNotify
+from djangoblog.utils import get_current_site
+from blog.models import Article, Tag, Category
+
+site = get_current_site().domain
+
+
+class Command(BaseCommand):
+ help = 'notify baidu url'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ 'data_type',
+ type=str,
+ choices=[
+ 'all',
+ 'article',
+ 'tag',
+ 'category'],
+ help='article : all article,tag : all tag,category: all category,all: All of these')
+
+ def get_full_url(self, path):
+ url = "https://{site}{path}".format(site=site, path=path)
+ return url
+
+ def handle(self, *args, **options):
+ type = options['data_type']
+ self.stdout.write('start get %s' % type)
+
+ urls = []
+ if type == 'article' or type == 'all':
+ for article in Article.objects.filter(status='p'):
+ urls.append(article.get_full_url())
+ if type == 'tag' or type == 'all':
+ for tag in Tag.objects.all():
+ url = tag.get_absolute_url()
+ urls.append(self.get_full_url(url))
+ if type == 'category' or type == 'all':
+ for category in Category.objects.all():
+ url = category.get_absolute_url()
+ urls.append(self.get_full_url(url))
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ 'start notify %d urls' %
+ len(urls)))
+ SpiderNotify.baidu_notify(urls)
+ self.stdout.write(self.style.SUCCESS('finish notify'))
diff --git a/src/DjangoBlog-master/blog/management/commands/sync_user_avatar.py b/src/DjangoBlog-master/blog/management/commands/sync_user_avatar.py
new file mode 100644
index 0000000..d0f4612
--- /dev/null
+++ b/src/DjangoBlog-master/blog/management/commands/sync_user_avatar.py
@@ -0,0 +1,47 @@
+import requests
+from django.core.management.base import BaseCommand
+from django.templatetags.static import static
+
+from djangoblog.utils import save_user_avatar
+from oauth.models import OAuthUser
+from oauth.oauthmanager import get_manager_by_type
+
+
+class Command(BaseCommand):
+ help = 'sync user avatar'
+
+ def test_picture(self, url):
+ try:
+ if requests.get(url, timeout=2).status_code == 200:
+ return True
+ except:
+ pass
+
+ def handle(self, *args, **options):
+ static_url = static("../")
+ users = OAuthUser.objects.all()
+ self.stdout.write(f'开始同步{len(users)}个用户头像')
+ for u in users:
+ self.stdout.write(f'开始同步:{u.nickname}')
+ url = u.picture
+ if url:
+ if url.startswith(static_url):
+ if self.test_picture(url):
+ continue
+ else:
+ if u.metadata:
+ manage = get_manager_by_type(u.type)
+ url = manage.get_picture(u.metadata)
+ url = save_user_avatar(url)
+ else:
+ url = static('blog/img/avatar.png')
+ else:
+ url = save_user_avatar(url)
+ else:
+ url = static('blog/img/avatar.png')
+ if url:
+ self.stdout.write(
+ f'结束同步:{u.nickname}.url:{url}')
+ u.picture = url
+ u.save()
+ self.stdout.write('结束同步')
diff --git a/src/DjangoBlog-master/blog/middleware.py b/src/DjangoBlog-master/blog/middleware.py
new file mode 100644
index 0000000..94dd70c
--- /dev/null
+++ b/src/DjangoBlog-master/blog/middleware.py
@@ -0,0 +1,42 @@
+import logging
+import time
+
+from ipware import get_client_ip
+from user_agents import parse
+
+from blog.documents import ELASTICSEARCH_ENABLED, ElaspedTimeDocumentManager
+
+logger = logging.getLogger(__name__)
+
+
+class OnlineMiddleware(object):
+ def __init__(self, get_response=None):
+ self.get_response = get_response
+ super().__init__()
+
+ def __call__(self, request):
+ ''' page render time '''
+ start_time = time.time()
+ response = self.get_response(request)
+ http_user_agent = request.META.get('HTTP_USER_AGENT', '')
+ ip, _ = get_client_ip(request)
+ user_agent = parse(http_user_agent)
+ if not response.streaming:
+ try:
+ cast_time = time.time() - start_time
+ if ELASTICSEARCH_ENABLED:
+ time_taken = round((cast_time) * 1000, 2)
+ url = request.path
+ from django.utils import timezone
+ ElaspedTimeDocumentManager.create(
+ url=url,
+ time_taken=time_taken,
+ log_datetime=timezone.now(),
+ useragent=user_agent,
+ ip=ip)
+ response.content = response.content.replace(
+ b'', str.encode(str(cast_time)[:5]))
+ except Exception as e:
+ logger.error("Error OnlineMiddleware: %s" % e)
+
+ return response
diff --git a/src/DjangoBlog-master/blog/migrations/0001_initial.py b/src/DjangoBlog-master/blog/migrations/0001_initial.py
new file mode 100644
index 0000000..3d391b6
--- /dev/null
+++ b/src/DjangoBlog-master/blog/migrations/0001_initial.py
@@ -0,0 +1,137 @@
+# Generated by Django 4.1.7 on 2023-03-02 07:14
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import mdeditor.fields
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='BlogSettings',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('sitename', models.CharField(default='', max_length=200, verbose_name='网站名称')),
+ ('site_description', models.TextField(default='', max_length=1000, verbose_name='网站描述')),
+ ('site_seo_description', models.TextField(default='', max_length=1000, verbose_name='网站SEO描述')),
+ ('site_keywords', models.TextField(default='', max_length=1000, verbose_name='网站关键字')),
+ ('article_sub_length', models.IntegerField(default=300, verbose_name='文章摘要长度')),
+ ('sidebar_article_count', models.IntegerField(default=10, verbose_name='侧边栏文章数目')),
+ ('sidebar_comment_count', models.IntegerField(default=5, verbose_name='侧边栏评论数目')),
+ ('article_comment_count', models.IntegerField(default=5, verbose_name='文章页面默认显示评论数目')),
+ ('show_google_adsense', models.BooleanField(default=False, verbose_name='是否显示谷歌广告')),
+ ('google_adsense_codes', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='广告内容')),
+ ('open_site_comment', models.BooleanField(default=True, verbose_name='是否打开网站评论功能')),
+ ('beiancode', models.CharField(blank=True, default='', max_length=2000, null=True, verbose_name='备案号')),
+ ('analyticscode', models.TextField(default='', max_length=1000, verbose_name='网站统计代码')),
+ ('show_gongan_code', models.BooleanField(default=False, verbose_name='是否显示公安备案号')),
+ ('gongan_beiancode', models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='公安备案号')),
+ ],
+ options={
+ 'verbose_name': '网站配置',
+ 'verbose_name_plural': '网站配置',
+ },
+ ),
+ migrations.CreateModel(
+ name='Links',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=30, unique=True, verbose_name='链接名称')),
+ ('link', models.URLField(verbose_name='链接地址')),
+ ('sequence', models.IntegerField(unique=True, verbose_name='排序')),
+ ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
+ ('show_type', models.CharField(choices=[('i', '首页'), ('l', '列表页'), ('p', '文章页面'), ('a', '全站'), ('s', '友情链接页面')], default='i', max_length=1, verbose_name='显示类型')),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ],
+ options={
+ 'verbose_name': '友情链接',
+ 'verbose_name_plural': '友情链接',
+ 'ordering': ['sequence'],
+ },
+ ),
+ migrations.CreateModel(
+ name='SideBar',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100, verbose_name='标题')),
+ ('content', models.TextField(verbose_name='内容')),
+ ('sequence', models.IntegerField(unique=True, verbose_name='排序')),
+ ('is_enable', models.BooleanField(default=True, verbose_name='是否启用')),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ],
+ options={
+ 'verbose_name': '侧边栏',
+ 'verbose_name_plural': '侧边栏',
+ 'ordering': ['sequence'],
+ },
+ ),
+ migrations.CreateModel(
+ name='Tag',
+ fields=[
+ ('id', models.AutoField(primary_key=True, serialize=False)),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ('name', models.CharField(max_length=30, unique=True, verbose_name='标签名')),
+ ('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
+ ],
+ options={
+ 'verbose_name': '标签',
+ 'verbose_name_plural': '标签',
+ 'ordering': ['name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='Category',
+ fields=[
+ ('id', models.AutoField(primary_key=True, serialize=False)),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ('name', models.CharField(max_length=30, unique=True, verbose_name='分类名')),
+ ('slug', models.SlugField(blank=True, default='no-slug', max_length=60)),
+ ('index', models.IntegerField(default=0, verbose_name='权重排序-越大越靠前')),
+ ('parent_category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='父级分类')),
+ ],
+ options={
+ 'verbose_name': '分类',
+ 'verbose_name_plural': '分类',
+ 'ordering': ['-index'],
+ },
+ ),
+ migrations.CreateModel(
+ name='Article',
+ fields=[
+ ('id', models.AutoField(primary_key=True, serialize=False)),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ('title', models.CharField(max_length=200, unique=True, verbose_name='标题')),
+ ('body', mdeditor.fields.MDTextField(verbose_name='正文')),
+ ('pub_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='发布时间')),
+ ('status', models.CharField(choices=[('d', '草稿'), ('p', '发表')], default='p', max_length=1, verbose_name='文章状态')),
+ ('comment_status', models.CharField(choices=[('o', '打开'), ('c', '关闭')], default='o', max_length=1, verbose_name='评论状态')),
+ ('type', models.CharField(choices=[('a', '文章'), ('p', '页面')], default='a', max_length=1, verbose_name='类型')),
+ ('views', models.PositiveIntegerField(default=0, verbose_name='浏览量')),
+ ('article_order', models.IntegerField(default=0, verbose_name='排序,数字越大越靠前')),
+ ('show_toc', models.BooleanField(default=False, verbose_name='是否显示toc目录')),
+ ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
+ ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='分类')),
+ ('tags', models.ManyToManyField(blank=True, to='blog.tag', verbose_name='标签集合')),
+ ],
+ options={
+ 'verbose_name': '文章',
+ 'verbose_name_plural': '文章',
+ 'ordering': ['-article_order', '-pub_time'],
+ 'get_latest_by': 'id',
+ },
+ ),
+ ]
diff --git a/src/DjangoBlog-master/blog/migrations/0002_blogsettings_global_footer_and_more.py b/src/DjangoBlog-master/blog/migrations/0002_blogsettings_global_footer_and_more.py
new file mode 100644
index 0000000..adbaa36
--- /dev/null
+++ b/src/DjangoBlog-master/blog/migrations/0002_blogsettings_global_footer_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.1.7 on 2023-03-29 06:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('blog', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='blogsettings',
+ name='global_footer',
+ field=models.TextField(blank=True, default='', null=True, verbose_name='公共尾部'),
+ ),
+ migrations.AddField(
+ model_name='blogsettings',
+ name='global_header',
+ field=models.TextField(blank=True, default='', null=True, verbose_name='公共头部'),
+ ),
+ ]
diff --git a/src/DjangoBlog-master/blog/migrations/0003_blogsettings_comment_need_review.py b/src/DjangoBlog-master/blog/migrations/0003_blogsettings_comment_need_review.py
new file mode 100644
index 0000000..e9f5502
--- /dev/null
+++ b/src/DjangoBlog-master/blog/migrations/0003_blogsettings_comment_need_review.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.1 on 2023-05-09 07:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('blog', '0002_blogsettings_global_footer_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='blogsettings',
+ name='comment_need_review',
+ field=models.BooleanField(default=False, verbose_name='评论是否需要审核'),
+ ),
+ ]
diff --git a/src/DjangoBlog-master/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py b/src/DjangoBlog-master/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py
new file mode 100644
index 0000000..ceb1398
--- /dev/null
+++ b/src/DjangoBlog-master/blog/migrations/0004_rename_analyticscode_blogsettings_analytics_code_and_more.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.2.1 on 2023-05-09 07:51
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('blog', '0003_blogsettings_comment_need_review'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='blogsettings',
+ old_name='analyticscode',
+ new_name='analytics_code',
+ ),
+ migrations.RenameField(
+ model_name='blogsettings',
+ old_name='beiancode',
+ new_name='beian_code',
+ ),
+ migrations.RenameField(
+ model_name='blogsettings',
+ old_name='sitename',
+ new_name='site_name',
+ ),
+ ]
diff --git a/src/DjangoBlog-master/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py b/src/DjangoBlog-master/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py
new file mode 100644
index 0000000..d08e853
--- /dev/null
+++ b/src/DjangoBlog-master/blog/migrations/0005_alter_article_options_alter_category_options_and_more.py
@@ -0,0 +1,300 @@
+# Generated by Django 4.2.5 on 2023-09-06 13:13
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import mdeditor.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('blog', '0004_rename_analyticscode_blogsettings_analytics_code_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='article',
+ options={'get_latest_by': 'id', 'ordering': ['-article_order', '-pub_time'], 'verbose_name': 'article', 'verbose_name_plural': 'article'},
+ ),
+ migrations.AlterModelOptions(
+ name='category',
+ options={'ordering': ['-index'], 'verbose_name': 'category', 'verbose_name_plural': 'category'},
+ ),
+ migrations.AlterModelOptions(
+ name='links',
+ options={'ordering': ['sequence'], 'verbose_name': 'link', 'verbose_name_plural': 'link'},
+ ),
+ migrations.AlterModelOptions(
+ name='sidebar',
+ options={'ordering': ['sequence'], 'verbose_name': 'sidebar', 'verbose_name_plural': 'sidebar'},
+ ),
+ migrations.AlterModelOptions(
+ name='tag',
+ options={'ordering': ['name'], 'verbose_name': 'tag', 'verbose_name_plural': 'tag'},
+ ),
+ migrations.RemoveField(
+ model_name='article',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='article',
+ name='last_mod_time',
+ ),
+ migrations.RemoveField(
+ model_name='category',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='category',
+ name='last_mod_time',
+ ),
+ migrations.RemoveField(
+ model_name='links',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='sidebar',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='tag',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='tag',
+ name='last_mod_time',
+ ),
+ migrations.AddField(
+ model_name='article',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='article',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
+ ),
+ migrations.AddField(
+ model_name='category',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='category',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
+ ),
+ migrations.AddField(
+ model_name='links',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='sidebar',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='tag',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='tag',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='article_order',
+ field=models.IntegerField(default=0, verbose_name='order'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='author',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='body',
+ field=mdeditor.fields.MDTextField(verbose_name='body'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='category',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='comment_status',
+ field=models.CharField(choices=[('o', 'Open'), ('c', 'Close')], default='o', max_length=1, verbose_name='comment status'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='pub_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='publish time'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='show_toc',
+ field=models.BooleanField(default=False, verbose_name='show toc'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='status',
+ field=models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='p', max_length=1, verbose_name='status'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='tags',
+ field=models.ManyToManyField(blank=True, to='blog.tag', verbose_name='tag'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='title',
+ field=models.CharField(max_length=200, unique=True, verbose_name='title'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='type',
+ field=models.CharField(choices=[('a', 'Article'), ('p', 'Page')], default='a', max_length=1, verbose_name='type'),
+ ),
+ migrations.AlterField(
+ model_name='article',
+ name='views',
+ field=models.PositiveIntegerField(default=0, verbose_name='views'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='article_comment_count',
+ field=models.IntegerField(default=5, verbose_name='article comment count'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='article_sub_length',
+ field=models.IntegerField(default=300, verbose_name='article sub length'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='google_adsense_codes',
+ field=models.TextField(blank=True, default='', max_length=2000, null=True, verbose_name='adsense code'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='open_site_comment',
+ field=models.BooleanField(default=True, verbose_name='open site comment'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='show_google_adsense',
+ field=models.BooleanField(default=False, verbose_name='show adsense'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='sidebar_article_count',
+ field=models.IntegerField(default=10, verbose_name='sidebar article count'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='sidebar_comment_count',
+ field=models.IntegerField(default=5, verbose_name='sidebar comment count'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='site_description',
+ field=models.TextField(default='', max_length=1000, verbose_name='site description'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='site_keywords',
+ field=models.TextField(default='', max_length=1000, verbose_name='site keywords'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='site_name',
+ field=models.CharField(default='', max_length=200, verbose_name='site name'),
+ ),
+ migrations.AlterField(
+ model_name='blogsettings',
+ name='site_seo_description',
+ field=models.TextField(default='', max_length=1000, verbose_name='site seo description'),
+ ),
+ migrations.AlterField(
+ model_name='category',
+ name='index',
+ field=models.IntegerField(default=0, verbose_name='index'),
+ ),
+ migrations.AlterField(
+ model_name='category',
+ name='name',
+ field=models.CharField(max_length=30, unique=True, verbose_name='category name'),
+ ),
+ migrations.AlterField(
+ model_name='category',
+ name='parent_category',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='parent category'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='is_enable',
+ field=models.BooleanField(default=True, verbose_name='is show'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='last_mod_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='link',
+ field=models.URLField(verbose_name='link'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='name',
+ field=models.CharField(max_length=30, unique=True, verbose_name='link name'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='sequence',
+ field=models.IntegerField(unique=True, verbose_name='order'),
+ ),
+ migrations.AlterField(
+ model_name='links',
+ name='show_type',
+ field=models.CharField(choices=[('i', 'index'), ('l', 'list'), ('p', 'post'), ('a', 'all'), ('s', 'slide')], default='i', max_length=1, verbose_name='show type'),
+ ),
+ migrations.AlterField(
+ model_name='sidebar',
+ name='content',
+ field=models.TextField(verbose_name='content'),
+ ),
+ migrations.AlterField(
+ model_name='sidebar',
+ name='is_enable',
+ field=models.BooleanField(default=True, verbose_name='is enable'),
+ ),
+ migrations.AlterField(
+ model_name='sidebar',
+ name='last_mod_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='modify time'),
+ ),
+ migrations.AlterField(
+ model_name='sidebar',
+ name='name',
+ field=models.CharField(max_length=100, verbose_name='title'),
+ ),
+ migrations.AlterField(
+ model_name='sidebar',
+ name='sequence',
+ field=models.IntegerField(unique=True, verbose_name='order'),
+ ),
+ migrations.AlterField(
+ model_name='tag',
+ name='name',
+ field=models.CharField(max_length=30, unique=True, verbose_name='tag name'),
+ ),
+ ]
diff --git a/src/DjangoBlog-master/blog/migrations/0006_alter_blogsettings_options.py b/src/DjangoBlog-master/blog/migrations/0006_alter_blogsettings_options.py
new file mode 100644
index 0000000..e36feb4
--- /dev/null
+++ b/src/DjangoBlog-master/blog/migrations/0006_alter_blogsettings_options.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.7 on 2024-01-26 02:41
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('blog', '0005_alter_article_options_alter_category_options_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='blogsettings',
+ options={'verbose_name': 'Website configuration', 'verbose_name_plural': 'Website configuration'},
+ ),
+ ]
diff --git a/src/DjangoBlog-master/blog/migrations/__init__.py b/src/DjangoBlog-master/blog/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/DjangoBlog-master/blog/models.py b/src/DjangoBlog-master/blog/models.py
new file mode 100644
index 0000000..083788b
--- /dev/null
+++ b/src/DjangoBlog-master/blog/models.py
@@ -0,0 +1,376 @@
+import logging
+import re
+from abc import abstractmethod
+
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.urls import reverse
+from django.utils.timezone import now
+from django.utils.translation import gettext_lazy as _
+from mdeditor.fields import MDTextField
+from uuslug import slugify
+
+from djangoblog.utils import cache_decorator, cache
+from djangoblog.utils import get_current_site
+
+logger = logging.getLogger(__name__)
+
+
+class LinkShowType(models.TextChoices):
+ I = ('i', _('index'))
+ L = ('l', _('list'))
+ P = ('p', _('post'))
+ A = ('a', _('all'))
+ S = ('s', _('slide'))
+
+
+class BaseModel(models.Model):
+ id = models.AutoField(primary_key=True)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_modify_time = models.DateTimeField(_('modify time'), default=now)
+
+ def save(self, *args, **kwargs):
+ is_update_views = isinstance(
+ self,
+ Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
+ if is_update_views:
+ Article.objects.filter(pk=self.pk).update(views=self.views)
+ else:
+ if 'slug' in self.__dict__:
+ slug = getattr(
+ self, 'title') if 'title' in self.__dict__ else getattr(
+ self, 'name')
+ setattr(self, 'slug', slugify(slug))
+ super().save(*args, **kwargs)
+
+ def get_full_url(self):
+ site = get_current_site().domain
+ url = "https://{site}{path}".format(site=site,
+ path=self.get_absolute_url())
+ return url
+
+ class Meta:
+ abstract = True
+
+ @abstractmethod
+ def get_absolute_url(self):
+ pass
+
+
+class Article(BaseModel):
+ """文章"""
+ STATUS_CHOICES = (
+ ('d', _('Draft')),
+ ('p', _('Published')),
+ )
+ COMMENT_STATUS = (
+ ('o', _('Open')),
+ ('c', _('Close')),
+ )
+ TYPE = (
+ ('a', _('Article')),
+ ('p', _('Page')),
+ )
+ title = models.CharField(_('title'), max_length=200, unique=True)
+ body = MDTextField(_('body'))
+ pub_time = models.DateTimeField(
+ _('publish time'), blank=False, null=False, default=now)
+ status = models.CharField(
+ _('status'),
+ max_length=1,
+ choices=STATUS_CHOICES,
+ default='p')
+ comment_status = models.CharField(
+ _('comment status'),
+ max_length=1,
+ choices=COMMENT_STATUS,
+ default='o')
+ type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
+ views = models.PositiveIntegerField(_('views'), default=0)
+ author = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ verbose_name=_('author'),
+ blank=False,
+ null=False,
+ on_delete=models.CASCADE)
+ article_order = models.IntegerField(
+ _('order'), blank=False, null=False, default=0)
+ show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
+ category = models.ForeignKey(
+ 'Category',
+ verbose_name=_('category'),
+ on_delete=models.CASCADE,
+ blank=False,
+ null=False)
+ tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
+
+ def body_to_string(self):
+ return self.body
+
+ def __str__(self):
+ return self.title
+
+ class Meta:
+ ordering = ['-article_order', '-pub_time']
+ verbose_name = _('article')
+ verbose_name_plural = verbose_name
+ get_latest_by = 'id'
+
+ def get_absolute_url(self):
+ return reverse('blog:detailbyid', kwargs={
+ 'article_id': self.id,
+ 'year': self.creation_time.year,
+ 'month': self.creation_time.month,
+ 'day': self.creation_time.day
+ })
+
+ @cache_decorator(60 * 60 * 10)
+ def get_category_tree(self):
+ tree = self.category.get_category_tree()
+ names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
+
+ return names
+
+ def save(self, *args, **kwargs):
+ super().save(*args, **kwargs)
+
+ def viewed(self):
+ self.views += 1
+ self.save(update_fields=['views'])
+
+ def comment_list(self):
+ cache_key = 'article_comments_{id}'.format(id=self.id)
+ value = cache.get(cache_key)
+ if value:
+ logger.info('get article comments:{id}'.format(id=self.id))
+ return value
+ else:
+ comments = self.comment_set.filter(is_enable=True).order_by('-id')
+ cache.set(cache_key, comments, 60 * 100)
+ logger.info('set article comments:{id}'.format(id=self.id))
+ return comments
+
+ def get_admin_url(self):
+ info = (self._meta.app_label, self._meta.model_name)
+ return reverse('admin:%s_%s_change' % info, args=(self.pk,))
+
+ @cache_decorator(expiration=60 * 100)
+ def next_article(self):
+ # 下一篇
+ return Article.objects.filter(
+ id__gt=self.id, status='p').order_by('id').first()
+
+ @cache_decorator(expiration=60 * 100)
+ def prev_article(self):
+ # 前一篇
+ return Article.objects.filter(id__lt=self.id, status='p').first()
+
+ def get_first_image_url(self):
+ """
+ Get the first image url from article.body.
+ :return:
+ """
+ match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
+ if match:
+ return match.group(1)
+ return ""
+
+
+class Category(BaseModel):
+ """文章分类"""
+ name = models.CharField(_('category name'), max_length=30, unique=True)
+ parent_category = models.ForeignKey(
+ 'self',
+ verbose_name=_('parent category'),
+ blank=True,
+ null=True,
+ on_delete=models.CASCADE)
+ slug = models.SlugField(default='no-slug', max_length=60, blank=True)
+ index = models.IntegerField(default=0, verbose_name=_('index'))
+
+ class Meta:
+ ordering = ['-index']
+ verbose_name = _('category')
+ verbose_name_plural = verbose_name
+
+ def get_absolute_url(self):
+ return reverse(
+ 'blog:category_detail', kwargs={
+ 'category_name': self.slug})
+
+ def __str__(self):
+ return self.name
+
+ @cache_decorator(60 * 60 * 10)
+ def get_category_tree(self):
+ """
+ 递归获得分类目录的父级
+ :return:
+ """
+ categorys = []
+
+ def parse(category):
+ categorys.append(category)
+ if category.parent_category:
+ parse(category.parent_category)
+
+ parse(self)
+ return categorys
+
+ @cache_decorator(60 * 60 * 10)
+ def get_sub_categorys(self):
+ """
+ 获得当前分类目录所有子集
+ :return:
+ """
+ categorys = []
+ all_categorys = Category.objects.all()
+
+ def parse(category):
+ if category not in categorys:
+ categorys.append(category)
+ childs = all_categorys.filter(parent_category=category)
+ for child in childs:
+ if category not in categorys:
+ categorys.append(child)
+ parse(child)
+
+ parse(self)
+ return categorys
+
+
+class Tag(BaseModel):
+ """文章标签"""
+ name = models.CharField(_('tag name'), max_length=30, unique=True)
+ slug = models.SlugField(default='no-slug', max_length=60, blank=True)
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
+
+ @cache_decorator(60 * 60 * 10)
+ def get_article_count(self):
+ return Article.objects.filter(tags__name=self.name).distinct().count()
+
+ class Meta:
+ ordering = ['name']
+ verbose_name = _('tag')
+ verbose_name_plural = verbose_name
+
+
+class Links(models.Model):
+ """友情链接"""
+
+ name = models.CharField(_('link name'), max_length=30, unique=True)
+ link = models.URLField(_('link'))
+ sequence = models.IntegerField(_('order'), unique=True)
+ is_enable = models.BooleanField(
+ _('is show'), default=True, blank=False, null=False)
+ show_type = models.CharField(
+ _('show type'),
+ max_length=1,
+ choices=LinkShowType.choices,
+ default=LinkShowType.I)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_mod_time = models.DateTimeField(_('modify time'), default=now)
+
+ class Meta:
+ ordering = ['sequence']
+ verbose_name = _('link')
+ verbose_name_plural = verbose_name
+
+ def __str__(self):
+ return self.name
+
+
+class SideBar(models.Model):
+ """侧边栏,可以展示一些html内容"""
+ name = models.CharField(_('title'), max_length=100)
+ content = models.TextField(_('content'))
+ sequence = models.IntegerField(_('order'), unique=True)
+ is_enable = models.BooleanField(_('is enable'), default=True)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_mod_time = models.DateTimeField(_('modify time'), default=now)
+
+ class Meta:
+ ordering = ['sequence']
+ verbose_name = _('sidebar')
+ verbose_name_plural = verbose_name
+
+ def __str__(self):
+ return self.name
+
+
+class BlogSettings(models.Model):
+ """blog的配置"""
+ site_name = models.CharField(
+ _('site name'),
+ max_length=200,
+ null=False,
+ blank=False,
+ default='')
+ site_description = models.TextField(
+ _('site description'),
+ max_length=1000,
+ null=False,
+ blank=False,
+ default='')
+ site_seo_description = models.TextField(
+ _('site seo description'), max_length=1000, null=False, blank=False, default='')
+ site_keywords = models.TextField(
+ _('site keywords'),
+ max_length=1000,
+ null=False,
+ blank=False,
+ default='')
+ article_sub_length = models.IntegerField(_('article sub length'), default=300)
+ sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
+ sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
+ article_comment_count = models.IntegerField(_('article comment count'), default=5)
+ show_google_adsense = models.BooleanField(_('show adsense'), default=False)
+ google_adsense_codes = models.TextField(
+ _('adsense code'), max_length=2000, null=True, blank=True, default='')
+ open_site_comment = models.BooleanField(_('open site comment'), default=True)
+ global_header = models.TextField("公共头部", null=True, blank=True, default='')
+ global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
+ beian_code = models.CharField(
+ '备案号',
+ max_length=2000,
+ null=True,
+ blank=True,
+ default='')
+ analytics_code = models.TextField(
+ "网站统计代码",
+ max_length=1000,
+ null=False,
+ blank=False,
+ default='')
+ show_gongan_code = models.BooleanField(
+ '是否显示公安备案号', default=False, null=False)
+ gongan_beiancode = models.TextField(
+ '公安备案号',
+ max_length=2000,
+ null=True,
+ blank=True,
+ default='')
+ comment_need_review = models.BooleanField(
+ '评论是否需要审核', default=False, null=False)
+
+ class Meta:
+ verbose_name = _('Website configuration')
+ verbose_name_plural = verbose_name
+
+ def __str__(self):
+ return self.site_name
+
+ def clean(self):
+ if BlogSettings.objects.exclude(id=self.id).count():
+ raise ValidationError(_('There can only be one configuration'))
+
+ def save(self, *args, **kwargs):
+ super().save(*args, **kwargs)
+ from djangoblog.utils import cache
+ cache.clear()
diff --git a/src/DjangoBlog-master/blog/search_indexes.py b/src/DjangoBlog-master/blog/search_indexes.py
new file mode 100644
index 0000000..7f1dfac
--- /dev/null
+++ b/src/DjangoBlog-master/blog/search_indexes.py
@@ -0,0 +1,13 @@
+from haystack import indexes
+
+from blog.models import Article
+
+
+class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
+ text = indexes.CharField(document=True, use_template=True)
+
+ def get_model(self):
+ return Article
+
+ def index_queryset(self, using=None):
+ return self.get_model().objects.filter(status='p')
diff --git a/src/DjangoBlog-master/blog/templatetags/__init__.py b/src/DjangoBlog-master/blog/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/DjangoBlog-master/blog/templatetags/blog_tags.py b/src/DjangoBlog-master/blog/templatetags/blog_tags.py
new file mode 100644
index 0000000..bede348
--- /dev/null
+++ b/src/DjangoBlog-master/blog/templatetags/blog_tags.py
@@ -0,0 +1,401 @@
+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):
+ html_content = CommonMarkdown.get_markdown(content)
+
+ # 然后应用插件过滤器优化HTML
+ from djangoblog.plugin_manage import hooks
+ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
+ optimized_html = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, html_content)
+
+ return mark_safe(optimized_html)
+
+
+@register.simple_tag(takes_context=True)
+def render_article_content(context, article, is_summary=False):
+ """
+ 渲染文章内容,包含完整的上下文信息供插件使用
+
+ Args:
+ context: 模板上下文
+ article: 文章对象
+ is_summary: 是否为摘要模式(首页使用)
+ """
+ if not article or not hasattr(article, 'body'):
+ return ''
+
+ # 先转换Markdown为HTML
+ html_content = CommonMarkdown.get_markdown(article.body)
+
+ # 如果是摘要模式,先截断内容再应用插件
+ if is_summary:
+ # 截断HTML内容到合适的长度(约300字符)
+ from django.utils.html import strip_tags
+ from django.template.defaultfilters import truncatechars
+
+ # 先去除HTML标签,截断纯文本,然后重新转换为HTML
+ plain_text = strip_tags(html_content)
+ truncated_text = truncatechars(plain_text, 300)
+
+ # 重新转换截断后的文本为HTML(简化版,避免复杂的插件处理)
+ html_content = CommonMarkdown.get_markdown(truncated_text)
+
+ # 然后应用插件过滤器,传递完整的上下文
+ from djangoblog.plugin_manage import hooks
+ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
+
+ # 获取request对象
+ request = context.get('request')
+
+ # 应用所有文章内容相关的插件
+ # 注意:摘要模式下某些插件(如版权声明)可能不适用
+ optimized_html = hooks.apply_filters(
+ ARTICLE_CONTENT_HOOK_NAME,
+ html_content,
+ article=article,
+ request=request,
+ context=context,
+ is_summary=is_summary # 传递摘要标志,插件可以据此调整行为
+ )
+
+ return mark_safe(optimized_html)
+
+
+@register.simple_tag
+def get_markdown_toc(content):
+ from djangoblog.utils import CommonMarkdown
+ body, toc = CommonMarkdown.get_markdown_with_toc(content)
+ return mark_safe(toc)
+
+
+@register.filter()
+@stringfilter
+def comment_markdown(content):
+ content = CommonMarkdown.get_markdown(content)
+ return mark_safe(sanitize_html(content))
+
+
+@register.filter(is_safe=True)
+@stringfilter
+def truncatechars_content(content):
+ """
+ 获得文章内容的摘要
+ :param content:
+ :return:
+ """
+ from django.template.defaultfilters import truncatechars_html
+ from djangoblog.utils import get_blog_setting
+ blogsetting = get_blog_setting()
+ return truncatechars_html(content, blogsetting.article_sub_length)
+
+
+@register.filter(is_safe=True)
+@stringfilter
+def truncate(content):
+ from django.utils.html import strip_tags
+
+ return strip_tags(content)[:150]
+
+
+@register.inclusion_tag('blog/tags/breadcrumb.html')
+def load_breadcrumb(article):
+ """
+ 获得文章面包屑
+ :param article:
+ :return:
+ """
+ names = article.get_category_tree()
+ from djangoblog.utils import get_blog_setting
+ blogsetting = get_blog_setting()
+ site = get_current_site().domain
+ names.append((blogsetting.site_name, '/'))
+ names = names[::-1]
+
+ return {
+ 'names': names,
+ 'title': article.title,
+ 'count': len(names) + 1
+ }
+
+
+@register.inclusion_tag('blog/tags/article_tag_list.html')
+def load_articletags(article):
+ """
+ 文章标签
+ :param article:
+ :return:
+ """
+ tags = article.tags.all()
+ tags_list = []
+ for tag in tags:
+ url = tag.get_absolute_url()
+ count = tag.get_article_count()
+ tags_list.append((
+ url, count, tag, random.choice(settings.BOOTSTRAP_COLOR_TYPES)
+ ))
+ return {
+ 'article_tags_list': tags_list
+ }
+
+
+@register.inclusion_tag('blog/tags/sidebar.html')
+def load_sidebar(user, linktype):
+ """
+ 加载侧边栏
+ :return:
+ """
+ value = cache.get("sidebar" + linktype)
+ if value:
+ value['user'] = user
+ return value
+ else:
+ logger.info('load sidebar')
+ from djangoblog.utils import get_blog_setting
+ blogsetting = get_blog_setting()
+ recent_articles = Article.objects.filter(
+ status='p')[:blogsetting.sidebar_article_count]
+ sidebar_categorys = Category.objects.all()
+ extra_sidebars = SideBar.objects.filter(
+ is_enable=True).order_by('sequence')
+ most_read_articles = Article.objects.filter(status='p').order_by(
+ '-views')[:blogsetting.sidebar_article_count]
+ dates = Article.objects.datetimes('creation_time', 'month', order='DESC')
+ links = Links.objects.filter(is_enable=True).filter(
+ Q(show_type=str(linktype)) | Q(show_type=LinkShowType.A))
+ commment_list = Comment.objects.filter(is_enable=True).order_by(
+ '-id')[:blogsetting.sidebar_comment_count]
+ # 标签云 计算字体大小
+ # 根据总数计算出平均值 大小为 (数目/平均值)*步长
+ increment = 5
+ tags = Tag.objects.all()
+ sidebar_tags = None
+ if tags and len(tags) > 0:
+ s = [t for t in [(t, t.get_article_count()) for t in tags] if t[1]]
+ count = sum([t[1] for t in s])
+ dd = 1 if (count == 0 or not len(tags)) else count / len(tags)
+ import random
+ sidebar_tags = list(
+ map(lambda x: (x[0], x[1], (x[1] / dd) * increment + 10), s))
+ random.shuffle(sidebar_tags)
+
+ value = {
+ 'recent_articles': recent_articles,
+ 'sidebar_categorys': sidebar_categorys,
+ 'most_read_articles': most_read_articles,
+ 'article_dates': dates,
+ 'sidebar_comments': commment_list,
+ 'sidabar_links': links,
+ 'show_google_adsense': blogsetting.show_google_adsense,
+ 'google_adsense_codes': blogsetting.google_adsense_codes,
+ 'open_site_comment': blogsetting.open_site_comment,
+ 'show_gongan_code': blogsetting.show_gongan_code,
+ 'sidebar_tags': sidebar_tags,
+ 'extra_sidebars': extra_sidebars
+ }
+ cache.set("sidebar" + linktype, value, 60 * 60 * 60 * 3)
+ logger.info('set sidebar cache.key:{key}'.format(key="sidebar" + linktype))
+ value['user'] = user
+ return value
+
+
+@register.inclusion_tag('blog/tags/article_meta_info.html')
+def load_article_metas(article, user):
+ """
+ 获得文章meta信息
+ :param article:
+ :return:
+ """
+ return {
+ 'article': article,
+ 'user': user
+ }
+
+
+@register.inclusion_tag('blog/tags/article_pagination.html')
+def load_pagination_info(page_obj, page_type, tag_name):
+ previous_url = ''
+ next_url = ''
+ if page_type == '':
+ if page_obj.has_next():
+ next_number = page_obj.next_page_number()
+ next_url = reverse('blog:index_page', kwargs={'page': next_number})
+ if page_obj.has_previous():
+ previous_number = page_obj.previous_page_number()
+ previous_url = reverse(
+ 'blog:index_page', kwargs={
+ 'page': previous_number})
+ if page_type == '分类标签归档':
+ tag = get_object_or_404(Tag, name=tag_name)
+ if page_obj.has_next():
+ next_number = page_obj.next_page_number()
+ next_url = reverse(
+ 'blog:tag_detail_page',
+ kwargs={
+ 'page': next_number,
+ 'tag_name': tag.slug})
+ if page_obj.has_previous():
+ previous_number = page_obj.previous_page_number()
+ previous_url = reverse(
+ 'blog:tag_detail_page',
+ kwargs={
+ 'page': previous_number,
+ 'tag_name': tag.slug})
+ if page_type == '作者文章归档':
+ if page_obj.has_next():
+ next_number = page_obj.next_page_number()
+ next_url = reverse(
+ 'blog:author_detail_page',
+ kwargs={
+ 'page': next_number,
+ 'author_name': tag_name})
+ if page_obj.has_previous():
+ previous_number = page_obj.previous_page_number()
+ previous_url = reverse(
+ 'blog:author_detail_page',
+ kwargs={
+ 'page': previous_number,
+ 'author_name': tag_name})
+
+ if page_type == '分类目录归档':
+ category = get_object_or_404(Category, name=tag_name)
+ if page_obj.has_next():
+ next_number = page_obj.next_page_number()
+ next_url = reverse(
+ 'blog:category_detail_page',
+ kwargs={
+ 'page': next_number,
+ 'category_name': category.slug})
+ if page_obj.has_previous():
+ previous_number = page_obj.previous_page_number()
+ previous_url = reverse(
+ 'blog:category_detail_page',
+ kwargs={
+ 'page': previous_number,
+ 'category_name': category.slug})
+
+ return {
+ 'previous_url': previous_url,
+ 'next_url': next_url,
+ 'page_obj': page_obj
+ }
+
+
+@register.inclusion_tag('blog/tags/article_info.html')
+def load_article_detail(article, isindex, user):
+ """
+ 加载文章详情
+ :param article:
+ :param isindex:是否列表页,若是列表页只显示摘要
+ :return:
+ """
+ from djangoblog.utils import get_blog_setting
+ blogsetting = get_blog_setting()
+
+ return {
+ 'article': article,
+ 'isindex': isindex,
+ 'user': user,
+ 'open_site_comment': blogsetting.open_site_comment,
+ }
+
+
+# return only the URL of the gravatar
+# TEMPLATE USE: {{ email|gravatar_url:150 }}
+@register.filter
+def gravatar_url(email, size=40):
+ """获得gravatar头像"""
+ cachekey = 'gravatat/' + email
+ url = cache.get(cachekey)
+ if url:
+ return url
+ else:
+ usermodels = OAuthUser.objects.filter(email=email)
+ if usermodels:
+ o = list(filter(lambda x: x.picture is not None, usermodels))
+ if o:
+ return o[0].picture
+ email = email.encode('utf-8')
+
+ default = static('blog/img/avatar.png')
+
+ url = "https://www.gravatar.com/avatar/%s?%s" % (hashlib.md5(
+ email.lower()).hexdigest(), urllib.parse.urlencode({'d': default, 's': str(size)}))
+ cache.set(cachekey, url, 60 * 60 * 10)
+ logger.info('set gravatar cache.key:{key}'.format(key=cachekey))
+ return url
+
+
+@register.filter
+def gravatar(email, size=40):
+ """获得gravatar头像"""
+ url = gravatar_url(email, size)
+ return mark_safe(
+ '
' %
+ (url, size, size))
+
+
+@register.simple_tag
+def query(qs, **kwargs):
+ """ template tag which allows queryset filtering. Usage:
+ {% query books author=author as mybooks %}
+ {% for book in mybooks %}
+ ...
+ {% endfor %}
+ """
+ return qs.filter(**kwargs)
+
+
+@register.filter
+def addstr(arg1, arg2):
+ """concatenate arg1 & arg2"""
+ return str(arg1) + str(arg2)
diff --git a/src/DjangoBlog-master/blog/tests.py b/src/DjangoBlog-master/blog/tests.py
new file mode 100644
index 0000000..ee13505
--- /dev/null
+++ b/src/DjangoBlog-master/blog/tests.py
@@ -0,0 +1,232 @@
+import os
+
+from django.conf import settings
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.core.management import call_command
+from django.core.paginator import Paginator
+from django.templatetags.static import static
+from django.test import Client, RequestFactory, TestCase
+from django.urls import reverse
+from django.utils import timezone
+
+from accounts.models import BlogUser
+from blog.forms import BlogSearchForm
+from blog.models import Article, Category, Tag, SideBar, Links
+from blog.templatetags.blog_tags import load_pagination_info, load_articletags
+from djangoblog.utils import get_current_site, get_sha256
+from oauth.models import OAuthUser, OAuthConfig
+
+
+# Create your tests here.
+
+class ArticleTest(TestCase):
+ def setUp(self):
+ self.client = Client()
+ self.factory = RequestFactory()
+
+ def test_validate_article(self):
+ site = get_current_site().domain
+ user = BlogUser.objects.get_or_create(
+ email="liangliangyy@gmail.com",
+ username="liangliangyy")[0]
+ user.set_password("liangliangyy")
+ user.is_staff = True
+ user.is_superuser = True
+ user.save()
+ response = self.client.get(user.get_absolute_url())
+ self.assertEqual(response.status_code, 200)
+ response = self.client.get('/admin/servermanager/emailsendlog/')
+ response = self.client.get('admin/admin/logentry/')
+ s = SideBar()
+ s.sequence = 1
+ s.name = 'test'
+ s.content = 'test content'
+ s.is_enable = True
+ s.save()
+
+ category = Category()
+ category.name = "category"
+ category.creation_time = timezone.now()
+ category.last_mod_time = timezone.now()
+ category.save()
+
+ tag = Tag()
+ tag.name = "nicetag"
+ tag.save()
+
+ article = Article()
+ article.title = "nicetitle"
+ article.body = "nicecontent"
+ article.author = user
+ article.category = category
+ article.type = 'a'
+ article.status = 'p'
+
+ article.save()
+ self.assertEqual(0, article.tags.count())
+ article.tags.add(tag)
+ article.save()
+ self.assertEqual(1, article.tags.count())
+
+ for i in range(20):
+ article = Article()
+ article.title = "nicetitle" + str(i)
+ article.body = "nicetitle" + str(i)
+ article.author = user
+ article.category = category
+ article.type = 'a'
+ article.status = 'p'
+ article.save()
+ article.tags.add(tag)
+ article.save()
+ from blog.documents import ELASTICSEARCH_ENABLED
+ if ELASTICSEARCH_ENABLED:
+ call_command("build_index")
+ response = self.client.get('/search', {'q': 'nicetitle'})
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get(article.get_absolute_url())
+ self.assertEqual(response.status_code, 200)
+ from djangoblog.spider_notify import SpiderNotify
+ SpiderNotify.notify(article.get_absolute_url())
+ response = self.client.get(tag.get_absolute_url())
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get(category.get_absolute_url())
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get('/search', {'q': 'django'})
+ self.assertEqual(response.status_code, 200)
+ s = load_articletags(article)
+ self.assertIsNotNone(s)
+
+ self.client.login(username='liangliangyy', password='liangliangyy')
+
+ response = self.client.get(reverse('blog:archives'))
+ self.assertEqual(response.status_code, 200)
+
+ p = Paginator(Article.objects.all(), settings.PAGINATE_BY)
+ self.check_pagination(p, '', '')
+
+ p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY)
+ self.check_pagination(p, '分类标签归档', tag.slug)
+
+ p = Paginator(
+ Article.objects.filter(
+ author__username='liangliangyy'), settings.PAGINATE_BY)
+ self.check_pagination(p, '作者文章归档', 'liangliangyy')
+
+ p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY)
+ self.check_pagination(p, '分类目录归档', category.slug)
+
+ f = BlogSearchForm()
+ f.search()
+ # self.client.login(username='liangliangyy', password='liangliangyy')
+ from djangoblog.spider_notify import SpiderNotify
+ SpiderNotify.baidu_notify([article.get_full_url()])
+
+ from blog.templatetags.blog_tags import gravatar_url, gravatar
+ u = gravatar_url('liangliangyy@gmail.com')
+ u = gravatar('liangliangyy@gmail.com')
+
+ link = Links(
+ sequence=1,
+ name="lylinux",
+ link='https://wwww.lylinux.net')
+ link.save()
+ response = self.client.get('/links.html')
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get('/feed/')
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get('/sitemap.xml')
+ self.assertEqual(response.status_code, 200)
+
+ self.client.get("/admin/blog/article/1/delete/")
+ self.client.get('/admin/servermanager/emailsendlog/')
+ self.client.get('/admin/admin/logentry/')
+ self.client.get('/admin/admin/logentry/1/change/')
+
+ def check_pagination(self, p, type, value):
+ for page in range(1, p.num_pages + 1):
+ s = load_pagination_info(p.page(page), type, value)
+ self.assertIsNotNone(s)
+ if s['previous_url']:
+ response = self.client.get(s['previous_url'])
+ self.assertEqual(response.status_code, 200)
+ if s['next_url']:
+ response = self.client.get(s['next_url'])
+ self.assertEqual(response.status_code, 200)
+
+ def test_image(self):
+ import requests
+ rsp = requests.get(
+ 'https://www.python.org/static/img/python-logo.png')
+ imagepath = os.path.join(settings.BASE_DIR, 'python.png')
+ with open(imagepath, 'wb') as file:
+ file.write(rsp.content)
+ rsp = self.client.post('/upload')
+ self.assertEqual(rsp.status_code, 403)
+ sign = get_sha256(get_sha256(settings.SECRET_KEY))
+ with open(imagepath, 'rb') as file:
+ imgfile = SimpleUploadedFile(
+ 'python.png', file.read(), content_type='image/jpg')
+ form_data = {'python.png': imgfile}
+ rsp = self.client.post(
+ '/upload?sign=' + sign, form_data, follow=True)
+ self.assertEqual(rsp.status_code, 200)
+ os.remove(imagepath)
+ from djangoblog.utils import save_user_avatar, send_email
+ send_email(['qq@qq.com'], 'testTitle', 'testContent')
+ save_user_avatar(
+ 'https://www.python.org/static/img/python-logo.png')
+
+ def test_errorpage(self):
+ rsp = self.client.get('/eee')
+ self.assertEqual(rsp.status_code, 404)
+
+ def test_commands(self):
+ user = BlogUser.objects.get_or_create(
+ email="liangliangyy@gmail.com",
+ username="liangliangyy")[0]
+ user.set_password("liangliangyy")
+ user.is_staff = True
+ user.is_superuser = True
+ user.save()
+
+ c = OAuthConfig()
+ c.type = 'qq'
+ c.appkey = 'appkey'
+ c.appsecret = 'appsecret'
+ c.save()
+
+ u = OAuthUser()
+ u.type = 'qq'
+ u.openid = 'openid'
+ u.user = user
+ u.picture = static("/blog/img/avatar.png")
+ u.metadata = '''
+{
+"figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30"
+}'''
+ u.save()
+
+ u = OAuthUser()
+ u.type = 'qq'
+ u.openid = 'openid1'
+ u.picture = 'https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30'
+ u.metadata = '''
+ {
+ "figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30"
+ }'''
+ u.save()
+
+ from blog.documents import ELASTICSEARCH_ENABLED
+ if ELASTICSEARCH_ENABLED:
+ call_command("build_index")
+ call_command("ping_baidu", "all")
+ call_command("create_testdata")
+ call_command("clear_cache")
+ call_command("sync_user_avatar")
+ call_command("build_search_words")
diff --git a/src/DjangoBlog-master/blog/urls.py b/src/DjangoBlog-master/blog/urls.py
new file mode 100644
index 0000000..adf2703
--- /dev/null
+++ b/src/DjangoBlog-master/blog/urls.py
@@ -0,0 +1,62 @@
+from django.urls import path
+from django.views.decorators.cache import cache_page
+
+from . import views
+
+app_name = "blog"
+urlpatterns = [
+ path(
+ r'',
+ views.IndexView.as_view(),
+ name='index'),
+ path(
+ r'page//',
+ views.IndexView.as_view(),
+ name='index_page'),
+ path(
+ r'article////.html',
+ views.ArticleDetailView.as_view(),
+ name='detailbyid'),
+ path(
+ r'category/.html',
+ views.CategoryDetailView.as_view(),
+ name='category_detail'),
+ path(
+ r'category//.html',
+ views.CategoryDetailView.as_view(),
+ name='category_detail_page'),
+ path(
+ r'author/.html',
+ views.AuthorDetailView.as_view(),
+ name='author_detail'),
+ path(
+ r'author//.html',
+ views.AuthorDetailView.as_view(),
+ name='author_detail_page'),
+ path(
+ r'tag/.html',
+ views.TagDetailView.as_view(),
+ name='tag_detail'),
+ path(
+ r'tag//.html',
+ views.TagDetailView.as_view(),
+ name='tag_detail_page'),
+ path(
+ 'archives.html',
+ cache_page(
+ 60 * 60)(
+ views.ArchivesView.as_view()),
+ name='archives'),
+ path(
+ 'links.html',
+ views.LinkListView.as_view(),
+ name='links'),
+ path(
+ r'upload',
+ views.fileupload,
+ name='upload'),
+ path(
+ r'clean',
+ views.clean_cache_view,
+ name='clean'),
+]
diff --git a/src/DjangoBlog-master/blog/views.py b/src/DjangoBlog-master/blog/views.py
new file mode 100644
index 0000000..ace9e63
--- /dev/null
+++ b/src/DjangoBlog-master/blog/views.py
@@ -0,0 +1,375 @@
+import logging
+import os
+import uuid
+
+from django.conf import settings
+from django.core.paginator import Paginator
+from django.http import HttpResponse, HttpResponseForbidden
+from django.shortcuts import get_object_or_404
+from django.shortcuts import render
+from django.templatetags.static import static
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+from django.views.decorators.csrf import csrf_exempt
+from django.views.generic.detail import DetailView
+from django.views.generic.list import ListView
+from haystack.views import SearchView
+
+from blog.models import Article, Category, LinkShowType, Links, Tag
+from comments.forms import CommentForm
+from djangoblog.plugin_manage import hooks
+from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
+from djangoblog.utils import cache, get_blog_setting, get_sha256
+
+logger = logging.getLogger(__name__)
+
+
+class ArticleListView(ListView):
+ # template_name属性用于指定使用哪个模板进行渲染
+ template_name = 'blog/article_index.html'
+
+ # context_object_name属性用于给上下文变量取名(在模板中使用该名字)
+ context_object_name = 'article_list'
+
+ # 页面类型,分类目录或标签列表等
+ page_type = ''
+ paginate_by = settings.PAGINATE_BY
+ page_kwarg = 'page'
+ link_type = LinkShowType.L
+
+ def get_view_cache_key(self):
+ return self.request.get['pages']
+
+ @property
+ def page_number(self):
+ page_kwarg = self.page_kwarg
+ page = self.kwargs.get(
+ page_kwarg) or self.request.GET.get(page_kwarg) or 1
+ return page
+
+ def get_queryset_cache_key(self):
+ """
+ 子类重写.获得queryset的缓存key
+ """
+ raise NotImplementedError()
+
+ def get_queryset_data(self):
+ """
+ 子类重写.获取queryset的数据
+ """
+ raise NotImplementedError()
+
+ def get_queryset_from_cache(self, cache_key):
+ '''
+ 缓存页面数据
+ :param cache_key: 缓存key
+ :return:
+ '''
+ value = cache.get(cache_key)
+ if value:
+ logger.info('get view cache.key:{key}'.format(key=cache_key))
+ return value
+ else:
+ article_list = self.get_queryset_data()
+ cache.set(cache_key, article_list)
+ logger.info('set view cache.key:{key}'.format(key=cache_key))
+ return article_list
+
+ def get_queryset(self):
+ '''
+ 重写默认,从缓存获取数据
+ :return:
+ '''
+ key = self.get_queryset_cache_key()
+ value = self.get_queryset_from_cache(key)
+ return value
+
+ def get_context_data(self, **kwargs):
+ kwargs['linktype'] = self.link_type
+ return super(ArticleListView, self).get_context_data(**kwargs)
+
+
+class IndexView(ArticleListView):
+ '''
+ 首页
+ '''
+ # 友情链接类型
+ link_type = LinkShowType.I
+
+ def get_queryset_data(self):
+ article_list = Article.objects.filter(type='a', status='p')
+ return article_list
+
+ def get_queryset_cache_key(self):
+ cache_key = 'index_{page}'.format(page=self.page_number)
+ return cache_key
+
+
+class ArticleDetailView(DetailView):
+ '''
+ 文章详情页面
+ '''
+ template_name = 'blog/article_detail.html'
+ model = Article
+ pk_url_kwarg = 'article_id'
+ context_object_name = "article"
+
+ def get_context_data(self, **kwargs):
+ comment_form = CommentForm()
+
+ article_comments = self.object.comment_list()
+ parent_comments = article_comments.filter(parent_comment=None)
+ blog_setting = get_blog_setting()
+ paginator = Paginator(parent_comments, blog_setting.article_comment_count)
+ page = self.request.GET.get('comment_page', '1')
+ if not page.isnumeric():
+ page = 1
+ else:
+ page = int(page)
+ if page < 1:
+ page = 1
+ if page > paginator.num_pages:
+ page = paginator.num_pages
+
+ p_comments = paginator.page(page)
+ next_page = p_comments.next_page_number() if p_comments.has_next() else None
+ prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None
+
+ if next_page:
+ kwargs[
+ 'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container'
+ if prev_page:
+ kwargs[
+ 'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container'
+ kwargs['form'] = comment_form
+ kwargs['article_comments'] = article_comments
+ kwargs['p_comments'] = p_comments
+ kwargs['comment_count'] = len(
+ article_comments) if article_comments else 0
+
+ kwargs['next_article'] = self.object.next_article
+ kwargs['prev_article'] = self.object.prev_article
+
+ context = super(ArticleDetailView, self).get_context_data(**kwargs)
+ article = self.object
+ # Action Hook, 通知插件"文章详情已获取"
+ hooks.run_action('after_article_body_get', article=article, request=self.request)
+ return context
+
+
+class CategoryDetailView(ArticleListView):
+ '''
+ 分类目录列表
+ '''
+ page_type = "分类目录归档"
+
+ def get_queryset_data(self):
+ slug = self.kwargs['category_name']
+ category = get_object_or_404(Category, slug=slug)
+
+ categoryname = category.name
+ self.categoryname = categoryname
+ categorynames = list(
+ map(lambda c: c.name, category.get_sub_categorys()))
+ article_list = Article.objects.filter(
+ category__name__in=categorynames, status='p')
+ return article_list
+
+ def get_queryset_cache_key(self):
+ slug = self.kwargs['category_name']
+ category = get_object_or_404(Category, slug=slug)
+ categoryname = category.name
+ self.categoryname = categoryname
+ cache_key = 'category_list_{categoryname}_{page}'.format(
+ categoryname=categoryname, page=self.page_number)
+ return cache_key
+
+ def get_context_data(self, **kwargs):
+
+ categoryname = self.categoryname
+ try:
+ categoryname = categoryname.split('/')[-1]
+ except BaseException:
+ pass
+ kwargs['page_type'] = CategoryDetailView.page_type
+ kwargs['tag_name'] = categoryname
+ return super(CategoryDetailView, self).get_context_data(**kwargs)
+
+
+class AuthorDetailView(ArticleListView):
+ '''
+ 作者详情页
+ '''
+ page_type = '作者文章归档'
+
+ def get_queryset_cache_key(self):
+ from uuslug import slugify
+ author_name = slugify(self.kwargs['author_name'])
+ cache_key = 'author_{author_name}_{page}'.format(
+ author_name=author_name, page=self.page_number)
+ return cache_key
+
+ def get_queryset_data(self):
+ author_name = self.kwargs['author_name']
+ article_list = Article.objects.filter(
+ author__username=author_name, type='a', status='p')
+ return article_list
+
+ def get_context_data(self, **kwargs):
+ author_name = self.kwargs['author_name']
+ kwargs['page_type'] = AuthorDetailView.page_type
+ kwargs['tag_name'] = author_name
+ return super(AuthorDetailView, self).get_context_data(**kwargs)
+
+
+class TagDetailView(ArticleListView):
+ '''
+ 标签列表页面
+ '''
+ page_type = '分类标签归档'
+
+ def get_queryset_data(self):
+ slug = self.kwargs['tag_name']
+ tag = get_object_or_404(Tag, slug=slug)
+ tag_name = tag.name
+ self.name = tag_name
+ article_list = Article.objects.filter(
+ tags__name=tag_name, type='a', status='p')
+ return article_list
+
+ def get_queryset_cache_key(self):
+ slug = self.kwargs['tag_name']
+ tag = get_object_or_404(Tag, slug=slug)
+ tag_name = tag.name
+ self.name = tag_name
+ cache_key = 'tag_{tag_name}_{page}'.format(
+ tag_name=tag_name, page=self.page_number)
+ return cache_key
+
+ def get_context_data(self, **kwargs):
+ # tag_name = self.kwargs['tag_name']
+ tag_name = self.name
+ kwargs['page_type'] = TagDetailView.page_type
+ kwargs['tag_name'] = tag_name
+ return super(TagDetailView, self).get_context_data(**kwargs)
+
+
+class ArchivesView(ArticleListView):
+ '''
+ 文章归档页面
+ '''
+ page_type = '文章归档'
+ paginate_by = None
+ page_kwarg = None
+ template_name = 'blog/article_archives.html'
+
+ def get_queryset_data(self):
+ return Article.objects.filter(status='p').all()
+
+ def get_queryset_cache_key(self):
+ cache_key = 'archives'
+ return cache_key
+
+
+class LinkListView(ListView):
+ model = Links
+ template_name = 'blog/links_list.html'
+
+ def get_queryset(self):
+ return Links.objects.filter(is_enable=True)
+
+
+class EsSearchView(SearchView):
+ def get_context(self):
+ paginator, page = self.build_page()
+ context = {
+ "query": self.query,
+ "form": self.form,
+ "page": page,
+ "paginator": paginator,
+ "suggestion": None,
+ }
+ if hasattr(self.results, "query") and self.results.query.backend.include_spelling:
+ context["suggestion"] = self.results.query.get_spelling_suggestion()
+ context.update(self.extra_context())
+
+ return context
+
+
+@csrf_exempt
+def fileupload(request):
+ """
+ 该方法需自己写调用端来上传图片,该方法仅提供图床功能
+ :param request:
+ :return:
+ """
+ if request.method == 'POST':
+ sign = request.GET.get('sign', None)
+ if not sign:
+ return HttpResponseForbidden()
+ if not sign == get_sha256(get_sha256(settings.SECRET_KEY)):
+ return HttpResponseForbidden()
+ response = []
+ for filename in request.FILES:
+ timestr = timezone.now().strftime('%Y/%m/%d')
+ imgextensions = ['jpg', 'png', 'jpeg', 'bmp']
+ fname = u''.join(str(filename))
+ isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0
+ base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr)
+ if not os.path.exists(base_dir):
+ os.makedirs(base_dir)
+ savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}"))
+ if not savepath.startswith(base_dir):
+ return HttpResponse("only for post")
+ with open(savepath, 'wb+') as wfile:
+ for chunk in request.FILES[filename].chunks():
+ wfile.write(chunk)
+ if isimage:
+ from PIL import Image
+ image = Image.open(savepath)
+ image.save(savepath, quality=20, optimize=True)
+ url = static(savepath)
+ response.append(url)
+ return HttpResponse(response)
+
+ else:
+ return HttpResponse("only for post")
+
+
+def page_not_found_view(
+ request,
+ exception,
+ template_name='blog/error_page.html'):
+ if exception:
+ logger.error(exception)
+ url = request.get_full_path()
+ return render(request,
+ template_name,
+ {'message': _('Sorry, the page you requested is not found, please click the home page to see other?'),
+ 'statuscode': '404'},
+ status=404)
+
+
+def server_error_view(request, template_name='blog/error_page.html'):
+ return render(request,
+ template_name,
+ {'message': _('Sorry, the server is busy, please click the home page to see other?'),
+ 'statuscode': '500'},
+ status=500)
+
+
+def permission_denied_view(
+ request,
+ exception,
+ template_name='blog/error_page.html'):
+ if exception:
+ logger.error(exception)
+ return render(
+ request, template_name, {
+ 'message': _('Sorry, you do not have permission to access this page?'),
+ 'statuscode': '403'}, status=403)
+
+
+def clean_cache_view(request):
+ cache.clear()
+ return HttpResponse('ok')
diff --git a/src/DjangoBlog-master/codecov.yml b/src/DjangoBlog-master/codecov.yml
new file mode 100644
index 0000000..2298829
--- /dev/null
+++ b/src/DjangoBlog-master/codecov.yml
@@ -0,0 +1,87 @@
+codecov:
+ require_ci_to_pass: yes
+
+coverage:
+ precision: 2
+ round: down
+ range: "70...100"
+
+ status:
+ project:
+ default:
+ target: auto
+ threshold: 1%
+ informational: true
+ patch:
+ default:
+ target: auto
+ threshold: 1%
+ informational: true
+
+parsers:
+ gcov:
+ branch_detection:
+ conditional: yes
+ loop: yes
+ method: no
+ macro: no
+
+comment:
+ layout: "reach,diff,flags,tree"
+ behavior: default
+ require_changes: no
+
+ignore:
+ # Django 相关
+ - "*/migrations/*"
+ - "manage.py"
+ - "*/settings.py"
+ - "*/wsgi.py"
+ - "*/asgi.py"
+
+ # 测试相关
+ - "*/tests/*"
+ - "*/test_*.py"
+ - "*/*test*.py"
+
+ # 静态文件和模板
+ - "*/static/*"
+ - "*/templates/*"
+ - "*/collectedstatic/*"
+
+ # 国际化文件
+ - "*/locale/*"
+ - "**/*.po"
+ - "**/*.mo"
+
+ # 文档和部署
+ - "*/docs/*"
+ - "*/deploy/*"
+ - "README*.md"
+ - "LICENSE"
+ - "Dockerfile"
+ - "docker-compose*.yml"
+ - "*.yaml"
+ - "*.yml"
+
+ # 开发环境
+ - "*/venv/*"
+ - "*/__pycache__/*"
+ - "*.pyc"
+ - ".coverage"
+ - "coverage.xml"
+
+ # 日志文件
+ - "*/logs/*"
+ - "*.log"
+
+ # 特定文件
+ - "*/whoosh_cn_backend.py" # 搜索后端
+ - "*/elasticsearch_backend.py" # 搜索后端
+ - "*/MemcacheStorage.py" # 缓存存储
+ - "*/robot.py" # 机器人相关
+
+ # 配置文件
+ - "codecov.yml"
+ - ".coveragerc"
+ - "requirements*.txt"
diff --git a/src/DjangoBlog-master/comments/__init__.py b/src/DjangoBlog-master/comments/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/DjangoBlog-master/comments/admin.py b/src/DjangoBlog-master/comments/admin.py
new file mode 100644
index 0000000..dbde14f
--- /dev/null
+++ b/src/DjangoBlog-master/comments/admin.py
@@ -0,0 +1,49 @@
+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]
+ raw_id_fields = ('author', 'article')
+ search_fields = ('body',)
+
+ 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/DjangoBlog-master/comments/apps.py b/src/DjangoBlog-master/comments/apps.py
new file mode 100644
index 0000000..ff01b77
--- /dev/null
+++ b/src/DjangoBlog-master/comments/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class CommentsConfig(AppConfig):
+ name = 'comments'
diff --git a/src/DjangoBlog-master/comments/forms.py b/src/DjangoBlog-master/comments/forms.py
new file mode 100644
index 0000000..e83737d
--- /dev/null
+++ b/src/DjangoBlog-master/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/DjangoBlog-master/comments/migrations/0001_initial.py b/src/DjangoBlog-master/comments/migrations/0001_initial.py
new file mode 100644
index 0000000..61d1e53
--- /dev/null
+++ b/src/DjangoBlog-master/comments/migrations/0001_initial.py
@@ -0,0 +1,38 @@
+# Generated by Django 4.1.7 on 2023-03-02 07:14
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('blog', '0001_initial'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Comment',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('body', models.TextField(max_length=300, verbose_name='正文')),
+ ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
+ ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
+ ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
+ ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')),
+ ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
+ ('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')),
+ ],
+ options={
+ 'verbose_name': '评论',
+ 'verbose_name_plural': '评论',
+ 'ordering': ['-id'],
+ 'get_latest_by': 'id',
+ },
+ ),
+ ]
diff --git a/src/DjangoBlog-master/comments/migrations/0002_alter_comment_is_enable.py b/src/DjangoBlog-master/comments/migrations/0002_alter_comment_is_enable.py
new file mode 100644
index 0000000..17c44db
--- /dev/null
+++ b/src/DjangoBlog-master/comments/migrations/0002_alter_comment_is_enable.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.7 on 2023-04-24 13:48
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('comments', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='comment',
+ name='is_enable',
+ field=models.BooleanField(default=False, verbose_name='是否显示'),
+ ),
+ ]
diff --git a/src/DjangoBlog-master/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py b/src/DjangoBlog-master/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py
new file mode 100644
index 0000000..a1ca970
--- /dev/null
+++ b/src/DjangoBlog-master/comments/migrations/0003_alter_comment_options_remove_comment_created_time_and_more.py
@@ -0,0 +1,60 @@
+# Generated by Django 4.2.5 on 2023-09-06 13:13
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('blog', '0005_alter_article_options_alter_category_options_and_more'),
+ ('comments', '0002_alter_comment_is_enable'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='comment',
+ options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'},
+ ),
+ migrations.RemoveField(
+ model_name='comment',
+ name='created_time',
+ ),
+ migrations.RemoveField(
+ model_name='comment',
+ name='last_mod_time',
+ ),
+ migrations.AddField(
+ model_name='comment',
+ name='creation_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
+ ),
+ migrations.AddField(
+ model_name='comment',
+ name='last_modify_time',
+ field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
+ ),
+ migrations.AlterField(
+ model_name='comment',
+ name='article',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'),
+ ),
+ migrations.AlterField(
+ model_name='comment',
+ name='author',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
+ ),
+ migrations.AlterField(
+ model_name='comment',
+ name='is_enable',
+ field=models.BooleanField(default=False, verbose_name='enable'),
+ ),
+ migrations.AlterField(
+ model_name='comment',
+ name='parent_comment',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'),
+ ),
+ ]
diff --git a/src/DjangoBlog-master/comments/migrations/__init__.py b/src/DjangoBlog-master/comments/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/DjangoBlog-master/comments/models.py b/src/DjangoBlog-master/comments/models.py
new file mode 100644
index 0000000..7c3bbc8
--- /dev/null
+++ b/src/DjangoBlog-master/comments/models.py
@@ -0,0 +1,39 @@
+from django.conf import settings
+from django.db import models
+from django.utils.timezone import now
+from django.utils.translation import gettext_lazy as _
+
+from blog.models import Article
+
+
+# Create your models here.
+
+class Comment(models.Model):
+ body = models.TextField('正文', max_length=300)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_modify_time = models.DateTimeField(_('last modify time'), default=now)
+ author = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ verbose_name=_('author'),
+ on_delete=models.CASCADE)
+ article = models.ForeignKey(
+ Article,
+ verbose_name=_('article'),
+ on_delete=models.CASCADE)
+ parent_comment = models.ForeignKey(
+ 'self',
+ verbose_name=_('parent comment'),
+ blank=True,
+ null=True,
+ on_delete=models.CASCADE)
+ is_enable = models.BooleanField(_('enable'),
+ default=False, blank=False, null=False)
+
+ class Meta:
+ ordering = ['-id']
+ verbose_name = _('comment')
+ verbose_name_plural = verbose_name
+ get_latest_by = 'id'
+
+ def __str__(self):
+ return self.body
diff --git a/src/DjangoBlog-master/comments/templatetags/__init__.py b/src/DjangoBlog-master/comments/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/DjangoBlog-master/comments/templatetags/comments_tags.py b/src/DjangoBlog-master/comments/templatetags/comments_tags.py
new file mode 100644
index 0000000..fde02b4
--- /dev/null
+++ b/src/DjangoBlog-master/comments/templatetags/comments_tags.py
@@ -0,0 +1,30 @@
+from django import template
+
+register = template.Library()
+
+
+@register.simple_tag
+def parse_commenttree(commentlist, comment):
+ """获得当前评论子评论的列表
+ 用法: {% parse_commenttree article_comments comment as childcomments %}
+ """
+ datas = []
+
+ def parse(c):
+ childs = commentlist.filter(parent_comment=c, is_enable=True)
+ for child in childs:
+ datas.append(child)
+ parse(child)
+
+ parse(comment)
+ return datas
+
+
+@register.inclusion_tag('comments/tags/comment_item.html')
+def show_comment_item(comment, ischild):
+ """评论"""
+ depth = 1 if ischild else 2
+ return {
+ 'comment_item': comment,
+ 'depth': depth
+ }
diff --git a/src/DjangoBlog-master/comments/tests.py b/src/DjangoBlog-master/comments/tests.py
new file mode 100644
index 0000000..2a7f55f
--- /dev/null
+++ b/src/DjangoBlog-master/comments/tests.py
@@ -0,0 +1,109 @@
+from django.test import Client, RequestFactory, TransactionTestCase
+from django.urls import reverse
+
+from accounts.models import BlogUser
+from blog.models import Category, Article
+from comments.models import Comment
+from comments.templatetags.comments_tags import *
+from djangoblog.utils import get_max_articleid_commentid
+
+
+# Create your tests here.
+
+class CommentsTest(TransactionTestCase):
+ def setUp(self):
+ self.client = Client()
+ self.factory = RequestFactory()
+ from blog.models import BlogSettings
+ value = BlogSettings()
+ value.comment_need_review = True
+ value.save()
+
+ self.user = BlogUser.objects.create_superuser(
+ email="liangliangyy1@gmail.com",
+ username="liangliangyy1",
+ password="liangliangyy1")
+
+ def update_article_comment_status(self, article):
+ comments = article.comment_set.all()
+ for comment in comments:
+ comment.is_enable = True
+ comment.save()
+
+ def test_validate_comment(self):
+ self.client.login(username='liangliangyy1', password='liangliangyy1')
+
+ category = Category()
+ category.name = "categoryccc"
+ category.save()
+
+ article = Article()
+ article.title = "nicetitleccc"
+ article.body = "nicecontentccc"
+ article.author = self.user
+ article.category = category
+ article.type = 'a'
+ article.status = 'p'
+ article.save()
+
+ comment_url = reverse(
+ 'comments:postcomment', kwargs={
+ 'article_id': article.id})
+
+ response = self.client.post(comment_url,
+ {
+ 'body': '123ffffffffff'
+ })
+
+ self.assertEqual(response.status_code, 302)
+
+ article = Article.objects.get(pk=article.pk)
+ self.assertEqual(len(article.comment_list()), 0)
+ self.update_article_comment_status(article)
+
+ self.assertEqual(len(article.comment_list()), 1)
+
+ response = self.client.post(comment_url,
+ {
+ 'body': '123ffffffffff',
+ })
+
+ self.assertEqual(response.status_code, 302)
+
+ article = Article.objects.get(pk=article.pk)
+ self.update_article_comment_status(article)
+ self.assertEqual(len(article.comment_list()), 2)
+ parent_comment_id = article.comment_list()[0].id
+
+ response = self.client.post(comment_url,
+ {
+ 'body': '''
+ # Title1
+
+ ```python
+ import os
+ ```
+
+ [url](https://www.lylinux.net/)
+
+ [ddd](http://www.baidu.com)
+
+
+ ''',
+ 'parent_comment_id': parent_comment_id
+ })
+
+ self.assertEqual(response.status_code, 302)
+ self.update_article_comment_status(article)
+ article = Article.objects.get(pk=article.pk)
+ self.assertEqual(len(article.comment_list()), 3)
+ comment = Comment.objects.get(id=parent_comment_id)
+ tree = parse_commenttree(article.comment_list(), comment)
+ self.assertEqual(len(tree), 1)
+ data = show_comment_item(comment, True)
+ self.assertIsNotNone(data)
+ s = get_max_articleid_commentid()
+ self.assertIsNotNone(s)
+
+ from comments.utils import send_comment_email
+ send_comment_email(comment)
diff --git a/src/DjangoBlog-master/comments/urls.py b/src/DjangoBlog-master/comments/urls.py
new file mode 100644
index 0000000..7df3fab
--- /dev/null
+++ b/src/DjangoBlog-master/comments/urls.py
@@ -0,0 +1,11 @@
+from django.urls import path
+
+from . import views
+
+app_name = "comments"
+urlpatterns = [
+ path(
+ 'article//postcomment',
+ views.CommentPostView.as_view(),
+ name='postcomment'),
+]
diff --git a/src/DjangoBlog-master/comments/utils.py b/src/DjangoBlog-master/comments/utils.py
new file mode 100644
index 0000000..f01dba7
--- /dev/null
+++ b/src/DjangoBlog-master/comments/utils.py
@@ -0,0 +1,38 @@
+import logging
+
+from django.utils.translation import gettext_lazy as _
+
+from djangoblog.utils import get_current_site
+from djangoblog.utils import send_email
+
+logger = logging.getLogger(__name__)
+
+
+def send_comment_email(comment):
+ site = get_current_site().domain
+ subject = _('Thanks for your comment')
+ article_url = f"https://{site}{comment.article.get_absolute_url()}"
+ html_content = _("""Thank you very much for your comments on this site
+ You can visit %(article_title)s
+ to review your comments,
+ Thank you again!
+
+ If the link above cannot be opened, please copy this link to your browser.
+ %(article_url)s""") % {'article_url': article_url, 'article_title': comment.article.title}
+ tomail = comment.author.email
+ send_email([tomail], subject, html_content)
+ try:
+ if comment.parent_comment:
+ html_content = _("""Your comment on %(article_title)s
has
+ received a reply.
%(comment_body)s
+
+ go check it out!
+
+ If the link above cannot be opened, please copy this link to your browser.
+ %(article_url)s
+ """) % {'article_url': article_url, 'article_title': comment.article.title,
+ 'comment_body': comment.parent_comment.body}
+ tomail = comment.parent_comment.author.email
+ send_email([tomail], subject, html_content)
+ except Exception as e:
+ logger.error(e)
diff --git a/src/DjangoBlog-master/comments/views.py b/src/DjangoBlog-master/comments/views.py
new file mode 100644
index 0000000..ad9b2b9
--- /dev/null
+++ b/src/DjangoBlog-master/comments/views.py
@@ -0,0 +1,63 @@
+# Create your views here.
+from django.core.exceptions import ValidationError
+from django.http import HttpResponseRedirect
+from django.shortcuts import get_object_or_404
+from django.utils.decorators import method_decorator
+from django.views.decorators.csrf import csrf_protect
+from django.views.generic.edit import FormView
+
+from accounts.models import BlogUser
+from blog.models import Article
+from .forms import CommentForm
+from .models import Comment
+
+
+class CommentPostView(FormView):
+ form_class = CommentForm
+ template_name = 'blog/article_detail.html'
+
+ @method_decorator(csrf_protect)
+ def dispatch(self, *args, **kwargs):
+ return super(CommentPostView, self).dispatch(*args, **kwargs)
+
+ def get(self, request, *args, **kwargs):
+ article_id = self.kwargs['article_id']
+ article = get_object_or_404(Article, pk=article_id)
+ url = article.get_absolute_url()
+ return HttpResponseRedirect(url + "#comments")
+
+ def form_invalid(self, form):
+ article_id = self.kwargs['article_id']
+ article = get_object_or_404(Article, pk=article_id)
+
+ return self.render_to_response({
+ 'form': form,
+ 'article': article
+ })
+
+ def form_valid(self, form):
+ """提交的数据验证合法后的逻辑"""
+ user = self.request.user
+ author = BlogUser.objects.get(pk=user.pk)
+ article_id = self.kwargs['article_id']
+ article = get_object_or_404(Article, pk=article_id)
+
+ if article.comment_status == 'c' or article.status == 'c':
+ raise ValidationError("该文章评论已关闭.")
+ comment = form.save(False)
+ comment.article = article
+ from djangoblog.utils import get_blog_setting
+ settings = get_blog_setting()
+ if not settings.comment_need_review:
+ comment.is_enable = True
+ comment.author = author
+
+ if form.cleaned_data['parent_comment_id']:
+ parent_comment = Comment.objects.get(
+ pk=form.cleaned_data['parent_comment_id'])
+ comment.parent_comment = parent_comment
+
+ comment.save(True)
+ return HttpResponseRedirect(
+ "%s#div-comment-%d" %
+ (article.get_absolute_url(), comment.pk))
diff --git a/src/DjangoBlog-master/deploy/docker-compose/docker-compose.es.yml b/src/DjangoBlog-master/deploy/docker-compose/docker-compose.es.yml
new file mode 100644
index 0000000..83e35ff
--- /dev/null
+++ b/src/DjangoBlog-master/deploy/docker-compose/docker-compose.es.yml
@@ -0,0 +1,48 @@
+version: '3'
+
+services:
+ es:
+ image: liangliangyy/elasticsearch-analysis-ik:8.6.1
+ container_name: es
+ restart: always
+ environment:
+ - discovery.type=single-node
+ - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
+ ports:
+ - 9200:9200
+ volumes:
+ - ./bin/datas/es/:/usr/share/elasticsearch/data/
+
+ kibana:
+ image: kibana:8.6.1
+ restart: always
+ container_name: kibana
+ ports:
+ - 5601:5601
+ environment:
+ - ELASTICSEARCH_HOSTS=http://es:9200
+
+ djangoblog:
+ build: .
+ restart: always
+ command: bash -c 'sh /code/djangoblog/bin/docker_start.sh'
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./collectedstatic:/code/djangoblog/collectedstatic
+ - ./uploads:/code/djangoblog/uploads
+ environment:
+ - DJANGO_MYSQL_DATABASE=djangoblog
+ - DJANGO_MYSQL_USER=root
+ - DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E
+ - DJANGO_MYSQL_HOST=db
+ - DJANGO_MYSQL_PORT=3306
+ - DJANGO_MEMCACHED_LOCATION=memcached:11211
+ - DJANGO_ELASTICSEARCH_HOST=es:9200
+ links:
+ - db
+ - memcached
+ depends_on:
+ - db
+ container_name: djangoblog
+
diff --git a/src/DjangoBlog-master/deploy/docker-compose/docker-compose.yml b/src/DjangoBlog-master/deploy/docker-compose/docker-compose.yml
new file mode 100644
index 0000000..9609af3
--- /dev/null
+++ b/src/DjangoBlog-master/deploy/docker-compose/docker-compose.yml
@@ -0,0 +1,60 @@
+version: '3'
+
+services:
+ db:
+ image: mysql:latest
+ restart: always
+ environment:
+ - MYSQL_DATABASE=djangoblog
+ - MYSQL_ROOT_PASSWORD=DjAnGoBlOg!2!Q@W#E
+ ports:
+ - 3306:3306
+ volumes:
+ - ./bin/datas/mysql/:/var/lib/mysql
+ depends_on:
+ - redis
+ container_name: db
+
+ djangoblog:
+ build:
+ context: ../../
+ restart: always
+ command: bash -c 'sh /code/djangoblog/bin/docker_start.sh'
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./collectedstatic:/code/djangoblog/collectedstatic
+ - ./logs:/code/djangoblog/logs
+ - ./uploads:/code/djangoblog/uploads
+ environment:
+ - DJANGO_MYSQL_DATABASE=djangoblog
+ - DJANGO_MYSQL_USER=root
+ - DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E
+ - DJANGO_MYSQL_HOST=db
+ - DJANGO_MYSQL_PORT=3306
+ - DJANGO_REDIS_URL=redis:6379
+ links:
+ - db
+ - redis
+ depends_on:
+ - db
+ container_name: djangoblog
+ nginx:
+ restart: always
+ image: nginx:latest
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./bin/nginx.conf:/etc/nginx/nginx.conf
+ - ./collectedstatic:/code/djangoblog/collectedstatic
+ links:
+ - djangoblog:djangoblog
+ container_name: nginx
+
+ redis:
+ restart: always
+ image: redis:latest
+ container_name: redis
+ ports:
+ - "6379:6379"
diff --git a/src/DjangoBlog-master/deploy/entrypoint.sh b/src/DjangoBlog-master/deploy/entrypoint.sh
new file mode 100644
index 0000000..2fb6491
--- /dev/null
+++ b/src/DjangoBlog-master/deploy/entrypoint.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+NAME="djangoblog"
+DJANGODIR=/code/djangoblog
+USER=root
+GROUP=root
+NUM_WORKERS=1
+DJANGO_WSGI_MODULE=djangoblog.wsgi
+
+
+echo "Starting $NAME as `whoami`"
+
+cd $DJANGODIR
+
+export PYTHONPATH=$DJANGODIR:$PYTHONPATH
+
+python manage.py makemigrations && \
+ python manage.py migrate && \
+ python manage.py collectstatic --noinput && \
+ python manage.py compress --force && \
+ python manage.py build_index && \
+ python manage.py compilemessages || exit 1
+
+exec gunicorn ${DJANGO_WSGI_MODULE}:application \
+--name $NAME \
+--workers $NUM_WORKERS \
+--user=$USER --group=$GROUP \
+--bind 0.0.0.0:8000 \
+--log-level=debug \
+--log-file=- \
+--worker-class gevent \
+--threads 4
diff --git a/src/DjangoBlog-master/deploy/k8s/configmap.yaml b/src/DjangoBlog-master/deploy/k8s/configmap.yaml
new file mode 100644
index 0000000..835d4ad
--- /dev/null
+++ b/src/DjangoBlog-master/deploy/k8s/configmap.yaml
@@ -0,0 +1,119 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: web-nginx-config
+ namespace: djangoblog
+data:
+ nginx.conf: |
+ user nginx;
+ worker_processes auto;
+ error_log /var/log/nginx/error.log notice;
+ pid /var/run/nginx.pid;
+
+ events {
+ worker_connections 1024;
+ multi_accept on;
+ use epoll;
+ }
+
+ http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ access_log /var/log/nginx/access.log main;
+
+ sendfile on;
+ keepalive_timeout 65;
+ gzip on;
+ gzip_disable "msie6";
+
+ gzip_vary on;
+ gzip_proxied any;
+ gzip_comp_level 8;
+ gzip_buffers 16 8k;
+ gzip_http_version 1.1;
+ gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
+
+ # Include server configurations
+ include /etc/nginx/conf.d/*.conf;
+ }
+ djangoblog.conf: |
+ server {
+ server_name lylinux.net;
+ root /code/djangoblog/collectedstatic/;
+ listen 80;
+ keepalive_timeout 70;
+ location /static/ {
+ expires max;
+ alias /code/djangoblog/collectedstatic/;
+ }
+
+ location ~* (robots\.txt|ads\.txt|favicon\.ico|favion\.ico|crossdomain\.xml|google93fd32dbd906620a\.html|BingSiteAuth\.xml|baidu_verify_Ijeny6KrmS\.html)$ {
+ root /resource/djangopub;
+ expires 1d;
+ access_log off;
+ error_log off;
+ }
+
+ location / {
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-NginX-Proxy true;
+ proxy_redirect off;
+ if (!-f $request_filename) {
+ proxy_pass http://djangoblog:8000;
+ break;
+ }
+ }
+ }
+ server {
+ server_name www.lylinux.net;
+ listen 80;
+ return 301 https://lylinux.net$request_uri;
+ }
+ resource.lylinux.net.conf: |
+ server {
+ index index.html index.htm;
+ server_name resource.lylinux.net;
+ root /resource/;
+
+ location /djangoblog/ {
+ alias /code/djangoblog/collectedstatic/;
+ }
+
+ access_log off;
+ error_log off;
+ include lylinux/resource.conf;
+ }
+ lylinux.resource.conf: |
+ expires max;
+ access_log off;
+ log_not_found off;
+ add_header Pragma public;
+ add_header Cache-Control "public";
+ add_header "Access-Control-Allow-Origin" "*";
+
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: djangoblog-env
+ namespace: djangoblog
+data:
+ DJANGO_MYSQL_DATABASE: djangoblog
+ DJANGO_MYSQL_USER: db_user
+ DJANGO_MYSQL_PASSWORD: db_password
+ DJANGO_MYSQL_HOST: db_host
+ DJANGO_MYSQL_PORT: db_port
+ DJANGO_REDIS_URL: "redis:6379"
+ DJANGO_DEBUG: "False"
+ MYSQL_ROOT_PASSWORD: db_password
+ MYSQL_DATABASE: djangoblog
+ MYSQL_PASSWORD: db_password
+ DJANGO_SECRET_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+
diff --git a/src/DjangoBlog-master/deploy/k8s/deployment.yaml b/src/DjangoBlog-master/deploy/k8s/deployment.yaml
new file mode 100644
index 0000000..414fdcc
--- /dev/null
+++ b/src/DjangoBlog-master/deploy/k8s/deployment.yaml
@@ -0,0 +1,274 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: djangoblog
+ namespace: djangoblog
+ labels:
+ app: djangoblog
+spec:
+ replicas: 3
+ selector:
+ matchLabels:
+ app: djangoblog
+ template:
+ metadata:
+ labels:
+ app: djangoblog
+ spec:
+ containers:
+ - name: djangoblog
+ image: liangliangyy/djangoblog:latest
+ imagePullPolicy: Always
+ ports:
+ - containerPort: 8000
+ envFrom:
+ - configMapRef:
+ name: djangoblog-env
+ readinessProbe:
+ httpGet:
+ path: /
+ port: 8000
+ initialDelaySeconds: 10
+ periodSeconds: 30
+ livenessProbe:
+ httpGet:
+ path: /
+ port: 8000
+ initialDelaySeconds: 10
+ periodSeconds: 30
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ volumeMounts:
+ - name: djangoblog
+ mountPath: /code/djangoblog/collectedstatic
+ - name: resource
+ mountPath: /resource
+ volumes:
+ - name: djangoblog
+ persistentVolumeClaim:
+ claimName: djangoblog-pvc
+ - name: resource
+ persistentVolumeClaim:
+ claimName: resource-pvc
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: redis
+ namespace: djangoblog
+ labels:
+ app: redis
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: redis
+ template:
+ metadata:
+ labels:
+ app: redis
+ spec:
+ containers:
+ - name: redis
+ image: redis:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 6379
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: 200m
+ memory: 2Gi
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: db
+ namespace: djangoblog
+ labels:
+ app: db
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: db
+ template:
+ metadata:
+ labels:
+ app: db
+ spec:
+ containers:
+ - name: db
+ image: mysql:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 3306
+ envFrom:
+ - configMapRef:
+ name: djangoblog-env
+ readinessProbe:
+ exec:
+ command:
+ - mysqladmin
+ - ping
+ - "-h"
+ - "127.0.0.1"
+ - "-u"
+ - "root"
+ - "-p$MYSQL_ROOT_PASSWORD"
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ livenessProbe:
+ exec:
+ command:
+ - mysqladmin
+ - ping
+ - "-h"
+ - "127.0.0.1"
+ - "-u"
+ - "root"
+ - "-p$MYSQL_ROOT_PASSWORD"
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ volumeMounts:
+ - name: db-data
+ mountPath: /var/lib/mysql
+ volumes:
+ - name: db-data
+ persistentVolumeClaim:
+ claimName: db-pvc
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: nginx
+ namespace: djangoblog
+ labels:
+ app: nginx
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: nginx
+ template:
+ metadata:
+ labels:
+ app: nginx
+ spec:
+ containers:
+ - name: nginx
+ image: nginx:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 80
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ volumeMounts:
+ - name: nginx-config
+ mountPath: /etc/nginx/nginx.conf
+ subPath: nginx.conf
+ - name: nginx-config
+ mountPath: /etc/nginx/conf.d/default.conf
+ subPath: djangoblog.conf
+ - name: nginx-config
+ mountPath: /etc/nginx/conf.d/resource.lylinux.net.conf
+ subPath: resource.lylinux.net.conf
+ - name: nginx-config
+ mountPath: /etc/nginx/lylinux/resource.conf
+ subPath: lylinux.resource.conf
+ - name: djangoblog-pvc
+ mountPath: /code/djangoblog/collectedstatic
+ - name: resource-pvc
+ mountPath: /resource
+ volumes:
+ - name: nginx-config
+ configMap:
+ name: web-nginx-config
+ - name: djangoblog-pvc
+ persistentVolumeClaim:
+ claimName: djangoblog-pvc
+ - name: resource-pvc
+ persistentVolumeClaim:
+ claimName: resource-pvc
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: elasticsearch
+ namespace: djangoblog
+ labels:
+ app: elasticsearch
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: elasticsearch
+ template:
+ metadata:
+ labels:
+ app: elasticsearch
+ spec:
+ containers:
+ - name: elasticsearch
+ image: liangliangyy/elasticsearch-analysis-ik:8.6.1
+ imagePullPolicy: IfNotPresent
+ env:
+ - name: discovery.type
+ value: single-node
+ - name: ES_JAVA_OPTS
+ value: "-Xms256m -Xmx256m"
+ - name: xpack.security.enabled
+ value: "false"
+ - name: xpack.monitoring.templates.enabled
+ value: "false"
+ ports:
+ - containerPort: 9200
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ readinessProbe:
+ httpGet:
+ path: /
+ port: 9200
+ initialDelaySeconds: 15
+ periodSeconds: 30
+ livenessProbe:
+ httpGet:
+ path: /
+ port: 9200
+ initialDelaySeconds: 15
+ periodSeconds: 30
+ volumeMounts:
+ - name: elasticsearch-data
+ mountPath: /usr/share/elasticsearch/data/
+ volumes:
+ - name: elasticsearch-data
+ persistentVolumeClaim:
+ claimName: elasticsearch-pvc
diff --git a/src/DjangoBlog-master/deploy/k8s/gateway.yaml b/src/DjangoBlog-master/deploy/k8s/gateway.yaml
new file mode 100644
index 0000000..a8de073
--- /dev/null
+++ b/src/DjangoBlog-master/deploy/k8s/gateway.yaml
@@ -0,0 +1,17 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: nginx
+ namespace: djangoblog
+spec:
+ ingressClassName: nginx
+ rules:
+ - http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: nginx
+ port:
+ number: 80
\ No newline at end of file
diff --git a/src/DjangoBlog-master/deploy/k8s/pv.yaml b/src/DjangoBlog-master/deploy/k8s/pv.yaml
new file mode 100644
index 0000000..874b72f
--- /dev/null
+++ b/src/DjangoBlog-master/deploy/k8s/pv.yaml
@@ -0,0 +1,94 @@
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-db
+spec:
+ capacity:
+ storage: 10Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/local-storage-db
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-djangoblog
+spec:
+ capacity:
+ storage: 5Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/local-storage-djangoblog
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
+
+
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-resource
+spec:
+ capacity:
+ storage: 5Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/resource/
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
+
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-elasticsearch
+spec:
+ capacity:
+ storage: 5Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/local-storage-elasticsearch
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
\ No newline at end of file
diff --git a/src/DjangoBlog-master/deploy/k8s/pvc.yaml b/src/DjangoBlog-master/deploy/k8s/pvc.yaml
new file mode 100644
index 0000000..ef238c5
--- /dev/null
+++ b/src/DjangoBlog-master/deploy/k8s/pvc.yaml
@@ -0,0 +1,60 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: db-pvc
+ namespace: djangoblog
+spec:
+ storageClassName: local-storage
+ volumeName: local-pv-db
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 10Gi
+
+
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: djangoblog-pvc
+ namespace: djangoblog
+spec:
+ volumeName: local-pv-djangoblog
+ storageClassName: local-storage
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: resource-pvc
+ namespace: djangoblog
+spec:
+ volumeName: local-pv-resource
+ storageClassName: local-storage
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: elasticsearch-pvc
+ namespace: djangoblog
+spec:
+ volumeName: local-pv-elasticsearch
+ storageClassName: local-storage
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+
\ No newline at end of file
diff --git a/src/DjangoBlog-master/deploy/k8s/service.yaml b/src/DjangoBlog-master/deploy/k8s/service.yaml
new file mode 100644
index 0000000..4ef2931
--- /dev/null
+++ b/src/DjangoBlog-master/deploy/k8s/service.yaml
@@ -0,0 +1,80 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: djangoblog
+ namespace: djangoblog
+ labels:
+ app: djangoblog
+spec:
+ selector:
+ app: djangoblog
+ ports:
+ - protocol: TCP
+ port: 8000
+ targetPort: 8000
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: nginx
+ namespace: djangoblog
+ labels:
+ app: nginx
+spec:
+ selector:
+ app: nginx
+ ports:
+ - protocol: TCP
+ port: 80
+ targetPort: 80
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: redis
+ namespace: djangoblog
+ labels:
+ app: redis
+spec:
+ selector:
+ app: redis
+ ports:
+ - protocol: TCP
+ port: 6379
+ targetPort: 6379
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: db
+ namespace: djangoblog
+ labels:
+ app: db
+spec:
+ selector:
+ app: db
+ ports:
+ - protocol: TCP
+ port: 3306
+ targetPort: 3306
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: elasticsearch
+ namespace: djangoblog
+ labels:
+ app: elasticsearch
+spec:
+ selector:
+ app: elasticsearch
+ ports:
+ - protocol: TCP
+ port: 9200
+ targetPort: 9200
+ type: ClusterIP
+
diff --git a/src/DjangoBlog-master/deploy/k8s/storageclass.yaml b/src/DjangoBlog-master/deploy/k8s/storageclass.yaml
new file mode 100644
index 0000000..5d5a14c
--- /dev/null
+++ b/src/DjangoBlog-master/deploy/k8s/storageclass.yaml
@@ -0,0 +1,10 @@
+apiVersion: storage.k8s.io/v1
+kind: StorageClass
+metadata:
+ name: local-storage
+ annotations:
+ storageclass.kubernetes.io/is-default-class: "true"
+provisioner: kubernetes.io/no-provisioner
+volumeBindingMode: Immediate
+
+
diff --git a/src/DjangoBlog-master/deploy/nginx.conf b/src/DjangoBlog-master/deploy/nginx.conf
new file mode 100644
index 0000000..32161d8
--- /dev/null
+++ b/src/DjangoBlog-master/deploy/nginx.conf
@@ -0,0 +1,50 @@
+user nginx;
+worker_processes auto;
+
+error_log /var/log/nginx/error.log notice;
+pid /var/run/nginx.pid;
+
+
+events {
+ worker_connections 1024;
+}
+
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ access_log /var/log/nginx/access.log main;
+
+ sendfile on;
+ #tcp_nopush on;
+
+ keepalive_timeout 65;
+
+ #gzip on;
+
+ server {
+ root /code/djangoblog/collectedstatic/;
+ listen 80;
+ keepalive_timeout 70;
+ location /static/ {
+ expires max;
+ alias /code/djangoblog/collectedstatic/;
+ }
+ location / {
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-NginX-Proxy true;
+ proxy_redirect off;
+ if (!-f $request_filename) {
+ proxy_pass http://djangoblog:8000;
+ break;
+ }
+ }
+ }
+}
diff --git a/src/DjangoBlog-master/docs/README-en.md b/src/DjangoBlog-master/docs/README-en.md
new file mode 100644
index 0000000..37ea069
--- /dev/null
+++ b/src/DjangoBlog-master/docs/README-en.md
@@ -0,0 +1,158 @@
+# DjangoBlog
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+ (Left) Alipay / (Right) WeChat
+
+
+## 🙏 Acknowledgements
+
+A special thanks to **JetBrains** for providing a free open-source license for this project.
+
+
+
+
+
+
+
+---
+> If this project has helped you, please leave your website URL [here](https://github.com/liangliangyy/DjangoBlog/issues/214) to let more people see it. Your feedback is the driving force for my continued updates and maintenance.
diff --git a/src/DjangoBlog-master/docs/config-en.md b/src/DjangoBlog-master/docs/config-en.md
new file mode 100644
index 0000000..b877efb
--- /dev/null
+++ b/src/DjangoBlog-master/docs/config-en.md
@@ -0,0 +1,64 @@
+# Introduction to main features settings
+
+## Cache:
+Cache using `memcache` for default. If you don't have `memcache` environment, you can remove the `default` setting in `CACHES` and change `locmemcache` to `default`.
+```python
+CACHES = {
+ 'default': {
+ 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
+ 'LOCATION': '127.0.0.1:11211',
+ 'KEY_PREFIX': 'django_test' if TESTING else 'djangoblog',
+ 'TIMEOUT': 60 * 60 * 10
+ },
+ 'locmemcache': {
+ 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+ 'TIMEOUT': 10800,
+ 'LOCATION': 'unique-snowflake',
+ }
+}
+```
+
+## OAuth Login:
+QQ, Weibo, Google, GitHub and Facebook are now supported for OAuth login. Fetch OAuth login permissions from the corresponding open platform, and save them with `appkey`, `appsecret` and callback address in **Backend->OAuth** configuration.
+
+### Callback address examples:
+QQ: http://your-domain-name/oauth/authorize?type=qq
+Weibo: http://your-domain-name/oauth/authorize?type=weibo
+type is in the type field of `oauthmanager`.
+
+## owntracks:
+owntracks is a location tracking application. It will send your locaiton to the server by timing.Simple support owntracks features. Just install owntracks app and set api address as `your-domain-name/owntracks/logtracks`. Visit `your-domain-name/owntracks/show_dates` and you will see the date with latitude and langitude, click it and see the motion track. The map is drawn by AMap.
+
+## Email feature:
+Same as before, Configure your own error msg recvie email information with`ADMINS = [('liangliang', 'liangliangyy@gmail.com')]` in `settings.py`. And modify:
+```python
+EMAIL_HOST = 'smtp.zoho.com'
+EMAIL_PORT = 587
+EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
+EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
+DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
+SERVER_EMAIL = os.environ.get('DJANGO_EMAIL_USER')
+```
+with your email account information.
+
+## WeChat Official Account
+Simple wechat official account features integrated. Set token as `your-domain-name/robot` in wechat backend. Default token is `lylinux`, you can change it to your own in `servermanager/robot.py`. Add a new command in `Backend->Servermanager->command`, in this way, you can manage the system through wechat official account.
+
+## Introduction to website configuration
+You can add website configuration in **Backend->BLOG->WebSiteConfiguration**. Such as: keywords, description, Google Ad, website stats code, case number, etc.
+OAuth user avatar path is saved in *StaticFileSavedAddress*. Please input absolute path, code directory for default.
+
+## Source code highlighting
+If the code block in your article didn't show hightlight, please write the code blocks as following:
+
+
+
+That is, you should add the corresponding language name before the code block.
+
+## Update
+If you get errors as following while executing database migrations:
+```python
+django.db.migrations.exceptions.MigrationSchemaMissing: Unable to create the django_migrations table ((1064, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '(6) NOT NULL)' at line 1"))
+```
+This problem may cause by the mysql version under 5.6, a new version( >= 5.6 ) mysql is needed.
+
diff --git a/src/DjangoBlog-master/docs/config.md b/src/DjangoBlog-master/docs/config.md
new file mode 100644
index 0000000..24673a3
--- /dev/null
+++ b/src/DjangoBlog-master/docs/config.md
@@ -0,0 +1,58 @@
+# 主要功能配置介绍:
+
+## 缓存:
+缓存默认使用`localmem`缓存,如果你有`redis`环境,可以设置`DJANGO_REDIS_URL`环境变量,则会自动使用该redis来作为缓存,或者你也可以直接修改如下代码来使用。
+https://github.com/liangliangyy/DjangoBlog/blob/ffcb2c3711de805f2067dd3c1c57449cd24d84ee/djangoblog/settings.py#L185-L199
+
+
+## oauth登录:
+
+现在已经支持QQ,微博,Google,GitHub,Facebook登录,需要在其对应的开放平台申请oauth登录权限,然后在
+**后台->Oauth** 配置中新增配置,填写对应的`appkey`和`appsecret`以及回调地址。
+### 回调地址示例:
+qq:http://你的域名/oauth/authorize?type=qq
+微博:http://你的域名/oauth/authorize?type=weibo
+type对应在`oauthmanager`中的type字段。
+
+## owntracks:
+owntracks是一个位置追踪软件,可以定时的将你的坐标提交到你的服务器上,现在简单的支持owntracks功能,需要安装owntracks的app,然后将api地址设置为:
+`你的域名/owntracks/logtracks`就可以了。然后访问`你的域名/owntracks/show_dates`就可以看到有经纬度记录的日期,点击之后就可以看到运动轨迹了。地图是使用高德地图绘制。
+
+## 邮件功能:
+同样,将`settings.py`中的`ADMINS = [('liangliang', 'liangliangyy@gmail.com')]`配置为你自己的错误接收邮箱,另外修改:
+```python
+EMAIL_HOST = 'smtp.zoho.com'
+EMAIL_PORT = 587
+EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
+EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
+DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
+SERVER_EMAIL = os.environ.get('DJANGO_EMAIL_USER')
+```
+为你自己的邮箱配置。
+
+## 微信公众号
+集成了简单的微信公众号功能,在微信后台将token地址设置为:`你的域名/robot` 即可,默认token为`lylinux`,当然你可以修改为你自己的,在`servermanager/robot.py`中。
+然后在**后台->Servermanager->命令**中新增命令,这样就可以使用微信公众号来管理了。
+## 网站配置介绍
+在**后台->BLOG->网站配置**中,可以新增网站配置,比如关键字,描述等,以及谷歌广告,网站统计代码及备案号等等。
+其中的*静态文件保存地址*是保存oauth用户登录的头像路径,填写绝对路径,默认是代码目录。
+## 代码高亮
+如果你发现你文章的代码没有高亮,请这样书写代码块:
+
+
+
+
+也就是说,需要在代码块开始位置加入这段代码对应的语言。
+
+## update
+如果你发现执行数据库迁移的时候出现如下报错:
+```python
+django.db.migrations.exceptions.MigrationSchemaMissing: Unable to create the django_migrations table ((1064, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '(6) NOT NULL)' at line 1"))
+```
+可能是因为你的mysql版本低于5.6,需要升级mysql版本>=5.6即可。
+
+
+django 4.0登录可能会报错CSRF,需要配置下`settings.py`中的`CSRF_TRUSTED_ORIGINS`
+
+https://github.com/liangliangyy/DjangoBlog/blob/master/djangoblog/settings.py#L39
+
diff --git a/src/DjangoBlog-master/docs/docker-en.md b/src/DjangoBlog-master/docs/docker-en.md
new file mode 100644
index 0000000..8d5d59e
--- /dev/null
+++ b/src/DjangoBlog-master/docs/docker-en.md
@@ -0,0 +1,114 @@
+# Deploying DjangoBlog with Docker
+
+
+
+
+
+This project fully supports containerized deployment using Docker, providing you with a fast, consistent, and isolated runtime environment. We recommend using `docker-compose` to launch the entire blog service stack with a single command.
+
+## 1. Prerequisites
+
+Before you begin, please ensure you have the following software installed on your system:
+- [Docker Engine](https://docs.docker.com/engine/install/)
+- [Docker Compose](https://docs.docker.com/compose/install/) (Included with Docker Desktop for Mac and Windows)
+
+## 2. Recommended Method: Using `docker-compose` (One-Click Deployment)
+
+This is the simplest and most recommended way to deploy. It automatically creates and manages the Django application, a MySQL database, and an optional Elasticsearch service for you.
+
+### Step 1: Start the Basic Services
+
+From the project's root directory, run the following command:
+
+```bash
+# Build and start the containers in detached mode (includes Django app and MySQL)
+docker-compose up -d --build
+```
+
+`docker-compose` will read the `docker-compose.yml` file, pull the necessary images, build the project image, and start all services.
+
+- **Access Your Blog**: Once the services are up, you can access the blog by navigating to `http://127.0.0.1` in your browser.
+- **Data Persistence**: MySQL data files will be stored in the `data/mysql` directory within the project root, ensuring that your data persists across container restarts.
+
+### Step 2: (Optional) Enable Elasticsearch for Full-Text Search
+
+If you want to use Elasticsearch for more powerful full-text search capabilities, you can include the `docker-compose.es.yml` configuration file:
+
+```bash
+# Build and start all services in detached mode (Django, MySQL, Elasticsearch)
+docker-compose -f docker-compose.yml -f deploy/docker-compose/docker-compose.es.yml up -d --build
+```
+- **Data Persistence**: Elasticsearch data will be stored in the `data/elasticsearch` directory.
+
+### Step 3: First-Time Initialization
+
+After the containers start for the first time, you'll need to execute some initialization commands inside the application container.
+
+```bash
+# Get a shell inside the djangoblog application container (named 'web')
+docker-compose exec web bash
+
+# Inside the container, run the following commands:
+# Create a superuser account (follow the prompts to set username, email, and password)
+python manage.py createsuperuser
+
+# (Optional) Create some test data
+python manage.py create_testdata
+
+# (Optional, if ES is enabled) Create the search index
+python manage.py rebuild_index
+
+# Exit the container
+exit
+```
+
+## 3. Alternative Method: Using the Standalone Docker Image
+
+If you already have an external MySQL database running, you can run the DjangoBlog application image by itself.
+
+```bash
+# Pull the latest image from Docker Hub
+docker pull liangliangyy/djangoblog:latest
+
+# Run the container and connect it to your external database
+docker run -d \
+ -p 8000:8000 \
+ -e DJANGO_SECRET_KEY='your-strong-secret-key' \
+ -e DJANGO_MYSQL_HOST='your-mysql-host' \
+ -e DJANGO_MYSQL_USER='your-mysql-user' \
+ -e DJANGO_MYSQL_PASSWORD='your-mysql-password' \
+ -e DJANGO_MYSQL_DATABASE='djangoblog' \
+ --name djangoblog \
+ liangliangyy/djangoblog:latest
+```
+
+- **Access Your Blog**: After startup, visit `http://127.0.0.1:8000`.
+- **Create Superuser**: `docker exec -it djangoblog python manage.py createsuperuser`
+
+## 4. Configuration (Environment Variables)
+
+Most of the project's configuration is managed through environment variables. You can modify them in the `docker-compose.yml` file or pass them using the `-e` flag with the `docker run` command.
+
+| Environment Variable | Default/Example Value | Notes |
+|---------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------|
+| `DJANGO_SECRET_KEY` | `your-strong-secret-key` | **Must be changed to a random, complex string!** |
+| `DJANGO_DEBUG` | `False` | Toggles Django's debug mode. |
+| `DJANGO_MYSQL_HOST` | `mysql` | Database hostname. |
+| `DJANGO_MYSQL_PORT` | `3306` | Database port. |
+| `DJANGO_MYSQL_DATABASE` | `djangoblog` | Database name. |
+| `DJANGO_MYSQL_USER` | `root` | Database username. |
+| `DJANGO_MYSQL_PASSWORD` | `djangoblog_123` | Database password. |
+| `DJANGO_REDIS_URL` | `redis:6379/0` | Redis connection URL (for caching). |
+| `DJANGO_ELASTICSEARCH_HOST`| `elasticsearch:9200` | Elasticsearch host address. |
+| `DJANGO_EMAIL_HOST` | `smtp.example.org` | Email server address. |
+| `DJANGO_EMAIL_PORT` | `465` | Email server port. |
+| `DJANGO_EMAIL_USER` | `user@example.org` | Email account username. |
+| `DJANGO_EMAIL_PASSWORD` | `your-email-password` | Email account password. |
+| `DJANGO_EMAIL_USE_SSL` | `True` | Whether to use SSL. |
+| `DJANGO_EMAIL_USE_TLS` | `False` | Whether to use TLS. |
+| `DJANGO_ADMIN_EMAIL` | `admin@example.org` | Admin email for receiving error reports. |
+| `DJANGO_BAIDU_NOTIFY_URL` | `http://data.zz.baidu.com/...` | Push API from [Baidu Webmaster Tools](https://ziyuan.baidu.com/linksubmit/index). |
+
+---
+
+After deployment, please review and adjust these environment variables according to your needs, especially `DJANGO_SECRET_KEY` and the database and email settings.
\ No newline at end of file
diff --git a/src/DjangoBlog-master/docs/docker.md b/src/DjangoBlog-master/docs/docker.md
new file mode 100644
index 0000000..e7c255a
--- /dev/null
+++ b/src/DjangoBlog-master/docs/docker.md
@@ -0,0 +1,114 @@
+# 使用 Docker 部署 DjangoBlog
+
+
+
+
+
+本项目全面支持使用 Docker 进行容器化部署,为您提供了快速、一致且隔离的运行环境。我们推荐使用 `docker-compose` 来一键启动整个博客服务栈。
+
+## 1. 环境准备
+
+在开始之前,请确保您的系统中已经安装了以下软件:
+- [Docker Engine](https://docs.docker.com/engine/install/)
+- [Docker Compose](https://docs.docker.com/compose/install/) (对于 Docker Desktop 用户,它已内置)
+
+## 2. 推荐方式:使用 `docker-compose` (一键部署)
+
+这是最简单、最推荐的部署方式。它会自动为您创建并管理 Django 应用、MySQL 数据库,以及可选的 Elasticsearch 服务。
+
+### 步骤 1: 启动基础服务
+
+在项目根目录下,执行以下命令:
+
+```bash
+# 构建并以后台模式启动容器 (包含 Django 应用和 MySQL)
+docker-compose up -d --build
+```
+
+`docker-compose` 会读取 `docker-compose.yml` 文件,自动拉取所需镜像、构建项目镜像,并启动所有服务。
+
+- **访问您的博客**: 服务启动后,在浏览器中访问 `http://127.0.0.1` 即可看到博客首页。
+- **数据持久化**: MySQL 的数据文件将存储在项目根目录下的 `data/mysql` 文件夹中,确保数据在容器重启后不丢失。
+
+### 步骤 2: (可选) 启用 Elasticsearch 全文搜索
+
+如果您希望使用 Elasticsearch 提供更强大的全文搜索功能,可以额外加载 `docker-compose.es.yml` 配置文件:
+
+```bash
+# 构建并以后台模式启动所有服务 (Django, MySQL, Elasticsearch)
+docker-compose -f docker-compose.yml -f deploy/docker-compose/docker-compose.es.yml up -d --build
+```
+- **数据持久化**: Elasticsearch 的数据将存储在 `data/elasticsearch` 文件夹中。
+
+### 步骤 3: 首次运行的初始化操作
+
+当容器首次启动后,您需要进入容器来执行一些初始化命令。
+
+```bash
+# 进入 djangoblog 应用容器
+docker-compose exec web bash
+
+# 在容器内执行以下命令:
+# 创建超级管理员账户 (请按照提示设置用户名、邮箱和密码)
+python manage.py createsuperuser
+
+# (可选) 创建一些测试数据
+python manage.py create_testdata
+
+# (可选,如果启用了 ES) 创建索引
+python manage.py rebuild_index
+
+# 退出容器
+exit
+```
+
+## 3. 备选方式:使用独立的 Docker 镜像
+
+如果您已经拥有一个正在运行的外部 MySQL 数据库,您也可以只运行 DjangoBlog 的应用镜像。
+
+```bash
+# 从 Docker Hub 拉取最新镜像
+docker pull liangliangyy/djangoblog:latest
+
+# 运行容器,并链接到您的外部数据库
+docker run -d \
+ -p 8000:8000 \
+ -e DJANGO_SECRET_KEY='your-strong-secret-key' \
+ -e DJANGO_MYSQL_HOST='your-mysql-host' \
+ -e DJANGO_MYSQL_USER='your-mysql-user' \
+ -e DJANGO_MYSQL_PASSWORD='your-mysql-password' \
+ -e DJANGO_MYSQL_DATABASE='djangoblog' \
+ --name djangoblog \
+ liangliangyy/djangoblog:latest
+```
+
+- **访问您的博客**: 启动完成后,访问 `http://127.0.0.1:8000`。
+- **创建管理员**: `docker exec -it djangoblog python manage.py createsuperuser`
+
+## 4. 配置说明 (环境变量)
+
+本项目的大部分配置都通过环境变量来管理。您可以在 `docker-compose.yml` 文件中修改它们,或者在使用 `docker run` 命令时通过 `-e` 参数传入。
+
+| 环境变量名称 | 默认值/示例 | 备注 |
+|-------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------|
+| `DJANGO_SECRET_KEY` | `your-strong-secret-key` | **请务必修改为一个随机且复杂的字符串!** |
+| `DJANGO_DEBUG` | `False` | 是否开启 Django 的调试模式 |
+| `DJANGO_MYSQL_HOST` | `mysql` | 数据库主机名 |
+| `DJANGO_MYSQL_PORT` | `3306` | 数据库端口 |
+| `DJANGO_MYSQL_DATABASE` | `djangoblog` | 数据库名称 |
+| `DJANGO_MYSQL_USER` | `root` | 数据库用户名 |
+| `DJANGO_MYSQL_PASSWORD` | `djangoblog_123` | 数据库密码 |
+| `DJANGO_REDIS_URL` | `redis:6379/0` | Redis 连接地址 (用于缓存) |
+| `DJANGO_ELASTICSEARCH_HOST` | `elasticsearch:9200` | Elasticsearch 主机地址 |
+| `DJANGO_EMAIL_HOST` | `smtp.example.org` | 邮件服务器地址 |
+| `DJANGO_EMAIL_PORT` | `465` | 邮件服务器端口 |
+| `DJANGO_EMAIL_USER` | `user@example.org` | 邮件账户 |
+| `DJANGO_EMAIL_PASSWORD` | `your-email-password` | 邮件密码 |
+| `DJANGO_EMAIL_USE_SSL` | `True` | 是否使用 SSL |
+| `DJANGO_EMAIL_USE_TLS` | `False` | 是否使用 TLS |
+| `DJANGO_ADMIN_EMAIL` | `admin@example.org` | 接收异常报告的管理员邮箱 |
+| `DJANGO_BAIDU_NOTIFY_URL` | `http://data.zz.baidu.com/...` | [百度站长平台](https://ziyuan.baidu.com/linksubmit/index) 的推送接口 |
+
+---
+
+部署完成后,请务必检查并根据您的实际需求调整这些环境变量,特别是 `DJANGO_SECRET_KEY` 和数据库、邮件相关的配置。
diff --git a/src/DjangoBlog-master/docs/es.md b/src/DjangoBlog-master/docs/es.md
new file mode 100644
index 0000000..97226c5
--- /dev/null
+++ b/src/DjangoBlog-master/docs/es.md
@@ -0,0 +1,28 @@
+# 集成Elasticsearch
+如果你已经有了`Elasticsearch`环境,那么可以将搜索从`Whoosh`换成`Elasticsearch`,集成方式也很简单,
+首先需要注意如下几点:
+1. 你的`Elasticsearch`支持`ik`中文分词
+2. 你的`Elasticsearch`版本>=7.3.0
+
+接下来在`settings.py`做如下改动即可:
+- 增加es链接,如下所示:
+```python
+ELASTICSEARCH_DSL = {
+ 'default': {
+ 'hosts': '127.0.0.1:9200'
+ },
+}
+```
+- 修改`HAYSTACK`配置:
+```python
+HAYSTACK_CONNECTIONS = {
+ 'default': {
+ 'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
+ },
+}
+```
+然后终端执行:
+```shell script
+./manage.py build_index
+```
+这将会在你的es中创建两个索引,分别是`blog`和`performance`,其中`blog`索引就是搜索所使用的,而`performance`会记录每个请求的响应时间,以供将来优化使用。
\ No newline at end of file
diff --git a/src/DjangoBlog-master/docs/imgs/alipay.jpg b/src/DjangoBlog-master/docs/imgs/alipay.jpg
new file mode 100644
index 0000000..424d70a
Binary files /dev/null and b/src/DjangoBlog-master/docs/imgs/alipay.jpg differ
diff --git a/src/DjangoBlog-master/docs/imgs/pycharm_logo.png b/src/DjangoBlog-master/docs/imgs/pycharm_logo.png
new file mode 100644
index 0000000..7f2a4b0
Binary files /dev/null and b/src/DjangoBlog-master/docs/imgs/pycharm_logo.png differ
diff --git a/src/DjangoBlog-master/docs/imgs/wechat.jpg b/src/DjangoBlog-master/docs/imgs/wechat.jpg
new file mode 100644
index 0000000..7edf525
Binary files /dev/null and b/src/DjangoBlog-master/docs/imgs/wechat.jpg differ
diff --git a/src/DjangoBlog-master/docs/k8s-en.md b/src/DjangoBlog-master/docs/k8s-en.md
new file mode 100644
index 0000000..20e9527
--- /dev/null
+++ b/src/DjangoBlog-master/docs/k8s-en.md
@@ -0,0 +1,141 @@
+# Deploying DjangoBlog with Kubernetes
+
+This document guides you through deploying the DjangoBlog application on a Kubernetes (K8s) cluster. We provide a complete set of `.yaml` configuration files in the `deploy/k8s` directory to deploy a full service stack, including the DjangoBlog application, Nginx, MySQL, Redis, and Elasticsearch.
+
+## Architecture Overview
+
+This deployment utilizes a microservices-based, cloud-native architecture:
+
+- **Core Components**: Each core service (DjangoBlog, Nginx, MySQL, Redis, Elasticsearch) runs as a separate `Deployment`.
+- **Configuration Management**: Nginx configurations and Django application environment variables are managed via `ConfigMap`. **Note: For sensitive information like passwords, using `Secret` is highly recommended.**
+- **Service Discovery**: All services are exposed internally within the cluster as `ClusterIP` type `Service`, enabling communication via service names.
+- **External Access**: An `Ingress` resource is used to route external HTTP traffic to the Nginx service, which acts as the single entry point for the entire blog application.
+- **Data Persistence**: A `local-storage` solution based on node-local paths is used. This requires you to manually create storage directories on a specific K8s node and statically bind them using `PersistentVolume` (PV) and `PersistentVolumeClaim` (PVC).
+
+## 1. Prerequisites
+
+Before you begin, please ensure you have the following:
+
+- A running Kubernetes cluster.
+- The `kubectl` command-line tool configured to connect to your cluster.
+- An [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/deploy/) installed and configured in your cluster.
+- Filesystem access to one of the nodes in your cluster (defaulted to `master` in the configs) to create local storage directories.
+
+## 2. Deployment Steps
+
+### Step 1: Create a Namespace
+
+We recommend deploying all DjangoBlog-related resources in a dedicated namespace for better management.
+
+```bash
+# Create a namespace named 'djangoblog'
+kubectl create namespace djangoblog
+```
+
+### Step 2: Configure Persistent Storage
+
+This setup uses Local Persistent Volumes. You need to create the data storage directories on a node within your cluster (the default is the `master` node in `pv.yaml`).
+
+```bash
+# Log in to your master node
+ssh user@master-node
+
+# Create the required storage directories
+sudo mkdir -p /mnt/local-storage-db
+sudo mkdir -p /mnt/local-storage-djangoblog
+sudo mkdir -p /mnt/resource/
+sudo mkdir -p /mnt/local-storage-elasticsearch
+
+# Log out from the node
+exit
+```
+**Note**: If you wish to store data on a different node or use different paths, you must modify the `nodeAffinity` and `local.path` settings in the `deploy/k8s/pv.yaml` file.
+
+After creating the directories, apply the storage-related configurations:
+
+```bash
+# Apply the StorageClass
+kubectl apply -f deploy/k8s/storageclass.yaml
+
+# Apply the PersistentVolumes (PVs)
+kubectl apply -f deploy/k8s/pv.yaml
+
+# Apply the PersistentVolumeClaims (PVCs)
+kubectl apply -f deploy/k8s/pvc.yaml
+```
+
+### Step 3: Configure the Application
+
+Before deploying the application, you need to edit the `deploy/k8s/configmap.yaml` file to modify sensitive information and custom settings.
+
+**It is strongly recommended to change the following fields:**
+- `DJANGO_SECRET_KEY`: Change to a random, complex string.
+- `DJANGO_MYSQL_PASSWORD` and `MYSQL_ROOT_PASSWORD`: Change to your own secure database password.
+
+```bash
+# Edit the ConfigMap file
+vim deploy/k8s/configmap.yaml
+
+# Apply the configuration
+kubectl apply -f deploy/k8s/configmap.yaml
+```
+
+### Step 4: Deploy the Application Stack
+
+Now, we can deploy all the core services.
+
+```bash
+# Deploy the Deployments (DjangoBlog, MySQL, Redis, Nginx, ES)
+kubectl apply -f deploy/k8s/deployment.yaml
+
+# Deploy the Services (to create internal endpoints for the Deployments)
+kubectl apply -f deploy/k8s/service.yaml
+```
+
+The deployment may take some time. You can run the following command to check if all Pods are running successfully (STATUS should be `Running`):
+
+```bash
+kubectl get pods -n djangoblog -w
+```
+
+### Step 5: Expose the Application Externally
+
+Finally, expose the Nginx service to external traffic by applying the `Ingress` rule.
+
+```bash
+# Apply the Ingress rule
+kubectl apply -f deploy/k8s/gateway.yaml
+```
+
+Once deployed, you can access your blog via the external IP address of your Ingress Controller. Use the following command to find the address:
+
+```bash
+kubectl get ingress -n djangoblog
+```
+
+### Step 6: First-Time Initialization
+
+Similar to the Docker deployment, you need to get a shell into the DjangoBlog application Pod to perform database initialization and create a superuser on the first run.
+
+```bash
+# First, get the name of a djangoblog pod
+kubectl get pods -n djangoblog | grep djangoblog
+
+# Exec into one of the Pods (replace [pod-name] with the name from the previous step)
+kubectl exec -it [pod-name] -n djangoblog -- bash
+
+# Inside the Pod, run the following commands:
+# Create a superuser account (follow the prompts)
+python manage.py createsuperuser
+
+# (Optional) Create some test data
+python manage.py create_testdata
+
+# (Optional, if ES is enabled) Create the search index
+python manage.py rebuild_index
+
+# Exit the Pod
+exit
+```
+
+Congratulations! You have successfully deployed DjangoBlog on your Kubernetes cluster.
\ No newline at end of file
diff --git a/src/DjangoBlog-master/docs/k8s.md b/src/DjangoBlog-master/docs/k8s.md
new file mode 100644
index 0000000..9da3c28
--- /dev/null
+++ b/src/DjangoBlog-master/docs/k8s.md
@@ -0,0 +1,141 @@
+# 使用 Kubernetes 部署 DjangoBlog
+
+本文档将指导您如何在 Kubernetes (K8s) 集群上部署 DjangoBlog 应用。我们提供了一套完整的 `.yaml` 配置文件,位于 `deploy/k8s` 目录下,用于部署一个包含 DjangoBlog 应用、Nginx、MySQL、Redis 和 Elasticsearch 的完整服务栈。
+
+## 架构概览
+
+本次部署采用的是微服务化的云原生架构:
+
+- **核心组件**: 每个核心服务 (DjangoBlog, Nginx, MySQL, Redis, Elasticsearch) 都将作为独立的 `Deployment` 运行。
+- **配置管理**: Nginx 的配置文件和 Django 应用的环境变量通过 `ConfigMap` 进行管理。**注意:敏感信息(如密码)建议使用 `Secret` 进行管理。**
+- **服务发现**: 所有服务都通过 `ClusterIP` 类型的 `Service` 在集群内部暴露,并通过服务名相互通信。
+- **外部访问**: 使用 `Ingress` 资源将外部的 HTTP 流量路由到 Nginx 服务,作为整个博客应用的统一入口。
+- **数据持久化**: 采用基于节点本地路径的 `local-storage` 方案。这需要您在指定的 K8s 节点上手动创建存储目录,并通过 `PersistentVolume` (PV) 和 `PersistentVolumeClaim` (PVC) 进行静态绑定。
+
+## 1. 环境准备
+
+在开始之前,请确保您已具备以下环境:
+
+- 一个正在运行的 Kubernetes 集群。
+- `kubectl` 命令行工具已配置并能够连接到您的集群。
+- 集群中已安装并配置好 [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/deploy/)。
+- 对集群中的一个节点(默认为 `master`)拥有文件系统访问权限,用于创建本地存储目录。
+
+## 2. 部署步骤
+
+### 步骤 1: 创建命名空间
+
+我们建议将 DjangoBlog 相关的所有资源都部署在一个独立的命名空间中,便于管理。
+
+```bash
+# 创建一个名为 djangoblog 的命名空间
+kubectl create namespace djangoblog
+```
+
+### 步骤 2: 配置持久化存储
+
+此方案使用本地持久卷 (Local Persistent Volume)。您需要在集群的一个节点上(在 `pv.yaml` 文件中默认为 `master` 节点)创建用于数据存储的目录。
+
+```bash
+# 登录到您的 master 节点
+ssh user@master-node
+
+# 创建所需的存储目录
+sudo mkdir -p /mnt/local-storage-db
+sudo mkdir -p /mnt/local-storage-djangoblog
+sudo mkdir -p /mnt/resource/
+sudo mkdir -p /mnt/local-storage-elasticsearch
+
+# 退出节点
+exit
+```
+**注意**: 如果您希望将数据存储在其他节点或使用不同的路径,请务必修改 `deploy/k8s/pv.yaml` 文件中 `nodeAffinity` 和 `local.path` 的配置。
+
+创建目录后,应用存储相关的配置文件:
+
+```bash
+# 应用 StorageClass
+kubectl apply -f deploy/k8s/storageclass.yaml
+
+# 应用 PersistentVolume (PV)
+kubectl apply -f deploy/k8s/pv.yaml
+
+# 应用 PersistentVolumeClaim (PVC)
+kubectl apply -f deploy/k8s/pvc.yaml
+```
+
+### 步骤 3: 配置应用
+
+在部署应用之前,您需要编辑 `deploy/k8s/configmap.yaml` 文件,修改其中的敏感信息和个性化配置。
+
+**强烈建议修改以下字段:**
+- `DJANGO_SECRET_KEY`: 修改为一个随机且复杂的字符串。
+- `DJANGO_MYSQL_PASSWORD` 和 `MYSQL_ROOT_PASSWORD`: 修改为您自己的数据库密码。
+
+```bash
+# 编辑 ConfigMap 文件
+vim deploy/k8s/configmap.yaml
+
+# 应用配置
+kubectl apply -f deploy/k8s/configmap.yaml
+```
+
+### 步骤 4: 部署应用服务栈
+
+现在,我们可以部署所有的核心服务了。
+
+```bash
+# 部署 Deployments (DjangoBlog, MySQL, Redis, Nginx, ES)
+kubectl apply -f deploy/k8s/deployment.yaml
+
+# 部署 Services (为 Deployments 创建内部访问端点)
+kubectl apply -f deploy/k8s/service.yaml
+```
+
+部署需要一些时间,您可以运行以下命令检查所有 Pod 是否都已成功运行 (STATUS 为 `Running`):
+
+```bash
+kubectl get pods -n djangoblog -w
+```
+
+### 步骤 5: 暴露应用到外部
+
+最后,通过应用 `Ingress` 规则来将外部流量引导至我们的 Nginx 服务。
+
+```bash
+# 应用 Ingress 规则
+kubectl apply -f deploy/k8s/gateway.yaml
+```
+
+部署完成后,您可以通过 Ingress Controller 的外部 IP 地址来访问您的博客。执行以下命令获取地址:
+
+```bash
+kubectl get ingress -n djangoblog
+```
+
+### 步骤 6: 首次运行的初始化操作
+
+与 Docker 部署类似,首次运行时,您需要进入 DjangoBlog 应用的 Pod 来执行数据库初始化和创建管理员账户。
+
+```bash
+# 首先,获取 djangoblog pod 的名称
+kubectl get pods -n djangoblog | grep djangoblog
+
+# 进入其中一个 Pod (将 [pod-name] 替换为上一步获取到的名称)
+kubectl exec -it [pod-name] -n djangoblog -- bash
+
+# 在 Pod 内部执行以下命令:
+# 创建超级管理员账户 (请按照提示操作)
+python manage.py createsuperuser
+
+# (可选) 创建测试数据
+python manage.py create_testdata
+
+# (可选,如果启用了 ES) 创建索引
+python manage.py rebuild_index
+
+# 退出 Pod
+exit
+```
+
+至此,您已成功在 Kubernetes 集群上完成了 DjangoBlog 的部署!
\ No newline at end of file
diff --git a/src/DjangoBlog-master/get-pip.py b/src/DjangoBlog-master/get-pip.py
new file mode 100644
index 0000000..7c7e9cb
--- /dev/null
+++ b/src/DjangoBlog-master/get-pip.py
@@ -0,0 +1,26947 @@
+#!/usr/bin/env python
+#
+# Hi There!
+#
+# You may be wondering what this giant blob of binary data here is, you might
+# even be worried that we're up to something nefarious (good for you for being
+# paranoid!). This is a base85 encoding of a zip file, this zip file contains
+# an entire copy of pip (version 25.2).
+#
+# Pip is a thing that installs packages, pip itself is a package that someone
+# might want to install, especially if they're looking to run this get-pip.py
+# script. Pip has a lot of code to deal with the security of installing
+# packages, various edge cases on various platforms, and other such sort of
+# "tribal knowledge" that has been encoded in its code base. Because of this
+# we basically include an entire copy of pip inside this blob. We do this
+# because the alternatives are attempt to implement a "minipip" that probably
+# doesn't do things correctly and has weird edge cases, or compress pip itself
+# down into a single file.
+#
+# If you're wondering how this is created, it is generated using
+# `scripts/generate.py` in https://github.com/pypa/get-pip.
+
+import sys
+
+this_python = sys.version_info[:2]
+min_version = (3, 9)
+if this_python < min_version:
+ message_parts = [
+ "This script does not work on Python {}.{}.".format(*this_python),
+ "The minimum supported Python version is {}.{}.".format(*min_version),
+ "Please use https://bootstrap.pypa.io/pip/{}.{}/get-pip.py instead.".format(*this_python),
+ ]
+ print("ERROR: " + " ".join(message_parts))
+ sys.exit(1)
+
+
+import os.path
+import pkgutil
+import shutil
+import tempfile
+import argparse
+import importlib
+from base64 import b85decode
+
+
+def include_setuptools(args):
+ """
+ Install setuptools only if absent, not excluded and when using Python <3.12.
+ """
+ cli = not args.no_setuptools
+ env = not os.environ.get("PIP_NO_SETUPTOOLS")
+ absent = not importlib.util.find_spec("setuptools")
+ python_lt_3_12 = this_python < (3, 12)
+ return cli and env and absent and python_lt_3_12
+
+
+def include_wheel(args):
+ """
+ Install wheel only if absent, not excluded and when using Python <3.12.
+ """
+ cli = not args.no_wheel
+ env = not os.environ.get("PIP_NO_WHEEL")
+ absent = not importlib.util.find_spec("wheel")
+ python_lt_3_12 = this_python < (3, 12)
+ return cli and env and absent and python_lt_3_12
+
+
+def determine_pip_install_arguments():
+ pre_parser = argparse.ArgumentParser()
+ pre_parser.add_argument("--no-setuptools", action="store_true")
+ pre_parser.add_argument("--no-wheel", action="store_true")
+ pre, args = pre_parser.parse_known_args()
+
+ args.append("pip")
+
+ if include_setuptools(pre):
+ args.append("setuptools")
+
+ if include_wheel(pre):
+ args.append("wheel")
+
+ return ["install", "--upgrade", "--force-reinstall"] + args
+
+
+def monkeypatch_for_cert(tmpdir):
+ """Patches `pip install` to provide default certificate with the lowest priority.
+
+ This ensures that the bundled certificates are used unless the user specifies a
+ custom cert via any of pip's option passing mechanisms (config, env-var, CLI).
+
+ A monkeypatch is the easiest way to achieve this, without messing too much with
+ the rest of pip's internals.
+ """
+ from pip._internal.commands.install import InstallCommand
+
+ # We want to be using the internal certificates.
+ cert_path = os.path.join(tmpdir, "cacert.pem")
+ with open(cert_path, "wb") as cert:
+ cert.write(pkgutil.get_data("pip._vendor.certifi", "cacert.pem"))
+
+ install_parse_args = InstallCommand.parse_args
+
+ def cert_parse_args(self, args):
+ if not self.parser.get_default_values().cert:
+ # There are no user provided cert -- force use of bundled cert
+ self.parser.defaults["cert"] = cert_path # calculated above
+ return install_parse_args(self, args)
+
+ InstallCommand.parse_args = cert_parse_args
+
+
+def bootstrap(tmpdir):
+ monkeypatch_for_cert(tmpdir)
+
+ # Execute the included pip and use it to install the latest pip and
+ # any user-requested packages from PyPI.
+ from pip._internal.cli.main import main as pip_entry_point
+ args = determine_pip_install_arguments()
+ sys.exit(pip_entry_point(args))
+
+
+def main():
+ tmpdir = None
+ try:
+ # Create a temporary working directory
+ tmpdir = tempfile.mkdtemp()
+
+ # Unpack the zipfile into the temporary directory
+ pip_zip = os.path.join(tmpdir, "pip.zip")
+ with open(pip_zip, "wb") as fp:
+ fp.write(b85decode(DATA.replace(b"\n", b"")))
+
+ # Add the zipfile to sys.path so that we can import it
+ sys.path.insert(0, pip_zip)
+
+ # Run the bootstrap
+ bootstrap(tmpdir=tmpdir)
+ finally:
+ # Clean up our temporary working directory
+ if tmpdir:
+ shutil.rmtree(tmpdir, ignore_errors=True)
+
+
+DATA = b"""
+P)h>@6aWAK2mo!S{#s3Wt_Jr2003bD000jF003}la4%n9X>MtBUtcb8c|DLpOT<77h41q#LNB_YQ&<$
+Wpx{ODA|AYn471yGJJ4o^By}nH<4r3y3}kqDJU(8>d4v$UNhih-AdMxnLL|x>HDwa#Lik1&``i5ys{O
+6sSI)T>I~Zf4%g85bU`V2$qWlFv@RkY9x~v^sKS3gG9N1J<_0cB*dyy6ync@J?@2`+)m|?_73SDNH1m
+Q44N##Nyp9zk}k_QAiaw;m`t${CQjcuD2R10cRV;bZN1QB}GB28VNlJ?WVAM;q3xy0Hs>m)Lv)OR-z_
+kJT=cefN`4j(o;KdD7}B;~neQ)O!@oH>?){D;D59n!zJ*0OWIP)h>@6aWAK2mo!S{#tMMuh+Z*00344
+000jF003}la4%n9ZDDC{Utcb8d0kRVZ`&{ozWY}Y>A}w6x;YO^fgN`(b{hsmkm;C+EeWC=&-?Ww6}xG
+tlOFsY{3x1&j|n^%bl>sHMezi0IQbhwS%PsL*f~aY2^Jr(
+->p)--qr&r|06wh_J-^zioU^jr0+!=aW4nhPeobk*_rwE$UrRTF%uPDhR9M-K~vi3o9aPFu(U7!^k`W
+x6h>k&oK#&LP;J12Kqozi_UZ*<5gUDHflV(D$5
+>Ngu-j_8O}-GQayT6qQ_`U(Y(5Jc!MA_5XQK;CTEzILQ%Xs!nw#k_qM*6|!Zx_q%18hbh`kOD>^skBu
+Fv(VE>^-plXGz~WsF|4^#(oqV6-A5cpJ1QY-O00;nWrv6$b9NL@J0ssK21pojQ0001RX>c!JUvOz~Ep
+l~kZe?;`UoLQYeN@3t+dvS#`zr=>s4W>6K`)J(suBt+7m_C7P*Jqn*kiKP-Zi^x8Vm97ow4HtB3d7k_
+3q4@H*elL48zF=v&JLfMrOWj!LOBr%+xKI0%}z!@JG>}1_`+;3bd#_png);=!({95G*;RP$dHWYgu6q
+(t+~ynt(SH?HY@OEPW4OnY^LsOzICFR+LuN#(GM7QZkTw`wq@akA00W41?epJ|6vouG$k?r;H||RV}?
+*sZ#kCvNdME!C~h4tZH0`K5PA&Ue9*HC8mrN1Vf$HG5Ce46>%u6si1CaYaR$F^`9v?oe%x
+HmCkq%ube_QHI0%9qi-C=B-bUMXJOVfd-_|$+b+3)Z0O+yk8>=Bq!}P%@9d#Ko6vo06kjO(e0jq+Er3
+1Fog}EJ_-N6?)U^D8GrE&<1){$hBjq*{q%TM@5v3{zM8>CqoYQCvE5$ay0=NE`9J~qnI`PcFDOH5RWU
+L{{4^7Fdq_jtz1n~(#}@eZqB#XNvZR(?$m1Ea}yok5^+y9u5{fs*t?vel|#FCj^!z1;M(wpdA}3Fy6v
+@1|`VP3ETIX!9sXF!^+P7IZW3&r7vRE18kw(YAvVXVR}PmCn(=muOr{Rn|jrt}I`IgUDcZH4}^J&HQ4
+r5cBDDNhaDxM#H}le2avLdHz``or9UGuR7Q{QWQH|-}g-Vv488rDx`+r$OcX<+oY0CKBScE(q
+OYH%+3ORI-+t}Fpfg@dzoOrXIx!Q*=&Fog7m%eVG$KD<+J>6@3s6e~1QY-O00;nWrv6&Z$G|nf0000U
+0RR9D0001RX>c!ac`kH$aAjmAj&U%1)EvFVxRjSzi=C
+>J@cMk87yJyz4~-Qcqlg}hXv}1CF`fEox?~SG{pae%Dy$pBG>tnWs3{>FohpTZSG@fe-hAmws@4PFv7
+Mv`H@JnAXTbgKqwrl)IWaYE>+%OsO9KQH000080BxrJS`BUSY&Zb`0RI6102u%P0B~t=FJEbHbY*gGV
+Qep7UukY>bYEXCaCvo6!AitH486}+g!Qroon8cWL64rqlQ)qv+oo+`Ix{4xOTmvft*+w1T=EEymzS5G
+^8`)P&pIkbFlCdRayLg5@77)Z?^5SnhsFz(_JIdEKqS#uQGSCDc+L
+soerpw6J(yuVII!C7b}u8@K>~$Qkl)R)*@YZCXf1>s5u{}*Dxjlzn!*BNA;k4U#vU0{YZf*+QtvkKXc
+D38Xbmz%=um^@b_s$AqiT^uT@R$=eDrOe>avtjThKL$%qaEE_1M8{GPTuke_Zmy&Jz`<4^08Sj12$)E-e568UO$QaA|NaUukZ1WpZv|Y%gMUX>4R)Wo~vZ
+aCzMtU31*F@m;?Hp&!C)c}(J`&P}eJ*p?GbU0GwxX)~G|21nu~Ry>jgKuH%>|Gm2lfCNFF>`tFj&DcD
+GyNmta#gb7Jz3R%UV5jw6DVusPYRx(&3btz`D^*i*T{blOCh8%5E{$xb$VmSww6cT!
+{-EB9?9HRE;NXu?$|Cw8rBuCD>j-8RboYPr6t%B{Oqr563~Ll33JgT9x%HCyIQRf(K7aC^R&g^;3nQ|
+5XClk#ou;Lfy=d|L?vqz|p=NnA>vi!IM@FJZNRj-oq&UoK9w*FT-U{_ENKj|sbwjc#BYtB1C1)9g;vM
+I+M|sBn}wy%SQQ5-o|J&Fz5V9)K3h3U$a8E@xGflLkH7WevR7yh`(`OcgLz12Ko+w4%=J;v9~n*CSjD
+owZpigCiL--ohNMPM4KRt!wluI{onK{m0Ye@V#<<7Nq!
+BS4#2xPO96svdM+&wUG0w%7*3Rs9NmXtSDvg3vmAiHq3P+4++D^@HD$E>jIsAH+GiQ34^~G;s7oltV9
+A_0B-v&F3jp%T0BMoxnpft$RyScgj;A!Oq^$gV4E*4YJ+dEo|v#jQO@V>>#$q5*ffklh1jrocxEf
+cn->w+!+%yidQ;tK9I?BBypg3mWbN+}>4)GX$Jw_uF|i08kPqSkE00oAvV@XW-+F-Eo(CR+Tx}vmu0Kf
+7z&~Wp%j2w#aZY+q};uUH>YLt+pexLA7xR#2Q*hqQ;ZJ`Sde4Tp;p-}f7p?o=W2rV#cy`-Vjs+
+L%ltWR|7vA?m}89P*$bi)2^;Z9Y)eA%wmf!pnMvfu?fF9Jt}(lj^{12O~8;$2j+?CIq(aG}{3n7CZ*N
+^s83`;>9;!d59OkqH4RE-|@1d;)w)eAe@3)L8V}Q4V!`+uv`kR!L_yrZ}t0^-&zHIAO$I~t=#jf{0$&
+MJl5<`mtZe7_8-{xhAXa7&mcjaYE$0oip75T2@@&7%0`7LXpYTrwuLsZ%WyDuT)I;u
+53L63;9Oi!1-CQ}bw+d#anoBJ9gabIF|-=Go_vW~hz=dQojsAP(3@J@fn`x|I^LXN;^KxJ$zARr)QL6
+m~Uz4H%}E{^H#YUBtB8+pRUck{QMj)3ggS>VwG5t#YY9#Q+~_!;IR3s%@H>)wq2*R>5t@v$j~
+jQ(FwEP8?6T{vnswz-tAkDAPYdsP)fm9Hw4HA3dA43ghRI)mIcOFPG&tM5kEV=c1TG)Xfq~ot!=}`U}
+?0BE|h2rF$;xrRkEEuEtL(!%-3?Sz`%V7~
+O0hXWU`;*U?eJ`UC*Fa+DkwSM52uPZr>{Q!xEfJJ&rhjGE8=K*cNY!C2dd2H2-^eUvm`x=SRi)TUg!+
+eqGeN|apI_MGHBXH(YDZb~S5u@eGx@ffXYOj6O)Vg`B4w(ZjJk4~mpCo9!w=X6-}-C|gsF_G25Rk
+SQYfC2RC+s$SX>KzU=TJnsb&}bg`cdN~6!hEp9lF*lgwFE5K&?C6;5ujid32Nm|=@H`~~iINeuWk#LP4wx2CU#wXVm2Ty@AJ1-{7j}de*Y37jBB<$OhFt8~
+R@N>rW-uIYE+#F8TlPeL!Cq5-D}NJaD5D#H!mtGYRB)zdX$$d-eHwT#+(MT*>dO$aMG?V^dG3O1cG%N
+%IzGN1pCyI%!Kc5MN9+M2z53u`N9N+~|)2tSUcmrEwT+3JPluqL`SiHxgU*`
+?JqgqFu-Yx$W4K9jlan}a@UYpXaXPOG#OQ*CDa5&aS+O{Z~toJXu5@yC$_$F%t^@64Vb*=|z^%YV!*1oY^5bo*wb%)oL?=rx_Cg(qK&Jcrk(Thc=JCPkkkyrkxl5(P)ep1zyp(#
+$CL<*52>whIS91tJ!VlhfK4e!Qjd0qXMs*C~PsKr76uhJzvf4Ob`9-C#bV%5KBj9Xc%I{_>7b^p)eJ}
+35_8=VPM7qKMFW!6bS<>LiXL-NjIQ|XIKMHISkv3K8sNjvu%JVrZ^+7a%uCZM?nJs{Ts<1n(>Q0aB9W
+52wm9IT)7|=7o}u1L6YTL6c2Cxi9
+;i1RInID69`X!7Tv*ZP3so4SZ}d7Dxf`M;VG?|uvhRI3!j
+yS-q-*p%}wii@NG!^T!yv8`}crkil_&UrqJ}I!IAG$;xK*tcj(XfF$LJl95q6D@DEn)Cj_2SV~aLr<^
+}t^_!^3&cz|@5m%2JIfu6@ni`4kSF&0{GeK6B1Mt+H%W1agi*~dgZk<3Ygil%JtN@!Mr;(_f&e#7fh?
+db#|@M9=9Vb0A?Gd12=G2L@{iyxwxnK*_ToxAa7c$``}MEn>LJxq?#(_+51<3O{lJ281kpOvjqppn$oFsj#1+g-MH(0)#+}^PT#5UR>xisqnp
+5)3iUC&t+dm`?JKY?hwVja*!#ifdlbCP;XjhW)MfM>oNmrJf5(bxX4<}#mL*UfBfxq#a=h`qFUNUlzs
+Z}?k_#Gee3LPl{hO;-Wxub$322y{1P$aDl=aw1TPE!*H?*Py=P0T9ek{ViO*HY*3g9cO|%(T(AQ7_#r
+_j1G-`OZA1P*JEn|Zt12Fe%G`e)3S0$vMHU5`4y{zqZ82_C&@7r(!7l#YOw*fx@yGuAc;|Y368Jpggk
+bW_lhnUA_fODz`hW?B0egghGY2wCw=Md{Pe!=!7LjJ!6o8%>Q4(E+N+e06Ph@v=^vXRg={0C4=0|XQR
+000O8ZKnQOuGux}3=9AOd?)|_761SMaA|NaUukZ1WpZv|Y%gPBV`ybAaCzNYZI9c=5&nL^VynOiRmeQ
+oaNJgaiaO3YcHx)6cHj?(1F;fU()uVa!zE8v+Wz-GGrJ_0B6U7DOnz&ig#G)MPSwDpy-2Udd
+c;)K2BqWHLEBTbE`hl4M<1b*Yj>=v`sTO2|Anm8`VM?b(?h+e*95^s;&UXieUH&&+0{^G)+;tfO1Vig
+q+af54-gs15~|H{(z8gJ-1BMV#EIJT+xp$knwZs&OSZwpsgLZe~K~l`4x;;ct@5o$8O=sM3P*+bGtR&
+RUWynVVd%WTt;sOfF=G3Fk74)u)vzSRu{67x@kLq#w3QWzTe`c+@<5ZVwRV8(qH3Yunksr(K
+mR*_7ubcHR@%@&idt#(n%Uq2uX3^tH?TQ2(wQ9w)W5<+-nV@ORCf7Po?P)5IrPT)6I;jlFcXmepph~f
+593$SS>Lrgw39C3sre|kw-oE($#p~p6@7}ylUO#ztf%OwpYHWzfx!|2=salJK91qN{swkZc`v1~aXSGwbUsKoB{$i^yP!AQ}B*H5O?c&k38dZTO=K@n%GO
+j;{AKwC+kK#CN3?EPR$?IrSFP7+ITAq_UENtwnBw$R$#9;(zCMkjdxbJ+_v{RHT&(
+-+3x-&UL$3@vyh9k|eS!ThBrYV2d^X+T@;LR%Ttmz|*+hH*KfDzJik=sdQunI5svW$BDIttWi7FZKR{
+)Zp)&-oz4&Vg_k96p1l!*SJpF63Yl{RG2@xG>_t}{_YnPiMShG;q#61G^~g%>YFmk=YMK3Lbgojd*VV
+RTr_~&Nz7ijl*vlLQjM%7L!Gjc%s-Wn{P;K#qQ|i)N5)L;Ldsx)uUsuh}fZRu7p;WYwEcWBumtbfm=~
+;t8z1dQJz5$PzMpPq!5t%tJUYBx1`92^g^Rzo{B};I#G&PB9toH=^fGej&=>6{#@IINdA}R0_5+8e#h
+WtO%4DS>n!E$wO*?dtO!o@0hv@4C~#C
+zjk9=hfNApfbPOR}EK*Yu7Y9=LQqk(N&aq>50KreNstAn7@90vrpRaMye`S}
+JC)yo)*&x=DL&rvae?v|S$0nbX7|Xfvm?EH-VNurGzbD2hNx9_4zFKq1LVWgCf*u_fPpGmrk_H`NON
+37xVJr{89JtX#Ti+cs#$l>ZJ8mokg|H<-LUd&eY*TVPs)4
+YetD|GYn^|Hb%#J;}{eQtP35L9lR{Itp(kBFoZJy^S#TO5T(yoFMgIpFG$jGcnjnH{*su#N=<7P^w7iDS6CcSv(q
+8^UvGHu`_fzFRyMErxxVz&bLilDYi6H2>t=RRnNuG|t9m1vI0YAG#HD*-uWC?lB<=}dj1f-`gQVNb%9
+ejf$`76IdY^$yRtn_fK1eWz)T`x6Gd5&xZbxYw6TL^>Bb2W?%03WEUGXJ|eCvp_Wfp1*-_1!8fSw%fn
+;^`+w6N3iTwxLD=~K?sV`0mDV#4}_<+#0XrlB0&vi&SzZR@jx{k-Am@tZO7-H<}zchDN)x&kduhaR#z
+zX?I7~M$+H7lL@`jtCJGoF+x8UXTzEJ*Xj)_tPE%9Xbm$GOyxuJ_M9r7usl%owuOzS(C5MhI*HC|=Ut
+e}!O6jl8^-r~m#YMb{MSSjKD+J1^`58SNd0FFAbfhbgy2Hw(vj%}RcnW(S#1G;)rb&nhZfQxyi@lAdG
+aN&RuBHQ~L9r;lq+ycBQp$FXJC#YVFzBH!QLG7MEER@Tidanp?fa(A`6Bgm9NfO2WB*`5_FKK$I_$Cs
+zHHRTE;Sy(O}XUMKi*LF)4%WR`ZX%3#qZejy%&?=d{@E^=x~Ei@?onL^=YF6cht~}|31Ve|06lKLj;6
+6f}#rBl56(P9IKN3oN%JdFNW7vQMjNpkn;m5%wLtc?|k=@_srY^nd}zua#Yx>^3WfkkMrk+yMv0lv!}
+h4?m}(!7Hvq9@_^23)thjz=12*AT+GLb-z`o-u)zWD#wt$Lvff04S^7*~Y|zqC;D%{j+;h1Z{~=gWNh
+jl*Q=?p`yOR}%q=EtpBwi4ra4j{O14=Gz-Fql-vr$5UB}eD8o_~j*Q2ybH>77+hLO3P2u@Uv7WI9yuS
+K?dFkX*)sR@QZ@=dxUF&sDX$8xx(Y#wNc224g;F>XoQvbP0lfxuly2<0mgp`uDG&qfv~t9BTb|GxfbooR;$ZkOa)tU-M
+SM?&qOh3M)%yOQCc`{BTJ)>)N
+Ijk_ZB%s{zPyJXzlTCYt_9e@M0miY^}L;0nxevKC^r(TpKE^{}RM}t=Tbb%g=zrlUG
+Z<(;RER=*&R6JDvI8Zz0G`E{=m+xM0|0^d6==I;eL;7gB`t4qvx~P~&JU*c*3Hq%lv()Nih~u7>*UC$
+7GNb^iYS^Os>rh%GT99dn-@rdXf{bQTO~T;t6&_R{tf!$?s@I%1zxrOd+Dub%PH0?6!bJAM>9{jxw3E
+r#$vRP^F>575{4qW>78oxrI)cRm?rJr@rAr&E69cFY2m_;=^0BtDAV{|kwq$;=*!Oq;rQ*?+b4)0@>!
+p*Kp~U_)6pWtybG&6xrGaP>Os7mt}}=XJg|0b&~|E571~5Unc^cSB@f3d3$iU?I(05Z12;sGgcSOWCw
+^{4s=TU*fvxCny!4YWF|TZJVqJ`tvI?bEC?Asp%C*b*i-w#Qy?F)ms3^8tc8^zxnRXoL~8MYF9ti8lL
+IjC+8!(ZhMzo07sqw;lVeLA5kzMk8S9B@|Z^JC-j2>x{c{MCk+m{rO#cAMW4wVXCeAI#Lz(Z=tj49>(
+U(08^$fiWbfJ7fk^zz^tA$Hl=K5Av8*?OZ!p|B2*|Q_UdJ!DPq_mVWtRy3pt;6YB3F9>TY$g`*Pu_Q$
+V>WaCyaB|8LtkmjB&<1y{wOHpWqRb{C5q1HQY=B<%!~CPmV
+AW?>jw#iCPV$ofk?P%3!`YU3kid7odG7(>fMtQfDcc@xsE2}7^aIIjr{q0;;|lj6Rp5|K!?s#1Wt755ui0ribEs
+973u;t?Y!$~djWijH1a#dZ_~!DvM9L=>%e^{$dpB+_kJRJDlnyr_A<8jZ|%8f+O?N>=u}tXRdFw4VxP
+->JRQpjSnfXfYBGq5O&jM5yN@;D3S*Oe?8q}?(GV}$T
+zdsq3aLjBC66x3i0r%ipdKFNbp7Iz-
+>f`W)YYvj=xjJ_q&C@`nYfGq*nUeYmog0i!FFr@K{yI8)cX4@jdO{oTWThYjzz#MtE
+1`d{#7AiC7aXWWvG!G0kv2o8<4T4jy=2bRYH@$MKE0X;J5U37pyw0ObzRC;x=vTT;@$b>Psiu;;5I96
+;!Ll)-m6+}#cydMdCm3N$J2SB(pu{0i_d9Q@8WDJPV>98DsuJ?9$1hz4`hC)H?X`uoPCH4qu!pKpW0>
+7=i@703Ja{9D&o3^OWM`=wh(zyZR
+0HcA4z_QAi!BBaxJ3f0mB{Be>2uNarB?`cg8$~k9r?}k;Z?LdZ-lg1KTQj8&?U~#(JZQUA2;8EFH!c6
+?^9i`8k5c5a#>`)^`t@CSfJZEM7t7BhtyEV8Sb**1Ss9aaAjF&*R_-4>e95Sc^Z+mL%9B&-S9MUsZ$MkM0I^J(0)=(>YM4~V;Atdi%q0$#)@`FZ~mLvY-E}?^Qu6G&&W=P7De(j&h05a(NYX2079
+vSj$389jM90&glCevmCqt|!dA${Gh>Y$+?Xk>DE{}d*cFLboS7sHkV1R9|~H0QhSX*MLgX=f?n(PtwXvzx6-9p9gfE`(5E@
+Z6s#7!*-I%+En&;d>|JOf)KaUE;=0gRAU2wv$5nTHXwri8sWGPAUfB4p{=dWzLWMjV;}@gjrQK~5P_G
+?)oato~PA6gk-l{b3hHZ(0|wb|uH62JB?iJcErk;0P^p;|z_^Zl9RY&uZlN}&a!KOqBUe|QW+Vs~8k;W12vSM40OaXCiVMdI2)cQWbi^uK985#vM4cW~Ce^%t}V&s*e
+M-o6q)$yH2o?4Gg{S&F)fS6_%4T}$EXTX3S$4z8NK6C#~mt64AVpqR`g#h$6?q~6Qr68JoLl$r7lPHD
+-kHlInReoENvdh8&g)!V~^-!ca6JJeQBp8xRpjpuKR1o^Q{T8=!+1`55))~ZNCY*7Oy0`sFvO?9nUSC
+~D+X6tZLL7M@#=(0Mvv1B}PZ7$~w$l{(t6)^9}Lkhd6ZRFbyxo#&jCBUkUE8f^(tN0;B%}`)L#>}2tPqYN0bp{2%!c$5#)prK60X
+zS)Oq-paJIb*gay@!CLcxU}Iri?d=J^CNMH4WB|D~+XoFE1@9z+to6Ix9_fa!&_x0|aZ5D>6?fcR(l7
+l@x6Hz~lfyFQpzOPnhRftL9=+9TQ($Sz6scv86Z+A3;-Aw!E8LZEuyX7`n+&OkyJrDXdg_I8XF$1LiM
+HHgS%YIJY2hZs7V(}+ne{HyrIfIx6m>?BwQtL8-vJg}6o}cY4Y4?A(uzH0>#s6VtQDc_)bQR
+t8$Z{L0{H9T@&1?C~dpzDNp1jRAz_c6T9A>)pu%({9)O2l)`X{SYzPZ#B$h;7+eSg)1lkMDl$jg_V0k
+%n8k6D^cIf>)`to)f`siG(v=*Sg4e2^u8EcWE|(d&Zd=K0oQtUgk0E>Rx7($n3@5>SamAvk)0n
+8-L*(o~UlF@mPDQT~$f0ypFbDOSOlWLuwTEOPFW94(Df{Z)t41SFL%Z>F$4+=Ide{{8ptpK`z913RV1
+CtA9szP8k{GSrvpwSj(>{|CFyB?WNV&whQr|y{d-#EZEmdlyo<%=oV?5w{;9%%t|T_e68u}0lm1WDoU
+AK?FsIK2^q?|zm{G`OZkZL+CRXk$mKO_2mz6ORiW3vX5s7LI5%cptXL%yalhe;uocH}S4;E_T|<0eY9
+CSKGvLRA4&jylUwo+2JE)$hiXDzL?Xp)+8*Equ4=8!M9wFFV9Sn}Ux1%;{VeM@{>3iy_q2;#?so2mNKzVK7=P927D#n@}C+sn$DEwgKdQP^fqycmh-xy^yzG^Z~
+tj3#u~iUHp=d?_wS9F{#|$7g_l>iu(&R7P|ZzGw;=ecpNtK9DzEWw@%Y2MvvN3q_KJP-
+1J|+Qxh9wr}mhBTby2WnVaT2IqE&_6cUUaoi%QSH4wu!r+$^P)GZRz32zHpMSYT_x5w*)H`W}+B?4?R
+?j&fFt~E1Wx}2D_f3
+@yWAsvwrMl8vZ=iF)Kgdc*(3mbFhw3nc{^_MnTq`2K9k`3F1RUe6DpuoTjDTQlfFwf!(Xisd(6?Ye5v
+pnRzEeX?3ZHA)+#zaEO`m3eAMdB+J~u_yUIvEF(vw1mXB
+?U;+b_J|$h=KF^!;gkXDwSz+gl_?%>ic<*M@t@Y7FgeNNMl!DVH{S*Nq)Ce}_zLRWQAC^G$dwYdwpC)
+X_d2zeV7GNrv3iWpPtsSs3%qiG%6zr#!_|c_Qimf!axP;aWV}u
+I=|6t_b~f?hbN+wwtj!PKdwoMH<_>G!*(tbwp=w74k}Ylilkr1K_Pf6sGu-
+wzHW`lj2S=`^&vmeMZrZ~i<@dGb2bnN>4X+w062kaBFOL8P82M^fXk3KSD~{Y(#%akB3+ju-^&8~z}^
+7zo3OIzkW)1iDj@40528XL_<&zWB>*X~!#vQfppm8qK{K;o0d3I?vuGmt`1a=9WitV2YqS$jWB6j2Bh|b;|IN|sjgkM^D{N4CW*eE)+P%haSb)pt>h|!7q&py7HP
+1Pa-Mj-hC;(YW1b@i0A#~enjrAgUK&bg|65lyjNhj4H~UV}9*SQexI6CkVhazBnti%rs?4z0o~*rrlc
+cK5;=RskXdTV{h2ZRK9!a|ad-q6`RE;2aTja$Sr5sk8ZEe$bgNUoF1j&P#0}&k$OriLFk?Z%T=jSu=z
+PM|C2ci2N;K;7l)eOj5?{a2sD-u{if1J^p1~{tq-l>}r2Y8t4P}|_(fNFcDR%(9)d!UD)A1&RfrBYT$
+#9{jr6jPm=#t~X?jjG8%XK{jKfrT(;Bov;|0DnundO60)U)^l@aVzPyX)$*@8G4E3!mrv7oDxnZ3S}s=Qc
+0>z@$elqSVe*3{~RKB^)%ZVg^7=CzEF}T?rfT7cA{%P5UJ0+|X(BwTkLGDqL>#gYCBis8us()dS^Wa6{pI&-VIC1UZl
+li4bZz8Q*yn3aBS{(hb{Dz?2zwPTb7i?c?)>VUnLWF0FtV)so(2pe!TpvOh{pH@}_|q3?!)t6$tOwey
+7$Uk5Fr&r(PhlLsu7d}CM8pH&Q54!==RA%dx-(dRs2(T5=>GsvO9KQH000080BxrJS`A5tEZrag04{L
+=02=@R0B~t=FJEbHbY*gGVQepDcw=R7bZKvHb1ras-97Dc<3@7-^%P@lQcKz-*7n`Gu9(Pm@~+mq<^5
+13t?#a6g#v*gi4zF$Fo3i)!>UxC;Zk{xyhGmPo+SM-GZ=hP)SjzS7nQvtftjA3o}T`mk>`1*A49n=;w
+)97R5B99GSAAzGAj$Q4#L}DA^oGH%Vn&@DvQcQinkvwF2%d|m!ilwqR2$NT4#A7=6SXfLAoiHak>!qG
+5!`e>v-)?EpnDFbfSnuM4D7mUE*Rz7<)PDDyg825~wP3s|c4Y%>
+DDYZ(;WxY(?51$4{HKc1aVpT9bN{=@5c-;cx_1QjHj4zxOVK%^}9nU>Jg&*h)XIF~D#7Iwhb>0OY-(R
+uZ1AA~!ZE0|HE^N;+r-7$~Dr4N)T1LwqwNSB5%sS&*z@;m$?UON_w9spCw)+>z*tO4@)6b2cn;gL&@juwpLNMx|<=
+1F#c?8S>T%cVCIUv#y%gU-F5(k)MD=XLa_hC}~PS)2|KL0_$txEKH@WcSlq5~R1smw73NxXxh`C?(F~
+^=Y1G`M@d_J$(?pmrE&Tu-eFSLSieQ1hVe>A`W0wRm7nMVm?$bPEziP!agW)brcDy$aaPWnrHcnBsnS
++ko>6Da-XS4ItOJqh)*Q~F)Pz>DY7|oaU71YhfGQL0PLdJm?0P*w9CqzQ6O+DsTAlR<%`n*;Lr;J8v#
+L=X$*TE)@?3<_@S(|34>Gwi2~)BhDjMof)+$kjN=4}Q8Oce1L_XdQ}hhF1<2@9jK|`B3DQdhxilDp{z
+Psk%}A)h4E10dOBf3PAVYu{fgZran?$;Vf&W0}tPvJ#5+ELf0Bc}fau3oipxM!w$WuVYyM}FJp4v|JabdTkY=DON
+t7@g`V%p$ttzPOCa9?UTRU2Fy*ix(gt#HZuTD5Qj^4|6$P4Wi6R)XV+I87B;GO)7i98Ifg?zo)r>@JR
+E3G`z*VUVv=*
+uT7~6lQdCqW?kDLQz;;;G(7o!T9dKBf-L~QGG-hJy0-HiyAV-7H|5wRDr-o)8Qq;v+g1>HB?z-u(M;~qEg_
+$=SSvNl?4TI;s@TW3p6cHL5c>uhPsuiM)1ovrOk=+^c-w63@HhTm2X>LdlxJk^S~U&eLY9r%;6sXJ+!
+zLgAcZQzLmF+ufojGHy`aHl)_z)DH7JcPHAsK%W=vxz16O>^&R%W!sJizU(Z*236bJ-eQWE9Ys&RPf4
+cK=K^efC@?oJuyE}VH{7hG}+W8&l7RR%8WD*X)w(LBUWy?OypgVS~pCaW3GJD!_{`FV<6TCtg!917@I
+#XXO22sgw6GIe|VTtFu;+sDlio*p#Ax(
+)re%=vF#Q11%%+(Ap}iTx96Pl_kx;`z67yXW=1Z~@h}Z$qm&V+hd84TaW=L8oNs2i?{ApmnW-Y*V{;M
+eS;?fXZO@vkXK?B>XxSu3A<6X*3wN=6NuY4xgt5`6=@ZH{glTBoWLfPO>#ivRfYJg7(9?_l6AMtp&65
+bQIwvEK2Z<$Rkwngx$vH6r(G1a?twR#_wf{-h+_FD@6w;bbGp={*^U7&AcrQrS9<}A<
+tB*Wr6beMENMGe}{Z#C3;5Mx;OekoJ2-mJO1Uu3HUO>}WXuGd&?z^7Z?jKHHUfb8|hCZeZq{I+X`_QH
+y>8(a8exBJ+1_c}Z{b`L%unP71h%Be4W%WAEyzA^mf-gDqb?M;O6P;5DTH_+gRy9W0eLlB*+_6G6DuQ
+g#}q*s88PvOWWH8H^K|CF{xBZV%mT#8%^4p3uIoJprGmNdek?MB>Kii3x;@#`>$^%+zKs%O1$KvJnvT
+9@8V0fTSATEGy&EV~1{h-ekB{RUYoJC+FJT}Ua;PS3<|zWS}0gTzLdkt_n3Gf4fl4C8qm64&~kgWu5E
+6-2aOmVuqccpSs(UcVT_Vgx-^AsHQgvHT$w^
+gvg@JUp1JDHOl(`f~A6jdXZcSzhWf(cJrsR}pZ#*?LRffH5l*m0g?A57Lk9;`IZC2$3=qvd@&vZ+DefEwM;@ll9XShVY(0F
+otL!D1VD1`XTKx=K#+j2G5aTh1`i0G2DWTJFlCr?CoHD(O^DIRvMY0RC+|)^JK!*#+dfG^qedG&xQrf9LL`d7Y!c;qTx4U|
+Ce?-mNflVNiaM7;6PVb*uj3I!z*A_1J}@lo0aO&Q83gf2$h{2X68&Ak8tNfx71={-6M-p8t`_wlBjp6
+JQ%TfxM>xynU7VGQtjZU0e(+FF6=goSH~Dl)92U5AWFA$!(jDy`=g7f#Ou6);Z@5>NmuCo6H{%&SN+V
+K_=}IjeZO40$^ZyfD3DB1j(jshPY6lKX47n=lHJ})S;Q3khOr7mG_F5z=7378+gX2vVnI>hc_9#Bnhf
+*uL#G%oEXK`#MR)1~j1r6+HH@;A}lIvr-g#d0VoDB=O3V~JwlkYOnDCb2@y-H*bCtbsv8P%|94b<`>?
+EFTe8dUchK2i}WovEt`*mB)|zmzgTDT#V{pM2EqahFMUi?K&d)O<)`Hdq@X8SFlAPFn_9gXAK22vP&-
+FMQaOd0wuIp$o90SIyHiMvM0ItmWw}B?p^yJ1S{um`WT^WQ*-FhMLu|G(GWjYXR+^@+jyi6(-!Jhut9
+~Yd6n#LP*+>dR=)N#XgFmEG;VY%T_%&-bbeT_YRlOoa9o$D+e+{*V1G9@V+I>{UL!j
+Im4I{h3YsqE^*AyQpvY`$4v7AFwZH(NMfLR6J(G$5?9D_$hn2bo7rPlm&G&f;)xIZJH+n*;LWltl(M<
+$VGrTG-b)t^`PbDuz_B}Tx&L-?!yg5bg%`rUYipz#&{Zg>;=75#oBQ#O;j=hjr-T2yxwbf_`RwnBF|X
+{AqXE$6mEo${>csag&~pxbH!wbbgcgWn%C{omSRIcMYSkqQ0lOX9OP3bc0#+1$};>(LToANg7G8S)vA
+iN@J=$E`2QnGQj*Y159+joNW;*OgKh02t<2eM^y;o@iXvcq~7HBNdI&o*1*W^hs4e+G3o7a~kIHx{}|
+OVFd$C!z=<}9;Z&~gDaJ$Xlkkn*y^gdf_~Svs+^9cjpwzV-IX!A1a~->UucB2n^=R=*SdzpIV17Y6_r)H<26Pb?x
+|MkjGVS9025y1Ql{4?gxbLpL##Uh6Q8ZSyaY3-q)5)ZOp;A!EFVQwjP(P^I9tm^`y-$xN?TjEzIbNcd
+dHtCfZ7-0)xKvQFEAhgi^-Mm&Mq7Y?Vw>`26QEb`#OTW9<-k&a>W)s1z!7RHf$6dbr6{9n_5pBV$aYJ
+MW6ZnXz{o0Lt!Uar0|5c^&xj{>xeUR*z+Zctic|!*e?U?*)a`zys)sxEDt|TxM`<;%JFTrKMu!$AsPZwH*W&}#j^~L^`PuBi)xvLU1r
+T?Snw1Rwp8y
+F8I)${;*oZ-r-D5aiG8AG55N}l#Ke&=orB4f(KSuvRP09j?w4gv7U_?
+J%$0b+k(5{`O`=8VOvjJUFDwi`HU`2!#@9fbNP?4vGRPMvShIf%0F2J&ol9I9I1macodg>#FV`9RTruVJKrwC{>(?IIql^|vOIznWiE$9}a4_xF_Hx)OF
+TmPZkUA)=31ScBIl9VbPv$TwPBm3!G)fCLz!92_oV?Rhip5ztcqlF8+~gf_9VIAbkb@s@j%GNnMW=68
+k9$%}BpfPe=4Z`}$ce{d{8hE5Xb*RbDGVv|jzVJ#jE@{S6ZA`MF-okqrK#(s@+;Gc2nM4Bz}0wODj{T
+9dno*lXGr3dj)b_D6(=Gq`tWKX8#r+bXB?f^d~jK@3O~E8ttuDK)+7_=1*u3&XBB|c)@4HBDA=Yr(_E
+@7fn5pfI5jWPSGWaR{>Y_Y+m^#GxBls%A<;PH=W^iJ$gG&E9em}|X`=B~Gq
+SJ^vsy9X(5G)u|XoH#0$R!#@dI;RT1Zj=38p|C30+?6JjKSd`Tr-s^C+kWZ820X$?&hw{Sxi#`IIXEz
+8JSuURa-e_<&Z_9qNJzsxeqp6i~$oiIX68!lIkihz##*buFt_aIZvHZ`j97oH#Gg~lW2<3*U7VI-L!=
+!8<(!@3`H;MOHH6I;vs=_E#w2lN~Nqw6K7v$jW(ggAmfv@B#reLa}F$O^EMrr5YIaNAK#RO@xANpB?d0Jgu1XJmCHaftbep;-gigf&(A
+Cp?K(Emh~3B&D4Zfis(D^*ZUZ+%Hdle1HD>@^t#@SdiA@099tlfyC
+~DKC!B6xL72?dWP9LHr{vVSoM=c6$os|;eKC615LNkiezY1KXr%a)Xx&NcTCO8qF$nV1?d-3r&~yVyZ5el>7K67m|@ESo6!AMq1vvF<|9Cq-5#R2N_L3l
+7^H~8&$crYtq5WeMeWLTpy;1E$9;hK!tC~0)%R@ToHr820+u2tlNx+RosaB)uYbxBLG?8%_|)WUmZGz
+-m(o%Zw;L^t2
+|`q1{Xb*8IcL|s0ug-I)06X1v#$CEwcBZwG6)g%{O52hmkz=pO&6;e?Uxp_4TiQ{hL
+Go=>+yYaDo280q2MQQ+M#4py8Y-GzF$gr&&IrFIBk`t^!^AM0>7jjlF~^fj(sg!-pqAR(izAwC%qcL;
+sxFox-a3lI$Z;{tajWI>#g&FbWF}D#a%i$QvwwrK>np-I6fZ(;$L_D~tNIxteO=G2OkPlXh$+>1+7{<
+JA(Nk!2%Ql{E7P@&Tch8Z<)ve8Lg$R_LVZo}m9=?wRM71Ggz=&Kc%#C$@6uxk&+oS{GEEZD_GjHA<74
+wpJ22g!DnQv1EfS+stgYZL(&{p`&o`Yy+-ostcD7E#O;Ir|MKd7=mKeQq!CU8#831o(N2pQ>@h$J4Z0
+FGVwXy-S#dkzLRgrnHsc@cUEZqzN$AyC!G@^I;)Ak7}sytj(L)>epLOgb1V!a&7r+qZpMSi$VoZQ?QZ
+QW=GCb;_hvbt6NHCV^>){g4r|E!j5Af(;)nXnxh`X*-}P6YRKR518;09^=2u%~Qsm?#Nc*NeCLdWB_E>x;Hn7%lDdGg`S<@EFqm#63NPTsuvJUf8OQ@>!}-(dQ~iVHi}I?7?Fw1yv|oZp?H?&?Ihz>gj$fai$GP
+bRo6#BJj>Y8?7R(VC;S@H<=$56>{#uWxCZ7g9xC-jg5%O1P6b822Ee8sQ2@!Mf)vJyoGf?Z
+Mr%uXwHjt0@2vDmT+rR}XPWPyI{h1u1)rytF$wlSwSe)7J$q=nEIYt+Z|+)mFo%qo{31bywGB1){&ds
+Gp_Mi3X4g9Wed~n4sB*Irkh|5jrC*L#KdPtZvbOp8==cqU<14^tc_++Ng|hBdr6DlaI#)$mBLmLVo{=
+_`buzurVKbI2eEZPGMdylM?yd+{D^2yPt?R&!=xsFD@utK#6d1_Wt7
+aAIudDOwQ*5)8i3X(JWBtv=^4S)ed5UtSB&GX6of7Yd4!$WRAh(0t@b_^VW%r5>8|TsiRT9~`JVJ
+Mfbxz0Juxexp-CP_0Y=sz@Z%7FjSegUNV%*>O3v=`@X0VDzcJcMM;{+>(`R-F2fgus?K;ugf!L`5~5{
+Ra;L}xH4s%G@K=##?OMC{LZ!l{;%>xq&yvgP&8c;r`$cALShJc|MIF0Q1p`
+p^GL#zOUPbzxuK+>eU8Hq|Pu>Qnr2U2I~4OY3pXNQ>QTecVs#=HYXJwvy!c(Db|8jU+Cq;T(In)!Eg6
+gSK7M!?2&3_Y=-kyew9_uHX$7-%?D%*tuvN#$Mu+aixD*d)YJn82`V&YANtrUi~0DsjdE#I2z0FbUUj
+Hy@4CeT`lbi0V>?KiwHK3YyXs17AN@RV;MCv;xp7Uj=8y6A6E)DdU%?R0(PF*L445W?lxnsdyuZ-ZtR
+K=COM72P{8vA#t;`vp&F#XkHwQLNGJ7NTkEiF~!D#hx6QD80V@di#V;Ck@r{I
+`FvQ7zHC)Y5%A^gZaq9KQ%b{^dG-2%tQ^%puS(U6u)t_Y*k4CqL%U{O1
+4G&GA=lwX@kdx3^7O*9S^$n3i~=)59pFh~6D;j{4=c4#Pp^3@YqhzKhOx9~4sxa`pZ2d`oL?6ZBHsdN
+RDE}fjfeW0pi;%Jd=EwOjKW}&`3pS&5m&YBabmY3YDL(_zN?2!+eguQ#98ltwOyC!vv4c>W@!s7N+RX
+Gui4e4ZK^Sl~UEzQLF>GSvJFWB=(!c|vQ^l;vx_Up2CHmTa$Z{#8fH@LY-KI!1wqqlZs0F3QYkE_xz?
+Dz}q^Z%nIea)3V!p_rSX?>Lux!d@sA9OUE@4*StH^*}_9gJ?ObEUpSn-e(Tn`Iov1g7T~_p})jl;u>b
+pKyJ2js>}0fbE12<&Ju6uytVD-Xd|vOL`!Cn7gqKVu_xxr*m56p*wx_aA16dla4OEbl*W{)PYRHTrjPR&06xcxz#Nrd!%r)n^rsCCrQ8#fu#43Ie+P}yq4{>pLQKA_yw
+vV46L>(_E`=ch+1tjbM0v7iNU#7m2@|6AltfxpcJD+e!e#5%j?5V)^+Utiz49T1m+~0`1;rWS0ksUAD
+r-5MAsD7_x}Xbym8l>N)Gf@f(>gFs~;OO7HD@2Gy|{v0t5-7L)p)80oyL{!>fXR_%_rD-wiUPsMIl$~qJ3T
+{-&B~*J+6Y;c65ZT`PEL?Z=Dt|UQR`INUB_+C0bA<2vnH~UsIKTiT5@z9FfGUxY(PC!8O!>Scb5Tt4I
+bHh(@ivusbEM8&SOI9OIQbdew!qEhhkequT&fiQNcEEZ=;E5=}6*MTrqS?C%e@l>nbZ;!wBzx2O+YVwenv7k
+b1)8J{-$8BJbTj$tY7R6dko;^c`UT@ZcA7{^G`b^R7zR!wm9f#^!Mb&Y&yw9G1s*NzYI_}^xzWPG-*a
+2iF(2YQJ-Pr?sq{h!IxyE$sk-qX4=wnR%ekINQbg50dM!2g|?^7>saKd1G1)Iqbq4*}mIU7NTC%P^$s
+9zDxHRbw|I>?x;ZbHoY(k41$jfN1DVK|~Wg8$Ujf&SV50#Hi>1QY-O00;nWrv6&&p3pDp0000|
+0RR9M0001RX>c!JX>N37a&BR4FKuCIZZ2?nJ&-|51ThSS@BI~FPA+JB5!3-adi3N?q;#k0OrX=2B%=)
+YDA&~G0FCQ;)UBUab>Bi_i(Ap|1gi@*orCP)o6EVRWsN4jeWrR$kAENvTKk!xy!t*gsaQ6aKbTr
+Uw7>4(S1bR0{LH@!BsWQ|YVd}m$Bgn+mR6v)Y{lgBTlrx+W7!xv*suiYZVNEfz{A)*G#at
+ne8i)xe6xtVm?&UUJ+YkLc>QFA1+p}2?pr$>7QJZ8Ei&@Zg-?8x2JZk<9b7UjLwuF5qQ|IMK_<{=8~^|s0001RX>c!JX>N37a&BR4FK~Hqa&Ky7V{|TX
+dDU6pZ`?QzexJWW=RRZ%%s4G@#bpP`9(J)^T!6hruq|*8K`_wR@{AgfoctL^
+0Hbs#X`SGLL8og)hbvwAhi1nJueXosUTq*6ilUmu?EWGo-I=QI^C%gDo+gXb`YTb3BrWH|%;(O7!q3?
+tjafe>2ZT$L^I?Vk0UiQ`cS*S*vs^|3+--1|W#J>k=#9k;jYP?g=ypzoz)2CF0R8APhyGneiMW2~6pE
+3BkQ`YgWBfmZvqs=0`Caw2}d!s*t4?hpGi+flv@=s|k77f|tcd|Kp0Sa
+BGwLJ8MMC*3zy!Y28}4YwOHhZ$7Uuq9t4Y$TnJcD>B;%I9A0Z*bbhnTA<*9;mQ)uxM2{DdGb{{v0pGy
+Sh7|dhL21t28+tD@t!}4b${r67}nt0ZayfqS~P2`2UCl61Gd&qo5RYh0+Zl(W|2lu-LCqqrMkR+!HRL
+K3PV?*85H}E2MfmiA{>XPvD+lq{81ODQpJKj6d{!OP;78l)!?7?PSj7yJ94aeoJF@g&6~t+w8if;SO|
+yc-@p6YisH`x!+zIFELbi~k$}j8_dVzdk6_Fi4X4K$1?r4*UZW3RQ53&{Da`Ez%R|Fj*@
+&*sSLKGXCHn`rqykRB4C2)|OHvCzqJ$R$1EB(C)ade7$JX^E2M;$%
+M~5Z0ds>PXUn8Li!veq}5o`VAR7)ihv81SM%PW9E6Tmg5r2t5flqlOY4(tf7S-?SYUR-;(^4Aco_CpL
+D4F#M|O)%-?UU(ZZ58-uHqV^n8OUbANC?#(>6=;FSJbA950}QIEiX?c^vCOH-g
+%{AvbT8CV=oXLwklv6cx5ePvMi(ZD5k7NcToXY(F1uN193xV?eBX$DlUSGeRTZ&ng6U7P&bI;v84aLJzs2jTnnv1QKa?e4DT9g380UxW`glm9T9YzR!iGC1>AiSfmM+(AQw2YK7Kf;XWgW<^v1g1?!BhU
+$;*nH?*e%w`1@GI=H*Tg+q)D^(|4cj@_+tu}TBW*oc=*HCeDs8sc?ix^L8Ejil`!8$cGfOab3BeaeVx
+X5Bv4r3NEQ;p(-MgE2Z?Ati+F}cuh7AsL*AQ@y+OJ_G1eCtMdH?>0sXX%vn~3{KTII!)AztVSSBG)&2&VyP{rrT-oTz{^9QJ-dNvb!l^ejdLHL5
+!x9$=2x56~F|&4Y;=XLut~J?{eBpT;urMbv}e8o@D)9sxw)O==B$hG8~Ld5Jw_haGy%@bEN($0f%1H7
+k$T73^}3i8e7lhRp?Vgvxda@HDXDxfBkJY3d
+(8hcGaqJHfK$kx&4G@PN{Vj);L3=qziSuRC_kxos3t5RD*DiLrp&M10LXoa=pJW$Y}R3-WQe-kT?Pin
+MRp7aACw(TFlZbcLmkEHVJB^#SR@2zB-62F-HaXWBN=#z7?bIBL~^m-(46E7z_KTCNcc*GiYZ$>v|gf
+Go-n<7X*dsU@-_~1Bl;4zB31!Ajs}I8ak{*tGH?cz+i
+!FSKwBw+v3h$bfiiOWr_u_ciFuR&0`yyW+agZXyX3N-`>!gR-0@AoKbXd^3It<&Tx-zS?9^$Q-Ac=%4
+aU2(GI!?I!~LwJBEKA$0U|51{3n%aUfHE#PUH&Y+tdj$G?XvCRje3!scBO7GV9)b=j00D+g4eY>PxN?E1q`m2EZ>!kP$mfYQ
+Us0+BU;;ga%~I9f0ORv_w5OMyg!IM!)JfkOr(Hhkyy4=-Iw(>E%R1aH1UQ5^WGRg^ys-GEhW?%?iTwj
+?h&OP_T%S^ePpB0BfmVn6cA9DtN1ADStR?boyvwcWT7+VB6iI+(08_;Fg*O`o$yD%2If*?Z>;^1)53y
+RtNyc~XG@gy;J^{;_;06}AfjjYy?4&!OnepheYfGmy#)Kq8jI6`cD(&ngr|
+Y23W%&XmV&xOmSGw(6XBPzxO+HAvqMCWVd>-#o;+;&i$scl{=QEWv9EArzsP=Mz)$|buD$Kg{;+L;m(
+Rob1inz~mt?Efh6*rmQ`bTw8i;@__m04b8N^!*oF6
+@zo(1!+e{mbv`!&+sOI9^r=TwBTjdRXXMMooHpf-A#w`1PfzIKoBqT_3u|$1Dd|sopgyygR<8*gI<)J+1_Z
+FZ!{41+r{o;Huq?N^9!+W@NX94Y7UMos5g=k5jMf&0V{707bStX`FFul&AB0H$$FMy$}0k2DZ`iP;e*
+XArr(>mKBVF79Z&tfSlQXyq=64|_rZuw!ulx|n-DlSY>9dowv-i)w*_?f|Ieqpki*%cbiYWYQ8QyP^I+#!l37)C0Aq(?@T_2
+dND8yYNQ%shOyaNJQ`myJ8EccN#ae(8+kDb3o%U_pkw*2$y>E`rwxgz74TuyOaJ}39NAQM`Z7-hmQg-
++85`JjX&XYes{p+TQ-WbNSTLtwG>gAFT0t}g+%rn+zivwnJ*12hgp!2t-Ow&;PLu=nkORA0%gFskPFL
+TS>b!R!G=Iby9p{mebC;Kd)2IHCZ-1d^u(T@e8jSWw)YlbZ|RpsRpN_=F6#Il5#oz=>>l9nJB$W+pBm
+YO?HfdGgogii%wibWCp|Erg^7ASTsCJzB^Yzt4Gt{POnVylo|<{Uxh9?!N!X59ZtI_^KyJMolGhp{s-
+O;g*s*tYQh0`>q)da?bXg6|#P+**>c^OgTe~(=AMPn#@Q>HtJ`@F2xBFb5@BWKEiCM2{EVNH7yj`>O=
+3hFo;oWn0n~r0tY!erphMn7d;ZJ>;1fITa&^mKO6^{r^^02aevAp^T^*aQzE}YT<=n2HUlkQ4L+uN9~(oMbozB}
+7dqS%_NufoW-R3#&LlZ#+Xj-T*DeOHCG_4hmEBP>|BS!&X=qORv7N2Afm4e
+*2v-5KW9C8#Yo%onwF_;M6`o4{G)q2pLhaDqVC1YSQtJ`5t8p~}^+qT4mf7BAEjG)gIWQpx+aqi2rEF
+N5M$5-r!%)@+tz-I@A&)Qr!ooJwmMI#wC)TY=<5{PaEQi2PAaf5I+q(ik>dT30crHqjJ8RmJU{7VP
+W*)D7Na;B^PuJ})d`3HJz#!FaLhnLv0yZCN
+c1lShx=jNtGNqdL>v(c6sI$D$2Y-`W=_F>dq(irWUOOxEpV7?hA3OE0S+`*^Qd{@9t%H^l8nBbz-13N
+Ku)=*?IfWxy}G@*1twm3`YE5x|vu8d!1l!w4TXqqJ3+MiiZyE<
+d%aisSYbO0oAoup<4)rKs}iB?fKlHg~qT>RBLY~G+t$v+XN9%cN+({9b$IQNBvS
+AGCo)->XM}yRnpRmzjhnI?`-th$MU*sWlWXnLK;_
+(qEI(QFRMZ*tW`Y64Spld>I*kUd7?54a9Ov2(b`$i_jjxZ}cxOuhyfOyIR{Q3@U#Rk0GIyP!r2NrI~2V?8+;524_
+aWGjxwRkA`OFD+yce(1tTE_;q&^E#F9o7$l2Qc6G4~w<%+*_ugq_rJ?Wm_Hf=?EoL+Zse+$3W~i(d$2
+|XJ8TLeM+261~s^xle!5sE$@{S{9iye55W0?~9w0eT^*`jL0YUa`p^h*v^y-5-MyS;brj^1Oa5X}M11
+|%h^x3$?@jnk-ERP<#xuu;w$gRs9%lszO-6wClLzRm_Cvp*yg#NhQPp@HRjrj8^J
+LNo$QLb)ik=9+rNWX{7D6CJu_MLIvuSJ7{(4EJ<$zVWFnyb_sw6sSvIIspv_ZPdD)d8)eyOwj3)&1|wEp?x{HOH!_s@SgPhY
+(HiKcE)lBL~Px=kXHlkNnrr1DlY+H7OO9&B*b-`euhSiHiG^QS&}dp2_j=Ahp~_!Eamtfs@6Z&POBy|4%|a`pP^&AJ>ZP
+iv7?;bilLws+lkk3Kwx$z&ad`9!y)?t3$m#w65z*4AhWqoqllA>ta=)oqr1TGHgb+%RfEA6AS10Z>Z=
+1QY-O00;nWrv6&t>%1Yo3jhGwD*yl<0001RX>c!JX>N37a&BR4FL!8VWo%z!b!lv5WpXZXdEFXoZ`{W
+5`~8YFjUYUPVr?UK+5;{?Ys-OwBn~7e{a^^hirl+n#3N}gDOqvz-#at=
KSWmZqe#X>*P@fK%U$^N5=%pU4WPwuYBXW`AD^VM20CkAr8x&78^C{}TwmhI
++*0u|7cDUyY(!wM%StHff34-v;=E8ZbGVM8=LPS%!GzHAR`%jZAPL)xJ|i_}MF}=gUJm_O2o6v7qC2o(PfZhDXpgnFcWahqU;iO;PQ~|%6YC8s1*Is_DxgCj5suCj{Hhw-e
+g`0UT3`9%R6ulmu=VNLV8YERhb)J1_cXkk!Az<4&GmZsB#EWvw~J&L+2bGV@uXI8H~*VT<3Nr{JUDAm
+_^67CQ7B?8b8uloKd{Uvh`|}iTxgM?3%0-u(lIv-W*$yXxv5ddw%lAlas$?>BTqe6ZiJ$n-ds_5?uY}
+ho9e`oxc40`6(d#2MYL1$f4>17l^hJ?<;V__fzw@NZ8WM#cH)G#ExZoQ}-MU&C(dl4FjiMvy<=HC1`E
+4V(>uJekDK%$Fdd};`;-z4ZmKZ9~mHi=OFq9gxyJzveR^*LJnz8o~2
+LCX7slM%js&OBKBnn_QoOq#B#khY{)}1fM=2Q#Txj-X2xZb)s(X{wU1#77?{E_wFA2pQ==W_RHxn?
+8S>G%!ahiOT^*cw3EsWGHY+a*?tGdaUsvw$h&S3TJpZ&GbjX1&;r9c9(nzl%Fy`7>IC3W&y*P-ikkh4
+{oK@IGqSBv=US^Xiid=cT9*(+1Vpx)%)Z664Ji>ci`Z29A*vEo-EbrmKL~$@m6GvmUREeiSXLJSqiez
+i@s<_F*9v1|tQMk>=rzUy(-A*`Q7|aUj1@Zj@lB-`P?>1L9%LY%aq7(|ts@)e1uYxmqQA{=FV7ilkzX
+}KpR!jCQAN9Yi-x+Qcn|Tn0cRmLIhIVvLD7ZA1hbfmQVSP2o3+$nK5!v33v_0eP^Ce=25}VLFm{^}vS
+bNF9z|@KQ}62#zlX?9fq=YW6pQ5r;@5R%B;t@m!bt*B*G`V1?>#7PAYK}~|sn>T
+{)Wz5OO^UQ_B67>fWOJ+?2RM;VCy=P03Rpj8dj0$l^UNY+Xx1Y`h{^`!|HwFpQ7g6CDBTf~Mo$tv2_<
+)C7neQZho2OL@MG?xwv>dH&el6bIIuL9mpiJYrMJ-+V6noM{%$B%DqhfXaRX~
+wYI~@y#lx_O7R>xkTw1LbiBT;ClyY~QL9C6JRH}DF?5@mk(b+avR6GH3lLm}Pp-E6|6e)skU?rJGtlP
+sB4VRX?{nl$I^APC`pXW&sK^zcX^jbJ&f?(NCq6SUUWq@to-M0d44gck6J3djV~Fbv#dojs$d65FA8!
+SmhoN?IJxtp=858&13&Fx{o_RyJx!lQYYK&!P)bQPWu1$WHQKY2i78Ds4wP?LVz3XxB9-?
+-Qnmc;w@;Ww4^9*s5-Ji%iacAAi82au{X^{Bap{IfjeM}9i;JKkZqDC}Ib*!^dtsE@BP_z50xdh
+)`nzr46Mrg8SSPWA;2~C5pR87aGLcD6&cG??Rvva9ZEl)5CBR*8AnFE)EY!+rr-u2~<=NPRbLtKPlw)
+n&p#UjERO|9J>NyJWmBa41}e%f_F4}=HL(^QL|-tevl#~Pc0;vW6&D@muA_=jlUz`tHd@EI77nshWC6
+=5_{ODS!lxJ>n=Ekuj05Uo^wrK?z6trDYwK7aaGo$|{5K$|No5%slW*@bx!wzEuZ&B{XH+BoDP@r*JV
+TV4{FTEZrkw)JXnXY^ie({|lRq4)$PgI^vvxoP#r4?EFZ2l}6}0aAPK>AXa-6LO_@r)#HQ=2iZoHobS
+pz$7wE(8wva8X-S$tL2#a855VH1`P
+|~{5$P9ozL!s3}jn{Fa$lUl6|Ksqn4Km+k`N)a4t6Y;-gg952-zF;
+7@*&qvX1>4z@Z)kPja}U1CQqt28nr3sO`nkA|a$Jy0=Y_&9%QSaYRl*bZn#(s`OSd@EUI9}fuc|!i9b9*jr%~}^RVVOK;i>e
+hXOo5vpoTi$e35;H5~;>#ut}pyc&!e?W4m*?_uaH&
+m(bb>=jAsbtZ2m{`u~XhwDGI8ZFq{QAai2EqJU**UcoVQj1gS#j_t@%pD=x+l!4La+o%Oaf@zL7gq`vml_+P9{3mL$XX&BEPufR;on;)oCc9O8
+A-lS7E4w95z0Len@bNvRy>i*uPgG`ab5()9bRLeA|HDHBvN7+nIXE#;pZUxk7a$HiDww%S>kqtVa>#Q
+b3Vpmz$hcR>_YXj!!iR;Q+=OW-c5)4M@%|!#AJc^^j_hrnOegkj;e&XlI!OJw5m2N)`w=pTY+x3V%CP
+qI8sXptriVvK4V}Qipm{a`3j{1SXp?rqze5Oab-
+Y%d2FLkFP0+FJc5-m$KZs^BXm^=TDeJ$dO1rP-o6e8!Rn2v0i}$ncdR!H_GOXwPH(Syn=hJNZn4O8d>
+69H7%}rf3ykOAY?LkBO)_Hty>LEso3=G}eEtiaLzX`y8;2p1f@8(PN@F(Yx3{_8gBdo_RAf0hpb#V)b
+1ai?e!~UQy={nK!PINE>#wh5(bpdN7`mYfKNC0m(2AR$-^sllyl4fR^RFaIH`Amg2Et4LzUt2sK)={a
+xBDaAGVuk>FBfh0?++y!jqSz^ZJy(kXV1pMixWE+V_M+AfP5^@|Mf+XJf&k9RL6TaA|NaU
+ukZ1WpZv|Y%gPMX)j-2X>MtBUtcb8c_oZ74g(rfczF|*SohJr-pVW
+e08mQ<1QY-O00;nWrv6&TRWc!JX>N37a&BR4FJo+JFJX0bZ)0z5aBO9CX>V
+>WaCx0s-H+Qg5`Xt!!BZbrLcMB}M_*kaNEdg(<(fs3;vNh`=E}6qMkaM6wYvuHkG~myNTeihlKLTQlb
+r9F-wc^7%ihZ)uZcNPLt6R7lqR$@vX<8)oceUrWSq3J4b&kQA8M>#21_noFsy#c
+VX*$i@F9
+~Q$Zt~Im_W%0KadW+a-tg7wd_=lNkH|fir61`!%E8m2(tTk_tl#}K7Xj*e)#wO`@7F~8G#Id9D*>H-Y
+TaweiVar5m&{J5;1ZLd8CSCDriqk6|;g`#WXt2>w`;!;1XP%XQ)~ApAR2@`;-*~Ty^!#TRfB%v+CAQQ
+bFw35#=W4ye)OxaFZ9CSF7zn0R#}Cd_v?zZ3}x6B00#cJEhRzwHLW@d~V4r$Cf?UtcB7Ld-i;%lvH!1
+SfRM$^L#y%;<=MOBnTSUaBcD=vE3=3J8)vNUHAwLlp{u!pl7xs+9>iro3ucNf9x^LRcjDrh%<1|AL19
+bBDIG788N^bNs=QEVC$EOlEH?MDPu-l+r$`(%rj_N&1pkws==n46KmR=m@5hRk_YTosF9A&sm7l`j!I?_pV6kLr_rvi05xzuohKt*+Tu$f6oZ*Vb5{FeR)iqo24r<=HJ1Ns1iaKZ
+0x$#WNI|Ez`ALezdVoyfUXw>Jg|E(C-R5=0$S0sWG{|84b3x(Sg|HRDb-S2g{lYDL6RS8ag?H{>5|X0
+Z^pGHZ-VS;g;eLosEBPgH%b;T-Kje$BGFzaKVhwI08*QrNV&BkOipqND+`{>UjM8fU?br0dP9-^!d}9
+vLpxi3wU3DAtt1B)L2r#9>|sOjeYjQG5oX&0TK^S&5mp1rG&PP0Ro&S9KJWhu|Pxi_Kb?JB(D{nT875
+A)SS?G>}a8RvI(LaFk4%iHP)UezinXO{4W$dcvlDDwjUx~HB|;
+hb+q$3b%1?jX-+#RO^r`yi-TV8gO`(PSI9G@G$>Br=wY0SC1yx5~1T}Hp&J?gyHBuZa_y)Y(0)#~wyE
+0+CT|}S?lMB=?k)0hI9=Yr^;Hib$1804QAa-`6kCfZ!POu$moqRCm+4ucyofVs(w^rfjxF*Sp3>|!^$
+f%>{&r9;?O!Z%#rFI{@{T#}?7rdP@%AbdkJ&XZdw4Iq1_@x(!-$vrT#Kq!y6|qD>=6KwM(VLMYe`LXC
+TU>+tuC!qM3|uP9#i2gteKp!jE8EFgPS?_Io0xP(rNX40T)NVIYa<_viB4V`L{er~
+5?siN34&?9^Cs6o-M#8TcT}Ir}60*<%4tfz#MjTS`h!X(mmgMIm!79saJ4Oh1BMj85G0O^q%o?)x2cQ
+H-O^3EE1K-`OZ-%C#$UpEs
+RKZjn0T&~UuwfKt;-(P=yV>&Nz-00{oNu&6lWd?E1-a2H8_o`%OVCJn|M5~Kx--|J*%*HRI+Pz0+qUS
+X_Qavcu4X?LychqW->3%z7Ld$<`wrZ2^fBz^z1z$G%t56f4#
+N3Z(xAgdf%+@Nx)7yuo<4XHgCjbyRh#(!^OFO?fC~p6GnFzMtN|M9fddF?Xc6hMgx7ueZf<%;UF{bkQ
+JmOJ@?9Kgc!KPBKPQ;1y|4T;sT6@cMjjNVkY0i!#;bYJe(Uo1L-6av0>CFR=dmm%Du*{CdON*e47WnJ
+Hs1*8_a$oufwy@=*i{v_Bt#$ZPJGg4-qLa#DK8=HbAJQFkE!QUDPtS`DzqGSP`bqpTef?p9_VxDe+H^-knz=E~<=4_Z&>)&BcI+B(ogEr2xAEX;6AIs|F{e<9({s1`JCS%hf8v_CW@?#6qz;
+6xk(X(C2kWu0Y$8A{J+S_@b)Os_J7=!Kk^&p*$`@OamgpORMgfjBn74=2E9jA*?E64!S*Xjfep%Y{{*
+_Pa5{3FK3efCu*8Q+?k51ADjeql=Uorpg>?UjXHUgI?iksjmTH_Ss9W}fxyX8EVxy1F4)rWu0zZ}mPA
+*4Fv%U&M#f@*ypIZxUDq_GhX;SK~IL-D7ZrQ*krsK0?k+y);cDi;=@aj=G2ErP6+Oru>=YCP4<;k{
+4JO)7n|kRm6h1n?LLPHYNm#_$P{g;iK7MI(*$LFWa`_kC;pRmT!%caPQ(UGgpV3XuU)~`9)7zetGb?5
+~^5Ar6@zPyzywO~vAdB%SC|)>g_JiZ1;V%j$*BI$*#A$2-CqQk0yO*G5&!PLg3uz34S0pN9~
+b8<_LV#1_PmRIBrSq~pe5R7Ba1pxTCbbtfA4Qdy)AEY1$wA($jan!W;pK|T@VBp!b*0jw_B0MEJ}p6Q
+%0{wCL)*ktBYKv(gr~=8m)}pGR{{;UKq(aQ`=0N9Pp{mMXq#eN278r(d)HJ*S()%q|=qES9O+W!dO}V
+w$+>6*RQz|kz9)CvnOAjsffQ9Ng=J@%l8?GR6Ca6yZnUI(lhVZt)
+n;0o9+ZcR10SyB}Fir&=yyWsFukYZOFa7GT>SdlD!{DT1nuBzwTSxTH?M&HI7DG
+$v`bw@uk>vJgnPl&@*@_%!j-j>CY+=DyENz8+JYsmR@9%H#r|$Lc{`&p=_UVCNJk4*e_|^QLw|1Wvzb
+tM)EZR%&=l73K=Qr1j_k3~t$lu*QEv}}c$q*CrC6ZotUGf(qE~Vp9mOrkI(Z;V_DX~tq%~f=#vex=M#
+id{ygtWE1koiuV&yZXyqu$<~k@6ut3`?ICD
+R0l7N{w&jnJccjc_$^PLS(z6ZuYBz&zOAxRg={Tzo*BKKvXoSxsDDYv)A7@|LdIiNo7N+KLj;hKHv7k
+YChz51+1%{OFUrko>HD#+6TK7(r)8{is8X!SXsZSDO^naV$GJ*ve!DeSyA12E7*`Y4+tWWwi^|T|5(Z`XgKDRC-?Mu|O5Q
+9uslQHvr&17|)!Xx*$YtM*nn|5&R;f{3*vh=dq4B??P3Im{l*$c3;wcvp(y44*R!L9%M>?n$sth-C1T
+gx=3QW6)Up~%Qee4%9g*`8tFT$k7Cg+@KVi?bglI;5}C?fI3THe4cef~G}0J}&f;v9hC>he6X6}VY+>
+4^Hfdkr91Q0hiBN_DTk%_e7L@h
+Q%5{?hk1N^jS&2#^(442ML|9U!fR;1tZx`4j}P|nOeCGg;4zB1$vT{?62Y8U+z1QD
+HskW-lm)5g4rS1iJi`94%uw2zHGU=Vu#a%EPfmzPBaIA=P^c{zI-hT|L~g-|^d&_rECWB^8Ie&i^>h;
+XEEAp<+od#Tgz?Qkm;ABHH54*}?*P|;a%Ors1iskcTtT8oEhPCDQ#g@l<5;BbeJnqY~sW%|%(Ah=%CF?R|BMK+%D3LzLE|tRtUBa_rAcNw$Ne{e
+HdU>(7B=lFA)yb{X;&L?{Vh9K5l?lwjx|2x9gnAejUR_PF=g$Jces@qkuNC?9O#UxKGI;-XgY#Wn*5>zzvU#jq
+>GFEQF7}lEm@Z7ok()d0z`VrU9yGQ>QTfic(o(HR)lCnF%~9AJwv>@55Vox&icA5FDPbg4+pu#eGw^#
+{n^-?%@MTFwQ+aX+u`V6|5CC;>GqiA_r_vQ7!e2Y+vmctC;p+P0>8Ha0+XO<#0*AQ92PD2*%D>3{QVS
+E$sRuGemVd((Zjr2<#mffTMYP&T(d?d{t8o_^nZ~2b}HX^I)tVg9gQci7YN+Z>
+{bSYRY7IXc2mGRc4@n~<-dpV6ZS!}ZDEmx(RM1=WcW5^DIrDa3d6D?Vs+!Mv1)cyjp3z*tD4q%Yu>pQ
+h-$+n!bTJ!RH$Vl$+jI)SO*hJ)%vd80^ug`=xa%3QaBUKGe9C6;2rWXG)xsd}&_eBDXM
+!o0ep{WEbVRAAltLQ_oE7nA4`+YoD;VoVzGQLn@ZVvrulx={r9OO+(Du*V;6*>s_hK`tZz!F3|27n^L
+k8z_2}g4j*tu)Q(GS;S_b-p>m2lq$5O}m`U{AJE*tMUUP6(SC@WGveSCyw?fJM_Y|TBxc}OzV-LFu8a
+_ahmM;auqf1N`jrJCs%~&9cTnAlx!>}ySX+ZRP?EE-me|8c9z-kqYT9KrlOpnp$L^{!CR^hG|8e{9^q
+gi)0*sYyv+OsYzy-~<9l3g%LAw-6|WLCjqVQ}~%lAYLFE>kkvn6IRuZwcPA@=60?wOqkX*q_|~v5x=z
+=EM0v9{Ba*{Nm>Niod%)e|);Xei-QRA^syQ4CbPpFzJE#=m{R-U@M&@dPgNneGli9jsPnOoUu1HWW52
+FLjgkz|EVdCYJ)a&e1x${Z;I7%x2E}aenxJ8dYbL?4PqmFv!D(Z%}@F^XEu|OS}C}nSRo4ZLZK~|)9#mAv5Q!Ex+?OEX|t+
+pr|`di^qNG_?@R8J$av2#i^R`Y>*%L>i(rYH_2_+(LDCPmjST3cG&^22nv)Hn&_vhm~j_-}T?;504N0{PJ;ryYNUW-yOs@JSS
+<-{M}v2o?HYT`Y6zAb>;|M%$LU!ql$weM%
+w#r)fNx>CABoittk05ior@lx?#wuTARJb5PVa&HGMXaZ6uZt>29Kq&VdBDE4?DS{0O2j%GNT2i^Xz38
+_(ENssru&Vh(=N5Az3mcf%6engx;gSwRH~=5X7V4a$n{6l`u|J#IM8iQ9VhLsvps7ELQ(=H0{5w>B*c
+8sv92Yp2mGbTm#5|KAja~hU%rn115ir?1QY-O00;nWrv6%SwSB@EA^-p&eEc!JX>N37a&
+BR4FJo+JFJo5L0P8a+N*PwMRG9HGt<-4ujy$-QFKr*(n`-%xoWbqs8w3b)OA&^SM|Zc#X_r9l@-%$
+m20)ol~!4;no`a5JS((qQx;P_RFie1{#=yJpH)`WO^d{@`H&{djSc+Filg)tY6t_t
+jbNYkqqN%;$3?E%W0Pn^i;gbDFPp&CpEG2HH%|0FCl&vqIF$lcNIG^B7h
+ro#gs}YXB|8$t{pgS;eb#dXwU82?KWQKu%?mP18L4hfa!g88kJd5>N9irdd^{-EmpWvul6gUG4O{sTO
+*%?UqZRx|6CZJ6q)CGz}WOJwG{1j*pICoFqTIJR1U0H%VPhlT`(~?p+s%WjWJ%9nZ^ZnKlW~Z&Q`I0r
+w-79&_P6t+QgL-xH&+J-;}6^ZI%6;^_Rv$@%U^%
+dDQ7Ca^P_ast$NaDdbd3v_76I)?sVQCUBsrA?B-!NCD=D^;agtrOl(iN>|)^YV^cw8&M)mTZ^xHSNZM
+8hxi;mxUf5DENna9zV|zE^I()M~nz*l)6xB`pzB2-0L7dpoT})bt8
+47WoPHkDDnD#tRhyU-Rfa!}`D#$Lc*i_~2JD`!JpZ+v`Uvihl=w)rW_1y~?wuKNt)S+~8$)@YrB{nci
+50lR)Xy-?5qjW4i>54si!x4eji=HyGjE;~Op1Yv6)~Lk3k*<>YrgZKwkEr_^YqfX%O$zu=$?4(VNOwyU8U5CT#`MiE^Z+6bY?>1-xQ?
+aPNOL&ezeE6YMm7kYY=%s`|im1eo(n%86DzC)?UTYIc{$QxyM`OVFIQqaeQqy#28ucm-TAi*FSr7owKWLAlB~Siz7<7r722pOud|i~Fl=#s7(LPZxky
+nAV7M&63ro1ciGM&}JCvkMjTZ~#Qft08d(59eoEs*MRT}?IBuQE_Ot>TI$qk5&M**u%7yM+c}1xj3vE
+OA2w2*3o$^%}?t#EfMND2PEv#J)fq%0Q`LS^-k(Bok64cVg7-UlKim8qg0GJpvjo+`Lg>n5~0dkSGM%
+4}VEDZ2(@;7@S2RsL_b&a%AWdc7cJ0-+|fxyBCUo`Nnh
+yLXO=qZXQ6f042R_Gvi~-ID&_LH+0RmuxRi9dTeWB*f+VvmQ=a$y)x}4@{#hqp=2HNM30XM(+_4MT2%
+H)`T{zdg8oIwO+?~gSa*ft5M-^YJDISZSb(s){~HvR3|mm#<-XuTU5ovmR|(yFywFwrOOAJ~b8Zk`0?
+2Me9AlCiamF5@^h<`x1L%Z&+ER)Hnr2+$$lMhHZHfsxxBMqYuK=YA1>J&ev5U>M`q7>Wl-3X}D9f)>M
+G!x>TgU=$8wYb>B}faqzewYK%=={j%NZ1x7}Hfr<=cKo_n00rkD0zA8IRh6ss8hsb#x~a1n`7l781tq
+OsPe8E(9<6fwWMXwQ1I<{N)`0Mm)g|x(J$nRd42S?b0o*?TL1(}#(YKP3j%}bTgg
+#Ux?a9z7ZnLT^$k%b3RvAu(g)uS)A4^lx@7EOxoB$)0{`)$E@d2b}o#p^?KePy(k%JR$KXavwHru&-@
+EEKek$(vB2CGks63QIJp^{%3~9_A0=xhXWph*^)yaQdoSeNrdiCnp(N9OOkDj0WpiafB;HituP*}CuZ-(4
+9m{L&0gES;hi-5acq%@Gd9hKF_gv|kBCS|?fM)v4azyu=<%M602<+^BE1o@>ui|5%b@GDpaWMHdx6I*
+WqY|Pm#Yv4J1LeaGf3K!Z%YE3KiOf}h3*D|-F1noaZ|G+*t2W^j92XitY_uXpI`FaKT2Z>K$&~wd`w5
+w5PdlJdd!D`f5i(bOAkB~bFLuo;%#?38b1b&02>mDQgyBi?^3C8D_Jd{>E#zj;^D(D_m<8d)2Al09i6
+)e-LEM_DROZCgq+3T0DpAXf^+1Z;j26cS)^5W(3(JLc2eK@(k1@mnXa6XXD*SPq}B;AuJ4TKRu2-$%L
+S{^i;l?EIx%4}*@mw(iUmr=TIN>m_`BH-_QjvrNe7hQ$umWrJ?KPUFGK}(g)t%eRr8fscMzr3Yf&s^b>%&^GdajOJ
+Oc{Sr{_y-K_&?)OqRDd-o6qPTnO;}!7UJ57;$mndl<=_5g
+d=}(eF0b^9w(n>-~_`I7D2_<%*mHwqu#kjozR_mOzbL5Y&5a4}mf=P=Z;syV$uWW<1&%erLBmego|pT
+0+hGQ})P7K@-QVF|Ft@2*wWUSl$5dL3}--IG8vded>3+FPkoaz@}gfWHqVn
+Xtm4cFmoBo0mM+bd0YkX+{XDA`8H_ru6LWgnbx~an_Jlu-UeVXw}>bTC6dfGRn*oVJfcsc1`~6s$6tJ
+LiC7!svFP3GKdz`^3?;W%2y+)w1NEoMbiJ>VPK{eSxL?Ne$+iU7*Zy>VO5bJ6^-`q`=&Y-Tj->$>j3%
+o=fmVgSQ!g$qPSIypXxeXa=L*S;@PHtBsO7%xQKR68%
+1@w7Yl^>N@K^u1$OI!W!$x%21p63F*fEf?lwN21_(Jv=OAk4<@5%>G7!76c7_VN1Kk7l2bwB*ra%d+n
+>u0k+xL!_0$-E(k?hWXVl<(<1%(};ftG{CG}|yh#{xzp?qS3|M9y>1Qx5v~o^~i2A!DtHlj5H{{4>En
+so|nNvM#+cBV}0nd2sELYz;(&ji8Vp^R&~BiNi+m_+P&sfZa4*3{^iH&|v_4yBlOH{G1HHs>GB3ema;
+4Cn45|4m@2wlmxfc3TzyZl~=H(A9N`ZZFys~htrdvMjAHN47A+iBZXkmUyFXB$r_zTu|N5jb0ty{1cf
+n{>D<9AO;G(@Yt=hHf2EE+us`dHzry@_$e+;^ot4wIb<_JGQ2vvVmlb?a)Ap)N^9cAy$
+jcdBJtetBiOe%M~pCNAKzdqDD-lJ7Jxbc014HyX+$+5)|
+2UZ?jVDyrh7952{+H$2=Wu1Xa%4cACmKNfVIz2g6Uw`?`$`fM1G&H}_(do+p9dGba8fZ=x2wr@Wry1G
+l27u@ub-L8yf(Vyxsx+~vdN7|R_6tq7R>R@H1-kBHif$?HWujdimzU=RJHhp-ae>60rl?b08|@N3f@D
+?0!Ucz2J~hSaoL9+)oe9#aHF;U({
+91YP9N4B}g2Vhtjr{%YOWhV6V@j1#+4Bz3^mh*aYW`N3o)|(@1TzVk28V@`cW!-!VmymWNY`3-GKF|l
+Pxn18EZ_Y1XAN_RlASUrsTHSCMDRzYT#?P^YS7sGOE$~o=O5bWkWx2jysI2j^H!A_ph@?e&tML#O8Er
+);&S?Fdw^8Or5PSm0rr~yRK(?^w!GH9A@f<`BZ^xzs<6Sk85J+j)86wJ$l0X;%xsmLXuq)q@xrAX*n6
+veN`)HFxB2>ga1#eY|HxJ4d;`|;y59TEPMsX;u#g^akAB8Rrf(<3jC!k7)s4@_?CAd6zJd%*lebK>_T
+hTcNZK-8WKzn8s~cl5qXDmgWw!#=u-%jxAqw%Hg)#`yoA9YMMg@08
+R3sQY(T=Kcah6*VmM*f7s)I&gB0SPbjh`mg0;|9*)21=ev+=D25ArZ#TqEff2MYt-+w_38PMPM2ulDZ
+|NS3ZLmNtRPf&tX7_HM(_)iJaahF|vwtpgUA+yvAZ;|i6Zhu#u}Zd@Y@ZKNWq#g?3e7*KRJrP+wR3ui
+?JC&{|FMc-!hEuLKy0e$xcqAgav?S?3s`5uNjTCKV9bX`?walzE3dCzHA2~Ix@1Y~OP0!KpBc4wQ`o|_5LX9PwP_!8P!1(y`gz7a_X>)&TwVV_$*zS4##N=T-O`-s&s3wqSM^K47l^U19faXp=a)q#)Dq9s+HZXg&sp@odq(G(-
+BY9*1S5}Ay(ot*2*VGA8wwuUt{A)wR-o#7j{hcS8-rh+%U9HfZcKV=T8;DF*)%Yr=SOW*FqkUY6`hS#
+-Hj1%X(Wua}e#10I!81@3)YqR&bWPoI^U_j{&bN6z<{CCrvcuU=|;`dEXOLshuOi}c$!vLvzAqo0k
+v$aSN;zFob>EP_c2^if&sQ9GQl|&z<
+AiE2KkRwrDb2%EPXlwypC8ulGzhjG(rwrO8+1S+_3+IB?cnjpi$!nOx{u7g;l
+EG?V&jU9C#IXv<0lY&|f%&D}q9|E1pw-v&zvmYe@(c##%X{%VMi)&Gz#aTVU>I%cRGvOzIZNS1Vt>%DL@`UPRfiH78)Tc)9}(?c)>{SATW9?P`)h8`&}lQ)y}({vP*{#l*kz>495Zxp4}%}CWOjm2
+;5RNY%Q+OH}#@e0^4CQU^Dco&Om*qo{hHvqHKsjvMtdm)hsHl?TRpH?-p0&E?{_I4-eJT)=Ud^r>UVh
+1gCg@0V@XU$_x5U9qkkIS$}ls(qzcebRNKp^5UW+vJ%a(p&@=<3G~6q5Y#F=ubLq#Q(gtt*2+(=en^S=2zaZ*98Ipxqm~eL)?v(}PYmRr_
+j@Pm-q9jy;B^!hL+hQm1Q?@fL6c!++-TYF4nLvwKKm^`1WeRDFQ27UT!VRCitmHulP4DaeF&hkU11NX
+*$bts8*2=e*e;m~iFfF%hY)`GvAD0KX*=yPiJOY0-7JaaWL0OPca^eNU!F(SOsA3wKWG-)J+~lT)#|J
+*gml?2LVr^@v;Y3L2@L0dL!|6QvI2v6CV*U1gYa!TLN!7Bj(la+1bpT8X361%I=LnPXjDase!%KriVlY3pUle2R}-$3lPZmPHU*v;XUI077*
+eZvCaqdl4lhN13xC|gY*w4(uTOjJbIOmcgO7X!+N9SG>mI0&tKnO^H{!=ryFFmLHE{KulhObZHszX4t
+U_)bn-0QL{MOA}{{}B#qisn&GSJK+SeKa=k_##nm_{H-;vv$o?oXGLV4yZ?0;Mr%DG8>IpN+tpCC07L
+St%6$_tXrLa9>Sa6MXJvod{;uPIN$X@pF~%oWhhQhd~WT%yyQdA#~pH2eS250atZL$^e{!trR8&2+?3
+Gb>#;LmC0Z_f*U-+(A7@I(QZEwH{9h33cz+ag0{j!e750*hIH@_>i?ABx7~phWVh{4p_6hp&$h0ihjl
+r;K;Ky-S+L)l>z9VMPWR80dL}AS6fXd6g62CDA(X6n3430k*Ho18mus0~Cl9@4RR
+>r_@!GYc}6dndF_Y4Hml&}6{#1L5Dz<8&OdJ!ZTlR?)*$UA6z10_|x0J|CJb|`=vJ0wv#eQ$#Y5uF27
+hKY^vsxnH)2?jX|l&&kdL-w4UzC2A{zj0)Bh}+PnwcxHhO)at8IqrxY6RdG^6A)b}<}4n`h8mG7j)yQ
+7N?>r~KbySCH~jG5mR*f{E);fTUU;zK2A%Z*#K2}Cc>_4iBoSP#8**>8F(mc_Pm_H7cE|&}{A9|q2>(
+*`*+(@;PR9O>qKpQxqU0I1o6II3;_D;=3+xBJ_=Q(~>88sJ0G8LN>QUrhow8<;;|+icYz0`DH88q63(
+BJ<+57g88Q1l9^g9^axaK*@vX<%s$bmEW_2*)FJW;r{?$T;jw@fY0+Xn)a43b?SiKE8?>Se)Km|-#i5
+A*5WrJI`0Y_i7I-llfTDKbOkbkx!(yPuph%eccNcgU8^MI8S~#rxl3eGnUesBP`@-7qqscK4jHg1e@M
+`B(NHJvNS71vXhtXtr%9@Yy@Slkdlw69jW!;e+myN%r|hGKT&0QKN+77ckxqjAK
+FIT2^tm>ofvIWefGpONlW1E(CoCZE;fFHpaMiNgUOaZ@hy-+V8IX$*U$U5yofk`m0i$uS_K{B8Uj3KJ
+|wJ1xaKa1QYsNs2}%N5BM2wiq(%Y3*+`G)}8VgCx={kCAraXMAmOz5mA#K3fQ&oL9
++bx%E<)6(4mS#1aSvoY6kL*qit}Iy|8+ZqTcu{%_QvOWSFKgt{)f0r|U}NZG}D-3}PDfpSDL3iY@kAM
+vQuFNY>~*;|Mj^KgM0TwJb5)@%=t8gXDv{JNuKkYa4n?jlRUGj<5Vt
+5x?rWAV0*w%%fr91QN2>US|oIlyEBwq9&SztwNiU+!F-T90t-27Mm!m1X~50bPrqqRwQUK*qhzI5Q1PE@3d1-?JdXWs;b1yDOyP4fS=(Liv
+Rj9qooWgNP^SUyf!wm71KZhH3C1ek-ySxW3*{8*wuccv`dvz@O(a&|+w+rz34{#e=pmM|2=gd9l3Ex8
+Ad{SzRT~AKTd*o8FqDljbcq?F`+M!tbWXtnl7)_fej>&BwOGK&veOv@o*=C-saZt{*=f)Y*4&QZBi-9
+>y*@3N?Y_-jE$0j{G!&-hSE56hhtAV`$E>cZFk&H~)xS@~GCAL=MP9Wl-y&i;j_40DnUZY(;x#m
+RfUzwrlb1Ibg1@Q!v#gdXe5{0A=8GpHG`~$RQpy?+5rVT-$pw6TSyvOYAbsNW>3Ue~w_58@&+|6Ixp1
+(z7iOooxx#$dEgXeDl&vZ9b2Vj6lSddM`u!V6`rz;A7<9-%fr#{Q2mWfJc-=_tzi2I%7veEnv@wqDSD
+o|6ofVavLWAjJM;-Ay9{jar2CqK(3Z7Njrj*XqE>Wnq76+>3(&ng6A
++Ho$RHR9y<>hgBuxep;{Vt<)65GvL)Jr)iOj|UD&IQEb^c
+GRRJz>4To@#F)#pae3c+e@mDaE$H6!~T-rgLUdtkc^g*S(TalI_4CI&3n5-40U-i#tOV{M-4dznA^Ea
+E#q1F(0ph15=)8id*Q(1o;Zp<7UV(x~3RHzi%=-ChpHsZhM)<+@7d}n)6&`t*3PLLeGswZhW{X*&H^E
+#P;$;<~rTq6sOjY^fnU=4AK%)P#hx8*SxCsJOqG-`=>f6=wcH(fJc>vA=d0gh^m1=ol5sTGf0}&^FzmPwow6inNE#samGf1?X@i^gqxz|MUFK>oCQKoLVI?FB>L7+WM
+YZq8Z!>z?py;_CbVR$6cL1lIB*r%F0
+^W@y538Qp7%zbIY(7R|Pcnl$tuQ+bq9te#geuL7MmKr>DT4Hbnk39^{z*?6C@D%ImM}yo0mAGYWendY
+`6^@Oa;jPddPH)4`OhK>&FpNgn=&)X-Uw!?r<5shDGM#Cl^0w)US2YNiC57(?FPVDCR5)2R42K4cpeo
+pV%bGUk`wtX3^!`H}$s1C(-~J$T2l5}97bgXS7z8Z_t>JeqXbf^%wm_d(@$zb5RB&EU+z?oKKMsH`BN
+LbM0_QEK`xsG$S9F2FA!V;AA-tTIoVnZ)hVW(-9wKm(1nE0T6uvKkZn+dnQfFdL9o?7sWO>N|LHI;_)
+NT7#oH}L`_L&Z4SKIhUv*ZN9tTvpl2835LUC`;R4Wgtm8Fk3Lha*-e>`5HA2$U#tjwHzp$|RSUJ^QGv
+_5pgr-1VK9d$=r1tf>d=B$9PX-;d|_fGkJ@QRjBRBjGw`tj|Dk_6W^;d>Yec~_IIaO3G!X>*@>
+Nh_%^UG%GHT4;D|N~mC`uEPBu+qlDDOM+ZN>|@yz~?_ub&@ZM^p~;rnM0ZckpTM((zX-cQq$)Xu%gE}
+Pu--+SexeXw{Yf53xGCc$}YvAq|!gtinb@NQiV@!CrzoG@M12yCG+&^JMi1y+1-<0uUG9tpT$`a3(_m
+nXDd;t9O`bqNM5rea)_Xr5#Atwn&G)_yhm?inZ?X;Ei<>cp2$e+2%pu1r`AXZ5->p)i@nqU1j{)%hbObsIrx*L&
+I%b{L7+CGb)9$c~48Pxb>&Xd}2QPC+Y0D@(dvO1Aaz&7EAZSK8~Vd5yPMsBxrG*9csj|*cz++WlV{05
+tWlX0URV{Qzm7#ld`wZ-HH6Kw+sB`LAIOpW&k;t%=;+nnB2^Jp|WSnV*ARu`WcsqC}IB+zAktAoT}@(
+7cWT9kW8?HjDRF?S-@oVA+w2#0SDj`(&*@QNr2zw+M@b#Nf>NKQK6#))==>=5LGV-?Bm6+yiXB{W{Av
+G;mJdi9i>w|iTYD+}IHA_A~|@!TtZ#1Fboux@l&aX=3TChuo1p2B`E2Z7p_W+(S;ATx0jD6KiRpZ_YWrB?gI%
+g%x3!l^0Qf8{RIiqy|%~zN?nP|^XZNFe*sWS0|XQR000O8ZKnQO2nrlwJplj!F#`YqBme*aaA|NaUuk
+Z1WpZv|Y%gPMX)j}MZEaz0WM5-%ZggdMbS`jtg;GII!!QuM`xRDCja2f0N=2Xw5C^yraj7cHO*UnPV@
+K<4X?~B>5Qm1+?!l6GXU8)mm*gs$J7XYcbk?VOlS`~>M=YPf0%>+6$_`~24s3AK1MWATN$0%mCNJ9RE
+znFp9FX5w=by8Fsp>`@5jQp3ZBLRlHN0yQLS=H%-=-6lP1^0fCe)S}8SNkuLgYq!FPE+gj79%09-i%7
+$WR58QYo~^N=-eOGLbj8^3_@B=56_14Kz*Ch)af1*hoWLRXQ9$+JmY`Z9I%nBS`cd=#I#{k@a5Q@4Z1
+{%zD*|oyp-`56Xje2#Hz8=aq`9M%ox7h^ZO4+xkNOIOFaV)K-IFir6jDYGX!Rm!phnKp!q)^ZKbmHF+7$1$>>Tx1iT}A#%p>iXe=cW-%v{f1QY-O00;nWrv6&i*KcmD2LJ$7761Su0001RX>
+c!JX>N37a&BR4FJo+JFKKRMWq4m>Z*6U1Ze%WSd97G$Z{s!={jOg@cs`7cwUud$ZLz_6(IS&hgH2}!l
+Wc)vFc27twz-i-ElI_3AOF4Q@}ZaI>0+@$fXL!~@$%dkXR}#yr7Fv1WmvBEdtMbRFS#+ou)|K~JGSRX
+CMzp6&n-JhyJNhT4`$%qoO&{jzU#0E#u{Z9}U|{+tFDuI;)hl$
+L}y$D)mwPo*ReJ6~Os$%4djg|r?q{$49N;X>US(a@Y+h~zxfenO}vfP5lBuV&Is_ho?;!kDbn>iXUXV
+X-mOsShJ=lM?fbyeG%YZHxQKk>2=f!7{uGB&K-|NQYq1n-vldbS*O_(Y*#vx<_;-T5jXRoE<4<#LBr;$B~oFg3A`=i}qb3r1LhH(2;`o*
+ii%oeRYEr7(z+2P$~^|8|y5ob#GNj&GGw(Q1zz1CXk6T=Q%SFq=dX+~DLaEE1@+KHw(mhgGC{jZHWht
+5vV|Hs(q$GYTpqx3#_Fjy{%&l#NCXJDrjmUgE;$M)0rAKu-+yLxw<-QV72SGPCUZ|@h8F)bA@us1C?$
+E;Mj&kOSqeCc5)w3shEc~A^4fYD`L79boV2%#gZ`9wGcOi@;%|KItXjl%#v!*)+{Ib}TZQ1;NsvdBX0
+%ok}Z$Gx~P0DPs*vbhoE#(}=WIX!S8;%Je!be$)OBaOl@`^nS?3%uHS`CDh(UI6DsryP-*x$2TNx`f|
+Kv!ql=PP_~`(Ah+}{-d$_(;yDRW5`rwg9e1{nSEQ}=ztknI
+`F}8^%9|visvwH-HWnVbjH!Pra7$%^OhT1
+>$if3ug!s~x)Od_{yImx4ZX~aD3{)J3~L&=^vkv*rVCx5;(f_`em(V!LnTH3IDF>
+C4%IcHa4ltQZ}54;bt4pLp8W4y2wtIxG+j1vxjsg*ydrtRv~*>+(z{U3<)r%3Vwq%X6+<_uf0A-pRjU
+KOo*QS>-SS5Y74Op`j0<^3l{p)vphg7J-1uIOF+T>HC>JSV20W6pa#E4$MsLxIq}3)a9tM^D1ejudt(
+^&>O_2l#@TsCtBa>j-_iZCJKoY9
+B(kZr-Z{jfij))G{=d~^A
+Y5nXCyr&x=fQl}8G)hmch|wy?)(S!gi47BUJW#Orb{=+HoETnqF818Ot3d^ur7?hIz|JN+)$N_xsJfqn`I5jaYV`JcyT?vOx3(26ptlt;gXlIspE}Wl5H$Q;8@x