ADMINS = [ADMINISTRATOR, CONTENT_DEVELOPER, TEACHING_ASSISTANT, INSTRUCTOR] INTERVENTION_POINT_URL_TAG = 'intervention_point_url_' DEPLOY_OPTION_TAG = 'is_canvas_page_for_track_' class InterventionPoint(CourseObject):
    """ This model stores the configuration of an intervention point"""
    name = models.CharField(max_length=NAME_CHAR_LIMIT)
    notes = models.TextField()
    experiment = models.ForeignKey(Experiment, related_name="intervention_points")
    tracks = models.ManyToManyField(Track, through="InterventionPointUrl")
    
    def is_missing_urls(self):
        if (Track.objects.filter(course_id=self.course_id, experiment=self.experiment).count()
            != self.tracks.count()):
            return True
        for intervention_point_url in InterventionPointUrl.objects.filter(intervention_point=self):
            if not intervention_point_url.url:
                return True
        return False
    
    class Meta:
        unique_together = (('experiment', 'name'),)
    
    def track_urls(self):
        """ This gets the InterventionPointUrls for an InterventionPoint in the
            default databse order of Tracks and uses a mock InterventionPointUrl
            with only the track attribute set in the event an InterventionPointUrl
            is missing for that track """
        ip_urls = self.interventionpointurl_set.all()
        track_urls = []
        for track in self.experiment.tracks.all():
            for ip_url in ip_urls:
                if ip_url.track == track:
                    track_urls.append(ip_url)
                    break
            else:
                track_urls.append(InterventionPointUrl(track=track))
        return track_urls 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 InterventionPointUrl(TimestampedModel):
    """ This model stores the URL of a single intervention """
    url = models.URLField(max_length=URL_CHAR_LIMIT)
    track = models.ForeignKey(Track)
    intervention_point = models.ForeignKey(InterventionPoint)
    open_as_tab = models.BooleanField(default=False)
    is_canvas_page = models.BooleanField(default=False)
    
    class Meta:
        unique_together = (('track', 'intervention_point'),) 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'),) 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] 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 validate_format_url(url):
    validator = URLValidator()
    """ Adds "http://" to the beginning of a url if it isn't there """
    url = url.strip()
    if not url.startswith("http://") and not url.startswith("https://"):
        url = "http://%s" % url
    try:
        #the validator enforces standard URL length requirement
        validator(url)
        return url
    except ValidationError:
        raise INVALID_URL_PARAM def post_param(request, param_name):
    if param_name in request.POST:
        return request.POST[param_name]
    raise missing_param_error(param_name) def assign_track_and_create_student(experiment, student_id, student_name):
    """ Method looks up assignment_mode to select track and creates student in selected track.
        Method returns student object """
    if experiment.assignment_method == Experiment.CSV_UPLOAD:
        # Raise error as student should have already been assigned a track if CSV upload
        #TODO: infrastructure needed notify course administrator about incomplete student-track mapping
        raise CSV_UPLOAD_NEEDED
    tracks = experiment.tracks.all()
    if not tracks:
        raise NO_TRACKS_FOR_EXPERIMENT
    if experiment.assignment_method == Experiment.UNIFORM_RANDOM:
        # If uniform, pick randomly from the set of tracks
        chosen_track = choice(tracks)
    if experiment.assignment_method == Experiment.WEIGHTED_PROBABILITY_RANDOM:
        # If weighted, generate a weighted list of tracks and pick one randomly from list
        weighted_tracks = []
        for track in tracks:
            try:
                track_weight = TrackProbabilityWeight.objects.get(track=track, experiment=experiment)
            except TrackProbabilityWeight.DoesNotExist:
                raise TRACK_WEIGHTS_NOT_SET
            weighted_tracks.extend([track] * track_weight.weighting)
        chosen_track = choice(weighted_tracks)
    # Create student with chosen track
    student = ExperimentStudent.objects.create(
            student_id=student_id, course_id=experiment.course_id,
            track=chosen_track, student_name=student_name,
            experiment=experiment
    )
    return student def validate_name(name):
    if not name:
        raise MISSING_NAME_PARAM
    if len(name) > NAME_CHAR_LIMIT:
        raise PARAM_LENGTH_EXCEEDS_LIMIT
    return name DELETING_INSTALLED_INTERVENTION_POINT = Renderable400("Deleting an installed Intervention Point is not allowed.") EXPERIMENT_TRACKS_NOT_FINALIZED = Renderable403(STUDENT_VISIBLE_ERROR) NO_URL_FOR_TRACK = Renderable403(STUDENT_VISIBLE_ERROR) UNIQUE_NAME_ERROR = Renderable400("Another intervention point with this name was found in the database. Names must be unique. Ensure you are using a different name and please try again.") EXPERIMENT_TRACKS_ALREADY_FINALIZED = Renderable400("You can't edit or delete the experiment after it has been started.") DELETING_INTERVENTION_POINT_AFTER_FINALIZED = Renderable400("Deleting an Intervention Point is not allowed after its experiment has been started.") def log_intervention_point_interaction(course_id, student, intervention_point,
                                      experiment, track, intervention_point_url):
    InterventionPointInteraction.objects.create(
            course_id=course_id, student=student,
            intervention_point=intervention_point,
            experiment=experiment,
            track=track,
            url=intervention_point_url.url
    )
