def update_attributes(self, candidate_batch_size=None): """ Update the attributes for this user/emotion mapping to the average attributes of the most recent songs the user has upvoted as making them feel this emotion. If the user doesn't have any upvotes for this emotion, the attributes will be set to `None` and reset to the emotion defaults in the save() call. :param candidate_batch_size: (int) Number of songs to include in batch for calculating new attribute values """ if not candidate_batch_size: candidate_batch_size = settings.CANDIDATE_BATCH_SIZE_FOR_USER_EMOTION_ATTRIBUTES_UPDATE # First, get the distinct upvotes by the user for the emotion. # Avoid factoring in a song more than once if there are multiple upvotes for the song distinct_votes = self.user.usersongvote_set.filter( emotion=self.emotion, vote=True).distinct('song__pk', ).values_list('pk', flat=True) # Next, get the `candidate_batch_size` most recently distinct songs upvoted for the emotion song_pks = UserSongVote.objects.filter( pk__in=distinct_votes).order_by('-created').values_list( 'song__pk', flat=True)[:candidate_batch_size] # Finally, calculate the average emotion attributes for the songs the user has most # recently upvoted for the emotion and set the UserEmotion attributes to the average values songs = Song.objects.filter(pk__in=song_pks) attributes = average(songs, 'valence', 'energy', 'danceability') self.valence = attributes['valence__avg'] self.energy = attributes['energy__avg'] self.danceability = attributes['danceability__avg'] self.save()
def test_filter_playlist_by_genre(self): new_song = MoodyUtil.create_song(genre='super-dope') MoodyUtil.create_user_song_vote(user=self.user, song=self.song, emotion=self.emotion, vote=True) MoodyUtil.create_user_song_vote(user=self.user, song=new_song, emotion=self.emotion, vote=True) data = {'emotion': self.emotion.name, 'genre': new_song.genre} resp = self.client.get(self.url, data=data) resp_data = resp.json() queryset = UserSongVote.objects.filter(user=self.user, emotion=self.emotion, vote=True, song__genre=new_song.genre) votes_for_emotion_data = average(queryset, 'song__valence', 'song__energy', 'song__danceability') valence = votes_for_emotion_data['song__valence__avg'] * 100 energy = votes_for_emotion_data['song__energy__avg'] * 100 danceability = votes_for_emotion_data['song__danceability__avg'] * 100 self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(len(resp_data['results']), 1) self.assertEqual(resp_data['results'][0]['song']['code'], new_song.code) self.assertEqual(resp_data['valence'], valence) self.assertEqual(resp_data['energy'], energy) self.assertEqual(resp_data['danceability'], danceability)
def test_upvoting_on_song_updates_user_emotion_attributes(self): user_emotion = self.user.useremotion_set.get( emotion__name=Emotion.HAPPY) new_song = MoodyUtil.create_song(energy=.75, valence=.65) data = { 'emotion': Emotion.HAPPY, 'song_code': self.song.code, 'vote': True } self.client.post(self.url, data=data, format='json') data = { 'emotion': Emotion.HAPPY, 'song_code': new_song.code, 'vote': True } self.client.post(self.url, data=data, format='json') votes = UserSongVote.objects.filter(user=self.user, vote=True) expected_attributes = average(votes, 'song__valence', 'song__energy', 'song__danceability') expected_valence = expected_attributes['song__valence__avg'] expected_energy = expected_attributes['song__energy__avg'] expected_danceability = expected_attributes['song__danceability__avg'] user_emotion.refresh_from_db() self.assertEqual(user_emotion.energy, expected_energy) self.assertEqual(user_emotion.valence, expected_valence) self.assertEqual(user_emotion.danceability, expected_danceability)
def test_empty_queryset_returns_null_values(self): collection = Song.objects.none() calculated_attrs = average(collection, 'valence', 'energy', 'danceability') self.assertIsNone(calculated_attrs['valence__avg']) self.assertIsNone(calculated_attrs['energy__avg']) self.assertIsNone(calculated_attrs['danceability__avg'])
def test_average_computes_proper_average_for_criteria(self): collection = Song.objects.all() expected_valence = .6 expected_energy = .57 expected_danceability = .62 calculated_attrs = average(collection, 'valence', 'energy', 'danceability') self.assertEqual(calculated_attrs['valence__avg'], expected_valence) self.assertEqual(calculated_attrs['energy__avg'], expected_energy) self.assertEqual(calculated_attrs['danceability__avg'], expected_danceability)
def list(self, request, *args, **kwargs): logger.info( 'Generating {} emotion playlist for user {}'.format( self.cleaned_data['emotion'], self.request.user.username, ), extra={ 'fingerprint': auto_fingerprint('generate_emotion_playlist', **kwargs), 'user_id': self.request.user.pk, 'emotion': Emotion.get_full_name_from_keyword(self.cleaned_data['emotion']), 'genre': self.cleaned_data.get('genre'), 'context': self.cleaned_data.get('context'), 'artist': self.cleaned_data.get('artist'), 'page': self.request.GET.get('page'), 'trace_id': request.trace_id, } ) resp = super().list(request, *args, **kwargs) first_page = last_page = None if resp.data['previous']: first_page = re.sub(r'&page=[0-9]*', '', resp.data['previous']) if resp.data['next']: last_page = re.sub(r'page=[0-9]*', 'page=last', resp.data['next']) queryset = self.filter_queryset(self.get_queryset()) # Update response data with analytics for emotion votes_for_emotion_data = average(queryset, 'song__valence', 'song__energy', 'song__danceability') valence = votes_for_emotion_data['song__valence__avg'] or 0 energy = votes_for_emotion_data['song__energy__avg'] or 0 danceability = votes_for_emotion_data['song__danceability__avg'] or 0 # Normalize emotion data as whole numbers normalized_valence = valence * 100 normalized_energy = energy * 100 normalized_danceability = danceability * 100 resp.data.update({ 'valence': normalized_valence, 'energy': normalized_energy, 'danceability': normalized_danceability, 'emotion_name': Emotion.get_full_name_from_keyword(self.cleaned_data['emotion']), 'first_page': first_page, 'last_page': last_page, }) return resp
def test_playlist_for_context_is_generated_with_upvoted_song_attributes_for_context( self, mock_generate_playlist): context = 'WORK' emotion = Emotion.objects.get(name=Emotion.HAPPY) song = MoodyUtil.create_song(energy=.25, valence=.50, danceability=.75) song2 = MoodyUtil.create_song(energy=.75, valence=.50, danceability=.25) song3 = MoodyUtil.create_song(energy=.50, valence=.75, danceability=.25) MoodyUtil.create_user_song_vote(self.user, song, emotion, True, context=context) MoodyUtil.create_user_song_vote(self.user, song2, emotion, True, context=context) MoodyUtil.create_user_song_vote( self.user, song3, emotion, True) # Attributes should not be factored in params = {'emotion': emotion.name, 'jitter': 0, 'context': context} self.client.get(self.url, data=params) votes = UserSongVote.objects.filter(user=self.user, emotion=emotion, context=context, vote=True) attributes_for_votes = average(votes, 'song__valence', 'song__energy', 'song__danceability') expected_valence = attributes_for_votes['song__valence__avg'] expected_energy = attributes_for_votes['song__energy__avg'] expected_danceability = attributes_for_votes['song__danceability__avg'] call_args = mock_generate_playlist.mock_calls[0][1] called_energy = call_args[0] called_valence = call_args[1] called_danceability = call_args[2] self.assertEqual(called_valence, expected_valence) self.assertEqual(called_energy, expected_energy) self.assertEqual(called_danceability, expected_danceability)
def test_set_precision_returns_average_to_desired_precision(self): collection = Song.objects.all() expected_valence = .6 expected_energy = .575 expected_danceability = .625 calculated_attrs = average(collection, 'valence', 'energy', 'danceability', precision=3) self.assertEqual(calculated_attrs['valence__avg'], expected_valence) self.assertEqual(calculated_attrs['energy__avg'], expected_energy) self.assertEqual(calculated_attrs['danceability__avg'], expected_danceability)
def test_filter_by_context(self): expected_song = MoodyUtil.create_song(name='song-with-context') context = 'WORK' MoodyUtil.create_user_song_vote(user=self.user, song=expected_song, emotion=self.emotion, vote=True, context=context) MoodyUtil.create_user_song_vote( user=self.user, song=self.song, emotion=self.emotion, vote=True, ) data = {'emotion': self.emotion.name, 'context': context} resp = self.client.get(self.url, data=data) resp_data = resp.json() queryset = UserSongVote.objects.filter(user=self.user, emotion=self.emotion, vote=True, context=context) votes_for_emotion_data = average(queryset, 'song__valence', 'song__energy', 'song__danceability') valence = votes_for_emotion_data['song__valence__avg'] * 100 energy = votes_for_emotion_data['song__energy__avg'] * 100 danceability = votes_for_emotion_data['song__danceability__avg'] * 100 self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(len(resp_data['results']), 1) self.assertEqual(resp_data['results'][0]['song']['code'], expected_song.code) self.assertEqual(resp_data['valence'], valence) self.assertEqual(resp_data['energy'], energy) self.assertEqual(resp_data['danceability'], danceability)
def test_happy_path(self): dispatch_uid = settings.UPDATE_USER_EMOTION_ATTRIBUTES_SIGNAL_UID with SignalDisconnect(post_save, update_user_emotion_attributes, UserSongVote, dispatch_uid): MoodyUtil.create_user_song_vote(self.user, self.song1, self.emotion, True) MoodyUtil.create_user_song_vote(self.user, self.song2, self.emotion, True) UpdateUserEmotionRecordAttributeTask().run(self.user.pk, self.emotion.pk) user_emotion = self.user.get_user_emotion_record(self.emotion.name) user_votes = self.user.usersongvote_set.all() expected_attributes = average(user_votes, 'song__valence', 'song__energy', 'song__danceability') expected_valence = expected_attributes['song__valence__avg'] expected_energy = expected_attributes['song__energy__avg'] expected_danceability = expected_attributes['song__danceability__avg'] self.assertEqual(user_emotion.valence, expected_valence) self.assertEqual(user_emotion.energy, expected_energy) self.assertEqual(user_emotion.danceability, expected_danceability)
def filter_queryset(self, queryset, **kwargs): cached_playlist_manager = CachedPlaylistManager(self.request.user) jitter = self.cleaned_data.get('jitter') limit = self.cleaned_data.get('limit') or self.default_limit artist = self.cleaned_data.get('artist') energy = None valence = None danceability = None strategy = random.choice(settings.BROWSE_PLAYLIST_STRATEGIES) # Should be able to supply 0 for jitter, so we'll check explicitly for None if jitter is None: jitter = self.default_jitter # Try to use upvotes for this emotion and context to generate attributes for songs to return if self.cleaned_data.get('context'): votes = self.request.user.usersongvote_set.filter( emotion__name=self.cleaned_data['emotion'], context=self.cleaned_data['context'], vote=True ) if votes.exists(): attributes_for_votes = average(votes, 'song__valence', 'song__energy', 'song__danceability') valence = attributes_for_votes['song__valence__avg'] energy = attributes_for_votes['song__energy__avg'] danceability = attributes_for_votes['song__danceability__avg'] # If context not provided or the previous query on upvotes for context did return any votes, # determine attributes from the attributes for the user and emotion if energy is None or valence is None or valence is None: user_emotion = self.request.user.get_user_emotion_record(self.cleaned_data['emotion']) # If the user doesn't have a UserEmotion record for the emotion, fall back to the # default attributes for the emotion if not user_emotion: emotion = Emotion.objects.get(name=self.cleaned_data['emotion']) energy = emotion.energy valence = emotion.valence danceability = emotion.danceability else: energy = user_emotion.energy valence = user_emotion.valence danceability = user_emotion.danceability # Try to fetch top artists for user from Spotify top_artists = None try: spotify_data = SpotifyUserData.objects.get(spotify_auth__user=self.request.user) top_artists = spotify_data.top_artists except SpotifyUserData.DoesNotExist: pass logger.info( 'Generating {} browse playlist for user {}'.format( self.cleaned_data['emotion'], self.request.user.username, ), extra={ 'fingerprint': auto_fingerprint('generate_browse_playlist', **kwargs), 'user_id': self.request.user.pk, 'emotion': Emotion.get_full_name_from_keyword(self.cleaned_data['emotion']), 'genre': self.cleaned_data.get('genre'), 'context': self.cleaned_data.get('context'), 'strategy': strategy, 'energy': energy, 'valence': valence, 'danceability': danceability, 'artist': artist, 'jitter': jitter, 'top_artists': top_artists, 'trace_id': self.request.trace_id, } ) playlist = generate_browse_playlist( energy, valence, danceability, strategy=strategy, limit=limit, jitter=jitter, artist=artist, top_artists=top_artists, songs=queryset ) cached_playlist_manager.cache_browse_playlist( playlist, self.cleaned_data['emotion'], self.cleaned_data.get('context'), self.cleaned_data.get('description') ) return playlist
def test_invalid_attribute_raises_exception(self): collection = Song.objects.all() with self.assertRaises(ValueError): average(collection, 'foo')