models.py 9.57 KB
Newer Older
1 2
"""Django models related to teams functionality."""

3
from datetime import datetime
4
from uuid import uuid4
5
import pytz
6
from model_utils import FieldTracker
7

8
from django.core.exceptions import ObjectDoesNotExist
9 10
from django.contrib.auth.models import User
from django.db import models
11
from django.dispatch import receiver
12 13 14
from django.utils.translation import ugettext_lazy
from django_countries.fields import CountryField

15 16 17 18 19 20 21 22 23 24 25
from django_comment_common.signals import (
    thread_created,
    thread_edited,
    thread_deleted,
    thread_voted,
    comment_created,
    comment_edited,
    comment_deleted,
    comment_voted,
    comment_endorsed
)
26
from xmodule_django.models import CourseKeyField
27
from util.model_utils import slugify
28
from student.models import LanguageField, CourseEnrollment
29
from .errors import AlreadyOnTeamInCourse, NotEnrolledInCourseForTeam, ImmutableMembershipFieldException
30
from teams.utils import emit_team_event
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
from teams import TEAM_DISCUSSION_CONTEXT


@receiver(thread_voted)
@receiver(thread_created)
@receiver(comment_voted)
@receiver(comment_created)
def post_create_vote_handler(sender, **kwargs):  # pylint: disable=unused-argument
    """Update the user's last activity date upon creating or voting for a
    post."""
    handle_activity(kwargs['user'], kwargs['post'])


@receiver(thread_edited)
@receiver(thread_deleted)
@receiver(comment_edited)
@receiver(comment_deleted)
def post_edit_delete_handler(sender, **kwargs):  # pylint: disable=unused-argument
    """Update the user's last activity date upon editing or deleting a
    post."""
    post = kwargs['post']
    handle_activity(kwargs['user'], post, long(post.user_id))


@receiver(comment_endorsed)
def comment_endorsed_handler(sender, **kwargs):  # pylint: disable=unused-argument
    """Update the user's last activity date upon endorsing a comment."""
    comment = kwargs['post']
    handle_activity(kwargs['user'], comment, long(comment.thread.user_id))


def handle_activity(user, post, original_author_id=None):
    """Handle user activity from django_comment_client and discussion_api
    and update the user's last activity date. Checks if the user who
    performed the action is the original author, and that the
    discussion has the team context.
    """
    if original_author_id is not None and user.id != original_author_id:
        return
    if getattr(post, "context", "course") == TEAM_DISCUSSION_CONTEXT:
        CourseTeamMembership.update_last_activity(user, post.commentable_id)
72 73 74 75 76 77


class CourseTeam(models.Model):
    """This model represents team related info."""

    team_id = models.CharField(max_length=255, unique=True)
78
    discussion_topic_id = models.CharField(max_length=255, unique=True)
79
    name = models.CharField(max_length=255, db_index=True)
80 81 82 83 84 85 86 87 88
    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)
    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."),
    )
89
    last_activity_at = models.DateTimeField(db_index=True)  # indexed for ordering
90
    users = models.ManyToManyField(User, db_index=True, related_name='teams', through='CourseTeamMembership')
91
    team_size = models.IntegerField(default=0, db_index=True)  # indexed for ordering
92

93 94 95 96 97
    field_tracker = FieldTracker()

    # Don't emit changed events when these fields change.
    FIELD_BLACKLIST = ['last_activity_at', 'team_size']

98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114
    @classmethod
    def create(cls, name, course_id, description, topic_id=None, country=None, language=None):
        """Create a complete CourseTeam object.

        Args:
            name (str): The name of the team to be created.
            course_id (str): The ID string of the course associated
              with this team.
            description (str): A description of the team.
            topic_id (str): An optional identifier for the topic the
              team formed around.
            country (str, optional): An optional country where the team
              is based, as ISO 3166-1 code.
            language (str, optional): An optional language which the
              team uses, as ISO 639-1 code.

        """
115 116 117
        unique_id = uuid4().hex
        team_id = slugify(name)[0:20] + '-' + unique_id
        discussion_topic_id = unique_id
118 119 120

        course_team = cls(
            team_id=team_id,
121
            discussion_topic_id=discussion_topic_id,
122 123 124 125 126 127
            name=name,
            course_id=course_id,
            topic_id=topic_id if topic_id else '',
            description=description,
            country=country if country else '',
            language=language if language else '',
128
            last_activity_at=datetime.utcnow().replace(tzinfo=pytz.utc)
129 130 131 132 133 134
        )

        return course_team

    def add_user(self, user):
        """Adds the given user to the CourseTeam."""
135 136
        if not CourseEnrollment.is_enrolled(user, self.course_id):
            raise NotEnrolledInCourseForTeam
137
        if CourseTeamMembership.user_in_team_for_course(user, self.course_id):
138 139
            raise AlreadyOnTeamInCourse
        return CourseTeamMembership.objects.create(
140 141 142 143
            user=user,
            team=self
        )

144 145 146 147 148
    def reset_team_size(self):
        """Reset team_size to reflect the current membership count."""
        self.team_size = CourseTeamMembership.objects.filter(team=self).count()
        self.save()

149 150 151 152 153 154 155 156 157 158 159

class CourseTeamMembership(models.Model):
    """This model represents the membership of a single user in a single team."""

    class Meta(object):
        """Stores meta information for the model."""
        unique_together = (('user', 'team'),)

    user = models.ForeignKey(User)
    team = models.ForeignKey(CourseTeam, related_name='membership')
    date_joined = models.DateTimeField(auto_now_add=True)
160 161
    last_activity_at = models.DateTimeField()

162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177
    immutable_fields = ('user', 'team', 'date_joined')

    def __setattr__(self, name, value):
        """Memberships are immutable, with the exception of last activity
        date.
        """
        if name in self.immutable_fields:
            # Check the current value -- if it is None, then this
            # model is being created from the database and it's fine
            # to set the value. Otherwise, we're trying to overwrite
            # an immutable field.
            current_value = getattr(self, name, None)
            if current_value is not None:
                raise ImmutableMembershipFieldException
        super(CourseTeamMembership, self).__setattr__(name, value)

178
    def save(self, *args, **kwargs):
179 180 181 182 183 184 185
        """Customize save method to set the last_activity_at if it does not
        currently exist. Also resets the team's size if this model is
        being created.
        """
        should_reset_team_size = False
        if self.pk is None:
            should_reset_team_size = True
186 187 188
        if not self.last_activity_at:
            self.last_activity_at = datetime.utcnow().replace(tzinfo=pytz.utc)
        super(CourseTeamMembership, self).save(*args, **kwargs)
189 190 191 192 193 194 195
        if should_reset_team_size:
            self.team.reset_team_size()  # pylint: disable=no-member

    def delete(self, *args, **kwargs):
        """Recompute the related team's team_size after deleting a membership"""
        super(CourseTeamMembership, self).delete(*args, **kwargs)
        self.team.reset_team_size()  # pylint: disable=no-member
Usman Khalid committed
196 197 198 199 200 201 202 203

    @classmethod
    def get_memberships(cls, username=None, course_ids=None, team_id=None):
        """
        Get a queryset of memberships.

        Args:
            username (unicode, optional): The username to filter on.
204
            course_ids (list of unicode, optional) Course IDs to filter on.
Usman Khalid committed
205 206 207 208 209 210 211 212 213 214 215
            team_id (unicode, optional): The team_id to filter on.
        """
        queryset = cls.objects.all()
        if username is not None:
            queryset = queryset.filter(user__username=username)
        if course_ids is not None:
            queryset = queryset.filter(team__course_id__in=course_ids)
        if team_id is not None:
            queryset = queryset.filter(team__team_id=team_id)

        return queryset
216 217 218 219 220 221 222 223 224 225 226 227 228 229 230

    @classmethod
    def user_in_team_for_course(cls, user, course_id):
        """
        Checks whether or not a user is already in a team in the given course.

        Args:
            user: the user that we want to query on
            course_id: the course_id of the course we're interested in

        Returns:
            True if the user is on a team in the course already
            False if not
        """
        return cls.objects.filter(user=user, team__course_id=course_id).exists()
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249

    @classmethod
    def update_last_activity(cls, user, discussion_topic_id):
        """Set the `last_activity_at` for both this user and their team in the
        given discussion topic. No-op if the user is not a member of
        the team for this discussion.
        """
        try:
            membership = cls.objects.get(user=user, team__discussion_topic_id=discussion_topic_id)
        # If a privileged user is active in the discussion of a team
        # they do not belong to, do not update their last activity
        # information.
        except ObjectDoesNotExist:
            return
        now = datetime.utcnow().replace(tzinfo=pytz.utc)
        membership.last_activity_at = now
        membership.team.last_activity_at = now
        membership.team.save()
        membership.save()
250 251 252
        emit_team_event('edx.team.activity_updated', membership.team.course_id, {
            'team_id': membership.team_id,
        })