Open edX:审核系统

2016年9月21日 由 Amon 没有评论 »

参考:http://blog.just4fun.site/edx-check-publish

目的

审核人员审核通过才予以发布,粒度细到每一单元 (章->节->单元)

思路

构建审核系统的核心是构建权限系统,将审核系统视为权限系统的一个应用(上下层关系)

利用django的权限系统,参考djangobook:会话、用户和注册
构建用户组,可实现不同粒度的权限系统(审核只是权限系统的一个应用)
django admin可以增删改用户/用户组
把用户组与课程关联
构建出院系/部分之类的逻辑关系
简单地利用既有的is_staff/is_superuser构建全局的权限系统
利用is_staff可以构建出课程级别的审核权限(全局审核员,将课程审核员添加为对应课程的团队成员)
代价,可能会损失is_staff本身的特性,比如studio本可以将其用于教师身份审核机制(当然这个特性默认不启用)

简易实现

我们先实现全局的权限系统,superuser拥有全局的审核权限,每一个章节的发布都需要superuser的审核

特性描述

superuser是网站全站管理员,可以增删其他superuser
每一个章节的发布都需要superuser的审核通过

技术实现

我们可以直接以发布按钮作为切入点:

具体的想法是,【发布】按钮改为【审核通过】按钮,只有superuser对其可见/可操作。

使用浏览器调试工具,锁定元素(action-publish),跟踪到publish-xblock.underscore (https://github.com/edx/edx-platform/blob/master/cms/templates/js/publish-xblock.underscore#L113

我们现在需要在目标元素(主要是发布按钮)前后一个逻辑判断,仅当用户是superuser时才予以展示(潜在风险是后台其实没做验证,暂不论)

往细里说,我们只需要往这个文件中传递request.user.is_superuser参数,便可得知当前用户是否是superuser。

问题装化为如何往当前文件(publish-xblock.underscore) 传递变量

我们发现publish-xblock.underscore由container_subviews.js(https://github.com/edx/edx-platform/blob/master/cms/static/js/views/pages/container_subviews.js#L89)加载

这是一个枢纽。我们可以跟踪publishedBy变量(https://github.com/edx/edx-platform/blob/master/cms/static/js/views/pages/container_subviews.js#L112),它关联到后端变量published_by(https://github.com/edx/edx-platform/blob/master/cms/djangoapps/contentstore/views/item.py#L1063

在此注入变量便可

开发调试

在devstack中编辑cms/static/js/views/pages/container_subviews.js即使生效

提醒

不要忘了编辑静态文件后,用paver编译一下

遗留问题

能否在js里直接写 ${ request.user.username }

实验结果

不能在cms/static/js/views/pages/container_subviews.js中写${ request.user.username },只有混杂在html中的js才会被当模板渲染

可以通过传参的的机制实现,不过实现起来比较棘手,

可以直接写html使按钮隐藏,同时在后台验证用户是够是超级用户:具体修改_save_xblock (https://github.com/edx/edx-platform/blob/master/cms/djangoapps/contentstore/views/item.py#L585

把:

        if publish == 'make_public':
            modulestore().publish(xblock.location, user.id)

改为

        if publish == 'make_public':
            if user.is_superuser:
                modulestore().publish(xblock.location, user.id)

如此一来。普通教师可以编写课程内容,却无法发布。而超级用户可以点击发布,在超级用户视角下,所有可点击状态的发布按钮,都编码当前内容有修改,待发布

结论

搞定!

Open edX:自动化工作

2016年9月21日 由 Amon 没有评论 »

参考:http://blog.just4fun.site/edx-studio-spider

studio

按官方说法:Studio是课程制作系统,课程团队使用它来创建和更新课程。Studio将课程内容写入到Mongo数据库中,之后LMS从中读取课程内容

架构

从技术角度来看,studio是个web服务,而且是个前端很重的web服务(mvc/模块化)。edx的课程制作后台,复杂而强大,官方好像并不认为适合用移动端来做,他们压根没构建RESTful接口

需求

随着对平台的深入了解和使用,我们可能会想把一些重复性的劳动自动化,诸如批量构建课程、批量备份、批量添加老师、批量构建结构化课程。人力来做的麻烦除了耗费精力,还容易出错

如何使studio可编程控制,是一个随着使用深入可能遇到的需求

思路

这个问题,我最初的想法是翻后台源码,看看它们的实现,看看是否有可能引出资源的RESTful接口,只要有小而美的接口,组合它们来完成任务就不麻烦

切入点从前端入手,之后跟踪请求,找到请求的后端实现,然后重新封装实现,引为外部接口,这部分的探索你可以从我的edx_siteapi(https://github.com/wwj718/edx_siteapi)提交记录中看到

后来偶然的机会在github上看到@pmitros(https://github.com/pmitros)的实现:edx-rest(https://github.com/pmitros/edx-rest),pmitros来自mit,是edx项目的首席科学家、创始人之一,他采用了爬虫的思路

爬虫视角

爬虫爬取一个页面,通常是为了采集数据,爬取的过程常常会涉及与页面交互,点击以获取更多数据或是进入其他链接,而我们在一个页面中点击操作完成任务,常常也是点击链接的过程,如此一来爬虫与页面的交互,很自然地被用来自动化完成任务(很像批处理)

对服务器来说,爬虫对页面的操作,与用户的操作不可区分(等效),这么一来,理论上,任何能在open edx studio里做的事,都能采用爬虫完成

说到爬虫,最近edx官方源码库出现了一个活跃的爬虫项目:pa11ycrawler(https://github.com/edx/pa11ycrawler),基于Scrapy(https://scrapy.org/ https://github.com/scrapy/scrapy ),用于检测页面的可访问性(是否挂掉)。

多说一句,如果你想反爬虫,尤其是对付那些会动态ip切换的老手的话,可以使用机器学习观察他们的行为模式(我在一些技术会议上遇到过这么做的公司)

技术细节

我们知道studio需要登录,所以爬虫需要伪造为有效用户,你需要使用浏览器取得当前账号的csrftoken和sessionid,之后爬虫就会以你的身份去和服务器交互。如果你对http协议熟悉,了解cookie/session的概念,你知道只要我们取得cookie,就可以伪装成用户身份。许多著名的网络攻击都用到cookie

当我们能用程序伪装为浏览器时,剩下的细节就是找到功能对应的请求,分析http请求,并仿造即可(chrome调试工具里的network会帮上大忙)

这样做的好处

采用爬虫来使studio可编程的好处很多:

1. 非侵入。我们获得这些功能的同时,没有入侵源码
2. 操作直接,简易。不涉及open edx的内部逻辑
3. 权限合理,因为爬虫模拟的用户是合法用户,爬虫权限和用户一致,这样一来,爬虫的改进可以是自下而上的,不需要网站管理员干预
4. 易于分发,爬虫实际是python包,可以运行在任何能联网的机器上
5. 用户自主,用户可以自行为studio的操作编程

后来我的edx_siteapi和course_backup(https://github.com/wwj718/course_backup)都推翻原有设计,采用基于edx-rest的方式来构建

我的补充

edx-rest只支持studio,我把爬虫对接到lms:edx_lms_rest.py(https://github.com/wwj718/edx_siteapi/blob/master/edx_lms_rest.py),如此一来,lms上能在网页上操作的项目,也都能由爬虫自动化完成了

我给出了往课程里批量化注册学生的例子,并且封装为RESTful服务,使用方法也给出了:enrollment批量选修课程(https://github.com/wwj718/edx_siteapi#enrollment%E6%89%B9%E9%87%8F%E9%80%89%E8%AF%BE%E8%AF%BE%E7%A8%8B%E5%88%9B%E5%BB%BA%E6%97%B6),细节看源码
其他思路

如果你对 爬虫/http请求 不熟悉,可能会觉得让爬虫模拟浏览器不是建轻松的工作,教你另一种巧妙的方式:使用自动化测试工具, 下边是我们常用的工具,即便对那些熟悉爬虫的人来说,遇到前端特别复杂的页面,他们也偏好这些工具:

1. PhantomJS
2. webdriver/selenium

参考:http://fangruhua.com/post-59

Open edX:体系架构

2016年9月21日 由 Amon 没有评论 »

参考:http://blog.just4fun.site/Open-edX-Architecture-Translation
参考:https://open.edx.org/contributing-to-edx/architecture
参考:https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/architecture

Open edX是一个基于web技术的平台,用于创建,发布和分析在线课程。它驱动着edx.org以及其他的许多在线教育网站。

这篇文章准备站在较高的层面上来说明平台当前的架构方案,而不会深入到许多细节里。同时也描述出我们正在积极做着的变更。

技术

Open edX中,几乎所有的后端(服务器端)代码都是用Python写的,django作为web应用框架,使用Mako模板(译者注:替换Django原生模板)

前端(浏览器端)代码主要用JavaScript来写。部分代码使用CoffeeScript来写,edX正将其替换为JavaScript。部分客户端代码(前端)由Backbone.js写成,更多的代码正在往这个框架上迁移。

edX同时使用Sass和Bourbon框架来写css

edX_Architecture

组件

Open edX平台中有一组核心组件。在可能的情况下,这些组件采用稳定的,经过验证的的api来互相通信

点击示意图可以查看Open edX的组件:

edX_architecture_CMS_LMS

学习管理系统(LMS)

LMS是Open edX的核心部分,学生使用它来学习课程。LMS也提供教师面板。

LMS采用了多种存储机制,课程存储在Mongo中,视频存储在YouTube和或亚马逊S3上,而每个学生的数据存储在MySQL数据库里

学生浏览课程并与课程内容交互。这个过程产生的事件被推送到analytics pipeline里,这些事件将被收集,分析和发布

当前edX正在把LMS打造成一个LTI服务提供者,以便Open edX课程组件能嵌入到其他学习平台

Studio

Studio是课程制作系统,课程团队使用它来创建和更新课程。Studio将课程内容写入到Mongo数据库中,之后LMS从中读取课程内容

课程浏览

Open edX提供了一个简单的前端页面用来浏览课程。edx.org有一个独立的主页和课程搜索页面,不过并没有开源

课程结构

Open edX的课程由单元构成,这些单元被称为XBlocks。任何人都可以写出新的XBlocks,教育者和技术人员可以利用xblock来扩充他们的课程组件。edX平台仍然包含着一些XModules组件,XModules组件是XBlocks的前一个版本。EdX正在将这部分XModules重写为XBlocks,逐渐将它移除。

除了XBlocks,还有几种方法也可以拓展课程组件的行为:

1. LMS支持LTI,课程作者可以集成其他LTI工具到Open edX课程中
2. 问题中,可以嵌入python代码来呈现问题或是评审学生的答案,这些python代码将在CodeJail中执行(隔离的环境)
3. JavaScript组件能通过JS Input集成进去
4. 课件可以导入导出为Open Learning XML (OLX),这是一种基于XML的格式

讨论

课程内的讨论由一个独立的论坛组件来驱动。这是为数不多没有采用Python来写的组件,它选择了Ruby的Sinatra框架,LMS使用了论坛组件提供的API 来与之通信,并将论坛集成到LMS中

论坛带有一个通知服务,会通知学生有哪些他感兴趣的话题有了更新

移动端

Open edX也打造了移动端,包括iOS和Android平台,目前只允许学生看视频。edX正在强化他们

数据分析

记录学生行为的事件由Open edX的数据分析管道捕获。这些事件数据使用json格式存储在S3中。之后可以使用Hadoop来处理,分析聚合完的结果将被发布到MySQL中。这些分析结果将通过REST API传送到Insights中呈现给用户。Insights是一个Django app,课程讲师和网站管理员可以通过它来了解学生正在做什么,以及他们的学习习惯

示意图中展示了Open edX中数据分析部分的架构:

edX_Architecture_Analytics

后台进程

一些比较大的任务被丢到后台进程,而不是在请求中执行。这些任务是队列式和分布式的,使用了Celery and RabbitMQ来管理它们。例子如:

1. 课程学习评分(成绩单)
2. 群发邮件
3. 生成答案分布报告
4. 生成结业证书

Open edX包含了一个叫做XQueue的可定制队列,用来跑自定义评级代码。这些都是独立的进程,用来运行计算密集型评估工作

搜索

Open edX在以下地方使用了Elasticsearch来搜索:

1. 课程内容
2. 讨论区
3. 学生笔记

Open edX:视频流

2016年9月20日 由 Amon 没有评论 »

参考:http://blog.just4fun.site/open-edx-qiniu

在Open edX的众多组件和服务中,并不包含视频流服务。不可否认的是,在线教育中,视频是要素之一,也许是最重要的要素之一,对一些人而言,甚至没有之一。

视频流一般被视为一个common server,市面上有数不清的商业或是开源解决方案,Open edX没有去重造车轮,而是和youtube做了很多整合。我们与youtube无缘。当然作为通用的组件,Open edX中的视频模块支持一般的视频资源(url),无论是云存储还是自建服务。

自建视频流

如果准备自建视频流服务,可以参考@MT的在内部网络为edX配置视频服务:

对于局域网内的用户(学校/企业),自建服务是个有诱惑力的方案。

不过这里边存在的坑是,视频流服务搭建不难,搭建一个友好的客户端,上传管理视频却颇为不易。在此推荐使用minio(https://github.com/minio/minio)作为管理视频资源的工具,细节可以参考我的这篇文章:

构建类s3存储系统(Minio)http://blog.just4fun.site/install-Minio-Cloud-Storage

使用云存储

视频解决方案有很多,大家可以自行google,看大家的对比评测,再结合自己的需求选型,在此就不多推荐了。

我比较偏好七牛云(http://www.qiniu.com/)。对开发者友好,api写得很漂亮

在此演示如何使用七牛云为open edX提供视频服务,并将客户端(js)集成其中

如何集成

首先我们需要考虑一个问题,视频管理入口以什么形态集成到Open edX中合适(如何集成七牛云存储)。换个角度,Open edX有哪些拓展方式呢。毕竟我们可以把集成外部存储系统,看做一次对系统的拓展

在Extending edX(https://open.edx.org/extending-edx)中,官方给出了集中常见的拓展方式。此外还有两种很典型的拓展:

1. 对django开发者而言还可以直接侵入式拓展open edx,通过添加django app或者修改增强mvt中的任何一个环节

2. 模仿insights的做法,完全构建一个新的服务(网站),之后使用oauth2来打通用户系统

因为我们希望将系统集成到open edx内部,所以决定采用添加django app的做法。 用户上传和管理视频资源需要UI界面,参考Adding a UI Page(http://edx.readthedocs.io/projects/edx-developer-guide/en/latest/user_interface_development#adding-a-ui-page),发现侵入式地定制open edx很是繁琐,我们决定为此功能写一个独立的页面,绕开繁重的前端架构

为何不是xblock

也许许多Open edX用户会觉得为何放着xlock不用,而采用侵入性更大的django app来拓展呢。原因有二:

1. 视频管理是一个用户视角下,全局性的操作,应该有一个同意的资源管理入口,而不是每次需要先添加一个组件,再在组件里边管理视频,逻辑上,这样也能做出来。我们可以把xblock视为必须实例化(instance)为组件的东西

2. 我们不想放弃既有的视频组件(数据采集等强大功能)

技术背景

关于七牛云你需要了解的知识和上传管理的逻辑,可以参考我此前的文章:为Open edX构建存储服务 (http://blog.just4fun.site/built-data-storage-system

如果你想读懂接下来的源码,你需要了解django和django-restful-framework,如果只是用的话,就无所谓

后端部分

我们直接在 /edx/app/edxapp/edx-platform/cms/djangoapps 添加一个django appqiniu_storage,形如:

├── add_the_app.sh
├── ajax.js
├── __init__.py
├── models.py
├── permissions.py
├── readme.md
├── serializers.py
├── urls.py
├── views.py

我们重点介绍model和view部分,其他不赘述

models.py

#!/usr/bin/env python
# encoding: utf-8

from __future__ import unicode_literals
from django.db import models
#from django.contrib.auth.models import User

class QiniuFiles(models.Model):
    course_id = models.CharField(max_length=100,blank=True)
    username = models.CharField(max_length=50,blank=True) # 上传用户,资源所有者
    file_key = models.CharField(max_length=100)
    file_url = models.CharField(max_length=100,blank=True)
    file_name = models.CharField(max_length=100)
    file_size = models.CharField(max_length=20,default="0")
    #endUser = Column(String(100),nullable=True)
    create_time = models.DateTimeField(u'创建时间',auto_now=True)

    class Meta:
        ordering = ('create_time',)

views.py

只列出关键部分

qiniu_access_key = getattr(settings,  "QINIU_ACCESS_KEY", None)
class QiniuFilesViewSet(viewsets.ModelViewSet):
    authentication_classes = (TokenAuthentication, SessionAuthentication,) 
    serializer_class = QiniuFilesSerializer
    permission_classes = (permissions.IsAuthenticatedOrReadOnly,IsOwnerOrReadOnly,)

    def get_queryset(self):
        """
        This view should return a list of all the purchases
        for the currently authenticated user.
        """
        return QiniuFiles.objects.filter(username=self.request.user.username) #用户级别的管理权限,每个用户只能管理自己上传的文件
    # 删除功能暂不演示

其中的IsOwnerOrReadOnly值得关注,校验用户与资源的关系

from rest_framework import permissions

class IsOwnerOrReadOnly(permissions.BasePermission):
    """
    Object-level permission to only allow owners of an object to edit it.
    Assumes the model instance has an `owner` attribute.
    """

    def has_object_permission(self, request, view, obj):

        if request.method in permissions.SAFE_METHODS:
            return True
        return obj.username == request.user.username

前端部分

前端部分主要参考七牛的js-sdk(https://github.com/qiniu/js-sdk),使用了clipboard.js(https://github.com/zenorocha/clipboard.js)用于点击事件,使用了noty(https://github.com/needim/noty)用于消息提醒

代码形如:

$(function() {
    var uploader = Qiniu.uploader({
        runtimes: 'html5,flash,html4',
        browse_button: 'pickfiles',//是哪个可上传的元素
        container: 'container',
        drop_element: 'container',//是否可拖动,且是哪个元素
        max_file_size: '1000mb',
       // flash_swf_url: 'bower_components/plupload/js/Moxie.swf',
        dragdrop: true,
        chunk_size: '4mb',
        //uptoken: 'xxx' //测试用
        uptoken_url: '/qiniu/uptoken',  //key由后端生成,定制化的规则包含在载荷中
        domain: 'xxx',//这是域名的绑定地址
        get_new_uptoken: false,
        unique_names: true,
        auto_start: true,
        log_level: 5,
        ...

上传流程涉及的代码

在七牛的上传原理中,上传需要凭证,我们来看看凭证的生成规则

@api_view(['GET'])
def make_uptoken(request, format=None):
            test_uptoken = QiniuTool().get_test_uptoken(request)
            #跨域的问题 Access-Control-Allow-Origin
            response = Response({"uptoken": test_uptoken})
            return response

其中Qiniu类为

class QiniuTool(object):
        '''
        #处理七牛凭证相关的工具,生成uptoken
        存储相关的部分被抽象为rest服务
        函数只接受get和post
        '''
        callback_url = 'http://studio.xxx.com/qiniu/post_from_qiniu'
        #http://developer.qiniu.com/article/kodo/kodo-developer/up/vars 所有的魔法变量
        #callback_body = 'filename=$(fname)&filesize=$(fsize)&key=$(key)&mimeType=$(mimeType)&endUser=$(endUser)&etag=$(etag)'
        access_key = getattr(settings,  "QINIU_ACCESS_KEY", None)
        secret_key = getattr(settings,  "QINIU_SECRET_KEY", None)
        q = Auth(access_key, secret_key) # access_key和secret_key来自settings里
        bucket_name = "easy-edx"
        def get_test_uptoken(self,request):
            callback_body = 'file_name=$(fname)&file_size=$(fsize)&file_key=$(key)&mimeType=$(mimeType)&endUser=$(endUser)&etag=$(etag)&username={}'.format(request.user.username)
            # 上传策略有许多可选的参数,方便服务于业务逻辑:参考[python-sdk](http://developer.qiniu.com/docs/v6/sdk/python-sdk)
            #上传文件到七牛后, 七牛将文件名和文件大小回调给业务服务器。
            policy={
                    'scope':self.bucket_name,
                    'callbackUrl':self.callback_url, #回调 请求方式为POST
                    'callbackBody':callback_body
            }
            #token = q.upload_token(bucket_name,3600,policy)
            token = self.q.upload_token(self.bucket_name,policy=policy)
            return token

视频上传好之后,七牛会可以发送一个消息给服务器,我们在此存下文件信息即可

from qiniu_files.serializers import QiniuFilesSerializer

@api_view(['POST'])
def post_from_qiniu(request, format=None):
            origin_authorization = request.META.get('HTTP_AUTHORIZATION', None)
            access_key = re.split(r'\W',origin_authorization)[1]
            request.data["file_url"] = "http://media.xxx.com/"+ request.data["file_key"]
            request.data["file_size"] = request.data["file_size"]
            serializer =  QiniuFilesSerializer(data=request.data)
            if access_key == Qiniu().access_key and serializer.is_valid():
                serializer.save() #把信息存储到qiniu_storage模型里
                instance = serializer.save()
                data = file_info_format(request.data)
                #使用序列化就能存入本地
                data["id"]=instance.pk
                return Response(data)
            return Response({"success":False,"message":u"请求不合格"})

todo

还有许多细节可以改进,诸如校验用户是否有教师权限

后记

上边实际给出了open edx集成外部存储的方式,思路是通用的,不限于七牛。诸如你也可以将你自建的视频存储集成到open edx中,区别仅在抽象的存储接口(我们可以用minio构建)

Open edX:运维管理

2016年9月20日 由 Amon 没有评论 »

命令行工具

参考:http://blog.just4fun.site/edx-tools

lms的日志文件:

/edx/var/log/lms/edx.log

参考:https://github.com/edx/configuration/issues/2107
参考:http://blog.just4fun.site/install-edx-Dogwood
参考:http://blog.just4fun.site/about-Open-edX