from django.shortcuts import render_to_response, redirect, render
from django.core.urlresolvers import reverse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django_auth_lti.decorators import lti_role_required
from ab_tool.constants import (ADMINS, INTERVENTION_POINT_URL_TAG,
    DEPLOY_OPTION_TAG)
from ab_tool.models import (InterventionPoint, Track, InterventionPointUrl,
     ExperimentStudent, Experiment)
from ab_tool.canvas import get_lti_param, CanvasModules
from ab_tool.controllers import (validate_format_url, post_param, assign_track_and_create_student,
    validate_name)
from ab_tool.exceptions import (DELETING_INSTALLED_INTERVENTION_POINT,
    EXPERIMENT_TRACKS_NOT_FINALIZED, NO_URL_FOR_TRACK, UNIQUE_NAME_ERROR,
    EXPERIMENT_TRACKS_ALREADY_FINALIZED, DELETING_INTERVENTION_POINT_AFTER_FINALIZED)
from ab_tool.analytics import log_intervention_point_interaction
from django.http.response import Http404
    try:
        student = ExperimentStudent.objects.get(student_id=student_id, experiment=experiment)
    except ExperimentStudent.DoesNotExist:
        # If this is a new student or the student doesn't yet have a track,
        # select a track before creating the student. This avoids a race condition of
        # a student existing but not having a track assigned (e.g. if the update to
        # a student database object fails)
        student_name = get_lti_param(request, "lis_person_name_full")
        student = assign_track_and_create_student(experiment, student_id, student_name)
    
    # Retrieve the url for the student's track at the current intervention point
    # Return an error page if there is no url configured.
    try:
        chosen_intervention_point_url = InterventionPointUrl.objects.get(intervention_point__pk=intervention_point_id, track=student.track)
    except InterventionPointUrl.DoesNotExist:
        raise NO_URL_FOR_TRACK
    if not chosen_intervention_point_url.url:
        raise NO_URL_FOR_TRACK
    
    log_intervention_point_interaction(course_id, student, intervention_point, experiment,
                                       student.track, chosen_intervention_point_url)
    
    if chosen_intervention_point_url.open_as_tab:
        return render_to_response("ab_tool/new_tab_redirect.html", {"url": chosen_intervention_point_url.url})
    if chosen_intervention_point_url.is_canvas_page:
        return render_to_response("ab_tool/window_redirect.html", {"url": chosen_intervention_point_url.url})
    return redirect(chosen_intervention_point_url.url)


