Skip to content

Section models

section.models

Models for managing audio files used in experiments

Our examples will assume that a playlist with the following csv data has been added to an empty database:

(
    "Lion King,Hakuna Matata,0.0,10.0,/my/experiment/lionking1.mp3,Disney,happy"
    "Lion King,Hakuna Matata,30.0,10.0,/my/experiment/lionking2.mp3,Disney,happy"
    "Frozen,Let It Go,0.0,10.0,/my/experiment/frozen1.mp3,Disney,sad"
    "Frozen,Let It Go,30.0,10.0,/my/experiment/frozen2.mp3,Disney,sad"
    "West Side Story,America,0.0,10.0,/my/experiment/westsidestory1.mp3,Musical,happy"
    "West Side Story,America,30.0,10.0,/my/experiment/westsidestory2.mp3,Musical,happy"
    "Porgy & Bess,Summertime,0.0,10.0,/my/experiment/porgyandbess1.mp3,Musical,sad"
    "Porgy & Bess,Summertime,30.0,10.0,/my/experiment/porgyandbess2.mp3,Musical,sad"
)

Playlist

Bases: Model

A model defining a list of sections to be played in a Block

Attributes:

Name Type Description
name str

playlist name

url_prefix str

prefix for sections served from an external site

process_csv bool

whether a csv file should be processed to create or edit this playlist

csv str

a csv file which can be processed to create or edit this playlist

Source code in section/models.py
 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
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
class Playlist(models.Model):
    """A model defining a list of sections to be played in a Block

    Attributes:
        name (str): playlist name
        url_prefix (str): prefix for sections served from an external site
        process_csv (bool): whether a csv file should be processed to create or edit this playlist
        csv (str): a csv file which can be processed to create or edit this playlist
    """

    name = models.CharField(db_index=True, max_length=64)
    url_prefix = models.CharField(max_length=128,
                                  blank=True,
                                  default='',
                                  validators=[url_prefix_validator])

    process_warning = 'Warning: Processing a live playlist may affect the result data'
    process_csv = models.BooleanField(default=False, help_text=process_warning)

    default_csv_row = 'CSV Format: artist_name [string],\
        song_name [string], start_position [float], duration [float],\
        "path/filename.mp3" [string], tag [string], group [string]'
    csv = models.TextField(blank=True, help_text=default_csv_row)

    CSV_OK = 0
    CSV_ERROR = 10

    def clean_csv(self):
        errors = []
        sections = Section.objects.filter(playlist=self)

        for section in sections:
            filename = str(section.filename)

            try:
                file_exists_validator(filename)
            except ValidationError as e:
                errors.append(e)

        if errors:
            raise ValidationError(errors)

        return self.csv

    def save(self, *args, **kwargs):
        if self.process_csv is False and self.id:
            self.csv = self._update_admin_csv()
        if self.url_prefix and self.url_prefix[-1] != '/':
            self.url_prefix += '/'
        self.process_csv = False
        super(Playlist, self).save(*args, **kwargs)

    class Meta:
        ordering = ['name']

    def __str__(self):
        return self.name

    def _section_count(self):
        """Number of sections, as displayed in the admin interface"""
        return self.section_set.count()

    _section_count.short_description = "Sections"

    def _block_count(self):
        """Number of Blocks"""
        return self.block_set.count()

    _block_count.short_description = "Blocks"

    def _update_sections(self):
        """update sections associated with a Playlist object based on its `csv` field"""
        # CSV empty
        if len(self.csv) == 0:
            # Delete all existing sections
            self.section_set.all().delete()
            return {
                'status': self.CSV_OK,
                'message': "No sections added. Deleted all existing sections."
            }

        # Store existing sections
        existing_sections = [section for section in self.section_set.all()]

        # Add new sections from csv
        try:
            reader = csv.DictReader(self.csv.splitlines(), fieldnames=(
                'artist', 'name', 'start_time', 'duration', 'filename', 'tag', 'group'))
        except csv.Error:
            return {
                'status': self.CSV_ERROR,
                'message': "Error: could not initialize csv.DictReader"
            }

        def is_number(string):
            try:
                float(string)
                return True
            except ValueError:
                return False

        sections = []
        updated = 0
        lines = 0
        for row in reader:
            lines += 1

            # Check for valid row length in csv. If it has less than 8 entries, csv.DictReader will assign None to values of missing keys
            if None in row.values():
                return {
                    'status': self.CSV_ERROR,
                    'message': "Error: Invalid row length, line: " + str(lines)
                }

            # check for valid numbers
            if not (is_number(row['start_time'])
                    and is_number(row['duration'])):
                return {
                    'status': self.CSV_ERROR,
                    'message': "Error: Expected number fields on line: " + str(lines)
                }

            # Retrieve or create Song object
            song = None
            if row['artist'] or row['name']:
                song = get_or_create_song(row['artist'], row['name'])

            # create new section
            section = Section(playlist=self,
                              start_time=float(row['start_time']),
                              duration=float(row['duration']),
                              filename=row['filename'],
                              tag=row['tag'],
                              group=row['group'],
                              )
            section.song = song

            # if same section already exists, update it with new info
            for ex_section in existing_sections:
                if ex_section.filename == section.filename:
                    if song:
                        ex_section.song = song
                        ex_section.save()
                    ex_section.start_time = section.start_time
                    ex_section.duration = section.duration
                    ex_section.tag = section.tag
                    ex_section.group = section.group
                    ex_section.save()
                    updated += 1

                    # Remove from existing sections list
                    existing_sections.remove(ex_section)
                    section = None
                    break

            # append section
            if section:
                sections.append(section)

        # Add sections
        Section.objects.bulk_create(sections)

        # Remove obsolete sections
        delete_ids = [ex_section.id for ex_section in existing_sections]
        self.section_set.filter(pk__in=delete_ids).delete()

        # Reset process csv option and save playlist
        self.process_csv = False
        self.save()

        return {
            'status': self.CSV_OK,
            'message': "Sections processed from CSV. Added: " + str(len(sections)) + " - Updated: " + str(updated) + " - Removed: " + str(len(delete_ids))
        }

    def _export_admin(self):
        """Export data for admin"""
        return {
            "exportedAt": timezone.now().isoformat(),
            "playlist": {
                "id": self.id,
                "name": self.name,
                "sections": [
                    section._export_admin() for section in self.section_set.all()
                ],
            },
        }

    def _export_sections(self):
        # export section objects
        return self.section_set.all()

    def _update_admin_csv(self):
        """Update csv data for admin"""
        csvfile = CsvStringBuilder()
        writer = csv.writer(csvfile)
        for section in self.section_set.all():
            if section.song:
                this_artist = section.artist_name()
                this_name = section.song_name()
            else:
                this_artist = ''
                this_name = ''
            writer.writerow([this_artist,
                            this_name,
                            section.start_time,
                            section.duration,
                            section.filename,
                            section.tag,
                            section.group])
        csv_string = csvfile.csv_string
        return ''.join(csv_string)

    def get_section(
        self, filter_by: dict = {}, exclude: dict = {}, song_ids: list = []
    ):
        """Get a random section from this playlist
        Optionally, limit to specific song_ids and filter conditions
        `filter_by` and `exclude` use [Django's querying syntax](https://docs.djangoproject.com/en/4.2/topics/db/queries/)

        Attributes:
            filter_by: a dictionary defining conditions a section should meet
            exclude: a dictionary defining conditions by which to exclude sections from selection
            song_ids: a list of identifiers of `Song` objects from which the section should be sampled

        Examples:
            >>> playlist.get_section(exclude={'group': 'Disney})
            West Side Story - America (0.0 - 10.0) OR West Side Story - America (30.0 - 40.0) OR
            Porgy and Bess - Summertime (0.0 - 10.0) OR Porgy and Bess - Summertime (30.0 - 40.0)

            >>> example_playlist.get_section({'tag': 'happy', 'start_time__gt': 20.0})
            West Side Story - America (30.0 - 40.0) OR Lion King - Hakuna Matata (30.0 - 40.0)

            >>> playlist.get_section(song_ids=[1])
            Frozen - Let It Go (0.0 - 10.0) OR Frozen - Let It Go (30.0 - 40.0)
        """
        if song_ids:
            sections = self.section_set.filter(song__id__in=song_ids)
        else:
            sections = self.section_set
        pks = (
            sections.exclude(**exclude).filter(**filter_by).values_list("pk", flat=True)
        )
        if len(pks) == 0:
            raise Section.DoesNotExist
        return self.section_set.get(pk=random.choice(pks))

get_section(filter_by={}, exclude={}, song_ids=[])

Get a random section from this playlist Optionally, limit to specific song_ids and filter conditions filter_by and exclude use Django’s querying syntax

Attributes:

Name Type Description
filter_by

a dictionary defining conditions a section should meet

exclude

a dictionary defining conditions by which to exclude sections from selection

song_ids

a list of identifiers of Song objects from which the section should be sampled

Examples:

