diff --git a/course/constants.py b/course/constants.py index 514998f6c..c6197d959 100644 --- a/course/constants.py +++ b/course/constants.py @@ -38,6 +38,8 @@ COURSE_ID_REGEX = "(?P[-a-zA-Z0-9]+)" EVENT_KIND_REGEX = "(?P[_a-z0-9]+)" FLOW_ID_REGEX = "(?P[-_a-zA-Z0-9]+)" +FORM_ID_REGEX = "(?P[-_a-zA-Z0-9]+)" +FORM_FIELD_ID_REGEX = "(?P[-_a-zA-Z0-9]+)" GRADING_OPP_ID_REGEX = "(?P[-_a-zA-Z0-9]+)" # FIXME : Support page hierarchy. Add '/' here, fix validation code. STATICPAGE_PATH_REGEX = r"(?P[-\w]+)" @@ -128,6 +130,7 @@ class participation_permission: # noqa use_git_endpoint = "use_git_endpoint" use_markup_sandbox = "use_markup_sandbox" use_page_sandbox = "use_page_sandbox" + use_forms = "use_forms" test_flow = "test_flow" edit_events = "edit_events" diff --git a/course/content.py b/course/content.py index b6978593f..9b9385592 100644 --- a/course/content.py +++ b/course/content.py @@ -1552,11 +1552,11 @@ def is_commit_sha_valid(repo, commit_sha): return sha.encode() -def list_flow_ids(repo, commit_sha): - # type: (Repo_ish, bytes) -> List[Text] +def list_dir_yaml_ids(repo, commit_sha, dir_name): + # type: (Repo_ish, bytes, Text) -> List[Text] flow_ids = [] try: - flows_tree = get_repo_blob(repo, "flows", commit_sha) + flows_tree = get_repo_blob(repo, dir_name, commit_sha) except ObjectDoesNotExist: # That's OK--no flows yet. pass @@ -1567,4 +1567,9 @@ def list_flow_ids(repo, commit_sha): return sorted(flow_ids) + +def list_flow_ids(repo, commit_sha): + # type: (Repo_ish, bytes) -> List[Text] + return list_dir_yaml_ids(repo, commit_sha, "flows") + # vim: foldmethod=marker diff --git a/course/forms.py b/course/forms.py new file mode 100644 index 000000000..d551dea36 --- /dev/null +++ b/course/forms.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8 -*- + +from __future__ import division + +__copyright__ = "Copyright (C) 2019 Isuru Fernando" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +import textwrap + +import django.forms as forms +from django.contrib import messages # noqa +from django.core.exceptions import PermissionDenied +from django.utils.translation import ugettext_lazy as _ +from django import http # noqa +from django.utils.timezone import now + +from crispy_forms.layout import Submit + +from course.utils import course_view, render_course_page + +from course.constants import participation_permission as pperm +from course.utils import ( # noqa + CoursePageContext) +from course.content import get_yaml_from_repo +from course.validation import ValidationError +from relate.utils import string_concat, as_local_time + +# {{{ for mypy + +if False: + from typing import Text, Optional, Any, Iterable, Dict, List # noqa + from relate.utils import Repo_ish # noqa + +# }}} + + +class CreateForm(forms.Form): + # prevents form submission with codemirror's empty textarea + use_required_attribute = False + + def __init__(self, form_fields): + super(CreateForm, self).__init__() + + from crispy_forms.helper import FormHelper + self.helper = FormHelper() + + self.form_fields = form_fields + self.created_time = now() + self.id = as_local_time(self.created_time).strftime("%Y%m%d_%H%M%S_%f") + + for field in form_fields: + field_data = dict(required=True, + initial=field.value, + label=field.label) + if field.type == "Choice": + self.fields[field.id] = forms.ChoiceField( + choices=[(c, c) for c in field.choices], + **field_data) + elif field.type == "Text": + self.fields[field.id] = forms.CharField( + **field_data) + elif field.type == "Integer": + self.fields[field.id] = forms.IntegerField( + **field_data) + elif field.type == "Float": + self.fields[field.id] = forms.FloatField( + **field_data) + else: + assert field.type == "Hidden" + + if field.id == "template_in": + self.template_in = field.value + if field.id == "template_out": + file_out = field.value.rsplit(".", 1) + self.template_out = file_out[0] + "_" + self.id + if len(file_out) > 1: + self.template_out += "." + file_out[-1] + if field.id == "announce": + self.announce = str(field.value).lower() == "true" + + self.helper.add_input( + Submit("submit", _("Submit"), accesskey="p"), + ) + self.helper.add_input( + Submit("reset", _("Reset"), css_class="btn-default"), + ) + self.helper.add_input( + Submit("validate", _("Validate"), css_class="btn-default"), + ) + + def get_jinja_text(self): + created_time = as_local_time(self.created_time).strftime("%Y-%m-%d @ %H:%M") + + text = "{{% with id=\"{id}\",\n".format(id=self.id) + for field in self.form_fields: + text += " {field_name}=\"{field_value}\",\n".format( + field_name=field.id, field_value=field.value) + text += " created_time=\"{created_time}\" %}}".format( + created_time=created_time) + text += textwrap.dedent(""" + {{% include "{template_in}" %}} + {{% endwith %}} + """).format(template_in=self.template_in) + return text, self.template_out + + +def process_value(field): + try: + if field.type == "Integer": + field.value = int(field.value) + elif field.type == "Float": + field.value = float(field.value) + except ValueError: + # This condition is impossible if the user uses the web UI + raise ValidationError( + _("form field '%(id)s' value '%(field_value)s' is" + " not a '%(field_type)s'.") % {'field_value': field.value, + 'field_type': field.type, + 'id': field.id}) + + +def process_form_fields(form_fields, data): + if "reset" in data: + data = {} + for field in form_fields: + if not hasattr(field, "label"): + field.label = field.id + + if field.id in data: + field.value = data[field.id] + + if field.type == "Choice": + choices = [] + for value in field.choices: + value = str(value) + if value.startswith("~DEFAULT~"): + v = value[9:].strip() + choices.append(v) + if not hasattr(field, "value"): + field.value = v + else: + choices.append(value) + field.choices = choices + process_value(field) + + +def get_form(repo, form_name, commit_sha): + contents = get_yaml_from_repo(repo, "forms/%s.yml" % form_name, commit_sha) + contents.name = form_name + return contents + + +def get_all_forms(repo, commit_sha): + from course.content import list_dir_yaml_ids + form_names = list_dir_yaml_ids(repo, commit_sha, "forms") + forms = [] + for name in form_names: + contents = get_form(repo, name, commit_sha) + forms.append(contents) + return forms + + +@course_view +def view_all_forms(pctx): + if not pctx.has_permission(pperm.use_forms): + raise PermissionDenied() + + forms = get_all_forms(pctx.repo, pctx.course_commit_sha) + + return render_course_page(pctx, "course/forms.html", { + "forms": forms, + }) + + +@course_view +def view_form(pctx, form_id): + if not pctx.has_permission(pperm.use_forms): + raise PermissionDenied() + + form_info = get_form(pctx.repo, form_id, pctx.course_commit_sha) + + from course.enrollment import get_participation_role_identifiers + roles = get_participation_role_identifiers( + pctx.course, pctx.participation) + + if not any(role in form_info.access_roles for role in roles): + raise PermissionDenied() + + def back_to_form(form, form_info): + return render_course_page(pctx, "course/form.html", { + "form": form, + "description": form_info.description, + "title": form_info.title, + }) + + request = pctx.request + + if request.method != "POST": + process_form_fields(form_info.fields, {}) + form = CreateForm(form_info.fields) + return back_to_form(form, form_info) + + process_form_fields(form_info.fields, request.POST) + form = CreateForm(form_info.fields) + + if "reset" in request.POST: + return back_to_form(form, form_info) + + page_source, file_out = form.get_jinja_text() + + # {{{ Check if file already exists + + course = pctx.course + content_repo = pctx.repo + + from course.content import SubdirRepoWrapper + if isinstance(content_repo, SubdirRepoWrapper): + repo = content_repo.repo + else: + repo = content_repo + + repo_head = repo[b"HEAD"] + repo_contents = [(entry.path, entry.sha, entry.mode) for entry in + repo.object_store.iter_tree_contents(repo_head.tree)] + for entry in repo_contents: + if entry[0].decode("utf-8") == file_out: + messages.add_message(request, messages.ERROR, + _("Target file: '%s' already exists ") % file_out) + return back_to_form(form, form_info) + # }}} + + # {{{ Create a blob (file) and save in object store + from dulwich.objects import Blob + blob = Blob.from_string(page_source.encode("utf-8")) + repo.object_store.add_object(blob) + + # }}} + + # {{{ Create a tree with the contents from HEAD and new file + from dulwich.index import commit_tree + repo_contents.append((file_out.encode("utf-8"), blob.id, 0o100644)) + tree_id = commit_tree(repo.object_store, repo_contents) + + user = pctx.participation.user + committer = "{} <{}>".format(user.username, user.email).encode("utf-8") + message = "Create page {} with form {}".format(file_out, form_id).encode("utf-8") + + # }}} + + # {{{ Create a commit with the tree and parent as HEAD. + from dulwich.objects import Commit + commit = Commit() + commit.tree = tree_id + commit.parents = [repo_head.id] + commit.author = commit.committer = committer + commit.commit_time = commit.author_time = int(now().timestamp()) + commit.commit_timezone = commit.author_timezone = 0 + commit.encoding = b"UTF-8" + commit.message = message + repo.object_store.add_object(commit) + + # }}} + + # {{{ validate + + from course.validation import validate_course_content, ValidationError + try: + warnings = validate_course_content( + content_repo, course.course_file, course.events_file, + commit.id, course=course) + except ValidationError as e: + messages.add_message(request, messages.ERROR, + _("Course content did not validate successfully: '%s' " + "Update not applied.") % str(e)) + return back_to_form(form, form_info) + else: + if not warnings: + messages.add_message(request, messages.SUCCESS, + _("Course content validated successfully.")) + else: + messages.add_message(request, messages.WARNING, + string_concat( + _("Course content validated OK, with warnings: "), + "
    %s
