给Django模型加上依赖条件的“伪”必填属性

近被分配到一个将用户表格电子化的任务。表格很大,打印版出来有3页多,而且许多项都是必填的。考虑到网络的不稳定,随时保存的功能是必不可少的。因为随时保存的特性的引入,所以不仅在form上无法做用required来限定,在model上的每一条字段也都要求允许null或者空字符。那么一个问题就出来了,如何验证一个表格实例是否已经完成呢?

一个很直观的想法,就是给这个表格模型添加一个验证函数,在调用时逐个检查每个字段是否为空。这是一个很不错方法,事实上Django对form的验证也是使用这样的思路。但是这个表单有一个比较特殊的地方,就是部分字段是否是必填依赖与其他字段。如果依照Django form框架的逻辑,那就为每个字段建立相应 clean_foo 的方法,在需要验证时利用自省进行调用。嗯,这样应该也可以吧。不过既然django并没有给model提供类似功能的框架,我就想来弄点新的东西出来玩玩 :P

分析当前表格的性质,大体上有如下几种必填属性:

  • 没有任何依赖,必须填写。
  • 依赖项为一个布尔值,当该依赖项被选种时(值为True),必须填写。
  • 依赖项为N个字段,如果其中一个已填写,则该项不是必填项,反之必填。

由于逻辑比较固定,更确定了不想使用 clean_foo 的方式,因为有违DRY的精神。基本的想法就是给model field加上required属性,同时扩展这个属性的功能,把依赖信息也加入进去。然后在做检查时,根据依赖信息调用特定的检查逻辑。下面就是目前的实现代码。

def mark_required(f, **kwargs):
    """
      empty_value - define the empty value of this field. If this is not specified, this
                    function will try to use the field type's default empty value:
                    e.g. CharField: '', ForeignKey: None, .etc.

      dep_fields  - required depends on the field(s). List, tuple or a single field name
                    is required. If not specified, no dependance will be calculated.

      dep_default - A flag for those dep fields not specified in the dep_values. It is a
                    boolean value notes the field is required when thos dep fields are
                    empty or not. default is False, means if those fields are empty, this
                    field is not required. Particularly designed for the boolean field if
                    some questions are required when it is checked.

      dep_values  - a dict for the field in dep_fields, each pair contains the following
                    information
                      key: values
                    or
                      key: (values, flag)
                    key is the name of that dep field. values accept a list(important! not
                    tuple!) or a single value for that field. the flag notes that the field
                    is required when the dep field should be equal or not with one of the
                    given values. If the flag is not specified, the dep_default will be used.

    """
    try:
        f._j_empty_values = kwargs['empty_value']
        # Special treasurement on empty list/tuple
        if f._j_empty_values in ([], ()): f._j_empty_values = [f._j_empty_values]
        if not isinstance(f._j_empty_values, (list, tuple)):
            f._j_empty_values = [f._j_empty_values]
    except KeyError: pass
        # cannot get ForeignKey's default here, so leave it until runtime
        # f._j_empty_values = [f.get_default()]

    # Make dep_fields a tuple, or None if not required
    dep_fields = kwargs.get('dep_fields', None)
    if dep_fields:
        # cannot check if the names are valid during building time
        if not isinstance(dep_fields, (list, tuple)):
            dep_fields = (dep_fields, )
        for dep in dep_fields:
            assert isinstance(dep, (str, unicode))
    f._j_dep_fields = dep_fields

    if bool(f._j_dep_fields):
        # Get default setting
        f._j_dep_default = kwargs.get('dep_default', False)
        # Get specifed values
        f._j_dep_values = kwargs.get('dep_values', {})
        if f._j_dep_values:
            for k in f._j_dep_values.keys():
                if not k in f._j_dep_fields:
                    f._j_dep_values.pop(k)
                elif not isinstance(f._j_dep_values[k], tuple):
                    if isinstance(f._j_dep_values[k], list):
                        f._j_dep_values[k] = (f._j_dep_values[k], f._j_dep_default)
                    else:
                        f._j_dep_values[k] = ([f._j_dep_values[k]], f._j_dep_default)
                else:
                    v, flag = f._j_dep_values[k]
                    if not isinstance(v, list):
                        f._j_dep_values[k] = ([v], flag)

    return f

def is_required(obj, f):
    if not isinstance(f, models.Field):
        f = obj._meta.get_field(f)
    try:
        if not hasattr(f, '_j_empty_values'): f._j_empty_values = [f.get_default()]
        # check if this field is filled not not
        is_empty = getattr(obj, f.attname) in f._j_empty_values
        # if filled or no dep on other fields, return because no further checks are needed
        if not is_empty or not f._j_dep_fields:
            return is_empty

        required = True

        for dep in f._j_dep_fields:
            # Try to update the dep dict at runtime for caching
            if not f._j_dep_values.has_key(dep):
                f._j_dep_values[dep] = ([obj._meta.get_field(dep).get_default()], f._j_dep_default)

            values, flag = f._j_dep_values[dep]

            required &= (getattr(obj, dep) in values) == flag

        return required

    except AttributeError:
        return False

使用方法是在定义model的时候,给需要验证的字段套用mark_required来指定依赖信息:

class Profile(models.Model):
    # 必填
    birthday = mark_required(models.DateField(_(u'Date of Birth'), blank = True, null = True))
    # 中文名字和英文名字二者择其一
    english_name = mark_required(models.CharField(_(u'Name(in English)'), max_length = 50, blank = True), dep_fields = 'chinese_name', dep_default = True)
    chinese_name = mark_required(models.CharField(_(u'Name(in Chinese)'), max_length = 20, blank = True), dep_fields = 'english_name', dep_default = True)
    # 是否是学生
    is_student = models.BooleanField(_(u'Student?'), default = False)
    # 如果是学生,学校名称是必要信息
    school_name = mark_required(models.CharField(_(u'School Name'), max_length = 100, blank = True), dep_fields = ['is_student'])
    # 也可以结合起来使用,比如如果是学生,家长姓名的中,英版本二者择其一
    guardian_english_name = mark_required(models.CharField(_(u'Guardian Name(in English)'), max_length = 100, blank = True), dep_fields = ['is_student', 'guardian_chinese_name'], dep_values = {'guardian_chinese_name' : ('', True)})
    guardian_chinese_name = mark_required(models.CharField(_(u'Guardian Name(in Chinese)'), max_length = 100, blank = True), dep_fields = ['is_student', 'guardian_english_name'], dep_values = {'guardian_english_name' : ('', True)})

然后需要验证时,使用如下的验证方法:

def is_completed(instance):
    for field in instance._meta.fields:
        if is_required(instance, field):
            return False
    return True

最后可以给对应model添加是否信息完整的字段,给 post_save 添加上事件,更新该字段,这儿就不贴代码了。

现在的功能还是挺简陋的,比如空值的判断也只是以default为主,难免会有bug。不过基本上满足项目要求,就先这样吧,等有时间了再对代码好好调整一下。