diff --git a/docs/source/contents.rst b/docs/source/contents.rst index 70c5359..091d6f1 100644 --- a/docs/source/contents.rst +++ b/docs/source/contents.rst @@ -5,6 +5,7 @@ Contents :maxdepth: 1 getting_started + understanding_quizapp understanding_models import_export mturk diff --git a/docs/source/understanding_quizapp.rst b/docs/source/understanding_quizapp.rst new file mode 100644 index 0000000..ee49b10 --- /dev/null +++ b/docs/source/understanding_quizapp.rst @@ -0,0 +1,23 @@ +.. _understanding_quizapp: + +################################### +Understanding the QuizApp structure +################################### + +There are 3 principle parts of the quizApp system: the experiments, activities, and datasets. The experiment components handle tasks related to rendering, running, and updating experiments - this includes showing assignments to participants, saving their answers, generating experiment reports, and more. Activities is concerned primarily with rendering and managing activities. Datasets is primarily concerned with managing datasets as well as rendering and managing activities. Most components of quizApp are divided along these broad lines. + +As far as application logic goes, quizApp follows a fairly typical MVC design pattern. The ``quizApp/`` directory is organized like so: + +- ``forms``: Contains logic used for rendering and validating forms +- ``static``: Contains static files, like graphs, css, and js +- ``templates``: Contains template files, which specify what is displayed to the users and in what format +- ``views``: Contains view logic that interfaces with the models and sends data to templates for rendering +- ``models.py``: Database models that specify how information is stored in the database and take care of validation. More information about models is available in :ref:`understanding_models`. +- ``filters.py``: Various jinja filters that are used for formatting and rendering purposes +- ``__init__.py``: File that handles setup and initialization of the application + +************ +QuizApp Flow +************ + +In general, each view file registers a number of URLs that are handled by some function in the view file. When a user makes a request to a certain endpoint, the view function first checks authentication, then validates the URL (if applicable, e.g. checking to make sure the requested experiment exists), accesses the database (if necessary), performs any necessary processing, then sends some context variables to a template, which is rendered and shown to the user. diff --git a/quizApp/filters.py b/quizApp/filters.py index ea2f062..2509ceb 100644 --- a/quizApp/filters.py +++ b/quizApp/filters.py @@ -37,10 +37,10 @@ def get_graph_url_filter(graph): else: filename = current_app.config.get("EXPERIMENTS_PLACEHOLDER_GRAPH") - graph_path = url_for( - 'static', - filename=os.path.join(current_app.config.get("GRAPH_DIRECTORY"), - filename)) + graph_path = url_for('static', + filename=os.path.join( + current_app.config.get("GRAPH_DIRECTORY"), + filename)) return graph_path diff --git a/quizApp/forms/common.py b/quizApp/forms/common.py index 374d60c..4a6d840 100644 --- a/quizApp/forms/common.py +++ b/quizApp/forms/common.py @@ -70,9 +70,13 @@ def populate_object_type(self, mapping): class OrderFormMixin(object): - ''' - To apply add to Meta 'order' iterable - ''' + """This mixin allows us to set the order of fields in a ModelForm. + + To use, specify a ``Meta`` class in your form class and define ``order`` as + an attribute in the ``Meta`` class. + + Based on https://gist.github.com/rombr/89d4d9db0229237f40bbd46482764918/ + """ def __init__(self, *args, **kwargs): super(OrderFormMixin, self).__init__(*args, **kwargs) diff --git a/quizApp/forms/datasets.py b/quizApp/forms/datasets.py index f529d71..7f8a7fb 100644 --- a/quizApp/forms/datasets.py +++ b/quizApp/forms/datasets.py @@ -4,7 +4,6 @@ import os from wtforms import SubmitField, FileField from wtforms_alchemy import ModelForm -from flask import current_app from quizApp import db from quizApp.forms.common import OrderFormMixin @@ -15,16 +14,10 @@ class UploadedFileField(FileField): """This behaves just like FileField, however when ``populate_obj`` is called it will overwrite the file pointed to by ``obj.name`` with the uploaded file. - """ - file_dir = "" - - def __init__(self, *args, **kwargs): - # TODO: can we move this elsewhere - self.file_dir = os.path.join( - current_app.static_folder, - current_app.config.get("GRAPH_DIRECTORY")) - super(UploadedFileField, self).__init__(*args, **kwargs) + ``obj.directory`` must return the directory where the file is to be saved, + if ``getattr(obj, name)`` does not exist. + """ def populate_obj(self, obj, name): if not self.data: return @@ -36,7 +29,7 @@ def populate_obj(self, obj, name): # Need to create a new file file_name = str(obj.id) +\ os.path.splitext(self.data.filename)[1] - path = os.path.join(self.file_dir, file_name) + path = os.path.join(obj.directory, file_name) setattr(obj, name, path) db.session.commit() self.data.save(path) diff --git a/quizApp/forms/experiments.py b/quizApp/forms/experiments.py index 3f96823..e856f6c 100644 --- a/quizApp/forms/experiments.py +++ b/quizApp/forms/experiments.py @@ -87,6 +87,9 @@ def populate_assignment(self, assignment): @property def result(self): """Create a Result object based on this form's data. + + The Result should be appropriate to the type of activity this form is + dealing with. """ raise NotImplementedError diff --git a/quizApp/models.py b/quizApp/models.py index 016c03a..ed49754 100644 --- a/quizApp/models.py +++ b/quizApp/models.py @@ -6,6 +6,7 @@ from datetime import datetime from quizApp import db +from flask import current_app from flask_security import UserMixin, RoleMixin @@ -104,10 +105,8 @@ class Participant(User): canvas, mechanical turk) it may be necessary to record their user ID on the other service (e.g. preventing multiple submission). This field holds the foreign ID of this user. - assignments (list of Assignments): List of assignments that this user - has - assignment_sets (list of AssignmentSets): List of - AssignmentSets that this participant has + assignment_sets (list of AssignmentSets): List of AssignmentSets that + this participant has """ opt_in = db.Column(db.Boolean) @@ -122,15 +121,12 @@ class Participant(User): class AssignmentSet(Base): - """An Association Object that relates a User to an Experiment and also - stores the progress of the User in this Experiment as well as the order of - Questions that this user does. - Essentially, this tracks the progress of each User in each Experiment. + """An AssignmentSet represents a sequence of Assignments within an + Experiment. All Assignments in an AssignmentSet are done in order by the + same Participant. Attributes: - activities (list of Activity): Order of activities for this - user in this experiment - progress (int): Which question the user is currently working on. + progress (int): Which Assignment the user is currently working on. complete (bool): True if the user has finalized their responses, False otherwise participant (Participant): Which Participant this refers to @@ -195,8 +191,6 @@ class Assignment(Base): they skipped this assignment. Attributes: - skipped (bool): True if the Participant skipped this Question, False - otherwise comment (string): An optional comment entered by the student. choice_order (string): A JSON object in string form that represents the order of choices that this participant was presented with when @@ -206,12 +200,10 @@ class Assignment(Base): the question being submitted. media_items (list of MediaItem): What MediaItems should be shown activity (Activity): Which Activity this Participant should see - choice (Choice): Which Choice this Participant chose as their answer - assignment_set (AssignmentSet): Which - AssignmentSet this Assignment belongs to + assignment_set (AssignmentSet): Which AssignmentSet this Assignment + belongs to """ - skipped = db.Column(db.Boolean, info={"import_include": False}) comment = db.Column(db.String(500), info={"import_include": False}) choice_order = db.Column(db.String(80), info={"import_include": False}) time_to_submit = db.Column(db.Interval(), info={"import_include": False}) @@ -254,8 +246,7 @@ def score(self): @db.validates("activity") def validate_activity(self, _, activity): - """Make sure that the activity is part of this experiment. - Make sure that the number of media items on the activity is the same as + """Make sure that the number of media items on the activity is the same as the number of media items this assignment has. """ try: @@ -420,12 +411,12 @@ class Activity(Base): they picked what they did after they answer the question. category (string): A description of this assignment's category, for the users' convenience. - experiments (list of Experiment): What Experiments include this - Activity assignments (list of Assignment): What Assignments include this Activity scorecard_settings (ScorecardSettings): Settings for scorecards after this Activity is done + include_in_scorecards (bool): Whether or not to show this Activity in + scorecards """ class Meta(object): """Define what kind of Result we are looking for. @@ -444,8 +435,7 @@ class Meta(object): scorecard_settings_id = db.Column(db.Integer, db.ForeignKey("scorecard_settings.id")) - scorecard_settings = db.relationship("ScorecardSettings", - info={"import_include": False}) + scorecard_settings = db.relationship("ScorecardSettings") def __init__(self, *args, **kwargs): """Make sure to populate scorecard_settings. @@ -547,20 +537,13 @@ class Question(Activity): "label": "Number of media items to show" }) - choices = db.relationship("Choice", back_populates="question", - info={"import_include": False}) + choices = db.relationship("Choice", back_populates="question") datasets = db.relationship("Dataset", secondary=question_dataset_table, back_populates="questions") def __str__(self): return self.question - def import_dict(self, **kwargs): - if "num_media_items" in kwargs: - self.num_media_items = kwargs.pop("num_media_items") - - super(Question, self).import_dict(**kwargs) - __mapper_args__ = { 'polymorphic_identity': 'question', } @@ -784,7 +767,20 @@ class Graph(MediaItem): def filename(self): """Return the filename of this graph. """ - return os.path.split(os.path.basename(self.path))[1] + return os.path.basename(self.path) + + @property + def directory(self): + """Return the directory this graph is located in. + + If ``path`` is not empty, return the lowest directory specified by + ``path``. Otherwise, return the designated graph directory. + """ + current_directory = os.path.split(self.path)[0] + if current_directory: + return current_directory + return os.path.join(current_app.static_folder, + current_app.config.get("GRAPH_DIRECTORY")) __mapper_args__ = { 'polymorphic_identity': 'graph' @@ -843,12 +839,10 @@ class Experiment(Base): """An Experiment contains a list of Activities. Attributes: - name (string - created (datetime + name (str): The name of this experiment + created (datetime): When this experiment was created start (datetime): When this experiment becomes accessible for answers stop (datetime): When this experiment stops accepting answers - activities (list of Activity): What Activities are included in this - Experiment's AssignmentSets assignment_sets (list of ParticiapntExperiment): List of AssignmentSets that are associated with this Experiment disable_previous (bool): If True, don't allow Participants to view and @@ -871,13 +865,12 @@ class Experiment(Base): In addition, a scorecard will be rendered after the experiment according to the Experiment's ``ScorecardSettings``. flash (bool): If True, flash the MediaItem for flash_duration - milliseconds + milliseconds flash_duration (int): How long to display the MediaItem in milliseconds """ - name = db.Column(db.String(150), index=True, nullable=False, - info={"label": "Name"}) - created = db.Column(db.DateTime, info={"import_include": False}) + name = db.Column(db.String(150), nullable=False, info={"label": "Name"}) + created = db.Column(db.DateTime) start = db.Column(db.DateTime, nullable=False, info={"label": "Start"}) stop = db.Column(db.DateTime, nullable=False, info={"label": "Stop"}) blurb = db.Column(db.Text, info={"label": "Blurb"}) @@ -896,14 +889,12 @@ class Experiment(Base): info={"label": "Show timers on activities"}) assignment_sets = db.relationship("AssignmentSet", - back_populates="experiment", - info={"import_include": False}) + back_populates="experiment") scorecard_settings_id = db.Column(db.Integer, db.ForeignKey("scorecard_settings.id")) scorecard_settings = db.relationship("ScorecardSettings", - uselist=False, - info={"import_include": False}) + uselist=False) def __init__(self, *args, **kwargs): """Make sure to populate scorecard_settings. diff --git a/quizApp/static/graphs/11.png b/quizApp/static/graphs/11.png index f48ecef..84b9a23 100644 Binary files a/quizApp/static/graphs/11.png and b/quizApp/static/graphs/11.png differ diff --git a/quizApp/views/activities.py b/quizApp/views/activities.py index 266b233..7e2b3ac 100644 --- a/quizApp/views/activities.py +++ b/quizApp/views/activities.py @@ -303,6 +303,7 @@ class ChoiceCollectionView(ObjectCollectionView): decorators = [roles_required("experimenter")] methods = ["POST"] get_members = None + template = None def resolve_kwargs(self, question_id): question = validate_model_id(Question, question_id) @@ -311,8 +312,6 @@ def resolve_kwargs(self, question_id): def create_form(self): return ChoiceForm(request.form, prefix="create") - template = None - def create_member(self, create_form, question): choice = Choice() diff --git a/quizApp/views/experiments.py b/quizApp/views/experiments.py index 58a67f4..f0c41f0 100644 --- a/quizApp/views/experiments.py +++ b/quizApp/views/experiments.py @@ -1,7 +1,7 @@ """Views that handle CRUD for experiments and rendering questions for participants. """ -from collections import defaultdict +from collections import defaultdict, Counter from datetime import datetime import json import os @@ -199,8 +199,8 @@ def read_scorecard(experiment, assignment): next_url = get_next_assignment_url(assignment_set, this_index) + # Get the previous assignment, if any previous_assignment = None - if this_index - 1 > -1 and not experiment.disable_previous: previous_assignment = assignment_set.assignments[this_index - 1] @@ -239,14 +239,13 @@ def read_question(experiment, assignment): question_form.populate_from_result(assignment.result) if not assignment_set.complete: - # If the participant is not done, then save the choice order next_url = None else: # If the participant is done, have a link right to the next question next_url = get_next_assignment_url(assignment_set, this_index) + # Get the previous assignment, if any previous_assignment = None - if this_index - 1 > -1 and not experiment.disable_previous: previous_assignment = assignment_set.assignments[this_index - 1] @@ -406,9 +405,6 @@ def get_question_stats(assignment, question_stats): if assignment.result: question_stats[question.id]["num_responses"] += 1 - if assignment.result: - question_stats[question.id]["num_correct"] += 1 - @experiments.route(EXPERIMENT_ROUTE + "/results", methods=["GET"]) @roles_required("experimenter") @@ -420,7 +416,7 @@ def results_experiment(experiment_id): num_participants = Participant.query.count() num_finished = AssignmentSet.query.\ filter_by(experiment_id=experiment.id).\ - filter_by(progress=-1).count() + filter_by(complete=True).count() percent_finished = num_finished / float(num_participants) @@ -471,6 +467,28 @@ def export_results_experiment(experiment_id): attachment_filename="experiment_{}_report.xlsx".format(experiment.id)) +def get_activity_column_index(activity, activity_column_mapping, + activity_counter, headers): + """Find the column index for this occurrence of the given activity. This + will update headers, counter, and mapping if necessary. + """ + activity_occurrence = activity_counter[activity.id] + activity_counter[activity.id] += 1 + try: + return activity_column_mapping[activity.id][activity_occurrence] + except KeyError: + activity_column_mapping[activity.id] = [len(headers) + 1] + headers.append("{}: {}".format(activity.id, activity)) + headers.append("Correct?") + headers.append("Points") + except IndexError: + activity_column_mapping[activity.id].append(len(headers) + 1) + headers.append("{}: {}".format(activity.id, activity)) + headers.append("Correct?") + headers.append("Points") + return activity_column_mapping[activity.id][activity_occurrence] + + def get_results_workbook(experiment): """Analyze the assignment sets in the experiment and return an excel workbook. @@ -481,16 +499,26 @@ def get_results_workbook(experiment): next_participant_row = 2 participant_row_mapping = {} + # The same activity can appear multiple times in an assignment set. To + # display them properly, we keep a list of their ocurrences in + # activity_column_mapping, like so: + # {1: [3, 7, 10], ...} means activity 1 occurs in columns 3, 7, and 10 + # When populating a row, we will use the earliest occurrence of the + # activity possible. + workbook = openpyxl.Workbook() sheet = workbook.active sheet.title = "Experiment {} - Report".format(experiment.id) for assignment_set in assignment_sets: + activity_counter = Counter() participant = assignment_set.participant + # Nobody has done this set if not participant: continue + # Encountered a new participant if participant.id not in participant_row_mapping: participant_row_mapping[participant.id] = next_participant_row populate_row_segment(sheet, @@ -501,35 +529,28 @@ def get_results_workbook(experiment): for assignment in assignment_set.assignments: activity = assignment.activity - - if activity.id not in activity_column_mapping: - activity_column_mapping[activity.id] = len(headers) + 1 - headers.append("{}/{}: {}".format(assignment.id, - activity.id, activity)) - headers.append("Correct?") - headers.append("Points") + activity_column_index = get_activity_column_index( + activity, + activity_column_mapping, + activity_counter, + headers) if not assignment.result: - continue + row = ["_BLANK_"] * 3 + else: + row = ["{}:{}".format(assignment.id, assignment.result), + assignment.correct, + assignment.score] populate_row_segment( sheet, participant_row_mapping[participant.id], - activity_column_mapping[activity.id], - [str(assignment.result), - assignment.correct, - assignment.score] + activity_column_index, + row ) populate_row_segment(sheet, 1, 1, headers) - # Put a special token in all blank spaces - for r in range(1, next_participant_row): - for c in range(1, len(headers) + 1): - value = sheet.cell(row=r, column=c).value - if value is None: - sheet.cell(row=r, column=c).value = "_BLANK_" - # Specify experiment ID sheet.cell(row=1, column=len(headers) + 1).value = "Experiment ID" sheet.cell(row=2, column=len(headers) + 1).value = experiment.id diff --git a/quizApp/views/helpers.py b/quizApp/views/helpers.py index 0fcb34e..0511f74 100644 --- a/quizApp/views/helpers.py +++ b/quizApp/views/helpers.py @@ -45,8 +45,6 @@ def get_first_assignment(experiment): If the set is complete, this function returns the first assignment in the set. - - If """ assignment_set = get_or_create_assignment_set(experiment) diff --git a/tests/factories.py b/tests/factories.py index 5ba6ce5..dfe0ac4 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -116,7 +116,6 @@ class AssignmentFactory(factory.Factory): class Meta(object): model = models.Assignment - skipped = factory.Faker("boolean") comment = factory.Faker("text") choice_order = factory.Faker("text") diff --git a/tests/test_models.py b/tests/test_models.py index d52bad9..f708de6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -137,6 +137,16 @@ def test_graph_filename(): assert graph.filename() == filename +def test_graph_directory(): + path = "/foo/bar/baz" + filename = "boo.png" + + full_path = os.path.join(path, filename) + graph = models.Graph(path=full_path, name="Foobar") + + assert graph.directory == path + + def test_save(): """Make sure saving works correctly. """ diff --git a/tests/test_views_experiments.py b/tests/test_views_experiments.py index a00076e..9a68004 100644 --- a/tests/test_views_experiments.py +++ b/tests/test_views_experiments.py @@ -2,6 +2,7 @@ """ from __future__ import unicode_literals from builtins import str +from collections import Counter import json import random import mock @@ -11,9 +12,10 @@ import openpyxl from quizApp import db -from quizApp.models import AssignmentSet +from quizApp.models import AssignmentSet, Activity from quizApp.views.experiments import get_next_assignment_url, \ - POST_FINALIZE_HANDLERS, validate_assignment_set, populate_row_segment + POST_FINALIZE_HANDLERS, validate_assignment_set, populate_row_segment, \ + get_activity_column_index from tests.factories import ExperimentFactory, create_experiment, \ ParticipantFactory, create_result from tests.auth import login_participant, get_participant, \ @@ -726,3 +728,25 @@ def test_export_experiment_results(client, users): url = "/experiments/{}/results/export".format(exp.id) response = client.get(url) assert response.status_code == 200 + + +def test_get_activity_column_index(): + activity = mock.MagicMock(autospec=Activity) + activity.id = 5 + activity.__str__.return_value = "" + counter = Counter() + mapping = {} + headers = [] + + get_activity_column_index(activity, mapping, counter, headers) + + assert mapping[activity.id][0] == 1 # 1-indexed due to openpyxl + assert len(mapping[activity.id]) == 1 + assert counter[activity.id] == 1 + + get_activity_column_index(activity, mapping, counter, headers) + + assert mapping[activity.id][0] == 1 + assert mapping[activity.id][1] == 4 + assert len(mapping[activity.id]) == 2 + assert counter[activity.id] == 2