From 112e0a179f7e77271b3a2837bed266301a2e3506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=8B=E5=B0=A7=E5=90=9B?= <2939334239@qq.com> Date: Sat, 27 Sep 2025 19:34:16 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=B8=BAOAuth=E5=92=8COwnTracks=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E6=B7=BB=E5=8A=A0=E8=AF=A6=E7=BB=86=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=EF=BC=8C=E6=8F=90=E5=8D=87=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=8F=AF=E8=AF=BB=E6=80=A7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inspectionProfiles/profiles_settings.xml | 6 + .idea/vcs.xml | 6 + .idea/workspace.xml | 92 +++++ .idea/zyd2025.iml | 7 + src/accounts/models.py | 38 +- src/accounts/views.py | 59 ++- src/blog/management/commands/build_index.py | 7 + src/blog/models.py | 352 +++++++++++++++--- src/blog/templatetags/blog_tags.py | 97 ++++- src/blog/views.py | 51 ++- src/comments/models.py | 27 +- src/comments/views.py | 21 +- .../plugin_manage/hook_constants.py | 16 +- src/djangoblog/plugin_manage/hooks.py | 8 +- src/djangoblog/plugin_manage/loader.py | 10 +- src/djangoblog/settings.py | 6 + src/djangoblog/urls.py | 2 +- src/djangoblog/utils.py | 82 +++- src/oauth/admin.py | 60 ++- src/oauth/apps.py | 6 + src/oauth/forms.py | 14 + src/oauth/migrations/0001_initial.py | 31 +- ...ptions_alter_oauthuser_options_and_more.py | 75 +++- .../0003_alter_oauthuser_nickname.py | 14 +- src/oauth/models.py | 61 ++- src/oauth/oauthmanager.py | 336 +++++++++++++++++ src/oauth/tests.py | 98 +++++ src/oauth/urls.py | 5 + src/oauth/views.py | 54 ++- src/owntracks/admin.py | 6 + src/owntracks/apps.py | 6 + src/owntracks/migrations/0001_initial.py | 18 +- ...0002_alter_owntracklog_options_and_more.py | 13 +- src/owntracks/models.py | 24 +- src/owntracks/tests.py | 27 ++ src/owntracks/urls.py | 4 + src/owntracks/views.py | 58 +++ src/plugins/article_copyright/plugin.py | 17 +- src/plugins/external_links/plugin.py | 16 +- src/plugins/reading_time/plugin.py | 12 +- src/plugins/seo_optimizer/plugin.py | 38 +- src/plugins/view_count/plugin.py | 15 +- src/servermanager/models.py | 45 ++- 43 files changed, 1795 insertions(+), 145 deletions(-) create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/workspace.xml create mode 100644 .idea/zyd2025.iml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..5c327aa --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + "associatedIndex": 8 +} + + + + { + "keyToString": { + "ModuleVcsDetector.initialDetectionPerformed": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", + "RunOnceActivity.git.unshallow": "true", + "git-widget-placeholder": "main", + "last_opened_file_path": "E:/zyd2025", + "settings.editor.selected.configurable": "preferences.pluginManager" + } +} + + + + + + + + + + 1758455435191 + + + + \ No newline at end of file diff --git a/.idea/zyd2025.iml b/.idea/zyd2025.iml new file mode 100644 index 0000000..0070e87 --- /dev/null +++ b/.idea/zyd2025.iml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/src/accounts/models.py b/src/accounts/models.py index 3baddbb..0dbb726 100644 --- a/src/accounts/models.py +++ b/src/accounts/models.py @@ -9,20 +9,56 @@ from djangoblog.utils import get_current_site # Create your models here. class BlogUser(AbstractUser): + """ + 博客用户模型类 + 继承自Django的AbstractUser,扩展了额外的用户信息字段 + + Attributes: + nickname (CharField): 用户昵称,最大长度100个字符,可为空 + creation_time (DateTimeField): 用户创建时间,默认为当前时间 + last_modify_time (DateTimeField): 用户信息最后修改时间,默认为当前时间 + source (CharField): 用户创建来源,最大长度100个字符,可为空 + + Meta: + ordering: 按照id倒序排列 + verbose_name: 用户的可读性名称 + verbose_name_plural: 用户的复数形式名称 + get_latest_by: 指定用于latest()查询的字段 + """ nickname = models.CharField(_('nick name'), max_length=100, blank=True) creation_time = models.DateTimeField(_('creation time'), default=now) last_modify_time = models.DateTimeField(_('last modify time'), default=now) source = models.CharField(_('create source'), max_length=100, blank=True) def get_absolute_url(self): + """ + 获取用户详情页URL + 通过reverse函数解析URL,使用author_detail命名URL模式 + + Returns: + str: 用户详情页的相对URL路径 + """ return reverse( 'blog:author_detail', kwargs={ 'author_name': self.username}) def __str__(self): + """ + 模型的字符串表示 + + Returns: + str: 用户邮箱地址 + """ return self.email def get_full_url(self): + """ + 获取用户页面的完整URL(包含域名) + 用于构建完整的用户页面链接,包含协议和域名 + + Returns: + str: 完整的用户页面URL,格式为 https://{site}{path} + """ site = get_current_site().domain url = "https://{site}{path}".format(site=site, path=self.get_absolute_url()) @@ -32,4 +68,4 @@ class BlogUser(AbstractUser): ordering = ['-id'] verbose_name = _('user') verbose_name_plural = verbose_name - get_latest_by = 'id' + get_latest_by = 'id' \ No newline at end of file diff --git a/src/accounts/views.py b/src/accounts/views.py index ae67aec..752475d 100644 --- a/src/accounts/views.py +++ b/src/accounts/views.py @@ -32,14 +32,25 @@ logger = logging.getLogger(__name__) # Create your views here. class RegisterView(FormView): + """ + 用户注册视图类 + 处理用户注册表单提交和验证邮箱功能 + """ form_class = RegisterForm template_name = 'account/registration_form.html' @method_decorator(csrf_protect) def dispatch(self, *args, **kwargs): + """ + 调度方法,添加CSRF保护装饰器 + """ return super(RegisterView, self).dispatch(*args, **kwargs) def form_valid(self, form): + """ + 处理有效的注册表单 + 保存用户信息,发送验证邮件 + """ if form.is_valid(): user = form.save(False) user.is_active = False @@ -81,19 +92,33 @@ class RegisterView(FormView): 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): + """ + 处理GET请求,执行登出操作 + """ 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 = '/' @@ -104,10 +129,16 @@ class LoginView(FormView): @method_decorator(csrf_protect) @method_decorator(never_cache) def dispatch(self, request, *args, **kwargs): + """ + 调度方法,添加敏感参数保护、CSRF保护和不缓存装饰器 + """ return super(LoginView, self).dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): + """ + 获取上下文数据,处理重定向URL + """ redirect_to = self.request.GET.get(self.redirect_field_name) if redirect_to is None: redirect_to = '/' @@ -116,6 +147,10 @@ class LoginView(FormView): return super(LoginView, self).get_context_data(**kwargs) def form_valid(self, form): + """ + 处理有效的登录表单 + 进行用户认证并登录 + """ form = AuthenticationForm(data=self.request.POST, request=self.request) if form.is_valid(): @@ -133,6 +168,9 @@ class LoginView(FormView): }) def get_success_url(self): + """ + 获取登录成功后的重定向URL + """ redirect_to = self.request.POST.get(self.redirect_field_name) if not url_has_allowed_host_and_scheme( @@ -143,6 +181,10 @@ class LoginView(FormView): def account_result(request): + """ + 账户操作结果视图函数 + 处理注册和邮箱验证的结果页面显示 + """ type = request.GET.get('type') id = request.GET.get('id') @@ -176,10 +218,18 @@ def account_result(request): 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"]) @@ -190,8 +240,15 @@ class ForgetPasswordView(FormView): class ForgetPasswordEmailCode(View): + """ + 忘记密码邮箱验证码视图类 + 处理通过邮箱发送验证码的请求 + """ def post(self, request: HttpRequest): + """ + 处理POST请求,发送验证码到用户邮箱 + """ form = ForgetPasswordCodeForm(request.POST) if not form.is_valid(): return HttpResponse("错误的邮箱") @@ -201,4 +258,4 @@ class ForgetPasswordEmailCode(View): utils.send_verify_email(to_email, code) utils.set_code(to_email, code) - return HttpResponse("ok") + return HttpResponse("ok") \ No newline at end of file diff --git a/src/blog/management/commands/build_index.py b/src/blog/management/commands/build_index.py index 3c4acd7..a4e315e 100644 --- a/src/blog/management/commands/build_index.py +++ b/src/blog/management/commands/build_index.py @@ -6,9 +6,16 @@ from blog.documents import ElapsedTimeDocument, ArticleDocumentManager, ElaspedT # TODO 参数化 class Command(BaseCommand): + """ + Django管理命令,用于构建Elasticsearch搜索索引 + """ help = 'build search index' def handle(self, *args, **options): + """ + 执行命令时的处理逻辑 + 如果启用了Elasticsearch,则构建性能和文章的索引 + """ if ELASTICSEARCH_ENABLED: ElaspedTimeDocumentManager.build_index() manager = ElapsedTimeDocument() diff --git a/src/blog/models.py b/src/blog/models.py index 083788b..3efbbc1 100644 --- a/src/blog/models.py +++ b/src/blog/models.py @@ -18,6 +18,14 @@ logger = logging.getLogger(__name__) class LinkShowType(models.TextChoices): + """ + 链接显示类型枚举类 + I: 首页显示 + L: 列表页显示 + P: 文章页显示 + A: 所有页面显示 + S: 幻灯片显示 + """ I = ('i', _('index')) L = ('l', _('list')) P = ('p', _('post')) @@ -26,11 +34,35 @@ class LinkShowType(models.TextChoices): 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) + """ + 基础模型类 + + 提供所有模型共有的字段和方法, + 包含主键、创建时间和最后修改时间字段 + + Attributes: + id (AutoField): 主键字段 + creation_time (DateTimeField): 创建时间 + last_modify_time (DateTimeField): 最后修改时间 + """ + id = models.AutoField( + primary_key=True, + help_text='主键ID') + creation_time = models.DateTimeField( + _('creation time'), + default=now, + help_text='记录创建时间') + last_modify_time = models.DateTimeField( + _('modify time'), + default=now, + help_text='记录最后修改时间') def save(self, *args, **kwargs): + """ + 重写保存方法 + 如果是更新文章浏览量,则只更新浏览量字段 + 否则,处理slug字段并调用父类保存方法 + """ is_update_views = isinstance( self, Article) and 'update_fields' in kwargs and kwargs['update_fields'] == ['views'] @@ -45,6 +77,10 @@ class BaseModel(models.Model): super().save(*args, **kwargs) def get_full_url(self): + """ + 获取完整URL地址 + :return: 完整的URL地址,包含域名 + """ site = get_current_site().domain url = "https://{site}{path}".format(site=site, path=self.get_absolute_url()) @@ -55,6 +91,10 @@ class BaseModel(models.Model): @abstractmethod def get_absolute_url(self): + """ + 抽象方法,子类必须实现 + 返回模型对象的绝对URL路径 + """ pass @@ -72,40 +112,79 @@ class Article(BaseModel): ('a', _('Article')), ('p', _('Page')), ) - title = models.CharField(_('title'), max_length=200, unique=True) - body = MDTextField(_('body')) + title = models.CharField( + _('title'), + max_length=200, + unique=True, + help_text='文章标题,必须唯一') + body = MDTextField( + _('body'), + help_text='文章正文内容,支持Markdown语法') pub_time = models.DateTimeField( - _('publish time'), blank=False, null=False, default=now) + _('publish time'), + blank=False, + null=False, + default=now, + help_text='文章发布时间') status = models.CharField( _('status'), max_length=1, choices=STATUS_CHOICES, - default='p') + default='p', + help_text='文章状态:草稿或已发布') 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) + default='o', + help_text='评论状态:开放或关闭') + type = models.CharField( + _('type'), + max_length=1, + choices=TYPE, + default='a', + help_text='文章类型:普通文章或页面') + views = models.PositiveIntegerField( + _('views'), + default=0, + help_text='文章浏览量') author = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_('author'), blank=False, null=False, - on_delete=models.CASCADE) + on_delete=models.CASCADE, + help_text='文章作者') article_order = models.IntegerField( - _('order'), blank=False, null=False, default=0) - show_toc = models.BooleanField(_('show toc'), blank=False, null=False, default=False) + _('order'), + blank=False, + null=False, + default=0, + help_text='文章排序') + show_toc = models.BooleanField( + _('show toc'), + blank=False, + null=False, + default=False, + help_text='是否显示目录') category = models.ForeignKey( 'Category', verbose_name=_('category'), on_delete=models.CASCADE, blank=False, - null=False) - tags = models.ManyToManyField('Tag', verbose_name=_('tag'), blank=True) + null=False, + help_text='文章分类') + tags = models.ManyToManyField( + 'Tag', + verbose_name=_('tag'), + blank=True, + help_text='文章标签') def body_to_string(self): + """ + 将文章内容转换为字符串 + :return: 文章内容字符串 + """ return self.body def __str__(self): @@ -118,6 +197,10 @@ class Article(BaseModel): get_latest_by = 'id' def get_absolute_url(self): + """ + 获取文章详情页的URL + :return: 文章详情页URL + """ return reverse('blog:detailbyid', kwargs={ 'article_id': self.id, 'year': self.creation_time.year, @@ -127,19 +210,34 @@ class Article(BaseModel): @cache_decorator(60 * 60 * 10) def get_category_tree(self): + """ + 获取分类目录树 + :return: 分类目录树列表 + """ 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): + """ + 获取文章的评论列表 + 使用缓存机制提高性能 + :return: 评论列表 + """ cache_key = 'article_comments_{id}'.format(id=self.id) value = cache.get(cache_key) if value: @@ -152,24 +250,37 @@ class Article(BaseModel): return comments def get_admin_url(self): + """ + 获取文章在管理后台的编辑URL + :return: 管理后台编辑URL + """ 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: 下一篇文章对象 + """ # 下一篇 return Article.objects.filter( id__gt=self.id, status='p').order_by('id').first() @cache_decorator(expiration=60 * 100) def prev_article(self): + """ + 获取上一篇文章 + :return: 上一篇文章对象 + """ # 前一篇 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: + 从文章正文中获取第一张图片的URL + 使用正则表达式匹配Markdown格式的图片 + :return: 第一张图片的URL,如果没有找到则返回空字符串 """ match = re.search(r'!\[.*?\]\((.+?)\)', self.body) if match: @@ -179,15 +290,27 @@ class Article(BaseModel): class Category(BaseModel): """文章分类""" - name = models.CharField(_('category name'), max_length=30, unique=True) + name = models.CharField( + _('category name'), + max_length=30, + unique=True, + help_text='分类名称,必须唯一') parent_category = models.ForeignKey( 'self', verbose_name=_('parent category'), blank=True, null=True, - on_delete=models.CASCADE) - slug = models.SlugField(default='no-slug', max_length=60, blank=True) - index = models.IntegerField(default=0, verbose_name=_('index')) + on_delete=models.CASCADE, + help_text='父级分类') + slug = models.SlugField( + default='no-slug', + max_length=60, + blank=True, + help_text='分类slug,用于生成URL') + index = models.IntegerField( + default=0, + verbose_name=_('index'), + help_text='分类索引,用于排序') class Meta: ordering = ['-index'] @@ -195,6 +318,10 @@ class Category(BaseModel): verbose_name_plural = verbose_name def get_absolute_url(self): + """ + 获取分类详情页URL + :return: 分类详情页URL + """ return reverse( 'blog:category_detail', kwargs={ 'category_name': self.slug}) @@ -206,7 +333,7 @@ class Category(BaseModel): def get_category_tree(self): """ 递归获得分类目录的父级 - :return: + :return: 包含当前分类及其所有父级分类的列表 """ categorys = [] @@ -222,7 +349,7 @@ class Category(BaseModel): def get_sub_categorys(self): """ 获得当前分类目录所有子集 - :return: + :return: 包含当前分类及其所有子分类的列表 """ categorys = [] all_categorys = Category.objects.all() @@ -242,17 +369,33 @@ class Category(BaseModel): class Tag(BaseModel): """文章标签""" - name = models.CharField(_('tag name'), max_length=30, unique=True) - slug = models.SlugField(default='no-slug', max_length=60, blank=True) + name = models.CharField( + _('tag name'), + max_length=30, + unique=True, + help_text='标签名称,必须唯一') + slug = models.SlugField( + default='no-slug', + max_length=60, + blank=True, + help_text='标签slug,用于生成URL') def __str__(self): return self.name def get_absolute_url(self): + """ + 获取标签详情页URL + :return: 标签详情页URL + """ return reverse('blog:tag_detail', kwargs={'tag_name': self.slug}) @cache_decorator(60 * 60 * 10) def get_article_count(self): + """ + 获取使用该标签的文章数量 + :return: 文章数量 + """ return Article.objects.filter(tags__name=self.name).distinct().count() class Meta: @@ -264,18 +407,38 @@ class Tag(BaseModel): class Links(models.Model): """友情链接""" - name = models.CharField(_('link name'), max_length=30, unique=True) - link = models.URLField(_('link')) - sequence = models.IntegerField(_('order'), unique=True) + name = models.CharField( + _('link name'), + max_length=30, + unique=True, + help_text='链接名称') + link = models.URLField( + _('link'), + help_text='链接地址') + sequence = models.IntegerField( + _('order'), + unique=True, + help_text='链接排序') is_enable = models.BooleanField( - _('is show'), default=True, blank=False, null=False) + _('is show'), + default=True, + blank=False, + null=False, + help_text='是否显示') 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) + default=LinkShowType.I, + help_text='链接显示类型') + creation_time = models.DateTimeField( + _('creation time'), + default=now, + help_text='链接创建时间') + last_mod_time = models.DateTimeField( + _('modify time'), + default=now, + help_text='链接最后修改时间') class Meta: ordering = ['sequence'] @@ -288,12 +451,29 @@ class Links(models.Model): 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) + name = models.CharField( + _('title'), + max_length=100, + help_text='侧边栏标题') + content = models.TextField( + _('content'), + help_text='侧边栏内容,支持HTML') + sequence = models.IntegerField( + _('order'), + unique=True, + help_text='侧边栏排序') + is_enable = models.BooleanField( + _('is enable'), + default=True, + help_text='是否启用') + creation_time = models.DateTimeField( + _('creation time'), + default=now, + help_text='侧边栏创建时间') + last_mod_time = models.DateTimeField( + _('modify time'), + default=now, + help_text='侧边栏最后修改时间') class Meta: ordering = ['sequence'] @@ -311,53 +491,103 @@ class BlogSettings(models.Model): max_length=200, null=False, blank=False, - default='') + default='', + help_text='网站名称') site_description = models.TextField( _('site description'), max_length=1000, null=False, blank=False, - default='') + default='', + help_text='网站描述') site_seo_description = models.TextField( - _('site seo description'), max_length=1000, null=False, blank=False, default='') + _('site seo description'), + max_length=1000, + null=False, + blank=False, + default='', + help_text='网站SEO描述') 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) + default='', + help_text='网站关键词') + article_sub_length = models.IntegerField( + _('article sub length'), + default=300, + help_text='文章摘要长度') + sidebar_article_count = models.IntegerField( + _('sidebar article count'), + default=10, + help_text='侧边栏文章显示数量') + sidebar_comment_count = models.IntegerField( + _('sidebar comment count'), + default=5, + help_text='侧边栏评论显示数量') + article_comment_count = models.IntegerField( + _('article comment count'), + default=5, + help_text='文章评论显示数量') + show_google_adsense = models.BooleanField( + _('show adsense'), + default=False, + help_text='是否显示Google广告') 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='') + _('adsense code'), + max_length=2000, + null=True, + blank=True, + default='', + help_text='Google广告代码') + open_site_comment = models.BooleanField( + _('open site comment'), + default=True, + help_text='是否开放站点评论') + global_header = models.TextField( + "公共头部", + null=True, + blank=True, + default='', + help_text='公共头部代码') + global_footer = models.TextField( + "公共尾部", + null=True, + blank=True, + default='', + help_text='公共尾部代码') beian_code = models.CharField( '备案号', max_length=2000, null=True, blank=True, - default='') + default='', + help_text='网站备案号') analytics_code = models.TextField( "网站统计代码", max_length=1000, null=False, blank=False, - default='') + default='', + help_text='网站统计代码,如百度统计') show_gongan_code = models.BooleanField( - '是否显示公安备案号', default=False, null=False) + '是否显示公安备案号', + default=False, + null=False, + help_text='是否显示公安备案号') gongan_beiancode = models.TextField( '公安备案号', max_length=2000, null=True, blank=True, - default='') + default='', + help_text='公安备案号') comment_need_review = models.BooleanField( - '评论是否需要审核', default=False, null=False) + '评论是否需要审核', + default=False, + null=False, + help_text='评论是否需要审核') class Meta: verbose_name = _('Website configuration') @@ -367,10 +597,18 @@ class BlogSettings(models.Model): 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() + cache.clear() \ No newline at end of file diff --git a/src/blog/templatetags/blog_tags.py b/src/blog/templatetags/blog_tags.py index d6cd5d5..6c46dad 100644 --- a/src/blog/templatetags/blog_tags.py +++ b/src/blog/templatetags/blog_tags.py @@ -27,11 +27,23 @@ register = template.Library() @register.simple_tag(takes_context=True) def head_meta(context): + """ + 头部元数据标签 + 通过插件系统应用过滤器生成头部元数据 + :param context: 模板上下文 + :return: 头部元数据HTML + """ return mark_safe(hooks.apply_filters('head_meta', '', context)) @register.simple_tag def timeformat(data): + """ + 时间格式化标签 + 将时间数据格式化为设置中指定的时间格式 + :param data: 时间数据 + :return: 格式化后的时间字符串 + """ try: return data.strftime(settings.TIME_FORMAT) except Exception as e: @@ -41,6 +53,12 @@ def timeformat(data): @register.simple_tag def datetimeformat(data): + """ + 日期时间格式化标签 + 将时间数据格式化为设置中指定的日期时间格式 + :param data: 时间数据 + :return: 格式化后的日期时间字符串 + """ try: return data.strftime(settings.DATE_TIME_FORMAT) except Exception as e: @@ -51,11 +69,23 @@ def datetimeformat(data): @register.filter() @stringfilter def custom_markdown(content): + """ + 自定义Markdown转换过滤器 + 将Markdown格式的内容转换为HTML + :param content: Markdown格式的内容 + :return: 转换后的HTML内容 + """ return mark_safe(CommonMarkdown.get_markdown(content)) @register.simple_tag def get_markdown_toc(content): + """ + 获取Markdown目录标签 + 从Markdown内容中提取目录结构 + :param content: Markdown格式的内容 + :return: 目录HTML + """ from djangoblog.utils import CommonMarkdown body, toc = CommonMarkdown.get_markdown_with_toc(content) return mark_safe(toc) @@ -64,6 +94,12 @@ def get_markdown_toc(content): @register.filter() @stringfilter def comment_markdown(content): + """ + 评论Markdown转换过滤器 + 将Markdown格式的评论内容转换为HTML并清理不安全标签 + :param content: Markdown格式的评论内容 + :return: 转换并清理后的HTML内容 + """ content = CommonMarkdown.get_markdown(content) return mark_safe(sanitize_html(content)) @@ -73,8 +109,9 @@ def comment_markdown(content): def truncatechars_content(content): """ 获得文章内容的摘要 - :param content: - :return: + 根据博客设置中的文章摘要长度截取内容 + :param content: 原始内容 + :return: 截取后的内容 """ from django.template.defaultfilters import truncatechars_html from djangoblog.utils import get_blog_setting @@ -85,6 +122,12 @@ def truncatechars_content(content): @register.filter(is_safe=True) @stringfilter def truncate(content): + """ + 内容截取过滤器 + 移除HTML标签并截取前150个字符 + :param content: 原始内容 + :return: 截取后的内容 + """ from django.utils.html import strip_tags return strip_tags(content)[:150] @@ -93,9 +136,10 @@ def truncate(content): @register.inclusion_tag('blog/tags/breadcrumb.html') def load_breadcrumb(article): """ - 获得文章面包屑 - :param article: - :return: + 加载面包屑导航标签 + 生成文章的面包屑导航信息 + :param article: 文章对象 + :return: 面包屑导航数据 """ names = article.get_category_tree() from djangoblog.utils import get_blog_setting @@ -114,9 +158,10 @@ def load_breadcrumb(article): @register.inclusion_tag('blog/tags/article_tag_list.html') def load_articletags(article): """ - 文章标签 - :param article: - :return: + 加载文章标签列表标签 + 生成文章标签的显示数据 + :param article: 文章对象 + :return: 文章标签列表数据 """ tags = article.tags.all() tags_list = [] @@ -134,8 +179,12 @@ def load_articletags(article): @register.inclusion_tag('blog/tags/sidebar.html') def load_sidebar(user, linktype): """ - 加载侧边栏 - :return: + 加载侧边栏标签 + 生成侧边栏显示数据,包括文章、分类、标签等信息 + 使用缓存提高性能 + :param user: 当前用户 + :param linktype: 链接类型 + :return: 侧边栏数据 """ value = cache.get("sidebar" + linktype) if value: @@ -194,9 +243,11 @@ def load_sidebar(user, linktype): @register.inclusion_tag('blog/tags/article_meta_info.html') def load_article_metas(article, user): """ - 获得文章meta信息 - :param article: - :return: + 加载文章元信息标签 + 生成文章元信息显示数据 + :param article: 文章对象 + :param user: 当前用户 + :return: 文章元信息数据 """ return { 'article': article, @@ -206,6 +257,14 @@ def load_article_metas(article, user): @register.inclusion_tag('blog/tags/article_pagination.html') def load_pagination_info(page_obj, page_type, tag_name): + """ + 加载分页信息标签 + 根据页面类型生成分页导航链接 + :param page_obj: 分页对象 + :param page_type: 页面类型 + :param tag_name: 标签名称 + :return: 分页信息数据 + """ previous_url = '' next_url = '' if page_type == '': @@ -276,10 +335,12 @@ def load_pagination_info(page_obj, page_type, tag_name): @register.inclusion_tag('blog/tags/article_info.html') def load_article_detail(article, isindex, user): """ - 加载文章详情 - :param article: - :param isindex:是否列表页,若是列表页只显示摘要 - :return: + 加载文章详情标签 + 生成文章详情显示数据 + :param article: 文章对象 + :param isindex: 是否为列表页 + :param user: 当前用户 + :return: 文章详情数据 """ from djangoblog.utils import get_blog_setting blogsetting = get_blog_setting() @@ -341,4 +402,4 @@ def query(qs, **kwargs): @register.filter def addstr(arg1, arg2): """concatenate arg1 & arg2""" - return str(arg1) + str(arg2) + return str(arg1) + str(arg2) \ No newline at end of file diff --git a/src/blog/views.py b/src/blog/views.py index d5dc7ec..1155fe7 100644 --- a/src/blog/views.py +++ b/src/blog/views.py @@ -25,6 +25,10 @@ logger = logging.getLogger(__name__) class ArticleListView(ListView): + """ + 文章列表视图基类 + 继承自Django的ListView,提供文章列表的通用功能 + """ # template_name属性用于指定使用哪个模板进行渲染 template_name = 'blog/article_index.html' @@ -38,10 +42,18 @@ class ArticleListView(ListView): link_type = LinkShowType.L def get_view_cache_key(self): + """ + 获取视图缓存键 + :return: 缓存键 + """ return self.request.get['pages'] @property def page_number(self): + """ + 获取当前页码 + :return: 页码 + """ page_kwarg = self.page_kwarg page = self.kwargs.get( page_kwarg) or self.request.GET.get(page_kwarg) or 1 @@ -63,7 +75,7 @@ class ArticleListView(ListView): ''' 缓存页面数据 :param cache_key: 缓存key - :return: + :return: 查询结果集 ''' value = cache.get(cache_key) if value: @@ -78,36 +90,49 @@ class ArticleListView(ListView): def get_queryset(self): ''' 重写默认,从缓存获取数据 - :return: + :return: 查询结果集 ''' key = self.get_queryset_cache_key() value = self.get_queryset_from_cache(key) return value def get_context_data(self, **kwargs): + """ + 获取上下文数据 + """ kwargs['linktype'] = self.link_type return super(ArticleListView, self).get_context_data(**kwargs) class IndexView(ArticleListView): ''' - 首页 + 首页视图类 + 显示所有已发布文章的列表 ''' # 友情链接类型 link_type = LinkShowType.I def get_queryset_data(self): + """ + 获取首页文章数据 + :return: 已发布文章查询集 + """ article_list = Article.objects.filter(type='a', status='p') return article_list def get_queryset_cache_key(self): + """ + 获取首页缓存键 + :return: 缓存键 + """ cache_key = 'index_{page}'.format(page=self.page_number) return cache_key class ArticleDetailView(DetailView): ''' - 文章详情页面 + 文章详情页面视图类 + 显示单篇文章的详细内容及相关信息 ''' template_name = 'blog/article_detail.html' model = Article @@ -115,6 +140,10 @@ class ArticleDetailView(DetailView): context_object_name = "article" def get_context_data(self, **kwargs): + """ + 获取文章详情页上下文数据 + 包括评论表单、评论列表、分页信息等 + """ comment_form = CommentForm() article_comments = self.object.comment_list() @@ -163,11 +192,16 @@ class ArticleDetailView(DetailView): class CategoryDetailView(ArticleListView): ''' - 分类目录列表 + 分类目录详情视图类 + 显示指定分类下的所有文章 ''' page_type = "分类目录归档" def get_queryset_data(self): + """ + 获取指定分类下的文章数据 + :return: 文章查询集 + """ slug = self.kwargs['category_name'] category = get_object_or_404(Category, slug=slug) @@ -180,6 +214,10 @@ class CategoryDetailView(ArticleListView): return article_list def get_queryset_cache_key(self): + """ + 获取分类详情页缓存键 + :return: 缓存键 + """ slug = self.kwargs['category_name'] category = get_object_or_404(Category, slug=slug) categoryname = category.name @@ -189,6 +227,9 @@ class CategoryDetailView(ArticleListView): return cache_key def get_context_data(self, **kwargs): + """ + 获取分类详情页上下文数据 + """ categoryname = self.categoryname try: diff --git a/src/comments/models.py b/src/comments/models.py index 7c3bbc8..25e74f7 100644 --- a/src/comments/models.py +++ b/src/comments/models.py @@ -9,6 +9,25 @@ from blog.models import Article # Create your models here. class Comment(models.Model): + """ + 评论模型类 + 用于存储文章的评论信息 + + Attributes: + body (TextField): 评论正文内容,最大长度300个字符 + creation_time (DateTimeField): 评论创建时间,默认为当前时间 + last_modify_time (DateTimeField): 评论最后修改时间,默认为当前时间 + author (ForeignKey): 评论作者,关联到BlogUser模型 + article (ForeignKey): 评论所属文章,关联到Article模型 + parent_comment (ForeignKey): 父级评论,用于实现评论回复功能,可为空 + is_enable (BooleanField): 评论是否启用/显示,False表示未审核或已屏蔽 + + Meta: + ordering: 按照id倒序排列 + verbose_name: 评论的可读性名称 + verbose_name_plural: 评论的复数形式名称 + get_latest_by: 指定用于latest()查询的字段 + """ body = models.TextField('正文', max_length=300) creation_time = models.DateTimeField(_('creation time'), default=now) last_modify_time = models.DateTimeField(_('last modify time'), default=now) @@ -36,4 +55,10 @@ class Comment(models.Model): get_latest_by = 'id' def __str__(self): - return self.body + """ + 模型的字符串表示 + + Returns: + str: 评论正文内容 + """ + return self.body \ No newline at end of file diff --git a/src/comments/views.py b/src/comments/views.py index ad9b2b9..5387d73 100644 --- a/src/comments/views.py +++ b/src/comments/views.py @@ -13,20 +13,34 @@ 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): + """ + 调度方法,添加CSRF保护装饰器 + """ return super(CommentPostView, self).dispatch(*args, **kwargs) def get(self, request, *args, **kwargs): + """ + 处理GET请求,重定向到文章详情页的评论部分 + """ 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) @@ -36,7 +50,10 @@ class CommentPostView(FormView): }) def form_valid(self, form): - """提交的数据验证合法后的逻辑""" + """ + 提交的数据验证合法后的逻辑 + 保存评论信息到数据库 + """ user = self.request.user author = BlogUser.objects.get(pk=user.pk) article_id = self.kwargs['article_id'] @@ -60,4 +77,4 @@ class CommentPostView(FormView): comment.save(True) return HttpResponseRedirect( "%s#div-comment-%d" % - (article.get_absolute_url(), comment.pk)) + (article.get_absolute_url(), comment.pk)) \ No newline at end of file diff --git a/src/djangoblog/plugin_manage/hook_constants.py b/src/djangoblog/plugin_manage/hook_constants.py index 6685b7c..5821e82 100644 --- a/src/djangoblog/plugin_manage/hook_constants.py +++ b/src/djangoblog/plugin_manage/hook_constants.py @@ -1,7 +1,13 @@ -ARTICLE_DETAIL_LOAD = 'article_detail_load' -ARTICLE_CREATE = 'article_create' -ARTICLE_UPDATE = 'article_update' -ARTICLE_DELETE = 'article_delete' +""" +钩子常量定义文件 +定义了系统中使用的各种钩子名称常量 +""" -ARTICLE_CONTENT_HOOK_NAME = "the_content" +# 文章相关钩子 +ARTICLE_DETAIL_LOAD = 'article_detail_load' # 文章详情加载 +ARTICLE_CREATE = 'article_create' # 文章创建 +ARTICLE_UPDATE = 'article_update' # 文章更新 +ARTICLE_DELETE = 'article_delete' # 文章删除 +# 内容过滤钩子 +ARTICLE_CONTENT_HOOK_NAME = "the_content" # 文章内容过滤 \ No newline at end of file diff --git a/src/djangoblog/plugin_manage/hooks.py b/src/djangoblog/plugin_manage/hooks.py index d712540..bba7c16 100644 --- a/src/djangoblog/plugin_manage/hooks.py +++ b/src/djangoblog/plugin_manage/hooks.py @@ -8,6 +8,8 @@ _hooks = {} def register(hook_name: str, callback: callable): """ 注册一个钩子回调。 + :param hook_name: 钩子名称 + :param callback: 回调函数 """ if hook_name not in _hooks: _hooks[hook_name] = [] @@ -19,6 +21,7 @@ def run_action(hook_name: str, *args, **kwargs): """ 执行一个 Action Hook。 它会按顺序执行所有注册到该钩子上的回调函数。 + :param hook_name: 钩子名称 """ if hook_name in _hooks: logger.debug(f"Running action hook '{hook_name}'") @@ -33,6 +36,9 @@ def apply_filters(hook_name: str, value, *args, **kwargs): """ 执行一个 Filter Hook。 它会把 value 依次传递给所有注册的回调函数进行处理。 + :param hook_name: 钩子名称 + :param value: 要处理的值 + :return: 处理后的值 """ if hook_name in _hooks: logger.debug(f"Applying filter hook '{hook_name}'") @@ -41,4 +47,4 @@ def apply_filters(hook_name: str, value, *args, **kwargs): value = callback(value, *args, **kwargs) except Exception as e: logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) - return value + return value \ No newline at end of file diff --git a/src/djangoblog/plugin_manage/loader.py b/src/djangoblog/plugin_manage/loader.py index 12e824b..f110fc7 100644 --- a/src/djangoblog/plugin_manage/loader.py +++ b/src/djangoblog/plugin_manage/loader.py @@ -6,8 +6,12 @@ logger = logging.getLogger(__name__) def load_plugins(): """ - Dynamically loads and initializes plugins from the 'plugins' directory. - This function is intended to be called when the Django app registry is ready. + 动态加载并初始化'plugins'目录中的插件。 + 此函数应在Django应用注册表准备就绪时调用。 + + 通过遍历ACTIVE_PLUGINS设置中的插件名称, + 检查插件目录和plugin.py文件是否存在, + 如果存在则尝试导入插件模块。 """ for plugin_name in settings.ACTIVE_PLUGINS: plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name) @@ -16,4 +20,4 @@ def load_plugins(): __import__(f'plugins.{plugin_name}.plugin') logger.info(f"Successfully loaded plugin: {plugin_name}") except ImportError as e: - logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) \ No newline at end of file + logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) \ No newline at end of file diff --git a/src/djangoblog/settings.py b/src/djangoblog/settings.py index 30f9ac5..de83a11 100644 --- a/src/djangoblog/settings.py +++ b/src/djangoblog/settings.py @@ -17,6 +17,12 @@ from django.utils.translation import gettext_lazy as _ def env_to_bool(env, default): + """ + 将环境变量转换为布尔值 + :param env: 环境变量名 + :param default: 默认值 + :return: 布尔值 + """ str_val = os.environ.get(env) return default if str_val is None else str_val == 'True' diff --git a/src/djangoblog/urls.py b/src/djangoblog/urls.py index 4aae58a..3506a5f 100644 --- a/src/djangoblog/urls.py +++ b/src/djangoblog/urls.py @@ -61,4 +61,4 @@ urlpatterns += i18n_patterns( , 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) + document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/src/djangoblog/utils.py b/src/djangoblog/utils.py index 57f63dc..6b7f4d9 100644 --- a/src/djangoblog/utils.py +++ b/src/djangoblog/utils.py @@ -21,17 +21,32 @@ logger = logging.getLogger(__name__) def get_max_articleid_commentid(): + """ + 获取最大的文章ID和评论ID + :return: (最大文章ID, 最大评论ID) + """ from blog.models import Article from comments.models import Comment return (Article.objects.latest().pk, Comment.objects.latest().pk) def get_sha256(str): + """ + 计算字符串的SHA256哈希值 + :param str: 输入字符串 + :return: SHA256哈希值 + """ m = sha256(str.encode('utf-8')) return m.hexdigest() def cache_decorator(expiration=3 * 60): + """ + 缓存装饰器 + 用于缓存函数的返回值,提高性能 + :param expiration: 缓存过期时间,默认3分钟 + :return: 装饰器函数 + """ def wrapper(func): def news(*args, **kwargs): try: @@ -94,13 +109,27 @@ def expire_view_cache(path, servername, serverport, key_prefix=None): @cache_decorator() def get_current_site(): + """ + 获取当前站点信息 + 使用缓存装饰器提高性能 + :return: 当前站点对象 + """ site = Site.objects.get_current() return site class CommonMarkdown: + """ + Markdown处理工具类 + 提供Markdown到HTML的转换功能 + """ @staticmethod def _convert_markdown(value): + """ + 转换Markdown为HTML + :param value: Markdown格式的文本 + :return: (HTML内容, 目录) + """ md = markdown.Markdown( extensions=[ 'extra', @@ -115,16 +144,33 @@ class CommonMarkdown: @staticmethod def get_markdown_with_toc(value): + """ + 获取带目录的Markdown HTML + :param value: Markdown格式的文本 + :return: (HTML内容, 目录) + """ body, toc = CommonMarkdown._convert_markdown(value) return body, toc @staticmethod def get_markdown(value): + """ + 获取Markdown HTML(不带目录) + :param value: Markdown格式的文本 + :return: HTML内容 + """ body, toc = CommonMarkdown._convert_markdown(value) return body def send_email(emailto, title, content): + """ + 发送邮件 + 通过信号机制发送邮件 + :param emailto: 收件人列表 + :param title: 邮件标题 + :param content: 邮件内容 + """ from djangoblog.blog_signals import send_email_signal send_email_signal.send( send_email.__class__, @@ -134,11 +180,19 @@ def send_email(emailto, title, content): def generate_code() -> str: - """生成随机数验证码""" + """ + 生成随机数验证码 + :return: 6位随机数字验证码 + """ return ''.join(random.sample(string.digits, 6)) def parse_dict_to_url(dict): + """ + 将字典转换为URL参数 + :param dict: 字典对象 + :return: URL参数字符串 + """ from urllib.parse import quote url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/')) for k, v in dict.items()]) @@ -146,6 +200,11 @@ def parse_dict_to_url(dict): def get_blog_setting(): + """ + 获取博客设置 + 使用缓存提高性能,如果缓存不存在则从数据库获取 + :return: 博客设置对象 + """ value = cache.get('get_blog_setting') if value: return value @@ -202,6 +261,10 @@ def save_user_avatar(url): def delete_sidebar_cache(): + """ + 删除侧边栏缓存 + 清除所有侧边栏相关的缓存 + """ from blog.models import LinkShowType keys = ["sidebar" + x for x in LinkShowType.values] for k in keys: @@ -210,12 +273,21 @@ def delete_sidebar_cache(): def delete_view_cache(prefix, keys): + """ + 删除模板片段缓存 + :param prefix: 缓存前缀 + :param keys: 缓存键列表 + """ from django.core.cache.utils import make_template_fragment_key key = make_template_fragment_key(prefix, keys) cache.delete(key) def get_resource_url(): + """ + 获取资源URL + :return: 静态资源URL + """ if settings.STATIC_URL: return settings.STATIC_URL else: @@ -229,4 +301,10 @@ ALLOWED_ATTRIBUTES = {'a': ['href', 'title'], 'abbr': ['title'], 'acronym': ['ti def sanitize_html(html): - return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES) + """ + 清理HTML内容 + 只保留允许的标签和属性,防止XSS攻击 + :param html: 原始HTML内容 + :return: 清理后的HTML内容 + """ + return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES) \ No newline at end of file diff --git a/src/oauth/admin.py b/src/oauth/admin.py index 57eab5f..0bd0f03 100644 --- a/src/oauth/admin.py +++ b/src/oauth/admin.py @@ -7,48 +7,88 @@ from django.utils.html import format_html logger = logging.getLogger(__name__) - class OAuthUserAdmin(admin.ModelAdmin): + """ + OAuth用户模型在Django管理后台的配置类 + """ + # 设置搜索字段,可以在管理后台通过昵称或邮箱搜索用户 search_fields = ('nickname', 'email') + # 每页显示的记录数 list_per_page = 20 + # 在列表页显示的字段 list_display = ( - 'id', - 'nickname', - 'link_to_usermodel', - 'show_user_image', - 'type', - 'email', + 'id', # 用户ID + 'nickname', # 用户昵称 + 'link_to_usermodel', # 关联的系统用户链接 + 'show_user_image', # 显示用户头像 + 'type', # OAuth类型(如GitHub、微博等) + 'email', # 用户邮箱 ) + # 可以点击进入详情页的字段 list_display_links = ('id', 'nickname') + # 列表过滤器,可以通过作者和类型进行筛选 list_filter = ('author', 'type',) + # 只读字段列表(初始为空) readonly_fields = [] def get_readonly_fields(self, request, obj=None): + """ + 获取只读字段列表 + 将所有字段都设置为只读,禁止在管理后台修改OAuth用户信息 + """ + # 将所有模型字段和多对多字段都设置为只读 return list(self.readonly_fields) + \ [field.name for field in obj._meta.fields] + \ [field.name for field in obj._meta.many_to_many] def has_add_permission(self, request): + """ + 控制是否允许添加新记录 + 返回False表示禁止在管理后台手动添加OAuth用户 + """ return False def link_to_usermodel(self, obj): + """ + 创建指向关联系统用户的链接 + 如果该OAuth用户关联了系统用户,则显示一个链接到该系统用户详情页的链接 + """ if obj.author: + # 获取关联用户模型的app_label和model_name info = (obj.author._meta.app_label, obj.author._meta.model_name) + # 构造管理后台编辑页面的URL link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,)) + # 返回格式化的HTML链接 return format_html( u'%s' % (link, obj.author.nickname if obj.author.nickname else obj.author.email)) def show_user_image(self, obj): + """ + 显示用户头像 + 从picture字段获取图片URL,并显示为50x50像素的缩略图 + """ img = obj.picture + # 返回格式化的HTML图片标签 return format_html( u'' % (img)) - link_to_usermodel.short_description = '用户' - show_user_image.short_description = '用户头像' + # 设置自定义方法在列表中的显示名称 + link_to_usermodel.short_description = '用户' # 关联用户列的显示名称 + show_user_image.short_description = '用户头像' # 用户头像列的显示名称 class OAuthConfigAdmin(admin.ModelAdmin): - list_display = ('type', 'appkey', 'appsecret', 'is_enable') + """ + OAuth配置模型在Django管理后台的配置类 + """ + # 在列表页显示的字段 + list_display = ( + 'type', # OAuth类型 + 'appkey', # App Key + 'appsecret', # App Secret + 'is_enable' # 是否启用 + ) + # 列表过滤器,可以通过类型进行筛选 list_filter = ('type',) diff --git a/src/oauth/apps.py b/src/oauth/apps.py index 17fcea2..25f6145 100644 --- a/src/oauth/apps.py +++ b/src/oauth/apps.py @@ -2,4 +2,10 @@ from django.apps import AppConfig class OauthConfig(AppConfig): + """ + OAuth应用的配置类 + + 该类继承自Django的AppConfig,用于配置OAuth应用的基本信息。 + name属性指定了应用的名称,Django会根据这个名称来识别和加载相应的应用。 + """ name = 'oauth' diff --git a/src/oauth/forms.py b/src/oauth/forms.py index 0e4ede3..387b899 100644 --- a/src/oauth/forms.py +++ b/src/oauth/forms.py @@ -3,10 +3,24 @@ from django.forms import widgets class RequireEmailForm(forms.Form): + """ + 要求用户提供邮箱的表单类 + + 该表单用于在OAuth登录过程中要求用户输入电子邮箱地址, + 通常在第三方登录无法获取用户邮箱时使用。 + """ + + # 邮箱字段,设置为必填项 email = forms.EmailField(label='电子邮箱', required=True) + + # OAuth ID字段,用于存储第三方平台的用户ID,隐藏字段,非必填 oauthid = forms.IntegerField(widget=forms.HiddenInput, required=False) def __init__(self, *args, **kwargs): + """ + 初始化表单,设置邮箱输入框的样式和属性 + """ super(RequireEmailForm, self).__init__(*args, **kwargs) + # 自定义邮箱输入框的widget属性 self.fields['email'].widget = widgets.EmailInput( attrs={'placeholder': "email", "class": "form-control"}) diff --git a/src/oauth/migrations/0001_initial.py b/src/oauth/migrations/0001_initial.py index 3aa3e03..73f007a 100644 --- a/src/oauth/migrations/0001_initial.py +++ b/src/oauth/migrations/0001_initial.py @@ -5,53 +5,76 @@ from django.db import migrations, models import django.db.models.deletion import django.utils.timezone - class Migration(migrations.Migration): - + # 标记这是一个初始迁移文件 initial = True + # 定义此迁移依赖的其他迁移 dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] + # 定义要执行的操作 operations = [ + # 创建 OAuthConfig 模型 migrations.CreateModel( name='OAuthConfig', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # OAuth类型选择,包含微博、谷歌、GitHub、Facebook和QQ ('type', models.CharField(choices=[('weibo', '微博'), ('google', '谷歌'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='类型')), + # AppKey字段 ('appkey', models.CharField(max_length=200, verbose_name='AppKey')), + # AppSecret字段 ('appsecret', models.CharField(max_length=200, verbose_name='AppSecret')), + # 回调URL,默认为百度首页 ('callback_url', models.CharField(default='http://www.baidu.com', max_length=200, verbose_name='回调地址')), + # 是否启用该配置 ('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), + # 创建时间,默认为当前时间 ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + # 最后修改时间,默认为当前时间 ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), ], + # 模型选项设置 options={ 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置', - 'ordering': ['-created_time'], + 'ordering': ['-created_time'], # 按创建时间倒序排列 }, ), + + # 创建 OAuthUser 模型 migrations.CreateModel( name='OAuthUser', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 用户在第三方平台的唯一标识 ('openid', models.CharField(max_length=50)), + # 用户昵称 ('nickname', models.CharField(max_length=50, verbose_name='昵称')), + # 访问令牌 ('token', models.CharField(blank=True, max_length=150, null=True)), + # 用户头像URL ('picture', models.CharField(blank=True, max_length=350, null=True)), + # OAuth类型(如weibo, github等) ('type', models.CharField(max_length=50)), + # 用户邮箱 ('email', models.CharField(blank=True, max_length=50, null=True)), + # 其他元数据信息 ('metadata', models.TextField(blank=True, null=True)), + # 创建时间,默认为当前时间 ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), + # 最后修改时间,默认为当前时间 ('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), + # 关联到系统用户,允许为空 ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')), ], + # 模型选项设置 options={ 'verbose_name': 'oauth用户', 'verbose_name_plural': 'oauth用户', - 'ordering': ['-created_time'], + 'ordering': ['-created_time'], # 按创建时间倒序排列 }, ), ] diff --git a/src/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py b/src/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py index d5cc70e..864c16e 100644 --- a/src/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py +++ b/src/oauth/migrations/0002_alter_oauthconfig_options_alter_oauthuser_options_and_more.py @@ -5,79 +5,124 @@ from django.db import migrations, models import django.db.models.deletion import django.utils.timezone - class Migration(migrations.Migration): - + # 定义此迁移依赖的其他迁移文件 dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('oauth', '0001_initial'), + ('oauth', '0001_initial'), # 依赖于oauth应用的初始迁移 ] operations = [ + # 修改 OAuthConfig 模型选项 migrations.AlterModelOptions( name='oauthconfig', - options={'ordering': ['-creation_time'], 'verbose_name': 'oauth配置', 'verbose_name_plural': 'oauth配置'}, + options={ + 'ordering': ['-creation_time'], # 按创建时间倒序排列 + 'verbose_name': 'oauth配置', # 单数名称 + 'verbose_name_plural': 'oauth配置' # 复数名称 + }, ), + + # 修改 OAuthUser 模型选项 migrations.AlterModelOptions( name='oauthuser', - options={'ordering': ['-creation_time'], 'verbose_name': 'oauth user', 'verbose_name_plural': 'oauth user'}, + options={ + 'ordering': ['-creation_time'], # 按创建时间倒序排列 + 'verbose_name': 'oauth user', # 单数名称 + 'verbose_name_plural': 'oauth user' # 复数名称 + }, ), + + # 移除 OAuthConfig 模型中的旧时间字段 migrations.RemoveField( model_name='oauthconfig', - name='created_time', + name='created_time', # 删除created_time字段 ), migrations.RemoveField( model_name='oauthconfig', - name='last_mod_time', + name='last_mod_time', # 删除last_mod_time字段 ), + + # 移除 OAuthUser 模型中的旧时间字段 migrations.RemoveField( model_name='oauthuser', - name='created_time', + name='created_time', # 删除created_time字段 ), migrations.RemoveField( model_name='oauthuser', - name='last_mod_time', + name='last_mod_time', # 删除last_mod_time字段 ), + + # 为 OAuthConfig 模型添加新的时间字段 migrations.AddField( model_name='oauthconfig', - name='creation_time', + name='creation_time', # 添加creation_time字段 field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), ), migrations.AddField( model_name='oauthconfig', - name='last_modify_time', + name='last_modify_time', # 添加last_modify_time字段 field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), ), + + # 为 OAuthUser 模型添加新的时间字段 migrations.AddField( model_name='oauthuser', - name='creation_time', + name='creation_time', # 添加creation_time字段 field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), ), migrations.AddField( model_name='oauthuser', - name='last_modify_time', + name='last_modify_time', # 添加last_modify_time字段 field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), ), + + # 修改 OAuthConfig 模型的callback_url字段 migrations.AlterField( model_name='oauthconfig', name='callback_url', field=models.CharField(default='', max_length=200, verbose_name='callback url'), ), + + # 修改 OAuthConfig 模型的is_enable字段(仅更改显示名称) migrations.AlterField( model_name='oauthconfig', name='is_enable', field=models.BooleanField(default=True, verbose_name='is enable'), ), + + # 修改 OAuthConfig 模型的type字段选项(中文标签改为英文) migrations.AlterField( model_name='oauthconfig', name='type', - field=models.CharField(choices=[('weibo', 'weibo'), ('google', 'google'), ('github', 'GitHub'), ('facebook', 'FaceBook'), ('qq', 'QQ')], default='a', max_length=10, verbose_name='type'), + field=models.CharField( + choices=[ + ('weibo', 'weibo'), + ('google', 'google'), + ('github', 'GitHub'), + ('facebook', 'FaceBook'), + ('qq', 'QQ') + ], + default='a', + max_length=10, + verbose_name='type' + ), ), + + # 修改 OAuthUser 模型的author字段显示名称 migrations.AlterField( model_name='oauthuser', name='author', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name='author' + ), ), + + # 修改 OAuthUser 模型的nickname字段显示名称(中文改为英文) migrations.AlterField( model_name='oauthuser', name='nickname', diff --git a/src/oauth/migrations/0003_alter_oauthuser_nickname.py b/src/oauth/migrations/0003_alter_oauthuser_nickname.py index 6af08eb..59843c7 100644 --- a/src/oauth/migrations/0003_alter_oauthuser_nickname.py +++ b/src/oauth/migrations/0003_alter_oauthuser_nickname.py @@ -2,17 +2,21 @@ from django.db import migrations, models - class Migration(migrations.Migration): - + # 定义此迁移依赖的前一个迁移文件 dependencies = [ ('oauth', '0002_alter_oauthconfig_options_alter_oauthuser_options_and_more'), ] + # 定义要执行的迁移操作 operations = [ + # 修改 OAuthUser 模型中的 nickname 字段 migrations.AlterField( - model_name='oauthuser', - name='nickname', - field=models.CharField(max_length=50, verbose_name='nick name'), + model_name='oauthuser', # 目标模型为 OAuthUser + name='nickname', # 字段名为 nickname + field=models.CharField( + max_length=50, # 最大长度仍为50个字符 + verbose_name='nick name' # 将字段的显示名称从 "nickname" 改为 "nick name" + ), ), ] diff --git a/src/oauth/models.py b/src/oauth/models.py index be838ed..daded06 100644 --- a/src/oauth/models.py +++ b/src/oauth/models.py @@ -7,6 +7,27 @@ from django.utils.translation import gettext_lazy as _ class OAuthUser(models.Model): + """ + OAuth用户模型 + 用于存储通过第三方平台登录的用户信息 + + Attributes: + author (ForeignKey): 关联的本地用户,可为空 + openid (CharField): 第三方平台的用户唯一标识,最大长度50 + nickname (CharField): 用户在第三方平台的昵称,最大长度50 + token (CharField): OAuth认证令牌,最大长度150,可为空 + picture (CharField): 用户头像URL,最大长度350,可为空 + type (CharField): 第三方平台类型,如weibo、google等,最大长度50 + email (CharField): 用户邮箱,最大长度50,可为空 + metadata (TextField): 其他元数据信息,可为空 + creation_time (DateTimeField): 记录创建时间,默认为当前时间 + last_modify_time (DateTimeField): 记录最后修改时间,默认为当前时间 + + Meta: + verbose_name: OAuth用户的可读性名称 + verbose_name_plural: OAuth用户的复数形式名称 + ordering: 按照创建时间倒序排列 + """ author = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_('author'), @@ -24,6 +45,12 @@ class OAuthUser(models.Model): last_modify_time = models.DateTimeField(_('last modify time'), default=now) def __str__(self): + """ + 模型的字符串表示 + + Returns: + str: 用户昵称 + """ return self.nickname class Meta: @@ -33,6 +60,25 @@ class OAuthUser(models.Model): class OAuthConfig(models.Model): + """ + OAuth配置模型 + 用于存储第三方OAuth登录的配置信息 + + Attributes: + TYPE (tuple): 支持的OAuth平台类型选项 + type (CharField): OAuth平台类型,从TYPE中选择 + appkey (CharField): 第三方平台分配的应用Key,最大长度200 + appsecret (CharField): 第三方平台分配的应用密钥,最大长度200 + callback_url (CharField): OAuth回调URL,最大长度200 + is_enable (BooleanField): 是否启用该OAuth配置 + creation_time (DateTimeField): 配置创建时间,默认为当前时间 + last_modify_time (DateTimeField): 配置最后修改时间,默认为当前时间 + + Meta: + verbose_name: OAuth配置的可读性名称 + verbose_name_plural: OAuth配置的复数形式名称 + ordering: 按照创建时间倒序排列 + """ TYPE = ( ('weibo', _('weibo')), ('google', _('google')), @@ -54,14 +100,27 @@ class OAuthConfig(models.Model): last_modify_time = models.DateTimeField(_('last modify time'), default=now) def clean(self): + """ + 验证模型数据 + 确保同类型的配置只能存在一个实例 + + Raises: + ValidationError: 当已存在相同类型的配置时抛出验证错误 + """ if OAuthConfig.objects.filter( type=self.type).exclude(id=self.id).count(): raise ValidationError(_(self.type + _('already exists'))) def __str__(self): + """ + 模型的字符串表示 + + Returns: + str: 配置类型 + """ return self.type class Meta: verbose_name = 'oauth配置' verbose_name_plural = verbose_name - ordering = ['-creation_time'] + ordering = ['-creation_time'] \ No newline at end of file diff --git a/src/oauth/oauthmanager.py b/src/oauth/oauthmanager.py index 2e7ceef..5e6fa91 100644 --- a/src/oauth/oauthmanager.py +++ b/src/oauth/oauthmanager.py @@ -29,55 +29,133 @@ class BaseOauthManager(metaclass=ABCMeta): ICON_NAME = None def __init__(self, access_token=None, openid=None): + """ + 初始化OAuth管理器 + + Args: + access_token (str, optional): 访问令牌 + openid (str, optional): 用户唯一标识 + """ self.access_token = access_token self.openid = openid @property def is_access_token_set(self): + """检查访问令牌是否已设置""" return self.access_token is not None @property def is_authorized(self): + """检查是否已授权(访问令牌和openid都已设置)""" return self.is_access_token_set and self.access_token is not None and self.openid is not None @abstractmethod def get_authorization_url(self, nexturl='/'): + """ + 获取授权URL + + Args: + nexturl (str): 授权后跳转的URL + + Returns: + str: 授权页面URL + """ pass @abstractmethod def get_access_token_by_code(self, code): + """ + 通过授权码获取访问令牌 + + Args: + code (str): 授权码 + + Returns: + str: 访问令牌 + """ pass @abstractmethod def get_oauth_userinfo(self): + """ + 获取OAuth用户信息 + + Returns: + OAuthUser: OAuth用户对象 + """ pass @abstractmethod def get_picture(self, metadata): + """ + 从元数据中获取用户头像 + + Args: + metadata (str): 用户元数据JSON字符串 + + Returns: + str: 头像URL + """ pass def do_get(self, url, params, headers=None): + """ + 发送GET请求 + + Args: + url (str): 请求URL + params (dict): 请求参数 + headers (dict, optional): 请求头 + + Returns: + str: 响应文本 + """ rsp = requests.get(url=url, params=params, headers=headers) logger.info(rsp.text) return rsp.text def do_post(self, url, params, headers=None): + """ + 发送POST请求 + + Args: + url (str): 请求URL + params (dict): 请求参数 + headers (dict, optional): 请求头 + + Returns: + str: 响应文本 + """ rsp = requests.post(url, params, headers=headers) logger.info(rsp.text) return rsp.text def get_config(self): + """ + 获取OAuth配置信息 + + Returns: + OAuthConfig: OAuth配置对象 + """ value = OAuthConfig.objects.filter(type=self.ICON_NAME) return value[0] if value else None class WBOauthManager(BaseOauthManager): + """新浪微博OAuth管理器""" AUTH_URL = 'https://api.weibo.com/oauth2/authorize' TOKEN_URL = 'https://api.weibo.com/oauth2/access_token' API_URL = 'https://api.weibo.com/2/users/show.json' ICON_NAME = 'weibo' def __init__(self, access_token=None, openid=None): + """ + 初始化新浪微博OAuth管理器 + + Args: + access_token (str, optional): 访问令牌 + openid (str, optional): 用户唯一标识 + """ config = self.get_config() self.client_id = config.appkey if config else '' self.client_secret = config.appsecret if config else '' @@ -89,6 +167,15 @@ class WBOauthManager(BaseOauthManager): openid=openid) def get_authorization_url(self, nexturl='/'): + """ + 获取新浪微博授权URL + + Args: + nexturl (str): 授权后跳转的URL + + Returns: + str: 新浪微博授权页面URL + """ params = { 'client_id': self.client_id, 'response_type': 'code', @@ -98,7 +185,18 @@ class WBOauthManager(BaseOauthManager): return url def get_access_token_by_code(self, code): + """ + 通过授权码获取新浪微博访问令牌 + + Args: + code (str): 授权码 + Returns: + OAuthUser: OAuth用户对象 + + Raises: + OAuthAccessTokenException: 获取访问令牌失败时抛出异常 + """ params = { 'client_id': self.client_id, 'client_secret': self.client_secret, @@ -117,6 +215,12 @@ class WBOauthManager(BaseOauthManager): raise OAuthAccessTokenException(rsp) def get_oauth_userinfo(self): + """ + 获取新浪微博用户信息 + + Returns: + OAuthUser: OAuth用户对象 + """ if not self.is_authorized: return None params = { @@ -142,12 +246,26 @@ class WBOauthManager(BaseOauthManager): return None def get_picture(self, metadata): + """ + 从新浪微博元数据中获取用户头像 + + Args: + metadata (str): 用户元数据JSON字符串 + + Returns: + str: 头像URL + """ datas = json.loads(metadata) return datas['avatar_large'] class ProxyManagerMixin: + """代理管理混入类,用于支持HTTP代理""" + def __init__(self, *args, **kwargs): + """ + 初始化代理设置 + """ if os.environ.get("HTTP_PROXY"): self.proxies = { "http": os.environ.get("HTTP_PROXY"), @@ -157,23 +275,53 @@ class ProxyManagerMixin: self.proxies = None def do_get(self, url, params, headers=None): + """ + 发送带代理的GET请求 + + Args: + url (str): 请求URL + params (dict): 请求参数 + headers (dict, optional): 请求头 + + Returns: + str: 响应文本 + """ rsp = requests.get(url=url, params=params, headers=headers, proxies=self.proxies) logger.info(rsp.text) return rsp.text def do_post(self, url, params, headers=None): + """ + 发送带代理的POST请求 + + Args: + url (str): 请求URL + params (dict): 请求参数 + headers (dict, optional): 请求头 + + Returns: + str: 响应文本 + """ rsp = requests.post(url, params, headers=headers, proxies=self.proxies) logger.info(rsp.text) return rsp.text class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): + """Google OAuth管理器""" AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token' API_URL = 'https://www.googleapis.com/oauth2/v3/userinfo' ICON_NAME = 'google' def __init__(self, access_token=None, openid=None): + """ + 初始化Google OAuth管理器 + + Args: + access_token (str, optional): 访问令牌 + openid (str, optional): 用户唯一标识 + """ config = self.get_config() self.client_id = config.appkey if config else '' self.client_secret = config.appsecret if config else '' @@ -185,6 +333,15 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): openid=openid) def get_authorization_url(self, nexturl='/'): + """ + 获取Google授权URL + + Args: + nexturl (str): 授权后跳转的URL + + Returns: + str: Google授权页面URL + """ params = { 'client_id': self.client_id, 'response_type': 'code', @@ -195,6 +352,18 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): return url def get_access_token_by_code(self, code): + """ + 通过授权码获取Google访问令牌 + + Args: + code (str): 授权码 + + Returns: + str: 访问令牌 + + Raises: + OAuthAccessTokenException: 获取访问令牌失败时抛出异常 + """ params = { 'client_id': self.client_id, 'client_secret': self.client_secret, @@ -216,6 +385,12 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): raise OAuthAccessTokenException(rsp) def get_oauth_userinfo(self): + """ + 获取Google用户信息 + + Returns: + OAuthUser: OAuth用户对象 + """ if not self.is_authorized: return None params = { @@ -241,17 +416,34 @@ class GoogleOauthManager(ProxyManagerMixin, BaseOauthManager): return None def get_picture(self, metadata): + """ + 从Google元数据中获取用户头像 + + Args: + metadata (str): 用户元数据JSON字符串 + + Returns: + str: 头像URL + """ datas = json.loads(metadata) return datas['picture'] class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): + """GitHub OAuth管理器""" AUTH_URL = 'https://github.com/login/oauth/authorize' TOKEN_URL = 'https://github.com/login/oauth/access_token' API_URL = 'https://api.github.com/user' ICON_NAME = 'github' def __init__(self, access_token=None, openid=None): + """ + 初始化GitHub OAuth管理器 + + Args: + access_token (str, optional): 访问令牌 + openid (str, optional): 用户唯一标识 + """ config = self.get_config() self.client_id = config.appkey if config else '' self.client_secret = config.appsecret if config else '' @@ -263,6 +455,15 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): openid=openid) def get_authorization_url(self, next_url='/'): + """ + 获取GitHub授权URL + + Args: + next_url (str): 授权后跳转的URL + + Returns: + str: GitHub授权页面URL + """ params = { 'client_id': self.client_id, 'response_type': 'code', @@ -273,6 +474,18 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): return url def get_access_token_by_code(self, code): + """ + 通过授权码获取GitHub访问令牌 + + Args: + code (str): 授权码 + + Returns: + str: 访问令牌 + + Raises: + OAuthAccessTokenException: 获取访问令牌失败时抛出异常 + """ params = { 'client_id': self.client_id, 'client_secret': self.client_secret, @@ -292,7 +505,12 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): raise OAuthAccessTokenException(rsp) def get_oauth_userinfo(self): + """ + 获取GitHub用户信息 + Returns: + OAuthUser: OAuth用户对象 + """ rsp = self.do_get(self.API_URL, params={}, headers={ "Authorization": "token " + self.access_token }) @@ -314,17 +532,34 @@ class GitHubOauthManager(ProxyManagerMixin, BaseOauthManager): return None def get_picture(self, metadata): + """ + 从GitHub元数据中获取用户头像 + + Args: + metadata (str): 用户元数据JSON字符串 + + Returns: + str: 头像URL + """ datas = json.loads(metadata) return datas['avatar_url'] class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): + """Facebook OAuth管理器""" AUTH_URL = 'https://www.facebook.com/v16.0/dialog/oauth' TOKEN_URL = 'https://graph.facebook.com/v16.0/oauth/access_token' API_URL = 'https://graph.facebook.com/me' ICON_NAME = 'facebook' def __init__(self, access_token=None, openid=None): + """ + 初始化Facebook OAuth管理器 + + Args: + access_token (str, optional): 访问令牌 + openid (str, optional): 用户唯一标识 + """ config = self.get_config() self.client_id = config.appkey if config else '' self.client_secret = config.appsecret if config else '' @@ -336,6 +571,15 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): openid=openid) def get_authorization_url(self, next_url='/'): + """ + 获取Facebook授权URL + + Args: + next_url (str): 授权后跳转的URL + + Returns: + str: Facebook授权页面URL + """ params = { 'client_id': self.client_id, 'response_type': 'code', @@ -346,6 +590,18 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): return url def get_access_token_by_code(self, code): + """ + 通过授权码获取Facebook访问令牌 + + Args: + code (str): 授权码 + + Returns: + str: 访问令牌 + + Raises: + OAuthAccessTokenException: 获取访问令牌失败时抛出异常 + """ params = { 'client_id': self.client_id, 'client_secret': self.client_secret, @@ -365,6 +621,12 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): raise OAuthAccessTokenException(rsp) def get_oauth_userinfo(self): + """ + 获取Facebook用户信息 + + Returns: + OAuthUser: OAuth用户对象 + """ params = { 'access_token': self.access_token, 'fields': 'id,name,picture,email' @@ -388,11 +650,21 @@ class FaceBookOauthManager(ProxyManagerMixin, BaseOauthManager): return None def get_picture(self, metadata): + """ + 从Facebook元数据中获取用户头像 + + Args: + metadata (str): 用户元数据JSON字符串 + + Returns: + str: 头像URL + """ datas = json.loads(metadata) return str(datas['picture']['data']['url']) class QQOauthManager(BaseOauthManager): + """QQ OAuth管理器""" AUTH_URL = 'https://graph.qq.com/oauth2.0/authorize' TOKEN_URL = 'https://graph.qq.com/oauth2.0/token' API_URL = 'https://graph.qq.com/user/get_user_info' @@ -400,6 +672,13 @@ class QQOauthManager(BaseOauthManager): ICON_NAME = 'qq' def __init__(self, access_token=None, openid=None): + """ + 初始化QQ OAuth管理器 + + Args: + access_token (str, optional): 访问令牌 + openid (str, optional): 用户唯一标识 + """ config = self.get_config() self.client_id = config.appkey if config else '' self.client_secret = config.appsecret if config else '' @@ -411,6 +690,15 @@ class QQOauthManager(BaseOauthManager): openid=openid) def get_authorization_url(self, next_url='/'): + """ + 获取QQ授权URL + + Args: + next_url (str): 授权后跳转的URL + + Returns: + str: QQ授权页面URL + """ params = { 'response_type': 'code', 'client_id': self.client_id, @@ -420,6 +708,18 @@ class QQOauthManager(BaseOauthManager): return url def get_access_token_by_code(self, code): + """ + 通过授权码获取QQ访问令牌 + + Args: + code (str): 授权码 + + Returns: + str: 访问令牌 + + Raises: + OAuthAccessTokenException: 获取访问令牌失败时抛出异常 + """ params = { 'grant_type': 'authorization_code', 'client_id': self.client_id, @@ -438,6 +738,12 @@ class QQOauthManager(BaseOauthManager): raise OAuthAccessTokenException(rsp) def get_open_id(self): + """ + 获取QQ用户openid + + Returns: + str: 用户openid + """ if self.is_access_token_set: params = { 'access_token': self.access_token @@ -454,6 +760,12 @@ class QQOauthManager(BaseOauthManager): return openid def get_oauth_userinfo(self): + """ + 获取QQ用户信息 + + Returns: + OAuthUser: OAuth用户对象 + """ openid = self.get_open_id() if openid: params = { @@ -477,12 +789,27 @@ class QQOauthManager(BaseOauthManager): return user def get_picture(self, metadata): + """ + 从QQ元数据中获取用户头像 + + Args: + metadata (str): 用户元数据JSON字符串 + + Returns: + str: 头像URL + """ datas = json.loads(metadata) return str(datas['figureurl']) @cache_decorator(expiration=100 * 60) def get_oauth_apps(): + """ + 获取所有启用的OAuth应用 + + Returns: + list: OAuth应用管理器实例列表 + """ configs = OAuthConfig.objects.filter(is_enable=True).all() if not configs: return [] @@ -493,6 +820,15 @@ def get_oauth_apps(): def get_manager_by_type(type): + """ + 根据类型获取OAuth管理器 + + Args: + type (str): OAuth类型(如'weibo', 'google', 'github'等) + + Returns: + BaseOauthManager: 对应的OAuth管理器实例 + """ applications = get_oauth_apps() if applications: finds = list( diff --git a/src/oauth/tests.py b/src/oauth/tests.py index bb23b9b..34f1fc1 100644 --- a/src/oauth/tests.py +++ b/src/oauth/tests.py @@ -13,33 +13,55 @@ from oauth.oauthmanager import BaseOauthManager # Create your tests here. class OAuthConfigTest(TestCase): + """OAuth配置测试类""" + def setUp(self): + """ + 测试初始化设置 + """ self.client = Client() self.factory = RequestFactory() def test_oauth_login_test(self): + """ + 测试OAuth登录功能 + """ + # 创建微博OAuth配置 c = OAuthConfig() c.type = 'weibo' c.appkey = 'appkey' c.appsecret = 'appsecret' c.save() + # 测试获取OAuth登录链接 response = self.client.get('/oauth/oauthlogin?type=weibo') self.assertEqual(response.status_code, 302) self.assertTrue("api.weibo.com" in response.url) + # 测试授权回调 response = self.client.get('/oauth/authorize?type=weibo&code=code') self.assertEqual(response.status_code, 302) self.assertEqual(response.url, '/') class OauthLoginTest(TestCase): + """OAuth登录测试类""" + def setUp(self) -> None: + """ + 测试初始化设置 + """ self.client = Client() self.factory = RequestFactory() self.apps = self.init_apps() def init_apps(self): + """ + 初始化所有OAuth应用 + + Returns: + list: OAuth应用管理器实例列表 + """ applications = [p() for p in BaseOauthManager.__subclasses__()] for application in applications: c = OAuthConfig() @@ -50,6 +72,15 @@ class OauthLoginTest(TestCase): return applications def get_app_by_type(self, type): + """ + 根据类型获取OAuth应用管理器 + + Args: + type (str): OAuth类型 + + Returns: + BaseOauthManager: 对应的OAuth管理器实例 + """ for app in self.apps: if app.ICON_NAME.lower() == type: return app @@ -57,12 +88,21 @@ class OauthLoginTest(TestCase): @patch("oauth.oauthmanager.WBOauthManager.do_post") @patch("oauth.oauthmanager.WBOauthManager.do_get") def test_weibo_login(self, mock_do_get, mock_do_post): + """ + 测试微博登录流程 + + Args: + mock_do_get: 模拟GET请求 + mock_do_post: 模拟POST请求 + """ weibo_app = self.get_app_by_type('weibo') assert weibo_app url = weibo_app.get_authorization_url() + # 模拟返回访问令牌 mock_do_post.return_value = json.dumps({"access_token": "access_token", "uid": "uid" }) + # 模拟返回用户信息 mock_do_get.return_value = json.dumps({ "avatar_large": "avatar_large", "screen_name": "screen_name", @@ -76,13 +116,22 @@ class OauthLoginTest(TestCase): @patch("oauth.oauthmanager.GoogleOauthManager.do_post") @patch("oauth.oauthmanager.GoogleOauthManager.do_get") def test_google_login(self, mock_do_get, mock_do_post): + """ + 测试Google登录流程 + + Args: + mock_do_get: 模拟GET请求 + mock_do_post: 模拟POST请求 + """ google_app = self.get_app_by_type('google') assert google_app url = google_app.get_authorization_url() + # 模拟返回访问令牌 mock_do_post.return_value = json.dumps({ "access_token": "access_token", "id_token": "id_token", }) + # 模拟返回用户信息 mock_do_get.return_value = json.dumps({ "picture": "picture", "name": "name", @@ -97,12 +146,21 @@ class OauthLoginTest(TestCase): @patch("oauth.oauthmanager.GitHubOauthManager.do_post") @patch("oauth.oauthmanager.GitHubOauthManager.do_get") def test_github_login(self, mock_do_get, mock_do_post): + """ + 测试GitHub登录流程 + + Args: + mock_do_get: 模拟GET请求 + mock_do_post: 模拟POST请求 + """ github_app = self.get_app_by_type('github') assert github_app url = github_app.get_authorization_url() self.assertTrue("github.com" in url) self.assertTrue("client_id" in url) + # 模拟返回访问令牌 mock_do_post.return_value = "access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer" + # 模拟返回用户信息 mock_do_get.return_value = json.dumps({ "avatar_url": "avatar_url", "name": "name", @@ -117,13 +175,22 @@ class OauthLoginTest(TestCase): @patch("oauth.oauthmanager.FaceBookOauthManager.do_post") @patch("oauth.oauthmanager.FaceBookOauthManager.do_get") def test_facebook_login(self, mock_do_get, mock_do_post): + """ + 测试Facebook登录流程 + + Args: + mock_do_get: 模拟GET请求 + mock_do_post: 模拟POST请求 + """ facebook_app = self.get_app_by_type('facebook') assert facebook_app url = facebook_app.get_authorization_url() self.assertTrue("facebook.com" in url) + # 模拟返回访问令牌 mock_do_post.return_value = json.dumps({ "access_token": "access_token", }) + # 模拟返回用户信息 mock_do_get.return_value = json.dumps({ "name": "name", "id": "id", @@ -149,6 +216,12 @@ class OauthLoginTest(TestCase): }) ]) def test_qq_login(self, mock_do_get): + """ + 测试QQ登录流程 + + Args: + mock_do_get: 模拟GET请求序列 + """ qq_app = self.get_app_by_type('qq') assert qq_app url = qq_app.get_authorization_url() @@ -160,6 +233,13 @@ class OauthLoginTest(TestCase): @patch("oauth.oauthmanager.WBOauthManager.do_post") @patch("oauth.oauthmanager.WBOauthManager.do_get") def test_weibo_authoriz_login_with_email(self, mock_do_get, mock_do_post): + """ + 测试微博授权登录(带邮箱) + + Args: + mock_do_get: 模拟GET请求 + mock_do_post: 模拟POST请求 + """ mock_do_post.return_value = json.dumps({"access_token": "access_token", "uid": "uid" @@ -172,14 +252,17 @@ class OauthLoginTest(TestCase): } mock_do_get.return_value = json.dumps(mock_user_info) + # 测试获取OAuth登录链接 response = self.client.get('/oauth/oauthlogin?type=weibo') self.assertEqual(response.status_code, 302) self.assertTrue("api.weibo.com" in response.url) + # 测试授权回调 response = self.client.get('/oauth/authorize?type=weibo&code=code') self.assertEqual(response.status_code, 302) self.assertEqual(response.url, '/') + # 验证用户登录状态 user = auth.get_user(self.client) assert user.is_authenticated self.assertTrue(user.is_authenticated) @@ -187,6 +270,7 @@ class OauthLoginTest(TestCase): self.assertEqual(user.email, mock_user_info['email']) self.client.logout() + # 再次测试授权登录 response = self.client.get('/oauth/authorize?type=weibo&code=code') self.assertEqual(response.status_code, 302) self.assertEqual(response.url, '/') @@ -200,6 +284,13 @@ class OauthLoginTest(TestCase): @patch("oauth.oauthmanager.WBOauthManager.do_post") @patch("oauth.oauthmanager.WBOauthManager.do_get") def test_weibo_authoriz_login_without_email(self, mock_do_get, mock_do_post): + """ + 测试微博授权登录(不带邮箱,需要用户输入邮箱) + + Args: + mock_do_get: 模拟GET请求 + mock_do_post: 模拟POST请求 + """ mock_do_post.return_value = json.dumps({"access_token": "access_token", "uid": "uid" @@ -211,17 +302,21 @@ class OauthLoginTest(TestCase): } mock_do_get.return_value = json.dumps(mock_user_info) + # 测试获取OAuth登录链接 response = self.client.get('/oauth/oauthlogin?type=weibo') self.assertEqual(response.status_code, 302) self.assertTrue("api.weibo.com" in response.url) + # 测试授权回调(无邮箱情况) response = self.client.get('/oauth/authorize?type=weibo&code=code') self.assertEqual(response.status_code, 302) + # 验证跳转到邮箱输入页面 oauth_user_id = int(response.url.split('/')[-1].split('.')[0]) self.assertEqual(response.url, f'/oauth/requireemail/{oauth_user_id}.html') + # 提交邮箱表单 response = self.client.post(response.url, {'email': 'test@gmail.com', 'oauthid': oauth_user_id}) self.assertEqual(response.status_code, 302) @@ -233,6 +328,7 @@ class OauthLoginTest(TestCase): }) self.assertEqual(response.url, f'{url}?type=email') + # 验证邮箱确认链接 path = reverse('oauth:email_confirm', kwargs={ 'id': oauth_user_id, 'sign': sign @@ -240,6 +336,8 @@ class OauthLoginTest(TestCase): response = self.client.get(path) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, f'/oauth/bindsuccess/{oauth_user_id}.html?type=success') + + # 验证用户登录状态和信息 user = auth.get_user(self.client) from oauth.models import OAuthUser oauth_user = OAuthUser.objects.get(author=user) diff --git a/src/oauth/urls.py b/src/oauth/urls.py index c4a12a0..6e8af07 100644 --- a/src/oauth/urls.py +++ b/src/oauth/urls.py @@ -4,21 +4,26 @@ from . import views app_name = "oauth" urlpatterns = [ + # OAuth授权回调处理路由 path( r'oauth/authorize', views.authorize), + # 要求用户输入邮箱的页面路由,用于OAuth登录时第三方未返回邮箱的情况 path( r'oauth/requireemail/.html', views.RequireEmailView.as_view(), name='require_email'), + # 邮箱确认路由,用于验证用户输入的邮箱地址 path( r'oauth/emailconfirm//.html', views.emailconfirm, name='email_confirm'), + # OAuth绑定成功页面路由 path( r'oauth/bindsuccess/.html', views.bindsuccess, name='bindsuccess'), + # OAuth登录入口路由,跳转到第三方授权页面 path( r'oauth/oauthlogin', views.oauthlogin, diff --git a/src/oauth/views.py b/src/oauth/views.py index 12e3a6e..c123ae9 100644 --- a/src/oauth/views.py +++ b/src/oauth/views.py @@ -27,6 +27,11 @@ logger = logging.getLogger(__name__) def get_redirecturl(request): + """ + 获取重定向URL + :param request: HTTP请求对象 + :return: 重定向URL + """ nexturl = request.GET.get('next_url', None) if not nexturl or nexturl == '/login/' or nexturl == '/login': nexturl = '/' @@ -41,6 +46,12 @@ def get_redirecturl(request): def oauthlogin(request): + """ + OAuth登录入口 + 根据请求类型重定向到相应的OAuth提供商 + :param request: HTTP请求对象 + :return: 重定向响应 + """ type = request.GET.get('type', None) if not type: return HttpResponseRedirect('/') @@ -53,6 +64,12 @@ def oauthlogin(request): def authorize(request): + """ + OAuth授权回调处理 + 处理OAuth提供商返回的授权码,获取用户信息并登录 + :param request: HTTP请求对象 + :return: 重定向响应 + """ type = request.GET.get('type', None) if not type: return HttpResponseRedirect('/') @@ -125,6 +142,14 @@ def authorize(request): def emailconfirm(request, id, sign): + """ + 邮箱确认绑定处理 + 验证签名并完成邮箱绑定流程 + :param request: HTTP请求对象 + :param id: OAuth用户ID + :param sign: 签名 + :return: 重定向响应 + """ if not sign: return HttpResponseForbidden() if not get_sha256(settings.SECRET_KEY + @@ -171,10 +196,18 @@ def emailconfirm(request, id, sign): class RequireEmailView(FormView): + """ + 要求邮箱视图类 + 当OAuth用户没有邮箱时,要求用户提供邮箱地址 + """ form_class = RequireEmailForm template_name = 'oauth/require_email.html' def get(self, request, *args, **kwargs): + """ + 处理GET请求 + 检查OAuth用户是否已有邮箱 + """ oauthid = self.kwargs['oauthid'] oauthuser = get_object_or_404(OAuthUser, pk=oauthid) if oauthuser.email: @@ -184,6 +217,10 @@ class RequireEmailView(FormView): return super(RequireEmailView, self).get(request, *args, **kwargs) def get_initial(self): + """ + 获取表单初始数据 + :return: 包含OAuth用户ID的字典 + """ oauthid = self.kwargs['oauthid'] return { 'email': '', @@ -191,6 +228,10 @@ class RequireEmailView(FormView): } def get_context_data(self, **kwargs): + """ + 获取上下文数据 + 添加用户头像等信息到模板上下文 + """ oauthid = self.kwargs['oauthid'] oauthuser = get_object_or_404(OAuthUser, pk=oauthid) if oauthuser.picture: @@ -198,6 +239,10 @@ class RequireEmailView(FormView): return super(RequireEmailView, self).get_context_data(**kwargs) def form_valid(self, form): + """ + 处理有效的表单提交 + 保存邮箱信息并发送确认邮件 + """ email = form.cleaned_data['email'] oauthid = form.cleaned_data['oauthid'] oauthuser = get_object_or_404(OAuthUser, pk=oauthid) @@ -234,6 +279,13 @@ class RequireEmailView(FormView): def bindsuccess(request, oauthid): + """ + 绑定成功页面 + 显示绑定成功或需要验证邮箱的提示信息 + :param request: HTTP请求对象 + :param oauthid: OAuth用户ID + :return: 渲染的模板响应 + """ type = request.GET.get('type', None) oauthuser = get_object_or_404(OAuthUser, pk=oauthid) if type == 'email': @@ -250,4 +302,4 @@ def bindsuccess(request, oauthid): return render(request, 'oauth/bindsuccess.html', { 'title': title, 'content': content - }) + }) \ No newline at end of file diff --git a/src/owntracks/admin.py b/src/owntracks/admin.py index 655b535..f32de53 100644 --- a/src/owntracks/admin.py +++ b/src/owntracks/admin.py @@ -4,4 +4,10 @@ from django.contrib import admin class OwnTrackLogsAdmin(admin.ModelAdmin): + """ + OwnTrackLogs模型的Django管理后台配置类 + + 用于在Django管理界面中管理OwnTrackLogs数据。 + 目前为空配置,使用默认的管理界面功能。 + """ pass diff --git a/src/owntracks/apps.py b/src/owntracks/apps.py index 1bc5f12..4d0ab99 100644 --- a/src/owntracks/apps.py +++ b/src/owntracks/apps.py @@ -2,4 +2,10 @@ from django.apps import AppConfig class OwntracksConfig(AppConfig): + """ + Owntracks应用的配置类 + + 继承自Django的AppConfig类,用于配置owntracks应用的基本信息。 + name属性指定应用的名称,Django会根据这个名称来识别和加载相应的应用。 + """ name = 'owntracks' diff --git a/src/owntracks/migrations/0001_initial.py b/src/owntracks/migrations/0001_initial.py index 9eee55c..2016a53 100644 --- a/src/owntracks/migrations/0001_initial.py +++ b/src/owntracks/migrations/0001_initial.py @@ -5,27 +5,37 @@ import django.utils.timezone class Migration(migrations.Migration): + """ + 初始迁移文件,用于创建OwnTrackLog模型表 + """ initial = True dependencies = [ + # 依赖列表为空,表示这是初始迁移 ] operations = [ + # 创建OwnTrackLog模型的操作 migrations.CreateModel( name='OwnTrackLog', fields=[ + # 主键字段,自动创建 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # 用户标识字段,最大长度100 ('tid', models.CharField(max_length=100, verbose_name='用户')), + # 纬度字段,浮点数类型 ('lat', models.FloatField(verbose_name='纬度')), + # 经度字段,浮点数类型 ('lon', models.FloatField(verbose_name='经度')), + # 创建时间字段,默认值为当前时间 ('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), ], options={ - 'verbose_name': 'OwnTrackLogs', - 'verbose_name_plural': 'OwnTrackLogs', - 'ordering': ['created_time'], - 'get_latest_by': 'created_time', + 'verbose_name': 'OwnTrackLogs', # 模型的可读名称 + 'verbose_name_plural': 'OwnTrackLogs', # 模型的复数可读名称 + 'ordering': ['created_time'], # 默认排序字段 + 'get_latest_by': 'created_time', # 获取最新记录的字段 }, ), ] diff --git a/src/owntracks/migrations/0002_alter_owntracklog_options_and_more.py b/src/owntracks/migrations/0002_alter_owntracklog_options_and_more.py index b4f8dec..04a4684 100644 --- a/src/owntracks/migrations/0002_alter_owntracklog_options_and_more.py +++ b/src/owntracks/migrations/0002_alter_owntracklog_options_and_more.py @@ -4,16 +4,27 @@ from django.db import migrations class Migration(migrations.Migration): + """ + 数据库迁移文件,用于修改 OwnTrackLog 模型的选项和字段名 + """ dependencies = [ + # 依赖于 owntracks 应用的 0001_initial 迁移文件 ('owntracks', '0001_initial'), ] operations = [ + # 修改 OwnTrackLog 模型的选项配置 migrations.AlterModelOptions( name='owntracklog', - options={'get_latest_by': 'creation_time', 'ordering': ['creation_time'], 'verbose_name': 'OwnTrackLogs', 'verbose_name_plural': 'OwnTrackLogs'}, + options={ + 'get_latest_by': 'creation_time', # 更改获取最新记录的字段为 creation_time + 'ordering': ['creation_time'], # 更改默认排序字段为 creation_time + 'verbose_name': 'OwnTrackLogs', # 模型的可读名称 + 'verbose_name_plural': 'OwnTrackLogs' # 模型的复数可读名称 + }, ), + # 重命名字段:将 created_time 字段更名为 creation_time migrations.RenameField( model_name='owntracklog', old_name='created_time', diff --git a/src/owntracks/models.py b/src/owntracks/models.py index 760942c..a227fe0 100644 --- a/src/owntracks/models.py +++ b/src/owntracks/models.py @@ -5,16 +5,38 @@ from django.utils.timezone import now # Create your models here. class OwnTrackLog(models.Model): + """ + OwnTracks日志模型 + 用于存储OwnTracks位置跟踪日志 + + Attributes: + tid (CharField): 用户标识符,最大长度100个字符,不能为空 + lat (FloatField): 纬度坐标 + lon (FloatField): 经度坐标 + creation_time (DateTimeField): 日志创建时间,默认为当前时间 + + Meta: + ordering: 按照创建时间升序排列 + verbose_name: OwnTrack日志的可读性名称 + verbose_name_plural: OwnTrack日志的复数形式名称 + get_latest_by: 指定用于latest()查询的字段 + """ tid = models.CharField(max_length=100, null=False, verbose_name='用户') lat = models.FloatField(verbose_name='纬度') lon = models.FloatField(verbose_name='经度') creation_time = models.DateTimeField('创建时间', default=now) def __str__(self): + """ + 模型的字符串表示 + + Returns: + str: 用户ID + """ return self.tid class Meta: ordering = ['creation_time'] verbose_name = "OwnTrackLogs" verbose_name_plural = verbose_name - get_latest_by = 'creation_time' + get_latest_by = 'creation_time' \ No newline at end of file diff --git a/src/owntracks/tests.py b/src/owntracks/tests.py index 3b4b9d8..b23b6f9 100644 --- a/src/owntracks/tests.py +++ b/src/owntracks/tests.py @@ -9,11 +9,32 @@ from .models import OwnTrackLog # Create your tests here. class OwnTrackLogTest(TestCase): + """ + OwnTrackLog模型的测试类 + + 用于测试OwnTracks功能的相关接口和数据处理逻辑 + """ + def setUp(self): + """ + 测试初始化方法 + + 创建测试客户端和请求工厂实例,用于模拟HTTP请求 + """ self.client = Client() self.factory = RequestFactory() def test_own_track_log(self): + """ + 测试OwnTrack日志记录功能 + + 验证以下功能: + 1. 正常的日志数据能够正确保存到数据库 + 2. 缺少必要字段的日志数据会被拒绝 + 3. 地图展示功能的访问权限控制 + 4. 管理员用户登录后可以正常访问地图和数据接口 + """ + # 测试正常数据提交 o = { 'tid': 12, 'lat': 123.123, @@ -27,6 +48,7 @@ class OwnTrackLogTest(TestCase): length = len(OwnTrackLog.objects.all()) self.assertEqual(length, 1) + # 测试缺少必要字段的数据提交(应被拒绝) o = { 'tid': 12, 'lat': 123.123 @@ -39,21 +61,26 @@ class OwnTrackLogTest(TestCase): length = len(OwnTrackLog.objects.all()) self.assertEqual(length, 1) + # 测试未登录用户访问地图功能(应重定向到登录页面) rsp = self.client.get('/owntracks/show_maps') self.assertEqual(rsp.status_code, 302) + # 创建管理员用户并登录 user = BlogUser.objects.create_superuser( email="liangliangyy1@gmail.com", username="liangliangyy1", password="liangliangyy1") self.client.login(username='liangliangyy1', password='liangliangyy1') + + # 添加测试数据 s = OwnTrackLog() s.tid = 12 s.lon = 123.234 s.lat = 34.234 s.save() + # 测试各种数据展示接口的访问 rsp = self.client.get('/owntracks/show_dates') self.assertEqual(rsp.status_code, 200) rsp = self.client.get('/owntracks/show_maps') diff --git a/src/owntracks/urls.py b/src/owntracks/urls.py index c19ada8..049ac20 100644 --- a/src/owntracks/urls.py +++ b/src/owntracks/urls.py @@ -5,8 +5,12 @@ from . import views app_name = "owntracks" urlpatterns = [ + # 接收OwnTracks客户端位置数据的日志记录接口 path('owntracks/logtracks', views.manage_owntrack_log, name='logtracks'), + # 显示地图页面,展示位置轨迹 path('owntracks/show_maps', views.show_maps, name='show_maps'), + # 获取位置数据接口,用于前端地图渲染 path('owntracks/get_datas', views.get_datas, name='get_datas'), + # 显示可用的日志日期列表页面 path('owntracks/show_dates', views.show_log_dates, name='show_dates') ] diff --git a/src/owntracks/views.py b/src/owntracks/views.py index 4c72bdd..0e7df64 100644 --- a/src/owntracks/views.py +++ b/src/owntracks/views.py @@ -21,6 +21,18 @@ logger = logging.getLogger(__name__) @csrf_exempt def manage_owntrack_log(request): + """ + 处理OwnTracks客户端发送的位置日志数据 + + 该视图接收来自OwnTracks应用程序的POST请求,解析其中的tid(设备ID)、 + lat(纬度)、lon(经度)等位置信息,并保存到OwnTrackLog数据库中。 + + Args: + request: HTTP请求对象,包含JSON格式的位置数据 + + Returns: + HttpResponse: 返回处理结果状态('ok'/'data error'/'error') + """ try: s = json.loads(request.read().decode('utf-8')) tid = s['tid'] @@ -46,6 +58,18 @@ def manage_owntrack_log(request): @login_required def show_maps(request): + """ + 显示位置轨迹地图页面 + + 仅允许超级用户访问,显示指定日期的位置轨迹数据。 + 如果未指定日期,则默认显示当天数据。 + + Args: + request: HTTP请求对象 + + Returns: + HttpResponse: 地图页面或403 Forbidden响应 + """ if request.user.is_superuser: defaultdate = str(datetime.datetime.now(timezone.utc).date()) date = request.GET.get('date', defaultdate) @@ -60,6 +84,17 @@ def show_maps(request): @login_required def show_log_dates(request): + """ + 显示有位置记录的日期列表 + + 获取数据库中所有位置记录的日期,并去重排序后返回给前端。 + + Args: + request: HTTP请求对象 + + Returns: + HttpResponse: 包含日期列表的页面 + """ dates = OwnTrackLog.objects.values_list('creation_time', flat=True) results = list(sorted(set(map(lambda x: x.strftime('%Y-%m-%d'), dates)))) @@ -70,6 +105,17 @@ def show_log_dates(request): def convert_to_amap(locations): + """ + 将GPS坐标转换为高德地图坐标 + + 由于高德地图API限制每次最多转换30个坐标点,因此需要分批处理。 + + Args: + locations: 位置对象列表 + + Returns: + str: 转换后的坐标字符串,格式为"经度,纬度;经度,纬度;..." + """ convert_result = [] it = iter(locations) @@ -96,6 +142,18 @@ def convert_to_amap(locations): @login_required def get_datas(request): + """ + 获取位置轨迹数据接口 + + 根据指定日期查询位置数据,按设备ID分组并按时间排序, + 返回JSON格式的数据供前端地图渲染使用。 + + Args: + request: HTTP请求对象,可通过GET参数指定查询日期 + + Returns: + JsonResponse: 位置轨迹数据,格式为[{name: 设备ID, path: [[经度,纬度],...]}] + """ now = django.utils.timezone.now().replace(tzinfo=timezone.utc) querydate = django.utils.timezone.datetime( now.year, now.month, now.day, 0, 0, 0) diff --git a/src/plugins/article_copyright/plugin.py b/src/plugins/article_copyright/plugin.py index 317fed2..258bc33 100644 --- a/src/plugins/article_copyright/plugin.py +++ b/src/plugins/article_copyright/plugin.py @@ -1,9 +1,18 @@ +""" +文章版权插件 +在文章末尾自动添加版权声明 +""" + from djangoblog.plugin_manage.base_plugin import BasePlugin from djangoblog.plugin_manage import hooks from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME class ArticleCopyrightPlugin(BasePlugin): + """ + 文章版权插件类 + 继承自BasePlugin基类,实现文章版权声明功能 + """ PLUGIN_NAME = '文章结尾版权声明' PLUGIN_DESCRIPTION = '一个在文章正文末尾添加版权声明的插件。' PLUGIN_VERSION = '0.2.0' @@ -11,6 +20,10 @@ class ArticleCopyrightPlugin(BasePlugin): # 2. 实现 register_hooks 方法,专门用于注册钩子 def register_hooks(self): + """ + 注册插件钩子 + 将add_copyright_to_content方法注册到文章内容过滤钩子上 + """ # 在这里将插件的方法注册到指定的钩子上 hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_copyright_to_content) @@ -18,6 +31,8 @@ class ArticleCopyrightPlugin(BasePlugin): """ 这个方法会被注册到 'the_content' 过滤器钩子上。 它接收原始内容,并返回添加了版权信息的新内容。 + :param content: 原始文章内容 + :return: 添加版权声明后的文章内容 """ article = kwargs.get('article') if not article: @@ -29,4 +44,4 @@ class ArticleCopyrightPlugin(BasePlugin): # 3. 实例化插件。 # 这会自动调用 BasePlugin.__init__,然后 BasePlugin.__init__ 会调用我们上面定义的 register_hooks 方法。 -plugin = ArticleCopyrightPlugin() +plugin = ArticleCopyrightPlugin() \ No newline at end of file diff --git a/src/plugins/external_links/plugin.py b/src/plugins/external_links/plugin.py index 5b2ef14..354a170 100644 --- a/src/plugins/external_links/plugin.py +++ b/src/plugins/external_links/plugin.py @@ -6,15 +6,29 @@ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME class ExternalLinksPlugin(BasePlugin): + """ + 外部链接处理器插件 + 自动为文章中的外部链接添加 target="_blank" 和 rel="noopener noreferrer" 属性 + """ PLUGIN_NAME = '外部链接处理器' PLUGIN_DESCRIPTION = '自动为文章中的外部链接添加 target="_blank" 和 rel="noopener noreferrer" 属性。' PLUGIN_VERSION = '0.1.0' PLUGIN_AUTHOR = 'liangliangyy' def register_hooks(self): + """ + 注册钩子 + 将process_external_links方法注册到文章内容过滤钩子上 + """ hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.process_external_links) def process_external_links(self, content, *args, **kwargs): + """ + 处理外部链接 + 为外部链接添加target="_blank"和rel="noopener noreferrer"属性 + :param content: 原始文章内容 + :return: 处理后的文章内容 + """ from djangoblog.utils import get_current_site site_domain = get_current_site().domain @@ -45,4 +59,4 @@ class ExternalLinksPlugin(BasePlugin): return link_pattern.sub(replacer, content) -plugin = ExternalLinksPlugin() +plugin = ExternalLinksPlugin() \ No newline at end of file diff --git a/src/plugins/reading_time/plugin.py b/src/plugins/reading_time/plugin.py index 35f9db1..520414b 100644 --- a/src/plugins/reading_time/plugin.py +++ b/src/plugins/reading_time/plugin.py @@ -6,17 +6,27 @@ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME class ReadingTimePlugin(BasePlugin): + """ + 阅读时间预测插件 + 估算文章阅读时间并显示在文章开头 + """ PLUGIN_NAME = '阅读时间预测' PLUGIN_DESCRIPTION = '估算文章阅读时间并显示在文章开头。' PLUGIN_VERSION = '0.1.0' PLUGIN_AUTHOR = 'liangliangyy' def register_hooks(self): + """ + 注册钩子 + 将add_reading_time方法注册到文章内容过滤钩子上 + """ hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_reading_time) def add_reading_time(self, content, *args, **kwargs): """ 计算阅读时间并添加到内容开头。 + :param content: 原始文章内容 + :return: 添加了阅读时间提示的文章内容 """ # 移除HTML标签和空白字符,以获得纯文本 clean_content = re.sub(r'<[^>]*>', '', content) @@ -40,4 +50,4 @@ class ReadingTimePlugin(BasePlugin): return reading_time_html + content -plugin = ReadingTimePlugin() \ No newline at end of file +plugin = ReadingTimePlugin() \ No newline at end of file diff --git a/src/plugins/seo_optimizer/plugin.py b/src/plugins/seo_optimizer/plugin.py index b5b19a3..b069be4 100644 --- a/src/plugins/seo_optimizer/plugin.py +++ b/src/plugins/seo_optimizer/plugin.py @@ -8,15 +8,30 @@ from djangoblog.utils import get_blog_setting class SeoOptimizerPlugin(BasePlugin): + """ + SEO优化插件 + 为文章、页面等提供SEO优化,动态生成meta标签和JSON-LD结构化数据 + """ PLUGIN_NAME = 'SEO 优化器' PLUGIN_DESCRIPTION = '为文章、页面等提供 SEO 优化,动态生成 meta 标签和 JSON-LD 结构化数据。' PLUGIN_VERSION = '0.2.0' PLUGIN_AUTHOR = 'liuangliangyy' def register_hooks(self): + """ + 注册钩子 + 将dispatch_seo_generation方法注册到head_meta钩子上 + """ hooks.register('head_meta', self.dispatch_seo_generation) def _get_article_seo_data(self, context, request, blog_setting): + """ + 获取文章页面的SEO数据 + :param context: 页面上下文 + :param request: HTTP请求对象 + :param blog_setting: 博客设置 + :return: 包含SEO数据的字典 + """ article = context.get('article') if not isinstance(article, Article): return None @@ -62,6 +77,13 @@ class SeoOptimizerPlugin(BasePlugin): } def _get_category_seo_data(self, context, request, blog_setting): + """ + 获取分类页面的SEO数据 + :param context: 页面上下文 + :param request: HTTP请求对象 + :param blog_setting: 博客设置 + :return: 包含SEO数据的字典 + """ category_name = context.get('tag_name') if not category_name: return None @@ -93,6 +115,13 @@ class SeoOptimizerPlugin(BasePlugin): } def _get_default_seo_data(self, context, request, blog_setting): + """ + 获取默认页面的SEO数据 + :param context: 页面上下文 + :param request: HTTP请求对象 + :param blog_setting: 博客设置 + :return: 包含SEO数据的字典 + """ # Homepage and other default pages structured_data = { "@context": "https://schema.org", @@ -113,6 +142,13 @@ class SeoOptimizerPlugin(BasePlugin): } def dispatch_seo_generation(self, metas, context): + """ + 分发SEO数据生成 + 根据不同的页面类型生成相应的SEO数据 + :param metas: 原始meta标签 + :param context: 页面上下文 + :return: 生成的SEO标签 + """ request = context.get('request') if not request: return metas @@ -139,4 +175,4 @@ class SeoOptimizerPlugin(BasePlugin): {json_ld_script} """ -plugin = SeoOptimizerPlugin() +plugin = SeoOptimizerPlugin() \ No newline at end of file diff --git a/src/plugins/view_count/plugin.py b/src/plugins/view_count/plugin.py index 15e9d94..ec07525 100644 --- a/src/plugins/view_count/plugin.py +++ b/src/plugins/view_count/plugin.py @@ -3,16 +3,29 @@ from djangoblog.plugin_manage import hooks class ViewCountPlugin(BasePlugin): + """ + 文章浏览次数统计插件 + 用于统计和记录文章的浏览次数 + """ PLUGIN_NAME = '文章浏览次数统计' PLUGIN_DESCRIPTION = '统计文章的浏览次数' PLUGIN_VERSION = '0.1.0' PLUGIN_AUTHOR = 'liangliangyy' def register_hooks(self): + """ + 注册钩子 + 将record_view方法注册到文章获取后的钩子上 + """ hooks.register('after_article_body_get', self.record_view) def record_view(self, article, *args, **kwargs): + """ + 记录文章浏览次数 + 当文章内容被获取后调用此方法增加浏览次数 + :param article: 文章对象 + """ article.viewed() -plugin = ViewCountPlugin() \ No newline at end of file +plugin = ViewCountPlugin() \ No newline at end of file diff --git a/src/servermanager/models.py b/src/servermanager/models.py index 4326c65..ce26b84 100644 --- a/src/servermanager/models.py +++ b/src/servermanager/models.py @@ -3,6 +3,21 @@ from django.db import models # Create your models here. class commands(models.Model): + """ + 命令模型 + 用于存储服务器管理命令 + + Attributes: + title (CharField): 命令标题,最大长度300个字符 + command (CharField): 实际执行的命令,最大长度2000个字符 + describe (CharField): 命令描述信息,最大长度300个字符 + creation_time (DateTimeField): 命令创建时间,自动设置为创建时的时间 + last_modify_time (DateTimeField): 命令最后修改时间,每次保存时更新 + + Meta: + verbose_name: 命令的可读性名称 + verbose_name_plural: 命令的复数形式名称 + """ title = models.CharField('命令标题', max_length=300) command = models.CharField('命令', max_length=2000) describe = models.CharField('命令描述', max_length=300) @@ -10,6 +25,12 @@ class commands(models.Model): last_modify_time = models.DateTimeField('修改时间', auto_now=True) def __str__(self): + """ + 模型的字符串表示 + + Returns: + str: 命令标题 + """ return self.title class Meta: @@ -18,6 +39,22 @@ class commands(models.Model): class EmailSendLog(models.Model): + """ + 邮件发送日志模型 + 用于记录邮件发送的历史记录 + + Attributes: + emailto (CharField): 收件人邮箱地址,最大长度300个字符 + title (CharField): 邮件标题,最大长度2000个字符 + content (TextField): 邮件正文内容 + send_result (BooleanField): 邮件发送结果,True表示成功,False表示失败 + creation_time (DateTimeField): 邮件发送记录创建时间,自动设置为创建时的时间 + + Meta: + verbose_name: 邮件发送日志的可读性名称 + verbose_name_plural: 邮件发送日志的复数形式名称 + ordering: 按照创建时间倒序排列 + """ emailto = models.CharField('收件人', max_length=300) title = models.CharField('邮件标题', max_length=2000) content = models.TextField('邮件内容') @@ -25,9 +62,15 @@ class EmailSendLog(models.Model): creation_time = models.DateTimeField('创建时间', auto_now_add=True) def __str__(self): + """ + 模型的字符串表示 + + Returns: + str: 邮件标题 + """ return self.title class Meta: verbose_name = '邮件发送log' verbose_name_plural = verbose_name - ordering = ['-creation_time'] + ordering = ['-creation_time'] \ No newline at end of file -- 2.34.1 From 2ff034d444f55bddbcc72bde8b48d04877842be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=8B=E5=B0=A7=E5=90=9B?= <2939334239@qq.com> Date: Mon, 29 Sep 2025 10:14:54 +0800 Subject: [PATCH 2/3] modify gitignore --- .idea/workspace.xml | 41 ++--------------------------------------- src/.gitignore | 1 - 2 files changed, 2 insertions(+), 40 deletions(-) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 5c327aa..9b2c442 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -5,45 +5,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +