ADMINS = [ADMINISTRATOR, CONTENT_DEVELOPER, TEACHING_ASSISTANT, INSTRUCTOR] class Track(CourseObject):
    name = models.CharField(max_length=NAME_CHAR_LIMIT)
    experiment = models.ForeignKey(Experiment, related_name="tracks")
    
    class Meta:
        unique_together = (('experiment', 'name'),)
    
    def set_weighting(self, new_weighting):
        if new_weighting is None:
            new_weighting = 0
        try:
            self.weight.update(weighting=new_weighting)
        except TrackProbabilityWeight.DoesNotExist:
            TrackProbabilityWeight.objects.create(
                    track=self, weighting=new_weighting,
                    experiment=self.experiment,
                    course_id=self.course_id,
        )
    
    def get_weighting(self):
        try:
            return self.weight.weighting
        except TrackProbabilityWeight.DoesNotExist:
            return None class Experiment(CourseObject):
    UNIFORM_RANDOM = 1
    WEIGHTED_PROBABILITY_RANDOM = 2
    CSV_UPLOAD = 3
    REVERSE_API = 4
    
    ASSIGNMENT_ENUM_TYPES = (
        (UNIFORM_RANDOM, "uniform_random"),
        (WEIGHTED_PROBABILITY_RANDOM, "weighted_probability_random"),
        (CSV_UPLOAD, "csv_upload"),
        (REVERSE_API, "reverse_api"),
    )
    
    name = models.CharField(max_length=NAME_CHAR_LIMIT)
    notes = models.TextField()
    tracks_finalized = models.BooleanField(default=False)
    assignment_method = models.IntegerField(max_length=1, default=1,
                                            choices=ASSIGNMENT_ENUM_TYPES,)
    
    class Meta:
        unique_together = (('course_id', 'name'),)
    
    @classmethod
    def get_all_started_csv(cls):
        return cls.objects.filter(tracks_finalized=True,
                                  assignment_method=Experiment.CSV_UPLOAD)
    
    def get_course_notification(self):
        """ The CourseNotification object should exist, at the point at which
            an experiment has been created under the same course, so this method
            will raise an exception if the CourseNotification object doesn't
            exist yet."""
        return CourseNotification.objects.get(course_id=self.course_id)
    
    def assert_not_finalized(self):
        if self.tracks_finalized:
            raise EXPERIMENT_TRACKS_ALREADY_FINALIZED
    
    def new_track(self, track_name):
        return Track.objects.create(course_id=self.course_id, experiment=self,
                                    name=track_name)
    
    def get_incomplete_intervention_point_names(self):
        return [point.name for point in self.intervention_points.all() if point.is_missing_urls()]
    
    def to_json(self):
        """ Converts the experiment and its associated tracks to json,
            in the form expected by the edit_experiment.html template and
            by edit_experiment.js """
        experiment_dict = {
            "id": self.id,
            "name": self.name,
            "notes": self.notes,
            "uniformRandom": bool(self.assignment_method == self.UNIFORM_RANDOM),
            "csvUpload": bool(self.assignment_method == self.CSV_UPLOAD),
            "tracks": [{"id": t.id,
                        "weighting": t.get_weighting(),
                        "name": t.name,
                        # databaseName stores the track name in the database for collision comparison
                        "databaseName": t.name,
                        # newName and editing are used to hold temporary values for track name editing
                        "newName" : t.name,
                        "editing": False,
                        "deleteURL": reverse('ab_testing_tool_delete_track', args=(t.id,))}
                       for t in self.tracks.all()],
        }
        return json.dumps(experiment_dict)
    
    def copy(self, new_name):
        tracks = self.tracks.all()
        intervention_points = self.intervention_points.all()
        # Copy Experiment
        self.save_as_new_object(name=new_name, tracks_finalized=False)
        
        # Copy Tracks
        track_id_mapping = {}
        for track in tracks:
            original_track_id = track.id
            track.save_as_new_object(experiment=self)
            track_id_mapping[original_track_id] = track
            # Copy TrackProbabilityWeight, if any
            try:
                weight = TrackProbabilityWeight.objects.get(track_id=original_track_id)
                weight.save_as_new_object(track=track)
            except TrackProbabilityWeight.DoesNotExist:
                pass
        
        # Copy InterventionPoints
        for intervention_point in intervention_points:
            orig_ip_id = intervention_point.id
            intervention_point.save_as_new_object(experiment=self)
            # Copy InterventionPointUrls, if any
            for ip_url in InterventionPointUrl.objects.filter(intervention_point_id=orig_ip_id):
                ip_url.save_as_new_object(track=track_id_mapping[ip_url.track.id],
                                          intervention_point=intervention_point)
    
    @classmethod
    def get_placeholder_course_experiment(cls, course_id):
        """ Gets or creates a single experiment for the course.  Placeholder
            method until interface supports multiple experiments.
            TODO: Remove once multiple experiments are supported """
        return Experiment.objects.get_or_create(course_id=course_id, name="Experiment 1")[0] class ExperimentStudent(CourseObject):
    """ This model stores which track a student is in for a given experiment
        within a given course.  A real-world student can be represented by
        multiple ExperimentStudent objects, and will have a separate object for
        each experiment they are in. """
    student_id = models.CharField(max_length=128, db_index=True)
    student_name = models.CharField(max_length=256, db_index=True, null=True)
    experiment = models.ForeignKey(Experiment, related_name="students")
    track = models.ForeignKey(Track)
    
    class Meta:
        unique_together = (('experiment', 'student_id'),) def get_lti_param(request, key):
    """
    Note: Not Canvas related, but located here to avoid cyclic imports
    TODO: Add this function to Django auth lti library
    """
    if "LTI_LAUNCH" not in request.session:
        raise MISSING_LTI_LAUNCH
    if key not in request.session["LTI_LAUNCH"]:
        raise MISSING_LTI_PARAM
    return request.session["LTI_LAUNCH"][key] class CanvasModules(object):
    def __init__(self, request):
        self.request_context = get_canvas_request_context(request)
        self.course_id = get_lti_param(request, "custom_canvas_course_id")
        # Only pass this attribute to intervention_point_url
        self.request = request
        # self.modules is a list of module dicts
        self.modules = list_modules(self.request_context, self.course_id)
        for module in self.modules:
            module["module_items"] = list_module_items(
                    self.request_context, self.course_id, module["id"])
    
    def get_uninstalled_intervention_points(self):
        """ Returns the list of InterventionPoint objects that have been created for the
            current course but not installed in any of that course's modules """
        installed_intervention_point_urls = self._get_installed_intervention_point_urls()
        intervention_points = [intervention_point for intervention_point in
                               InterventionPoint.objects.filter(course_id=self.course_id)
                               if intervention_point_url(self.request, intervention_point.id)
                               not in installed_intervention_point_urls]
        return intervention_points
    
    def get_deletable_experiment_ids(self):
        """ Returns list of experiment ids for experiments that can be deleted
            (they have tracks_finalzed == False and no installed intervention
            points """
        return [e.id for e in Experiment.objects.filter(course_id=self.course_id)
                if not e.tracks_finalized
                and not self.experiment_has_installed_intervention(e)]
    
    def intervention_point_is_installed(self, intervention_point):
        return (intervention_point_url(self.request, intervention_point.id) in
                self._get_installed_intervention_point_urls())
    
    def experiment_has_installed_intervention(self, experiment):
        """ Checks to see if a experiment has any intervention points installed.
            For Python2.6+, the built-in isdisjoint provides the fastest return"""
        return not set([intervention_point_url(self.request, ip.id) for ip in
               experiment.intervention_points.all()]).isdisjoint(set(self._get_installed_intervention_point_urls()))
    
    def get_modules_with_items(self):
        """ Returns a list of all modules with the items of that module added to the
            module under the extra attribute "module_items" (this is a list).
            Each item in those lists has the added attribute "is_intervention_point",
            which is a boolean describing whether or not the item is a intervention_point
            of the ab_testing_tool """
        intervention_point_urls = self._all_intervention_point_urls()
        for module in self.modules:
            for item in module["module_items"]:
                is_intervention_point = (item["type"] == "ExternalTool" and "external_url" in item
                            and item["external_url"] in intervention_point_urls)
                item["is_intervention_point"] = is_intervention_point
                if is_intervention_point:
                    item["database_name"] = intervention_point_urls[item["external_url"]].name
                    item["experiment_name"] = intervention_point_urls[item["external_url"]].experiment.name
        return self.modules
    
    def _all_intervention_point_urls(self):
        """ Returns a dict of deploy urls to intervention points of all intervention_points
            in the database for that course"""
        return {intervention_point_url(self.request, intervention_point.id): intervention_point
                for intervention_point in InterventionPoint.objects.filter(course_id=self.course_id)}
    
    def _get_installed_intervention_point_urls(self):
        """ Returns the list of InterventionPointUrls (as strings) for InterventionPoints
            that have been installed in at least one of the courses modules. """
        installed_intervention_point_urls = []
        for module in self.modules:
            intervention_point_urls = [item["external_url"] for item in module["module_items"]
                          if item["type"] == "ExternalTool"]
            installed_intervention_point_urls.extend(intervention_point_urls)
        return installed_intervention_point_urls def get_unassigned_students(request, experiment):
    """ Gets the list of students that have not been assigned a track.
        This version of the function is for requests that have come through
        a web view and consequently have a django request object off of which
        to make a canvas request context """
    request_context = get_canvas_request_context(request)
    return get_unassigned_students_with_context(request_context, experiment) NO_TRACKS_FOR_EXPERIMENT = Renderable403(STUDENT_VISIBLE_ERROR) INTERVENTION_POINTS_ARE_INSTALLED = Renderable400("You can't edit or delete the experiment if there are intervention points installed for the experiment.") FILE_TOO_LARGE = Renderable403("Files over %sMB are not allowed for upload" %
                               (int(settings.MAX_FILE_UPLOAD_SIZE) / 1024 / 1024)) COPIES_EXCEEDS_LIMIT = Renderable400("There exists too many copies of the experiment you are trying to copy. Please delete some before copying.") def get_missing_track_weights(experiment, course_id):
    if experiment.assignment_method != Experiment.WEIGHTED_PROBABILITY_RANDOM:
        return []
    missing_weights = []
    track_weights = [t.track for t in
                     TrackProbabilityWeight.objects.filter(course_id=course_id, experiment=experiment)]
    for track in experiment.tracks.all():
        if track not in track_weights:
            missing_weights.append(track)
    return [track.name for track in missing_weights] def get_incomplete_intervention_points(intervention_point_list):
    """ Takes paramter intervention_point_list instead of parameter course_id to avoid
        second database call to the InterventionPoint table in methods that needs to
        already fetch the InterventionPoint table"""
    return [intervention_point.name for intervention_point in intervention_point_list
            if intervention_point.is_missing_urls()] def validate_weighting(weighting):
    """ Track weights need to be an integer between 1 and 100 """
    weighting = int(weighting)
    if not 0 <= weighting <=100:
        raise INCORRECT_WEIGHTING_PARAM
    return weighting def validate_name(name):
    if not name:
        raise MISSING_NAME_PARAM
    if len(name) > NAME_CHAR_LIMIT:
        raise PARAM_LENGTH_EXCEEDS_LIMIT
    return name def get_track_selection_xlsx(request, experiment, file_title="test.xlsx"):
    output = StringIO.StringIO()
    workbook = xlsxwriter.Workbook(output, {"in_memory": True})
    worksheet = workbook.add_worksheet()
    worksheet.set_column(0, 3, 20)
    headers = ["Student Name", "Student ID", "Experiment", "Assigned Track"]
    worksheet.write_row(0, 0, headers)
    students = get_unassigned_students(request, experiment)
    # Row offsets of +1 below are to account for header
    for i, student_id in enumerate(students):
        row = [students[student_id], student_id, experiment.name, ""]
        worksheet.write_row(i + 1, 0, row)
    # Add drop-down with track names as the only valid options for the missing column
    track_names = [t.name for t in experiment.tracks.all()]
    worksheet.data_validation(1, 3, len(students) + 1, 3,
                              {'validate': 'list', 'source': track_names,})
    workbook.close()
    output.seek(0)
    content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
    response = HttpResponse(output.read(), content_type=content_type)
    response['Content-Disposition'] = ("attachment; filename=%s" % file_title)
    return response def get_track_selection_csv(request, experiment, file_title="test.xlsx"):
    def row_generator():
        yield ["Student Name", "Student ID", "Experiment", "Assigned Track"]
        # Write data to CSV file
        for student_id, student_name in get_unassigned_students(request, experiment).items():
            yield [student_name, student_id, experiment.name, ""]
    return streamed_csv_response(row_generator(), file_title) def parse_uploaded_file(experiment, unassigned_students, input_spreadsheet, filename):
    """ This function parses an uploaded spreadsheet of student track assignments.
        It returns a dictionary `students`, mapping sis_ids to track
        names as well as a list of errors (in string form) encountered while
        parsing the spreadsheet.  If errors is empty, no errors were encountered. """
    students = {}
    errors = []
    track_names = {track.name: track for track in experiment.tracks.all()}
    if filename.endswith('.csv'):
        reader = csv.reader(input_spreadsheet.splitlines())
        reader.next()  # Skip the header
        for row_number, row in enumerate(reader):
            # Row number is off by 2 because we trimmed the headers and
            # CSV files are 1-indexed while enumerate is 0-indexed
            parse_row(row, row_number + 2, experiment, track_names,
                      unassigned_students, students, errors)
    elif filename.endswith('.xlsx') or filename.endswith('.xls'):
        book = xlrd.open_workbook(file_contents=input_spreadsheet)
        sheet = book.sheet_by_index(0)
        # Start at 1 to skip headers
        for row_number in range(1, sheet.nrows):
            row = sheet.row_values(row_number)
            parse_row(row, row_number, experiment, track_names,
                      unassigned_students, students, errors)
    else:
        raise INVALID_FILE_TYPE
    return students, errors
import json
from django.shortcuts import render_to_response, redirect, render
from django.views.decorators.http import require_POST
from django_auth_lti.decorators import lti_role_required
from django.core.urlresolvers import reverse
from django.conf import settings
from ab_tool.constants import ADMINS
from ab_tool.models import (Track, Experiment, ExperimentStudent)
from ab_tool.canvas import get_lti_param, CanvasModules, get_unassigned_students
from ab_tool.exceptions import (NO_TRACKS_FOR_EXPERIMENT,
    INTERVENTION_POINTS_ARE_INSTALLED, FILE_TOO_LARGE, COPIES_EXCEEDS_LIMIT)
from django.http.response import HttpResponse, Http404
from ab_tool.controllers import (get_missing_track_weights,
    get_incomplete_intervention_points, validate_weighting, validate_name)
from ab_tool.spreadsheets import (get_track_selection_xlsx, get_track_selection_csv,
    parse_uploaded_file)
    notes = experiment_dict["notes"]
    if experiment.tracks_finalized:
        # Only allow updating name, notes, and track names for started experiments
        experiment.update(name=name, notes=notes)
        existing_tracks = [i for i in experiment_dict["tracks"] if i["id"] is not None]
        for track_dict in existing_tracks:
            track = Track.get_or_404_check_course(track_dict["id"], course_id)
            track.update(name=validate_name(track_dict["name"]))
        return HttpResponse("success")
    
    uniform_random = experiment_dict["uniformRandom"]
    csv_upload = experiment_dict["csvUpload"]
    existing_tracks = [i for i in experiment_dict["tracks"] if i["id"] is not None]
    new_tracks = [i for i in experiment_dict["tracks"] if i["id"] is None]
    if csv_upload:
        assignment_method = Experiment.CSV_UPLOAD
    elif uniform_random:
        assignment_method = Experiment.UNIFORM_RANDOM
    else:
        assignment_method = Experiment.WEIGHTED_PROBABILITY_RANDOM
    experiment.update(name=name, notes=notes, assignment_method=assignment_method)
    
    # Update existing tracks
    for track_dict in existing_tracks:
        track = Track.get_or_404_check_course(track_dict["id"], course_id)
        track.update(name=validate_name(track_dict["name"]))
        if not uniform_random and not csv_upload:
            track.set_weighting(validate_weighting(track_dict["weighting"]))
    
    # Create new tracks
    for track_dict in new_tracks:
        track = experiment.new_track(validate_name(track_dict["name"]))
        if not uniform_random and not csv_upload:
            track.set_weighting(validate_weighting(track_dict["weighting"]))
    return HttpResponse("success")


@require_POST
@lti_role_required(ADMINS)
def copy_experiment(request, experiment_id):
    course_id = get_lti_param(request, "custom_canvas_course_id")
    experiment = Experiment.get_or_404_check_course(experiment_id, course_id)
    experiments_with_prefix = set([e.name for e in Experiment.objects.filter(
            course_id=course_id, name__startswith=experiment.name)])
    for i in range(1,1000):
        new_name = "%s_copy%s" % (experiment.name, i)
        if new_name not in experiments_with_prefix:
            experiment.copy(new_name)
            return redirect(reverse("ab_testing_tool_index"))
    raise COPIES_EXCEEDS_LIMIT


@require_POST
@lti_role_required(ADMINS)
def delete_experiment(request, experiment_id):
    """ If Http404 is raised, delete_experiment redirects regardless. This is by
        design, as multiple users may be deleting concurrently in the same course """
    course_id = get_lti_param(request, "custom_canvas_course_id")
    try:
        experiment = Experiment.get_or_404_check_course(experiment_id, course_id)
        experiment.assert_not_finalized()
        canvas_modules = CanvasModules(request)
        if canvas_modules.experiment_has_installed_intervention(experiment):
            raise INTERVENTION_POINTS_ARE_INSTALLED
        experiment.delete()
    except Http404:
        pass
    return redirect(reverse("ab_testing_tool_index"))


@require_POST
@lti_role_required(ADMINS)
def finalize_tracks(request, experiment_id):
    course_id = get_lti_param(request, "custom_canvas_course_id")
    experiment = Experiment.get_or_404_check_course(experiment_id, course_id)
    if not experiment.tracks.count():
        raise NO_TRACKS_FOR_EXPERIMENT
    intervention_points = experiment.intervention_points.all()
    incomplete_intervention_points = get_incomplete_intervention_points(intervention_points)
    if incomplete_intervention_points:
        return HttpResponse("URLs missing for these tracks in these Intervention Points: %s"
                            % ", ".join(incomplete_intervention_points))
    missing_track_weights = get_missing_track_weights(experiment, course_id)
    if missing_track_weights:
        return HttpResponse("Track weightings missing for these tracks: %s"
                            % ", ".join(missing_track_weights))
    experiment.tracks_finalized = True
    experiment.save()
    return redirect(reverse("ab_testing_tool_index"))


@lti_role_required(ADMINS)
def track_selection_xlsx(request, experiment_id):
    course_id = get_lti_param(request, "custom_canvas_course_id")
    experiment = Experiment.get_or_404_check_course(experiment_id, course_id)
    return get_track_selection_xlsx(request, experiment, "track_selection.xlsx")

@lti_role_required(ADMINS)
def track_selection_csv(request, experiment_id):
    course_id = get_lti_param(request, "custom_canvas_course_id")
    experiment = Experiment.get_or_404_check_course(experiment_id, course_id)
    return get_track_selection_csv(request, experiment, "track_selection.csv")

@lti_role_required(ADMINS)
def upload_track_assignments(request, experiment_id):
    course_id = get_lti_param(request, "custom_canvas_course_id")
    experiment = Experiment.get_or_404_check_course(experiment_id, course_id)
    uploaded_file = request.FILES["track_assignments"]
    if (uploaded_file.size > int(settings.MAX_FILE_UPLOAD_SIZE)):
        raise FILE_TOO_LARGE
    uploaded_text = uploaded_file.read()
    unassigned_students = get_unassigned_students(request, experiment)
    students, errors = parse_uploaded_file(
            experiment, unassigned_students, uploaded_text, uploaded_file.name
    )
    if errors:
        return render_to_response("ab_tool/spreadsheetErrors.html", {"errors": errors})
    
    for student_id, track in students.items():
        # lis_person_sourcedid is not returned by SDK, so we set it to None

        ExperimentStudent.objects.create(