diff --git a/CHANGES.md b/CHANGES.md index c7d1f82..39c6fea 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,10 @@ ## 版本更新记录 +### v0.2.7 2020-01-01 + +- 添加文件权限控制功能,支持:公开、私密、指定用户可见、访问码可见4中权限模式; +- 优化部分样式; + ### v0.2.6 2019-12-18 - 优化文档编写页面布局; diff --git a/MrDoc/settings.py b/MrDoc/settings.py index de9e6fb..8fb7bf9 100644 --- a/MrDoc/settings.py +++ b/MrDoc/settings.py @@ -25,7 +25,7 @@ SECRET_KEY = '5&71mt9@^58zdg*_!t(x6g14q*@84d%ptr%%s6e0l50zs0we3d' # SECURITY WARNING: don't run with debug turned on in production! DEBUG = False -VERSIONS = '0.2.6' +VERSIONS = '0.2.7' ALLOWED_HOSTS = ['*'] diff --git a/README.md b/README.md index 18e0bf6..1fd2f2b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ ## 介绍 基于Python的一个简单文档写作系统。 +当前版本为:**v0.2.7**,版本发布时间为**2020-01-01**,更新记录详见:[CHANGES.md](./CHANGES.md) + + MrDoc拥有以下特点: - 基于Django自带的用户模型,实现简单高效的用户管理,支持用户注册、用户登录、管理员等控制等功能; @@ -19,12 +22,11 @@ MrDoc拥有以下特点: - 仿GitBook文档阅读页面,支持文档阅读页面的字体缩放,字体类型修改; - 支持三级目录层级显示; - 支持文集导出为markdown文本格式.md文件; +- 支持基于文集的权限控制,提供公开、私密、指定用户可见、访问码可见4种权限模式; - 使用方便、二次开发修改也方便; 在开发过程中,参考和借鉴了GitBook、ShowDoc、Wordbook等应用的功能和样式。 -当前版本为:**v0.2.5**,更多更新记录详见:[CHANGES.md](./CHANGES.md) - ## 软件架构 后端基于Python Web框架Django diff --git a/app_admin/urls.py b/app_admin/urls.py index 8bfdf26..ed9f1b9 100644 --- a/app_admin/urls.py +++ b/app_admin/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ path('change_pwd',views.admin_change_pwd,name="change_pwd"), # 管理员修改用户密码 path('modify_pwd',views.change_pwd,name="modify_pwd"), # 普通用户修改密码 path('project_manage/',views.admin_project,name='project_manage'), # 文集管理 + path('project_role_manage//',views.admin_project_role,name="admin_project_role"), # 管理文集权限 path('doc_manage/',views.admin_doc,name='doc_manage'), # 文档管理 path('doctemp_manage/',views.admin_doctemp,name='doctemp_manage'), # 文档模板管理 path('setting/',views.admin_setting,name="sys_setting"), # 应用设置 diff --git a/app_admin/views.py b/app_admin/views.py index 8b2af08..8d1ea76 100644 --- a/app_admin/views.py +++ b/app_admin/views.py @@ -1,5 +1,6 @@ +# coding:utf-8 from django.shortcuts import render,redirect -from django.http.response import JsonResponse,HttpResponse +from django.http.response import JsonResponse,HttpResponse,Http404 from django.contrib.auth import authenticate,login,logout # 认证相关方法 from django.contrib.auth.models import User # Django默认用户模型 from django.contrib.auth.decorators import login_required # 登录需求装饰器 @@ -295,6 +296,39 @@ def admin_project(request): else: return HttpResponse('方法错误') +# 管理员后台 - 修改文集权限 +@superuser_only +def admin_project_role(request,pro_id): + pro = Project.objects.get(id=pro_id) + if request.method == 'GET': + return render(request,'app_admin/admin_project_role.html',locals()) + elif request.method == 'POST': + role_type = request.POST.get('role','') + if role_type != '': + if int(role_type) in [0,1]:# 公开或私密 + Project.objects.filter(id=int(pro_id)).update( + role = role_type, + modify_time = datetime.datetime.now() + ) + if int(role_type) == 2: # 指定用户可见 + role_value = request.POST.get('tagsinput','') + Project.objects.filter(id=int(pro_id)).update( + role=role_type, + role_value = role_value, + modify_time = datetime.datetime.now() + ) + if int(role_type) == 3: # 访问码可见 + role_value = request.POST.get('viewcode','') + Project.objects.filter(id=int(pro_id)).update( + role=role_type, + role_value=role_value, + modify_time=datetime.datetime.now() + ) + pro = Project.objects.get(id=int(pro_id)) + return render(request, 'app_admin/admin_project_role.html', locals()) + else: + return Http404 + # 管理员后台 - 文档管理 @superuser_only diff --git a/app_doc/migrations/0007_auto_20191221_1035.py b/app_doc/migrations/0007_auto_20191221_1035.py new file mode 100644 index 0000000..b6f92d9 --- /dev/null +++ b/app_doc/migrations/0007_auto_20191221_1035.py @@ -0,0 +1,37 @@ +# Generated by Django 2.1 on 2019-12-21 10:35 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('app_doc', '0006_auto_20191215_1910'), + ] + + operations = [ + migrations.CreateModel( + name='ProjectRole', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role_type', models.IntegerField(choices=[(0, 0), (1, 1)], verbose_name='私密文集类型')), + ('role_value', models.TextField(blank=True, null=True, verbose_name='文集受限值')), + ('create_time', models.DateField(auto_now_add=True, verbose_name='创建时间')), + ], + options={ + 'verbose_name': '私密文集权限', + 'verbose_name_plural': '私密文集权限', + }, + ), + migrations.AddField( + model_name='project', + name='role', + field=models.IntegerField(choices=[(0, 0), (1, 1)], default=0, verbose_name='文集权限'), + ), + migrations.AddField( + model_name='projectrole', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app_doc.Project', unique=True), + ), + ] diff --git a/app_doc/migrations/0008_auto_20191221_1055.py b/app_doc/migrations/0008_auto_20191221_1055.py new file mode 100644 index 0000000..88d043c --- /dev/null +++ b/app_doc/migrations/0008_auto_20191221_1055.py @@ -0,0 +1,30 @@ +# Generated by Django 2.1 on 2019-12-21 10:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('app_doc', '0007_auto_20191221_1035'), + ] + + operations = [ + migrations.RemoveField( + model_name='projectrole', + name='project', + ), + migrations.AddField( + model_name='project', + name='role_value', + field=models.TextField(blank=True, null=True, verbose_name='文集权限值'), + ), + migrations.AlterField( + model_name='project', + name='role', + field=models.IntegerField(choices=[(0, 0), (1, 1), (2, 2), (3, 3)], default=0, verbose_name='文集权限'), + ), + migrations.DeleteModel( + name='ProjectRole', + ), + ] diff --git a/app_doc/models.py b/app_doc/models.py index 2a1a3b9..73d0d06 100644 --- a/app_doc/models.py +++ b/app_doc/models.py @@ -5,6 +5,9 @@ from django.contrib.auth.models import User class Project(models.Model): name = models.CharField(verbose_name="文档名称",max_length=50) intro = models.TextField(verbose_name="介绍") + # 文集权限说明:0表示公开,1表示私密,2表示指定用户可见,3表示访问码可见 默认公开 + role = models.IntegerField(choices=((0,0),(1,1),(2,2),(3,3)), default=0,verbose_name="文集权限") + role_value = models.TextField(verbose_name="文集权限值",blank=True,null=True) create_user = models.ForeignKey(User,on_delete=models.CASCADE) create_time = models.DateTimeField(auto_now_add=True) modify_time = models.DateTimeField(auto_now=True) diff --git a/app_doc/urls.py b/app_doc/urls.py index 03a1e74..3f685f1 100644 --- a/app_doc/urls.py +++ b/app_doc/urls.py @@ -11,6 +11,8 @@ urlpatterns = [ path('manage_project',views.manage_project,name="manage_project"), # 管理文集 path('del_project/',views.del_project,name='del_project'), # 删除文集 path('report_project_md/',views.report_md,name='report_md'), # 导出文集MD文件 + path('modify_pro_role//',views.modify_project_role,name="modify_pro_role"),# 修改文集权限 + path('check_viewcode/',views.check_viewcode,name='check_viewcode'),# 文集访问码验证 #################文档相关 path('project///', views.doc, name='doc'), # 文档浏览页 path('create_doc/', views.create_doc, name="create_doc"), # 新建文档 diff --git a/app_doc/views.py b/app_doc/views.py index febccfc..e2b7419 100644 --- a/app_doc/views.py +++ b/app_doc/views.py @@ -1,7 +1,10 @@ -from django.shortcuts import render +from django.shortcuts import render,redirect from django.http.response import JsonResponse,Http404,HttpResponseNotAllowed,HttpResponse +from django.http import HttpResponseForbidden from django.contrib.auth.decorators import login_required # 登录需求装饰器 +from django.views.decorators.http import require_http_methods,require_GET,require_POST # 视图请求方法装饰器 from django.core.paginator import Paginator,PageNotAnInteger,EmptyPage,InvalidPage # 后端分页 +from django.core.exceptions import PermissionDenied from app_doc.models import Project,Doc,DocTemp from django.contrib.auth.models import User from django.db.models import Q @@ -12,7 +15,14 @@ from app_doc.report_utils import * # 文集列表 def project_list(request): - project_list = Project.objects.all() + # 登录用户 + if request.user.is_authenticated: + project_list = Project.objects.filter( + Q(role=0) | Q(role=2,role_value__contains=str(request.user.username)) | Q(create_user=request.user) + ) + else: + # 非登录用户只显示公开文集 + project_list = Project.objects.filter(role=0) return render(request, 'app_doc/pro_list.html', locals()) @@ -23,11 +33,13 @@ def create_project(request): try: name = request.POST.get('pname','') desc = request.POST.get('desc','') + role = request.POST.get('role','') if name != '': project = Project.objects.create( name=name, intro=desc[:100], - create_user=request.user + create_user=request.user, + role = int(role) ) project.save() return JsonResponse({'status':True,'data':{'id':project.id,'name':project.name}}) @@ -40,27 +52,46 @@ def create_project(request): # 文集页 +@require_http_methods(['GET']) def project_index(request,pro_id): # 获取文集 - if request.method == 'GET': - try: - # 获取文集信息 - project = Project.objects.get(id=int(pro_id)) - # 获取搜索词 - kw = request.GET.get('kw','') - # 获取文集下所有一级文档 - project_docs = Doc.objects.filter(top_doc=int(pro_id), parent_doc=0, status=1).order_by('sort') - if kw != '': - search_result = Doc.objects.filter(top_doc=int(pro_id),pre_content__icontains=kw) - # if search_result.count() == 0: - # search_result = {'count':0} - return render(request,'app_doc/project_doc_search.html',locals()) - return render(request, 'app_doc/project.html', locals()) - except Exception as e: - print(traceback.print_exc()) - return HttpResponse('请求出错') - else: - return HttpResponse('方法不允许') + try: + # 获取文集信息 + project = Project.objects.get(id=int(pro_id)) + + # 私密文集并且访问者非创建者 + if project.role == 1 and request.user != project.create_user: + return render(request,'404.html') + # 指定用户可见文集 + elif project.role == 2: + user_list = project.role_value + if request.user.is_authenticated: # 认证用户判断是否在许可用户列表中 + if request.user.username not in user_list and request.user != project.create_user: # 访问者不在指定用户之中 + return render(request, '404.html') + else:# 游客直接返回404 + return render(request, '404.html') + # 访问码可见 + elif project.role == 3: + # 浏览用户不为创建者 + if request.user != project.create_user: + viewcode = project.role_value + viewcode_name = 'viewcode-{}'.format(project.id) + r_viewcode = request.COOKIES[viewcode_name] if viewcode_name in request.COOKIES.keys() else 0 # 从cookie中获取访问码 + if viewcode != r_viewcode: # cookie中的访问码不等于文集访问码,跳转到访问码认证界面 + return redirect('/check_viewcode/?to={}'.format(request.path)) + + # 获取搜索词 + kw = request.GET.get('kw','') + # 获取文集下所有一级文档 + project_docs = Doc.objects.filter(top_doc=int(pro_id), parent_doc=0, status=1).order_by('sort') + if kw != '': + search_result = Doc.objects.filter(top_doc=int(pro_id),pre_content__icontains=kw) + return render(request,'app_doc/project_doc_search.html',locals()) + return render(request, 'app_doc/project.html', locals()) + except Exception as e: + print(traceback.print_exc()) + print(repr(e)) + return HttpResponse('请求出错') # 修改文集 @@ -85,6 +116,68 @@ def modify_project(request): return JsonResponse({'status':False,'data':'方法不允许'}) +# 修改文集权限 +@login_required() +def modify_project_role(request,pro_id): + pro = Project.objects.get(id=pro_id) + if (pro.create_user != request.user) and (request.user.is_superuser is False): + return render(request,'403.html') + else: + if request.method == 'GET': + return render(request,'app_doc/manage_project_role.html',locals()) + elif request.method == 'POST': + role_type = request.POST.get('role','') + if role_type != '': + if int(role_type) in [0,1]:# 公开或私密 + Project.objects.filter(id=int(pro_id)).update( + role = role_type, + modify_time = datetime.datetime.now() + ) + if int(role_type) == 2: # 指定用户可见 + role_value = request.POST.get('tagsinput','') + Project.objects.filter(id=int(pro_id)).update( + role=role_type, + role_value = role_value, + modify_time = datetime.datetime.now() + ) + if int(role_type) == 3: # 访问码可见 + role_value = request.POST.get('viewcode','') + Project.objects.filter(id=int(pro_id)).update( + role=role_type, + role_value=role_value, + modify_time=datetime.datetime.now() + ) + pro = Project.objects.get(id=int(pro_id)) + return render(request, 'app_doc/manage_project_role.html', locals()) + else: + return Http404 + + +# 验证文集访问码 +@require_http_methods(['GET',"POST"]) +def check_viewcode(request): + try: + if request.method == 'GET': + project_id = request.GET.get('to','').split("/")[2] + project = Project.objects.get(id=int(project_id)) + return render(request,'app_doc/check_viewcode.html',locals()) + else: + viewcode = request.POST.get('viewcode','') + project_id = request.POST.get('project_id','') + project = Project.objects.get(id=int(project_id)) + if project.role == 3 and project.role_value == viewcode: + obj = redirect("/project/{}/".format(project_id)) + obj.set_cookie('viewcode-{}'.format(project_id),viewcode) + return obj + else: + errormsg = "访问码错误" + return render(request, 'app_doc/check_viewcode.html', locals()) + + except Exception as e: + print(repr(e)) + return render(request,'404.html') + + # 删除文集 @login_required() def del_project(request): @@ -142,23 +235,44 @@ def manage_project(request): # 文档浏览页页 +@require_http_methods(['GET']) def doc(request,pro_id,doc_id): - if request.method == 'GET': - try: - if pro_id != '' and doc_id != '': - # 获取文集信息 - project = Project.objects.get(id=int(pro_id)) - # 获取文档内容 - doc = Doc.objects.get(id=int(doc_id),status=1) - # 获取文集下一级文档 - project_docs = Doc.objects.filter(top_doc=doc.top_doc, parent_doc=0, status=1).order_by('sort') - return render(request,'app_doc/doc.html',locals()) - else: - return HttpResponse('参数错误') - except Exception as e: - return HttpResponse('请求出错') - else: - return HttpResponse('方法不允许') + try: + if pro_id != '' and doc_id != '': + # 获取文集信息 + project = Project.objects.get(id=int(pro_id)) + + # 私密文集并且访问者非创建者 + if project.role == 1 and request.user != project.create_user: + return render(request, '404.html') + # 指定用户可见文集 + elif project.role == 2: + user_list = project.role_value + if request.user.is_authenticated: # 认证用户判断是否在许可用户列表中 + if request.user.username not in user_list and request.user != project.create_user: # 访问者不在指定用户之中 + return render(request, '404.html') + else: # 游客直接返回404 + return render(request, '404.html') + # 访问码可见 + elif project.role == 3: + # 浏览用户不为创建者 + if request.user != project.create_user: + viewcode = project.role_value + viewcode_name = 'viewcode-{}'.format(project.id) + r_viewcode = request.COOKIES[ + viewcode_name] if viewcode_name in request.COOKIES.keys() else 0 # 从cookie中获取访问码 + if viewcode != r_viewcode: # cookie中的访问码不等于文集访问码,跳转到访问码认证界面 + return redirect('/check_viewcode/?to={}'.format(request.path)) + + # 获取文档内容 + doc = Doc.objects.get(id=int(doc_id),status=1) + # 获取文集下一级文档 + project_docs = Doc.objects.filter(top_doc=doc.top_doc, parent_doc=0, status=1).order_by('sort') + return render(request,'app_doc/doc.html',locals()) + else: + return HttpResponse('参数错误') + except Exception as e: + return HttpResponse('请求出错') # 创建文档 diff --git a/static/tagsInput/tagsinput.css b/static/tagsInput/tagsinput.css new file mode 100644 index 0000000..93a056e --- /dev/null +++ b/static/tagsInput/tagsinput.css @@ -0,0 +1,106 @@ +*{margin: 0;padding: 0;list-style-type: none;text-decoration: none;} +.box{width: 500px;margin: auto;} +.bootstrap-tagsinput { + background-color: white; + border: 2px solid #ebedef; + border-radius: 6px; + margin-bottom: 18px; + padding: 6px 1px 1px 6px; + text-align: left; + font-size: 0; +} + +.bootstrap-tagsinput .badge { + border-radius: 4px; + background-color: #ebedef; + color: #7b8996; + font-size: 13px; + cursor: pointer; + display: inline-block; + position: relative; + vertical-align: middle; + overflow: hidden; + margin: 0 5px 5px 0; + + + padding: 6px 28px 6px 14px; + transition: .25s linear; +} + +.bootstrap-tagsinput .badge > span { + color: white; + padding: 0 10px 0 0; + cursor: pointer; + font-size: 12px; + position: absolute; + right: 0; + text-align: right; + text-decoration: none; + top: 0; + width: 100%; + bottom: 0; + z-index: 2; +} + +.bootstrap-tagsinput .badge > span:after { + content: "x"; + font-family: "Flat-UI-Pro-Icons"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 27px; +} + +@media (hover: hover) { + .bootstrap-tagsinput .badge { + padding: 6px 21px; + } + .bootstrap-tagsinput .badge > span { + opacity: 0; + filter: "alpha(opacity=0)"; + transition: opacity .25s linear; + } + .bootstrap-tagsinput .badge:hover { + background-color: #16a085; + color: white; + padding-right: 28px; + padding-left: 14px; + } + .bootstrap-tagsinput .badge:hover > span { + padding: 0 10px 0 0; + opacity: 1; + -webkit-filter: none; + filter: none; + } +} + + +.bootstrap-tagsinput input[type="text"] { + font-size: 14px; + border: none; + box-shadow: none; + outline: none; + background-color: transparent; + padding: 0; + margin: 0; + width: auto !important; + max-width: inherit; + min-width: 80px; + vertical-align: top; + height: 29px; + color: #34495e; +} + + .tagsinput-primary { + margin-bottom: 18px; +} + +.tagsinput-primary .bootstrap-tagsinput { + border-color: #1abc9c; + margin-bottom: 0; +} + +.tagsinput-primary .badge { + background-color: #1abc9c; + color: white; +} +.btn{background: #1ABC9C;border: none;color: #fff;padding: 10px;border-radius: 5px;margin-top: 10px;} \ No newline at end of file diff --git a/static/tagsInput/tagsinput.js b/static/tagsInput/tagsinput.js new file mode 100644 index 0000000..4643aff --- /dev/null +++ b/static/tagsInput/tagsinput.js @@ -0,0 +1,687 @@ + /* bootstrap-tagsinput v0.8.0 + */ + +(function ($) { + "use strict"; + + var defaultOptions = { + tagClass: function(item) { + return 'badge badge-info'; + }, + focusClass: 'focus', + itemValue: function(item) { + return item ? item.toString() : item; + }, + itemText: function(item) { + return this.itemValue(item); + }, + itemTitle: function(item) { + return null; + }, + freeInput: true, + addOnBlur: true, + maxTags: undefined, + maxChars: undefined, + confirmKeys: [13, 44], + delimiter: ',', + delimiterRegex: null, + cancelConfirmKeysOnEmpty: false, + onTagExists: function(item, $tag) { + $tag.hide().fadeIn(); + }, + trimValue: false, + allowDuplicates: false, + triggerChange: true + }; + + /** + * Constructor function + */ + function TagsInput(element, options) { + this.isInit = true; + this.itemsArray = []; + + this.$element = $(element); + this.$element.hide(); + + this.isSelect = (element.tagName === 'SELECT'); + this.multiple = (this.isSelect && element.hasAttribute('multiple')); + this.objectItems = options && options.itemValue; + this.placeholderText = element.hasAttribute('placeholder') ? this.$element.attr('placeholder') : ''; + this.inputSize = Math.max(1, this.placeholderText.length); + + this.$container = $('
'); + this.$input = $('').appendTo(this.$container); + + this.$element.before(this.$container); + + this.build(options); + this.isInit = false; + } + + TagsInput.prototype = { + constructor: TagsInput, + + /** + * Adds the given item as a new tag. Pass true to dontPushVal to prevent + * updating the elements val() + */ + add: function(item, dontPushVal, options) { + var self = this; + + if (self.options.maxTags && self.itemsArray.length >= self.options.maxTags) + return; + + // Ignore falsey values, except false + if (item !== false && !item) + return; + + // Trim value + if (typeof item === "string" && self.options.trimValue) { + item = $.trim(item); + } + + // Throw an error when trying to add an object while the itemValue option was not set + if (typeof item === "object" && !self.objectItems) + throw("Can't add objects when itemValue option is not set"); + + // Ignore strings only containg whitespace + if (item.toString().match(/^\s*$/)) + return; + + // If SELECT but not multiple, remove current tag + if (self.isSelect && !self.multiple && self.itemsArray.length > 0) + self.remove(self.itemsArray[0]); + + if (typeof item === "string" && this.$element[0].tagName === 'INPUT') { + var delimiter = (self.options.delimiterRegex) ? self.options.delimiterRegex : self.options.delimiter; + var items = item.split(delimiter); + if (items.length > 1) { + for (var i = 0; i < items.length; i++) { + this.add(items[i], true); + } + + if (!dontPushVal) + self.pushVal(self.options.triggerChange); + return; + } + } + + var itemValue = self.options.itemValue(item), + itemText = self.options.itemText(item), + tagClass = self.options.tagClass(item), + itemTitle = self.options.itemTitle(item); + + // Ignore items allready added + var existing = $.grep(self.itemsArray, function(item) { return self.options.itemValue(item) === itemValue; } )[0]; + if (existing && !self.options.allowDuplicates) { + // Invoke onTagExists + if (self.options.onTagExists) { + var $existingTag = $(".badge", self.$container).filter(function() { return $(this).data("item") === existing; }); + self.options.onTagExists(item, $existingTag); + } + return; + } + + // if length greater than limit + if (self.items().toString().length + item.length + 1 > self.options.maxInputLength) + return; + + // raise beforeItemAdd arg + var beforeItemAddEvent = $.Event('beforeItemAdd', { item: item, cancel: false, options: options}); + self.$element.trigger(beforeItemAddEvent); + if (beforeItemAddEvent.cancel) + return; + + // register item in internal array and map + self.itemsArray.push(item); + + // add a tag element + + var $tag = $('' + htmlEncode(itemText) + ''); + $tag.data('item', item); + self.findInputWrapper().before($tag); + $tag.after(' '); + + // Check to see if the tag exists in its raw or uri-encoded form + var optionExists = ( + $('option[value="' + encodeURIComponent(itemValue) + '"]', self.$element).length || + $('option[value="' + htmlEncode(itemValue) + '"]', self.$element).length + ); + + // add