Commit 0eb88c0e by Nimisha Asthagiri

fixup! course_api unit tests.

parent b7c4ada9
......@@ -3,17 +3,17 @@
"""
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.forms import Form, CharField, Field, MultipleHiddenInput
from django.forms import Form, CharField, ChoiceField, Field, MultipleHiddenInput
from django.http import Http404
from rest_framework.exceptions import PermissionDenied
from courseware.access import _has_access_to_course
from xmodule.modulestore.django import modulestore
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
from transformers.student_view import StudentViewTransformer
from transformers.block_counts import BlockCountsTransformer
from .permissions import can_access_other_users_blocks, can_access_users_blocks
class ListField(Field):
......@@ -33,6 +33,10 @@ class BlockListGetForm(Form):
student_view_data = ListField(required=False)
block_counts = ListField(required=False)
depth = CharField(required=False)
return_type = ChoiceField(
required=False,
choices=[(choice, choice) for choice in ['dict', 'list']],
)
def clean_requested_fields(self):
# add default requested_fields
......@@ -60,37 +64,56 @@ class BlockListGetForm(Form):
return usage_key
def clean(self):
cleaned_data = super(BlockListGetForm, self).clean()
# add additional requested_fields that are specified as separate parameters, if they were requested
for additional_field in [StudentViewTransformer.STUDENT_VIEW_DATA, BlockCountsTransformer.BLOCK_COUNTS]:
if cleaned_data.get(additional_field):
cleaned_data['requested_fields'].add(additional_field)
usage_key = self.cleaned_data.get('usage_key')
if not usage_key:
return
def clean_return_type(self):
"""
Return valid 'return_type' or default value of 'dict'
"""
return self.cleaned_data['return_type'] or 'dict'
# validate and set user
def clean_requested_user(self, cleaned_data, course_key):
"""
Validates and returns the requested_user, while checking permissions.
"""
requested_username = cleaned_data.get('user', '')
requesting_user = self.initial['requesting_user']
if requesting_user.username.lower() == requested_username.lower():
cleaned_data['user'] = requesting_user
requested_user = requesting_user
else:
# the requesting user is trying to access another user's view
# verify requesting user is staff and update requested user's object
if not _has_access_to_course(requesting_user, 'staff', usage_key.course_key):
# verify requesting user can access another user's blocks
if not can_access_other_users_blocks(requesting_user, course_key):
raise PermissionDenied(
"'{requesting_username}' does not have permission to access view for '{requested_username}'."
.format(requesting_username=requesting_user.username, requested_username=requested_username)
)
# get requested user object
# update requested user object
try:
cleaned_data['user'] = User.objects.get(username=requested_username)
requested_user = User.objects.get(username=requested_username)
except (User.DoesNotExist):
raise Http404("'{username}' does not exist.".format(username=requested_username))
# verify whether the requested user's blocks can be accessed
if not can_access_users_blocks(requested_user, course_key):
raise PermissionDenied(
"Course blocks for '{requested_username}' cannot be accessed."
.format(requested_username=requested_username)
)
return requested_user
def clean(self):
cleaned_data = super(BlockListGetForm, self).clean()
# add additional requested_fields that are specified as separate parameters, if they were requested
for additional_field in [StudentViewTransformer.STUDENT_VIEW_DATA, BlockCountsTransformer.BLOCK_COUNTS]:
if cleaned_data.get(additional_field):
cleaned_data['requested_fields'].add(additional_field)
usage_key = cleaned_data.get('usage_key')
if not usage_key:
return
cleaned_data['user'] = self.clean_requested_user(cleaned_data, usage_key.course_key)
return cleaned_data
"""
Encapsulates permissions checks for Course Blocks API
"""
from courseware.access import has_access
from student.models import CourseEnrollment
def can_access_other_users_blocks(requesting_user, course_key):
"""
Returns whether the requesting_user can access the blocks for
other users in the given course.
"""
return has_access(requesting_user, 'staff', course_key)
def can_access_users_blocks(requested_user, course_key):
"""
Returns whether blocks for the requested_user is accessible.
"""
return (
(requested_user.id and CourseEnrollment.is_enrolled(requested_user, course_key)) or
has_access(requested_user, 'staff', course_key)
)
......@@ -7,15 +7,20 @@ from rest_framework.reverse import reverse
from transformers import SUPPORTED_FIELDS
# TODO support depth parameter
class BlockSerializer(serializers.Serializer):
"""
TODO
Serializer for single course block
"""
def _get_field(self, block_key, transformer, field_name):
def _get_field(self, block_key, transformer, field_name, default):
if transformer:
return self.context['block_structure'].get_transformer_block_data(block_key, transformer, field_name)
value = self.context['block_structure'].get_transformer_block_data(block_key, transformer, field_name)
else:
return self.context['block_structure'].get_xblock_field(block_key, field_name)
value = self.context['block_structure'].get_xblock_field(block_key, field_name)
# TODO should we return falsey values in the response?
# for example, if student_view_multi_device is false, just don't specify it?
return value if (value is not None) else default
def to_native(self, block_key):
# create response data dict for basic fields
......@@ -36,13 +41,35 @@ class BlockSerializer(serializers.Serializer):
# add additional requested fields that are supported by the various transformers
for supported_field in SUPPORTED_FIELDS:
if supported_field.requested_field_name in self.context['requested_fields']:
data[supported_field.requested_field_name] = self._get_field(
field_value = self._get_field(
block_key,
supported_field.transformer,
supported_field.block_field_name,
supported_field.default_value,
)
if field_value is not None:
data[supported_field.requested_field_name] = field_value
if 'children' in self.context['requested_fields']:
data['children'] = self.context['block_structure'].get_children(block_key)
children = self.context['block_structure'].get_children(block_key)
if children:
data['children'] = children
return data
class BlockDictSerializer(serializers.Serializer):
"""
Serializer that formats to a dictionary, rather than a list, of blocks
"""
root = serializers.CharField(source='root_block_key')
blocks = serializers.SerializerMethodField('get_blocks')
def get_blocks(self, structure):
"""
Serialize to a dictionary of blocks keyed by the block's usage_key.
"""
return {
unicode(block_key): BlockSerializer(block_key, context=self.context).data
for block_key in structure
}
......@@ -3,43 +3,52 @@ Tests for Course Blocks forms
"""
import ddt
from django.http import Http404, QueryDict
from unittest import TestCase
from urllib import urlencode
from rest_framework.exceptions import PermissionDenied
from student.tests.factories import UserFactory
from opaque_keys.edx.keys import UsageKey
from student.models import CourseEnrollment
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from openedx.core.djangoapps.util.test_forms import FormTestMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ..forms import BlockListGetForm
@ddt.ddt
class TestBlockListGetForm(FormTestMixin, TestCase):
class TestBlockListGetForm(FormTestMixin, ModuleStoreTestCase):
"""
Tests for BlockListGetForm
"""
FORM_CLASS = BlockListGetForm
def setUp(self):
super(TestBlockListGetForm, self).setUp()
self.student = UserFactory.create()
self.student2 = UserFactory.create()
self.staff = UserFactory.create(is_staff=True)
self.initial = {'requesting_user': self.student}
usage_key_string = 'block-v1:some_org+split_course+some_run+type@some_block_type+block@some_block_id'
self.course = CourseFactory.create()
usage_key = self.course.location
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
CourseEnrollmentFactory.create(user=self.student2, course_id=self.course.id)
self.initial = {'requesting_user': self.student}
self.form_data = QueryDict(
urlencode({
'user': self.student.username,
'usage_key': usage_key_string,
'usage_key': unicode(usage_key),
}),
mutable=True,
)
self.cleaned_data = {
'block_counts': [],
'depth': 0,
'return_type': 'dict',
'requested_fields': {'display_name', 'type'},
'student_view_data': [],
'usage_key': UsageKey.from_string(usage_key_string),
'usage_key': usage_key,
'user': self.student,
}
......@@ -91,6 +100,21 @@ class TestBlockListGetForm(FormTestMixin, TestCase):
self.initial = {'requesting_user': self.staff}
self.get_form(expected_valid=True)
def test_unenrolled_student(self):
CourseEnrollment.unenroll(self.student, self.course.id)
self.assert_raises_permission_denied()
def test_unenrolled_staff(self):
CourseEnrollment.unenroll(self.staff, self.course.id)
self.initial = {'requesting_user': self.staff}
self.form_data['user'] = self.staff.username
self.get_form(expected_valid=True)
def test_unenrolled_student_by_staff(self):
CourseEnrollment.unenroll(self.student, self.course.id)
self.initial = {'requesting_user': self.staff}
self.assert_raises_permission_denied()
#-- depth
def test_depth_integer(self):
......@@ -107,6 +131,20 @@ class TestBlockListGetForm(FormTestMixin, TestCase):
self.form_data['depth'] = 'not_an_integer'
self.assert_error('depth', "'not_an_integer' is not a valid depth value.")
#-- return_type
def test_return_type(self):
self.form_data['return_type'] = 'list'
self.cleaned_data['return_type'] = 'list'
self.assert_equals_cleaned_data()
def test_return_type_invalid(self):
self.form_data['return_type'] = 'invalid_return_type'
self.assert_error(
'return_type',
"Select a valid choice. invalid_return_type is not one of the available choices."
)
#-- requested fields
def test_requested_fields(self):
......
"""
Tests for Course Blocks serializers
"""
from mock import MagicMock
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from lms.djangoapps.course_blocks.api import get_course_blocks, LMS_COURSE_TRANSFORMERS
from ..transformers.blocks_api import BlocksAPITransformer
from ..serializers import BlockSerializer, BlockDictSerializer
from .test_utils import deserialize_usage_key
class TestBlockSerializerBase(ModuleStoreTestCase):
"""
Base class for testing BlockSerializer and BlockDictSerializer
"""
def setUp(self):
super(TestBlockSerializerBase, self).setUp()
self.course_key = self.create_toy_course()
self.user = UserFactory.create()
blocks_api_transformer = BlocksAPITransformer(
block_types_to_count=['video'],
requested_student_view_data=['video'],
)
self.block_structure = get_course_blocks(
self.user,
course_key=self.course_key,
transformers=LMS_COURSE_TRANSFORMERS + [blocks_api_transformer],
)
self.serializer_context = {
'request': MagicMock(),
'block_structure': self.block_structure,
'requested_fields': ['type'],
}
def assert_basic_block(self, block_key_string, serialized_block):
"""
Verifies the given serialized_block when basic fields are requested.
"""
block_key = deserialize_usage_key(block_key_string, self.course_key)
self.assertEquals(
self.block_structure.get_xblock_field(block_key, 'category'),
serialized_block['type'],
)
self.assertEquals(
set(serialized_block.iterkeys()),
{'id', 'type', 'lms_web_url', 'student_view_url'},
)
def add_additional_requested_fields(self):
"""
Adds additional fields to the requested_fields context for the serializer.
"""
self.serializer_context['requested_fields'].extend([
'children',
'display_name',
'graded',
'format',
'block_counts',
'student_view_data',
'student_view_multi_device',
])
def assert_extended_block(self, serialized_block):
"""
Verifies the given serialized_block when additional fields are requested.
"""
self.assertLessEqual(
{
'id', 'type', 'lms_web_url', 'student_view_url',
'display_name', 'graded',
'block_counts', 'student_view_multi_device',
},
set(serialized_block.iterkeys()),
)
# video blocks should have student_view_data
if serialized_block['type'] == 'video':
self.assertTrue('student_view_data' in serialized_block)
# html blocks should have student_view_multi_device set to True
if serialized_block['type'] == 'html':
self.assertTrue(serialized_block['student_view_multi_device'])
class TestBlockSerializer(TestBlockSerializerBase):
"""
Tests the BlockSerializer class, which returns a list of blocks.
"""
def create_serializer(self):
"""
creates a BlockSerializer
"""
return BlockSerializer(
self.block_structure, many=True, context=self.serializer_context,
)
def test_basic(self):
serializer = self.create_serializer()
for serialized_block in serializer.data:
self.assert_basic_block(serialized_block['id'], serialized_block)
def test_additional_requested_fields(self):
self.add_additional_requested_fields()
serializer = self.create_serializer()
for serialized_block in serializer.data:
self.assert_extended_block(serialized_block)
class TestBlockDictSerializer(TestBlockSerializerBase):
"""
Tests the BlockDictSerializer class, which returns a dict of blocks key-ed by its block_key.
"""
def create_serializer(self):
"""
creates a BlockDictSerializer
"""
return BlockDictSerializer(
self.block_structure, many=False, context=self.serializer_context,
)
def test_basic(self):
serializer = self.create_serializer()
# verify root
self.assertEquals(serializer.data['root'], unicode(self.block_structure.root_block_key))
# verify blocks
for block_key_string, serialized_block in serializer.data['blocks'].iteritems():
self.assertEquals(serialized_block['id'], block_key_string)
self.assert_basic_block(block_key_string, serialized_block)
def test_additional_requested_fields(self):
self.add_additional_requested_fields()
serializer = self.create_serializer()
for serialized_block in serializer.data['blocks'].itervalues():
self.assert_extended_block(serialized_block)
"""
Helper functions for unit tests
"""
from opaque_keys.edx.keys import UsageKey
def deserialize_usage_key(usage_key_string, course_key):
"""
Returns the deserialized UsageKey object of the given usage_key_string for the given course.
"""
return UsageKey.from_string(usage_key_string).replace(course_key=course_key)
"""
Tests for Course Blocks views
"""
from django.core.urlresolvers import reverse
from student.models import CourseEnrollment
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from .test_utils import deserialize_usage_key
class TestCourseBlocksView(ModuleStoreTestCase):
"""
Test class for CourseBlocks view
"""
def setUp(self):
super(TestCourseBlocksView, self).setUp()
self.user = UserFactory.create()
self.client.login(username=self.user.username, password='test')
self.course_key = self.create_toy_course()
self.course_usage_key = self.store.make_course_usage_key(self.course_key)
CourseEnrollmentFactory.create(user=self.user, course_id=self.course_key)
self.non_orphaned_block_usage_keys = set(
unicode(item.location)
for item in self.store.get_items(self.course_key)
# remove all orphaned items in the course, except for the root 'course' block
if self.store.get_parent_location(item.location) or item.category == 'course'
)
self.url = reverse(
'course_blocks',
kwargs={'usage_key_string': unicode(self.course_usage_key)}
)
def verify_response(self, expected_status_code=200, params=None):
query_params = {'user': self.user.username}
if params:
query_params.update(params)
response = self.client.get(self.url, query_params)
self.assertEquals(response.status_code, expected_status_code)
return response
def verify_response_block_list(self, response):
self.assertSetEqual(
set(block['id'] for block in response.data),
self.non_orphaned_block_usage_keys,
)
def verify_response_block_dict(self, response):
self.assertSetEqual(
set(response.data['blocks'].iterkeys()),
self.non_orphaned_block_usage_keys,
)
def assert_in_iff(self, member, container, predicate):
if predicate:
self.assertIn(member, container)
else:
self.assertNotIn(member, container)
def assert_true_iff(self, expression, predicate):
if predicate:
self.assertTrue(expression)
else:
self.assertFalse(expression)
def test_not_authenticated(self):
self.client.logout()
self.verify_response(401)
def test_not_enrolled(self):
CourseEnrollment.unenroll(self.user, self.course_key)
self.verify_response(403)
def test_non_existent_course(self):
self.store.delete_course(self.course_key, self.user)
self.verify_response(404)
def test_basic(self):
response = self.verify_response()
self.assertEquals(response.data['root'], unicode(self.course_usage_key))
self.verify_response_block_dict(response)
for block_key_string, block_data in response.data['blocks'].iteritems():
block_key = deserialize_usage_key(block_key_string, self.course_key)
self.assertEquals(block_data['id'], block_key_string)
self.assertEquals(block_data['type'], block_key.block_type)
self.assertEquals(block_data['display_name'], self.store.get_item(block_key).display_name or '')
def test_return_type_param(self):
response = self.verify_response(params={'return_type': 'list'})
self.verify_response_block_list(response)
def test_block_counts_param(self):
response = self.verify_response(params={'block_counts': ['course', 'chapter']})
self.verify_response_block_dict(response)
for block_data in response.data['blocks'].itervalues():
self.assertEquals(
block_data['block_counts']['course'],
1 if block_data['type'] == 'course' else 0,
)
self.assertEquals(
block_data['block_counts']['chapter'],
(
1 if block_data['type'] == 'chapter' else
5 if block_data['type'] == 'course' else
0
)
)
def test_student_view_data_param(self):
response = self.verify_response(params={'student_view_data': ['video', 'chapter']})
self.verify_response_block_dict(response)
for block_data in response.data['blocks'].itervalues():
self.assert_in_iff('student_view_data', block_data, block_data['type'] == 'video')
def test_requested_fields_param(self):
response = self.verify_response(
params={'requested_fields': ['graded', 'format', 'student_view_multi_device', 'children', 'not_a_field']}
)
self.verify_response_block_dict(response)
for block_key_string, block_data in response.data['blocks'].iteritems():
block_key = deserialize_usage_key(block_key_string, self.course_key)
xblock = self.store.get_item(block_key)
self.assert_in_iff('children', block_data, xblock.has_children)
self.assert_in_iff('graded', block_data, xblock.graded is not None)
self.assert_in_iff('format', block_data, xblock.format is not None)
self.assert_true_iff(block_data['student_view_multi_device'], block_data['type'] == 'html')
self.assertNotIn('not_a_field', block_data)
......@@ -7,15 +7,16 @@ from block_counts import BlockCountsTransformer
class SupportedFieldType(object):
def __init__(self, block_field_name, transformer=None, requested_field_name=None):
def __init__(self, block_field_name, transformer=None, requested_field_name=None, default_value=None):
self.transformer = transformer
self.block_field_name = block_field_name
self.requested_field_name = requested_field_name or block_field_name
self.default_value = default_value
SUPPORTED_FIELDS = (
SupportedFieldType('category', None, 'type'),
SupportedFieldType('display_name'),
SupportedFieldType('display_name', default_value=''),
SupportedFieldType('graded'),
SupportedFieldType('format'),
......
......@@ -11,8 +11,8 @@ class BlocksAPITransformer(BlockStructureTransformer):
STUDENT_VIEW_DATA = 'student_view_data'
STUDENT_VIEW_MULTI_DEVICE = 'student_view_multi_device'
def __init__(self, block_counts, requested_student_view_data):
self.block_counts = block_counts
def __init__(self, block_types_to_count, requested_student_view_data):
self.block_types_to_count = block_types_to_count
self.requested_student_view_data = requested_student_view_data
@classmethod
......@@ -35,4 +35,4 @@ class BlocksAPITransformer(BlockStructureTransformer):
Mutates block_structure based on the given user_info.
"""
StudentViewTransformer(self.requested_student_view_data).transform(user_info, block_structure)
BlockCountsTransformer(self.block_counts).transform(user_info, block_structure)
BlockCountsTransformer(self.block_types_to_count).transform(user_info, block_structure)
......@@ -62,5 +62,5 @@ class StudentViewTransformer(BlockStructureTransformer):
Mutates block_structure based on the given user_info.
"""
for block_key in block_structure.post_order_traversal():
if block_structure.get_xblock_field(block_key, 'type') not in self.requested_student_view_data:
if block_structure.get_xblock_field(block_key, 'category') not in self.requested_student_view_data:
block_structure.remove_transformer_block_data(block_key, self, self.STUDENT_VIEW_DATA)
from django.core.exceptions import ValidationError
from django.http import Http404
from rest_framework.generics import ListAPIView
from rest_framework.response import Response
from lms.djangoapps.course_blocks.api import get_course_blocks, LMS_COURSE_TRANSFORMERS
from openedx.core.lib.api.view_utils import view_auth_classes, DeveloperErrorViewMixin
from xmodule.modulestore.exceptions import ItemNotFoundError
from transformers.blocks_api import BlocksAPITransformer
from transformers.block_counts import BlockCountsTransformer
from transformers.student_view import StudentViewTransformer
from .forms import BlockListGetForm
from .serializers import BlockSerializer
from .serializers import BlockSerializer, BlockDictSerializer
@view_auth_classes()
......@@ -39,10 +41,10 @@ class CourseBlocks(DeveloperErrorViewMixin, ListAPIView):
Example: block_counts=video,problem
* fields: (list) Indicates which additional fields to return for each block.
* requested_fields: (list) Indicates which additional fields to return for each block.
The following fields are always returned: type, display_name
Example: fields=graded,format,student_view_multi_device,children
Example: requested_fields=graded,format,student_view_multi_device,children
* depth (integer or all) Indicates how deep to traverse into the blocks hierarchy.
A value of all means the entire hierarchy.
......@@ -50,6 +52,11 @@ class CourseBlocks(DeveloperErrorViewMixin, ListAPIView):
Example: depth=all
* return_type (string) Indicates in what data type to return the blocks.
Default is dict. Supported values are: dict, list
Example: return_type=dict
**Response Values**
The following fields are returned with a successful response.
......@@ -67,18 +74,18 @@ class CourseBlocks(DeveloperErrorViewMixin, ListAPIView):
* display_name: (string) The display name of the block.
* children: (list) If the block has child blocks, a list of IDs of the child blocks.
Returned only if "children" is included in the "fields" parameter.
Returned only if "children" is included in the "requested_fields" parameter.
* block_counts: (dict) For each block type specified in the block_counts parameter to the endpoint, the
aggregate number of blocks of that type for this block and all of its descendants.
Returned only if the "block_counts" input parameter contains this block's type.
* graded (boolean) Whether or not the block or any of its descendants is graded.
Returned only if "graded" is included in the "fields" parameter.
Returned only if "graded" is included in the "requested_fields" parameter.
* format: (string) The assignment type of the block.
Possible values can be "Homework", "Lab", "Midterm Exam", and "Final Exam".
Returned only if "format" is included in the "fields" parameter.
Returned only if "format" is included in the "requested_fields" parameter.
* student_view_data: (dict) The JSON data for this block.
Returned only if the "student_view_data" input parameter contains this block's type.
......@@ -92,8 +99,7 @@ class CourseBlocks(DeveloperErrorViewMixin, ListAPIView):
* student_view_multi_device: (boolean) Whether or not the block's rendering obtained via block_url has support
for multiple devices.
Returned only if "student_view_multi_device" is included in the "fields" parameter.
Returned only if "student_view_multi_device" is included in the "requested_fields" parameter.
* lms_web_url: (string) The URL to the navigational container of the xBlock on the web LMS.
This URL can be used as a further fallback if the student_view_url and the student_view_data fields
......@@ -109,30 +115,38 @@ class CourseBlocks(DeveloperErrorViewMixin, ListAPIView):
request - Django request object
course - course module object
"""
# request parameters
requested_params = request.GET.copy()
requested_params.update({'usage_key': usage_key_string})
params = BlockListGetForm(requested_params, initial={'requesting_user': request.user})
if not params.is_valid():
raise ValidationError(params.errors)
# transform blocks
blocks_api_transformer = BlocksAPITransformer(
params.cleaned_data.get(BlockCountsTransformer.BLOCK_COUNTS, []),
params.cleaned_data.get(StudentViewTransformer.STUDENT_VIEW_DATA, []),
)
blocks = get_course_blocks(
params.cleaned_data['user'],
params.cleaned_data['usage_key'],
transformers=LMS_COURSE_TRANSFORMERS + [blocks_api_transformer],
)
return Response(
BlockSerializer(
blocks,
context={
'request': request,
'block_structure': blocks,
'requested_fields': params.cleaned_data['requested_fields'],
},
many=True,
).data
)
try:
blocks = get_course_blocks(
params.cleaned_data['user'],
params.cleaned_data['usage_key'],
transformers=LMS_COURSE_TRANSFORMERS + [blocks_api_transformer],
)
except ItemNotFoundError as exception:
raise Http404("Course block not found: {}".format(exception.message))
# serialize
serializer_context = {
'request': request,
'block_structure': blocks,
'requested_fields': params.cleaned_data['requested_fields'],
}
if params.cleaned_data['return_type'] == 'dict':
serializer = BlockDictSerializer(blocks, context=serializer_context, many=False)
else:
serializer = BlockSerializer(blocks, context=serializer_context, many=True)
# response
return Response(serializer.data)
......@@ -2,6 +2,7 @@
Mixins for testing forms.
"""
class FormTestMixin(object):
"""A mixin for testing forms"""
def get_form(self, expected_valid):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment