Пример #1
0
class Migration(migrations.Migration):
    initial = True

    dependencies = [
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
        ('reader', '0001_squashed'),
        ('socialaccount', '0003_extra_data_default_dict'),
    ]

    operations = [
        migrations.CreateModel(
            name='Bookmark',
            fields=[
                ('id',
                 models.AutoField(auto_created=True,
                                  primary_key=True,
                                  serialize=False,
                                  verbose_name='ID')),
                ('series',
                 models.ForeignKey(on_delete=models.deletion.CASCADE,
                                   to='reader.Series')),
                ('user',
                 models.ForeignKey(on_delete=models.deletion.CASCADE,
                                   related_name='bookmarks',
                                   to=settings.AUTH_USER_MODEL)),
            ],
        ),
        migrations.CreateModel(
            name='UserProfile',
            fields=[
                ('id',
                 models.AutoField(auto_created=True,
                                  primary_key=True,
                                  serialize=False,
                                  verbose_name='ID')),
                ('bio',
                 models.TextField(blank=True,
                                  verbose_name='biography',
                                  help_text="The user's biography.")),
                ('avatar',
                 models.ImageField(
                     blank=True,
                     help_text=(
                         "The user's avatar image. Must be up to 2 MBs."),
                     upload_to=_avatar_uploader,
                     storage=storage.CDNStorage((150, 150)),
                     validators=(validators.FileSizeValidator(2), ))),
                ('user',
                 models.OneToOneField(on_delete=models.deletion.CASCADE,
                                      related_name='profile',
                                      to=settings.AUTH_USER_MODEL)),
            ],
        ),
        migrations.AlterUniqueTogether(
            name='bookmark',
            unique_together={('series', 'user')},
        ),
    ]
Пример #2
0
class UserProfile(models.Model):
    """
    A model representing a user's profile.

    .. admonition:: TODO
       :class: warning

       Add links and let users hide their e-mail.
    """
    #: The user this profile belongs to.
    user = models.OneToOneField(User,
                                on_delete=models.CASCADE,
                                related_name='profile')
    #: The bio of the user.
    bio = models.TextField(blank=True,
                           verbose_name='biography',
                           help_text="The user's biography.")
    #: The avatar of the user.
    avatar = models.ImageField(
        help_text="The user's avatar image. Must be up to 2 MBs.",
        validators=(validators.FileSizeValidator(2), ),
        storage=storage.CDNStorage((150, 150)),
        upload_to=_avatar_uploader,
        blank=True)
    #: The token of the user. Used in the bookmarks feed.
    token = models.CharField(auto_created=True,
                             max_length=32,
                             unique=True,
                             editable=False)

    def save(self, *args, **kwargs):
        """Save the current instance."""
        data = f'{self.user.username}:{self.user.password}'
        self.token = blake2b(data.encode(),
                             digest_size=16,
                             key=settings.SECRET_KEY.encode()).hexdigest()
        super(UserProfile, self).save(*args, **kwargs)

    def get_directory(self) -> PurePath:
        """
        Get the storage directory of the object.

        :return: A path relative to
                 :const:`~MangAdventure.settings.MEDIA_ROOT`.
        """
        return PurePath('users', str(self.id))

    def __str__(self) -> str:
        """
        Return a string representing the object.

        :return: The user as a string.
        """
        return str(self.user)

    def __hash__(self) -> int:
        """
        Return the hash of the object.

        :return: An integer hash value.
        """
        return int(self.token, 16) & 0x7FFFFFFF
Пример #3
0
class Migration(migrations.Migration):

    initial = True

    dependencies = [
        ('groups', '0001_initial'),
    ]

    replaces = [
        ('reader', '0001_initial'),
        ('reader', '0002_reader_dates'),
        ('reader', '0003_chapter_groups'),
        ('reader', '0004_float_numbers'),
        ('reader', '0005_categories'),
        ('reader', '0006_remove_chapter_url'),
        ('reader', '0007_editable_slugs'),
        ('reader', '0008_add_indexes'),
        ('reader', '0009_cdn_storage'),
    ]

    operations = [
        migrations.CreateModel(
            name='Artist',
            fields=[
                ('id', models.AutoField(
                    auto_created=True, primary_key=True,
                    serialize=False, verbose_name='ID'
                )),
                ('name', models.CharField(
                    help_text="The artist's full name.",
                    max_length=100, db_index=True
                )),
            ],
        ),
        migrations.CreateModel(
            name='ArtistAlias',
            fields=[
                ('id', models.AutoField(
                    auto_created=True, primary_key=True,
                    serialize=False, verbose_name='ID'
                )),
                ('alias', models.CharField(
                    blank=True, help_text='Another name for the artist.',
                    max_length=100, unique=True, db_index=True
                )),
                ('artist', models.ForeignKey(
                    on_delete=models.deletion.CASCADE,
                    related_name='aliases', to='reader.Artist'
                )),
            ],
            options={
                'verbose_name': 'alias',
                'verbose_name_plural': 'aliases',
                'abstract': False,
            },
        ),
        migrations.CreateModel(
            name='Author',
            fields=[
                ('id', models.AutoField(
                    auto_created=True, primary_key=True,
                    serialize=False, verbose_name='ID'
                )),
                ('name', models.CharField(
                    help_text="The author's full name.",
                    max_length=100, db_index=True
                )),
            ],
        ),
        migrations.CreateModel(
            name='AuthorAlias',
            fields=[
                ('id', models.AutoField(
                    auto_created=True, primary_key=True,
                    serialize=False, verbose_name='ID'
                )),
                ('alias', models.CharField(
                    blank=True, help_text='Another name for the author.',
                    max_length=100, unique=True, db_index=True
                )),
                ('author', models.ForeignKey(
                    on_delete=models.deletion.CASCADE,
                    related_name='aliases', to='reader.Author'
                )),
            ],
            options={
                'verbose_name': 'alias',
                'verbose_name_plural': 'aliases',
                'abstract': False,
            },
        ),
        migrations.CreateModel(
            name='Chapter',
            fields=[
                ('id', models.AutoField(
                    auto_created=True, primary_key=True,
                    serialize=False, verbose_name='ID'
                )),
                ('title', models.CharField(
                    help_text='The title of the chapter.', max_length=250
                )),
                ('number', models.FloatField(
                    default=0, help_text='The number of the chapter.'
                )),
                ('volume', models.PositiveSmallIntegerField(
                    default=0, help_text=(
                        'The volume of the chapter. Leave '
                        'as 0 if the series has no volumes.'
                    )
                )),
                ('file', models.FileField(
                    blank=True, help_text=(
                        'Upload a zip or cbz file containing the '
                        'chapter pages. Its size cannot exceed 50 MBs '
                        'and it must not contain more than 1 subfolder.'
                    ), upload_to='', validators=(
                        validators.FileSizeValidator(50),
                        validators.zipfile_validator
                    )
                )),
                ('final', models.BooleanField(
                    default=False, help_text='Is this the final chapter?'
                )),
                ('modified', models.DateTimeField(
                    default=datetime.now(tz=timezone.utc)
                )),
                ('uploaded', models.DateTimeField(
                    default=datetime.now(tz=timezone.utc)
                )),
            ],
            options={
                'get_latest_by': ('uploaded', 'modified'),
                'ordering': ('series', 'volume', 'number'),
            },
        ),
        migrations.CreateModel(
            name='Page',
            fields=[
                ('id', models.AutoField(
                    auto_created=True, primary_key=True,
                    serialize=False, verbose_name='ID'
                )),
                ('image', models.ImageField(
                    storage=storage.CDNStorage(),
                    upload_to='', max_length=255
                )),
                ('number', _PageNumberField()),
                ('chapter', models.ForeignKey(
                    on_delete=models.deletion.CASCADE,
                    related_name='pages', to='reader.Chapter'
                )),
            ],
            options={
                'ordering': ('chapter', 'number')
            },
        ),
        migrations.CreateModel(
            name='Series',
            fields=[
                ('id', models.AutoField(
                    auto_created=True, primary_key=True,
                    serialize=False, verbose_name='ID'
                )),
                ('title', models.CharField(
                    help_text='The title of the series.',
                    max_length=250, db_index=True
                )),
                ('description', models.TextField(
                    blank=True, help_text='The description of the series.'
                )),
                ('cover', models.ImageField(
                    help_text=(
                        'Upload a cover image for the series.'
                        ' Its size must not exceed 2 MBs.'
                    ), upload_to=_cover_uploader,
                    storage=storage.CDNStorage((300, 300)),
                    validators=(validators.FileSizeValidator(2),)
                )),
                ('slug', models.SlugField(
                    primary_key=False, unique=True,
                    blank=True, help_text=(
                        'The unique slug of the series.'
                        ' Will be used in the URL.'
                    ), verbose_name='Custom slug',
                )),
                ('completed', models.BooleanField(
                    default=False, help_text='Is the series completed?'
                )),
                ('artists', models.ManyToManyField(
                    blank=True, to='reader.Artist'
                )),
                ('authors', models.ManyToManyField(
                    blank=True, to='reader.Author'
                )),
                ('modified', models.DateTimeField(
                    default=datetime.now(tz=timezone.utc)
                )),
            ],
            options={
                'get_latest_by': 'modified',
                'verbose_name_plural': 'series',
            },
        ),
        migrations.CreateModel(
            name='SeriesAlias',
            fields=[
                ('id', models.AutoField(
                    auto_created=True, primary_key=True,
                    serialize=False, verbose_name='ID'
                )),
                ('alias', models.CharField(
                    blank=True, help_text='Another title for the series.',
                    max_length=250, unique=True, db_index=True
                )),
                ('series', models.ForeignKey(
                    on_delete=models.deletion.CASCADE,
                    related_name='aliases', to='reader.Series'
                )),
            ],
            options={
                'verbose_name': 'alias',
                'verbose_name_plural': 'aliases',
                'abstract': False,
            },
        ),
        migrations.CreateModel(
            name='Category',
            fields=[
                ('id', models.CharField(
                    auto_created=True, primary_key=True,
                    max_length=25, serialize=False, default=''
                )),
                ('name', models.CharField(
                    max_length=25, help_text=(
                        'The name of the category. Must be '
                        'unique and cannot be changed once set.'
                    ), unique=True
                )),
                ('description', models.TextField(
                    help_text='A description for the category.'
                )),
            ],
            options={'verbose_name_plural': 'categories'},
        ),
        migrations.AddField(
            model_name='chapter',
            name='series',
            field=models.ForeignKey(
                help_text='The series this chapter belongs to.',
                on_delete=models.deletion.CASCADE,
                related_name='chapters', to='reader.Series'
            ),
        ),
        migrations.AddField(
            model_name='chapter',
            name='groups',
            field=models.ManyToManyField(
                blank=True, related_name='releases', to='groups.Group'
            ),
        ),
        migrations.AddField(
            model_name='series',
            name='categories',
            field=models.ManyToManyField(
                blank=True, to='reader.Category'
            ),
        ),
        migrations.AlterField(
            model_name='chapter',
            name='modified',
            field=models.DateTimeField(auto_now=True),
        ),
        migrations.AlterField(
            model_name='chapter',
            name='uploaded',
            field=models.DateTimeField(auto_now_add=True, db_index=True),
        ),
        migrations.AlterField(
            model_name='series',
            name='modified',
            field=models.DateTimeField(auto_now=True),
        ),
        migrations.AlterUniqueTogether(
            name='chapter',
            unique_together={('series', 'volume', 'number')},
        ),
    ]
Пример #4
0
class Migration(migrations.Migration):

    initial = True

    dependencies = []

    # TODO: remove squashed migrations after application
    replaces = [
        ('groups', '0001_initial'),
        ('groups', '0002_irc_reddit'),
        ('groups', '0003_cdn_storage'),
    ]

    operations = [
        migrations.CreateModel(
            name='Group',
            fields=[
                ('id',
                 models.SmallIntegerField(auto_created=True,
                                          primary_key=True,
                                          serialize=False)),
                ('name',
                 models.CharField(help_text="The group's name.",
                                  max_length=100)),
                ('website',
                 models.URLField(blank=True,
                                 help_text="The group's website.")),
                ('email',
                 models.EmailField(blank=True,
                                   max_length=254,
                                   help_text="The group's E-mail address.")),
                ('discord',
                 fields.DiscordURLField(
                     blank=True, help_text="The group's Discord link.")),
                ('twitter',
                 fields.TwitterField(
                     blank=True,
                     max_length=15,
                     help_text="The group's Twitter username.")),
                ('description',
                 models.TextField(blank=True,
                                  help_text='A description for the group.')),
                ('logo',
                 models.ImageField(
                     blank=True,
                     help_text=("Upload the group's logo. "
                                "Its size must not exceed 2 MBs."),
                     upload_to=_logo_uploader,
                     storage=storage.CDNStorage((150, 150)),
                     validators=(validators.FileSizeValidator(2), ))),
                ('irc',
                 models.CharField(blank=True,
                                  max_length=63,
                                  help_text="The group's IRC.")),
                (('reddit',
                  fields.RedditField(
                      blank=True,
                      help_text=("The group's Reddit username or "
                                 "subreddit. (Include /u/ or /r/)"),
                      max_length=24)))
            ],
        ),
        migrations.CreateModel(
            name='Member',
            fields=[('id',
                     models.AutoField(auto_created=True,
                                      primary_key=True,
                                      serialize=False,
                                      verbose_name='ID')),
                    ('name',
                     models.CharField(help_text="The member's name.",
                                      max_length=100)),
                    ('twitter',
                     fields.TwitterField(
                         blank=True,
                         max_length=15,
                         help_text="The member's Twitter username.")),
                    ('discord',
                     fields.DiscordNameField(
                         blank=True,
                         max_length=37,
                         help_text=
                         "The member's Discord username and discriminator.")),
                    ('irc',
                     models.CharField(blank=True,
                                      max_length=63,
                                      help_text="The member's IRC username.")),
                    (('reddit',
                      fields.RedditField(
                          blank=True,
                          max_length=21,
                          help_text="The member's Reddit username.")))],
        ),
        migrations.CreateModel(
            name='Role',
            fields=[
                ('id',
                 models.AutoField(auto_created=True,
                                  primary_key=True,
                                  serialize=False,
                                  verbose_name='ID')),
                ('role',
                 models.CharField(
                     choices=(('LD', 'Leader'), ('TL', 'Translator'),
                              ('PR', 'Proofreader'), ('CL', 'Cleaner'),
                              ('RD', 'Redrawer'), ('TS', 'Typesetter'),
                              ('RP', 'Raw Provider'), ('QC',
                                                       'Quality Checker')),
                     max_length=2)),
                ('group',
                 models.ForeignKey(on_delete=models.deletion.CASCADE,
                                   related_name='roles',
                                   to='groups.Group')),
                ('member',
                 models.ForeignKey(on_delete=models.deletion.CASCADE,
                                   related_name='roles',
                                   to='groups.Member')),
            ],
            options={
                'verbose_name': 'Role',
            },
        ),
        migrations.AddField(model_name='member',
                            name='groups',
                            field=models.ManyToManyField(
                                blank=False,
                                related_name='members',
                                through='groups.Role',
                                to='groups.Group')),
        migrations.AlterUniqueTogether(
            name='role',
            unique_together={('member', 'role', 'group')},
        ),
    ]
