Initial changes to Song model using Stores.

This commit is contained in:
RecursiveGreen 2019-06-03 10:59:18 -04:00
parent 7e0b2a5a45
commit 9fa3a408b1
6 changed files with 325 additions and 216 deletions

View file

@ -0,0 +1,39 @@
'''
Custom model fields for the Save Point Radio project.
'''
from django.core import validators
from django.db import models
from django.utils.translation import ugettext_lazy as _
from .forms import ALLOWED_SCHEMES, RadioIRIFormField
class RadioIRIField(models.TextField):
'''
A custom URL model field that allows schemes that match those from
Liquidsoap. This is necessary due to a bug in how Django currently
handles custom URLFields:
https://code.djangoproject.com/ticket/25594
https://stackoverflow.com/questions/41756572/
'''
default_validators = [validators.URLValidator(schemes=ALLOWED_SCHEMES)]
description = _("Long IRI")
def __init__(self, verbose_name=None, name=None, **kwargs):
# This is a limit for Internet Explorer URLs
kwargs.setdefault('max_length', 2000)
super().__init__(verbose_name, name, **kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if kwargs.get("max_length") == 2000:
del kwargs['max_length']
return name, path, args, kwargs
def formfield(self, **kwargs):
return super().formfield(**{
'form_class': RadioIRIFormField,
**kwargs,
})

View file

@ -0,0 +1,21 @@
'''
Custom forms/formfields for the Save Point Radio project.
'''
from django.core import validators
from django.forms.fields import URLField
ALLOWED_SCHEMES = ['http', 'https', 'file', 'ftp', 'ftps', 's3']
class RadioIRIFormField(URLField):
'''
A custom URL form field that allows schemes that match those from
Liquidsoap. This is necessary due to a bug in how Django currently
handles custom URLFields:
https://code.djangoproject.com/ticket/25594
https://stackoverflow.com/questions/41756572/
'''
default_validators = [validators.URLValidator(schemes=ALLOWED_SCHEMES)]

View file

@ -1,160 +1,129 @@
from decimal import * '''
Django management command to import old playlist data. This should only be used
for seeding a newly created database.
'''
import decimal
import json
import os import os
import sqlite3
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from radio.models import Album, Artist, Game, Song from core.utils import path_to_iri
from radio.models import Album, Artist, Game, Store, Song
getcontext().prec = 8 decimal.getcontext().prec = 8
def adapt_decimal(d):
return str(d)
def convert_decimal(s):
return Decimal(s.decode('utf8'))
sqlite3.register_adapter(Decimal, adapt_decimal)
sqlite3.register_converter("decimal", convert_decimal)
class Command(BaseCommand): class Command(BaseCommand):
help = 'Imports the old radio data from the original sqlite3 database' '''Main "importoldreadio" command class'''
help = 'Imports the old radio data from an exported playlist'
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument('sqlite3_db_file', nargs=1) parser.add_argument('playlist_file', nargs=1)
def handle(self, *args, **options): def handle(self, *args, **options):
if not os.path.isfile(options['sqlite3_db_file'][0]): playlist_file = options['playlist_file'][0]
if not os.path.isfile(playlist_file):
raise CommandError('File does not exist') raise CommandError('File does not exist')
else:
total_albums = 0
total_artists = 0
total_games = 0
total_songs = 0
total_jingles = 0
detect_types = sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES with open(playlist_file, 'r', encoding='utf8') as pfile:
con = sqlite3.connect(options['sqlite3_db_file'][0], playlist = json.load(pfile, parse_float=decimal.Decimal)
detect_types=detect_types)
cur = con.cursor()
# Fetching albums first totals = {
for album in con.execute('SELECT title, enabled FROM albums'): 'albums': 0,
album_disabled = not bool(album[1]) 'artists': 0,
Album.objects.create(title=album[0], disabled=album_disabled) 'games': 0,
total_albums += 1 'songs': 0,
'jingles': 0
}
self.stdout.write('Imported {} albums'.format(str(total_albums))) # Fetching albums first
for album in playlist['albums']:
Album.objects.create(title=album['title'],
disabled=album['disabled'])
total_albums += 1
# Next up, artists self.stdout.write('Imported {} albums'.format(str(totals['albums'])))
cur.execute('''SELECT
artists_id,
alias,
firstname,
lastname,
enabled
FROM artists''')
artists = cur.fetchall()
for artist in artists: # Next up, artists
artist_disabled = not bool(artist[4]) for artist in playlist['artists']:
Artist.objects.create(alias=artist[1] or '', Artist.objects.create(alias=artist['alias'] or '',
first_name=artist[2] or '', first_name=artist['first_name'] or '',
last_name=artist[3] or '', last_name=artist['last_name'] or '',
disabled=artist_disabled) disabled=artist['disabled'])
total_artists += 1 total_artists += 1
self.stdout.write('Imported {} artists'.format(str(total_artists))) self.stdout.write('Imported {} artists'.format(str(totals['artists'])))
# On to games # On to games
for game in con.execute('SELECT title, enabled FROM games'): for game in playlist['games']:
game_disabled = not bool(game[1]) Game.objects.create(title=game['title'],
Game.objects.create(title=game[0], disabled=game_disabled) disabled=game['disabled'])
total_games += 1 total_games += 1
self.stdout.write('Imported {} games'.format(str(total_games))) self.stdout.write('Imported {} games'.format(str(totals['games'])))
# Followed by the songs # Followed by the songs
cur.execute('''SELECT for song in playlist['songs']:
songs.songs_id AS id, try:
games.title AS game, album = Album.objects.get(title__exact=song['album'])
albums.title AS album, except Album.DoesNotExist:
songs.enabled AS enabled, album = None
songs.type AS type,
songs.title AS title,
songs.length AS length,
songs.path AS path
FROM songs
LEFT JOIN games
ON (songs.game = games.games_id)
LEFT JOIN albums
ON (songs.album = albums.albums_id)''')
songs = cur.fetchall()
for song in songs: try:
try: game = Game.objects.get(title__exact=song['game'])
album = Album.objects.get(title__exact=song[2]) except Game.DoesNotExist:
except Album.DoesNotExist: game = None
album = None
try: new_song = Song.objects.create(album=album,
game = Game.objects.get(title__exact=song[1]) game=game,
except Game.DoesNotExist: disabled=song['disabled'],
game = None song_type=song['type'],
title=song['title'])
song_disabled = not bool(song[3]) for artist in song['artists']:
new_song = Song.objects.create(album=album, new_artist = Artist.objects.get(
game=game, alias__exact=artist['alias'] or '',
disabled=song_disabled, first_name__exact=artist['first_name'] or '',
song_type=song[4], last_name__exact=artist['last_name'] or ''
title=song[5],
length=song[6],
path=song[7])
if song[4] == 'S':
total_songs += 1
else:
total_jingles += 1
cur.execute('''SELECT
ifnull(alias, "") AS alias,
ifnull(firstname, "") AS firstname,
ifnull(lastname, "") AS lastname
FROM artists
WHERE artists_id
IN (SELECT artists_artists_id
FROM songs_have_artists
WHERE songs_songs_id = ?)''', [song[0]])
old_artists = cur.fetchall()
for old_artist in old_artists:
new_artist = Artist.objects.get(alias__exact=old_artist[0],
first_name__exact=old_artist[1],
last_name__exact=old_artist[2])
new_song.artists.add(new_artist)
self.stdout.write(
'Imported {} requestables ({} songs, {} jingles)'.format(
str(total_songs + total_jingles),
str(total_songs),
str(total_jingles)
) )
new_song.artists.add(new_artist)
new_store = Store.objects.create(
iri=path_to_iri(song['store']['path']),
mime_type=song['store']['mime'],
file_size=song['store']['filesize'],
length=song['store']['length']
) )
new_song.stores.add(new_store)
pub = input('Do you want to publish all imported objects as well? ' new_song.current_store = new_store
'[Y/N] ') new_song.save()
if song['type'] == 'S':
if pub == 'Y' or pub == 'y': totals['songs'] += 1
for al in Album.objects.all():
al.publish()
for ar in Artist.objects.all():
ar.publish()
for g in Game.objects.all():
g.publish()
for s in Song.objects.all():
s.publish()
self.stdout.write('Published imported objects successfully')
else: else:
self.stdout.write('Skipped publishing songs') totals['jingles'] += 1
self.stdout.write(
'Imported {} requestables ({} songs, {} jingles)'.format(
str(totals['songs'] + totals['jingles']),
str(totals['songs']),
str(totals['jingles'])
)
)
pub = input('Do you want to publish all imported objects as well? '
'[Y/N] ')
if pub in ('Y', 'y'):
for album in Album.objects.all():
album.publish()
for artist in Artist.objects.all():
artist.publish()
for game in Game.objects.all():
game.publish()
for song in Song.objects.all():
song.publish()
self.stdout.write('Published imported objects successfully')
else:
self.stdout.write('Skipped publishing songs')