") + % ("".join( + "
  • %(location)s: %(warningtext)s
  • " + % {'location': w.location, 'warningtext': w.text} + for w in warnings))) + # }}} + + if "validate" in request.POST: + return back_to_form(form, form_info) + + if pctx.participation.preview_git_commit_sha is not None: + messages.add_message(request, messages.ERROR, + _("Cannot apply update while previewing. ")) + return back_to_form(form, form_info) + + if repo[b"HEAD"] != repo_head: + messages.add_message(request, messages.ERROR, + _("Repo updated by somebody else. Try again.")) + return back_to_form(form, form_info) + + repo[b"HEAD"] = commit.id + course.active_git_commit_sha = commit.id.decode() + course.save() + messages.add_message(request, messages.SUCCESS, + _("Update applied. ")) + + # {{{ Create InstantFlow + + if form_info.type == "flow" and hasattr(form, "announce") and form.announce: + from course.models import InstantFlowRequest + from datetime import timedelta + ifr = InstantFlowRequest() + ifr.course = course + ifr.flow_id = file_out.rsplit("/", 1)[-1][:-4] + ifr.start_time = form.created_time + ifr.end_time = form.created_time + timedelta(minutes=20) + ifr.save() + # }}} + + return back_to_form(form, form_info) diff --git a/course/migrations/0114_add_use_forms_permission.py b/course/migrations/0114_add_use_forms_permission.py new file mode 100644 index 000000000..3ecec7be4 --- /dev/null +++ b/course/migrations/0114_add_use_forms_permission.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +from django.db import migrations + +def add_use_forms_permission(apps, schema_editor): + from course.constants import participation_permission as pperm + + ParticipationRolePermission = apps.get_model("course", "ParticipationRolePermission") # noqa + + roles_pks = ( + ParticipationRolePermission.objects.filter( + permission=pperm.edit_course) + .values_list("role", flat=True) + ) + + if roles_pks.count(): + for pk in roles_pks: + ParticipationRolePermission.objects.get_or_create( + role_id=pk, + permission=pperm.use_forms + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0113_merge_20190919_1408'), + ] + + operations = [ + migrations.RunPython(add_use_forms_permission) + ] diff --git a/course/templates/course/course-base.html b/course/templates/course/course-base.html index 4d206bbfc..1a62df2d1 100644 --- a/course/templates/course/course-base.html +++ b/course/templates/course/course-base.html @@ -119,6 +119,7 @@ {% if pperm.use_markup_sandbox %}
  • {% trans "Markup sandbox" %}
  • {% endif %} +
  • {% trans "Forms" %}
  • {% if pperm.test_flow %} diff --git a/course/templates/course/form.html b/course/templates/course/form.html new file mode 100644 index 000000000..aeccf138d --- /dev/null +++ b/course/templates/course/form.html @@ -0,0 +1,34 @@ +{% extends "course/course-base.html" %} +{% load i18n %} + +{% load crispy_forms_tags %} +{% load static %} + +{% block content %} +

    {{ title }}

    + {{ description }} + + {% if page_errors %} +
    + + {{ page_errors | safe }} +
    + {% endif %} + + {% if page_warnings %} +
    + + {% blocktrans trimmed %} Warnings were encountered when validating the page: {% endblocktrans %} + +
      + {% for w in page_warnings %} +
    • {{ w.location }}: {{ w.text }}
    • + {% endfor %} +
    +
    + {% endif %} +
    + {% crispy form %} +
    +{% endblock %} + diff --git a/course/templates/course/forms.html b/course/templates/course/forms.html new file mode 100644 index 000000000..594370d1a --- /dev/null +++ b/course/templates/course/forms.html @@ -0,0 +1,19 @@ +{% extends "course/course-base.html" %} +{% load i18n %} + +{% load crispy_forms_tags %} +{% load static %} + +{%block header_extra %} + +{% endblock %} + +{% block content %} +

    {% trans "Forms" %}

    + +{% endblock %} + diff --git a/course/validation.py b/course/validation.py index c0a646348..5c033efe7 100644 --- a/course/validation.py +++ b/course/validation.py @@ -1157,6 +1157,150 @@ def validate_flow_desc(vctx, location, flow_desc): # }}} +# {{{ form validation + + +def validate_form_desc(vctx, location, form_desc): + validate_struct( + vctx, + location, + form_desc, + required_attrs=[ + ("title", str), + ("description", "markup"), + ("type", str), + ("fields", list), + ("access_roles", list), + ], + allowed_attrs=[], + ) + + for j, role in enumerate(form_desc.access_roles): + validate_role( + vctx, + "%s, role %d" % (location, j+1), + role) + + for j, field in enumerate(form_desc.fields): + validate_form_field( + vctx, + "%s, field %d" % (location, j+1), + field) + + # {{{ check field id uniqueness + + field_ids = set() + + for field in form_desc.fields: + if field.id in field_ids: + raise ValidationError( + string_concat("%(location)s: ", + _("form field id '%(field_id)s' not unique")) + % {'location': location, 'field_id': field.id}) + + field_ids.add(field.id) + + # }}} + + # Check required fields + + for req_field in ["template_in", "template_out"]: + if req_field not in field_ids: + raise ValidationError( + string_concat("%(location)s: ", + _("required form field id '%(field_id)s' not found")) + % {'location': location, 'field_id': req_field}) + + +def validate_form_field(vctx, location, field_desc): + validate_struct( + vctx, + location, + field_desc, + required_attrs=[ + ("id", str), + ("type", str), + ], + allowed_attrs=[ + ("choices", list), + ("value", (str, int, float, bool)), + ("label", str), + ], + ) + + if field_desc.type not in ["Text", "Integer", "Float", "Choice", "Hidden"]: + raise ValidationError( + string_concat("%(location)s: ", + _("form field type '%(field_type)s' not recognized")) + % {'location': location, 'field_type': field_desc.type}) + + from course.constants import FORM_FIELD_ID_REGEX + + match = re.match("^" + FORM_FIELD_ID_REGEX + "$", field_desc.id) + if match is None: + raise ValidationError( + string_concat("%s: ", + _("invalid form field id. " + "Form field id may only contain (roman) " + "letters, numbers, " + "dashes and underscores.")) + % location) + + if field_desc.type != "Choice": + required_types = { + "Integer": int, + "Float": float, + } + value_types = required_types.get(field_desc.type, + (str, int, float, bool)) + validate_struct( + vctx, + location, + field_desc, + required_attrs=[ + ("id", str), + ("type", str), + ("value", value_types), + ], + allowed_attrs=[ + ("label", str), + ], + ) + else: + validate_struct( + vctx, + location, + field_desc, + required_attrs=[ + ("id", str), + ("type", str), + ("choices", list), + ], + allowed_attrs=[ + ("label", str), + ], + ) + found_default = 0 + for choice in field_desc.choices: + if str(choice).startswith("~DEFAULT~"): + found_default += 1 + if found_default == 0: + raise ValidationError( + string_concat("%(location)s: ", + _("form field '%(id)s' of type '%(field_type)s' requires" + " a default value.")) + % {'location': location, 'field_type': field_desc.type, + 'id': field_desc.id}) + if found_default > 1: + raise ValidationError( + string_concat("%(location)s: ", + _("form field '%(id)s' of type '%(field_type)s' requires" + " only one default value.")) + % {'location': location, 'field_type': field_desc.type, + 'id': field_desc.id}) + +# }}} + # {{{ calendar validation @@ -1424,6 +1568,21 @@ def validate_flow_id(vctx, location, flow_id): % location) +def validate_form_id(vctx, location, form_id): + # type: (ValidationContext, Text, Text) -> None + + from course.constants import FORM_ID_REGEX + match = re.match("^" + FORM_ID_REGEX + "$", form_id) + if match is None: + raise ValidationError( + string_concat("%s: ", + _("invalid form name. " + "Form names may only contain (roman) " + "letters, numbers, " + "dashes and underscores.")) + % location) + + def validate_static_page_name(vctx, location, page_name): # type: (ValidationContext, Text, Text) -> None @@ -1591,6 +1750,31 @@ def validate_course_content(repo, course_file, events_file, # }}} + # {{{ forms + + try: + forms_tree = get_repo_blob(repo, "forms", validate_sha) + except ObjectDoesNotExist: + # That's OK--no forms yet. + pass + else: + for entry in forms_tree.items(): + entry_path = entry.path.decode("utf-8") + if not entry_path.endswith(".yml"): + continue + + tmpl_id = entry_path[:-4] + location = entry_path + validate_form_id(vctx, location, tmpl_id) + + location = "forms/%s" % entry_path + form_desc = get_yaml_from_repo_safely(repo, location, + commit_sha=validate_sha) + + validate_form_desc(vctx, location, form_desc) + + # }}} + return vctx.warnings diff --git a/doc/flow.rst b/doc/flow.rst index fa26f0e0a..02fefecb3 100644 --- a/doc/flow.rst +++ b/doc/flow.rst @@ -789,6 +789,162 @@ Life cycle .. autoclass:: flow_session_expiration_mode + +Templated Flows and Forms +------------------------- + +Forms provide a web interface for creating flows from a templated flow. +A templated flow is a flow with an extension `.jinja` that has one or more +undefined jinja variables used in it. These undefined jinja variables +are filled using a form submitted by an admin user. When an admin user fills +the fields in the web form, values from the fields are substituted into the +undefined jinja variables in the templated flow and a new flow is created. +This is useful for creating flows without access to the git repository and +also provides a way to create a flow in the during a lecture. + +For example, an instructor wants to create a multiple choice question in the +middle of the lecture and have students submit an answer within 10 minutes of +the announcement similar to an i-clicker. Then, a templated flow looks like +the following:: + + title: "{{ title }}" + + description: | + # RELATE Instant Flow + + rules: + start: + - + if_before: end_of_class + if_has_role: [student, ta, instructor] + if_has_fewer_sessions_than: 1 + may_start_new_session: True + may_list_existing_sessions: True + + - + may_start_new_session: False + may_list_existing_sessions: True + + access: + - + permissions: [view, submit_answer, end_session, see_correctness, see_answer_after_submission] + + grade_identifier: instant_quiz_{{ id }} + grade_aggregation_strategy: use_latest + + grading: + - + if_completed_before: "{{ created_time }} + {{ duration }} minutes" + credit_percent: 100 + + - + credit_percent: 0 + + pages: + - + type: ChoiceQuestion + id: instant_{{ id }} + title: {{ title }} + shuffle: True + prompt: | + + {{ description }} + + choices: + - ~CORRECT~ {{ choice1 }} + - {{ choice2 }} + - {{ choice3 }} + + +The jinja variables `title, description, choice1, choice2, choice3, duration, +created_time, id` are undefined and needs to be created by the form. +REALTE will fill in `created_time` and `id` and the others need to be fields +in the form. A form must have the fields `template_in`, `template_out` +which correspond to the name of the templted flow and actual flow respectively. +`template_out` will get the timestamp appended to make the file name unique. +A special field `announce` will create an Instant Flow request which will make +the flow visible to all visitors to the course page. + +A form description file will create a form in the web interface for an admin +user and upon submitting a new flow will be created. +The form in the web interface can have several types of field. +Field types are one of `Text, Integer, Float, Choice, Hidden`. Hidden +fields are not shown to the admin user, but their default values are +substituted in the templated flow. An example form description is as follows:: + + title: "Create an instant flow with one multiple choice question" + + description: | + Instructions on filling out this form + type: flow + + access_roles: [ta, instructor] + + fields: + - id: title + type: Text + value: "InClass quiz" + label: "Title" + + - id: description + type: Text + value: "" + label: "Question" + + - id: duration + type: Integer + value: 20 + label: "Duration in minutes for the flow" + + - id: choice1 + type: Text + value: "(a)" + label: "Correct choice" + + - id: choice2 + type: Text + value: "(b)" + label: "An Incorrect choice" + + - id: choice3 + type: Text + value: "(c)" + label: "Another incorrect choice" + + - id: template_in + type: Hidden + value: "flows/instant_flow.jinja" + + - id: template_out + type: Hidden + value: "flows/instant_flow.yml" + + - id: announce + type: Choice + choices: + - ~DEFAULT~ True + - False + label: "Announce to the class" + + +When the admin user submits the form, a flow will be created using the +values the admin user filled in the web form like the following:: + + {% with id="20190930_022148_311577", + title="InClass quiz", + description="What's 1 + 1?", + duration="20", + choice1="~CORRECT~ 2", + choice2="1", + choice3="3", + template_in="flows/instant_flow.jinja", + template_out="flows/instant_flow.yml", + announce="True", + created_time="2019-09-30 @ 02:21" %} + {% include "flows/instant_flow.jinja" %} + {% endwith %} + + Sample Rule Sets ---------------- diff --git a/relate/urls.py b/relate/urls.py index 4b63ecdda..14859e4a9 100644 --- a/relate/urls.py +++ b/relate/urls.py @@ -27,7 +27,8 @@ from django.conf.urls import include, url from django.contrib import admin from django.conf import settings -from course.constants import COURSE_ID_REGEX, FLOW_ID_REGEX, STATICPAGE_PATH_REGEX +from course.constants import (COURSE_ID_REGEX, FLOW_ID_REGEX, STATICPAGE_PATH_REGEX, + FORM_ID_REGEX) import course.auth import course.views @@ -41,6 +42,7 @@ import course.analytics import course.exam import course.api +import course.forms urlpatterns = [ url(r"^login/$", @@ -156,6 +158,19 @@ course.sandbox.view_page_sandbox, name="relate-view_page_sandbox"), + url(r"^course" + "/" + COURSE_ID_REGEX + + "/forms/$", + course.forms.view_all_forms, + name="relate-view_all_forms"), + + url(r"^course" + "/" + COURSE_ID_REGEX + + "/forms/" + FORM_ID_REGEX + + "/$", + course.forms.view_form, + name="relate-view_form"), + url("^purge-pageview-data/$", course.flow.purge_page_view_data, name="relate-purge_page_view_data"), diff --git a/tests/base_test_mixins.py b/tests/base_test_mixins.py index 669125d50..f19cf613d 100644 --- a/tests/base_test_mixins.py +++ b/tests/base_test_mixins.py @@ -823,10 +823,10 @@ def create_course(cls, create_course_kwargs, raise_error=True): assert Course.objects.count() == existing_course_count + 1 @classmethod - def get_course_view_url(cls, view_name, course_identifier=None): + def get_course_view_url(cls, view_name, course_identifier=None, *args): course_identifier = ( course_identifier or cls.get_default_course_identifier()) - return reverse(view_name, args=[course_identifier]) + return reverse(view_name, args=[course_identifier] + list(args)) @classmethod def get_course_calender_url(cls, course_identifier=None): @@ -847,6 +847,19 @@ def get_edit_course_url(cls, course_identifier=None): course_identifier or cls.get_default_course_identifier()) return cls.get_course_view_url("relate-edit_course", course_identifier) + @classmethod + def get_view_all_forms_url(cls, course_identifier=None): + course_identifier = ( + course_identifier or cls.get_default_course_identifier()) + return cls.get_course_view_url("relate-view_all_forms", course_identifier) + + @classmethod + def get_view_form_url(cls, form_id, course_identifier=None): + course_identifier = ( + course_identifier or cls.get_default_course_identifier()) + return cls.get_course_view_url("relate-view_form", course_identifier, + form_id) + @classmethod def post_edit_course(cls, data, course=None): course = course or cls.get_default_course() diff --git a/tests/test_forms.py b/tests/test_forms.py new file mode 100644 index 000000000..a85d51490 --- /dev/null +++ b/tests/test_forms.py @@ -0,0 +1,199 @@ +from __future__ import division + +__copyright__ = "Copyright (C) 2019 Isuru Fernando" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +from django.test import TestCase +import django.forms as forms + +from course.forms import process_form_fields, CreateForm +from course.validation import ValidationError +from course.constants import participation_permission as pperm +from relate.utils import dict_to_struct +from tests import factories +from course.models import ParticipationRolePermission, ParticipationRole + +from tests.base_test_mixins import SingleCourseTestMixin, MockAddMessageMixing + + +class CreateFormTest(TestCase): + + def test_fields_label(self): + fields = [ + dict_to_struct({"id": "template_in", "type": "Text"}), + dict_to_struct({"id": "template_out", "type": "Text", "label": "label"}), + ] + process_form_fields(fields, {}) + self.assertEqual(fields[0].label, "template_in") + self.assertEqual(fields[1].label, "label") + + def test_fields_value(self): + fields = [ + dict_to_struct({"id": "template_in", "type": "Text", "value": "spam"}), + dict_to_struct({"id": "template_out", "type": "Choice", + "choices": ["choice1", "~DEFAULT~ choice2"]}), + ] + process_form_fields(fields, {}) + self.assertEqual(fields[0].value, "spam") + self.assertEqual(fields[1].value, "choice2") + self.assertEqual(fields[1].choices, ["choice1", "choice2"]) + + def test_reset(self): + fields = [ + dict_to_struct({"id": "template_in", "type": "Text", "value": "spam"}), + dict_to_struct({"id": "template_out", "type": "Text", "value": "eggs"}), + ] + process_form_fields(fields, {"reset": True, "template_in": "eggs"}) + self.assertEqual(fields[0].value, "spam") + self.assertEqual(fields[1].value, "eggs") + + def test_fields_assign_data(self): + fields = [ + dict_to_struct({"id": "template_in", "type": "Text", "value": "spam"}), + dict_to_struct({"id": "template_out", "type": "Choice", + "choices": ["choice1", "~DEFAULT~ choice2"]}), + dict_to_struct({"id": "field0", "type": "Integer", "value": 2}), + dict_to_struct({"id": "field1", "type": "Float", "value": 2.5}), + ] + process_form_fields(fields, {"template_in": "eggs", + "template_out": "choice1", + "field0": "1", + "field1": "1.5", + }) + _ = CreateForm(fields) + self.assertEqual(fields[0].value, "eggs") + self.assertEqual(fields[1].value, "choice1") + self.assertEqual(fields[2].value, 1) + self.assertEqual(fields[3].value, 1.5) + + def test_invalid_data(self): + fields = [ + dict_to_struct({"id": "template_in", "type": "Text", "value": "spam"}), + dict_to_struct({"id": "template_out", "type": "Text", "value": "spam"}), + dict_to_struct({"id": "field0", "type": "Integer", "value": 2}), + ] + + expected_error_msg = ( + "form field 'field0' value 'a' is not a 'Integer'.") + with self.assertRaises(ValidationError) as cm: + process_form_fields(fields, {"template_in": "eggs", + "template_out": "choice1", + "field0": "a", + }) + self.assertIn(expected_error_msg, str(cm.exception)) + + def test_create_form(self): + fields = [ + dict_to_struct({"id": "template_in", "type": "Text", "value": "spam"}), + dict_to_struct({"id": "template_out", "type": "Text", + "value": "out.yml"}), + dict_to_struct({"id": "field0", "type": "Integer", "value": 2}), + dict_to_struct({"id": "field1", "type": "Float", "value": 2.5}), + dict_to_struct({"id": "field2", "type": "Choice", + "choices": ["choice1", "~DEFAULT~ choice2"]}), + dict_to_struct({"id": "field3", "type": "Hidden", "value": 2}), + ] + process_form_fields(fields, {}) + form = CreateForm(fields) + for field, ftype in [("field0", forms.IntegerField), + ("field1", forms.FloatField), + ("field2", forms.ChoiceField), + ("template_in", forms.CharField), + ("template_out", forms.CharField)]: + self.assertIn(field, form.fields) + self.assertIsInstance(form.fields[field], ftype) + + self.assertNotIn("field3", form.fields) + # Check that template_out has id appended + self.assertEqual(form.template_out, "out_{}.yml".format(form.id)) + self.assertIn(form.id, form.get_jinja_text()[0]) + + +class FormsBase(SingleCourseTestMixin, MockAddMessageMixing, TestCase): + + initial_commit_sha = "f3e9d31a61714e759a6ea12b900b173accb753f5" + form_title = b"Create an instant flow with one multiple choice question" + + def get_user_with_no_forms(self): + # This user has no form with access, but has access to viewing the + # forms list. + limited_instructor = factories.UserFactory() + limited_instructor_role = factories.ParticipationRoleFactory( + course=self.course, + identifier="limited_instructor" + ) + participation = factories.ParticipationFactory( + course=self.course, + user=limited_instructor) + participation.roles.set([limited_instructor_role]) + ParticipationRolePermission(role=limited_instructor_role, + permission=pperm.use_forms).save() + return limited_instructor + + +class ViewAllFormsTest(FormsBase): + + def test_student_no_form_access(self): + with self.temporarily_switch_to_user(self.student_participation.user): + resp = self.c.get(self.get_view_all_forms_url()) + self.assertEqual(resp.status_code, 403) + + def test_use_forms_permission(self): + with self.temporarily_switch_to_user(self.get_user_with_no_forms()): + resp = self.c.get(self.get_view_all_forms_url()) + self.assertEqual(resp.status_code, 200) + self.assertIn(self.form_title, resp.content) + + +class ViewFormTest(FormsBase): + + def test_student_no_form_access(self): + with self.temporarily_switch_to_user(self.student_participation.user): + resp = self.c.get(self.get_view_form_url(form_id="instant")) + self.assertEqual(resp.status_code, 403) + + def test_user_with_no_forms(self): + with self.temporarily_switch_to_user(self.get_user_with_no_forms()): + resp = self.c.get(self.get_view_form_url(form_id="instant")) + self.assertEqual(resp.status_code, 403) + + def get_instructor_with_perm(self): + role = ParticipationRole.objects.filter( + identifier="instructor", + ).first() + ParticipationRolePermission(role=role, + permission=pperm.use_forms).save() + return self.instructor_participation.user + + def test_instructor_form_access(self): + with self.temporarily_switch_to_user(self.get_instructor_with_perm()): + resp = self.c.get(self.get_view_form_url(form_id="instant")) + self.assertEqual(resp.status_code, 200) + + def test_form_reset(self): + with self.temporarily_switch_to_user(self.get_instructor_with_perm()): + from time import time + new_duration = int(time()) + data = {"reset": "", "duration": new_duration} + resp = self.c.post(self.get_view_form_url(form_id="instant"), data=data) + self.assertEqual(resp.status_code, 200) + self.assertNotIn(str(new_duration), resp.content.decode("utf-8")) diff --git a/tests/test_validation/test_validate_course_content.py b/tests/test_validation/test_validate_course_content.py index 6a561b50e..39f18f87f 100644 --- a/tests/test_validation/test_validate_course_content.py +++ b/tests/test_validation/test_validate_course_content.py @@ -139,6 +139,11 @@ staticpage2_id = "spage2" staticpage2_desc = mock.MagicMock() +form1_path = "forms/form1.yml" +form1_location = "form1.yml" +form1_id = "form1" +form1_desc = mock.MagicMock() + flow1_path = "flows/flow1.yml" flow1_location = "flow1.yml" flow1_id = "flow1" @@ -203,6 +208,9 @@ def get_yaml_from_repo_safely_side_effect(repo, full_name, commit_sha): if full_name == staticpage2_path: return staticpage2_desc + if full_name == form1_path: + return form1_desc + return get_yaml_from_repo_safely(repo, full_name, commit_sha) @@ -223,6 +231,9 @@ def get_yaml_from_repo_safely_with_duplicate_grade_identifier_side_effect( if full_name == staticpage2_path: return staticpage2_desc + if full_name == form1_path: + return form1_desc + return get_yaml_from_repo_safely(repo, full_name, commit_sha) @@ -244,6 +255,11 @@ def get_repo_blob_side_effect(repo, full_name, commit_sha, allow_tree=True): tree.add(staticpage2_location.encode(), stat.S_IFREG, b"a static page") return tree + if full_name == "forms": + tree = Tree() + tree.add(b"not_a_form", stat.S_IFREG, b"not a form") + tree.add(form1_location.encode(), stat.S_IFREG, b"a form") + return tree if full_name == "": return Tree() @@ -267,6 +283,10 @@ def get_repo_blob_side_effect1(repo, full_name, commit_sha, allow_tree=True): tree.add(staticpage2_location.encode(), stat.S_IFREG, b"a static page") return tree + if full_name == "forms": + tree = Tree() + tree.add(b"not_a_form", stat.S_IFREG, b"not a form") + return tree if full_name == "": return Tree() @@ -285,6 +305,10 @@ def get_repo_blob_side_effect2(repo, full_name, commit_sha, allow_tree=True): tree.add(staticpage2_location.encode(), stat.S_IFREG, b"a static page") return tree + if full_name == "forms": + tree = Tree() + tree.add(b"not_a_form", stat.S_IFREG, b"not a form") + return tree if full_name == "": return Tree() @@ -301,6 +325,10 @@ def get_repo_blob_side_effect3(repo, full_name, commit_sha, allow_tree=True): return tree if full_name == "staticpages": raise ObjectDoesNotExist() + if full_name == "forms": + tree = Tree() + tree.add(b"not_a_form", stat.S_IFREG, b"not a form") + return tree if full_name == "": return Tree() @@ -327,6 +355,11 @@ def setUp(self): self.mock_validate_staticpage_desc = fake_validate_staticpage_desc.start() self.addCleanup(fake_validate_staticpage_desc.stop) + fake_validate_form_desc = mock.patch( + "course.validation.validate_form_desc") + self.mock_validate_form_desc = fake_validate_form_desc.start() + self.addCleanup(fake_validate_form_desc.stop) + fake_get_yaml_from_repo = mock.patch( "course.content.get_yaml_from_repo") self.mock_get_yaml_from_repo = fake_get_yaml_from_repo.start() @@ -403,6 +436,10 @@ def test_course_none(self): self.assertSetEqual(expected_validate_staticpage_desc_call_args, args_set) + # make sure validate_form_desc was called with expected args + self.assertEqual(self.mock_validate_form_desc.call_args_list[0][0][1:], + (form1_path, form1_desc)) + # validate_calendar_desc_struct is called self.assertEqual(self.mock_validate_calendar_desc_struct.call_count, 1) diff --git a/tests/test_validation/test_validation_tools.py b/tests/test_validation/test_validation_tools.py index c858b5767..c65b29f75 100644 --- a/tests/test_validation/test_validation_tools.py +++ b/tests/test_validation/test_validation_tools.py @@ -2872,6 +2872,151 @@ def test_fail(self): self.assertIn(expected_error_msg, str(cm.exception)) +class ValidateFormIdTest(ValidationTestMixin, unittest.TestCase): + # test validation.validate_form_id + + def test_success(self): + flow_id = "abc-def" + validation.validate_form_id(vctx, location, flow_id) + flow_id = "abc_def1" + validation.validate_form_id(vctx, location, flow_id) + + def test_fail(self): + expected_error_msg = ( + "invalid form name. Form names may only contain (roman) " + "letters, numbers, dashes and underscores.") + + flow_id = "abc def" + with self.assertRaises(ValidationError) as cm: + validation.validate_form_id(vctx, location, flow_id) + self.assertIn(expected_error_msg, str(cm.exception)) + + flow_id = "abc/def" + with self.assertRaises(ValidationError) as cm: + validation.validate_form_id(vctx, location, flow_id) + self.assertIn(expected_error_msg, str(cm.exception)) + + +class ValidateFormFieldTest(ValidationTestMixin, unittest.TestCase): + # test validation.validate_form_field + + def get_updated_form_field(self, **kwargs): + field_desc = {"id": "my_page_id", + "type": "Text", + "value": "foo"} + field_desc.update(kwargs) + return dict_to_struct(field_desc) + + def test_success(self): + validation.validate_form_field(vctx, location, + self.get_updated_form_field(id="abc")) + + def test_invalid_form_field_id(self): + expected_error_msg = ( + "invalid form field id. Form field id may only contain (roman) " + "letters, numbers, dashes and underscores.") + with self.assertRaises(ValidationError) as cm: + validation.validate_form_field(vctx, location, + self.get_updated_form_field(id="abc def")) + self.assertIn(expected_error_msg, str(cm.exception)) + + def test_invalid_form_field_type(self): + expected_error_msg = ( + "some_where: form field type 'qwe' not recognized") + with self.assertRaises(ValidationError) as cm: + validation.validate_form_field(vctx, location, + self.get_updated_form_field(type="qwe")) + self.assertIn(expected_error_msg, str(cm.exception)) + + def test_invalid_form_field_choice(self): + field_desc = {"id": "my_page_id", + "type": "Choice", + "choices": ["foo"]} + field_desc = dict_to_struct(field_desc) + expected_error_msg = ( + "form field 'my_page_id' of type 'Choice' requires" + " a default value.") + with self.assertRaises(ValidationError) as cm: + validation.validate_form_field(vctx, location, field_desc) + self.assertIn(expected_error_msg, str(cm.exception)) + + field_desc.choices = ["~DEFAULT~ a", "~DEFAULT~ b", "c"] + expected_error_msg = ( + "form field 'my_page_id' of type 'Choice' requires" + " only one default value.") + with self.assertRaises(ValidationError) as cm: + validation.validate_form_field(vctx, location, field_desc) + self.assertIn(expected_error_msg, str(cm.exception)) + + field_desc.choices = ["~DEFAULT~ a", "b"] + validation.validate_form_field(vctx, location, field_desc) + + +class ValidateFormTest(ValidationTestMixin, unittest.TestCase): + # test validation.validate_form_desc + + def setUp(self): + super(ValidateFormTest, self).setUp() + patch = mock.patch("course.validation.validate_role") + self.mock_validate_role = patch.start() + self.addCleanup(patch.stop) + + patch = mock.patch("course.validation.validate_form_field") + self.mock_validate_form_field = patch.start() + self.addCleanup(patch.stop) + + def get_updated_form_desc(self, **kwargs): + form_desc = { + "title": "title", + "description": "description", + "type": "flow", + "access_roles": ["ta", "ta2"], + "fields": [ + dict_to_struct({"id": "template_in", "type": "Text"}), + dict_to_struct({"id": "template_out", "type": "Text"}), + ], + } + form_desc.update(kwargs) + return dict_to_struct(form_desc) + + def test_validate_role_called(self): + validation.validate_form_desc(vctx, location, + self.get_updated_form_desc(access_roles=[])) + self.assertEqual(self.mock_validate_role.call_count, 0) + self.assertEqual(self.mock_validate_form_field.call_count, 2) + + validation.validate_form_desc(vctx, location, + self.get_updated_form_desc()) + self.assertEqual(self.mock_validate_role.call_count, 2) + + def test_field_id_unique(self): + expected_error_msg = ("some_where: form field id 'template_in' not unique") + fields = [ + dict_to_struct({"id": "template_in", "type": "Text"}), + dict_to_struct({"id": "template_in", "type": "Text"}), + dict_to_struct({"id": "template_out", "type": "Text"}), + ] + with self.assertRaises(ValidationError) as cm: + validation.validate_form_desc(vctx, location, + self.get_updated_form_desc(fields=fields)) + self.assertIn(expected_error_msg, str(cm.exception)) + + def test_field_required(self): + fields = [ + dict_to_struct({"id": "template_in", "type": "Text"}), + dict_to_struct({"id": "template_out", "type": "Text"}), + ] + + for field_name in ["template_in", "template_out"]: + expected_error_msg = ( + "some_where: required form field id '%s' not found" % field_name) + test_fields = [field for field in fields if field.id != field_name] + with self.assertRaises(ValidationError) as cm: + validation.validate_form_desc(vctx, location, + self.get_updated_form_desc(fields=test_fields)) + self.assertIn(expected_error_msg, str(cm.exception)) + + class ValidateStaticPageNameTest(ValidationTestMixin, unittest.TestCase): # test validation.validate_static_page_name