From ce8f311256d0ff861deb1ab60b616b1cb66b060c Mon Sep 17 00:00:00 2001 From: Diana Huang <dkh@edx.org> Date: Fri, 14 Aug 2015 16:01:36 -0400 Subject: [PATCH] Add a new last_activity_at field. TNL-3068 --- lms/djangoapps/teams/migrations/0004_auto__add_field_courseteam_discussion_topic_id__add_field_courseteam_l.py | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lms/djangoapps/teams/models.py | 13 ++++++++++++- lms/djangoapps/teams/serializers.py | 7 ++++--- lms/djangoapps/teams/tests/factories.py | 7 +++++++ lms/djangoapps/teams/tests/test_models.py | 20 +++++++++++++++++--- lms/djangoapps/teams/tests/test_views.py | 13 ++++++++++++- lms/djangoapps/teams/views.py | 12 ++++++++++++ 7 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 lms/djangoapps/teams/migrations/0004_auto__add_field_courseteam_discussion_topic_id__add_field_courseteam_l.py diff --git a/lms/djangoapps/teams/migrations/0004_auto__add_field_courseteam_discussion_topic_id__add_field_courseteam_l.py b/lms/djangoapps/teams/migrations/0004_auto__add_field_courseteam_discussion_topic_id__add_field_courseteam_l.py new file mode 100644 index 0000000..024c06d --- /dev/null +++ b/lms/djangoapps/teams/migrations/0004_auto__add_field_courseteam_discussion_topic_id__add_field_courseteam_l.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +import pytz +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'CourseTeam.last_activity_at' + db.add_column('teams_courseteam', 'last_activity_at', + self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2015, 8, 17, 0, 0).replace(tzinfo=pytz.utc)), + keep_default=False) + + # Adding field 'CourseTeamMembership.last_activity_at' + db.add_column('teams_courseteammembership', 'last_activity_at', + self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2015, 8, 17, 0, 0).replace(tzinfo=pytz.utc)), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'CourseTeam.last_activity_at' + db.delete_column('teams_courseteam', 'last_activity_at') + + # Deleting field 'CourseTeamMembership.last_activity_at' + db.delete_column('teams_courseteammembership', 'last_activity_at') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'teams.courseteam': { + 'Meta': {'object_name': 'CourseTeam'}, + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'blank': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'date_created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'description': ('django.db.models.fields.CharField', [], {'max_length': '300'}), + 'discussion_topic_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'language': ('student.models.LanguageField', [], {'max_length': '16', 'blank': 'True'}), + 'last_activity_at': ('django.db.models.fields.DateTimeField', [], {}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'team_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'topic_id': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'db_index': 'True', 'related_name': "'teams'", 'symmetrical': 'False', 'through': "orm['teams.CourseTeamMembership']", 'to': "orm['auth.User']"}) + }, + 'teams.courseteammembership': { + 'Meta': {'unique_together': "(('user', 'team'),)", 'object_name': 'CourseTeamMembership'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_activity_at': ('django.db.models.fields.DateTimeField', [], {}), + 'team': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'membership'", 'to': "orm['teams.CourseTeam']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['teams'] diff --git a/lms/djangoapps/teams/models.py b/lms/djangoapps/teams/models.py index 6e2a4f0..a0d872e 100644 --- a/lms/djangoapps/teams/models.py +++ b/lms/djangoapps/teams/models.py @@ -1,6 +1,8 @@ """Django models related to teams functionality.""" from uuid import uuid4 +import pytz +from datetime import datetime from django.contrib.auth.models import User from django.db import models @@ -23,13 +25,13 @@ class CourseTeam(models.Model): course_id = CourseKeyField(max_length=255, db_index=True) topic_id = models.CharField(max_length=255, db_index=True, blank=True) date_created = models.DateTimeField(auto_now_add=True) - # last_activity is computed through a query description = models.CharField(max_length=300) country = CountryField(blank=True) language = LanguageField( blank=True, help_text=ugettext_lazy("Optional language the team uses as ISO 639-1 code."), ) + last_activity_at = models.DateTimeField() users = models.ManyToManyField(User, db_index=True, related_name='teams', through='CourseTeamMembership') @classmethod @@ -62,6 +64,7 @@ class CourseTeam(models.Model): description=description, country=country if country else '', language=language if language else '', + last_activity_at=datetime.utcnow().replace(tzinfo=pytz.utc) ) return course_team @@ -88,6 +91,14 @@ class CourseTeamMembership(models.Model): user = models.ForeignKey(User) team = models.ForeignKey(CourseTeam, related_name='membership') date_joined = models.DateTimeField(auto_now_add=True) + last_activity_at = models.DateTimeField() + + def save(self, *args, **kwargs): + """ Customize save method to set the last_activity_at if it does not currently exist. """ + if not self.last_activity_at: + self.last_activity_at = datetime.utcnow().replace(tzinfo=pytz.utc) + + super(CourseTeamMembership, self).save(*args, **kwargs) @classmethod def get_memberships(cls, username=None, course_ids=None, team_id=None): diff --git a/lms/djangoapps/teams/serializers.py b/lms/djangoapps/teams/serializers.py index d9262dd..4d9b59e 100644 --- a/lms/djangoapps/teams/serializers.py +++ b/lms/djangoapps/teams/serializers.py @@ -58,9 +58,10 @@ class CourseTeamSerializer(serializers.ModelSerializer): "description", "country", "language", + "last_activity_at", "membership", ) - read_only_fields = ("course_id", "date_created", "discussion_topic_id") + read_only_fields = ("course_id", "date_created", "discussion_topic_id", "last_activity_at") class CourseTeamCreationSerializer(serializers.ModelSerializer): @@ -118,8 +119,8 @@ class MembershipSerializer(serializers.ModelSerializer): class Meta(object): """Defines meta information for the ModelSerializer.""" model = CourseTeamMembership - fields = ("user", "team", "date_joined") - read_only_fields = ("date_joined",) + fields = ("user", "team", "date_joined", "last_activity_at") + read_only_fields = ("date_joined", "last_activity_at") class PaginatedMembershipSerializer(PaginationSerializer): diff --git a/lms/djangoapps/teams/tests/factories.py b/lms/djangoapps/teams/tests/factories.py index 1a491d2..07077a5 100644 --- a/lms/djangoapps/teams/tests/factories.py +++ b/lms/djangoapps/teams/tests/factories.py @@ -1,5 +1,7 @@ """Factories for testing the Teams API.""" +import pytz +from datetime import datetime from uuid import uuid4 import factory @@ -8,6 +10,9 @@ from factory.django import DjangoModelFactory from ..models import CourseTeam, CourseTeamMembership +LAST_ACTIVITY_AT = datetime(2015, 8, 15, 0, 0, 0, tzinfo=pytz.utc) + + class CourseTeamFactory(DjangoModelFactory): """Factory for CourseTeams. @@ -20,8 +25,10 @@ class CourseTeamFactory(DjangoModelFactory): discussion_topic_id = factory.LazyAttribute(lambda a: uuid4().hex) name = "Awesome Team" description = "A simple description" + last_activity_at = LAST_ACTIVITY_AT class CourseTeamMembershipFactory(DjangoModelFactory): """Factory for CourseTeamMemberships.""" FACTORY_FOR = CourseTeamMembership + last_activity_at = LAST_ACTIVITY_AT diff --git a/lms/djangoapps/teams/tests/test_models.py b/lms/djangoapps/teams/tests/test_models.py index ee6f523..ba82f7f 100644 --- a/lms/djangoapps/teams/tests/test_models.py +++ b/lms/djangoapps/teams/tests/test_models.py @@ -29,9 +29,23 @@ class TeamMembershipTest(SharedModuleStoreTestCase): self.team1 = CourseTeamFactory(course_id=COURSE_KEY1, team_id='team1') self.team2 = CourseTeamFactory(course_id=COURSE_KEY2, team_id='team2') - self.team_membership11 = CourseTeamMembershipFactory(user=self.user1, team=self.team1) - self.team_membership12 = CourseTeamMembershipFactory(user=self.user2, team=self.team1) - self.team_membership21 = CourseTeamMembershipFactory(user=self.user1, team=self.team2) + self.team_membership11 = CourseTeamMembership(user=self.user1, team=self.team1) + self.team_membership11.save() + self.team_membership12 = CourseTeamMembership(user=self.user2, team=self.team1) + self.team_membership12.save() + self.team_membership21 = CourseTeamMembership(user=self.user1, team=self.team2) + self.team_membership21.save() + + def test_membership_last_activity_set(self): + current_last_activity = self.team_membership11.last_activity_at + # Assert that the first save in the setUp sets a value. + self.assertIsNotNone(current_last_activity) + + self.team_membership11.save() + + # Verify that we only change the last activity_at when it doesn't + # already exist. + self.assertEqual(self.team_membership11.last_activity_at, current_last_activity) @ddt.data( (None, None, None, 3), diff --git a/lms/djangoapps/teams/tests/test_views.py b/lms/djangoapps/teams/tests/test_views.py index b97252f..26c0ff9 100644 --- a/lms/djangoapps/teams/tests/test_views.py +++ b/lms/djangoapps/teams/tests/test_views.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- """Tests for the teams API at the HTTP request level.""" import json +import pytz +from datetime import datetime +from dateutil import parser import ddt @@ -13,7 +16,7 @@ from courseware.tests.factories import StaffFactory from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory from student.models import CourseEnrollment from xmodule.modulestore.tests.factories import CourseFactory -from .factories import CourseTeamFactory +from .factories import CourseTeamFactory, LAST_ACTIVITY_AT from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from django_comment_common.models import Role, FORUM_ROLE_COMMUNITY_TA @@ -540,6 +543,13 @@ class TestCreateTeamAPI(TeamAPITestCase): team_membership = team['membership'] del team['membership'] + # verify that it's been set to a time today. + self.assertEqual( + parser.parse(team['last_activity_at']).date(), + datetime.utcnow().replace(tzinfo=pytz.utc).date() + ) + del team['last_activity_at'] + # Verify that the creating user gets added to the team. self.assertEqual(len(team_membership), 1) member = team_membership[0]['user'] @@ -587,6 +597,7 @@ class TestDetailTeamAPI(TeamAPITestCase): if status == 200: self.assertEqual(team['description'], self.test_team_1.description) self.assertEqual(team['discussion_topic_id'], self.test_team_1.discussion_topic_id) + self.assertEqual(parser.parse(team['last_activity_at']), LAST_ACTIVITY_AT) def test_does_not_exist(self): self.get_team_detail('no_such_team', 404) diff --git a/lms/djangoapps/teams/views.py b/lms/djangoapps/teams/views.py index 91038b3..e0f12a8 100644 --- a/lms/djangoapps/teams/views.py +++ b/lms/djangoapps/teams/views.py @@ -221,6 +221,9 @@ class TeamsListView(ExpandableFieldViewMixin, GenericAPIView): * language: Optionally specifies which language the team is associated with. + * last_activity_at: The date of the last activity of any team member + within the team. + * membership: A list of the users that are members of the team. See membership endpoint for more detail. @@ -449,6 +452,9 @@ class TeamsDetailView(ExpandableFieldViewMixin, RetrievePatchAPIView): * membership: A list of the users that are members of the team. See membership endpoint for more detail. + * last_activity_at: The date of the last activity of any team member + within the team. + For all text fields, clients rendering the values should take care to HTML escape them to avoid script injections, as the data is stored exactly as specified. The intention is that plain text is @@ -740,6 +746,9 @@ class MembershipListView(ExpandableFieldViewMixin, GenericAPIView): * date_joined: The date and time the membership was created. + * last_activity_at: The date of the last activity of the user + within the team. + For all text fields, clients rendering the values should take care to HTML escape them to avoid script injections, as the data is stored exactly as specified. The intention is that plain text is @@ -942,6 +951,9 @@ class MembershipDetailView(ExpandableFieldViewMixin, GenericAPIView): * date_joined: The date and time the membership was created. + * last_activity_at: The date of the last activity of any team member + within the team. + For all text fields, clients rendering the values should take care to HTML escape them to avoid script injections, as the data is stored exactly as specified. The intention is that plain text is -- libgit2 0.26.0