Skip to content

Commit

Permalink
TeamSubmission - added a new model and updated unit tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
Andytr1 committed Apr 1, 2020
1 parent 4a0d1d5 commit 30e742b
Show file tree
Hide file tree
Showing 8 changed files with 250 additions and 15 deletions.
12 changes: 12 additions & 0 deletions settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@
'submissions'
)

TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'OPTIONS': {
'context_processors': [
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]

MIDDLEWARE = (
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
Expand Down
2 changes: 1 addition & 1 deletion submissions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
""" API for creating submissions and scores. """
__version__ = '3.0.4'
__version__ = '3.0.5'
13 changes: 11 additions & 2 deletions submissions/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,16 @@
from django.core.cache import cache
from django.db import DatabaseError, IntegrityError

from submissions.models import Score, ScoreAnnotation, ScoreSummary, StudentItem, Submission, score_reset, score_set
from submissions.models import (
DELETED,
Score,
ScoreAnnotation,
ScoreSummary,
StudentItem,
Submission,
score_reset,
score_set
)
from submissions.serializers import (
ScoreSerializer,
StudentItemSerializer,
Expand Down Expand Up @@ -803,7 +812,7 @@ def reset_score(student_id, course_id, item_id, clear_state=False, emit_signal=T
if clear_state:
for sub in student_item.submission_set.all():
# soft-delete the Submission
sub.status = Submission.DELETED
sub.status = DELETED
sub.save(update_fields=["status"])

# Also clear out cached values
Expand Down
61 changes: 61 additions & 0 deletions submissions/migrations/0005_CreateTeamModel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Generated by Django 2.2.11 on 2020-03-27 19:48

import uuid

import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('submissions', '0004_remove_django_extensions'),
]

operations = [
migrations.CreateModel(
name='TeamSubmission',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name='created'
)),
('modified', model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name='modified'
)),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4)),
('attempt_number', models.PositiveIntegerField()),
('submitted_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
('course_id', models.CharField(db_index=True, max_length=255)),
('item_id', models.CharField(db_index=True, max_length=255)),
('team_id', models.CharField(db_index=True, max_length=255)),
('status', models.CharField(choices=[('D', 'Deleted'), ('A', 'Active')], default='A', max_length=1)),
('submitted_by', models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL
)),
],
options={
'ordering': ['-submitted_at', '-id'],
},
),
migrations.AddField(
model_name='submission',
name='team_submission',
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='submissions',
to='submissions.TeamSubmission'
),
),
]
112 changes: 102 additions & 10 deletions submissions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@
import logging
from uuid import uuid4

from django.contrib.auth.models import User
from django.db import DatabaseError, models
from django.db.models.signals import post_save
from django.db.models.signals import post_save, pre_save
from django.dispatch import Signal, receiver
from django.utils.encoding import python_2_unicode_compatible
from django.utils.timezone import now
from jsonfield import JSONField
from model_utils.models import TimeStampedModel

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -107,6 +109,96 @@ def from_db_value(self, value, expression, connection, context=None):
return json.loads(value, **self.load_kwargs)


# Has this submission been soft-deleted? This allows instructors to reset student
# state on an item, while preserving the previous value for potential analytics use.
DELETED = 'D'
ACTIVE = 'A'
STATUS_CHOICES = (
(DELETED, 'Deleted'),
(ACTIVE, 'Active'),
)


@python_2_unicode_compatible
class TeamSubmission(TimeStampedModel):
"""
A single response by a team for a given problem (ORA2) in a given course.
An abstraction layer over Submission used to for teams. Since we create a submission record for every team member,
there is a need to have a single point to connect the team to the workflows.
TeamSubmission is a 1 to many with Submission
"""

uuid = models.UUIDField(db_index=True, default=uuid4, null=False)

# Which attempt is this? Consecutive Submissions do not necessarily have
# increasing attempt_number entries -- e.g. re-scoring a buggy problem.
attempt_number = models.PositiveIntegerField()

# submitted_at is separate from created_at to support re-scoring and other
# processes that might create Submission objects for past user actions.
submitted_at = models.DateTimeField(default=now, db_index=True)

course_id = models.CharField(max_length=255, null=False, db_index=True)

item_id = models.CharField(max_length=255, null=False, db_index=True)

team_id = models.CharField(max_length=255, null=False, db_index=True)

submitted_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)

status = models.CharField(max_length=1, choices=STATUS_CHOICES, default=ACTIVE)

# Override the default Manager with our custom one to filter out soft-deleted items
class SoftDeletedManager(models.Manager):
def get_queryset(self):
return super(TeamSubmission.SoftDeletedManager, self).get_queryset().exclude(status=DELETED)

objects = SoftDeletedManager()
_objects = models.Manager() # Don't use this unless you know and can explain why objects doesn't work for you

@staticmethod
def get_cache_key(sub_uuid):
return "submissions.team_submission.{}".format(sub_uuid)

def __repr__(self):
return repr(dict(
uuid=self.uuid,
submitted_by=self.submitted_by,
attempt_number=self.attempt_number,
submitted_at=self.submitted_at,
created=self.created,
modified=self.modified,
))

def __str__(self):
return "Team Submission {}".format(self.uuid)

class Meta:
app_label = "submissions"
ordering = ["-submitted_at", "-id"]


@receiver(pre_save, sender=TeamSubmission)
def validate_only_one_submission_per_team(sender, **kwargs):
"""
Ensures that there is only one active submission per team.
"""
ts = kwargs['instance']
if TeamSubmission.objects.filter(
course_id=ts.course_id,
item_id=ts.item_id,
team_id=ts.team_id,
status='A'
).exists():
raise DuplicateTeamSubmissionsError('Can only have one submission per team.')


class DuplicateTeamSubmissionsError(Exception):
"""
An error that is raised when duplicate team submissions are detected.
"""


@python_2_unicode_compatible
class Submission(models.Model):
"""A single response by a student for a given problem in a given course.
Expand Down Expand Up @@ -143,20 +235,20 @@ class Submission(models.Model):
# name so it continues to use `raw_answer`.
answer = UpdatedJSONField(blank=True, db_column="raw_answer")

# Has this submission been soft-deleted? This allows instructors to reset student
# state on an item, while preserving the previous value for potential analytics use.
DELETED = u'D'
ACTIVE = u'A'
STATUS_CHOICES = (
(DELETED, u'Deleted'),
(ACTIVE, u'Active'),
)
status = models.CharField(max_length=1, choices=STATUS_CHOICES, default=ACTIVE)

team_submission = models.ForeignKey(
TeamSubmission,
related_name='submissions',
null=True,
db_index=True,
on_delete=models.SET_NULL
)

# Override the default Manager with our custom one to filter out soft-deleted items
class SoftDeletedManager(models.Manager):
def get_queryset(self):
return super(Submission.SoftDeletedManager, self).get_queryset().exclude(status=Submission.DELETED)
return super(Submission.SoftDeletedManager, self).get_queryset().exclude(status=DELETED)

objects = SoftDeletedManager()
_objects = models.Manager() # Don't use this unless you know and can explain why objects doesn't work for you
Expand Down
2 changes: 1 addition & 1 deletion submissions/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@ class Meta:
created_at = datetime.datetime.now()
answer = {}

status = models.Submission.ACTIVE
status = models.ACTIVE
62 changes: 61 additions & 1 deletion submissions/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,21 @@

from __future__ import absolute_import

from datetime import datetime

import pytest
from django.contrib.auth.models import User
from django.test import TestCase
from pytz import UTC

from submissions.models import Score, ScoreSummary, StudentItem, Submission
from submissions.models import (
DuplicateTeamSubmissionsError,
Score,
ScoreSummary,
StudentItem,
Submission,
TeamSubmission
)


class TestScoreSummary(TestCase):
Expand Down Expand Up @@ -166,3 +178,51 @@ def test_highest_score_hidden(self):
highest = ScoreSummary.objects.get(student_item=item).highest
self.assertEqual(highest.points_earned, 1)
self.assertEqual(highest.points_possible, 2)


class TestTeamSubmission(TestCase):
"""
Test the TeamSubmission class
"""

@classmethod
def setUpTestData(cls):
cls.user = cls.create_user('user1')
cls.default_team_id = 'team1'
cls.default_Course_id = 'c1'
cls.defaullt_item_id = 'i1'
cls.default_attempt_number = 1
cls.default_submission = cls.create_team_submission(user=cls.user)
super().setUpTestData()

def test_create_team_submission(self):
# force evaluation of __str__ to ensure there are no issues with the class, since there
# isn't much specific to assert.
self.assertNotEqual(self.default_submission.__str__, None)

def test_create_duplicate_team_submission_not_allowed(self):
with pytest.raises(DuplicateTeamSubmissionsError):
TestTeamSubmission.create_team_submission(user=self.user)

@staticmethod
def create_user(username):
return User.objects.create(
username=username,
password='secret',
first_name='fname',
last_name='lname',
is_staff=False,
is_active=True,
last_login=datetime(2012, 1, 1, tzinfo=UTC),
date_joined=datetime(2011, 1, 1, tzinfo=UTC)
)

@staticmethod
def create_team_submission(user, team_id='team1', course_id='c1', item_id='i1', attempt_number=1):
return TeamSubmission.objects.create(
submitted_by=user,
team_id=team_id,
course_id=course_id,
item_id=item_id,
attempt_number=attempt_number
)
1 change: 1 addition & 0 deletions urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
urlpatterns = []

0 comments on commit 30e742b

Please sign in to comment.