Пример #5
0
class Chapter(models.Model):
    """A model representing a chapter."""
    #: The title of the chapter.
    title = models.CharField(max_length=250,
                             help_text='The title of the chapter.')
    #: The number of the chapter.
    number = models.FloatField(default=0,
                               help_text='The number of the chapter.')
    #: The volume of the chapter.
    volume = models.PositiveSmallIntegerField(
        default=0,
        help_text=
        ('The volume of the chapter. Leave as 0 if the series has no volumes.'
         ))
    #: The series this chapter belongs to.
    series = models.ForeignKey(Series,
                               on_delete=models.CASCADE,
                               related_name='chapters',
                               help_text='The series this chapter belongs to.')
    #: The file which contains the chapter's pages.
    file = models.FileField(
        help_text=('Upload a zip or cbz file containing the chapter pages.'
                   ' Its size cannot exceed 50 MBs and it'
                   ' must not contain more than 1 subfolder.'),
        validators=(validators.FileSizeValidator(50),
                    validators.zipfile_validator),
        blank=True)
    #: The status of the chapter.
    final = models.BooleanField(default=False,
                                help_text='Is this the final chapter?')
    #: The publication date of the chapter.
    published = models.DateTimeField(
        db_index=True,
        help_text=('You can select a future date to schedule'
                   ' the publication of the chapter.'),
        default=timezone.now)
    #: The modification date of the chapter.
    modified = models.DateTimeField(auto_now=True)
    #: The groups that worked on this chapter.
    groups = models.ManyToManyField(Group, blank=True, related_name='releases')

    class Meta:
        unique_together = ('series', 'volume', 'number')
        ordering = ('series', 'volume', 'number')
        get_latest_by = ('published', 'modified')

    def save(self, *args, **kwargs):
        """Save the current instance."""
        super(Chapter, self).save(*args, **kwargs)
        if self.file:
            validators.zipfile_validator(self.file)
            Page.objects.filter(chapter_id=self.id).delete()
            self.unzip()
        self.series.completed = self.final
        self.series.save()

    @cached_property
    def next(self) -> 'Chapter':
        """Get the next chapter in the series."""
        q = Q(series_id=self.series_id) & (Q(volume__gt=self.volume) | Q(
            volume=self.volume, number__gt=self.number))
        return self.__class__.objects.filter(q) \
            .order_by('volume', 'number').first()

    @cached_property
    def prev(self) -> 'Chapter':
        """Get the previous chapter in the series."""
        q = Q(series_id=self.series_id) & (Q(volume__lt=self.volume) | Q(
            volume=self.volume, number__lt=self.number))
        return self.__class__.objects.filter(q) \
            .order_by('-volume', '-number').first()

    @cached_property
    def twitter_creator(self) -> str:
        """Get the Twitter username of the chapter's first group."""
        return '@' + Group.objects.filter(releases__id=self.id) \
            .exclude(twitter='').only('twitter').first().twitter

    def get_absolute_url(self) -> str:
        """
        Get the absolute URL of the object.

        :return: The URL of :func:`reader.views.chapter_redirect`.
        """
        return reverse('reader:chapter',
                       args=(self.series.slug, self.volume, self.number))

    def get_directory(self) -> PurePath:
        """
        Get the storage directory of the object.

        :return: A path relative to
                 :const:`~MangAdventure.settings.MEDIA_ROOT`.
        """
        return self.series.get_directory() / \
            str(self.volume) / f'{self.number:g}'

    def unzip(self):
        """Unzip the chapter and save its images."""
        counter = 0
        pages = []
        dir_path = path.join('series', self.series.slug, str(self.volume),
                             f'{self.number:g}')
        full_path = settings.MEDIA_ROOT / dir_path
        if full_path.exists():
            rmtree(full_path)
        full_path.mkdir(parents=True)
        with ZipFile(self.file) as zf:
            for name in utils.natsort(zf.namelist()):
                if zf.getinfo(name).is_dir():
                    continue
                counter += 1
                data = zf.read(name)
                dgst = blake2b(data, digest_size=16).hexdigest()
                filename = dgst + path.splitext(name)[-1]
                file_path = path.join(dir_path, filename)
                with open(full_path / filename, 'wb') as img:
                    img.write(data)
                pages.append(
                    Page(chapter_id=self.id, number=counter, image=file_path))
        self.pages.all().delete()
        self.pages.bulk_create(pages)
        self.file.delete(save=True)

    def zip(self) -> BytesIO:
        """
        Generate a zip file containing the pages of this chapter.

        :return: The file-like object of the generated file.
        """
        buf = BytesIO()
        with ZipFile(buf, 'a', compression=8) as zf:
            for page in self.pages.iterator():
                img = page.image.path
                name = f'{page.number:03d}'
                ext = path.splitext(img)[-1]
                zf.write(img, name + ext)
        buf.seek(0)
        return buf

    @cached_property
    def _tuple(self) -> Tuple[int, float]:
        return self.volume, self.number

    def __str__(self) -> str:
        """
        Return a string representing the object.

        :return: The chapter formatted according to the
                 :attr:`~reader.models.Series.format`.
        """
        if not self.series:  # pragma: no cover
            return Series.format.default.format(title=self.title or 'N/A',
                                                volume=self.volume,
                                                number=f'{self.number:g}',
                                                date='',
                                                series='')
        return self.series.format.format(title=self.title,
                                         volume=self.volume,
                                         number=f'{self.number:g}',
                                         date=self.published.strftime('%F'),
                                         series=self.series.title)

    def __eq__(self, other: Any) -> bool:
        """
        Check whether this object is equal to another.

        If the other object is a tuple, the objects are equal if
        the tuple consists of the volume and number of the chapter.

        Otherwise, the objects are equal if they have the
        same base model and their primary keys are equal.

        :param other: Any other object.

        :return: ``True`` if the objects are equal.
        """
        if isinstance(other, tuple):
            return self._tuple == other
        return super().__eq__(other)

    def __gt__(self, other: Any) -> bool:
        """
        Check whether this object is greater than another.

        If the other object is a tuple, this object is greater
        if its volume and number is greater than the tuple.

        Otherwise, it's greater if the objects have the same base model and
        the tuple of its ``volume`` and ``number`` is greater than the other's.

        :param other: Any other object.

        :return: ``True`` if this object is greater.

        :raises TypeError: If the other object is neither a tuple,
                           nor a ``Chapter`` model.
        """
        if isinstance(other, tuple):
            return self._tuple > other

        if isinstance(other, self.__class__):
            return self._tuple > other._tuple

        raise TypeError(
            "'>' not supported between instances of '{}' and '{}'".format(
                self.__class__, other.__class__))

    def __lt__(self, other: Any) -> bool:
        """
        Check whether this object is less than another.

        If the other object is a tuple, this object is lesser
        if its volume and number is less than the tuple.

        Otherwise, it's lesser if the objects have the same base model and
        the tuple of its ``volume`` and ``number`` is less than the other's.

        :param other: Any other object.

        :return: ``True`` if this object is lesser.

        :raises TypeError: If the other object is neither a tuple,
                           nor a ``Chapter`` model.
        """
        if isinstance(other, tuple):
            return self._tuple < other

        if isinstance(other, self.__class__):
            return self._tuple < other._tuple

        raise TypeError(
            "'<' not supported between instances of '{}' and '{}'".format(
                self.__class__, other.__class__))

    def __hash__(self) -> int:
        """
        Return the hash of the object.

        :return: An integer hash value.
        """
        return hash(str(self)) & 0x7FFFFFFF