@lti_role_required(ADMINS)
def submit_create_intervention_point(request, experiment_id):
    """ Note: request will always be POST because Canvas fetches pages within iframe by POST
        TODO: use Django forms library to save instead of getting individual POST params """
    course_id = get_lti_param(request, "custom_canvas_course_id")
    name = validate_name(post_param(request, "name"))
    notes = post_param(request, "notes")
    # validate_format_url validates URLs using backend rules before InterventionPointUrl object creation
    intervention_pointurls = [(k,validate_format_url(v)) for (k,v) in request.POST.iteritems()
                              if INTERVENTION_POINT_URL_TAG in k and v]
    experiment = Experiment.get_or_404_check_course(experiment_id, course_id)
    if InterventionPoint.objects.filter(name=name,
        course_id=course_id, experiment=experiment).count() > 0:
        raise UNIQUE_NAME_ERROR
    intervention_point = InterventionPoint.objects.create(
            name=name, notes=notes, course_id=course_id, experiment=experiment)
    for (k,v) in intervention_pointurls:
        _, track_id = k.split(INTERVENTION_POINT_URL_TAG)
        deploy_option = post_param(request, DEPLOY_OPTION_TAG + track_id)
        is_canvas_page = bool(deploy_option == "canvasPage")
        open_as_tab = bool(deploy_option == "newTab")
        InterventionPointUrl.objects.create(
                url=v, intervention_point_id=intervention_point.id,
                track_id=track_id, is_canvas_page=is_canvas_page, open_as_tab=open_as_tab
        )
    return redirect(reverse("ab_testing_tool_index"))


@lti_role_required(ADMINS)
def modules_page_view_intervention_point(request, intervention_point_id):
    context = intervention_point_context(request, intervention_point_id)
    return render_to_response("ab_tool/view_intervention_point_from_canvas.html", context)


@lti_role_required(ADMINS)
def modules_page_edit_intervention_point(request, intervention_point_id):
    context = intervention_point_context(request, intervention_point_id)
    return render(request, "ab_tool/edit_intervention_point_from_canvas.html", context)


def intervention_point_context(request, intervention_point_id):
    """ Common core shared between edit_intervention_point and modules_page_edit_intervention_point """
    course_id = get_lti_param(request, "custom_canvas_course_id")
    canvas_modules = CanvasModules(request)
    intervention_point = InterventionPoint.get_or_404_check_course(
            intervention_point_id, course_id)
    all_tracks = Track.objects.filter(course_id=course_id,
                                      experiment=intervention_point.experiment)
    track_urls = []
    for track in all_tracks:
        # This is a search for the joint unique index of InterventionPointUrl, so it
        # should not ever return multiple objects
        try:
            intervention_point_url = InterventionPointUrl.objects.get(intervention_point__pk=intervention_point_id, track=track)
            track_urls.append((track, intervention_point_url))
        except InterventionPointUrl.DoesNotExist:
            track_urls.append((track, None))
    context = {"intervention_point": intervention_point,
               "tracks": track_urls,
               "is_installed": canvas_modules.intervention_point_is_installed(intervention_point),
               #TODO: "installed_module": installed_module,
               }
    return context


@csrf_exempt
@lti_role_required(ADMINS)
def submit_edit_intervention_point(request, intervention_point_id):
    edit_intervention_point_common(request, intervention_point_id)
    return redirect(reverse("ab_testing_tool_index"))

@lti_role_required(ADMINS)
def submit_edit_intervention_point_from_modules(request, intervention_point_id):
    edit_intervention_point_common(request, intervention_point_id)
    return redirect(reverse("ab_testing_tool_modules_page_view_intervention_point",
                            args=(intervention_point_id,)))

def edit_intervention_point_common(request, intervention_point_id):
    """ Note: Only allowed if admin has privileges on the particular course.
        TODO: consider using Django forms to save rather of getting individual POST params """
    course_id = get_lti_param(request, "custom_canvas_course_id")
    intervention_point = InterventionPoint.get_or_404_check_course(
            intervention_point_id, course_id)
    new_name = validate_name(post_param(request, "name"))
    notes = post_param(request, "notes")
    # Checks to see if there exits another intervention point with the same name
    if new_name != intervention_point.name and InterventionPoint.objects.filter(name=new_name,
        course_id=course_id, experiment=intervention_point.experiment).count() > 0:
        raise UNIQUE_NAME_ERROR
    intervention_point.update(name=new_name, notes=notes)
    # Validates URLs using backend rules before any InterventionPointUrl object creation

    intervention_pointurls = [(k,validate_format_url(v)) for (k,v) in request.POST.iteritems() if INTERVENTION_POINT_URL_TAG in k and v]