View file

@ -1,3 +1,7 @@
'''
Django Model Managers for the Radio application.
'''
from datetime import timedelta from datetime import timedelta
from decimal import getcontext, Decimal, ROUND_UP from decimal import getcontext, Decimal, ROUND_UP
from random import randint from random import randint
@ -15,123 +19,126 @@ getcontext().prec = 16
class RadioManager(models.Manager): class RadioManager(models.Manager):
""" '''
Custom object manager for filtering out common behaviors for radio Custom object manager for filtering out common behaviors for radio
objects. objects.
""" '''
def get_queryset(self): def get_queryset(self):
""" '''
Return customized default QuerySet. Return customized default QuerySet.
""" '''
return RadioQuerySet(self.model, using=self._db) return RadioQuerySet(self.model, using=self._db)
def disabled(self): def disabled(self):
""" '''
Radio objects that are marked as disabled. Radio objects that are marked as disabled.
""" '''
return self.get_queryset().disabled() return self.get_queryset().disabled()
def enabled(self): def enabled(self):
""" '''
Radio objects that are marked as enabled. Radio objects that are marked as enabled.
""" '''
return self.get_queryset().enabled() return self.get_queryset().enabled()
def published(self): def published(self):
""" '''
Radio objects that are marked as published. Radio objects that are marked as published.
""" '''
return self.get_queryset().published() return self.get_queryset().published()
def unpublished(self): def unpublished(self):
""" '''
Radio objects that are marked as unpublished. Radio objects that are marked as unpublished.
""" '''
return self.get_queryset().unpublished() return self.get_queryset().unpublished()
def available(self): def available(self):
""" '''
Radio objects that are enabled and published. Radio objects that are enabled and published.
""" '''
return self.enabled().published() return self.enabled().published()
class SongManager(RadioManager): class SongManager(RadioManager):
""" '''
Custom object manager for filtering out common behaviors for Song objects. Custom object manager for filtering out common behaviors for Song objects.
""" '''
def get_queryset(self): def get_queryset(self):
""" '''
Return customized default QuerySet for Songs. Return customized default QuerySet for Songs.
""" '''
return SongQuerySet(self.model, using=self._db) return SongQuerySet(self.model, using=self._db)
def available_jingles(self): def available_jingles(self):
""" '''
Jingles that are currently published and are enabled. Jingles that are currently published and are enabled.
""" '''
return self.available().jingles() return self.available().jingles()
def available_songs(self): def available_songs(self):
""" '''
Songs that are currently published and are enabled. Songs that are currently published and are enabled.
""" '''
return self.available().songs() return self.available().songs()
def playlist_length(self): def playlist_length(self):
""" '''
Total length of available songs in the playlist (in seconds). Total length of available songs in the playlist (in seconds).
""" '''
length = self.available_songs().aggregate(models.Sum('length')) a_songs = self.available_songs()
return length['length__sum'] length = a_songs.aggregate(
total_time=models.Sum('current_store__length')
)
return length['total_time']
def wait_total(self, adjusted_ratio=0.0): def wait_total(self, adjusted_ratio=0.0):
""" '''
Default length in seconds before a song can be played again. This is Default length in seconds before a song can be played again. This is
based on the replay ratio set in the application settings. based on the replay ratio set in the application settings.
""" '''
total_ratio = get_setting('replay_ratio') + adjusted_ratio total_ratio = get_setting('replay_ratio') + adjusted_ratio
wait = self.playlist_length() * Decimal(total_ratio) wait = self.playlist_length() * Decimal(total_ratio)
wait = wait.quantize(Decimal('.01'), rounding=ROUND_UP) wait = wait.quantize(Decimal('.01'), rounding=ROUND_UP)
return timedelta(seconds=float(wait)) return timedelta(seconds=float(wait))
def datetime_from_wait(self): def datetime_from_wait(self):
""" '''
Datetime of now minus the default wait time for played songs. Datetime of now minus the default wait time for played songs.
""" '''
return timezone.now() - self.wait_total() return timezone.now() - self.wait_total()
def playable(self): def playable(self):
""" '''
Songs that are playable because they are available (enabled & Songs that are playable because they are available (enabled &
published) and they have not been played within the default wait time published) and they have not been played within the default wait time
(or at all). (or at all).
""" '''
return self.available_songs().filter( return self.available_songs().filter(
models.Q(next_play__lt=timezone.now()) | models.Q(next_play__lt=timezone.now()) |
models.Q(next_play__isnull=True) models.Q(next_play__isnull=True)
) )
def requestable(self): def requestable(self):
""" '''
Songs that can be placed in the request queue for playback. Songs that can be placed in the request queue for playback.
""" '''
# Import SongRequest here to get rid of circular dependencies # Import SongRequest here to get rid of circular dependencies
SongRequest = apps.get_model(app_label='profiles', song_request = apps.get_model(app_label='profiles',
model_name='SongRequest') model_name='SongRequest')
requests = SongRequest.music.unplayed().values_list('song__id', requests = song_request.music.unplayed().values_list('song__id',
flat=True) flat=True)
return self.playable().exclude(id__in=requests) return self.playable().exclude(id__in=requests)
def get_random_requestable_song(self): def get_random_requestable_song(self):
""" '''
Pick a random requestable song and return it. Pick a random requestable song and return it.
""" '''
return self.requestable()[randint(0, self.requestable().count() - 1)] return self.requestable()[randint(0, self.requestable().count() - 1)]
def get_random_jingle(self): def get_random_jingle(self):
""" '''
Pick a random jingle and return it. Pick a random jingle and return it.
""" '''
random_index = randint(0, self.available_jingles().count() - 1) random_index = randint(0, self.available_jingles().count() - 1)
return self.available_jingles()[random_index] return self.available_jingles()[random_index]

View file

@ -0,0 +1,49 @@
# Generated by Django 2.2.1 on 2019-05-31 03:00
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import radio.fields
class Migration(migrations.Migration):
dependencies = [
('radio', '0003_song_next_play'),
]
operations = [
migrations.CreateModel(
name='Store',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(auto_now_add=True, verbose_name='added on')),
('modified_date', models.DateTimeField(auto_now=True, verbose_name='last modified')),
('iri', radio.fields.RadioIRIField()),
('mime_type', models.CharField(blank=True, max_length=64, verbose_name='file MIME type')),
('file_size', models.BigIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='file size')),
('length', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True, verbose_name='song length (in seconds)')),
],
options={
'abstract': False,
},
),
migrations.RemoveField(
model_name='song',
name='length',
),
migrations.RemoveField(
model_name='song',
name='path',
),
migrations.AddField(
model_name='song',
name='current_store',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='current_of', to='radio.Store'),
),
migrations.AddField(
model_name='song',
name='stores',
field=models.ManyToManyField(blank=True, related_name='song', to='radio.Store'),
),
]

View file

@ -1,13 +1,19 @@
'''
Django Models for the Radio application.
'''
from datetime import timedelta from datetime import timedelta
from decimal import getcontext, Decimal, ROUND_UP from decimal import getcontext, Decimal, ROUND_UP
from django.apps import apps from django.apps import apps
from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from core.behaviors import Disableable, Publishable, Timestampable from core.behaviors import Disableable, Publishable, Timestampable
from core.utils import get_setting from core.utils import get_setting
from .fields import RadioIRIField
from .managers import RadioManager, SongManager from .managers import RadioManager, SongManager
@ -16,9 +22,9 @@ getcontext().prec = 16
class Album(Disableable, Publishable, Timestampable, models.Model): class Album(Disableable, Publishable, Timestampable, models.Model):
""" '''
A model for a music album. A model for a music album.
""" '''
title = models.CharField(_('title'), max_length=255, unique=True) title = models.CharField(_('title'), max_length=255, unique=True)
sorted_title = models.CharField(_('naturalized title'), sorted_title = models.CharField(_('naturalized title'),
@ -37,9 +43,9 @@ class Album(Disableable, Publishable, Timestampable, models.Model):
class Artist(Disableable, Publishable, Timestampable, models.Model): class Artist(Disableable, Publishable, Timestampable, models.Model):
""" '''
A model for a music artist. A model for a music artist.
""" '''
alias = models.CharField(_('alias'), max_length=127, blank=True) alias = models.CharField(_('alias'), max_length=127, blank=True)
first_name = models.CharField(_('first name'), max_length=127, blank=True) first_name = models.CharField(_('first name'), max_length=127, blank=True)
last_name = models.CharField(_('last name'), max_length=127, blank=True) last_name = models.CharField(_('last name'), max_length=127, blank=True)
@ -57,10 +63,10 @@ class Artist(Disableable, Publishable, Timestampable, models.Model):
@property @property
def full_name(self): def full_name(self):
""" '''
String representing the artist's full name including an alias, if String representing the artist's full name including an alias, if
available. available.
""" '''
if self.alias: if self.alias:
if self.first_name or self.last_name: if self.first_name or self.last_name:
return '{} "{}" {}'.format(self.first_name, return '{} "{}" {}'.format(self.first_name,
@ -74,9 +80,9 @@ class Artist(Disableable, Publishable, Timestampable, models.Model):
class Game(Disableable, Publishable, Timestampable, models.Model): class Game(Disableable, Publishable, Timestampable, models.Model):
""" '''
A model for a game. A model for a game.
""" '''
title = models.CharField(_('title'), max_length=255, unique=True) title = models.CharField(_('title'), max_length=255, unique=True)
sorted_title = models.CharField(_('naturalized title'), sorted_title = models.CharField(_('naturalized title'),
@ -94,10 +100,29 @@ class Game(Disableable, Publishable, Timestampable, models.Model):
return self.title return self.title
class Store(Timestampable, models.Model):
'''
A model to represent various data locations (stores) for the song.
'''
iri = RadioIRIField()
mime_type = models.CharField(_('file MIME type'),
max_length=64,
blank=True)
file_size = models.BigIntegerField(_('file size'),
validators=[MinValueValidator(0)],
blank=True,
null=True)
length = models.DecimalField(_('song length (in seconds)'),
max_digits=8,
decimal_places=2,
null=True,
blank=True)
class Song(Disableable, Publishable, Timestampable, models.Model): class Song(Disableable, Publishable, Timestampable, models.Model):
""" '''
A model for a song. A model for a song.
""" '''
JINGLE = 'J' JINGLE = 'J'
SONG = 'S' SONG = 'S'
TYPE_CHOICES = ( TYPE_CHOICES = (
@ -128,13 +153,12 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
null=True, null=True,
blank=True, blank=True,
editable=False) editable=False)
length = models.DecimalField(_('song length (in seconds)'), stores = models.ManyToManyField(Store, blank=True, related_name='song')
max_digits=8, current_store = models.ForeignKey(Store,
decimal_places=2, on_delete=models.SET_NULL,
null=True, null=True,
blank=True) blank=True,
path = models.TextField(_('absolute path to song file')) related_name='current_of')
sorted_title = models.CharField(_('naturalized title'), sorted_title = models.CharField(_('naturalized title'),
db_index=True, db_index=True,
editable=False, editable=False,
@ -147,34 +171,34 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
ordering = ['sorted_title', ] ordering = ['sorted_title', ]
def _is_jingle(self): def _is_jingle(self):
""" '''
Is the object a jingle? Is the object a jingle?
""" '''
return self.song_type == 'J' return self.song_type == 'J'
_is_jingle.boolean = True _is_jingle.boolean = True
is_jingle = property(_is_jingle) is_jingle = property(_is_jingle)
def _is_song(self): def _is_song(self):
""" '''
Is the object a song? Is the object a song?
""" '''
return self.song_type == 'S' return self.song_type == 'S'
_is_song.boolean = True _is_song.boolean = True
is_song = property(_is_song) is_song = property(_is_song)
def _is_available(self): def _is_available(self):
""" '''
Is the object both enabled and published? Is the object both enabled and published?
""" '''
return self._is_enabled() and self._is_published() return self._is_enabled() and self._is_published()
_is_available.boolean = True _is_available.boolean = True
is_available = property(_is_available) is_available = property(_is_available)
def _full_title(self): def _full_title(self):
""" '''
String representing the entire song title, including the game and String representing the entire song title, including the game and
artists involved. artists involved.
""" '''
if self._is_song(): if self._is_song():
enabled_artists = self.artists.all().filter(disabled=False) enabled_artists = self.artists.all().filter(disabled=False)
all_artists = ', '.join([a.full_name for a in enabled_artists]) all_artists = ', '.join([a.full_name for a in enabled_artists])
@ -185,9 +209,9 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
full_title = property(_full_title) full_title = property(_full_title)
def _average_rating(self): def _average_rating(self):
""" '''
Decimal number of the average rating of a song from 1 - 5. Decimal number of the average rating of a song from 1 - 5.
""" '''
ratings = self.rating_set.all() ratings = self.rating_set.all()
if ratings: if ratings:
avg = Decimal(ratings.aggregate(avg=models.Avg('value'))['avg']) avg = Decimal(ratings.aggregate(avg=models.Avg('value'))['avg'])
@ -196,9 +220,9 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
average_rating = property(_average_rating) average_rating = property(_average_rating)
def get_time_until_requestable(self): def get_time_until_requestable(self):
""" '''
Length of time before a song can be requested again. Length of time before a song can be requested again.
""" '''
if self._is_song() and self._is_available(): if self._is_song() and self._is_available():
if self.last_played: if self.last_played:
allowed_datetime = Song.music.datetime_from_wait() allowed_datetime = Song.music.datetime_from_wait()
@ -209,9 +233,9 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
return None return None
def get_date_when_requestable(self, last_play=None): def get_date_when_requestable(self, last_play=None):
""" '''
Datetime when a song can be requested again. Datetime when a song can be requested again.
""" '''
last = self.last_played if last_play is None else last_play last = self.last_played if last_play is None else last_play
if self._is_song() and self._is_available(): if self._is_song() and self._is_available():
@ -232,10 +256,10 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
return None return None
def _is_playable(self): def _is_playable(self):
""" '''
Is the song available and not been played within the default waiting Is the song available and not been played within the default waiting
period (or at all)? period (or at all)?
""" '''
if self._is_song() and self._is_available(): if self._is_song() and self._is_available():
return self.get_date_when_requestable() <= timezone.now() return self.get_date_when_requestable() <= timezone.now()
return False return False
@ -243,14 +267,14 @@ class Song(Disableable, Publishable, Timestampable, models.Model):
is_playable = property(_is_playable) is_playable = property(_is_playable)
def _is_requestable(self): def _is_requestable(self):
""" '''
Is the song playable and has it not already been requested? Is the song playable and has it not already been requested?
""" '''
if self._is_playable(): if self._is_playable():
SongRequest = apps.get_model(app_label='profiles', song_request = apps.get_model(app_label='profiles',
model_name='SongRequest') model_name='SongRequest')
requests = SongRequest.music.unplayed().values_list('song__id', requests = song_request.music.unplayed().values_list('song__id',
flat=True) flat=True)
return self.pk not in requests return self.pk not in requests
return False return False
_is_requestable.boolean = True _is_requestable.boolean = True