Пример #6
0
class Series(models.Model):
    #: The title of the series.
    title = models.CharField(max_length=250,
                             db_index=True,
                             help_text='The title of the series.')
    #: The unique slug of the series.
    slug = models.SlugField(
        blank=True,
        unique=True,
        verbose_name='Custom slug',
        help_text='The unique slug of the series. Will be used in the URL.')
    #: The description of the series.
    description = models.TextField(blank=True,
                                   help_text='The description of the series.')
    #: The cover image of the series.
    cover = models.ImageField(help_text=('Upload a cover image for the series.'
                                         ' Its size must not exceed 2 MBs.'),
                              upload_to=_cover_uploader,
                              validators=(validators.FileSizeValidator(2), ),
                              storage=storage.CDNStorage((300, 300)))
    #: The authors of the series.
    authors = models.ManyToManyField(Author, blank=True)
    #: The artists of the series.
    artists = models.ManyToManyField(Artist, blank=True)
    #: The categories of the series.
    categories = models.ManyToManyField(Category, blank=True)
    #: The status of the series.
    completed = models.BooleanField(default=False,
                                    help_text='Is the series completed?')
    #: The date the series was created.
    created = models.DateTimeField(auto_now_add=True, db_index=True)
    #: The modification date of the series.
    modified = models.DateTimeField(auto_now=True)
    #: The chapter name format of the series.
    format = models.CharField(
        max_length=100,
        default='Vol. {volume}, Ch. {number}: {title}',
        help_text='The format used to render the chapter names.',
        verbose_name='chapter name format')
    #: The aliases of the series.
    aliases = GenericRelation(to=Alias, blank=True, related_query_name='alias')
    #: The person who manages this series.
    manager = models.ForeignKey(
        User,
        editable=True,
        blank=False,
        null=True,
        help_text='The person who manages this series.',
        on_delete=models.SET_NULL,
        limit_choices_to=(models.Q(is_superuser=True)
                          | models.Q(groups__name='Scanlator')))

    def get_absolute_url(self) -> str:
        """
        Get the absolute URL of the object.

        :return: The URL of :func:`reader.views.series`.
        """
        return reverse('reader:series', args=(self.slug, ))

    def get_directory(self) -> PurePath:
        """
        Get the storage directory of the object.

        :return: A path relative to
                 :const:`~MangAdventure.settings.MEDIA_ROOT`.
        """
        return PurePath('series', self.slug)

    class Meta:
        verbose_name_plural = 'series'
        get_latest_by = 'modified'

    def save(self, *args, **kwargs):
        """Save the current instance."""
        self.slug = slugify(self.slug or self.title)
        super().save(*args, **kwargs)

    def __str__(self) -> str:
        """
        Return a string representing the object.

        :return: The title of the series.
        """
        return self.title