>>> playlist.get_section(exclude={'group': 'Disney})
West Side Story - America (0.0 - 10.0) OR West Side Story - America (30.0 - 40.0) OR
Porgy and Bess - Summertime (0.0 - 10.0) OR Porgy and Bess - Summertime (30.0 - 40.0)
>>> example_playlist.get_section({'tag': 'happy', 'start_time__gt': 20.0})
West Side Story - America (30.0 - 40.0) OR Lion King - Hakuna Matata (30.0 - 40.0)
>>> playlist.get_section(song_ids=[1])
Frozen - Let It Go (0.0 - 10.0) OR Frozen - Let It Go (30.0 - 40.0)
Source code in section/models.py
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
def get_section(
    self, filter_by: dict = {}, exclude: dict = {}, song_ids: list = []
):
    """Get a random section from this playlist
    Optionally, limit to specific song_ids and filter conditions
    `filter_by` and `exclude` use [Django's querying syntax](https://docs.djangoproject.com/en/4.2/topics/db/queries/)

    Attributes:
        filter_by: a dictionary defining conditions a section should meet
        exclude: a dictionary defining conditions by which to exclude sections from selection
        song_ids: a list of identifiers of `Song` objects from which the section should be sampled

    Examples:
        >>> playlist.get_section(exclude={'group': 'Disney})
        West Side Story - America (0.0 - 10.0) OR West Side Story - America (30.0 - 40.0) OR
        Porgy and Bess - Summertime (0.0 - 10.0) OR Porgy and Bess - Summertime (30.0 - 40.0)

        >>> example_playlist.get_section({'tag': 'happy', 'start_time__gt': 20.0})
        West Side Story - America (30.0 - 40.0) OR Lion King - Hakuna Matata (30.0 - 40.0)

        >>> playlist.get_section(song_ids=[1])
        Frozen - Let It Go (0.0 - 10.0) OR Frozen - Let It Go (30.0 - 40.0)
    """
    if song_ids:
        sections = self.section_set.filter(song__id__in=song_ids)
    else:
        sections = self.section_set
    pks = (
        sections.exclude(**exclude).filter(**filter_by).values_list("pk", flat=True)
    )
    if len(pks) == 0:
        raise Section.DoesNotExist
    return self.section_set.get(pk=random.choice(pks))

Section

Bases: Model

A snippet/section of a song, belonging to a Playlist

Attributes:

Name Type Description
playlist Playlist

a Many-To-One relationship to a Playlist object

song Song

a Many-To-One relationship to a Playlist object (can be null)

start_time float

the start time of the section in seconds, typically 0.0

duration float

the duration of the section in seconds, typically the duration of the audio file

filename str

the filename on the local file system or a link to an external file

play_count int

a counter for how often a given section has been played

tag str

a string with which to categorize the section

group str

another string with which to categorize the section

Examples:

After adding the example playlist, the database would contain 8 Section objects

Source code in section/models.py
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
class Section(models.Model):
    """A snippet/section of a song, belonging to a Playlist

    Attributes:
        playlist (Playlist): a Many-To-One relationship to a Playlist object
        song (Song): a Many-To-One relationship to a Playlist object (can be null)
        start_time (float): the start time of the section in seconds, typically 0.0
        duration (float): the duration of the section in seconds, typically the duration of the audio file
        filename (str): the filename on the local file system or a link to an external file
        play_count (int): a counter for how often a given section has been played
        tag (str): a string with which to categorize the section
        group (str): another string with which to categorize the section

    Examples:
        After adding the example playlist, the database would contain 8 Section objects
    """

    playlist = models.ForeignKey(Playlist, on_delete=models.CASCADE)
    song = models.ForeignKey(Song, on_delete=models.CASCADE, blank=True, null=True)
    start_time = models.FloatField(db_index=True, default=0.0)  # start time in seconds
    duration = models.FloatField(default=0.0)  # end time in seconds
    filename = models.FileField(
        upload_to=_audio_upload_path,
        max_length=255,
        validators=[audio_file_validator()],
    )
    play_count = models.PositiveIntegerField(default=0)
    tag = models.CharField(max_length=128, default='0', blank=True)
    group = models.CharField(max_length=128, default='0', blank=True)

    class Meta:
        ordering = ['song__artist', 'song__name', 'start_time']

    def __str__(self):
        return f"{self.song_label()} ({self.start_time_str()}-{self.end_time_str()})"

    def artist_name(self, placeholder: str = "") -> str:
        """
        Attributes:
            placeholder: a placeholder in case the section does not have an associated Song

        Returns:
            artist of associated song or placeholder
        """
        if self.song:
            return self.song.artist
        else:
            return placeholder

    def song_name(self, placeholder: str = "") -> str:
        """
        Attributes:
            placeholder: a placeholder in case the section does not have an associated Song

        Returns:
            name of associated song or placeholder
        """
        if self.song:
            return self.song.name
        else:
            return placeholder

    def song_label(self) -> str:
        """
        Returns:
            formatted artist and name of associated song, if available
        """
        if self.artist_name() or self.song_name():
            return f"{self.artist_name()} - {self.song_name()}"
        return ""

    def start_time_str(self) -> str:
        """
        Returns:
            the start time in minutes:seconds.milliseconds format
        """
        return datetime.datetime.strftime(
            datetime.datetime.fromtimestamp(self.start_time), "%M:%S.%f"
        )[:-3]

    def end_time_str(self) -> str:
        """
        Returns:
            the end time in minutes:seconds.milliseconds format
        """
        return datetime.datetime.strftime(
            datetime.datetime.fromtimestamp(self.start_time + self.duration), "%M:%S.%f"
        )[:-3]

    def add_play_count(self):
        """Increase play count for this section"""
        self.play_count += 1

    def absolute_url(self) -> str:
        """
        Returns:
            a url consisting of the BASE_URL configured for Django, plus the filename
        """
        base_url = getattr(settings, 'BASE_URL', '')
        sections_url = reverse("section:section", args=[self.pk])
        return base_url.rstrip('/') + sections_url

    def _export_admin(self):
        """Export data for admin"""
        return {
            'id': self.id,
            'artist': self.song.artist,
            'name': self.song.name,
            'play_count': self.play_count
        }

    def _export_admin_csv(self):
        """Export csv data for admin"""
        return [
            self.song.artist,
            self.song.name,
            self.start_time,
            self.duration,
            self.filename,
            self.tag,
            self.group,
        ]

absolute_url()

Returns:

Type Description
str

a url consisting of the BASE_URL configured for Django, plus the filename

Source code in section/models.py
400
401
402
403
404
405
406
407
def absolute_url(self) -> str:
    """
    Returns:
        a url consisting of the BASE_URL configured for Django, plus the filename
    """
    base_url = getattr(settings, 'BASE_URL', '')
    sections_url = reverse("section:section", args=[self.pk])
    return base_url.rstrip('/') + sections_url

add_play_count()

Increase play count for this section

Source code in section/models.py
396
397
398
def add_play_count(self):
    """Increase play count for this section"""
    self.play_count += 1

artist_name(placeholder='')

Attributes:

Name Type Description
placeholder

a placeholder in case the section does not have an associated Song

Returns:

Type Description
str

artist of associated song or placeholder

Source code in section/models.py
343
344
345
346
347
348
349
350
351
352
353
354
def artist_name(self, placeholder: str = "") -> str:
    """
    Attributes:
        placeholder: a placeholder in case the section does not have an associated Song

    Returns:
        artist of associated song or placeholder
    """
    if self.song:
        return self.song.artist
    else:
        return placeholder

end_time_str()

Returns:

Type Description
str

the end time in minutes:seconds.milliseconds format

Source code in section/models.py
387
388
389
390
391
392
393
394
def end_time_str(self) -> str:
    """
    Returns:
        the end time in minutes:seconds.milliseconds format
    """
    return datetime.datetime.strftime(
        datetime.datetime.fromtimestamp(self.start_time + self.duration), "%M:%S.%f"
    )[:-3]

song_label()

Returns:

Type Description
str

formatted artist and name of associated song, if available

Source code in section/models.py
369
370
371
372
373
374
375
376
def song_label(self) -> str:
    """
    Returns:
        formatted artist and name of associated song, if available
    """
    if self.artist_name() or self.song_name():
        return f"{self.artist_name()} - {self.song_name()}"
    return ""

song_name(placeholder='')

Attributes:

Name Type Description
placeholder

a placeholder in case the section does not have an associated Song

Returns:

Type Description
str

name of associated song or placeholder

Source code in section/models.py
356
357
358
359
360
361
362
363
364
365
366
367
def song_name(self, placeholder: str = "") -> str:
    """
    Attributes:
        placeholder: a placeholder in case the section does not have an associated Song

    Returns:
        name of associated song or placeholder
    """
    if self.song:
        return self.song.name
    else:
        return placeholder

start_time_str()

Returns:

Type Description
str

the start time in minutes:seconds.milliseconds format

Source code in section/models.py
378
379
380
381
382
383
384
385
def start_time_str(self) -> str:
    """
    Returns:
        the start time in minutes:seconds.milliseconds format
    """
    return datetime.datetime.strftime(
        datetime.datetime.fromtimestamp(self.start_time), "%M:%S.%f"
    )[:-3]

Song

Bases: Model

A Song object with an artist and name, artist / name combinations must be unique

Attributes:

Name Type Description
artist str

the artist of a song

name str

the name of a song

Examples:

After adding the example playlist, the database would contain 4 Song objects

Source code in section/models.py
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
class Song(models.Model):
    """A Song object with an artist and name, artist / name combinations must be unique

    Attributes:
        artist (str): the artist of a song
        name (str): the name of a song

    Examples:
        After adding the example playlist, the database would contain 4 Song objects
    """
    artist = models.CharField(db_index=True, blank=True, default='', max_length=128)
    name = models.CharField(db_index=True, blank=True, default='', max_length=128)

    class Meta:
        unique_together = ("artist", "name")