From 73428fdd931d0f594ceee54e0d2fde9c99e8a998 Mon Sep 17 00:00:00 2001
From: yyd
+ 一款功能强大、设计优雅的现代化博客系统
+
+
+ (左) 支付宝 / (右) 微信
+
+
+ 请点击下面链接验证您的邮箱 Thank you very much for your comments on this site
+ English • 简体中文
+
+
+
+
+
+ 如果上面链接无法打开,请将此链接复制至浏览器。
+ {url}
+ """.format(url=url)
+ send_email(
+ emailto=[
+ user.email,
+ ],
+ title='验证您的电子邮箱',
+ content=content)
+
+ url = reverse('accounts:result') + \
+ '?type=register&id=' + str(user.id)
+ return HttpResponseRedirect(url)
+ else:
+ return self.render_to_response({
+ 'form': form
+ })
+
+
+class LogoutView(RedirectView):
+ url = '/login/'
+
+ @method_decorator(never_cache)
+ def dispatch(self, request, *args, **kwargs):
+ return super(LogoutView, self).dispatch(request, *args, **kwargs)
+
+ def get(self, request, *args, **kwargs):
+ logout(request)
+ delete_sidebar_cache()
+ return super(LogoutView, self).get(request, *args, **kwargs)
+
+
+class LoginView(FormView):
+ form_class = LoginForm
+ template_name = 'account/login.html'
+ success_url = '/'
+ redirect_field_name = REDIRECT_FIELD_NAME
+ login_ttl = 2626560 # 一个月的时间
+
+ @method_decorator(sensitive_post_parameters('password'))
+ @method_decorator(csrf_protect)
+ @method_decorator(never_cache)
+ def dispatch(self, request, *args, **kwargs):
+
+ return super(LoginView, self).dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ redirect_to = self.request.GET.get(self.redirect_field_name)
+ if redirect_to is None:
+ redirect_to = '/'
+ kwargs['redirect_to'] = redirect_to
+
+ return super(LoginView, self).get_context_data(**kwargs)
+
+ def form_valid(self, form):
+ # 不要重新实例化 AuthenticationForm
+ delete_sidebar_cache()
+ logger.info(self.redirect_field_name)
+
+ auth.login(self.request, form.get_user())
+ if self.request.POST.get("remember"):
+ self.request.session.set_expiry(self.login_ttl)
+ return super(LoginView, self).form_valid(form)
+
+
+ def get_success_url(self):
+
+ redirect_to = self.request.POST.get(self.redirect_field_name)
+ if not url_has_allowed_host_and_scheme(
+ url=redirect_to, allowed_hosts=[
+ self.request.get_host()]):
+ redirect_to = self.success_url
+ return redirect_to
+
+
+def account_result(request):
+ type = request.GET.get('type')
+ id = request.GET.get('id')
+
+ user = get_object_or_404(get_user_model(), id=id)
+ logger.info(type)
+ if user.is_active:
+ return HttpResponseRedirect('/')
+ if type and type in ['register', 'validation']:
+ if type == 'register':
+ content = '''
+ 恭喜您注册成功,一封验证邮件已经发送到您的邮箱,请验证您的邮箱后登录本站。
+ '''
+ title = '注册成功'
+ else:
+ c_sign = get_sha256(get_sha256(settings.SECRET_KEY + str(user.id)))
+ sign = request.GET.get('sign')
+ if sign != c_sign:
+ return HttpResponseForbidden()
+ user.is_active = True
+ user.save()
+ content = '''
+ 恭喜您已经成功的完成邮箱验证,您现在可以使用您的账号来登录本站。
+ '''
+ title = '验证成功'
+ return render(request, 'account/result.html', {
+ 'title': title,
+ 'content': content
+ })
+ else:
+ return HttpResponseRedirect('/')
+
+
+class ForgetPasswordView(FormView):
+ form_class = ForgetPasswordForm
+ template_name = 'account/forget_password.html'
+
+ def form_valid(self, form):
+ if form.is_valid():
+ blog_user = BlogUser.objects.filter(email=form.cleaned_data.get("email")).get()
+ blog_user.password = make_password(form.cleaned_data["new_password2"])
+ blog_user.save()
+ return HttpResponseRedirect('/login/')
+ else:
+ return self.render_to_response({'form': form})
+
+
+class ForgetPasswordEmailCode(View):
+
+ def post(self, request: HttpRequest):
+ form = ForgetPasswordCodeForm(request.POST)
+ if not form.is_valid():
+ return HttpResponse("错误的邮箱")
+ to_email = form.cleaned_data["email"]
+
+ code = generate_code()
+ utils.send_verify_email(to_email, code)
+ utils.set_code(to_email, code)
+
+ return HttpResponse("ok")
diff --git a/Django博客项目/blog/__init__.py b/Django博客项目/blog/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/Django博客项目/blog/admin.py b/Django博客项目/blog/admin.py
new file mode 100644
index 0000000..b90bed3
--- /dev/null
+++ b/Django博客项目/blog/admin.py
@@ -0,0 +1,226 @@
+from django import forms
+from django.contrib import admin
+from django.contrib.auth import get_user_model
+from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
+from django.urls import reverse
+from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
+
+# Register your models here.
+from .models import Article, Tag, Category, Links, SideBar
+
+# 获取User模型 - 这是Django推荐的方式
+User = get_user_model()
+
+class ArticleForm(forms.ModelForm):
+ class Meta:
+ model = Article
+ fields = '__all__'
+
+def makr_article_publish(modeladmin, request, queryset):
+ queryset.update(status='p')
+
+def draft_article(modeladmin, request, queryset):
+ queryset.update(status='d')
+
+def close_article_commentstatus(modeladmin, request, queryset):
+ queryset.update(comment_status='c')
+
+def open_article_commentstatus(modeladmin, request, queryset):
+ queryset.update(comment_status='o')
+
+makr_article_publish.short_description = _('Publish selected articles')
+draft_article.short_description = _('Draft selected articles')
+close_article_commentstatus.short_description = _('Close article comments')
+open_article_commentstatus.short_description = _('Open article comments')
+
+class ArticlelAdmin(admin.ModelAdmin):
+ list_per_page = 20
+ search_fields = ('body', 'title')
+ form = ArticleForm
+ list_display = (
+ 'id',
+ 'title',
+ 'author',
+ 'link_to_category',
+ 'creation_time',
+ 'views',
+ 'status',
+ 'type',
+ 'article_order')
+ list_display_links = ('id', 'title')
+ list_filter = ('status', 'type', 'category')
+ filter_horizontal = ('tags',)
+ exclude = ('creation_time', 'last_modify_time')
+ view_on_site = True
+ actions = [
+ makr_article_publish,
+ draft_article,
+ close_article_commentstatus,
+ open_article_commentstatus]
+
+ def link_to_category(self, obj):
+ if obj.category:
+ info = (obj.category._meta.app_label, obj.category._meta.model_name)
+ link = reverse('admin:%s_%s_change' % info, args=(obj.category.id,))
+ return format_html(u'%s' % (link, obj.category.name))
+ return "-"
+
+ link_to_category.short_description = _('category')
+
+ def get_form(self, request, obj=None, **kwargs):
+ form = super(ArticlelAdmin, self).get_form(request, obj, **kwargs)
+
+ if request.user.is_superuser:
+ form.base_fields['author'].queryset = get_user_model().objects.filter(is_staff=True)
+ else:
+ form.base_fields['author'].queryset = get_user_model().objects.filter(id=request.user.id)
+ if not obj:
+ form.base_fields['author'].initial = request.user
+ form.base_fields['author'].disabled = True
+
+ return form
+
+ def get_queryset(self, request):
+ qs = super(ArticlelAdmin, self).get_queryset(request)
+ if request.user.is_superuser:
+ return qs
+ return qs.filter(author=request.user)
+
+ def save_model(self, request, obj, form, change):
+ if not change and not request.user.is_superuser:
+ obj.author = request.user
+ super(ArticlelAdmin, self).save_model(request, obj, form, change)
+
+ def has_change_permission(self, request, obj=None):
+ if obj is not None and not request.user.is_superuser:
+ return obj.author == request.user
+ return super().has_change_permission(request, obj)
+
+ def has_delete_permission(self, request, obj=None):
+ if obj is not None and not request.user.is_superuser:
+ return obj.author == request.user
+ return super().has_delete_permission(request, obj)
+
+ def get_view_on_site_url(self, obj=None):
+ if obj:
+ url = obj.get_full_url()
+ return url
+ else:
+ from djangoblog.utils import get_current_site
+ site = get_current_site().domain
+ return site
+
+class TagAdmin(admin.ModelAdmin):
+ exclude = ('slug', 'last_mod_time', 'creation_time')
+
+ def get_queryset(self, request):
+ qs = super().get_queryset(request)
+ return qs
+
+ def has_change_permission(self, request, obj=None):
+ return request.user.is_staff
+
+ def has_add_permission(self, request):
+ return request.user.is_staff
+
+ def has_delete_permission(self, request, obj=None):
+ return request.user.is_superuser
+
+class CategoryAdmin(admin.ModelAdmin):
+ list_display = ('name', 'parent_category', 'index')
+ exclude = ('slug', 'last_mod_time', 'creation_time')
+
+ def get_queryset(self, request):
+ qs = super().get_queryset(request)
+ return qs
+
+ def has_change_permission(self, request, obj=None):
+ return request.user.is_superuser
+
+ def has_add_permission(self, request):
+ return request.user.is_superuser
+
+ def has_delete_permission(self, request, obj=None):
+ return request.user.is_superuser
+
+class LinksAdmin(admin.ModelAdmin):
+ exclude = ('last_mod_time', 'creation_time')
+
+ def has_change_permission(self, request, obj=None):
+ return request.user.is_superuser
+
+ def has_add_permission(self, request):
+ return request.user.is_superuser
+
+ def has_delete_permission(self, request, obj=None):
+ return request.user.is_superuser
+
+class SideBarAdmin(admin.ModelAdmin):
+ list_display = ('name', 'content', 'is_enable', 'sequence')
+ exclude = ('last_mod_time', 'creation_time')
+
+ def has_change_permission(self, request, obj=None):
+ return request.user.is_superuser
+
+ def has_add_permission(self, request):
+ return request.user.is_superuser
+
+ def has_delete_permission(self, request, obj=None):
+ return request.user.is_superuser
+
+class BlogSettingsAdmin(admin.ModelAdmin):
+ pass
+
+class UserAdmin(BaseUserAdmin):
+ def get_queryset(self, request):
+ qs = super().get_queryset(request)
+ if request.user.is_superuser:
+ return qs
+ return qs.filter(id=request.user.id)
+
+ def get_form(self, request, obj=None, **kwargs):
+ form = super().get_form(request, obj, **kwargs)
+
+ if not request.user.is_superuser:
+ disabled_fields = {
+ 'is_superuser', 'is_staff', 'is_active',
+ 'user_permissions', 'groups', 'last_login', 'date_joined'
+ }
+
+ for field_name in disabled_fields:
+ if field_name in form.base_fields:
+ form.base_fields[field_name].disabled = True
+
+ if 'username' in form.base_fields:
+ form.base_fields['username'].disabled = True
+
+ return form
+
+ def has_add_permission(self, request):
+ return request.user.is_superuser
+
+ def has_delete_permission(self, request, obj=None):
+ if obj is not None and not request.user.is_superuser:
+ return False
+ return request.user.is_superuser
+
+ def has_change_permission(self, request, obj=None):
+ if obj is not None and not request.user.is_superuser:
+ return obj == request.user
+ return super().has_change_permission(request, obj)
+
+# 注册博客模型
+admin.site.register(Article, ArticlelAdmin)
+admin.site.register(Tag, TagAdmin)
+admin.site.register(Category, CategoryAdmin)
+admin.site.register(Links, LinksAdmin)
+admin.site.register(SideBar, SideBarAdmin)
+
+# 安全地重新注册User模型
+try:
+ admin.site.unregister(User)
+except admin.sites.NotRegistered:
+ pass
+
+admin.site.register(User, UserAdmin)
\ No newline at end of file
diff --git a/Django博客项目/blog/apps.py b/Django博客项目/blog/apps.py
new file mode 100644
index 0000000..7930587
--- /dev/null
+++ b/Django博客项目/blog/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class BlogConfig(AppConfig):
+ name = 'blog'
diff --git a/Django博客项目/blog/context_processors.py b/Django博客项目/blog/context_processors.py
new file mode 100644
index 0000000..73e3088
--- /dev/null
+++ b/Django博客项目/blog/context_processors.py
@@ -0,0 +1,43 @@
+import logging
+
+from django.utils import timezone
+
+from djangoblog.utils import cache, get_blog_setting
+from .models import Category, Article
+
+logger = logging.getLogger(__name__)
+
+
+def seo_processor(requests):
+ key = 'seo_processor'
+ value = cache.get(key)
+ if value:
+ return value
+ else:
+ logger.info('set processor cache.')
+ setting = get_blog_setting()
+ value = {
+ 'SITE_NAME': setting.site_name,
+ 'SHOW_GOOGLE_ADSENSE': setting.show_google_adsense,
+ 'GOOGLE_ADSENSE_CODES': setting.google_adsense_codes,
+ 'SITE_SEO_DESCRIPTION': setting.site_seo_description,
+ 'SITE_DESCRIPTION': setting.site_description,
+ 'SITE_KEYWORDS': setting.site_keywords,
+ 'SITE_BASE_URL': requests.scheme + '://' + requests.get_host() + '/',
+ 'ARTICLE_SUB_LENGTH': setting.article_sub_length,
+ 'nav_category_list': Category.objects.all(),
+ 'nav_pages': Article.objects.filter(
+ type='p',
+ status='p'),
+ 'OPEN_SITE_COMMENT': setting.open_site_comment,
+ 'BEIAN_CODE': setting.beian_code,
+ 'ANALYTICS_CODE': setting.analytics_code,
+ "BEIAN_CODE_GONGAN": setting.gongan_beiancode,
+ "SHOW_GONGAN_CODE": setting.show_gongan_code,
+ "CURRENT_YEAR": timezone.now().year,
+ "GLOBAL_HEADER": setting.global_header,
+ "GLOBAL_FOOTER": setting.global_footer,
+ "COMMENT_NEED_REVIEW": setting.comment_need_review,
+ }
+ cache.set(key, value, 60 * 60 * 10)
+ return value
diff --git a/Django博客项目/blog/documents.py b/Django博客项目/blog/documents.py
new file mode 100644
index 0000000..6b53625
--- /dev/null
+++ b/Django博客项目/blog/documents.py
@@ -0,0 +1,64 @@
+# 禁用Elasticsearch功能
+ELASTICSEARCH_ENABLED = False
+
+class ElapsedTimeDocumentManager:
+ """
+ 简单的文档管理器替代品
+ 不依赖Elasticsearch,提供空实现
+ """
+
+ @staticmethod
+ def add(instance, *args, **kwargs):
+ """添加文档 - 空实现"""
+ return None
+
+ @staticmethod
+ def update(instance, *args, **kwargs):
+ """更新文档 - 空实现"""
+ return None
+
+ @staticmethod
+ def delete(instance, *args, **kwargs):
+ """删除文档 - 空实现"""
+ return None
+
+ @staticmethod
+ def search(query, *args, **kwargs):
+ """搜索文档 - 返回空结果"""
+ return []
+
+ @staticmethod
+ def bulk_update(instances, *args, **kwargs):
+ """批量更新 - 空实现"""
+ return None
+
+# 为可能被导入的Elasticsearch DSL类创建空定义
+class Document:
+ pass
+
+class InnerDoc:
+ pass
+
+class Date:
+ pass
+
+class Integer:
+ pass
+
+class Long:
+ pass
+
+class Text:
+ pass
+
+class Object:
+ pass
+
+class GeoPoint:
+ pass
+
+class Keyword:
+ pass
+
+class Boolean:
+ pass
\ No newline at end of file
diff --git a/Django博客项目/blog/forms.py b/Django博客项目/blog/forms.py
new file mode 100644
index 0000000..715be76
--- /dev/null
+++ b/Django博客项目/blog/forms.py
@@ -0,0 +1,19 @@
+import logging
+
+from django import forms
+from haystack.forms import SearchForm
+
+logger = logging.getLogger(__name__)
+
+
+class BlogSearchForm(SearchForm):
+ querydata = forms.CharField(required=True)
+
+ def search(self):
+ datas = super(BlogSearchForm, self).search()
+ if not self.is_valid():
+ return self.no_query_found()
+
+ if self.cleaned_data['querydata']:
+ logger.info(self.cleaned_data['querydata'])
+ return datas
diff --git a/Django博客项目/blog/management/__init__.py b/Django博客项目/blog/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/Django博客项目/blog/management/commands/__init__.py b/Django博客项目/blog/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/Django博客项目/blog/management/commands/build_index.py b/Django博客项目/blog/management/commands/build_index.py
new file mode 100644
index 0000000..3c4acd7
--- /dev/null
+++ b/Django博客项目/blog/management/commands/build_index.py
@@ -0,0 +1,18 @@
+from django.core.management.base import BaseCommand
+
+from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedTimeDocumentManager, \
+ ELASTICSEARCH_ENABLED
+
+
+# TODO 参数化
+class Command(BaseCommand):
+ help = 'build search index'
+
+ def handle(self, *args, **options):
+ if ELASTICSEARCH_ENABLED:
+ ElaspedTimeDocumentManager.build_index()
+ manager = ElapsedTimeDocument()
+ manager.init()
+ manager = ArticleDocumentManager()
+ manager.delete_index()
+ manager.rebuild()
diff --git a/Django博客项目/blog/management/commands/build_search_words.py b/Django博客项目/blog/management/commands/build_search_words.py
new file mode 100644
index 0000000..cfe7e0d
--- /dev/null
+++ b/Django博客项目/blog/management/commands/build_search_words.py
@@ -0,0 +1,13 @@
+from django.core.management.base import BaseCommand
+
+from blog.models import Tag, Category
+
+
+# TODO 参数化
+class Command(BaseCommand):
+ help = 'build search words'
+
+ def handle(self, *args, **options):
+ datas = set([t.name for t in Tag.objects.all()] +
+ [t.name for t in Category.objects.all()])
+ print('\n'.join(datas))
diff --git a/Django博客项目/blog/management/commands/clear_cache.py b/Django博客项目/blog/management/commands/clear_cache.py
new file mode 100644
index 0000000..0d66172
--- /dev/null
+++ b/Django博客项目/blog/management/commands/clear_cache.py
@@ -0,0 +1,11 @@
+from django.core.management.base import BaseCommand
+
+from djangoblog.utils import cache
+
+
+class Command(BaseCommand):
+ help = 'clear the whole cache'
+
+ def handle(self, *args, **options):
+ cache.clear()
+ self.stdout.write(self.style.SUCCESS('Cleared cache\n'))
diff --git a/Django博客项目/blog/management/commands/create_testdata.py b/Django博客项目/blog/management/commands/create_testdata.py
new file mode 100644
index 0000000..675d2ba
--- /dev/null
+++ b/Django博客项目/blog/management/commands/create_testdata.py
@@ -0,0 +1,40 @@
+from django.contrib.auth import get_user_model
+from django.contrib.auth.hashers import make_password
+from django.core.management.base import BaseCommand
+
+from blog.models import Article, Tag, Category
+
+
+class Command(BaseCommand):
+ help = 'create test datas'
+
+ def handle(self, *args, **options):
+ user = get_user_model().objects.get_or_create(
+ email='test@test.com', username='测试用户', password=make_password('test!q@w#eTYU'))[0]
+
+ pcategory = Category.objects.get_or_create(
+ name='我是父类目', parent_category=None)[0]
+
+ category = Category.objects.get_or_create(
+ name='子类目', parent_category=pcategory)[0]
+
+ category.save()
+ basetag = Tag()
+ basetag.name = "标签"
+ basetag.save()
+ for i in range(1, 20):
+ article = Article.objects.get_or_create(
+ category=category,
+ title='nice title ' + str(i),
+ body='nice content ' + str(i),
+ author=user)[0]
+ tag = Tag()
+ tag.name = "标签" + str(i)
+ tag.save()
+ article.tags.add(tag)
+ article.tags.add(basetag)
+ article.save()
+
+ from djangoblog.utils import cache
+ cache.clear()
+ self.stdout.write(self.style.SUCCESS('created test datas \n'))
diff --git a/Django博客项目/blog/management/commands/ping_baidu.py b/Django博客项目/blog/management/commands/ping_baidu.py
new file mode 100644
index 0000000..2c7fbdd
--- /dev/null
+++ b/Django博客项目/blog/management/commands/ping_baidu.py
@@ -0,0 +1,50 @@
+from django.core.management.base import BaseCommand
+
+from djangoblog.spider_notify import SpiderNotify
+from djangoblog.utils import get_current_site
+from blog.models import Article, Tag, Category
+
+site = get_current_site().domain
+
+
+class Command(BaseCommand):
+ help = 'notify baidu url'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ 'data_type',
+ type=str,
+ choices=[
+ 'all',
+ 'article',
+ 'tag',
+ 'category'],
+ help='article : all article,tag : all tag,category: all category,all: All of these')
+
+ def get_full_url(self, path):
+ url = "https://{site}{path}".format(site=site, path=path)
+ return url
+
+ def handle(self, *args, **options):
+ type = options['data_type']
+ self.stdout.write('start get %s' % type)
+
+ urls = []
+ if type == 'article' or type == 'all':
+ for article in Article.objects.filter(status='p'):
+ urls.append(article.get_full_url())
+ if type == 'tag' or type == 'all':
+ for tag in Tag.objects.all():
+ url = tag.get_absolute_url()
+ urls.append(self.get_full_url(url))
+ if type == 'category' or type == 'all':
+ for category in Category.objects.all():
+ url = category.get_absolute_url()
+ urls.append(self.get_full_url(url))
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ 'start notify %d urls' %
+ len(urls)))
+ SpiderNotify.baidu_notify(urls)
+ self.stdout.write(self.style.SUCCESS('finish notify'))
diff --git a/Django博客项目/blog/management/commands/sync_user_avatar.py b/Django博客项目/blog/management/commands/sync_user_avatar.py
new file mode 100644
index 0000000..d0f4612
--- /dev/null
+++ b/Django博客项目/blog/management/commands/sync_user_avatar.py
@@ -0,0 +1,47 @@
+import requests
+from django.core.management.base import BaseCommand
+from django.templatetags.static import static
+
+from djangoblog.utils import save_user_avatar
+from oauth.models import OAuthUser
+from oauth.oauthmanager import get_manager_by_type
+
+
+class Command(BaseCommand):
+ help = 'sync user avatar'
+
+ def test_picture(self, url):
+ try:
+ if requests.get(url, timeout=2).status_code == 200:
+ return True
+ except:
+ pass
+
+ def handle(self, *args, **options):
+ static_url = static("../")
+ users = OAuthUser.objects.all()
+ self.stdout.write(f'开始同步{len(users)}个用户头像')
+ for u in users:
+ self.stdout.write(f'开始同步:{u.nickname}')
+ url = u.picture
+ if url:
+ if url.startswith(static_url):
+ if self.test_picture(url):
+ continue
+ else:
+ if u.metadata:
+ manage = get_manager_by_type(u.type)
+ url = manage.get_picture(u.metadata)
+ url = save_user_avatar(url)
+ else:
+ url = static('blog/img/avatar.png')
+ else:
+ url = save_user_avatar(url)
+ else:
+ url = static('blog/img/avatar.png')
+ if url:
+ self.stdout.write(
+ f'结束同步:{u.nickname}.url:{url}')
+ u.picture = url
+ u.save()
+ self.stdout.write('结束同步')
diff --git a/Django博客项目/blog/middleware.py b/Django博客项目/blog/middleware.py
new file mode 100644
index 0000000..6f624f4
--- /dev/null
+++ b/Django博客项目/blog/middleware.py
@@ -0,0 +1,48 @@
+import logging
+import time
+
+def get_client_ip(request):
+ x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
+ if x_forwarded_for:
+ ip = x_forwarded_for.split(',')[0]
+ else:
+ ip = request.META.get('REMOTE_ADDR')
+ return ip
+from user_agents import parse
+
+from blog.documents import ELASTICSEARCH_ENABLED, ElapsedTimeDocumentManager
+
+logger = logging.getLogger(__name__)
+
+
+class OnlineMiddleware(object):
+ def __init__(self, get_response=None):
+ self.get_response = get_response
+ super().__init__()
+
+ def __call__(self, request):
+ ''' page render time '''
+ start_time = time.time()
+ response = self.get_response(request)
+ http_user_agent = request.META.get('HTTP_USER_AGENT', '')
+ ip = get_client_ip(request)
+ user_agent = parse(http_user_agent)
+ if not response.streaming:
+ try:
+ cast_time = time.time() - start_time
+ if ELASTICSEARCH_ENABLED:
+ time_taken = round((cast_time) * 1000, 2)
+ url = request.path
+ from django.utils import timezone
+ ElapsedTimeDocumentManager.create(
+ url=url,
+ time_taken=time_taken,
+ log_datetime=timezone.now(),
+ useragent=user_agent,
+ ip=ip)
+ response.content = response.content.replace(
+ b'', str.encode(str(cast_time)[:5]))
+ except Exception as e:
+ logger.error("Error OnlineMiddleware: %s" % e)
+
+ return response
diff --git a/Django博客项目/blog/migrations/__init__.py b/Django博客项目/blog/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/Django博客项目/blog/models.py b/Django博客项目/blog/models.py
new file mode 100644
index 0000000..dbadd79
--- /dev/null
+++ b/Django博客项目/blog/models.py
@@ -0,0 +1,431 @@
+import logging
+import re
+from abc import abstractmethod
+
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.urls import reverse
+from django.utils.timezone import now
+from django.utils.translation import gettext_lazy as _
+from mdeditor.fields import MDTextField
+#from uuslug import slugify
+from django.utils.text import slugify
+
+from djangoblog.utils import cache_decorator, cache
+from djangoblog.utils import get_current_site
+
+logger = logging.getLogger(__name__)
+
+
+class LinkShowType(models.TextChoices):
+ I = ('i', _('index'))
+ L = ('l', _('list'))
+ P = ('p', _('post'))
+ A = ('a', _('all'))
+ S = ('s', _('slide'))
+
+
+class BaseModel(models.Model):
+ id = models.AutoField(primary_key=True)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_modify_time = models.DateTimeField(_('modify time'), default=now)
+
+ def save(self, *args, **kwargs):
+ is_update_views = isinstance(
+ self,
+ Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views']
+ if is_update_views:
+ Article.objects.filter(pk=self.pk).update(views=self.views)
+ else:
+ if 'slug' in self.__dict__:
+ slug = getattr(
+ self, 'title') if 'title' in self.__dict__ else getattr(
+ self, 'name')
+ setattr(self, 'slug', slugify(slug))
+ super().save(*args, **kwargs)
+
+ def get_full_url(self):
+ site = get_current_site().domain
+ url = "https://{site}{path}".format(site=site,
+ path=self.get_absolute_url())
+ return url
+
+ class Meta:
+ abstract = True
+
+ @abstractmethod
+ def get_absolute_url(self):
+ pass
+
+
+class Article(BaseModel):
+ """文章"""
+ STATUS_CHOICES = (
+ ('d', _('Draft')),
+ ('p', _('Published')),
+ )
+ COMMENT_STATUS = (
+ ('o', _('Open')),
+ ('c', _('Close')),
+ )
+ TYPE = (
+ ('a', _('Article')),
+ ('p', _('Page')),
+ )
+ title = models.CharField(_('title'), max_length=200, unique=True)
+ body = MDTextField(_('body'))
+ pub_time = models.DateTimeField(
+ _('publish time'), blank=False, null=False, default=now)
+ status = models.CharField(
+ _('status'),
+ max_length=1,
+ choices=STATUS_CHOICES,
+ default='p')
+ comment_status = models.CharField(
+ _('comment status'),
+ max_length=1,
+ choices=COMMENT_STATUS,
+ default='o')
+ type = models.CharField(_('type'), max_length=1, choices=TYPE, default='a')
+ views = models.PositiveIntegerField(_('views'), default=0)
+ author = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ verbose_name=_('author'),
+ blank=False,
+ null=False,
+ on_delete=models.CASCADE)
+ article_order = models.IntegerField(
+ _('order'), blank=False, null=False, default=0)
+ show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False)
+ category = models.ForeignKey(
+ 'Category',
+ verbose_name=_('category'),
+ on_delete=models.CASCADE,
+ blank=False,
+ null=False)
+ tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True)
+#新增
+ likes = models.PositiveIntegerField(_('likes'), default=0) # 新增点赞计数字段
+ liked_users = models.ManyToManyField(
+ settings.AUTH_USER_MODEL,
+ through='LikeRecord',
+ related_name='liked_articles',
+ blank=True,
+ verbose_name=_('liked users')
+ )
+
+ def like_count(self):
+ return self.liked_users.count()
+
+
+ def body_to_string(self):
+ return self.body
+
+ def __str__(self):
+ return self.title
+
+ class Meta:
+ ordering = ['-article_order', '-pub_time']
+ verbose_name = _('article')
+ verbose_name_plural = verbose_name
+ get_latest_by = 'id'
+
+ def get_absolute_url(self):
+ return reverse('blog:detailbyid', kwargs={
+ 'article_id': self.id,
+ 'year': self.creation_time.year,
+ 'month': self.creation_time.month,
+ 'day': self.creation_time.day
+ })
+
+ @cache_decorator(60 * 60 * 10)
+ def get_category_tree(self):
+ tree = self.category.get_category_tree()
+ names = list(map(lambda c: (c.name, c.get_absolute_url()), tree))
+
+ return names
+
+ def save(self, *args, **kwargs):
+ super().save(*args, **kwargs)
+
+ def viewed(self):
+ self.views += 1
+ self.save(update_fields=['views'])
+
+ def comment_list(self):
+ cache_key = 'article_comments_{id}'.format(id=self.id)
+ value = cache.get(cache_key)
+ if value:
+ logger.info('get article comments:{id}'.format(id=self.id))
+ return value
+ else:
+ comments = self.comment_set.filter(is_enable=True).order_by('-id')
+ cache.set(cache_key, comments, 60 * 100)
+ logger.info('set article comments:{id}'.format(id=self.id))
+ return comments
+
+ def get_admin_url(self):
+ info = (self._meta.app_label, self._meta.model_name)
+ return reverse('admin:%s_%s_change' % info, args=(self.pk,))
+
+ @cache_decorator(expiration=60 * 100)
+ def next_article(self):
+ # 下一篇
+ return Article.objects.filter(
+ id__gt=self.id, status='p').order_by('id').first()
+
+ @cache_decorator(expiration=60 * 100)
+ def prev_article(self):
+ # 前一篇
+ return Article.objects.filter(id__lt=self.id, status='p').first()
+
+ def get_first_image_url(self):
+ """
+ Get the first image url from article.body.
+ :return:
+ """
+ match = re.search(r'!\[.*?\]\((.+?)\)', self.body)
+ if match:
+ return match.group(1)
+ return ""
+
+
+class Category(BaseModel):
+ """文章分类"""
+ name = models.CharField(_('category name'), max_length=30, unique=True)
+ parent_category = models.ForeignKey(
+ 'self',
+ verbose_name=_('parent category'),
+ blank=True,
+ null=True,
+ on_delete=models.CASCADE)
+ slug = models.SlugField( max_length=60, blank=True,unique=True)
+ index = models.IntegerField(default=0, verbose_name=_('index'))
+
+ class Meta:
+ ordering = ['-index']
+ verbose_name = _('category')
+ verbose_name_plural = verbose_name
+
+ def get_absolute_url(self):
+ return reverse(
+ 'blog:category_detail', kwargs={
+ 'category_name': self.slug})
+
+ def __str__(self):
+ return self.name
+
+ @cache_decorator(60 * 60 * 10)
+ def get_category_tree(self):
+ """
+ 递归获得分类目录的父级
+ :return:
+ """
+ categorys = []
+
+ def parse(category):
+ categorys.append(category)
+ if category.parent_category:
+ parse(category.parent_category)
+
+ parse(self)
+ return categorys
+
+ @cache_decorator(60 * 60 * 10)
+ def get_sub_categorys(self):
+ """
+ 获得当前分类目录所有子集
+ :return:
+ """
+ categorys = []
+ all_categorys = Category.objects.all()
+
+ def parse(category):
+ if category not in categorys:
+ categorys.append(category)
+ childs = all_categorys.filter(parent_category=category)
+ for child in childs:
+ if category not in categorys:
+ categorys.append(child)
+ parse(child)
+
+ parse(self)
+ return categorys
+
+
+class Tag(BaseModel):
+ """文章标签"""
+ name = models.CharField(_('tag name'), max_length=30, unique=True)
+ slug = models.SlugField(max_length=60, unique=True) # 移除default,添加unique=True
+
+ def __str__(self):
+ return self.name
+
+ def save(self, *args, **kwargs):
+ # 自动生成slug
+ if not self.slug or self.slug == 'no-slug':
+ self.slug = self.generate_unique_slug()
+ super().save(*args, **kwargs)
+
+ def generate_unique_slug(self):
+ """生成唯一的slug"""
+ original_slug = slugify(self.name)
+ slug = original_slug
+ counter = 1
+
+ # 确保slug唯一
+ while Tag.objects.filter(slug=slug).exclude(id=self.id).exists():
+ slug = f"{original_slug}-{counter}"
+ counter += 1
+
+ return slug
+
+ def get_absolute_url(self):
+ return reverse('blog:tag_detail', kwargs={'tag_name': self.slug})
+
+ @cache_decorator(60 * 60 * 10)
+ def get_article_count(self):
+ return Article.objects.filter(tags__name=self.name).distinct().count()
+
+ class Meta:
+ ordering = ['name']
+ verbose_name = _('tag')
+ verbose_name_plural = verbose_name
+
+
+class Links(models.Model):
+ """友情链接"""
+
+ name = models.CharField(_('link name'), max_length=30, unique=True)
+ link = models.URLField(_('link'))
+ sequence = models.IntegerField(_('order'), unique=True)
+ is_enable = models.BooleanField(
+ _('is show'), default=True, blank=False, null=False)
+ show_type = models.CharField(
+ _('show type'),
+ max_length=1,
+ choices=LinkShowType.choices,
+ default=LinkShowType.I)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_mod_time = models.DateTimeField(_('modify time'), default=now)
+
+ class Meta:
+ ordering = ['sequence']
+ verbose_name = _('link')
+ verbose_name_plural = verbose_name
+
+ def __str__(self):
+ return self.name
+
+
+class SideBar(models.Model):
+ """侧边栏,可以展示一些html内容"""
+ name = models.CharField(_('title'), max_length=100)
+ content = models.TextField(_('content'))
+ sequence = models.IntegerField(_('order'), unique=True)
+ is_enable = models.BooleanField(_('is enable'), default=True)
+ creation_time = models.DateTimeField(_('creation time'), default=now)
+ last_mod_time = models.DateTimeField(_('modify time'), default=now)
+
+ class Meta:
+ ordering = ['sequence']
+ verbose_name = _('sidebar')
+ verbose_name_plural = verbose_name
+
+ def __str__(self):
+ return self.name
+
+
+class BlogSettings(models.Model):
+ """blog的配置"""
+ site_name = models.CharField(
+ _('site name'),
+ max_length=200,
+ null=False,
+ blank=False,
+ default='')
+ site_description = models.TextField(
+ _('site description'),
+ max_length=1000,
+ null=False,
+ blank=False,
+ default='')
+ site_seo_description = models.TextField(
+ _('site seo description'), max_length=1000, null=False, blank=False, default='')
+ site_keywords = models.TextField(
+ _('site keywords'),
+ max_length=1000,
+ null=False,
+ blank=False,
+ default='')
+ article_sub_length = models.IntegerField(_('article sub length'), default=300)
+ sidebar_article_count = models.IntegerField(_('sidebar article count'), default=10)
+ sidebar_comment_count = models.IntegerField(_('sidebar comment count'), default=5)
+ article_comment_count = models.IntegerField(_('article comment count'), default=5)
+ show_google_adsense = models.BooleanField(_('show adsense'), default=False)
+ google_adsense_codes = models.TextField(
+ _('adsense code'), max_length=2000, null=True, blank=True, default='')
+ open_site_comment = models.BooleanField(_('open site comment'), default=True)
+ global_header = models.TextField("公共头部", null=True, blank=True, default='')
+ global_footer = models.TextField("公共尾部", null=True, blank=True, default='')
+ beian_code = models.CharField(
+ '备案号',
+ max_length=2000,
+ null=True,
+ blank=True,
+ default='')
+ analytics_code = models.TextField(
+ "网站统计代码",
+ max_length=1000,
+ null=False,
+ blank=False,
+ default='')
+ show_gongan_code = models.BooleanField(
+ '是否显示公安备案号', default=False, null=False)
+ gongan_beiancode = models.TextField(
+ '公安备案号',
+ max_length=2000,
+ null=True,
+ blank=True,
+ default='')
+ comment_need_review = models.BooleanField(
+ '评论是否需要审核', default=False, null=False)
+
+ class Meta:
+ verbose_name = _('Website configuration')
+ verbose_name_plural = verbose_name
+
+ def __str__(self):
+ return self.site_name
+
+ def clean(self):
+ if BlogSettings.objects.exclude(id=self.id).count():
+ raise ValidationError(_('There can only be one configuration'))
+
+ def save(self, *args, **kwargs):
+ super().save(*args, **kwargs)
+ from djangoblog.utils import cache
+ cache.clear()
+ # 在文件末尾添加
+class LikeRecord(models.Model):
+ """点赞记录模型"""
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ verbose_name=_('user'),
+ on_delete=models.CASCADE
+ )
+ article = models.ForeignKey(
+ Article,
+ verbose_name=_('article'),
+ on_delete=models.CASCADE
+ )
+ created_time = models.DateTimeField(_('creation time'), auto_now_add=True)
+
+ class Meta:
+ verbose_name = _('like record')
+ verbose_name_plural = _('like records')
+ unique_together = ('user', 'article') # 防止重复点赞
+
+ def __str__(self):
+ return f"{self.user.username} liked {self.article.title}"
diff --git a/Django博客项目/blog/search_indexes.py b/Django博客项目/blog/search_indexes.py
new file mode 100644
index 0000000..8e6277c
--- /dev/null
+++ b/Django博客项目/blog/search_indexes.py
@@ -0,0 +1,14 @@
+from haystack import indexes
+
+from blog.models import Article
+
+from jieba.analyse import ChineseAnalyzer
+
+class ArticleIndex(indexes.SearchIndex, indexes.Indexable):
+ text = indexes.CharField(document=True, use_template=True)
+
+ def get_model(self):
+ return Article
+
+ def index_queryset(self, using=None):
+ return self.get_model().objects.all()
diff --git a/Django博客项目/blog/templatetags/__init__.py b/Django博客项目/blog/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/Django博客项目/blog/templatetags/blog_tags.py b/Django博客项目/blog/templatetags/blog_tags.py
new file mode 100644
index 0000000..d6cd5d5
--- /dev/null
+++ b/Django博客项目/blog/templatetags/blog_tags.py
@@ -0,0 +1,344 @@
+import hashlib
+import logging
+import random
+import urllib
+
+from django import template
+from django.conf import settings
+from django.db.models import Q
+from django.shortcuts import get_object_or_404
+from django.template.defaultfilters import stringfilter
+from django.templatetags.static import static
+from django.urls import reverse
+from django.utils.safestring import mark_safe
+
+from blog.models import Article, Category, Tag, Links, SideBar, LinkShowType
+from comments.models import Comment
+from djangoblog.utils import CommonMarkdown, sanitize_html
+from djangoblog.utils import cache
+from djangoblog.utils import get_current_site
+from oauth.models import OAuthUser
+from djangoblog.plugin_manage import hooks
+
+logger = logging.getLogger(__name__)
+
+register = template.Library()
+
+
+@register.simple_tag(takes_context=True)
+def head_meta(context):
+ return mark_safe(hooks.apply_filters('head_meta', '', context))
+
+
+@register.simple_tag
+def timeformat(data):
+ try:
+ return data.strftime(settings.TIME_FORMAT)
+ except Exception as e:
+ logger.error(e)
+ return ""
+
+
+@register.simple_tag
+def datetimeformat(data):
+ try:
+ return data.strftime(settings.DATE_TIME_FORMAT)
+ except Exception as e:
+ logger.error(e)
+ return ""
+
+
+@register.filter()
+@stringfilter
+def custom_markdown(content):
+ return mark_safe(CommonMarkdown.get_markdown(content))
+
+
+@register.simple_tag
+def get_markdown_toc(content):
+ from djangoblog.utils import CommonMarkdown
+ body, toc = CommonMarkdown.get_markdown_with_toc(content)
+ return mark_safe(toc)
+
+
+@register.filter()
+@stringfilter
+def comment_markdown(content):
+ content = CommonMarkdown.get_markdown(content)
+ return mark_safe(sanitize_html(content))
+
+
+@register.filter(is_safe=True)
+@stringfilter
+def truncatechars_content(content):
+ """
+ 获得文章内容的摘要
+ :param content:
+ :return:
+ """
+ from django.template.defaultfilters import truncatechars_html
+ from djangoblog.utils import get_blog_setting
+ blogsetting = get_blog_setting()
+ return truncatechars_html(content, blogsetting.article_sub_length)
+
+
+@register.filter(is_safe=True)
+@stringfilter
+def truncate(content):
+ from django.utils.html import strip_tags
+
+ return strip_tags(content)[:150]
+
+
+@register.inclusion_tag('blog/tags/breadcrumb.html')
+def load_breadcrumb(article):
+ """
+ 获得文章面包屑
+ :param article:
+ :return:
+ """
+ names = article.get_category_tree()
+ from djangoblog.utils import get_blog_setting
+ blogsetting = get_blog_setting()
+ site = get_current_site().domain
+ names.append((blogsetting.site_name, '/'))
+ names = names[::-1]
+
+ return {
+ 'names': names,
+ 'title': article.title,
+ 'count': len(names) + 1
+ }
+
+
+@register.inclusion_tag('blog/tags/article_tag_list.html')
+def load_articletags(article):
+ """
+ 文章标签
+ :param article:
+ :return:
+ """
+ tags = article.tags.all()
+ tags_list = []
+ for tag in tags:
+ url = tag.get_absolute_url()
+ count = tag.get_article_count()
+ tags_list.append((
+ url, count, tag, random.choice(settings.BOOTSTRAP_COLOR_TYPES)
+ ))
+ return {
+ 'article_tags_list': tags_list
+ }
+
+
+@register.inclusion_tag('blog/tags/sidebar.html')
+def load_sidebar(user, linktype):
+ """
+ 加载侧边栏
+ :return:
+ """
+ value = cache.get("sidebar" + linktype)
+ if value:
+ value['user'] = user
+ return value
+ else:
+ logger.info('load sidebar')
+ from djangoblog.utils import get_blog_setting
+ blogsetting = get_blog_setting()
+ recent_articles = Article.objects.filter(
+ status='p')[:blogsetting.sidebar_article_count]
+ sidebar_categorys = Category.objects.all()
+ extra_sidebars = SideBar.objects.filter(
+ is_enable=True).order_by('sequence')
+ most_read_articles = Article.objects.filter(status='p').order_by(
+ '-views')[:blogsetting.sidebar_article_count]
+ dates = Article.objects.datetimes('creation_time', 'month', order='DESC')
+ links = Links.objects.filter(is_enable=True).filter(
+ Q(show_type=str(linktype)) | Q(show_type=LinkShowType.A))
+ commment_list = Comment.objects.filter(is_enable=True).order_by(
+ '-id')[:blogsetting.sidebar_comment_count]
+ # 标签云 计算字体大小
+ # 根据总数计算出平均值 大小为 (数目/平均值)*步长
+ increment = 5
+ tags = Tag.objects.all()
+ sidebar_tags = None
+ if tags and len(tags) > 0:
+ s = [t for t in [(t, t.get_article_count()) for t in tags] if t[1]]
+ count = sum([t[1] for t in s])
+ dd = 1 if (count == 0 or not len(tags)) else count / len(tags)
+ import random
+ sidebar_tags = list(
+ map(lambda x: (x[0], x[1], (x[1] / dd) * increment + 10), s))
+ random.shuffle(sidebar_tags)
+
+ value = {
+ 'recent_articles': recent_articles,
+ 'sidebar_categorys': sidebar_categorys,
+ 'most_read_articles': most_read_articles,
+ 'article_dates': dates,
+ 'sidebar_comments': commment_list,
+ 'sidabar_links': links,
+ 'show_google_adsense': blogsetting.show_google_adsense,
+ 'google_adsense_codes': blogsetting.google_adsense_codes,
+ 'open_site_comment': blogsetting.open_site_comment,
+ 'show_gongan_code': blogsetting.show_gongan_code,
+ 'sidebar_tags': sidebar_tags,
+ 'extra_sidebars': extra_sidebars
+ }
+ cache.set("sidebar" + linktype, value, 60 * 60 * 60 * 3)
+ logger.info('set sidebar cache.key:{key}'.format(key="sidebar" + linktype))
+ value['user'] = user
+ return value
+
+
+@register.inclusion_tag('blog/tags/article_meta_info.html')
+def load_article_metas(article, user):
+ """
+ 获得文章meta信息
+ :param article:
+ :return:
+ """
+ return {
+ 'article': article,
+ 'user': user
+ }
+
+
+@register.inclusion_tag('blog/tags/article_pagination.html')
+def load_pagination_info(page_obj, page_type, tag_name):
+ previous_url = ''
+ next_url = ''
+ if page_type == '':
+ if page_obj.has_next():
+ next_number = page_obj.next_page_number()
+ next_url = reverse('blog:index_page', kwargs={'page': next_number})
+ if page_obj.has_previous():
+ previous_number = page_obj.previous_page_number()
+ previous_url = reverse(
+ 'blog:index_page', kwargs={
+ 'page': previous_number})
+ if page_type == '分类标签归档':
+ tag = get_object_or_404(Tag, name=tag_name)
+ if page_obj.has_next():
+ next_number = page_obj.next_page_number()
+ next_url = reverse(
+ 'blog:tag_detail_page',
+ kwargs={
+ 'page': next_number,
+ 'tag_name': tag.slug})
+ if page_obj.has_previous():
+ previous_number = page_obj.previous_page_number()
+ previous_url = reverse(
+ 'blog:tag_detail_page',
+ kwargs={
+ 'page': previous_number,
+ 'tag_name': tag.slug})
+ if page_type == '作者文章归档':
+ if page_obj.has_next():
+ next_number = page_obj.next_page_number()
+ next_url = reverse(
+ 'blog:author_detail_page',
+ kwargs={
+ 'page': next_number,
+ 'author_name': tag_name})
+ if page_obj.has_previous():
+ previous_number = page_obj.previous_page_number()
+ previous_url = reverse(
+ 'blog:author_detail_page',
+ kwargs={
+ 'page': previous_number,
+ 'author_name': tag_name})
+
+ if page_type == '分类目录归档':
+ category = get_object_or_404(Category, name=tag_name)
+ if page_obj.has_next():
+ next_number = page_obj.next_page_number()
+ next_url = reverse(
+ 'blog:category_detail_page',
+ kwargs={
+ 'page': next_number,
+ 'category_name': category.slug})
+ if page_obj.has_previous():
+ previous_number = page_obj.previous_page_number()
+ previous_url = reverse(
+ 'blog:category_detail_page',
+ kwargs={
+ 'page': previous_number,
+ 'category_name': category.slug})
+
+ return {
+ 'previous_url': previous_url,
+ 'next_url': next_url,
+ 'page_obj': page_obj
+ }
+
+
+@register.inclusion_tag('blog/tags/article_info.html')
+def load_article_detail(article, isindex, user):
+ """
+ 加载文章详情
+ :param article:
+ :param isindex:是否列表页,若是列表页只显示摘要
+ :return:
+ """
+ from djangoblog.utils import get_blog_setting
+ blogsetting = get_blog_setting()
+
+ return {
+ 'article': article,
+ 'isindex': isindex,
+ 'user': user,
+ 'open_site_comment': blogsetting.open_site_comment,
+ }
+
+
+# return only the URL of the gravatar
+# TEMPLATE USE: {{ email|gravatar_url:150 }}
+@register.filter
+def gravatar_url(email, size=40):
+ """获得gravatar头像"""
+ cachekey = 'gravatat/' + email
+ url = cache.get(cachekey)
+ if url:
+ return url
+ else:
+ usermodels = OAuthUser.objects.filter(email=email)
+ if usermodels:
+ o = list(filter(lambda x: x.picture is not None, usermodels))
+ if o:
+ return o[0].picture
+ email = email.encode('utf-8')
+
+ default = static('blog/img/avatar.png')
+
+ url = "https://www.gravatar.com/avatar/%s?%s" % (hashlib.md5(
+ email.lower()).hexdigest(), urllib.parse.urlencode({'d': default, 's': str(size)}))
+ cache.set(cachekey, url, 60 * 60 * 10)
+ logger.info('set gravatar cache.key:{key}'.format(key=cachekey))
+ return url
+
+
+@register.filter
+def gravatar(email, size=40):
+ """获得gravatar头像"""
+ url = gravatar_url(email, size)
+ return mark_safe(
+ '' %
+ (url, size, size))
+
+
+@register.simple_tag
+def query(qs, **kwargs):
+ """ template tag which allows queryset filtering. Usage:
+ {% query books author=author as mybooks %}
+ {% for book in mybooks %}
+ ...
+ {% endfor %}
+ """
+ return qs.filter(**kwargs)
+
+
+@register.filter
+def addstr(arg1, arg2):
+ """concatenate arg1 & arg2"""
+ return str(arg1) + str(arg2)
diff --git a/Django博客项目/blog/tests.py b/Django博客项目/blog/tests.py
new file mode 100644
index 0000000..ee13505
--- /dev/null
+++ b/Django博客项目/blog/tests.py
@@ -0,0 +1,232 @@
+import os
+
+from django.conf import settings
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.core.management import call_command
+from django.core.paginator import Paginator
+from django.templatetags.static import static
+from django.test import Client, RequestFactory, TestCase
+from django.urls import reverse
+from django.utils import timezone
+
+from accounts.models import BlogUser
+from blog.forms import BlogSearchForm
+from blog.models import Article, Category, Tag, SideBar, Links
+from blog.templatetags.blog_tags import load_pagination_info, load_articletags
+from djangoblog.utils import get_current_site, get_sha256
+from oauth.models import OAuthUser, OAuthConfig
+
+
+# Create your tests here.
+
+class ArticleTest(TestCase):
+ def setUp(self):
+ self.client = Client()
+ self.factory = RequestFactory()
+
+ def test_validate_article(self):
+ site = get_current_site().domain
+ user = BlogUser.objects.get_or_create(
+ email="liangliangyy@gmail.com",
+ username="liangliangyy")[0]
+ user.set_password("liangliangyy")
+ user.is_staff = True
+ user.is_superuser = True
+ user.save()
+ response = self.client.get(user.get_absolute_url())
+ self.assertEqual(response.status_code, 200)
+ response = self.client.get('/admin/servermanager/emailsendlog/')
+ response = self.client.get('admin/admin/logentry/')
+ s = SideBar()
+ s.sequence = 1
+ s.name = 'test'
+ s.content = 'test content'
+ s.is_enable = True
+ s.save()
+
+ category = Category()
+ category.name = "category"
+ category.creation_time = timezone.now()
+ category.last_mod_time = timezone.now()
+ category.save()
+
+ tag = Tag()
+ tag.name = "nicetag"
+ tag.save()
+
+ article = Article()
+ article.title = "nicetitle"
+ article.body = "nicecontent"
+ article.author = user
+ article.category = category
+ article.type = 'a'
+ article.status = 'p'
+
+ article.save()
+ self.assertEqual(0, article.tags.count())
+ article.tags.add(tag)
+ article.save()
+ self.assertEqual(1, article.tags.count())
+
+ for i in range(20):
+ article = Article()
+ article.title = "nicetitle" + str(i)
+ article.body = "nicetitle" + str(i)
+ article.author = user
+ article.category = category
+ article.type = 'a'
+ article.status = 'p'
+ article.save()
+ article.tags.add(tag)
+ article.save()
+ from blog.documents import ELASTICSEARCH_ENABLED
+ if ELASTICSEARCH_ENABLED:
+ call_command("build_index")
+ response = self.client.get('/search', {'q': 'nicetitle'})
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get(article.get_absolute_url())
+ self.assertEqual(response.status_code, 200)
+ from djangoblog.spider_notify import SpiderNotify
+ SpiderNotify.notify(article.get_absolute_url())
+ response = self.client.get(tag.get_absolute_url())
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get(category.get_absolute_url())
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get('/search', {'q': 'django'})
+ self.assertEqual(response.status_code, 200)
+ s = load_articletags(article)
+ self.assertIsNotNone(s)
+
+ self.client.login(username='liangliangyy', password='liangliangyy')
+
+ response = self.client.get(reverse('blog:archives'))
+ self.assertEqual(response.status_code, 200)
+
+ p = Paginator(Article.objects.all(), settings.PAGINATE_BY)
+ self.check_pagination(p, '', '')
+
+ p = Paginator(Article.objects.filter(tags=tag), settings.PAGINATE_BY)
+ self.check_pagination(p, '分类标签归档', tag.slug)
+
+ p = Paginator(
+ Article.objects.filter(
+ author__username='liangliangyy'), settings.PAGINATE_BY)
+ self.check_pagination(p, '作者文章归档', 'liangliangyy')
+
+ p = Paginator(Article.objects.filter(category=category), settings.PAGINATE_BY)
+ self.check_pagination(p, '分类目录归档', category.slug)
+
+ f = BlogSearchForm()
+ f.search()
+ # self.client.login(username='liangliangyy', password='liangliangyy')
+ from djangoblog.spider_notify import SpiderNotify
+ SpiderNotify.baidu_notify([article.get_full_url()])
+
+ from blog.templatetags.blog_tags import gravatar_url, gravatar
+ u = gravatar_url('liangliangyy@gmail.com')
+ u = gravatar('liangliangyy@gmail.com')
+
+ link = Links(
+ sequence=1,
+ name="lylinux",
+ link='https://wwww.lylinux.net')
+ link.save()
+ response = self.client.get('/links.html')
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get('/feed/')
+ self.assertEqual(response.status_code, 200)
+
+ response = self.client.get('/sitemap.xml')
+ self.assertEqual(response.status_code, 200)
+
+ self.client.get("/admin/blog/article/1/delete/")
+ self.client.get('/admin/servermanager/emailsendlog/')
+ self.client.get('/admin/admin/logentry/')
+ self.client.get('/admin/admin/logentry/1/change/')
+
+ def check_pagination(self, p, type, value):
+ for page in range(1, p.num_pages + 1):
+ s = load_pagination_info(p.page(page), type, value)
+ self.assertIsNotNone(s)
+ if s['previous_url']:
+ response = self.client.get(s['previous_url'])
+ self.assertEqual(response.status_code, 200)
+ if s['next_url']:
+ response = self.client.get(s['next_url'])
+ self.assertEqual(response.status_code, 200)
+
+ def test_image(self):
+ import requests
+ rsp = requests.get(
+ 'https://www.python.org/static/img/python-logo.png')
+ imagepath = os.path.join(settings.BASE_DIR, 'python.png')
+ with open(imagepath, 'wb') as file:
+ file.write(rsp.content)
+ rsp = self.client.post('/upload')
+ self.assertEqual(rsp.status_code, 403)
+ sign = get_sha256(get_sha256(settings.SECRET_KEY))
+ with open(imagepath, 'rb') as file:
+ imgfile = SimpleUploadedFile(
+ 'python.png', file.read(), content_type='image/jpg')
+ form_data = {'python.png': imgfile}
+ rsp = self.client.post(
+ '/upload?sign=' + sign, form_data, follow=True)
+ self.assertEqual(rsp.status_code, 200)
+ os.remove(imagepath)
+ from djangoblog.utils import save_user_avatar, send_email
+ send_email(['qq@qq.com'], 'testTitle', 'testContent')
+ save_user_avatar(
+ 'https://www.python.org/static/img/python-logo.png')
+
+ def test_errorpage(self):
+ rsp = self.client.get('/eee')
+ self.assertEqual(rsp.status_code, 404)
+
+ def test_commands(self):
+ user = BlogUser.objects.get_or_create(
+ email="liangliangyy@gmail.com",
+ username="liangliangyy")[0]
+ user.set_password("liangliangyy")
+ user.is_staff = True
+ user.is_superuser = True
+ user.save()
+
+ c = OAuthConfig()
+ c.type = 'qq'
+ c.appkey = 'appkey'
+ c.appsecret = 'appsecret'
+ c.save()
+
+ u = OAuthUser()
+ u.type = 'qq'
+ u.openid = 'openid'
+ u.user = user
+ u.picture = static("/blog/img/avatar.png")
+ u.metadata = '''
+{
+"figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30"
+}'''
+ u.save()
+
+ u = OAuthUser()
+ u.type = 'qq'
+ u.openid = 'openid1'
+ u.picture = 'https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30'
+ u.metadata = '''
+ {
+ "figureurl": "https://qzapp.qlogo.cn/qzapp/101513904/C740E30B4113EAA80E0D9918ABC78E82/30"
+ }'''
+ u.save()
+
+ from blog.documents import ELASTICSEARCH_ENABLED
+ if ELASTICSEARCH_ENABLED:
+ call_command("build_index")
+ call_command("ping_baidu", "all")
+ call_command("create_testdata")
+ call_command("clear_cache")
+ call_command("sync_user_avatar")
+ call_command("build_search_words")
diff --git a/Django博客项目/blog/urls.py b/Django博客项目/blog/urls.py
new file mode 100644
index 0000000..df66d0a
--- /dev/null
+++ b/Django博客项目/blog/urls.py
@@ -0,0 +1,75 @@
+from django.urls import path
+from django.views.decorators.cache import cache_page
+
+from . import views
+from blog.views import like_article
+
+app_name = "blog"
+urlpatterns = [
+ path(
+ r'',
+ views.IndexView.as_view(),
+ name='index'),
+ path(
+ r'page/
+ If the link above cannot be opened, please copy this link to your browser.
+ %(article_url)s""") % {'article_url': article_url, 'article_title': comment.article.title}
+ tomail = comment.author.email
+ send_email([tomail], subject, html_content)
+ try:
+ if comment.parent_comment:
+ html_content = _("""Your comment on %(article_title)s
has
+ received a reply.
%(comment_body)s
+
+ go check it out!
+
+ If the link above cannot be opened, please copy this link to your browser.
+ %(article_url)s
+ """) % {'article_url': article_url, 'article_title': comment.article.title,
+ 'comment_body': comment.parent_comment.body}
+ tomail = comment.parent_comment.author.email
+ send_email([tomail], subject, html_content)
+ except Exception as e:
+ logger.error(e)
diff --git a/Django博客项目/comments/views.py b/Django博客项目/comments/views.py
new file mode 100644
index 0000000..8cd9da1
--- /dev/null
+++ b/Django博客项目/comments/views.py
@@ -0,0 +1,85 @@
+# Create your views here.
+from django.core.exceptions import ValidationError
+from django.http import HttpResponseRedirect
+from django.shortcuts import get_object_or_404
+from django.utils.decorators import method_decorator
+from django.views.decorators.csrf import csrf_protect
+from django.views.generic.edit import FormView
+from django.http import JsonResponse
+from django.contrib.auth.decorators import login_required
+
+from accounts.models import BlogUser
+from blog.models import Article
+from .forms import CommentForm
+from .models import Comment
+
+
+class CommentPostView(FormView):
+ form_class = CommentForm
+ template_name = 'blog/article_detail.html'
+
+ @method_decorator(csrf_protect)
+ def dispatch(self, *args, **kwargs):
+ return super(CommentPostView, self).dispatch(*args, **kwargs)
+
+ def get(self, request, *args, **kwargs):
+ article_id = self.kwargs['article_id']
+ article = get_object_or_404(Article, pk=article_id)
+ url = article.get_absolute_url()
+ return HttpResponseRedirect(url + "#comments")
+
+ def form_invalid(self, form):
+ article_id = self.kwargs['article_id']
+ article = get_object_or_404(Article, pk=article_id)
+
+ return self.render_to_response({
+ 'form': form,
+ 'article': article
+ })
+
+ def form_valid(self, form):
+ """提交的数据验证合法后的逻辑"""
+ user = self.request.user
+ author = BlogUser.objects.get(pk=user.pk)
+ article_id = self.kwargs['article_id']
+ article = get_object_or_404(Article, pk=article_id)
+
+ if article.comment_status == 'c' or article.status == 'c':
+ raise ValidationError("该文章评论已关闭.")
+ comment = form.save(False)
+ comment.article = article
+ from djangoblog.utils import get_blog_setting
+ settings = get_blog_setting()
+ if not settings.comment_need_review:
+ comment.is_enable = True
+ comment.author = author
+
+ if form.cleaned_data['parent_comment_id']:
+ parent_comment = Comment.objects.get(
+ pk=form.cleaned_data['parent_comment_id'])
+ comment.parent_comment = parent_comment
+
+ comment.save(True)
+ return HttpResponseRedirect(
+ "%s#div-comment-%d" %
+ (article.get_absolute_url(), comment.pk))
+
+@login_required
+def like_comment(request, comment_id):
+ comment = Comment.objects.get(pk=comment_id)
+ user = request.user
+
+ if user in comment.liked.all():
+ # 取消点赞
+ comment.liked.remove(user)
+ liked = False
+ else:
+ # 添加点赞
+ comment.liked.add(user)
+ liked = True
+
+ return JsonResponse({
+ "status": "ok",
+ "liked": liked,
+ "like_count": comment.liked.count()
+ })
diff --git a/Django博客项目/deploy/docker-compose/docker-compose.es.yml b/Django博客项目/deploy/docker-compose/docker-compose.es.yml
new file mode 100644
index 0000000..83e35ff
--- /dev/null
+++ b/Django博客项目/deploy/docker-compose/docker-compose.es.yml
@@ -0,0 +1,48 @@
+version: '3'
+
+services:
+ es:
+ image: liangliangyy/elasticsearch-analysis-ik:8.6.1
+ container_name: es
+ restart: always
+ environment:
+ - discovery.type=single-node
+ - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
+ ports:
+ - 9200:9200
+ volumes:
+ - ./bin/datas/es/:/usr/share/elasticsearch/data/
+
+ kibana:
+ image: kibana:8.6.1
+ restart: always
+ container_name: kibana
+ ports:
+ - 5601:5601
+ environment:
+ - ELASTICSEARCH_HOSTS=http://es:9200
+
+ djangoblog:
+ build: .
+ restart: always
+ command: bash -c 'sh /code/djangoblog/bin/docker_start.sh'
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./collectedstatic:/code/djangoblog/collectedstatic
+ - ./uploads:/code/djangoblog/uploads
+ environment:
+ - DJANGO_MYSQL_DATABASE=djangoblog
+ - DJANGO_MYSQL_USER=root
+ - DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E
+ - DJANGO_MYSQL_HOST=db
+ - DJANGO_MYSQL_PORT=3306
+ - DJANGO_MEMCACHED_LOCATION=memcached:11211
+ - DJANGO_ELASTICSEARCH_HOST=es:9200
+ links:
+ - db
+ - memcached
+ depends_on:
+ - db
+ container_name: djangoblog
+
diff --git a/Django博客项目/deploy/docker-compose/docker-compose.yml b/Django博客项目/deploy/docker-compose/docker-compose.yml
new file mode 100644
index 0000000..9609af3
--- /dev/null
+++ b/Django博客项目/deploy/docker-compose/docker-compose.yml
@@ -0,0 +1,60 @@
+version: '3'
+
+services:
+ db:
+ image: mysql:latest
+ restart: always
+ environment:
+ - MYSQL_DATABASE=djangoblog
+ - MYSQL_ROOT_PASSWORD=DjAnGoBlOg!2!Q@W#E
+ ports:
+ - 3306:3306
+ volumes:
+ - ./bin/datas/mysql/:/var/lib/mysql
+ depends_on:
+ - redis
+ container_name: db
+
+ djangoblog:
+ build:
+ context: ../../
+ restart: always
+ command: bash -c 'sh /code/djangoblog/bin/docker_start.sh'
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./collectedstatic:/code/djangoblog/collectedstatic
+ - ./logs:/code/djangoblog/logs
+ - ./uploads:/code/djangoblog/uploads
+ environment:
+ - DJANGO_MYSQL_DATABASE=djangoblog
+ - DJANGO_MYSQL_USER=root
+ - DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E
+ - DJANGO_MYSQL_HOST=db
+ - DJANGO_MYSQL_PORT=3306
+ - DJANGO_REDIS_URL=redis:6379
+ links:
+ - db
+ - redis
+ depends_on:
+ - db
+ container_name: djangoblog
+ nginx:
+ restart: always
+ image: nginx:latest
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./bin/nginx.conf:/etc/nginx/nginx.conf
+ - ./collectedstatic:/code/djangoblog/collectedstatic
+ links:
+ - djangoblog:djangoblog
+ container_name: nginx
+
+ redis:
+ restart: always
+ image: redis:latest
+ container_name: redis
+ ports:
+ - "6379:6379"
diff --git a/Django博客项目/deploy/entrypoint.sh b/Django博客项目/deploy/entrypoint.sh
new file mode 100644
index 0000000..2fb6491
--- /dev/null
+++ b/Django博客项目/deploy/entrypoint.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+NAME="djangoblog"
+DJANGODIR=/code/djangoblog
+USER=root
+GROUP=root
+NUM_WORKERS=1
+DJANGO_WSGI_MODULE=djangoblog.wsgi
+
+
+echo "Starting $NAME as `whoami`"
+
+cd $DJANGODIR
+
+export PYTHONPATH=$DJANGODIR:$PYTHONPATH
+
+python manage.py makemigrations && \
+ python manage.py migrate && \
+ python manage.py collectstatic --noinput && \
+ python manage.py compress --force && \
+ python manage.py build_index && \
+ python manage.py compilemessages || exit 1
+
+exec gunicorn ${DJANGO_WSGI_MODULE}:application \
+--name $NAME \
+--workers $NUM_WORKERS \
+--user=$USER --group=$GROUP \
+--bind 0.0.0.0:8000 \
+--log-level=debug \
+--log-file=- \
+--worker-class gevent \
+--threads 4
diff --git a/Django博客项目/deploy/k8s/configmap.yaml b/Django博客项目/deploy/k8s/configmap.yaml
new file mode 100644
index 0000000..835d4ad
--- /dev/null
+++ b/Django博客项目/deploy/k8s/configmap.yaml
@@ -0,0 +1,119 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: web-nginx-config
+ namespace: djangoblog
+data:
+ nginx.conf: |
+ user nginx;
+ worker_processes auto;
+ error_log /var/log/nginx/error.log notice;
+ pid /var/run/nginx.pid;
+
+ events {
+ worker_connections 1024;
+ multi_accept on;
+ use epoll;
+ }
+
+ http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ access_log /var/log/nginx/access.log main;
+
+ sendfile on;
+ keepalive_timeout 65;
+ gzip on;
+ gzip_disable "msie6";
+
+ gzip_vary on;
+ gzip_proxied any;
+ gzip_comp_level 8;
+ gzip_buffers 16 8k;
+ gzip_http_version 1.1;
+ gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
+
+ # Include server configurations
+ include /etc/nginx/conf.d/*.conf;
+ }
+ djangoblog.conf: |
+ server {
+ server_name lylinux.net;
+ root /code/djangoblog/collectedstatic/;
+ listen 80;
+ keepalive_timeout 70;
+ location /static/ {
+ expires max;
+ alias /code/djangoblog/collectedstatic/;
+ }
+
+ location ~* (robots\.txt|ads\.txt|favicon\.ico|favion\.ico|crossdomain\.xml|google93fd32dbd906620a\.html|BingSiteAuth\.xml|baidu_verify_Ijeny6KrmS\.html)$ {
+ root /resource/djangopub;
+ expires 1d;
+ access_log off;
+ error_log off;
+ }
+
+ location / {
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-NginX-Proxy true;
+ proxy_redirect off;
+ if (!-f $request_filename) {
+ proxy_pass http://djangoblog:8000;
+ break;
+ }
+ }
+ }
+ server {
+ server_name www.lylinux.net;
+ listen 80;
+ return 301 https://lylinux.net$request_uri;
+ }
+ resource.lylinux.net.conf: |
+ server {
+ index index.html index.htm;
+ server_name resource.lylinux.net;
+ root /resource/;
+
+ location /djangoblog/ {
+ alias /code/djangoblog/collectedstatic/;
+ }
+
+ access_log off;
+ error_log off;
+ include lylinux/resource.conf;
+ }
+ lylinux.resource.conf: |
+ expires max;
+ access_log off;
+ log_not_found off;
+ add_header Pragma public;
+ add_header Cache-Control "public";
+ add_header "Access-Control-Allow-Origin" "*";
+
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: djangoblog-env
+ namespace: djangoblog
+data:
+ DJANGO_MYSQL_DATABASE: djangoblog
+ DJANGO_MYSQL_USER: db_user
+ DJANGO_MYSQL_PASSWORD: db_password
+ DJANGO_MYSQL_HOST: db_host
+ DJANGO_MYSQL_PORT: db_port
+ DJANGO_REDIS_URL: "redis:6379"
+ DJANGO_DEBUG: "False"
+ MYSQL_ROOT_PASSWORD: db_password
+ MYSQL_DATABASE: djangoblog
+ MYSQL_PASSWORD: db_password
+ DJANGO_SECRET_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+
diff --git a/Django博客项目/deploy/k8s/deployment.yaml b/Django博客项目/deploy/k8s/deployment.yaml
new file mode 100644
index 0000000..414fdcc
--- /dev/null
+++ b/Django博客项目/deploy/k8s/deployment.yaml
@@ -0,0 +1,274 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: djangoblog
+ namespace: djangoblog
+ labels:
+ app: djangoblog
+spec:
+ replicas: 3
+ selector:
+ matchLabels:
+ app: djangoblog
+ template:
+ metadata:
+ labels:
+ app: djangoblog
+ spec:
+ containers:
+ - name: djangoblog
+ image: liangliangyy/djangoblog:latest
+ imagePullPolicy: Always
+ ports:
+ - containerPort: 8000
+ envFrom:
+ - configMapRef:
+ name: djangoblog-env
+ readinessProbe:
+ httpGet:
+ path: /
+ port: 8000
+ initialDelaySeconds: 10
+ periodSeconds: 30
+ livenessProbe:
+ httpGet:
+ path: /
+ port: 8000
+ initialDelaySeconds: 10
+ periodSeconds: 30
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ volumeMounts:
+ - name: djangoblog
+ mountPath: /code/djangoblog/collectedstatic
+ - name: resource
+ mountPath: /resource
+ volumes:
+ - name: djangoblog
+ persistentVolumeClaim:
+ claimName: djangoblog-pvc
+ - name: resource
+ persistentVolumeClaim:
+ claimName: resource-pvc
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: redis
+ namespace: djangoblog
+ labels:
+ app: redis
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: redis
+ template:
+ metadata:
+ labels:
+ app: redis
+ spec:
+ containers:
+ - name: redis
+ image: redis:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 6379
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: 200m
+ memory: 2Gi
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: db
+ namespace: djangoblog
+ labels:
+ app: db
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: db
+ template:
+ metadata:
+ labels:
+ app: db
+ spec:
+ containers:
+ - name: db
+ image: mysql:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 3306
+ envFrom:
+ - configMapRef:
+ name: djangoblog-env
+ readinessProbe:
+ exec:
+ command:
+ - mysqladmin
+ - ping
+ - "-h"
+ - "127.0.0.1"
+ - "-u"
+ - "root"
+ - "-p$MYSQL_ROOT_PASSWORD"
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ livenessProbe:
+ exec:
+ command:
+ - mysqladmin
+ - ping
+ - "-h"
+ - "127.0.0.1"
+ - "-u"
+ - "root"
+ - "-p$MYSQL_ROOT_PASSWORD"
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ volumeMounts:
+ - name: db-data
+ mountPath: /var/lib/mysql
+ volumes:
+ - name: db-data
+ persistentVolumeClaim:
+ claimName: db-pvc
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: nginx
+ namespace: djangoblog
+ labels:
+ app: nginx
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: nginx
+ template:
+ metadata:
+ labels:
+ app: nginx
+ spec:
+ containers:
+ - name: nginx
+ image: nginx:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 80
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ volumeMounts:
+ - name: nginx-config
+ mountPath: /etc/nginx/nginx.conf
+ subPath: nginx.conf
+ - name: nginx-config
+ mountPath: /etc/nginx/conf.d/default.conf
+ subPath: djangoblog.conf
+ - name: nginx-config
+ mountPath: /etc/nginx/conf.d/resource.lylinux.net.conf
+ subPath: resource.lylinux.net.conf
+ - name: nginx-config
+ mountPath: /etc/nginx/lylinux/resource.conf
+ subPath: lylinux.resource.conf
+ - name: djangoblog-pvc
+ mountPath: /code/djangoblog/collectedstatic
+ - name: resource-pvc
+ mountPath: /resource
+ volumes:
+ - name: nginx-config
+ configMap:
+ name: web-nginx-config
+ - name: djangoblog-pvc
+ persistentVolumeClaim:
+ claimName: djangoblog-pvc
+ - name: resource-pvc
+ persistentVolumeClaim:
+ claimName: resource-pvc
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: elasticsearch
+ namespace: djangoblog
+ labels:
+ app: elasticsearch
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: elasticsearch
+ template:
+ metadata:
+ labels:
+ app: elasticsearch
+ spec:
+ containers:
+ - name: elasticsearch
+ image: liangliangyy/elasticsearch-analysis-ik:8.6.1
+ imagePullPolicy: IfNotPresent
+ env:
+ - name: discovery.type
+ value: single-node
+ - name: ES_JAVA_OPTS
+ value: "-Xms256m -Xmx256m"
+ - name: xpack.security.enabled
+ value: "false"
+ - name: xpack.monitoring.templates.enabled
+ value: "false"
+ ports:
+ - containerPort: 9200
+ resources:
+ requests:
+ cpu: 10m
+ memory: 100Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ readinessProbe:
+ httpGet:
+ path: /
+ port: 9200
+ initialDelaySeconds: 15
+ periodSeconds: 30
+ livenessProbe:
+ httpGet:
+ path: /
+ port: 9200
+ initialDelaySeconds: 15
+ periodSeconds: 30
+ volumeMounts:
+ - name: elasticsearch-data
+ mountPath: /usr/share/elasticsearch/data/
+ volumes:
+ - name: elasticsearch-data
+ persistentVolumeClaim:
+ claimName: elasticsearch-pvc
diff --git a/Django博客项目/deploy/k8s/gateway.yaml b/Django博客项目/deploy/k8s/gateway.yaml
new file mode 100644
index 0000000..a8de073
--- /dev/null
+++ b/Django博客项目/deploy/k8s/gateway.yaml
@@ -0,0 +1,17 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: nginx
+ namespace: djangoblog
+spec:
+ ingressClassName: nginx
+ rules:
+ - http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: nginx
+ port:
+ number: 80
\ No newline at end of file
diff --git a/Django博客项目/deploy/k8s/pv.yaml b/Django博客项目/deploy/k8s/pv.yaml
new file mode 100644
index 0000000..874b72f
--- /dev/null
+++ b/Django博客项目/deploy/k8s/pv.yaml
@@ -0,0 +1,94 @@
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-db
+spec:
+ capacity:
+ storage: 10Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/local-storage-db
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-djangoblog
+spec:
+ capacity:
+ storage: 5Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/local-storage-djangoblog
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
+
+
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-resource
+spec:
+ capacity:
+ storage: 5Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/resource/
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
+
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: local-pv-elasticsearch
+spec:
+ capacity:
+ storage: 5Gi
+ volumeMode: Filesystem
+ accessModes:
+ - ReadWriteOnce
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: local-storage
+ local:
+ path: /mnt/local-storage-elasticsearch
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - master
\ No newline at end of file
diff --git a/Django博客项目/deploy/k8s/pvc.yaml b/Django博客项目/deploy/k8s/pvc.yaml
new file mode 100644
index 0000000..ef238c5
--- /dev/null
+++ b/Django博客项目/deploy/k8s/pvc.yaml
@@ -0,0 +1,60 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: db-pvc
+ namespace: djangoblog
+spec:
+ storageClassName: local-storage
+ volumeName: local-pv-db
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 10Gi
+
+
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: djangoblog-pvc
+ namespace: djangoblog
+spec:
+ volumeName: local-pv-djangoblog
+ storageClassName: local-storage
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: resource-pvc
+ namespace: djangoblog
+spec:
+ volumeName: local-pv-resource
+ storageClassName: local-storage
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: elasticsearch-pvc
+ namespace: djangoblog
+spec:
+ volumeName: local-pv-elasticsearch
+ storageClassName: local-storage
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+
\ No newline at end of file
diff --git a/Django博客项目/deploy/k8s/service.yaml b/Django博客项目/deploy/k8s/service.yaml
new file mode 100644
index 0000000..4ef2931
--- /dev/null
+++ b/Django博客项目/deploy/k8s/service.yaml
@@ -0,0 +1,80 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: djangoblog
+ namespace: djangoblog
+ labels:
+ app: djangoblog
+spec:
+ selector:
+ app: djangoblog
+ ports:
+ - protocol: TCP
+ port: 8000
+ targetPort: 8000
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: nginx
+ namespace: djangoblog
+ labels:
+ app: nginx
+spec:
+ selector:
+ app: nginx
+ ports:
+ - protocol: TCP
+ port: 80
+ targetPort: 80
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: redis
+ namespace: djangoblog
+ labels:
+ app: redis
+spec:
+ selector:
+ app: redis
+ ports:
+ - protocol: TCP
+ port: 6379
+ targetPort: 6379
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: db
+ namespace: djangoblog
+ labels:
+ app: db
+spec:
+ selector:
+ app: db
+ ports:
+ - protocol: TCP
+ port: 3306
+ targetPort: 3306
+ type: ClusterIP
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: elasticsearch
+ namespace: djangoblog
+ labels:
+ app: elasticsearch
+spec:
+ selector:
+ app: elasticsearch
+ ports:
+ - protocol: TCP
+ port: 9200
+ targetPort: 9200
+ type: ClusterIP
+
diff --git a/Django博客项目/deploy/k8s/storageclass.yaml b/Django博客项目/deploy/k8s/storageclass.yaml
new file mode 100644
index 0000000..5d5a14c
--- /dev/null
+++ b/Django博客项目/deploy/k8s/storageclass.yaml
@@ -0,0 +1,10 @@
+apiVersion: storage.k8s.io/v1
+kind: StorageClass
+metadata:
+ name: local-storage
+ annotations:
+ storageclass.kubernetes.io/is-default-class: "true"
+provisioner: kubernetes.io/no-provisioner
+volumeBindingMode: Immediate
+
+
diff --git a/Django博客项目/deploy/nginx.conf b/Django博客项目/deploy/nginx.conf
new file mode 100644
index 0000000..32161d8
--- /dev/null
+++ b/Django博客项目/deploy/nginx.conf
@@ -0,0 +1,50 @@
+user nginx;
+worker_processes auto;
+
+error_log /var/log/nginx/error.log notice;
+pid /var/run/nginx.pid;
+
+
+events {
+ worker_connections 1024;
+}
+
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ access_log /var/log/nginx/access.log main;
+
+ sendfile on;
+ #tcp_nopush on;
+
+ keepalive_timeout 65;
+
+ #gzip on;
+
+ server {
+ root /code/djangoblog/collectedstatic/;
+ listen 80;
+ keepalive_timeout 70;
+ location /static/ {
+ expires max;
+ alias /code/djangoblog/collectedstatic/;
+ }
+ location / {
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-NginX-Proxy true;
+ proxy_redirect off;
+ if (!-f $request_filename) {
+ proxy_pass http://djangoblog:8000;
+ break;
+ }
+ }
+ }
+}
diff --git a/Django博客项目/djangoblog/__init__.py b/Django博客项目/djangoblog/__init__.py
new file mode 100644
index 0000000..66d588a
--- /dev/null
+++ b/Django博客项目/djangoblog/__init__.py
@@ -0,0 +1,19 @@
+# 当该包(通常是Django应用)被导入时,打印初始化信息
+# 主要用于开发调试,确认包的加载时机和状态
+print("__init__.py is running")
+
+# 定义Django应用的默认配置类
+# Django在启动时会根据此配置加载应用,包括应用名称、信号注册、初始化逻辑等
+# 'djangoblog.apps.DjangoblogAppConfig'表示配置类的完整路径:
+# - djangoblog:应用所在的包名
+# - apps:存放配置类的模块名
+# - DjangoblogAppConfig:具体的配置类(继承自django.apps.AppConfig)
+default_app_config = 'djangoblog.apps.DjangoblogAppConfig'
+
+# 导入pymysql库(Python连接MySQL的第三方库)
+import pymysql
+
+# 将pymysql伪装成MySQLdb库
+# 背景:早期Django默认使用MySQLdb库连接MySQL,但MySQLdb不支持Python3
+# 作用:让Django在使用`import MySQLdb`时实际导入pymysql,实现Python3环境下的MySQL连接兼容
+pymysql.install_as_MySQLdb()
\ No newline at end of file
diff --git a/Django博客项目/djangoblog/admin_site.py b/Django博客项目/djangoblog/admin_site.py
new file mode 100644
index 0000000..6cc3f4d
--- /dev/null
+++ b/Django博客项目/djangoblog/admin_site.py
@@ -0,0 +1,103 @@
+# 导入Django Admin相关核心组件
+from django.contrib.admin import AdminSite # Django Admin站点基类
+from django.contrib.admin.models import LogEntry # 管理员操作日志模型
+from django.contrib.sites.admin import SiteAdmin # 站点管理的默认Admin配置
+from django.contrib.sites.models import Site # Django内置的站点模型(用于多站点管理)
+
+# 导入各应用的Admin配置和数据模型
+from accounts.admin import * # 账户相关的Admin配置
+from blog.admin import * # 博客核心功能(文章、分类等)的Admin配置
+from blog.models import * # 博客核心数据模型
+from comments.admin import * # 评论功能的Admin配置
+from comments.models import * # 评论相关数据模型
+from djangoblog.logentryadmin import LogEntryAdmin # 自定义的操作日志Admin配置
+from oauth.admin import * # 第三方登录(OAuth)的Admin配置
+from oauth.models import * # OAuth相关数据模型
+from owntracks.admin import * # 位置追踪(OwnTracks)的Admin配置
+from owntracks.models import *# OwnTracks相关数据模型
+from servermanager.admin import * # 服务器管理的Admin配置
+from servermanager.models import * # 服务器管理相关数据模型
+
+
+class DjangoBlogAdminSite(AdminSite):
+ """
+ 自定义的Django Admin站点类,继承自Django内置的AdminSite
+
+ 作用:通过重写基类属性和方法,定制Admin后台的外观和权限控制
+ """
+ # 定制Admin站点的页面头部标题(显示在登录页和后台顶部导航栏)
+ site_header = 'djangoblog administration'
+ # 定制Admin站点的页面标题(显示在浏览器标签页)
+ site_title = 'djangoblog site admin'
+
+ def __init__(self, name='admin'):
+ """
+ 初始化自定义Admin站点
+
+ :param name: 站点名称,默认'admin'(与Django默认Admin站点名称保持一致,避免路由冲突)
+ """
+ super().__init__(name) # 调用父类构造方法初始化
+
+ def has_permission(self, request):
+ """
+ 重写权限检查方法,控制谁能访问Admin后台
+
+ :param request: HTTP请求对象,包含当前用户信息
+ :return: 布尔值,True表示允许访问,False表示拒绝访问
+ 此处限制仅超级用户(is_superuser)可访问,比默认的is_staff更严格
+ """
+ return request.user.is_superuser
+
+ # 以下为注释掉的自定义URL示例(可根据需求启用)
+ # def get_urls(self):
+ # """
+ # 扩展Admin站点的URL路由,添加自定义功能入口
+ # """
+ # # 先获取父类默认的URL配置
+ # urls = super().get_urls()
+ # # 导入URL路径处理和自定义视图
+ # from django.urls import path
+ # from blog.views import refresh_memcache # 示例:缓存刷新视图
+ #
+ # # 定义自定义URL规则,使用admin_view()包装确保权限检查
+ # my_urls = [
+ # path('refresh/', self.admin_view(refresh_memcache), name="refresh"),
+ # ]
+ # # 合并默认URL和自定义URL(自定义URL优先)
+ # return urls + my_urls
+
+
+# 实例化自定义的Admin站点,名称为'admin'(与Django默认Admin站点名称一致,接管后台)
+admin_site = DjangoBlogAdminSite(name='admin')
+
+# 注册数据模型与对应的Admin配置到自定义Admin站点
+# 博客核心内容
+admin_site.register(Article, ArticlelAdmin) # 文章模型 + 其Admin配置
+admin_site.register(Category, CategoryAdmin) # 分类模型 + 其Admin配置
+admin_site.register(Tag, TagAdmin) # 标签模型 + 其Admin配置
+admin_site.register(Links, LinksAdmin) # 友情链接模型 + 其Admin配置
+admin_site.register(SideBar, SideBarAdmin) # 侧边栏模型 + 其Admin配置
+admin_site.register(BlogSettings, BlogSettingsAdmin) # 博客设置模型 + 其Admin配置
+
+# 服务器管理
+admin_site.register(commands, CommandsAdmin) # 命令模型 + 其Admin配置
+admin_site.register(EmailSendLog, EmailSendLogAdmin)# 邮件发送日志模型 + 其Admin配置
+
+# 账户管理
+admin_site.register(BlogUser, BlogUserAdmin) # 自定义用户模型 + 其Admin配置
+
+# 评论管理
+admin_site.register(Comment, CommentAdmin) # 评论模型 + 其Admin配置
+
+# OAuth第三方登录
+admin_site.register(OAuthUser, OAuthUserAdmin) # OAuth用户模型 + 其Admin配置
+admin_site.register(OAuthConfig, OAuthConfigAdmin) # OAuth配置模型 + 其Admin配置
+
+# 位置追踪
+admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # 位置日志模型 + 其Admin配置
+
+# 站点管理(Django内置)
+admin_site.register(Site, SiteAdmin) # 站点模型 + Django默认的SiteAdmin配置
+
+# 操作日志管理
+admin_site.register(LogEntry, LogEntryAdmin) # 管理员操作日志模型 + 自定义Admin配置
\ No newline at end of file
diff --git a/Django博客项目/djangoblog/apps.py b/Django博客项目/djangoblog/apps.py
new file mode 100644
index 0000000..415652f
--- /dev/null
+++ b/Django博客项目/djangoblog/apps.py
@@ -0,0 +1,43 @@
+# 导入Django的AppConfig类,用于配置Django应用的生命周期和元数据
+from django.apps import AppConfig
+
+
+class DjangoblogAppConfig(AppConfig):
+ """
+ Django博客应用(djangoblog)的配置类,用于定义应用的核心配置和生命周期钩子
+
+ 作用:
+ 1. 配置应用的数据库主键生成规则
+ 2. 标识应用的唯一名称
+ 3. 定义应用就绪后的初始化逻辑(如加载插件)
+ """
+ # 配置Django模型默认的自增主键字段类型
+ # BigAutoField是64位整数型自增字段,支持更大的主键范围(适用于数据量较大的博客)
+ # 替代默认的AutoField(32位整数),避免数据量增长后主键溢出问题
+ default_auto_field = 'django.db.models.BigAutoField'
+
+ # 应用的唯一名称,必须与项目中应用的目录名一致(此处为'djangoblog')
+ # Django通过该名称识别应用,用于注册路由、加载模型等核心操作
+ name = 'djangoblog'
+
+ def ready(self):
+ """
+ Django应用就绪后的钩子方法,在应用完全加载并初始化后自动调用
+
+ 执行时机:
+ - 项目启动时(如runserver、celery启动)
+ - 应用注册表(app registry)完成所有应用加载后
+ 注意:该方法可能会被多次调用(如开发环境自动重载时),需确保逻辑可重入
+
+ 核心功能:
+ 调用插件加载函数,在应用就绪后初始化所有已激活的插件
+ """
+ # 调用父类的ready()方法,确保Django默认的应用就绪逻辑正常执行
+ super().ready()
+
+ # 导入并执行插件加载函数:
+ # 1. 从当前应用(djangoblog)的plugin_manage.loader模块中导入load_plugins函数
+ # 2. 调用load_plugins()触发插件动态加载(如导入插件模块、初始化插件实例)
+ # 此处是插件系统与Django应用生命周期的绑定点,确保插件在应用就绪后启动
+ from .plugin_manage.loader import load_plugins
+ load_plugins()
\ No newline at end of file
diff --git a/Django博客项目/djangoblog/blog_signals.py b/Django博客项目/djangoblog/blog_signals.py
new file mode 100644
index 0000000..48eafa3
--- /dev/null
+++ b/Django博客项目/djangoblog/blog_signals.py
@@ -0,0 +1,207 @@
+# 导入必要模块
+import _thread # 用于创建多线程,处理异步任务(如发送邮件)
+import logging # 日志记录模块,记录信号处理过程中的关键信息和错误
+
+import django.dispatch # Django信号系统,用于定义和发送自定义信号
+from django.conf import settings # 导入Django项目配置
+from django.contrib.admin.models import LogEntry # 管理员操作日志模型
+from django.contrib.auth.signals import user_logged_in, user_logged_out # Django内置的用户登录/登出信号
+from django.core.mail import EmailMultiAlternatives # 用于发送HTML格式的邮件
+from django.db.models.signals import post_save # Django模型保存后的信号
+from django.dispatch import receiver # 用于注册信号接收器
+
+# 导入项目内部模块
+from comments.models import Comment # 评论模型
+from comments.utils import send_comment_email # 发送评论通知邮件的工具函数
+from djangoblog.spider_notify import SpiderNotify # 搜索引擎推送工具类
+from djangoblog.utils import ( # 项目工具函数
+ cache, # 缓存操作对象
+ expire_view_cache, # 清除视图缓存
+ delete_sidebar_cache, # 清除侧边栏缓存
+ delete_view_cache # 清除指定视图缓存
+)
+from djangoblog.utils import get_current_site # 获取当前站点信息
+from oauth.models import OAuthUser # OAuth用户模型
+
+# 创建当前模块的日志记录器,用于记录信号处理相关日志
+logger = logging.getLogger(__name__)
+
+# 定义自定义信号:OAuth用户登录信号
+# 触发时机:当用户通过OAuth(第三方登录)成功登录时
+# 参数:['id'] 表示信号会携带OAuthUser的id
+oauth_user_login_signal = django.dispatch.Signal(['id'])
+
+# 定义自定义信号:发送邮件信号
+# 触发时机:需要发送邮件时(解耦邮件发送逻辑,便于多处调用)
+# 参数:['emailto', 'title', 'content'] 分别表示收件人、邮件标题、邮件内容
+send_email_signal = django.dispatch.Signal(['emailto', 'title', 'content'])
+
+
+@receiver(send_email_signal)
+def send_email_signal_handler(sender, **kwargs):
+ """
+ 发送邮件信号的接收器,处理实际的邮件发送逻辑
+
+ :param sender: 信号发送者(通常无需关注)
+ :param kwargs: 信号携带的参数(emailto, title, content)
+ """
+ # 从信号参数中提取邮件信息
+ emailto = kwargs['emailto'] # 收件人列表
+ title = kwargs['title'] # 邮件标题
+ content = kwargs['content'] # 邮件内容(HTML格式)
+
+ # 创建HTML格式邮件对象
+ # from_email:发件人(从项目配置中获取)
+ # to:收件人列表
+ msg = EmailMultiAlternatives(
+ title,
+ content,
+ from_email=settings.DEFAULT_FROM_EMAIL,
+ to=emailto
+ )
+ msg.content_subtype = "html" # 声明邮件内容为HTML格式
+
+ # 记录邮件发送日志(存入数据库)
+ from servermanager.models import EmailSendLog # 导入邮件发送日志模型
+ log = EmailSendLog()
+ log.title = title
+ log.content = content
+ log.emailto = ','.join(emailto) # 将收件人列表转为字符串存储
+
+ try:
+ # 发送邮件,返回成功发送的数量
+ result = msg.send()
+ log.send_result = result > 0 # 若成功发送数量>0,标记为发送成功
+ except Exception as e:
+ # 发送失败时记录错误日志
+ logger.error(f"失败邮箱号: {emailto}, {e}")
+ log.send_result = False # 标记为发送失败
+ log.save() # 保存日志记录到数据库
+
+
+@receiver(oauth_user_login_signal)
+def oauth_user_login_signal_handler(sender, **kwargs):
+ """
+ OAuth用户登录信号的接收器,处理第三方登录后的后续操作
+
+ :param sender: 信号发送者
+ :param kwargs: 信号携带的参数(id:OAuthUser的id)
+ """
+ id = kwargs['id'] # 获取OAuth用户ID
+ oauthuser = OAuthUser.objects.get(id=id) # 查询对应的OAuth用户对象
+
+ # 获取当前站点域名(用于判断头像是否为本站资源)
+ site = get_current_site().domain
+
+ # 若用户头像存在且不是本站资源(如第三方平台的头像URL),则下载并保存到本地
+ if oauthuser.picture and not oauthuser.picture.find(site) >= 0:
+ from djangoblog.utils import save_user_avatar # 导入保存用户头像的工具函数
+ oauthuser.picture = save_user_avatar(oauthuser.picture) # 下载并替换头像URL为本地路径
+ oauthuser.save() # 保存更新后的用户信息
+
+ # 清除侧边栏缓存(用户登录状态可能影响侧边栏展示内容)
+ delete_sidebar_cache()
+
+
+@receiver(post_save)
+def model_post_save_callback(
+ sender,
+ instance,
+ created,
+ raw,
+ using,
+ update_fields,
+ **kwargs):
+ """
+ Django模型保存后(post_save)信号的接收器,处理模型保存后的联动操作
+
+ :param sender: 发送信号的模型类
+ :param instance: 被保存的模型实例
+ :param created: 是否为新创建的记录(True表示新建,False表示更新)
+ :param raw: 是否为原始保存(如通过loaddata导入数据时为True)
+ :param using: 使用的数据库别名
+ :param update_fields: 被更新的字段列表(None表示全量更新)
+ :param kwargs: 其他参数
+ """
+ clearcache = False # 标记是否需要清除缓存
+
+ # 若保存的是管理员操作日志(LogEntry),直接返回(无需处理)
+ if isinstance(instance, LogEntry):
+ return
+
+ # 若模型实例有get_full_url方法(通常表示是可被搜索引擎收录的内容,如文章)
+ if 'get_full_url' in dir(instance):
+ # 判断是否仅更新了浏览量字段(views)
+ is_update_views = update_fields == {'views'}
+
+ # 非测试环境且不是仅更新浏览量时,通知搜索引擎更新内容
+ if not settings.TESTING and not is_update_views:
+ try:
+ notify_url = instance.get_full_url() # 获取内容的完整URL
+ SpiderNotify.baidu_notify([notify_url]) # 向百度搜索引擎推送更新
+ except Exception as ex:
+ logger.error("notify spider", ex) # 推送失败时记录错误日志
+
+ # 若不是仅更新浏览量,标记需要清除缓存
+ if not is_update_views:
+ clearcache = True
+
+ # 若保存的是评论(Comment)实例
+ if isinstance(instance, Comment):
+ # 仅处理已启用的评论(is_enable=True)
+ if instance.is_enable:
+ # 获取评论所属文章的URL路径
+ path = instance.article.get_absolute_url()
+ # 获取当前站点域名
+ site = get_current_site().domain
+
+ # 处理域名中的端口(若有),仅保留主域名
+ if site.find(':') > 0:
+ site = site[0:site.find(':')]
+
+ # 清除文章详情页的视图缓存(评论更新后页面内容需同步更新)
+ expire_view_cache(
+ path,
+ servername=site,
+ serverport=80,
+ key_prefix='blogdetail'
+ )
+
+ # 清除SEO处理器缓存(评论可能影响页面SEO信息)
+ if cache.get('seo_processor'):
+ cache.delete('seo_processor')
+
+ # 清除该文章的评论缓存
+ comment_cache_key = 'article_comments_{id}'.format(id=instance.article.id)
+ cache.delete(comment_cache_key)
+
+ # 清除侧边栏缓存(侧边栏可能包含最新评论等动态内容)
+ delete_sidebar_cache()
+
+ # 清除评论列表视图的缓存
+ delete_view_cache('article_comments', [str(instance.article.pk)])
+
+ # 启动新线程异步发送评论通知邮件(避免阻塞主请求)
+ _thread.start_new_thread(send_comment_email, (instance,))
+
+ # 若标记需要清除缓存,则执行缓存清除
+ if clearcache:
+ cache.clear()
+
+
+@receiver(user_logged_in) # 注册为用户登录信号的接收器
+@receiver(user_logged_out) # 同时注册为用户登出信号的接收器
+def user_auth_callback(sender, request, user, **kwargs):
+ """
+ 用户登录/登出信号的接收器,处理认证状态变化后的操作
+
+ :param sender: 信号发送者
+ :param request: HTTP请求对象
+ :param user: 当前用户对象
+ :param kwargs: 其他参数
+ """
+ # 若用户存在且用户名有效
+ if user and user.username:
+ logger.info(user) # 记录用户登录/登出日志
+ delete_sidebar_cache() # 清除侧边栏缓存(登录状态可能影响侧边栏内容,如显示用户名)
+ # cache.clear() # 注释:可根据需求开启全量缓存清除(通常侧边栏缓存足够)
\ No newline at end of file
diff --git a/Django博客项目/djangoblog/elasticsearch_backend.py b/Django博客项目/djangoblog/elasticsearch_backend.py
new file mode 100644
index 0000000..336f8f9
--- /dev/null
+++ b/Django博客项目/djangoblog/elasticsearch_backend.py
@@ -0,0 +1,316 @@
+# 导入必要模块
+from django.utils.encoding import force_str # 用于将数据转换为字符串(兼容Python 2/3)
+"""from elasticsearch_dsl import Q """ # Elasticsearch DSL的查询构建工具
+from haystack.backends import ( # Haystack搜索框架的基础类
+ BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query
+)
+from haystack.forms import ModelSearchForm # Haystack默认的模型搜索表单
+from haystack.models import SearchResult # Haystack的搜索结果封装类
+from haystack.utils import log as logging # Haystack的日志工具
+
+# 导入项目内部模块
+from blog.documents import ArticleDocument, ArticleDocumentManager # 文章的Elasticsearch文档定义及管理器
+from blog.models import Article # 博客文章模型
+
+# 创建当前模块的日志记录器,用于记录搜索相关日志
+logger = logging.getLogger(__name__)
+
+
+class ElasticSearchBackend(BaseSearchBackend):
+ """
+ 基于Elasticsearch的搜索后端实现,继承自Haystack的BaseSearchBackend
+
+ 作用:实现与Elasticsearch的交互逻辑,包括索引的创建、更新、删除,
+ 以及搜索查询的执行、拼写建议等功能
+ """
+ def __init__(self, connection_alias, **connection_options):
+ """
+ 初始化搜索后端
+
+ :param connection_alias: 数据库连接别名(用于多后端配置)
+ :param connection_options: 连接参数(如主机、端口等)
+ """
+ super(ElasticSearchBackend, self).__init__(connection_alias,** connection_options)
+ self.manager = ArticleDocumentManager() # 初始化文章文档管理器(处理索引操作)
+ self.include_spelling = True # 启用拼写建议功能
+
+ def _get_models(self, iterable):
+ """
+ 将模型实例列表转换为Elasticsearch文档对象
+
+ :param iterable: 模型实例列表(如Article对象列表)
+ :return: 转换后的Elasticsearch文档列表
+ """
+ # 若输入为空,默认使用所有已发布的文章
+ models = iterable if iterable and iterable[0] else Article.objects.all()
+ # 通过管理器将模型转换为文档
+ docs = self.manager.convert_to_doc(models)
+ return docs
+
+ def _create(self, models):
+ """
+ 创建索引并初始化文档(全量重建索引时使用)
+
+ :param models: 模型实例列表
+ """
+ self.manager.create_index() # 创建Elasticsearch索引(若不存在)
+ docs = self._get_models(models) # 转换模型为文档
+ self.manager.rebuild(docs) # 全量重建索引(清空旧数据后插入新数据)
+
+ def _delete(self, models):
+ """
+ 从索引中删除指定模型对应的文档
+
+ :param models: 要删除的模型实例列表
+ :return: 操作是否成功(始终返回True)
+ """
+ for m in models:
+ m.delete() # 调用文档的删除方法
+ return True
+
+ def _rebuild(self, models):
+ """
+ 增量更新索引(适用于部分数据更新)
+
+ :param models: 需要更新的模型实例列表(若为空则更新所有文章)
+ """
+ models = models if models else Article.objects.all() # 处理空输入
+ docs = self._get_models(models) # 转换模型为文档
+ self.manager.update_docs(docs) # 增量更新文档
+
+ def update(self, index, iterable, commit=True):
+ """
+ Haystack标准接口:更新索引(用于实时同步模型变更)
+
+ :param index: 索引名称(当前实现未使用,由管理器处理)
+ :param iterable: 模型实例列表
+ :param commit: 是否立即提交(当前实现未使用)
+ """
+ models = self._get_models(iterable) # 转换模型为文档
+ self.manager.update_docs(models) # 执行更新
+
+ def remove(self, obj_or_string):
+ """
+ Haystack标准接口:从索引中移除指定对象
+
+ :param obj_or_string: 模型实例或ID字符串
+ """
+ models = self._get_models([obj_or_string]) # 转换为文档
+ self._delete(models) # 执行删除
+
+ def clear(self, models=None, commit=True):
+ """
+ Haystack标准接口:清空索引(或指定模型的索引)
+
+ :param models: 可选,指定要清空的模型类(当前实现忽略,清空所有)
+ :param commit: 是否立即提交(当前实现未使用)
+ """
+ self.remove(None) # 调用删除方法清空所有
+
+ @staticmethod
+ def get_suggestion(query: str) -> str:
+ """
+ 获取搜索建议词(基于Elasticsearch的拼写纠错功能)
+
+ :param query: 用户输入的搜索词
+ :return: 建议的修正词(多个词用空格拼接)
+ """
+ # 构建搜索查询:匹配文章内容,并启用拼写建议
+ search = ArticleDocument.search() \
+ .query("match", body=query) \
+ .suggest('suggest_search', query, term={'field': 'body'}) \
+ .execute()
+
+ keywords = []
+ # 提取建议结果
+ for suggest in search.suggest.suggest_search:
+ if suggest["options"]: # 若有建议词,取第一个
+ keywords.append(suggest["options"][0]["text"])
+ else: # 若无建议,保留原词
+ keywords.append(suggest["text"])
+
+ return ' '.join(keywords) # 拼接建议词为字符串
+
+ @log_query # Haystack装饰器:记录查询日志
+ def search(self, query_string, **kwargs):
+ """
+ 执行搜索查询的核心方法
+
+ :param query_string: 用户输入的搜索字符串
+ :param kwargs: 额外参数(如分页偏移量start_offset/end_offset)
+ :return: 搜索结果字典(包含结果列表、命中数、拼写建议等)
+ """
+ logger.info('search query_string:' + query_string) # 记录搜索词
+
+ # 获取分页参数(用于限制返回结果范围)
+ start_offset = kwargs.get('start_offset', 0)
+ end_offset = kwargs.get('end_offset', 10) # 默认返回前10条
+
+ # 判断是否需要启用拼写建议(通过is_suggest参数控制)
+ if getattr(self, "is_suggest", None):
+ suggestion = self.get_suggestion(query_string) # 获取建议词
+ else:
+ suggestion = query_string # 不启用建议,使用原搜索词
+
+ # 构建Elasticsearch查询条件:
+ # 1. 布尔查询(should):匹配标题或内容,至少满足70%的条件
+ q = Q('bool',
+ should=[Q('match', body=suggestion), Q('match', title=suggestion)],
+ minimum_should_match="70%")
+
+ # 构建完整搜索:
+ # - 应用上述查询条件
+ # - 过滤:仅包含已发布(status='p')的文章(type='a')
+ # - 不返回原始文档内容(source=False)
+ # - 应用分页偏移
+ search = ArticleDocument.search() \
+ .query('bool', filter=[q]) \
+ .filter('term', status='p') \
+ .filter('term', type='a') \
+ .source(False)[start_offset: end_offset]
+
+ # 执行搜索并处理结果
+ results = search.execute()
+ hits = results['hits'].total # 总命中数
+ raw_results = []
+
+ # 遍历搜索结果,转换为Haystack的SearchResult格式
+ for raw_result in results['hits']['hits']:
+ app_label = 'blog' # 应用标签(固定为博客应用)
+ model_name = 'Article' # 模型名称(固定为文章模型)
+ additional_fields = {} # 额外字段(当前未使用)
+
+ # 创建SearchResult实例(适配Haystack的结果格式)
+ result = SearchResult(
+ app_label,
+ model_name,
+ raw_result['_id'], # 文档ID(对应文章ID)
+ raw_result['_score'], # 匹配得分
+ **additional_fields
+ )
+ raw_results.append(result)
+
+ # 构建返回结果字典
+ facets = {} # 分面搜索结果(当前未实现)
+ # 若建议词与原词不同,则返回建议词;否则为None
+ spelling_suggestion = None if query_string == suggestion else suggestion
+
+ return {
+ 'results': raw_results, # 搜索结果列表(SearchResult实例)
+ 'hits': hits, # 总命中数
+ 'facets': facets, # 分面数据
+ 'spelling_suggestion': spelling_suggestion, # 拼写建议
+ }
+
+
+class ElasticSearchQuery(BaseSearchQuery):
+ """
+ 自定义搜索查询类,继承自Haystack的BaseSearchQuery
+
+ 作用:处理搜索查询的构建逻辑,包括查询字符串清洗、参数转换等
+ """
+ def _convert_datetime(self, date):
+ """
+ 转换日期时间为Elasticsearch兼容的字符串格式
+
+ :param date: 日期时间对象
+ :return: 格式化的字符串(如'20231018123000')
+ """
+ if hasattr(date, 'hour'): # 若包含时间信息(datetime对象)
+ return force_str(date.strftime('%Y%m%d%H%M%S'))
+ else: # 仅日期(date对象),时间部分设为00:00:00
+ return force_str(date.strftime('%Y%m%d000000'))
+
+ def clean(self, query_fragment):
+ """
+ 清洗用户输入的查询片段,处理保留字和特殊字符
+
+ :param query_fragment: 用户输入的查询字符串片段
+ :return: 清洗后的查询字符串
+ """
+ words = query_fragment.split() # 按空格拆分词语
+ cleaned_words = []
+
+ for word in words:
+ # 处理Elasticsearch保留字(转为小写)
+ if word in self.backend.RESERVED_WORDS:
+ word = word.replace(word, word.lower())
+
+ # 处理特殊字符(若包含特殊字符,用单引号包裹)
+ for char in self.backend.RESERVED_CHARACTERS:
+ if char in word:
+ word = "'%s'" % word
+ break
+
+ cleaned_words.append(word)
+
+ return ' '.join(cleaned_words) # 拼接清洗后的词语
+
+ def build_query_fragment(self, field, filter_type, value):
+ """
+ 构建查询片段(适配Elasticsearch的查询语法)
+
+ :param field: 搜索字段
+ :param filter_type: 过滤类型
+ :param value: 查询值
+ :return: 构建的查询字符串
+ """
+ return value.query_string # 直接使用查询字符串(由value提供)
+
+ def get_count(self):
+ """
+ 获取搜索结果总数
+
+ :return: 结果数量
+ """
+ results = self.get_results()
+ return len(results) if results else 0
+
+ def get_spelling_suggestion(self, preferred_query=None):
+ """
+ 获取拼写建议(适配Haystack接口)
+
+ :param preferred_query: 优先使用的查询(未使用)
+ :return: 拼写建议词
+ """
+ return self._spelling_suggestion
+
+ def build_params(self, spelling_query=None):
+ """
+ 构建查询参数(适配Haystack接口)
+
+ :param spelling_query: 拼写建议查询(未使用)
+ :return: 构建的参数字典
+ """
+ kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query)
+ return kwargs
+
+
+class ElasticSearchModelSearchForm(ModelSearchForm):
+ """
+ 自定义搜索表单,继承自Haystack的ModelSearchForm
+
+ 作用:扩展默认搜索表单,支持控制是否启用拼写建议
+ """
+ def search(self):
+ """
+ 执行搜索,根据表单参数控制拼写建议
+
+ :return: 搜索结果集(SearchQuerySet)
+ """
+ # 通过表单数据中的"is_suggest"参数控制是否启用拼写建议
+ # 若"is_suggest"为"no",则禁用建议
+ self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no"
+ # 调用父类方法执行搜索
+ sqs = super().search()
+ return sqs
+
+
+class ElasticSearchEngine(BaseEngine):
+ """
+ Elasticsearch搜索引擎入口类,继承自Haystack的BaseEngine
+
+ 作用:绑定后端实现和查询类,作为Haystack的引擎配置入口
+ """
+ backend = ElasticSearchBackend # 指定使用的搜索后端
+ query = ElasticSearchQuery # 指定使用的查询类
\ No newline at end of file
diff --git a/Django博客项目/djangoblog/feeds.py b/Django博客项目/djangoblog/feeds.py
new file mode 100644
index 0000000..8c4e851
--- /dev/null
+++ b/Django博客项目/djangoblog/feeds.py
@@ -0,0 +1,40 @@
+from django.contrib.auth import get_user_model
+from django.contrib.syndication.views import Feed
+from django.utils import timezone
+from django.utils.feedgenerator import Rss201rev2Feed
+
+from blog.models import Article
+from djangoblog.utils import CommonMarkdown
+
+
+class DjangoBlogFeed(Feed):
+ feed_type = Rss201rev2Feed
+
+ description = '大巧无工,重剑无锋.'
+ title = "且听风吟 大巧无工,重剑无锋. "
+ link = "/feed/"
+
+ def author_name(self):
+ return get_user_model().objects.first().nickname
+
+ def author_link(self):
+ return get_user_model().objects.first().get_absolute_url()
+
+ def items(self):
+ return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5]
+
+ def item_title(self, item):
+ return item.title
+
+ def item_description(self, item):
+ return CommonMarkdown.get_markdown(item.body)
+
+ def feed_copyright(self):
+ now = timezone.now()
+ return "Copyright© {year} 且听风吟".format(year=now.year)
+
+ def item_link(self, item):
+ return item.get_absolute_url()
+
+ def item_guid(self, item):
+ return
diff --git a/Django博客项目/djangoblog/logentryadmin.py b/Django博客项目/djangoblog/logentryadmin.py
new file mode 100644
index 0000000..2f6a535
--- /dev/null
+++ b/Django博客项目/djangoblog/logentryadmin.py
@@ -0,0 +1,91 @@
+from django.contrib import admin
+from django.contrib.admin.models import DELETION
+from django.contrib.contenttypes.models import ContentType
+from django.urls import reverse, NoReverseMatch
+from django.utils.encoding import force_str
+from django.utils.html import escape
+from django.utils.safestring import mark_safe
+from django.utils.translation import gettext_lazy as _
+
+
+class LogEntryAdmin(admin.ModelAdmin):
+ list_filter = [
+ 'content_type'
+ ]
+
+ search_fields = [
+ 'object_repr',
+ 'change_message'
+ ]
+
+ list_display_links = [
+ 'action_time',
+ 'get_change_message',
+ ]
+ list_display = [
+ 'action_time',
+ 'user_link',
+ 'content_type',
+ 'object_link',
+ 'get_change_message',
+ ]
+
+ def has_add_permission(self, request):
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ return (
+ request.user.is_superuser or
+ request.user.has_perm('admin.change_logentry')
+ ) and request.method != 'POST'
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+ def object_link(self, obj):
+ object_link = escape(obj.object_repr)
+ content_type = obj.content_type
+
+ if obj.action_flag != DELETION and content_type is not None:
+ # try returning an actual link instead of object repr string
+ try:
+ url = reverse(
+ 'admin:{}_{}_change'.format(content_type.app_label,
+ content_type.model),
+ args=[obj.object_id]
+ )
+ object_link = '{}'.format(url, object_link)
+ except NoReverseMatch:
+ pass
+ return mark_safe(object_link)
+
+ object_link.admin_order_field = 'object_repr'
+ object_link.short_description = _('object')
+
+ def user_link(self, obj):
+ content_type = ContentType.objects.get_for_model(type(obj.user))
+ user_link = escape(force_str(obj.user))
+ try:
+ # try returning an actual link instead of object repr string
+ url = reverse(
+ 'admin:{}_{}_change'.format(content_type.app_label,
+ content_type.model),
+ args=[obj.user.pk]
+ )
+ user_link = '{}'.format(url, user_link)
+ except NoReverseMatch:
+ pass
+ return mark_safe(user_link)
+
+ user_link.admin_order_field = 'user'
+ user_link.short_description = _('user')
+
+ def get_queryset(self, request):
+ queryset = super(LogEntryAdmin, self).get_queryset(request)
+ return queryset.prefetch_related('content_type')
+
+ def get_actions(self, request):
+ actions = super(LogEntryAdmin, self).get_actions(request)
+ if 'delete_selected' in actions:
+ del actions['delete_selected']
+ return actions
diff --git a/Django博客项目/djangoblog/plugin_manage/base_plugin.py b/Django博客项目/djangoblog/plugin_manage/base_plugin.py
new file mode 100644
index 0000000..f7f22c2
--- /dev/null
+++ b/Django博客项目/djangoblog/plugin_manage/base_plugin.py
@@ -0,0 +1,95 @@
+# 导入Python标准库中的logging模块,用于实现插件运行过程中的日志记录功能
+# 日志可以帮助开发者追踪插件的运行状态、排查错误等
+import logging
+
+# 创建一个日志记录器实例,其名称与当前模块(__name__)绑定
+# 这样可以确保日志信息能够准确关联到插件模块,便于日志的分类和筛选
+logger = logging.getLogger(__name__)
+
+
+class BasePlugin:
+ """
+ 插件系统的基类(抽象基类角色),所有自定义插件都必须继承此类
+
+ 该类的核心作用是:
+ 1. 定义插件必须包含的元数据规范(名称、描述、版本)
+ 2. 提供插件初始化和钩子注册的统一流程
+ 3. 封装获取插件信息的通用方法
+ 子类通过继承此类并实现特定方法,即可快速接入插件系统
+ """
+
+ # 插件元数据字段(子类必须显式赋值,否则初始化会失败)
+ PLUGIN_NAME = None # 插件的唯一标识名称,用于在系统中区分不同插件,例如"DataCleanPlugin"
+ PLUGIN_DESCRIPTION = None # 插件功能的详细描述,说明插件的作用和使用场景,例如"用于清洗CSV格式的原始数据"
+ PLUGIN_VERSION = None # 插件的版本号,遵循语义化版本规范(如"1.0.0"),用于版本管理和兼容性判断
+
+ def __init__(self):
+ """
+ 插件实例的构造方法,负责插件的初始化流程控制
+
+ 执行逻辑:
+ 1. 首先验证子类是否完整实现了元数据(名称、描述、版本)
+ 2. 若元数据不完整,抛出ValueError异常阻止实例化
+ 3. 元数据验证通过后,依次调用初始化方法和钩子注册方法
+ 确保插件在使用前完成必要的准备工作
+ """
+ # 使用all()函数检查三个元数据字段是否都有值(非None)
+ # 若存在任何一个未定义的字段,触发异常
+ if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]):
+ raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.")
+
+ # 调用插件初始化方法,执行子类自定义的初始化逻辑
+ self.init_plugin()
+ # 调用钩子注册方法,让子类注册需要监听的系统事件
+ self.register_hooks()
+
+ def init_plugin(self):
+ """
+ 插件初始化的具体实现方法,用于执行插件启动前的准备工作
+
+ 基类默认实现:
+ - 输出一条INFO级别的日志,提示插件已完成初始化
+ 子类可重写此方法实现特定逻辑,例如:
+ - 加载配置文件
+ - 建立数据库连接
+ - 初始化缓存数据结构等
+ 注意:重写时若需要保留默认日志,可通过super().init_plugin()调用父类方法
+ """
+ # 记录插件初始化成功的日志,包含插件名称便于追踪
+ logger.info(f'{self.PLUGIN_NAME} initialized.')
+
+ def register_hooks(self):
+ """
+ 插件钩子注册方法,用于将插件功能与系统事件关联
+
+ 钩子(Hook)机制说明:
+ 系统在运行过程中会触发一系列事件(如"数据处理前"、"任务完成后"等),
+ 插件通过注册钩子,可以在特定事件发生时自动执行对应逻辑。
+
+ 基类默认实现:空方法(pass)
+ 子类需根据自身功能重写此方法,例如:
+ - 调用系统提供的register_hook()方法注册事件回调
+ - 定义需要监听的事件类型和对应的处理函数
+ """
+ pass
+
+ def get_plugin_info(self):
+ """
+ 获取插件元数据的统一接口,用于系统展示或管理插件信息
+
+ 返回值说明:
+ - 字典类型,包含三个键值对
+ - 'name':对应PLUGIN_NAME
+ - 'description':对应PLUGIN_DESCRIPTION
+ - 'version':对应PLUGIN_VERSION
+
+ 应用场景:
+ - 插件管理界面展示插件列表
+ - 系统启动时收集所有插件信息进行校验
+ - 插件间依赖关系判断时获取版本信息
+ """
+ return {
+ 'name': self.PLUGIN_NAME,
+ 'description': self.PLUGIN_DESCRIPTION,
+ 'version': self.PLUGIN_VERSION
+ }
\ No newline at end of file
diff --git a/Django博客项目/djangoblog/plugin_manage/hook_constants.py b/Django博客项目/djangoblog/plugin_manage/hook_constants.py
new file mode 100644
index 0000000..be2ca8a
--- /dev/null
+++ b/Django博客项目/djangoblog/plugin_manage/hook_constants.py
@@ -0,0 +1,30 @@
+# 文章相关系统事件常量定义
+# 用途:统一管理插件系统中与文章操作相关的事件名称,避免硬编码导致的不一致问题
+# 所有事件名称均采用大写蛇形命名法(UPPER_SNAKE_CASE),符合Python常量命名规范
+
+# 事件:文章详情页加载完成
+# 触发时机:当用户访问某篇文章的详情页,页面内容加载完成后触发
+# 应用场景:插件可监听此事件,执行详情页相关的自定义逻辑(如添加页面统计代码、注入额外内容等)
+ARTICLE_DETAIL_LOAD = 'article_detail_load'
+
+# 事件:文章创建完成
+# 触发时机:当一篇新文章在系统中创建成功(如数据库写入完成、状态设为"已发布"或"草稿")后触发
+# 应用场景:插件可监听此事件,执行创建后的后续操作(如自动生成文章摘要、同步到外部平台、发送通知等)
+ARTICLE_CREATE = 'article_create'
+
+# 事件:文章更新完成
+# 触发时机:当已存在的文章内容、属性(如标题、分类、状态)修改并保存成功后触发
+# 应用场景:插件可监听此事件,执行更新后的联动操作(如更新文章索引、记录修改日志、重新生成相关统计数据等)
+ARTICLE_UPDATE = 'article_update'
+
+# 事件:文章删除完成
+# 触发时机:当一篇文章从系统中删除(物理删除或逻辑删除,如标记为"已删除"状态)后触发
+# 应用场景:插件可监听此事件,执行删除后的清理操作(如删除关联的评论、移除相关缓存、同步删除外部存储的附件等)
+ARTICLE_DELETE = 'article_delete'
+
+
+# 文章内容钩子名称常量
+# 用途:定义专门用于拦截、修改文章内容的钩子标识,与上述"操作事件"区分(事件侧重流程节点,钩子侧重内容处理)
+# 命名格式与事件常量保持一致,确保插件系统中钩子名称的唯一性和可识别性
+# 应用场景:插件可注册此钩子,在文章内容渲染前(如详情页展示、导出为PDF)对内容进行自定义处理(如过滤敏感词、替换关键词、添加水印等)
+ARTICLE_CONTENT_HOOK_NAME = "the_content"
\ No newline at end of file
diff --git a/Django博客项目/djangoblog/plugin_manage/hooks.py b/Django博客项目/djangoblog/plugin_manage/hooks.py
new file mode 100644
index 0000000..62ad78f
--- /dev/null
+++ b/Django博客项目/djangoblog/plugin_manage/hooks.py
@@ -0,0 +1,92 @@
+# 导入logging模块,用于记录钩子系统运行过程中的日志(如注册信息、错误信息等)
+import logging
+
+# 创建当前模块的日志记录器,日志名称与模块绑定,便于区分不同组件的日志输出
+logger = logging.getLogger(__name__)
+
+# 全局钩子存储字典,用于保存所有注册的钩子及其对应的回调函数
+# 键:钩子名称(字符串,如"article_create")
+# 值:回调函数列表(所有注册到该钩子的可调用对象将按注册顺序存储)
+_hooks = {}
+
+
+def register(hook_name: str, callback: callable):
+ """
+ 注册一个钩子回调函数,将其添加到指定钩子名称对应的回调列表中
+
+ 核心作用:建立"钩子名称"与"处理逻辑(回调函数)"的映射关系,
+ 使后续触发钩子时能自动执行所有注册的回调
+
+ :param hook_name: 钩子名称(字符串),需与触发时使用的名称一致(如ARTICLE_CREATE)
+ :param callback: 可调用对象(函数、方法等),当钩子被触发时会执行此对象
+ 回调函数的参数需与钩子触发时传递的参数匹配
+ """
+ # 若钩子名称尚未在全局字典中,初始化一个空列表用于存储回调
+ if hook_name not in _hooks:
+ _hooks[hook_name] = []
+ # 将回调函数添加到对应钩子的列表中(按注册顺序存储,触发时也按此顺序执行)
+ _hooks[hook_name].append(callback)
+ # 记录DEBUG级日志,说明钩子注册成功(包含钩子名称和回调函数名,便于调试)
+ logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'")
+
+
+def run_action(hook_name: str, *args, **kwargs):
+ """
+ 执行指定名称的"动作钩子(Action Hook)",按注册顺序调用所有关联的回调函数
+
+ 动作钩子特性:用于触发一系列操作,不关注返回值,仅执行回调逻辑
+ 典型场景:文章创建后发送通知、记录日志等(执行动作但无需修改数据)
+
+ :param hook_name: 要触发的钩子名称(需已被注册过)
+ :param *args: 传递给回调函数的位置参数(可变参数,根据钩子场景定义)
+ :param **kwargs: 传递给回调函数的关键字参数(可变参数,根据钩子场景定义)
+ """
+ # 检查该钩子是否有已注册的回调函数
+ if hook_name in _hooks:
+ # 记录DEBUG级日志,说明开始执行该动作钩子
+ logger.debug(f"Running action hook '{hook_name}'")
+ # 按注册顺序遍历所有回调函数并执行
+ for callback in _hooks[hook_name]:
+ try:
+ # 传递位置参数和关键字参数给回调函数
+ callback(*args, **kwargs)
+ except Exception as e:
+ # 若回调执行出错,记录ERROR级日志(包含详细异常信息)
+ # exc_info=True 会在日志中附带堆栈跟踪,便于排查错误
+ logger.error(
+ f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}",
+ exc_info=True
+ )
+
+
+def apply_filters(hook_name: str, value, *args, **kwargs):
+ """
+ 执行指定名称的"过滤钩子(Filter Hook)",通过回调函数链式处理初始值并返回最终结果
+
+ 过滤钩子特性:用于对数据进行加工处理,每个回调函数接收上一个函数的输出作为输入,
+ 最终返回经过所有回调处理后的值
+ 典型场景:文章内容过滤敏感词、格式化文本等(修改数据并返回新值)
+
+ :param hook_name: 要触发的钩子名称(需已被注册过)
+ :param value: 初始值(需要被过滤/处理的数据,如文章内容字符串)
+ :param *args: 传递给回调函数的额外位置参数
+ :param **kwargs: 传递给回调函数的额外关键字参数
+ :return: 经过所有回调函数处理后的最终值
+ """
+ # 检查该钩子是否有已注册的回调函数
+ if hook_name in _hooks:
+ # 记录DEBUG级日志,说明开始执行该过滤钩子
+ logger.debug(f"Applying filter hook '{hook_name}'")
+ # 按注册顺序遍历所有回调函数,链式处理初始值
+ for callback in _hooks[hook_name]:
+ try:
+ # 调用回调函数,将当前值和额外参数传入,更新值为回调返回的结果
+ value = callback(value, *args, **kwargs)
+ except Exception as e:
+ # 若回调执行出错,记录ERROR级日志(包含详细异常信息)
+ logger.error(
+ f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}",
+ exc_info=True
+ )
+ # 返回经过所有过滤处理后的最终值
+ return value
\ No newline at end of file
diff --git a/Django博客项目/djangoblog/plugin_manage/loader.py b/Django博客项目/djangoblog/plugin_manage/loader.py
new file mode 100644
index 0000000..1e5157f
--- /dev/null
+++ b/Django博客项目/djangoblog/plugin_manage/loader.py
@@ -0,0 +1,51 @@
+# 导入必要的模块
+# os: 用于处理文件路径和目录操作
+# logging: 用于记录插件加载过程中的日志信息(成功/失败状态)
+# django.conf.settings: 用于获取Django项目的配置信息(如插件目录、激活的插件列表)
+import os
+import logging
+from django.conf import settings
+
+# 创建当前模块的日志记录器,日志名称与模块绑定,便于追踪插件加载相关的日志
+logger = logging.getLogger(__name__)
+
+
+def load_plugins():
+ """
+ 动态加载并初始化位于'plugins'目录中的插件
+
+ 功能说明:
+ - 从Django配置中读取激活的插件列表(settings.ACTIVE_PLUGINS)
+ - 检查每个插件的目录结构是否合法(是否存在plugin.py文件)
+ - 动态导入插件的核心模块(plugin.py),触发插件的初始化流程
+ - 通过日志记录每个插件的加载结果(成功/失败及原因)
+
+ 调用时机:
+ 该函数应在Django应用注册表(app registry)准备就绪后调用,
+ 通常在项目启动时(如通过AppConfig.ready()方法触发),确保Django环境已初始化完成。
+ """
+ # 遍历配置中激活的所有插件名称(settings.ACTIVE_PLUGINS是一个插件名称列表)
+ for plugin_name in settings.ACTIVE_PLUGINS:
+ # 构建插件的完整目录路径:
+ # settings.PLUGINS_DIR是项目中存放所有插件的根目录(如"project_root/plugins")
+ # 拼接根目录与当前插件名称,得到具体插件的目录路径(如"project_root/plugins/my_plugin")
+ plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name)
+
+ # 验证插件目录的合法性:
+ # 1. 必须是一个存在的目录(os.path.isdir(plugin_path))
+ # 2. 目录中必须包含核心文件plugin.py(插件的入口模块)
+ if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')):
+ try:
+ # 动态导入插件的plugin.py模块:
+ # 导入路径格式为"plugins.{插件名称}.plugin"(基于Python包结构)
+ # 导入后会自动执行plugin.py中的顶级代码(如插件类的定义和注册逻辑)
+ __import__(f'plugins.{plugin_name}.plugin')
+
+ # 记录INFO级日志,提示插件加载成功
+ logger.info(f"Successfully loaded plugin: {plugin_name}")
+
+ # 捕获导入过程中的异常(如模块不存在、语法错误、依赖缺失等)
+ except ImportError as e:
+ # 记录ERROR级日志,提示插件导入失败,并附带异常信息
+ # exc_info=e 会将异常堆栈信息写入日志,便于排查问题
+ logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e)
\ No newline at end of file
diff --git a/Django博客项目/djangoblog/settings.py b/Django博客项目/djangoblog/settings.py
new file mode 100644
index 0000000..d818556
--- /dev/null
+++ b/Django博客项目/djangoblog/settings.py
@@ -0,0 +1,347 @@
+"""
+Django settings for djangoblog project.
+
+Generated by 'django-admin startproject' using Django 1.10.2.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.10/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/1.10/ref/settings/
+"""
+import os
+import sys
+from pathlib import Path
+
+from django.utils.translation import gettext_lazy as _
+
+
+def env_to_bool(env, default):
+ str_val = os.environ.get(env)
+ return default if str_val is None else str_val == 'True'
+
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = os.environ.get(
+ 'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6'
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = env_to_bool('DJANGO_DEBUG', True)
+# DEBUG = False
+TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test'
+
+# ALLOWED_HOSTS = []
+ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com']
+# django 4.0新增配置
+CSRF_TRUSTED_ORIGINS = ['http://example.com']
+# Application definition
+
+
+INSTALLED_APPS = [
+ # 'django.contrib.admin',
+ 'django.contrib.admin.apps.SimpleAdminConfig',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ 'django.contrib.sites',
+ 'django.contrib.sitemaps',
+ 'mdeditor',
+ 'haystack',
+ 'blog',
+ 'accounts',
+ 'comments',
+ 'oauth',
+ 'servermanager',
+ 'owntracks',
+ 'compressor',
+ 'djangoblog'
+]
+
+MIDDLEWARE = [
+
+ 'django.middleware.security.SecurityMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.locale.LocaleMiddleware',
+ 'django.middleware.gzip.GZipMiddleware',
+ # 'django.middleware.cache.UpdateCacheMiddleware',
+ 'django.middleware.common.CommonMiddleware',
+ # 'django.middleware.cache.FetchFromCacheMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+ 'django.middleware.http.ConditionalGetMiddleware',
+ 'blog.middleware.OnlineMiddleware'
+]
+
+ROOT_URLCONF = 'djangoblog.urls'
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [os.path.join(BASE_DIR, 'templates')],
+ 'APP_DIRS': True,
+ 'OPTIONS': {
+ 'context_processors': [
+ 'django.template.context_processors.debug',
+ 'django.template.context_processors.request',
+ 'django.contrib.auth.context_processors.auth',
+ 'django.contrib.messages.context_processors.messages',
+ 'blog.context_processors.seo_processor'
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = 'djangoblog.wsgi.application'
+
+# Database
+# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
+
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.mysql',
+ 'NAME': os.environ.get('DJANGO_MYSQL_DATABASE') or 'djangoblog',
+ 'USER': os.environ.get('DJANGO_MYSQL_USER') or 'root',
+ 'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or '050322yyd*',
+ 'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1',
+ 'PORT': int(
+ os.environ.get('DJANGO_MYSQL_PORT') or 3306),
+ 'OPTIONS': {
+ 'charset': 'utf8mb4'},
+ }}
+
+# Password validation
+# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+]
+
+LANGUAGES = (
+ ('en', _('English')),
+ ('zh-hans', _('Simplified Chinese')),
+ ('zh-hant', _('Traditional Chinese')),
+)
+LOCALE_PATHS = (
+ os.path.join(BASE_DIR, 'locale'),
+)
+
+LANGUAGE_CODE = 'zh-hans'
+
+TIME_ZONE = 'Asia/Shanghai'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = False
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/1.10/howto/static-files/
+
+
+HAYSTACK_CONNECTIONS = {
+ 'default': {
+ 'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine',
+ 'PATH': os.path.join(BASE_DIR, 'whoosh_index'),
+ 'ANALYZER': 'utils.jieba_analyzer.jieba_analyzer',
+ },
+}
+# Automatically update searching index
+HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
+# Allow user login with username and password
+AUTHENTICATION_BACKENDS = [
+ 'accounts.user_login_backend.EmailOrUsernameModelBackend']
+
+STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic')
+
+STATIC_URL = '/static/'
+STATICFILES_DIRS = [
+ os.path.join(BASE_DIR, 'static'),
+]
+
+
+AUTH_USER_MODEL = 'accounts.BlogUser'
+LOGIN_URL = '/login/'
+
+TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
+DATE_TIME_FORMAT = '%Y-%m-%d'
+
+# bootstrap color styles
+BOOTSTRAP_COLOR_TYPES = [
+ 'default', 'primary', 'success', 'info', 'warning', 'danger'
+]
+
+# paginate
+PAGINATE_BY = 10
+# http cache timeout
+CACHE_CONTROL_MAX_AGE = 2592000
+# cache setting
+CACHES = {
+ 'default': {
+ 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+ 'TIMEOUT': 10800,
+ 'LOCATION': 'unique-snowflake',
+ }
+}
+# 使用redis作为缓存
+if os.environ.get("DJANGO_REDIS_URL"):
+ CACHES = {
+ 'default': {
+ 'BACKEND': 'django.core.cache.backends.redis.RedisCache',
+ 'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}',
+ }
+ }
+
+SITE_ID = 1
+BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \
+ or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn'
+
+# Email:
+EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
+EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False)
+EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True)
+EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com'
+EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465)
+EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER')
+EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD')
+DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
+SERVER_EMAIL = EMAIL_HOST_USER
+# Setting debug=false did NOT handle except email notifications
+ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')]
+# WX ADMIN password(Two times md5)
+WXADMIN = os.environ.get(
+ 'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
+
+LOG_PATH = os.path.join(BASE_DIR, 'logs')
+if not os.path.exists(LOG_PATH):
+ os.makedirs(LOG_PATH, exist_ok=True)
+
+LOGGING = {
+ 'version': 1,
+ 'disable_existing_loggers': False,
+ 'root': {
+ 'level': 'INFO',
+ 'handlers': ['console', 'log_file'],
+ },
+ 'formatters': {
+ 'verbose': {
+ 'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s',
+ }
+ },
+ 'filters': {
+ 'require_debug_false': {
+ '()': 'django.utils.log.RequireDebugFalse',
+ },
+ 'require_debug_true': {
+ '()': 'django.utils.log.RequireDebugTrue',
+ },
+ },
+ 'handlers': {
+ 'log_file': {
+ 'level': 'INFO',
+ 'class': 'logging.handlers.TimedRotatingFileHandler',
+ 'filename': os.path.join(LOG_PATH, 'djangoblog.log'),
+ 'when': 'D',
+ 'formatter': 'verbose',
+ 'interval': 1,
+ 'delay': True,
+ 'backupCount': 5,
+ 'encoding': 'utf-8'
+ },
+ 'console': {
+ 'level': 'DEBUG',
+ 'filters': ['require_debug_true'],
+ 'class': 'logging.StreamHandler',
+ 'formatter': 'verbose'
+ },
+ 'null': {
+ 'class': 'logging.NullHandler',
+ },
+ 'mail_admins': {
+ 'level': 'ERROR',
+ 'filters': ['require_debug_false'],
+ 'class': 'django.utils.log.AdminEmailHandler'
+ }
+ },
+ 'loggers': {
+ 'djangoblog': {
+ 'handlers': ['log_file', 'console'],
+ 'level': 'INFO',
+ 'propagate': True,
+ },
+ 'django.request': {
+ 'handlers': ['mail_admins'],
+ 'level': 'ERROR',
+ 'propagate': False,
+ }
+ }
+}
+
+STATICFILES_FINDERS = (
+ 'django.contrib.staticfiles.finders.FileSystemFinder',
+ 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
+ # other
+ 'compressor.finders.CompressorFinder',
+)
+COMPRESS_ENABLED = True
+# COMPRESS_OFFLINE = True
+
+
+COMPRESS_CSS_FILTERS = [
+ # creates absolute urls from relative ones
+ 'compressor.filters.css_default.CssAbsoluteFilter',
+ # css minimizer
+ 'compressor.filters.cssmin.CSSMinFilter'
+]
+COMPRESS_JS_FILTERS = [
+ 'compressor.filters.jsmin.JSMinFilter'
+]
+
+MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
+MEDIA_URL = '/media/'
+X_FRAME_OPTIONS = 'SAMEORIGIN'
+
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+
+if os.environ.get('DJANGO_ELASTICSEARCH_HOST'):
+ ELASTICSEARCH_DSL = {
+ 'default': {
+ 'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST')
+ },
+ }
+ HAYSTACK_CONNECTIONS = {
+ 'default': {
+ 'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
+ },
+ }
+
+# Plugin System
+PLUGINS_DIR = BASE_DIR / 'plugins'
+ACTIVE_PLUGINS = [
+ 'article_copyright',
+ 'reading_time',
+ 'external_links',
+ 'view_count',
+ 'seo_optimizer'
+]
diff --git a/Django博客项目/djangoblog/sitemap.py b/Django博客项目/djangoblog/sitemap.py
new file mode 100644
index 0000000..8b7d446
--- /dev/null
+++ b/Django博客项目/djangoblog/sitemap.py
@@ -0,0 +1,59 @@
+from django.contrib.sitemaps import Sitemap
+from django.urls import reverse
+
+from blog.models import Article, Category, Tag
+
+
+class StaticViewSitemap(Sitemap):
+ priority = 0.5
+ changefreq = 'daily'
+
+ def items(self):
+ return ['blog:index', ]
+
+ def location(self, item):
+ return reverse(item)
+
+
+class ArticleSiteMap(Sitemap):
+ changefreq = "monthly"
+ priority = "0.6"
+
+ def items(self):
+ return Article.objects.filter(status='p')
+
+ def lastmod(self, obj):
+ return obj.last_modify_time
+
+
+class CategorySiteMap(Sitemap):
+ changefreq = "Weekly"
+ priority = "0.6"
+
+ def items(self):
+ return Category.objects.all()
+
+ def lastmod(self, obj):
+ return obj.last_modify_time
+
+
+class TagSiteMap(Sitemap):
+ changefreq = "Weekly"
+ priority = "0.3"
+
+ def items(self):
+ return Tag.objects.all()
+
+ def lastmod(self, obj):
+ return obj.last_modify_time
+
+
+class UserSiteMap(Sitemap):
+ changefreq = "Weekly"
+ priority = "0.3"
+
+ def items(self):
+ return list(set(map(lambda x: x.author, Article.objects.all())))
+
+ def lastmod(self, obj):
+ return obj.date_joined
diff --git a/Django博客项目/djangoblog/spider_notify.py b/Django博客项目/djangoblog/spider_notify.py
new file mode 100644
index 0000000..7b909e9
--- /dev/null
+++ b/Django博客项目/djangoblog/spider_notify.py
@@ -0,0 +1,21 @@
+import logging
+
+import requests
+from django.conf import settings
+
+logger = logging.getLogger(__name__)
+
+
+class SpiderNotify():
+ @staticmethod
+ def baidu_notify(urls):
+ try:
+ data = '\n'.join(urls)
+ result = requests.post(settings.BAIDU_NOTIFY_URL, data=data)
+ logger.info(result.text)
+ except Exception as e:
+ logger.error(e)
+
+ @staticmethod
+ def notify(url):
+ SpiderNotify.baidu_notify(url)
diff --git a/Django博客项目/djangoblog/tests.py b/Django博客项目/djangoblog/tests.py
new file mode 100644
index 0000000..01237d9
--- /dev/null
+++ b/Django博客项目/djangoblog/tests.py
@@ -0,0 +1,32 @@
+from django.test import TestCase
+
+from djangoblog.utils import *
+
+
+class DjangoBlogTest(TestCase):
+ def setUp(self):
+ pass
+
+ def test_utils(self):
+ md5 = get_sha256('test')
+ self.assertIsNotNone(md5)
+ c = CommonMarkdown.get_markdown('''
+ # Title1
+
+ ```python
+ import os
+ ```
+
+ [url](https://www.lylinux.net/)
+
+ [ddd](http://www.baidu.com)
+
+
+ ''')
+ self.assertIsNotNone(c)
+ d = {
+ 'd': 'key1',
+ 'd2': 'key2'
+ }
+ data = parse_dict_to_url(d)
+ self.assertIsNotNone(data)
diff --git a/Django博客项目/djangoblog/urls.py b/Django博客项目/djangoblog/urls.py
new file mode 100644
index 0000000..824630c
--- /dev/null
+++ b/Django博客项目/djangoblog/urls.py
@@ -0,0 +1,64 @@
+"""djangoblog URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/1.10/topics/http/urls/
+Examples:
+Function views
+ 1. Add an import: from my_app import views
+ 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
+Class-based views
+ 1. Add an import: from other_app.views import Home
+ 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
+Including another URLconf
+ 1. Import the include() function: from django.conf.urls import url, include
+ 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
+"""
+from django.conf import settings
+from django.conf.urls.i18n import i18n_patterns
+from django.conf.urls.static import static
+from django.contrib.sitemaps.views import sitemap
+from django.urls import path, include
+from django.urls import re_path
+from haystack.views import search_view_factory
+
+from djangoblog.admin_site import admin_site
+#from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm
+from djangoblog.feeds import DjangoBlogFeed
+from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap
+
+sitemaps = {
+
+ 'blog': ArticleSiteMap,
+ 'Category': CategorySiteMap,
+ 'Tag': TagSiteMap,
+ 'User': UserSiteMap,
+ 'static': StaticViewSitemap
+}
+
+handler404 = 'blog.views.page_not_found_view'
+handler500 = 'blog.views.server_error_view'
+handle403 = 'blog.views.permission_denied_view'
+
+urlpatterns = [
+ path('i18n/', include('django.conf.urls.i18n')),
+ #path('search/', include('haystack.urls')),
+]
+urlpatterns += i18n_patterns(
+ re_path(r'^admin/', admin_site.urls),
+ re_path(r'', include('blog.urls', namespace='blog')),
+ re_path(r'mdeditor/', include('mdeditor.urls')),
+ re_path(r'', include('comments.urls', namespace='comment')),
+ re_path(r'', include('accounts.urls', namespace='account')),
+ re_path(r'', include('oauth.urls', namespace='oauth')),
+ re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps},
+ name='django.contrib.sitemaps.views.sitemap'),
+ re_path(r'^feed/$', DjangoBlogFeed()),
+ re_path(r'^rss/$', DjangoBlogFeed()),
+ #re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm),
+ # name='search'),
+ re_path(r'', include('servermanager.urls', namespace='servermanager')),
+ re_path(r'', include('owntracks.urls', namespace='owntracks'))
+ , prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+if settings.DEBUG:
+ urlpatterns += static(settings.MEDIA_URL,
+ document_root=settings.MEDIA_ROOT)
diff --git a/Django博客项目/djangoblog/utils.py b/Django博客项目/djangoblog/utils.py
new file mode 100644
index 0000000..57f63dc
--- /dev/null
+++ b/Django博客项目/djangoblog/utils.py
@@ -0,0 +1,232 @@
+#!/usr/bin/env python
+# encoding: utf-8
+
+
+import logging
+import os
+import random
+import string
+import uuid
+from hashlib import sha256
+
+import bleach
+import markdown
+import requests
+from django.conf import settings
+from django.contrib.sites.models import Site
+from django.core.cache import cache
+from django.templatetags.static import static
+
+logger = logging.getLogger(__name__)
+
+
+def get_max_articleid_commentid():
+ from blog.models import Article
+ from comments.models import Comment
+ return (Article.objects.latest().pk, Comment.objects.latest().pk)
+
+
+def get_sha256(str):
+ m = sha256(str.encode('utf-8'))
+ return m.hexdigest()
+
+
+def cache_decorator(expiration=3 * 60):
+ def wrapper(func):
+ def news(*args, **kwargs):
+ try:
+ view = args[0]
+ key = view.get_cache_key()
+ except:
+ key = None
+ if not key:
+ unique_str = repr((func, args, kwargs))
+
+ m = sha256(unique_str.encode('utf-8'))
+ key = m.hexdigest()
+ value = cache.get(key)
+ if value is not None:
+ # logger.info('cache_decorator get cache:%s key:%s' % (func.__name__, key))
+ if str(value) == '__default_cache_value__':
+ return None
+ else:
+ return value
+ else:
+ logger.debug(
+ 'cache_decorator set cache:%s key:%s' %
+ (func.__name__, key))
+ value = func(*args, **kwargs)
+ if value is None:
+ cache.set(key, '__default_cache_value__', expiration)
+ else:
+ cache.set(key, value, expiration)
+ return value
+
+ return news
+
+ return wrapper
+
+
+def expire_view_cache(path, servername, serverport, key_prefix=None):
+ '''
+ 刷新视图缓存
+ :param path:url路径
+ :param servername:host
+ :param serverport:端口
+ :param key_prefix:前缀
+ :return:是否成功
+ '''
+ from django.http import HttpRequest
+ from django.utils.cache import get_cache_key
+
+ request = HttpRequest()
+ request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport}
+ request.path = path
+
+ key = get_cache_key(request, key_prefix=key_prefix, cache=cache)
+ if key:
+ logger.info('expire_view_cache:get key:{path}'.format(path=path))
+ if cache.get(key):
+ cache.delete(key)
+ return True
+ return False
+
+
+@cache_decorator()
+def get_current_site():
+ site = Site.objects.get_current()
+ return site
+
+
+class CommonMarkdown:
+ @staticmethod
+ def _convert_markdown(value):
+ md = markdown.Markdown(
+ extensions=[
+ 'extra',
+ 'codehilite',
+ 'toc',
+ 'tables',
+ ]
+ )
+ body = md.convert(value)
+ toc = md.toc
+ return body, toc
+
+ @staticmethod
+ def get_markdown_with_toc(value):
+ body, toc = CommonMarkdown._convert_markdown(value)
+ return body, toc
+
+ @staticmethod
+ def get_markdown(value):
+ body, toc = CommonMarkdown._convert_markdown(value)
+ return body
+
+
+def send_email(emailto, title, content):
+ from djangoblog.blog_signals import send_email_signal
+ send_email_signal.send(
+ send_email.__class__,
+ emailto=emailto,
+ title=title,
+ content=content)
+
+
+def generate_code() -> str:
+ """生成随机数验证码"""
+ return ''.join(random.sample(string.digits, 6))
+
+
+def parse_dict_to_url(dict):
+ from urllib.parse import quote
+ url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/'))
+ for k, v in dict.items()])
+ return url
+
+
+def get_blog_setting():
+ value = cache.get('get_blog_setting')
+ if value:
+ return value
+ else:
+ from blog.models import BlogSettings
+ if not BlogSettings.objects.count():
+ setting = BlogSettings()
+ setting.site_name = 'djangoblog'
+ setting.site_description = '基于Django的博客系统'
+ setting.site_seo_description = '基于Django的博客系统'
+ setting.site_keywords = 'Django,Python'
+ setting.article_sub_length = 300
+ setting.sidebar_article_count = 10
+ setting.sidebar_comment_count = 5
+ setting.show_google_adsense = False
+ setting.open_site_comment = True
+ setting.analytics_code = ''
+ setting.beian_code = ''
+ setting.show_gongan_code = False
+ setting.comment_need_review = False
+ setting.save()
+ value = BlogSettings.objects.first()
+ logger.info('set cache get_blog_setting')
+ cache.set('get_blog_setting', value)
+ return value
+
+
+def save_user_avatar(url):
+ '''
+ 保存用户头像
+ :param url:头像url
+ :return: 本地路径
+ '''
+ logger.info(url)
+
+ try:
+ basedir = os.path.join(settings.STATICFILES, 'avatar')
+ rsp = requests.get(url, timeout=2)
+ if rsp.status_code == 200:
+ if not os.path.exists(basedir):
+ os.makedirs(basedir)
+
+ image_extensions = ['.jpg', '.png', 'jpeg', '.gif']
+ isimage = len([i for i in image_extensions if url.endswith(i)]) > 0
+ ext = os.path.splitext(url)[1] if isimage else '.jpg'
+ save_filename = str(uuid.uuid4().hex) + ext
+ logger.info('保存用户头像:' + basedir + save_filename)
+ with open(os.path.join(basedir, save_filename), 'wb+') as file:
+ file.write(rsp.content)
+ return static('avatar/' + save_filename)
+ except Exception as e:
+ logger.error(e)
+ return static('blog/img/avatar.png')
+
+
+def delete_sidebar_cache():
+ from blog.models import LinkShowType
+ keys = ["sidebar" + x for x in LinkShowType.values]
+ for k in keys:
+ logger.info('delete sidebar key:' + k)
+ cache.delete(k)
+
+
+def delete_view_cache(prefix, keys):
+ from django.core.cache.utils import make_template_fragment_key
+ key = make_template_fragment_key(prefix, keys)
+ cache.delete(key)
+
+
+def get_resource_url():
+ if settings.STATIC_URL:
+ return settings.STATIC_URL
+ else:
+ site = get_current_site()
+ return 'http://' + site.domain + '/static/'
+
+
+ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1',
+ 'h2', 'p']
+ALLOWED_ATTRIBUTES = {'a': ['href', 'title'], 'abbr': ['title'], 'acronym': ['title']}
+
+
+def sanitize_html(html):
+ return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)
diff --git a/Django博客项目/djangoblog/whoosh_cn_backend.py b/Django博客项目/djangoblog/whoosh_cn_backend.py
new file mode 100644
index 0000000..be70775
--- /dev/null
+++ b/Django博客项目/djangoblog/whoosh_cn_backend.py
@@ -0,0 +1,1047 @@
+# encoding: utf-8
+
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import json
+import os
+import re
+import shutil
+import threading
+import warnings
+
+import six
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+from datetime import datetime
+from django.utils.encoding import force_str
+from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query
+from haystack.constants import DJANGO_CT, DJANGO_ID, ID
+from haystack.exceptions import MissingDependency, SearchBackendError, SkipDocument
+from haystack.inputs import Clean, Exact, PythonData, Raw
+from haystack.models import SearchResult
+from haystack.utils import get_identifier, get_model_ct
+from haystack.utils import log as logging
+from haystack.utils.app_loading import haystack_get_model
+try:
+ from jieba.analyse import ChineseAnalyzer
+except ImportError:
+ ChineseAnalyzer = None
+from whoosh import index
+from whoosh.analysis import StemmingAnalyzer
+from whoosh.fields import BOOLEAN, DATETIME, IDLIST, KEYWORD, NGRAM, NGRAMWORDS, NUMERIC, Schema, TEXT
+from whoosh.fields import ID as WHOOSH_ID
+from whoosh.filedb.filestore import FileStorage, RamStorage
+from whoosh.highlight import ContextFragmenter, HtmlFormatter
+from whoosh.highlight import highlight as whoosh_highlight
+from whoosh.qparser import QueryParser
+from whoosh.searching import ResultsPage
+from whoosh.writing import AsyncWriter
+
+try:
+ import whoosh
+except ImportError:
+ raise MissingDependency(
+ "The 'whoosh' backend requires the installation of 'Whoosh'. Please refer to the documentation.")
+
+# Handle minimum requirement.
+if not hasattr(whoosh, '__version__') or whoosh.__version__ < (2, 5, 0):
+ raise MissingDependency(
+ "The 'whoosh' backend requires version 2.5.0 or greater.")
+
+# Bubble up the correct error.
+
+DATETIME_REGEX = re.compile(
+ '^(?POJjKX3TX6s4i!+%V)`F+XGwZ-@^b%srp8`98q*nf(LTI}c{Dax(r+>S
z&6C@QTwg(Lub`f&TjKA^=w%9JdNnUQZB`vt0`@i?)*a^7DO;MtP8OfAE>DmDEJ!>B
zeu8DUg{(u-B0lk(U2mGcHN9PJ^qr<#W4MXa@bjnaThmkR^m^L#9@ZdPe@=XPYVU=@
zQ%Y_2e;Iv%O1
+ A powerful, elegant, and modern blog system.
+
+ English • 简体中文
+
+
+
+
+ (Left) Alipay / (Right) WeChat +
+ +## 🙏 Acknowledgements + +A special thanks to **JetBrains** for providing a free open-source license for this project. + +
+
+
+
+
cN|W1iINFu+ zdk+p{Dp>xNWv}b(UhSp3MyNUv4N}Ha3|Z~aMgn!XEvu`Ks-~O`B@bK8=g-qL(NC7~ z-YPdRj_=@D#z|5?AoAUU3Ah#u4Cd>(ulOrGPy8G!G}Xszd@t2zHHnx<$963Q(HoUl zH8$-;MvAB(JYdf*fK``#tQRLIR8Pj@J=WVO1#X`I;2ae8VPg@G-+jV}s%wN u)F5LvHIN>&@_N(_(VxD!u*Q1Ly5c{b5%R zuEsvRXr@`mQGh4ihY}u~xSf}M;+e8!$4~B>r}yXJV{_v0r7Hb$o7Rb`B8zy!>hOOa z#NQSEmkp>5S*p6XQCN)x3b{|VPlB4?pM#E!3gvU9R%unNBLbk104Qm_w^VrFn)+R| zf#ItkgYP^^*(JdAeq>?$Jq-hks {6k@1c%D4xm& Vu(EW|K&f=GL$JbYmhw|eaRCqMXXA+S zF+<_l`s;4%8?@`hAKq@yYrh^``CcESk}(K$hJm$4s~RJBEiFy!>`qGwK1K@X%bt_h zZoQNr%+6Fi0vIsCPZaDT9N1DxQt`U&C>*~G(Jay4%fFKk|4!42TUh5;V$rf*F!LEa zExYi#=9Xbb_rkkWgEqv2i>!@hgf9gS(XM8N%$w<(xO1M89sy9a@%xI`I7_>q5=!`^ zpVd6JtD>aW7~OE#E6K_CQu*KShFB_=G&t#Gr{eS04kqilt)NfjK!^AT@}2y37v#36 zC>%^#oY%yR-AdG*u<>oaaKDGQ6!A`K7&^2X);NFuiAP$1vLo`V!~bC_I{VoFWmN=I zB7Iz!reW0w?)ck4bl+{`ddKa6S+ ^ZV{fdM2(>Y0$85tu0mkElsjyZxLa- zwP+}ngS2UEWDSD@$gozuf4%?d=RLR`nWF16+L46olzKwcwoEBEkJgf5w`76FWz>ab z>@0Q| >RlrBpVIp>Xk!PdWLLEIe5Y8lKxvALE-Ee3Qm)S$ zcZ+qw6x>S8N&Sk)n4)a{Y~(X@deaNuQ+$Y#&hZZ?>EE9$1NDA5X>+$Q3Q(O8EF}ta z-(O6{O^d3oHXSF1!34Ditx>a7N`|(j&NodByf;h4SixmDvApU$3O`72w^D{$pu1*< znfN%;Swq2T=6C1ptQso$YZ^=^1+a 1IqCz+OZ7 zW2sUcv`hZ92i$6WNesk+UW9n>ZJ$z#i)TtaM?ZHFHx4NSzY5Ml^h^8Af7;Gfg!O8@ z%?#AFWV9J;%)yQsABs+Y!3%~YF8tkBV2Z9AGncoXVCJ_}9__B=XIGl#7n $Q01C3c>XR}&qI@kegI0gia4 aYW2WaI*E|-6jR-3{_TFOLyl?rE7*b4^+wDB<1k2Pi zu|?I06_**St~>KZ@%(kQU*_Qs_->50PSmibm{v-dm@j6w`t9efKS>li$@@tAW vNCPAB{9PKcIBxw2cCf&Y)3KbSSjehi~3PL?(q>+ zW;AbXnq0}j=~YleN=}|YlI xt_iW&*Hd`VAPExVTUH+el8 z!_Ck7J_h6U|8+Rf)`ydKkNtIr6LiB _&P({B zk_t9W+dC(vS=HSY75r}u7eEcqRA7=A4a^6l;Tj1t{@xYDN6?1>$@JcKPnNl}Q-;-p zWI@6_V0;zLzLCN&oMT^}kk_CJao7O|BQNEE!}uJuH0%E&UVQJ%t~BG>$~ovGuCoV| znDj28N%B<}yS0b&&d+*&h)xKP1hd~A>G8P&!u;kN3@Owea}l{@cEaAT?`l-uCgc?M zwZk`k{--o^6BorS5&J3Rx@6-6NtRD`4$Pgwc);UL##;f*_)Q|IM3 faQsIQ}6{JmGb0vTfr_!5YNIc`yZ!{U>!+^4N;*gf_u z+)Gq^rS*LCxXPLFpS&l-0v`~cgRDlKx!oQgtM<3fS8l_j*GzY353^C_)u}{=G-AW3 zVil8L)K6ij1*qZ@^E*_5ebscRVrntqGD67mVja|FWKiY*<84Z$=wDGa``D_XZlNs! z;=-KsK@HG&J#rzz@+9bam`w1&)YhpQc3aBfEL&_j%6fWsaDks DG zXWXxH)eo`3hWA;(Xjd1s9GoZU2b>JBgwf2c1l^BFk$9TS7u%DVw7GUGq)bT3cg6 zy5m!fuae;Hi*DUjqNwJRZ*^ShWSLy$E2Wwts13>0U(ed3KH@mdXPnSu)LrGvc;y4b zh+y;Leb0EdM5fuwP!#iW8twq~O=qhyD(^0KrIEKY{kZ4yQ_@n7+$`=dV@u|YDCwuc zuAe_APk#2_#TS;_9u-`nrHqc82yB^mT;ZE7%-F`~F`~A+Hsn27&6)PsP11_R mi{Mz!YOo3%p63Zbg)S=VstJ2w`3CY0gGouNm!5AnftgC-X`F8>lA80Oi&~R~q=e z3Q*-49s9^DsiMktdr{W`ig~<{ml+0bREfGw*gtL~r%3LX_m}aLO}can;9_M&+?O4K z;@u?)5YEHD4yv1O#_}n);9MoZwT32_G?&$8BVS%8L9B(QuO0_ShFebs47uGuKtJhl z)^070K}GMi8OjoTo|}HMtzT_ES%sJr^faXrhwaNnShecIo;9|5j?RruYboOMrP>mE zwKBamoM6*Egh=Dly63hdo@Tghx08;3o4_=e2x0$0E#DDEv?MNNfO1lW93w{0jq4A5 zG(B>DC4clbIey3aB1l_BJugjQebIUvP7Hb#?wDGAS+u#tWof0ok|Ife=7|H__Ve7P zjtz*oB*gzy!t27Eq;t?f1E(&8rQG Et_$| zYlmKcZDdzzY_bC3g;jC_G)!(WWco G- zor4DLD@UD4rSxc0rULb|ZyNPyPzVFQbiT`71qbU?hif=${c(bbNJoH*lxu4no~q3; zX*Qy?BC >+q`$6vN1h8$++qiAN7_8wmefS#MQ-yD7WgV&)RxYx zl@4hH*zGC@1ujPv?E19*E6L5>Dq6)^riLWcr?8^WpZ&H@-1DqIC1kV Dpk$yk{g`R(rjXHWGKF2nS{|#3mvsc7~L2C@%^54rm3iUmidV-jaIICiIDpP z1IDDTX2zO12$y7Xk#b=JBk~1Wu58(*3^ul);*$@{- 8V>7{z=)u10qfD<~ z<$kiKirnyBApd7$5oS&9QtNU=PJs5v$+t*{WoW`=1TYhHL;3g^_B`)k6CKfy7{;aD z-^lVzu-DyHobXh+P034TzS!fyN$yO<$DMAO$YatM{r2Fg<{kUPBMRMrjL022 tPf-W5{54<&F &|33lqgqHodXps z-?P!lE=Vm4{SZP^`yFnG4v>hNP1X&^5xpAPT!wwB<)iC7cFEdp#czWG_$z4?hsZ$0 zjGqm%eWZY?U43LoPDf>Z{)&Rjn2G2ZR{@K;UrCf`;Jh0QYZ4-QK|_l8t4KA}tIJ=t zyR5FMnDryD)!*Si4*x3H=(aaIKSyVTOl9piU4EKL;uf-T73b&J2de51_rvPJJTIt+ z5YVOHKt s{=4kQrMA zNhlJHr=l{auZQqU=7zFX*MwOBM2AYHNX7!r;Tysfoq$RB)3K^C_g=*t0P+oo5jR zdHM~|Eyx3KjP5%v-OKze#5>k+O8X{uB|-^T*b7Id-C0(1;!-o(K@D4Q14QUZXu)b) z_;GFO8Ho!&svK$#a3*xfSfJ85iLanZ-}UNMWw=4GPH{#WUQ3&&8gG^B>bSp{x2o0_ zFt_ec#4^qL*M8Gt`DedgV7DQTo|3QBu3;D$CdVH|!{)RS%^m}PIRu4drMaz7(lOMM zIhFyf=E_w26J>!5^wGUIh=I=*cRO!v24e8<9x4Q|*S@aC4vaTyF#;rQu*QY vS^w|sa(H+qzzI;oW2P}O?c%}7M*JrGlw)O z_zQl6l;$Y(IS|U`Qj=!E+pd#hsb13XU7ELWbjCenh{5c6p}P0RjC%= >+=P35cE%HTcpxT5MVeZb!wY(<4N+0K;14l@{kP_&gs;peAZD|#x#s7f z@@sc`FuTxUN?Kfh6Gt_XXShwX`+!awyO@6zwq@$`+>WIK!agZ8x7WW& | z|1u*^1+|JsRapuq+jkB!5_^%fbf6=CG{P@&+fZ=Emz1nGuxC$apElN9WuUGlc$ELF zo7Ufh;585m$LmxzYUgl9R+KO4ubkXefHy$lz{;ndIh64;bfFX-Gy*YZ1_FW@meC}z zjfRv4hJJli_aTgh^TL9zpeS@klDX0e?91~>KTq`h*LC~Cv@n6tFo)n2y?|rxn-iS= zgW)PBD&Wi`&^r~b-LgmJ{mu+CmsxPffgu`cvsY4t366A|BS%q_);>iE?(KL9lACL6 zQWO+MRow?dOglO;W0%bBm;8Bi^N@=-I~>62ls3YO+t>nTP@?1r14)Pv@*wT9+zCsA z-SXLDFRQOuBI>X3vi0(67Al`vXH7YZG6;~K^Nj-H -*D5gnb*FsO9jB~}z%)6> zDwD5T)C!=kSi_X+&}Ive8HPqfsdmx2pwj@xIS5k6ed~}lIWoV|QCgEZH_~AEwB#3y zyey1% )rU^@KAn`yX02{FqS6!uQXRJGo?BR&+uT)*%$nO;tUY`^uf0aVOA*?% zUxRN``wje900=7!0lug&cYC>>cx<{JFr}<cm3Dz}qQ7AY9SR?PmK1XdAlM`X(Rp zRBu~Q@z3Gy3TB{PL&WrS!A-yb7mwh48u?ZNYsyXEMb-rtg9C6-1iIS`v8M6s2N@!K zz%apn|5W~R29qp({#geqJBeJdmgS?`W17w`k$8b o+)DDxZjDCd z*yEOiZ;Y-YAX0?Jy`PKLDfmu4P~UhqVoAUZvMtrcimr3}EVlX-D|tB0(mf5yA7g z-ax&e8{pGl*Vl3C^I+`)F;t1xQcR)7dhQOj;ByecOZGp!%>#T%BJ{jV3B*2>0I>wf zFZoqd98Gyk<4bPsik;4A=1b&)_k{l536FDm^&EuurVhnLc))kWoUlUjwd5rF2GSkK zFt!|R)4HPL#@Te^xSVTSa^D>P$^wcW8H#np@eA&_^jI_`H?d3H_GY_^0?+t7&xPKs z*n%_8K@p8(Cp=80z3Revir!r!l7!`NW(W@-{3yeEr5*0>(j|5 8GfHZ1~nX4|P|@0fR4T1ei%jLJ}3Z1bs%(}>LE&~BaNL}TVt6FwIaZBuZ<^iSe( zDxAK(froKlA_~$r!8&nh zoh!X77`3?-=^H{j`=ia`$LF9kFKxTFYl9m_+G-8lJBKgdFH;D&XmYo2?il;BodP3J zE+tOc4KkZ?XNCUez*-J}Lv<;aL{2I7Wy|)(H8N+Y3 zy79~P(LFGazr3OaPmw+@jB{*DTMZ1BkM7tY+XpXKXNJk#3MpZ_cDUU9_EGK9V#oE1 z)W`bRBU&=IEvD;-xR!@CBiEH4qvl*Z5@gy&j|>%v)MoQ^wogUW`rOpsLn6|m7{G%= zBs_>34cH3}uN;~FbsXb7sUh~C@(c|4IOGvB42|0jD~ a>~gu2UR>^bL`SHiJVX*cwX^i%G(F9wBOE2w%zUNVPg(*3&2pYh7{Uqh}B zf?c-z5@RiUu|sGx9giLoy-KpDjvy*dyva0E_fBN}i%VD35k)(-il)p%S`7bG(%Jr* zd8a_BPo|OK3?&U*R%!AQf}L{PHv0wYo>=FgrgKnVky`S76ATq4JM)nz_UP49E!)Dy z1ko@mADM4uQ=B{+)dN+HwIIXktdpWtM*7a$loi1e>t?1`rG&(goWS`lJN5xZu;P58 zOsn(t;Q3Q&udW%?(2$IJ3>C!24)T?@<8S@IK1K~| A%WT`0@3F163K3nwwouQG*kG-|!%R z0BLYx8U4&$m0uiLYEIg>g^FweJ&fd^@JpHqqfyT&1q|!*)SuwF-UhOfSC@0y2WQbz zqwpUr8d5kmk|fO|Uxxj~Wj2Ni5^davfWXDWeA1;bacm5)cBYd4{GaB2-PE}a=OEQ{ zkmJx8%9+=ZF=r88&)vIr#F!V~;yqm}N%G6`smy6R>KqsOaD+=zV5`^IY%?Am3Zyv? z{s=#gek^m^a}EkH65p{qfvOM5FNp8x>dI#GmXp!Qabic^Uee&cb_6@S94b_^f6M-z zxY-H)R_YoJKpP)jN73&q0C;V1HHuzDQrD0t&lHHxL6 +#8P|NUO18zy1sPyIF=T{|hC34B;lDL7AHt}gYt9(6q1BQCP9)WR`g&q#~<%!Q)y zIqr|vGrN1t)p-ng7;U&d^PedizP7PN*xo*(m3 Xc7Tu%`S3dA@ns`0;xJyNiM`tEtnK(Yn`65H1EyAbDKYcTEv%@% Ae)S={$inbWS`qz9J~gYx*XeBb8m4>VxM5k>a+az-Vi@mjW1(wKn1`(NK2W3>_A zbn>3(&h{CS`0M)abUQ;;(s)6RvkkF5BfC_aP#>z_;=(Rc(0>jnYk{T5bg3D|82{;d zAR6OqcrZPn^siHjHdZP*C8FgDpJ>9Xzu^JoE2gr--}0Z5zH%se_>vdb-)qjK3C6c0 zayIj|Q*>{_e_*N4)w|3Qdl6;5a(m1Z_TQg+ G&e2!KPkf1d`Er`;@H*k=dDTK z5Z_UB$k#mw?d^&m9@9+>1l{a |}PhK<52nyU#D%8*uf>__Hjq7dHS zSweNH$-xeqgu|IJ@XMy%cv2t9@;hKFwq&w0kbg6*<;|156<^Fcz}r?Ak;n6 @3N_`R25uwzIZIQ_6a&M3dQTUdd8VNiWffn(4M$wnEdu52(|pKV!+adX0m`P z`y&$Cx8FFAom93khy8Xb@MZO n;&U$NG7R=@G`_*-Hu76oMFmuPyq;wxs z6$vHYdbIZSRA(r?aIDqnBgL_Zft10wQ^G UiG}Zq>uF70yrQ({zJbWe?mOb5jMuL)0(afa+?<+blAg z3M^Ms85DIRbft{H5j}ik{q8P*m$~9)0VM&k#+oCxAINTwP(%9y`LF-kwQJ-32I=9v z_+$W_j1k(pX`(NTY4^}9-P%h<;Exmr_Wo~&pcC+_bu$y7=uCtv%CguFQ0`8w ayGoHK{XGa)$1&vZe;Kxx~N!Y7TmZ@I7Pm52@dU zoS2AU5-J*S-6p+cbSoZqf4|gL>L@wT qHpy# z?6q4zL8)*yX1I$e+%DsO`~YI}546v%?wS%k`jSvpWMFX)>Zm!<@`$VMQ}eoqVK7~s zRJu}1Cp^1*4JZBTSjzn uYF8ZYSEsB$=wOlRNCjK-vt(}!e1;P53 zGC}#bnxh>>l5f5>c8=*L!ajbo!+K34UENK$1?m@dRt)KKv%ozKMxecs`EC{CFlV4U za}Zfaw0Q^ycP7b|ozi?9$;34SIA-Ej^;~krVg5o9&5N4aEVQyDWf==>x*e5U_6Yl- zZ(C9>O46iZrHe`j`|Bd2P2eaTL4aOa`tJ_!mL2fP`%LeUUy2q3%hliH4OwE>HpuAv z_NCdBE+qp!wfxD5x+eiE;d2~43aOU?6=sm{$8zOHG$zVXu|Ry&gwheg{7ULh6s~BC z;T(x_@ZXA+m-5`abmt(wpLrG%Nf|~d^e=A8rC0J#9S`G9ByCN#F^d8OCc+uvVPejY z3&*oaVu+RVGR_9NVP9w|{MaQ^2O7FR$kWA~5M~Edh1r~gdcA{H)tRx?-w8hQG@SlH z9rxHuqpF+-|895}W93tdLn2DwDM#(Gf}6PO6;H{3EJPotTgmFarz(EQY>J3& CN=_Kq^#>$8&UsxPZm9rX>VNBQ(o|F|NCmWkMAjZ^cx9zsoB>13J zXpvha*4}Jx9h@L%iuDY;&B{_n6X%ZnLu_1quD_P()CQ&QbtUi|)~>R-Y&Ua6jpjJn zir(DilDe(INh6{5usqR3Q27I!lO7C)o5V9aeUi)WIekPW5Ve>Rb0>j;GF!;96r1*1 z{%IjM2i=K#7LZ#u^EG4kZwlvYsZz)Y8UE~*_vERg>r676YfXL5
ns2G`J`T6hpVeh5SO>Kz=%e$^z|b+z15lb%k|YI@ zbkrBda|9+SH50%r5h-T|Y>G*Q$HO^IsXEa)f_BKXpJn-`CY!fuD;a)|5eOq(eEc$D zZl(4S@61iSq;KfDqZkatM}FIKR+-2yWLh2(cV0+1!2nwWpi*HmM72kyTVTrV{bs-H z=Do~qPA6YH0a%e0Ku;MdhBtOZBD4avKS4c-d+CZsc?-v8+{*xU*pZzYz1PQ zBa#C>6yqH!$RWKYSTpU}?}`wzSp$MAqIvP^b<@JNcl@*dUdK@bW*Yv_NESP=e9hFY zZ1DkosWIE5)G@k@UuAk0Lv_t14orvO;7f?R=FNAgl*SH|;jf>@#l0{WOD1c{8p%$| z$`Rt9xdm$a$-)BQFh9#w;oVtRDywu<`y+!lT%OR&3RGr29tjCuIE-++k$`Gs+@M;Q zoXW(qO}EWa%0%mz!Z?7$mtR2Bds%^Iz1{o|{Ks?QXrDlfBZ0A?z{!LPsl{PC08)Of z(IU ^@7eQ)5h)Ote@R@sQC%osVwF&I*9?Z`?^b_0?pIf}fgrF}* sIck4PGmGR zYsJ@{$u(^xh0;`(abnt?lZJR3cY$Dwh2YA>l@$TQF>r7(3QgWyfZV|mW^KMagR!L! zhzI{WDf7SdgAzU9Ad|~?f6~S98rsV5bu(7FFZm`>;ByMB#+H}Y)Xh?O;hLc*<$RY> zlWGd(*nGzD3{4bh$haTyr!wn)bqq=dL%@$x=B*@#h2e64Fzm-w_9KW0fKt8S)-2l8 zk@F9{`LR)}?N&-W;(5a8%||n0Blj>B#aW18X`)$|!eQ?MSI7&RGkkgkE6z<=!HC+L za%b0wRV;esM6>1Zvc{1~7%G)dA<`T4VP=?4cV;Kr+U IvHh6nK@H{iKw zd1+B>^v;jB^vnhqW~owG%v;y6v!KC(&7hN$ 3w zzYXuCNV<1pWd0$|8`}e|a+Lh64sB@npb0vU*Gy}p?3-q@6631WWJmdG;%R)Ec721< zn3v*%rpKD16#If@ZInPpXXb5`%&So2gXJ=J-G|8ZIX`k%%Xyc(ANiNAA9$i9ZkQhj zm%e!foHF_0>Db-A=M_&x|25q7wDYr&e| NFWD=!vJ{yg2}F7vX<*oB{@65C zs+Ap?J)EpbRrxi_#bzQv2RB5cXoIZkx(y@&;fjP`5=P(Gh1xghyK!DZsu1Bg=poma zp)tnht|1wjBuX`lhow&6dv1-!6y)#60|g3o?@tA}w5_QyqRE; 3eJhJG7qc!A|D0##? zZAYu3cab-HQPNjd%f%Eq2r=C3GXh^Y({Z9<9Ij-Ft#U|&3=u@HrMX^r%Pg(aBM$Ym zByLLEH%JmA#Ls0~>PR`^^*E03n9kKGdE0sq++GwKWX40{6L>Q4bRnoYxu_OGZ>fY{ zE)j_f4I4J|k-ML7(T2T03H}CwqO3DSUfi3+^4GsU-S}H6dION9s#g`JYGU2*o*&y` zTR?w1Wr%y=!)F2TB&cIoodLtE!%Wb>e$$(&V tvT-tO#2 z-hNY6Cz6(lfx6!nR0Z~-5*(IgeV>v%rgo(vphF*RS-4hdbxeYQ(Fyy=W7JJ(&<1NK za(y^DFL)Q!d=7e6+|E&$l^SK n|8&GLlpSGkSCeob0cS%Y}7>XECBda#?AYw#(t#=+-AnYLjn@;v7-xc%|pZB z-@25Zf6@E?iqT-1>QI}V|2}Pi)D
#-7z5T*_tVDNm_fWm2J7~E{0EUH zaTS1=8oR}i)Yu5IzhuaJUQpk;{71-fgn99r)D@c@raD#Jg%rK8OP47l=598G)R@Wj z3sTPhZiV`Q96~rJBXt8Q=)!u8yhE%_RpnofgHAR5AyUT@l4pNCRG}~Y=Dj!tKLCypG0l6F-03=k8_xdFVtKtN$L;0@ zdllSZ8bb4G@{$AlXnPw?(qZ&2i;za~=DRe!%4ZZC8;W+TFN_-@m{VN{HK)4kjPA{M zXH#(!=3rgs@D?*K6N=(Bj}?rM^|%Mhm(NZSlyM4$Zh UriJrnEe) zRTrlvJqpsdr3&x#o3PF)w9dA4vIjNRNjnw|N*`)|T;=Y;n2VBrT)?}?qB7Dv%KC-k z=)n_;TisLzWGH;eJQ6`@
!No#iqD9T-fedVB88xA zo;JC|uZgi!#Co3ph0 }|BB{faa&67DZQZ{0o-XKATf{#ZZ%+&fF-U+AAOtAa?aLb}0l5WnG!>`?^H zx;`p{5TRCD<&pDIgyuBDu1=!sVf*15{)I(*0r6PfNrn+cCs{@i`14?)I&y!`f8JMd z*kb%Z+CGVHJ7sZ>lnL!6;R51P0M#nlTO&kwSc0l3X%OB1-+EPj7eFzDkPZFU(}W57 z5|=uiOyIL9nIqTRgi$pZPG-%!Mf-Or&w2l|+#ZpQ?4UM5&}dG?LIg _IU1eTafvAC3jwBAh% z9S0LF>sJsKN_hHwa+N=0)+^Uv9jPa19pl+^f4`NfU6>zvKq?rDO+|{&*r;1RAH`Nf zRpp~+Uw_j=Vh`;lbgJ95{8YG4e0|Of!f3Ex;VS#J=xb3=VFOFb;234CzO=o6DZq?{ zkhs2UeEw u79L22C4qkbyps>(LLEx{S&C9! zqFx9jK>9e%B;6VYNcEhXRc7t6XE0G_Z}PaJb<4OS9=lU$p?WhSV2HkccLqhdGHg(S zWrF|~p<%J>3qJo&Pz-2lNFnq^)@59eu$a4eoMPzfcAjiF2;bX5(Yk`+D%2LD|MixU zd_mo!|NBit2-hAGKj~7Iv$l)#L*f@3=0wuTv>^0Z2EcD~O@A=r&v+`CF1;pg`O#~R zdFh)!bZZ&CjU`0!k~h6o!*?3ig)3#y5Dr9yl!a%FcI?y_3y*6ZwT!Y&Gp`gzGp6qf z3!mXrO#mC4@-(282uWdko4($zc0XG5OPDmMp%ba0uuiMEql&`da93afQHT^9j7r>j zo_L1@zagwUwX-$#0jB}nxD*8LIW4%Fi4R{y%0fola}$zC&Os$7{dZ8LOg&aDDkvt* z6{|v6K&v164{d@r;P~b4$r{B*5p6*OuFfai)OxCSo1~XEHuz7)CLS2wBwbVKtnT0} zbJ+zuN1{ox6C_`^KtuQc_7gu!Q;3NTkdj=Idhi4l$%o`|>io%B4U#{JDTAC+Gv{50 z`r9cOO06j7F5xS$h}?jv{u+hxM<*_GH}}|ov}`aD>E{8|E9h-Sis-$GDh$e7|8%#h z*sG||^lCY>D`Sg)=Ma!M`(m@9#t2%1?XU1Z?;%RWm&D ZEc742@DRG(@p4~zzl*2hQqqGixEB@_XUwvGfowJb6dIkeE}_PtEnQ>*cqZ@ z`u%SM63p&Hl*CkhPZP<5Rnat6d4q+|3pvTSk7jPUpIy~b1;$&&8bRd{H~AkooZ@Wc z3Ek7VKj7|!e@!Z#-&AoGi5S=*W)7$wkpT0mYKJGbx(L;WGn@GZy0J|#j@wfC+lyw3 zN@Ae*m6)Li9uo&GC(+@%(3S8&0wBto6#SQDH{VpUDCR3jjq4$J$7~--n+MaP0Njak zIHjlIG2_4!VxnDz4#Qc~FcQ(kw 6`mi`Svqf62mZRuzHr__N zX|⁡O0bI!3Bi@jSTgKn>Bg&Q53II7XEA`NhoOY$25L(eK?<1>~>hujoRs}yUHVX zQTt}~MN1Zw0!xe7-aUbnm3fxd*KjwLLobx-SSZdGT`vUtB-=hS^~#Rs9K<#Wao{Zp z {kmB)_tVwwglj-W2qla4$Dx zz53|611jp`Ro8}#TnY+1OdA}SN;lGY+!=)=QY;q0b}yA~i0O^|s1#r34{5DET&Qh` z+yAF+h#H($?p6 {{X zz@F@jlhUjuyW%|!{Q!~$q>$)y1Hsl$FMYS;#r=c7U7SN-8Uv-lv<@mZOXKOlXLy4C z3v?;Yh1KbtsO!ynC%cyr-`4MR8{Y~e28GXK7yRZ9{3d5(xoP=XYdX*Uy?#pU$a5>& z`JUOV_ zRl}`OH8`y;U%XD+MFBD!HxL@B`JSH_G!;0V9-A+_4Hhf%=wJ%txE{qNaHkfkLsS+h zfe@JrYreKrFKhU2`1kD)K_#LCd!v$sI$is2ee^7Am=@kq2Z-`cU(l{<8*ON_J?@@& zOR+4E3%sN-vIK-%81R}RdPK$bCrBoGF zRu$>N^0s*R^! 6R919|j@|cY|A}(o`$`Onha=;(7(EQNg;0 z^sK+j)DQ>i-wCKpkXH^lH{QNw|FWJ%bUle^xtPfLdv# (gNpA7lv59GyIOXp*E+vz_!y|AkM=lB7mYXSWGfxr6#WlV5UW{v7tVn1!e zwg=v}kG!o+0qXfY-FyUozk}%&PS$ZRL^NoDuz%nVw(p)FVMPG6Ph*mgs4z0~C)P`9 zWsg_X!-)T2FkH=VqaRBu8Zkq=AX6oLSWpg(y Q^NIftdmxjx literal 0 HcmV?d00001 diff --git a/Django博客项目/docs/imgs/pycharm_logo.png b/Django博客项目/docs/imgs/pycharm_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7f2a4b0ea66469bd218774de8cb3027a9c18b84d GIT binary patch literal 132045 zcmZTw2|U#4|DU$qY=^9^R*2bBDb|r> =Jv qn&-a$U8~l#LJ&jyHfAI(S>t8P)K52l%`RvBw0 8PF#ZVCPOerZ-T{F^v^o!^bU+DE!L z0m)bXT *H)y=^3 z$a;HC%g)N@X_wZwuj+_)rQ{FZbv>!$q^EP&B_JU1(w`32c}ac6W;C{jW_0vdjuV4q zzz>^PEu>|?gD}v^9cwc;@bI*IRO3D$y|f31i|s96XOP7(w&SKQ{S5yi4mY>2tgtuT zA~$UxU0v1Ctzo0@bJaVE(b?w+3tmgHI9zpy$$FvkubGSQOUl|AFJ1?r^>Klc4;mVL z928pj$>&I)@BNS1jOl1i(j{s7iYU8qUZJ2;P$B7Y7d_vjM|)k zL7SL|13NY6W=77^zDJ)n4zm2(ACcWq~B{s;c@RjT0^1;@RMzw)6#u%eee5IWF1j zazJn4j4Fjq2&RqM*i4xtAHzMc$~MSrcsM?GFPS+u>zZ03v0=h%@sFyJk&GJ#Q - ze#E`{z?3pmTZD~14w0vXfU)uxZ>>U~+ai&i>}~U`r1znYr=ByTf)g6XOeyo7dFVWm zl*JMH4!4zPE^~f9N_0+iT;=VAT4xKx%iPvVtgn0<`uOoOtp^5f4gOci>_?|=j79Km zBqj+g1uj~Z>wi`0?!#}uU0}LT6Batc7sA=ypBY H2WQ7vv3adItmL9B4`2@H8>hB-nmfv5xJX{t$rH!Xc z`ZopWW1r1;cMs2PsPnglkNRHQpAh#}pLLr_nF{Ap+Ojx&YjDa6*e!1n6~f!8X K1dpL*Fft>(IsO9d^wlCNU|T8XfkdZ?g}t>MXhLAh3H4 z(^liQoi~~IG69j~pdK~hhu7v_Lx)%#=6!m{Q)^vqvcqZLRNqhI6tyIino#1vjfFi= zt !QhkI5F z44m>@M<%g%yW}O>XbtZ%hb$N1d3ovWl;eowOV1~jQqj$u@ z;Zqd+A^QeTmF`c)yXln6#V3=`7?`wLnH%6V53EB$eW_U}#6vmYQPJY9)#$6Q+4wnD zKE2UdyL7Do>SIQ$m4gnxF|aJ_+@_+4f!U$&a1Zqdf^S8XJymwrZ~YcFkdjkA!MB*h z`)ulVg7lpab;Y~m6Wv==HVmv`t%_u_TP+9t`jhyR_2z6= Fvz8{&H^1sx|2XYuPCSkD_3FH*0>PH88SE;-y%^5iPFH6axOrBt_nTOSiyh)J z@{`)1=S;-;Mh;Xcrfa;ktNfvoBZSt#t=Z{57Z$yVb4I4B^HCjG@EiN;4-#CbFs7^c zF}DWJm+R+;_mzpP3OwRs^pt}y=86`MI;!D^1Q#;PZfu&D#uf?LnVig-H+ImqR3J=s zR|*X1vM!1@;`gemo}(r&Tr!WxY3_QFtvr!ox?tc59*?iyUYeskF}k=5eLQd>;kK>% z?pW${w}nH0!;D6PXrrE;&NwAcm9Cy8^ZauBu {$dry457xA+M z*WlgzcZ-Lxuh+YNJaL3FT?}7t00szILrfB->$$-OrktMn&aj%5+$ewF62|RWFju#= z0sotCy0c;6ntHnDsLlM10AaMq1D9T3Rl_454fpQV*42u&Aus4zMJAmaR;rt`G;hOc zW)F2b(do8a(YTHMUc@~T+}!u(bFC;-21><#FreNZp&TT{Cwe#hy%F5t=dE;Y`0h@W z? vF zd&8MG#ty1(;zR^Y#|q$L{u&Nwb{R@oe9vzi?4+jAw0$AR^O#yk`DV0$@e43s-ln2> zDyjeK$dUf5*Yy&F! a#d7An81=%3r^5>G1qVy7v z1B)dAGH8(pI@?<^r}mpif2jK-xwg*h+js@1TbM3APi6+l5wbMmsjd`9$l5O+k2(6Z z!A=-NXEiyx;DYPoJM{Vcf2m0jJda5crh2nG!wlxc>-d?ceAX?{ubQ8WOZ2}vz8QBx z`BB?P*4+aO;Q?I?FXDL#Zpy|%NJx*r(XcSy@@v=rsaj(P?js9__f#ktqVybJ9qQCD zcYb7Ppkxhh>yezfL}FQHxikCq`>CH*!~Jgs97JEf$Hewr oL&~c-Z%z!U(d 6_x|* zKY}dMso_;ow5s$WDMjNf*`dDmWKmJWWcV7~wp`={c~?z(m$nUh5t~ekJz Ls zf)iQxOttOtRPT!rgaxnhb>U21R^{}Z4!cu81 pk{xUI2d;JS`Oz4>s-fMM?7kjQ9>2=3ugtSY3GJ99Qw zPTdi23bD|UQ(DLia7KICYM0_}EJau_sG4Uvf@k5ylX^M>$2N0+jAV9UZfR=MJd)=Z zy;{?KmfK|#W@>6T-~H$NsUs@kWtpE@`<8~#GV+IiPRvw05TEFwuRtjEbkcK`C>AWe zuFD!L3II?vXI$6*Db25SwPxQ?yYoceX*QwTV^6R5yD;Xv8^XLI<8(c$$Xf15f2NFK zf98U_RS34k&NVFcg=Y@UBLkf8ae>v4m-=cyE*Pl73PFEvU|tEx@O?U1zA%ic+^dUQ z86D1c3WVu}s?HYos?Pjxvti8c5VHdk+|@TaUp7$lQl_k^>u|?~i@B0ze(nLy |B-}OG3QqR`@}43{T4A_>0cmuyEjhge6J J2rnhn8qCow9NNbgpkTR4 z_)JGIZ*+EsL=_d83&V~d3Gn0gXymLnpq@9iYcDPSyji^U*A0Vxim8lm!}@#8F$(fT zfWM=i67|8r7R)oVtsU|{A=PB`)R^x9Nt3w5sgu-SamU5A9%PrMG aibsWZW z{NCU+2RWFhG&ANGv_%?nn2Ox+lHK)_ACYx6KR)bGBcGQdOy2_QnhT6!F(gXIkM`(z z7*=MBc92*%R^u{yij$)$WbLPe1)?_%dV>Gl$c!bXj@xi^X+NQTJUsg2^K830+vuWf z0=TWXNO4bRD5OJfPUn)D!`O_4!(#a??z_M{?h9h!mR-l~+KVjJ@Ku7PIy5)NP!5(( zA39wnDJsQNeVi2t&sF0SMHajBFF+u =^%R_|hX_7m=)f_8I#*cIyhFqgK*{)GV*#Z9ZP7a!$>$*;jL2mJEkb$5s ztIFDh+dvh)S?5@u^4X2?=X-!q6x+^o^{g$(do{0|>F_ar=G(3cXVU0qV;2l*!3)yD zSW(T-iT&yy`F_D|cr3cEZj<94C3U|&y_}1Y%qwc?8qS*Lc{zhJWj4q(4}Q!(-+!iR z%$j->JOz(H3L&+mn*2Q}@Q{1dtDxHhD^Z=vEtB?FH{gc`gP1Qc;wx0x1wx(r5ntPDVXrylE$ z8f1llFW;GC)Fvj`h)_Vi_raiaPdv4F;)5=hZF61Zf&|q8I1(TTJUMQw110jF6%M8k zuQ$-x8aDCr2+eKj4s8e8Nwu!F+Gv=u!%(e-!w{a&Q5hx5aON(Qui2fV&28~n-2Byn zf0j55q+8NtmAZ-ddcC)p4VtlH(G3GTHcc}snkme1F)G(kOhc7sOp5)pNF%VbF~{g4 z@1+x)v>pp+;*=G0JPo;(;A*OX*J+v3UFF`Dha_ijBFb_9YuI>czi{@8g=%B}v*uD7 z2ht}i_EALXr2^y^dTv>b0*! cOA=AUpI?do#hn{*$y<1QSJ9Qf#xF*p5=L}Zw3 zLC4A1e!bn;zr7}kGc$aWGc#wznR#NVM!1iK&>KN@0y}cL$Q$n=B8Hn84Zbwh0oU9` zIN$4i>inRYonIF1H?)dwi?!%k(&2suynbKD()b;^-*~17SVfmLM7$yXbJx^j*7nx; zZ`NyZ0fi4gw|J4C4)zS+GU(w7Zf)cnSM TJJVT&H
m>dqU{rBOk=GJHn z8;YDK4v>xxbjXqVItuYztFk4*u0Rw$rNF9uH? >|B0i}?SuRF#8Iz-Iu|=n`&Bbul4mym161t >=nXp$`t@JSo^Lr9>4%l%aON7;Y7cV-)q%-` z+*~@5yy}4t<|n7#WtROJG4N42Rp8U@vSO!+qkes8BTjEmPQ(~XF}m;87BbXlE0DvP ztsB>dv*=(9(baYb!SXNCC7e{%G+BmLT=tnYS0UD5I_U4^zD0DY!UT^t6C#-(apav5 z<6iai0Et+~052$p5PtVngB)};{topIAbX5iHncT*=L=Zks=FRuYF5pKl5Q{m-qoa7 z;K`-SIW$eQ&YGQ1t*ecUIN8zBoSE(((Yj DIeeP+Y#_J6$R`>kWcR8PYUcV9`JM4${pTH z?Zn00`qZd0#J%LIXk1Yw-6b%wj%^xV&ieQxEb+?U;KOtA=&+6@ILi{}Lu5bHz+!fa zD4p-PheH8Tb?zOpYSkWhluAx-Ni=~C1*xC<5^A>Fq 9Fx_$&)Zsv+>HtN| zHsE~zpCx<_do)meuQo(I^;f{jCxN4sxt@AD=(t*}Az2syNpNAec{bX|mfSK)6`5FD zM}xkO@dqz^I`_gYp(Oy v+5#UWh@=2ponc1@&hqKzH&tJ(lFgL7>~B M}N;8#$XblyO_vJx+0NslH@%RX!Mb zZH+anyde0~i82>tTB*13>h#aX#HlJ{e;ZHtuzp?Eq!^u_X_yk%2ohB*wYGkapm7Md z^%!H&Ud7|e(?RZQade(`!G)X!1NSl?v`Y1P1<{4e3avy{a6DJ{jRV~wyv*SLR+6s0 zgW!0`)L~+;|3jQp`aeFy-{&=^$Zb47*!__)D_8axwoapMYk_!dSXP XncDl(3FJ cp0obkt?7YgjaZM=FarSAYN6Y$3wyLG+~-wKw# zB&Fe-LKh9p>(FAmhLH4}**g=N@nyzb_-&~`7k1lZ9bMLKFfGm4VJvUn_`3@Mr>6;J zzF^+P(n{d^MHF4?=U! 5(hLn%xOdxZ?X+GJ{C zXLsfd *O%cpoH;oa&h1bthruTqY0soPZaSJyb$Sg$_h|<%6E#iy!sp4n)c84#H zI*Y0f%{P8`|3H33aqj2O{`Y%2tyw3_o5&j4J}HB4<~`UN7JlOg8V $peQn1*xRN)o(3hvm^RK*67w|;=;dE6489_l*35kOo-ilH2TN=<1WmzZRL-Ek z!z3Mwi0F$Io0N`#O+M7m{VqkJPHOYtFaE7#-{9BScbU&a_ESu_q5kBcFeYJhVgfk> z!MF|AiodXW0dxptO^u3fXg7pBhidc5ZS(>D{PLY80|t*kgq)9A{lHIpE^+AeFNx3e z6euN4r7W78xshE$Z;lu&bK%Eb1+`X(-;a($M2~N1L;iv;T$;I=dpV0fu#_%K@O(-2 z!o?1AK^}uuZeBZpR=G|}6A~>SkQPUaJPG=AqROxTB{)n6tW)vc7E8Pcv3~=&F;1KA za+Pd#a}S@<{^|a+^X7Kk0XUe(h8Zu&mr-JL&)J8Vadx^F4WdL>bF!#b>GE@S3NKlj zw1MZzf%?^&q2x~KliZ %$Fk3u^vyN0#~g2T&XZan*{fHR4hK#?e^zeYRDG$r0+1FQD_@tDYfezF^n_J# zuW%WNK-ZHNG^H5+w@$Q!)6lvF$rn~bSuuIHmsM^)T$X~7#v5yoo1N7jelW~U%rqs0 zA*j1cF;yt>&5&uG<5V(Q%_+2rlU-#jyMI@FL#KVHQfgd3ARzGPy{srya9S$5yPx!;`Bx4*=Q zbIfa}cK(XBMCk%%&wRVzTlm+v`QQI8CijZ!?qomHr`DtH@A{vx1Bp@E#nN4RS9O3M z j)c|q_;`r_^9$H(jS3M z&z_ZMj}>2Xow+mNN6Ofj3N?LRg3JFdb7|TXUBZ(%H_wjsQ5>s_Qq&^PrInJN>WhbJ z8gi;kmN1av-~L@p@Rp$d0W%XjS)#uhW9fETlQqjmIlQdK6DbhOJn&sSJHs_TQNSL< zODN*98pD|(U1Qvl&TRMLO#HZ{{HqT|xypmjTeqBBe6cj~GT$(b<|0D=3>OL-Q?DvI z=Yu1?1YLLV)EY={^%bJ^{6O1z ;SmV`7OuL?GV z9+JChiZ{0>5&*5uL7+a3Zn5KA(IS_2x!7+y$*Fw_HIR~m4x|fcWfe-Z++SmoOD=$x zukY`(dP6SqCz`Y(?cgLT>al968cOLx3AxjzD4Q6^xGK4cC)Z!;POi3G^kgDk1tBM_ zfN<}heo#|Kj;8kGu3c`6(3dnfv0G%k7KPkK`%Aj3b*>&=x0Dui9NK`qtk3ql+WHMq zIdn(70wkYU{@YXQZleV^K4m|`NO8LVm8aRhR_|T2=+d)_Tt_k+R>1X1UvGEwAF@5k zu`*DgdOKOH@B|)NzVD$&cIZ9_^?mD$*mv&-9`8|N)3!EEbcUK`OZb>LPwp+d1*-%; zQ`p|BvOD$`@lLg+Qf$2y1X*m!sxDBQz_Q %ZVTAUlixzkGowUs z;r^C{#RgB*evwZ#>}d4SQlWCJAm~dKK)B~IBxJCV%R1DhO0K~aaYRw&c=+jQ@?>`F zmc#&;7l OItK z#9 N653F%BDH6q?5uEJ`QdR%rvCu@m<2Q0g9sRq&9jL*V)I3GHV#gwB6Iu+U zg}%Cdcpx?4NZ z;XJdwRd;dxwDH^94mxrl1 _y1+_k3yogCCWO8PXupJnGqT}^oz0Yh%!+|u%jLG;A{@yi+ zqhsm=AvLZOq@BDeM}x2Z$G+WKe_ZzN=!?H!z_tkDR>)A+tnJ&HmOc%gg0TwRE6tX^ zOlnkE+EL&wr!T({ +wtRPNL+6#R zO80Ach+C8{?Vuwt-NtF-jupvGoD-05I?W<1);x!NB{qmWG>gt>t5Xi@TC?YFSm?OX zCM|mvd?^vKm8TT7&AwA9wPu-LnN3J=$BM^(u+J8&iI}?ell;%8H=kSVg-xx-5M5U& zajQ0W=u&SX*%MBvx#egVvG=Pnu{o+*FEJc|p)ghrK*@c`vzCsBKS`nF+ZKQ6?}Cs1 zc;Gq2jV%6V4-}VNH~e+M(pJyCuqERZH`3gcQPD9=NC>Tg_BwH3a 7V#vgdfa0er?ws5KT zg1u%OBt3wdD8*1a|9b4+#-UB0+a$jk^DM~iiNUTsDk^GJ%dyg(uRtjGq+OHCOJM zqQ^n`r15{$bvwAs%)^;{TN~Mm>_PRT@3}JTw*GeORy65MH)B2z+~JAe_iIoFA!|zb z1T>d(a({q|a|F!(Owz|xC9Vhnl$u2nn0=*~uOj~{;bU6hYPqB2?`i++?;={hsP5h! zCzLSd`+n-qHacYEBW!IE`kbibTs>NBoiipEHyk_GxBuYf{1wl_M430y% +x@u;PUtr_2^ot`phU86m%Xue%c8TJfoBSk`5@b9 z3aeOZ56}nCvvQ4zc{6BkMmQ~;>GDtR;=7{BaD6;?v$*q%(fluKw$)BvYMzS|O2jM2 zAjkpSM3io_C6Vg~lT3)J_S5`5r0V^Mv5_95zL?~C1ye?V5d{*NS&+XhO+6Mn7IfE) zApfelRcErnLK#b@8Oo`iTO{%0s)!;UnbSA6SBdj>m%=|b>`&tpfHo;04+Zdtqqd+S zK^WPfF#5rk;@;6@IlGJha~d>HSg^ej31MK+V6|$H*Ba-~Gd?1#{RJLYneJkbC-tR; zhd}EAINpEE_9Tqy+3ML4;ArsUbAQ`wL!VA1`OSmjTGw1g^{| Wuln >xK+H zn~k_p1rF==0HG`(`G0A1e`fT+Lh@RMjL(a;7P3?E#>64f1Zc!LU}1@|kx=4qpygNy z`T2IfKjiLTajm-WQ9#prxX1CWLr9zguQ&>`1diPJaTl~LV6?k?XX3JYt*%JnyEO6< z&<~WwGCs-}tR7MNaNhbw<5u;h%Z6#%eP@ev2F+o)CAIUtpy7pFJ#0Rx$o+E-OO>1j zj`2z!GQsd#aM+8moRS{~jAd7#q#*eZKGt!n=}$w&Li6(ObMr9_T*|ZGWD4d7;0CVM z+^oCU_TvTs6P_7!PT@>x-$cVnF#*`7_E%PC%*5b%MVZ)0e+?J3$xvCW%8iksT=s}z zn>@+rI`|Zc(W2X93V*V(iQ96HSZb-vP?dlhCp~e5eTrN+Fk25~HSl-xo0bE`+v}64 zH`B)Nz6X>^-~~M 9Pbq5dL7tIjr|l?tG}ORwGbskTl#ZkYa@icz z-wQv5-J*G)m11}tlET}|l_EFcB{m)e4-IDqJZ9wo9fbs9|I61W>B#At!09$BLY4$x z+9 Hne{v_?HF@B z;qK_Yn6szt#)O$(fN4ZZ|1wNt48g1>)ZP (i+PJ&l694;v<#e|DJTrXTBAK4& zxo*&^#o@kvf5X6tk`UG*tKXm2T`hv0cBLfLT-Kq@LU^^`>-_9pz#v}y`rEMM;w$8G z*A7xldPBz)efrK1`d8%ko6K6kYJqyYT#d|P>h*2`V5G&W|C5nWtd4E=se+pJ(NdKr zj{33;-HWquxRU&UrdaKLNhM8yf}Rn>q2g?(qAsNI^NCWi%H6JX=;T hAJvz0Dxpz(o{4-Vf-@K-kzl2m)Z|0YG2GsK9_da!G8{fhy{UmK6KG( zN6C|3;i96;^GfUW9O(7$SxsekDeAqP?(ny_F?e*;OQ)Ow+CCK(3gh(P7o*GXs`y%`^wmQn37r?lN51CD7>W-> zLz)V_>5Qgr0sWO{P0~plz98QHa#`O5gi`={{k(fuaxxmDn(UC>P#dMyEDGlxJJ{&u z|M&LNHdJ17#*eTU`&&6I*xmh+k8GW+3>SOzV|{7nqOVc3t@vMvC)6>4iM=fKEyF-K zzY@%q6_0Yy5?|yuPX3+3ixJiE#m|BMcSy)%s*Rv+?Nx0X?d*103a@T43z!lEB%;tv z1i|Sh433i7e_g?5W#cY>W>EE^z^;>_3NxL+ZYVn~9&Tk545sxF7Cdt>Pf{eYR!=Bv z@ sR(Z8n?i2gR&g4ttW8=qFQKwRjER)C>#>IC63fP#0fPrV zyIrfhbg$mBKYfx;Yq4Fd!zB{3Ks~u$<49 z2jQg3n$%}tcmSta uJ#o{k%xH`r15x2eBtW`cXS z^I&t_FK0gq% !|8iO7H5WlpJH4aB z+eoE^X FOX z00yDJuC$Q=cj4xMn+IXsT&~slT864*a%p;_%RrRY(LWS6tj0otmLT1=KHQPD7L~-% zgYK8-ZVW2Bo{)<0zw!v7OH^R7(jTjoDliP=nT{ zxQbATwpwZkcjV1@ z^4;NMvxkyy&bmsqw8+XF smRrtpXp%`K2tt0n_KuQol{XBIiq5AR77f1aP2F^N z^Vu?gWdu(UI>dhpEh~Xxl`s-&o31&8NH@QFe5B%A`cg*ko{_3=cK_PYv=645p#Gq} z?tZrZT#|0qdAAks!&f2JhUg?8bkzd%de0F5ge8@C+>kL6X>!g~7t<^604xk-`3eTf zAk{_qh=c1+wH@f3uS8!Dh#am}eQ}Xi?Z?!3Gd26fuXN#LbUsFmIN|2k D`Dldp^-+M8JdX2;|uDkF8O+dbAbouz@; B=zCH_pw|59ILc>cWA;(M6vhp`}pim)U-O(=bph1QHcVwW)4VoXekiAa;t zR$pcZytI$mlPJM`)8*c^zT>3oU9kb$nl&9w(CWDCoYHr9VY$LV(^xf?Hst76NRon7 zCKcV?IrEp>Hko6Ubn}q^ I;9PfiP@RnYJ$CZPc5u zLUMz)oVtIC9Z?I=vvUy%xP0I`r7{A{A9j=_MWiues{g++&l{$UXUmp2*_wLLZ5M)G z_xy1(iPirOTytCfN<@Z`b(V@qGO!$?^V+g1ge`jJ29 {CNjHwd9Oi%H z8?&Rk4y{IZB&Wfn?6vu?+YSTWy>&~5iVs6_ieVaXg_qjghh-$<$1ITjHHB5x8H)h6 z_RERsv16K7VjFQTUdK$JJCyinHJ9>BmV>#+v+~1Ush=8sTE3Ls4T527gk(_fIg$xY z{-v+cP!sOg@;dvZ{w{Dy{i29vi1{zMubJJC6gp+cx?zhDUmNrGeK@zCuh5DIQ539&))wnh-kb zevUhwY5AM1B0~j8A*h_-Hv( *%wO~3Ml2_&;eZxLf%E;T+y pm1Zr_cDHBOwI; 4s0m{?h>2r2 z)}gVvRe++9(8p`A#ZC}elB^Ktu3H*kf#AUFAP?B+dOlH powRcTLy2KlK$WuI4npj2MN(*l+|x&3pE_y7^|gy(zv^Tq=Lsgs{X&PJYXX? t8|k*w_AF8Ndcl7YZsK zJEU=}i>T2@%b463Fnp!qys;JM-5rFoF(-Of)U=R8 d5r`UuxN5#A`h3a)Sn=vt<;zno zS->T-nw;~SzhOe@e@*9$=l-tCIv!`riUxsgVlhFgZDOAK%a*faA~z>GQNRErODCl< zT89FhpuQ^m4c5^$GI|Sao1IyN%gYLN((+^$Lbmw7Ll;}Q2J7Xr(Fiv!r-@$V+Egy{ zTYhJW(~rDX3MgY^p=Dnp?p`NpL1J9>K-y4)xW!WTa3x(Xyo2W||0+ ~|%Lz*e{YWkK>)Nsd&2 z+>`u!N1!Yw4}fDs(+;_iz#G)JUvzGJXYvX4?aj?A 6`p6w2@vhQiIda?pPI(zCP!Gb)&^hp*xiC 0v @Jy&LbU$6#fwPla;!fXQ+e9XC0G( z@df@XaDg!eOTR~e4`c@+v*`AxBgz4#{FHa^s(Rsi3dF|{j+E|#c%f&}@#jU)Mo#Ll z1 M&0Vf3P?nm(kX?Ez zFujVgz$iV|t;)H4IP^npE3w#mJsN3X|59>9T34_YN@L(e%gtg`A8M|ka@ej8o-|}F zszUl!+0}FAbF0fDELytUtpLU4irHXU>SsR+39#Dz)C@Gj@KCZDj9C|Kxf)w^ne+nU zmFopT?uyX#C&%+Fn}9)nCpEbOKf8+;o5q5I^e^s#d PVC~pB^qykuxBTD8 zlU` |C`f^WXpiJNH(l+b_GxS$Xn%-Nc G0Pd`Jj)JBgr^5qhYv?b1`{9%-5 j?A~3&|a|OJKLyEciPA4zKf6 z)<+kpJF98rBS?US&k?rX*=~aOUy@FMg9Zf#iVD^aQmh CK6>Z^lq*_w22j|JY#oF1`VD^0*U!AnguwCwh(RvZRH)>UJ0tHXGiNKid^M&%SJF zUQ?;MKH5V73wKn&X}N+6^1-Zk6f|=oAqVpkvQtBDq L z9_INxsF5w(n{4W4b7f)e8KVx^W=4fDkM5u~H4bY`+=o4G-M7i*@#T}w9hJ?_`Bk&h z$d%T(d1J(hH<`k{9nLJzsgdP2B>ZGkvoQXlo?l3;>x|VIm95ZzqC!Q(8g}qtvwY?) zTG3;W!mM@MR(GV*K0sl%8Y{J?|21R?ONw23cCxP6+*CF9{L b zGuNocA~Sv2rIrIik~KZ!)j o5=l^Yq zfMXK+v4bPWL}o iAb|QO^O*`tYJM69c Ipe5R9h%}>}ZCi4X#!9i5Wd@A3# zZ1g4&3He%VQS{bo#gH%*BlnHS(jG1=9*L^4{Zt@+ASuAn1G>dbPLO_$fFdPshKG$H z)c0z0pD%2p_V4`_4zJ0dnDug}m|hJAj~=0Wuldgy7fd}o1#^Ck!qkinm5ijLV`Y~{ z{(6(XbPyYh#uVY$F>TfRB%h)zP;p*GW6$kwrU_*9*2;y^6nF&~L3Ey0r)Q&MuJ{-D zCf`trkhwdOk3)tW8;VqA13sa3MVY*oJ}=96hjcS0e^4*0lpvu|E6&bfBy-rWzEG^*T*LI?^!8mbU(noBJIvUWR^^b!~! z{1^>-W<*(hL|O>{V{~$?a3C^)>TjvQ!di7@hvq0bgeg4a7dMzTY7F+RUl;AKx|N61 zq0uQwK2CFzQ>0Bi0m=EH@ulXNETy;hap}DeTAxUv (Wgj4r1gaR|nUb)~C|+xk$IamX!&9K&;U^1(8@0PKcL}s!iK|z0@4dN+&GxiL zC7xn(&u!ERd*#6^p4 rP(!R^$@?hQ{tUiC5i7_U% zfMOzE@xVfp01W~RF3w>%46t#(pn6!1EQQUY?7D1gV`I}l_G13b)Ge99QFzN(zv5UY z#pyKZ0t+>3csA)GUf)|VaNuqH50Fr9{ceny!4=K8oKB=Gg9H_777ybi($RvY4$Vzv zg)@e8ALieri_IZ2L`&KZdo)Zl`DV>pnt3Y-*rxV6ywrzrBu2A3oy31Xil-m^m%+&W z$xeZ(ouhARCi5y-Wk)N}V>iGusB?bQPp83~3lBrCq&iHoocWzQgdoyw)3PiuVRacA zOL?MM-ngc{EZ1J)iyn-x@Q@JUK#YO!^2?Z8Fzx9|Q@;VHJGn*6UWzi=?zXM#9)!(? z ?+e^LZSd&T&f?r@m}d` z+ASmJ7W>W^tl%{EK@%D(wkfEe5GW^0s~1+6n&4-gptztC4XpT!HLEWuli;NSsC?HB z_z{;Rx1{*!h%^mRMeqr 9S{=*$x9LSw>6N>ZcOm0{ z2ZP#qptrj6JD)43pInYo@OiA?fjGyu?hyae?7zMRiBZL$p?rj>+G^n@=t=5Ga6Q$F zz_L9*YuQ)y7nmG)O9w?1bc4N!2*s^}Xj~cfjfH`a(_iNr&2J})W{$j6tCc>sZYua9 z;8!veO(dW@V+`Cb`=||EVC$tU2Rn^@VwSRiV0GbVcz9@Yr8|T%j8Q3eT%YQj{Ia8X zQE=x_OwIuI1EX$Zx!`vcE3nZ|R-v?mY1 v&LCRqtMcax#w8- z07#y^jX>8+g_LGqnA3NRq_hgg &RD{^m|8aPhe-UK7 nnBORGle(BpI78_oyEWs-qKnZ;^!kAf}p20PR0W+bz zu@eZT3|~$pQ$}O*dr+x2!b8{E!9nGO1W;;4H}Conxxl1LS~@$08k>ke>*?pyh*rR2 zNV==W>#&^9&(Noz+f=Me_}a1fcsrgf37}W>XUzM-^N_P(J1K24OevQqNal~e^lL;q z2G5{z!=c^iv89(hEaNMTa0N`}c&kJdh;H14G%qMu%d>L816y)B&F@mTWU@_FERZpH zNug9tk9BvzR|{4+aNq#HI{lXDmx3!`&xSwtRKX=Ix4(EAC(Kw=JNC=CZQd k{M^EZ^i{ajBTg;oQHwg3o7_7n{6@wD-)xkHj|3EMn=KGb*>@K$T!Hp zZ$T|7xEFXP*0)e+;$yQ8rwP-j@E}l|)B=5(lJLKxkb4QQBo)+{vGKygq}_>G&2f`A zoo~`B6HGvfL3TkN>;HM0AUDl|-3 tzVOw_!hrfW=L=1p8feQHk*34}kq(wE#oEXuQkJ-lT}y zUHa?!vW2`kv43&7VcNK9jicI$A8_|~WVpl&W%{Nbf?AUEl=Pou%bR+z*+yU5N09To zSeYfDf;4U3SsZkhe3Lt5eQsvuK>57B79RVFd?{Eu{Epth@5kE*&o0O ;gYz;9{?+a7p&powzH%82^baaG$3*OZ+GB`WWo0lEx(eXM0r-X`Mt^~P zq2pw)>Kn8Nq=Q0%eIrPY&3Xdcc-cHP+F<`dZwl(6(6!{97*x9t+-Vt4LfTy--j{r~ zzJkxUeDdprUpcC&ko?hX8{ANUrXwdK>nW_1j=dTfv-TyaMEGC2AN~IlEwtg|s%3xN zROo+kX+_tE-shFWG>UYW3Cy2v)iQXTl?O|OaVgCFU}YO *Q=1xCF~30b9Tn;Nc~_N)VleUjeTHCSv=-9n z5D|eGWdt+~Qbfz1EHq!NhuL0Kk4`zEqruo}m>`)0@2Y(+tcRw%G7LR*G&avvxox`= z?uhvgfW_6wZxH>!`>6$-$dx#s*Lzbw%W?8@_}dl5=c8YS?-i=9xtZ1C6UjnQv2(uO zW#XHi6;+{(D80~M#@j0~wT7iFN;jua1v5uqjzCPoV|!F-OW%;t(1|sx!v0VSM}*`A zzgOdejR0zIE0x${>2Gl%+CnpKMX^WcgQrOn-13=Y{CQm2wgLD-m1P^Dv(cWw6HJmT zETS+t@-ZvZSHUEs_kq 0OA8;3(f{y$8 zp!b2w#Y9NITbp hr2CPPbq94_RsaHIs Usfio}MQN&@WSM$X6DRM;sqc l#p8sk}dRD6J1kpwsa7E-}Z9;reL^??8OTV_>};% zbwF>q*AQI#D=5{VSv-dRv9GQI+!PuSf{|sUccM4IuZ8K `Oj&mh zhMQ3&xX%{*ttsEH#ytsGek;h 4TnSm5H#`7n9=fedXGiojUx zSoCsG0F)^*DHcj7?@5uz;YcS F3VgL0BbH-!8uCcEtWsq|?)nE@$;qT}Y_s=}p?IspaIU z82J)MmwW29*h5T=CqrcT!0#^! wPBtpP)ckHsGrWJF{6`aSiT)7WQbcx1a|4CUsACfw9rCcp4}o zNMt4-%h<#0h1c)^Rgn_;6+t~-LL;PVOo*`G6MTJOM~D71j?i##7s@~o3&;U@6H0iB zAlZ-I>BK2$81qzB$ZuHk?gJSi2PkpiePl#3 zBH soMu8Td1?uF>f0B2q|309BLFa(Ee%m!@bPx-4!BXfMkOc-MG*ab8 zZG9pn8( ->Q}Sf4`h=a=K!CKDXQpWg;<#0Ck37X!EUCq+Napp0lAE}9^-4{ zzPGFYt$ JRNi)rLDppIrZc7EcP)@obfjD0Z53#2|Jeo{)+3Eot5BfeC@vu zpt%3(gx?EYR@*5w5wh%t6$ewz&)J=2zE;F(-atRob5bQjq?xnF@%-}f?t_TJbDoTY zsiY6?*Bi!XTcXj;qs&Ung;-@(!>=Sg`aEAwIldYP^A8l;#M{-D{lGWGuSM|^^h)zR z)T~&7+r|;@Nl1 gg-(M>JO8!!aG~b_Wqxtq6Xd>?%+7rVBxA)>!QX@A>aw6KirS zozb`{NSGYA0V)M+1&^AQO8C5^=#EoaTBIkdaKYL?J|Fb!ux)~8fzbX}{6(La2OHtK z5Eg_VdZ7v~h@HzNK0@OXysQq4xiz%xCA< $A$Q=sCj10rEI_kYy=v3@xX3v(rP|Mfgdm2@MlZEZ9T}@Vo)JSYqo>u zU~}X=2nT%7QPYu05#8-D*Li=rVH`X5(H+5yKA2#WQ8XPMRDx!&H#WS;nJLWjO~s1@ z?nLP(<)3u37ySxB!-u-HCU#4;x5J LJW=Ih)EZwoi z@-%qNs>_kQ%l-%AvL^BLhWdsdTSs2{eg2o#!I3iHNV0~+cdMOnf!c2E!Jjq(z0?D_ zX7s%nFQ$0P1R7B=sSjMN_Bw0D@V35p;JbfBMC8MA$=k`+Px~?`*WougdB4ce$?MSH zm%tJZ?|A|?_G|JfbTOE+=h%43&f1~G`4?eWSpe`a;j<-zxlo7yrn3$A_pc{8%qzj$ zczgJFtfO>Nm*r_!#MQ6hz=YoakaZp4RKH)GR2m|wY3Mpd+x@=Z^PYV^=e+Ml9*=~JEN(%M z>Q13Yzf&!&2=|>K2e;Kwn;c9p-6k#O_#W>8w^i{uK~ 8yb)nyj zY^}Ac=zvZ)*TSRDgvojKFv(G{RvcWRx_BKR6HYX1gX7JvcS0v7Rbl&|s1{mwh^=~7 z($ofwDcGEn<&ovOo m1+l8`qJkcB$<-{I+;#la)IOGtn2xZ z&0mSly?q9$pwj#~{5NFje3&y}uaiDlC6B5Xhy{!22%I;^(N>ZWD$ukz$$dhvpir-( zNgkcW2Iq^WFu{N3@pbv{4J!Qk8ARZWTKu=(zaYCz61)yoq6~5mrpP_GTOmK?ltD^r z9h$tcO?E8#u^qlZK7lFBDCkBsJPm^&=$IR+Nl~GPTml2QgzInG#3U-&9#F2jT%@(9 zJ+Ut6+M^Tf)D*4mZr3f7UMQ>veJ=ZXFCd=EekSB!SuQatD3^tOyapWxNu6c#kir8` ztjENgH#kc+iW{JcDmZ5 yXC`>Mt_Y2$~~Adeu{Nets@<_gzds2#=#yodt7%n8Oi` zhj_f!zZ-16%H*bf&s%EZR5_GaG(*$iI2Mum3bEELY8F6JO7W>+GcFYCuE0zV=xf8} zK<{!jzpeyKj*$ol-6J~XhgTs*g7?<$%p9N*`djUl^yE~mYqoL@6Z&o T{osxx&a3UL|*E#%oNuGbGA3p_Ul(MByyMavsNCA>)4&6 zg_{ -)kC@L6eFbu2u62l95y~PvX=2C3 z>2{C14tq>3YmR~Y(+(M%uPC-%h@xA3qEkwQDJq D3wpyX~$>H?yfLIiKp{d`gYcl8}m zsidSp9!;|U*;)olM7udVyR!!>z$0+`i0Uy}prDAPjKt=AfNNo@;OG4l$EYddbmTi{ zIo tBus2Fo+3guPh?IYw<>=$l2J=$;;97OUQ$1QNKcilYO9ke zgs%!XVT;tFfezXGwZtPNL=`7`(r=LV8tDwdvp3`>o?c~AT~%??^e9{bf~~2l*%H?A zE0%~?64L?F&hteSaX+=B$&}N19{y8IoKMlUHTHMCv4Yhq+jYC`=5LaQ+;rI0b5yKJ z@RwlNJ_6mo#zuQXS_;*9R}OtISc8ZTqVXT-GdMwV6mKuIuidVV-fQr+Xr%7tCbVPJ zZ;S6ghd(Fl1rYmN6?`Kb#rs)Gf~h=NY^&f0!lzipRJLAO6ksPE3Wl-`p;M5qmp>`6 zG??Z)Aqrw^My SGWomL!kjH=nF^cj(r1ZrK9WpSXEa2kvcK>#?-n4 z;0ZEk$kY}EXar?_d+bOJRiL`$c|lrh{Q&WC828np;ep~JlvrF4JE=G-8wJy=S;mX6 zZ?==>kWu1D?rONZoC?G7sZ>+C8gkUiVnkkmk`tQ }VC2iA7m44o{4e2wT*Na9NV0(hJ5^y$!0 zDhf5Y|7L`Sd#xTLrc5jOHm37a`{)Lnjv@~xvhY=BNT!XX4xmPEz0(~xoCYyR5`m)rF75?OUE%l;$k_>gh@q^`Nnw>_VnYCD zDEqkkdC;DNrn$pU&}0vhUG>b6A0Z `^~2++8-cPL@)~eME+=-|1jpE6cvuaF^#a`JSDA&99QElZ%Xv$<$!v(z z#6BxIXrbC7zY}ijY8T7?e8mzbJfrCyPG&bJI?9inw~FT3t4k9muGkuSIK8ep@jk zvH*wiSfx*cX&A}XWahUVBGXN(igdhUYIVC99%qF3(-NChNWIB UrfTfCe~+C01$_* zc73B^vm)V&tX}`1&FXu?d 1F>(n4fb=8&jyhXEo+0>6 zg&kNMW;uj(kj4ffQH P9x6=$_ap7B1{D }IW-ZwPnXoi)E469dGniA#rCc-N61CQTG1r_mFX zgOX{-9*V#*7!>W+GIukLk-MPMdswDSRxp!*5v|SQ*>ihgCx!|2;dGESLtaO=e|u3l zq_K^k5c&?-yf4H@K*Sa~98@bM4Wn(o77CBKpIYRe>FoS<&21p10_;K*rhU;NDBLL! z@DgP{&qBh-KetJr-w7~(c0h!AhZ< K&vc4z8?aB9iV@IB{~q%>Dt-FooOz z9o8J<9kFh+f`jyggeA{=ZL@sxi`7b;?6rc4&-Rg1K}GS%H6_(RaoR2ut+R&)Ya+%h zK@>T3@QC-DB!S5L@Ak%xg>3_b%;|cQ5OKPVJXeBp%6(37kWD!BJuu>ExS|* 5VLnaIITf!o5|3?=+v!>8tF^a6uer1u8HTgWUVcNj^GK#&=aaa z>H!aFh!UhLeZEd-C&p(lf*9@?%%0NoVgXxOJNtm1)Yd8xK^G{JJs{~HX#a>s){HFv zxFW3que~boSjDWc1KB8qD FF}JH>Z}CouHl@u)$v+Q4b&%)^M~~RHkZBml&oC} z>^s4-U`%xL>f^+qli!Hs&wR5lBi?%rC?Wb_(1L{*JBE~=`F2ly1C7mwoU+5glbLAa zDBeNb5gb6GPeY=ohN}=r@DAA%E8vRRP>qR24}ZKYeJORHeErF?f>F_kaR>D?EYYO| zAKn<1vn(%U2y+Ee*R>_iJ7L}|j2^XWqPJ?s%diXRI9!@*T+tDac3R+ZSor$EYGBJu z06jLfxK>TR+dZstFz$T~oxCnngeG;n3chI*)vyOwqrY30p!y|Ui94JuFOxX>k|Z9< z;H&V?sqfxl@@QM@nyD!cwNOe|pFc)>PV$;nPwxK4#>O)*51fU+lD7ScbQBlgLGN3? z^dzJO=);#_p|ligaA>SYl=hrZxDx5Rn=L7hhL_bPdp-o{iS*G+N_Le0TE>`Pg!Gs6 znNT>~%?}q{Lr<$i{1DhWX-Vc<%J4YuX?59-qw7BW P_