parent
8bf638a18b
commit
0f0df1fdc7
@ -0,0 +1,11 @@
|
||||
bin/data/
|
||||
# virtualenv
|
||||
venv/
|
||||
collectedstatic/
|
||||
djangoblog/whoosh_index/
|
||||
uploads/
|
||||
settings_production.py
|
||||
*.md
|
||||
docs/
|
||||
logs/
|
||||
static/
|
||||
@ -0,0 +1,6 @@
|
||||
blog/static/* linguist-vendored
|
||||
*.js linguist-vendored
|
||||
*.css linguist-vendored
|
||||
* text=auto
|
||||
*.sh text eol=lf
|
||||
*.conf text eol=lf
|
||||
@ -0,0 +1,18 @@
|
||||
<!--
|
||||
如果你不认真勾选下面的内容,我可能会直接关闭你的 Issue。
|
||||
提问之前,建议先阅读 https://github.com/ruby-china/How-To-Ask-Questions-The-Smart-Way
|
||||
-->
|
||||
|
||||
**我确定我已经查看了** (标注`[ ]`为`[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 反馈
|
||||
- [ ] 添加新的特性或者功能
|
||||
- [ ] 请求技术支持
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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}}
|
||||
|
||||
|
||||
@ -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 }}
|
||||
@ -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/
|
||||
@ -0,0 +1 @@
|
||||
Subproject commit e02cb6723a0ecb5c2e5eb8da42b2589389ed92b5
|
||||
@ -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"]
|
||||
@ -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.
|
||||
@ -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')
|
||||
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
name = 'accounts'
|
||||
@ -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'),
|
||||
)
|
||||
@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'
|
||||
@ -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)
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
@ -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
|
||||
@ -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'<a href="%s">%s</a>' % (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
|
||||
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BlogConfig(AppConfig):
|
||||
name = 'blog'
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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))
|
||||
@ -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'))
|
||||
@ -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'))
|
||||
@ -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'))
|
||||
@ -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('结束同步')
|
||||
@ -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'<!!LOAD_TIMES!!>', str.encode(str(cast_time)[:5]))
|
||||
except Exception as e:
|
||||
logger.error("Error OnlineMiddleware: %s" % e)
|
||||
|
||||
return response
|
||||
@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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='公共头部'),
|
||||
),
|
||||
]
|
||||
@ -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='评论是否需要审核'),
|
||||
),
|
||||
]
|
||||
@ -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',
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'},
|
||||
),
|
||||
]
|
||||
@ -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()
|
||||
@ -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')
|
||||
@ -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")
|
||||
@ -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/<int:page>/',
|
||||
views.IndexView.as_view(),
|
||||
name='index_page'),
|
||||
path(
|
||||
r'article/<int:year>/<int:month>/<int:day>/<int:article_id>.html',
|
||||
views.ArticleDetailView.as_view(),
|
||||
name='detailbyid'),
|
||||
path(
|
||||
r'category/<slug:category_name>.html',
|
||||
views.CategoryDetailView.as_view(),
|
||||
name='category_detail'),
|
||||
path(
|
||||
r'category/<slug:category_name>/<int:page>.html',
|
||||
views.CategoryDetailView.as_view(),
|
||||
name='category_detail_page'),
|
||||
path(
|
||||
r'author/<author_name>.html',
|
||||
views.AuthorDetailView.as_view(),
|
||||
name='author_detail'),
|
||||
path(
|
||||
r'author/<author_name>/<int:page>.html',
|
||||
views.AuthorDetailView.as_view(),
|
||||
name='author_detail_page'),
|
||||
path(
|
||||
r'tag/<slug:tag_name>.html',
|
||||
views.TagDetailView.as_view(),
|
||||
name='tag_detail'),
|
||||
path(
|
||||
r'tag/<slug:tag_name>/<int:page>.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'),
|
||||
]
|
||||
@ -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"
|
||||
@ -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'<a href="%s">%s</a>' %
|
||||
(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'<a href="%s">%s</a>' % (link, obj.article.title))
|
||||
|
||||
link_to_userinfo.short_description = _('User')
|
||||
link_to_article.short_description = _('Article')
|
||||
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommentsConfig(AppConfig):
|
||||
name = 'comments'
|
||||
@ -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']
|
||||
@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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='是否显示'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
@ -0,0 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "comments"
|
||||
urlpatterns = [
|
||||
path(
|
||||
'article/<int:article_id>/postcomment',
|
||||
views.CommentPostView.as_view(),
|
||||
name='postcomment'),
|
||||
]
|
||||
@ -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 = _("""<p>Thank you very much for your comments on this site</p>
|
||||
You can visit <a href="%(article_url)s" rel="bookmark">%(article_title)s</a>
|
||||
to review your comments,
|
||||
Thank you again!
|
||||
<br />
|
||||
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 <a href="%(article_url)s" rel="bookmark">%(article_title)s</a><br/> has
|
||||
received a reply. <br/> %(comment_body)s
|
||||
<br/>
|
||||
go check it out!
|
||||
<br/>
|
||||
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)
|
||||
@ -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))
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,158 @@
|
||||
# DjangoBlog
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml"><img src="https://github.com/liangliangyy/DjangoBlog/actions/workflows/django.yml/badge.svg" alt="Django CI"></a>
|
||||
<a href="https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml"><img src="https://github.com/liangliangyy/DjangoBlog/actions/workflows/codeql-analysis.yml/badge.svg" alt="CodeQL"></a>
|
||||
<a href="https://codecov.io/gh/liangliangyy/DjangoBlog"><img src="https://codecov.io/gh/liangliangyy/DjangoBlog/branch/master/graph/badge.svg" alt="codecov"></a>
|
||||
<a href="https://github.com/liangliangyy/DjangoBlog/blob/master/LICENSE"><img src="https://img.shields.io/github/license/liangliangyy/djangoblog.svg" alt="license"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>A powerful, elegant, and modern blog system.</b>
|
||||
<br>
|
||||
<b>English</b> • <a href="/README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
<p align="center">
|
||||
<img src="/docs/imgs/alipay.jpg" width="150" alt="Alipay Sponsorship">
|
||||
<img src="/docs/imgs/wechat.jpg" width="150" alt="WeChat Sponsorship">
|
||||
</p>
|
||||
<p align="center">
|
||||
<i>(Left) Alipay / (Right) WeChat</i>
|
||||
</p>
|
||||
|
||||
## 🙏 Acknowledgements
|
||||
|
||||
A special thanks to **JetBrains** for providing a free open-source license for this project.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.jetbrains.com/?from=DjangoBlog">
|
||||
<img src="/docs/imgs/pycharm_logo.png" width="150" alt="JetBrains Logo">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
> 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.
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
@ -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` 和数据库、邮件相关的配置。
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 24 KiB |
@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue