Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/app/forms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django import forms
from django.conf import settings

import users
from app import config
from app.models import (
TV,
Expand Down Expand Up @@ -194,11 +195,17 @@ def save(self, commit=True): # noqa: FBT002
class MediaForm(forms.ModelForm):
"""Base form for all media types."""

can_toggle_unit = False
instance_id = forms.CharField(widget=forms.HiddenInput(), required=False)
media_type = forms.CharField(widget=forms.HiddenInput(), required=True)
source = forms.CharField(widget=forms.HiddenInput(), required=True)
media_id = forms.CharField(widget=forms.HiddenInput(), required=True)

def __init__(self, *args, **kwargs):
"""Initialize the form."""
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)

class Meta:
"""Define fields and input types."""

Expand Down Expand Up @@ -284,16 +291,46 @@ class Meta(MediaForm.Meta):
class BookForm(MediaForm):
"""Form for books."""

can_toggle_unit = True
progress_unit = forms.ChoiceField(
choices=users.models.ProgressUnit.choices,
widget=forms.HiddenInput(),
required=False,
)

class Meta(MediaForm.Meta):
"""Bind form to model."""

model = Book
fields = MediaForm.Meta.fields + ["progress_unit"]
labels = {
"progress": (
f"Progress ({config.get_unit(MediaTypes.BOOK.value, short=False)}s)"
),
}

def __init__(self, *args, **kwargs):
"""Initialize the form and set progress unit."""
super().__init__(*args, **kwargs)

# Set initial progress unit
if self.instance and self.instance.pk:
unit = self.instance.get_progress_unit()
self.initial["progress_unit"] = unit
else:
# For new items, use user preference if available
user = getattr(self, "user", None)
if user:
self.initial["progress_unit"] = user.book_progress_unit
else:
self.initial["progress_unit"] = users.models.ProgressUnit.PAGES

# Update label based on unit
current_unit = self.initial.get("progress_unit")
if current_unit == users.models.ProgressUnit.PERCENTAGE:
self.fields["progress"].label = "Progress (%)"
self.fields["progress"].widget.attrs["max"] = 100


class ComicForm(MediaForm):
"""Form for comics."""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.2.11 on 2026-03-10 00:34

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('app', '0055_alter_item_media_type'),
]

operations = [
migrations.AddField(
model_name='book',
name='progress_unit',
field=models.CharField(blank=True, default=None, max_length=20, null=True),
),
migrations.AddField(
model_name='historicalbook',
name='progress_unit',
field=models.CharField(blank=True, default=None, max_length=20, null=True),
),
]
84 changes: 64 additions & 20 deletions src/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,11 @@ def __str__(self):
"""Return the title of the media."""
return self.item.__str__()

def get_progress_unit(self):
"""Return the progress unit for the media."""
# Check if instance has a specific progress_unit field (like Book)
return getattr(self, "progress_unit", None)

def save(self, *args, **kwargs):
"""Save the media instance."""
if self.tracker.has_changed("progress"):
Expand All @@ -837,33 +842,47 @@ def process_progress(self):
"""Update fields depending on the progress of the media."""
if self.progress < 0:
self.progress = 0
elif self.status == Status.IN_PROGRESS.value:
max_progress = providers.services.get_media_metadata(
self.item.media_type,
self.item.media_id,
self.item.source,
)["max_progress"]

if max_progress:
self.progress = min(self.progress, max_progress)
if self.status != Status.IN_PROGRESS.value:
return

if self.get_progress_unit() == users.models.ProgressUnit.PERCENTAGE:
self.progress = min(self.progress, 100)
if self.progress == 100:
self.status = Status.COMPLETED.value
now = timezone.now().replace(second=0, microsecond=0)
self.end_date = now
return

max_progress = providers.services.get_media_metadata(
self.item.media_type,
self.item.media_id,
self.item.source,
)["max_progress"]

if self.progress == max_progress:
self.status = Status.COMPLETED.value
if max_progress:
self.progress = min(self.progress, max_progress)

now = timezone.now().replace(second=0, microsecond=0)
self.end_date = now
if self.progress == max_progress:
self.status = Status.COMPLETED.value

now = timezone.now().replace(second=0, microsecond=0)
self.end_date = now

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This method contains duplicated logic for marking an item as completed. To improve maintainability and adhere to the DRY principle, you could extract the completion logic into a local function.

        def mark_completed():
            self.status = Status.COMPLETED.value
            now = timezone.now().replace(second=0, microsecond=0)
            self.end_date = now

        if self.get_progress_unit() == users.models.ProgressUnit.PERCENTAGE:
            self.progress = min(self.progress, 100)
            if self.progress == 100:
                mark_completed()
            return

        max_progress = providers.services.get_media_metadata(
            self.item.media_type,
            self.item.media_id,
            self.item.source,
        )["max_progress"]

        if max_progress:
            self.progress = min(self.progress, max_progress)
            if self.progress == max_progress:
                mark_completed()

def process_status(self):
"""Update fields depending on the status of the media."""
if self.status == Status.COMPLETED.value:
max_progress = providers.services.get_media_metadata(
self.item.media_type,
self.item.media_id,
self.item.source,
)["max_progress"]
if self.get_progress_unit() == users.models.ProgressUnit.PERCENTAGE:
self.progress = 100
else:
max_progress = providers.services.get_media_metadata(
self.item.media_type,
self.item.media_id,
self.item.source,
)["max_progress"]

if max_progress:
self.progress = max_progress
if max_progress:
self.progress = max_progress

self.item.fetch_releases(delay=True)

Expand All @@ -881,7 +900,11 @@ def formatted_score(self):
@property
def formatted_progress(self):
"""Return the progress of the media in a formatted string."""
return str(self.progress)
display = str(self.progress)
max_progress = getattr(self, "max_progress", None)
if max_progress and self.item.media_type != MediaTypes.MOVIE.value:
display += f" / {max_progress}"
return display

def increase_progress(self):
"""Increase the progress of the media by one."""
Expand Down Expand Up @@ -1613,6 +1636,27 @@ class Book(Media):

tracker = FieldTracker()

progress_unit = models.CharField(
max_length=20,
null=True,
blank=True,
default=None,
)

@property
def formatted_progress(self):
"""Return the progress of the media in a formatted string."""
if self.get_progress_unit() == users.models.ProgressUnit.PERCENTAGE:
return f"{self.progress}%"
return super().formatted_progress

def get_progress_unit(self):
"""Return the progress unit for the book, falling back to user preference."""
unit = super().get_progress_unit()
if unit:
return unit
return self.user.book_progress_unit


class Comic(Media):
"""Model for comics."""
Expand Down
106 changes: 106 additions & 0 deletions src/app/tests/models/test_book.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from unittest.mock import patch

from django.contrib.auth import get_user_model
from django.test import TestCase

from app.models import (
Book,
Item,
MediaTypes,
Sources,
Status,
)
from users.models import ProgressUnit


@patch("app.providers.services.get_media_metadata")
class BookModelTests(TestCase):
"""Test case for the Book model methods."""

def setUp(self):
"""Set up test data for Book model tests."""
self.credentials = {"username": "test", "password": "12345"}
self.user = get_user_model().objects.create_user(**self.credentials)

self.item = Item.objects.create(
media_id="book123",
source=Sources.OPENLIBRARY.value,
media_type=MediaTypes.BOOK.value,
title="Test Book",
)

def test_get_progress_unit_fallback(self, mock_metadata):
"""Test that get_progress_unit falls back to user preference."""
mock_metadata.return_value = {"max_progress": 200}
book = Book.objects.create(
item=self.item,
user=self.user,
status=Status.PLANNING.value,
)

# Default user preference is PAGES
self.assertEqual(book.get_progress_unit(), ProgressUnit.PAGES)

# Update user preference to PERCENTAGE
self.user.book_progress_unit = ProgressUnit.PERCENTAGE
self.user.save()
self.assertEqual(book.get_progress_unit(), ProgressUnit.PERCENTAGE)

# Override on book specifically
book.progress_unit = ProgressUnit.PAGES
book.save()
self.assertEqual(book.get_progress_unit(), ProgressUnit.PAGES)

def test_process_progress_percentage(self, mock_metadata):
"""Test progress processing when using percentage unit."""
mock_metadata.return_value = {"max_progress": 200}

book = Book.objects.create(
item=self.item,
user=self.user,
status=Status.IN_PROGRESS.value,
progress_unit=ProgressUnit.PERCENTAGE,
)

# Set progress to 50%
book.progress = 50
book.save()
self.assertEqual(book.status, Status.IN_PROGRESS.value)

# Set progress to 100%
book.progress = 100
book.save()
self.assertEqual(book.status, Status.COMPLETED.value)
self.assertIsNotNone(book.end_date)

# Set progress > 100% should be capped
book.status = Status.IN_PROGRESS.value
book.progress = 150
book.save()
self.assertEqual(book.progress, 100)

def test_formatted_progress_percentage(self, mock_metadata):
"""Test formatting of progress when using percentage unit."""
mock_metadata.return_value = {"max_progress": 200}
book = Book.objects.create(
item=self.item,
user=self.user,
status=Status.IN_PROGRESS.value,
progress=45,
progress_unit=ProgressUnit.PERCENTAGE,
)
self.assertEqual(book.formatted_progress, "45%")

def test_formatted_progress_pages(self, mock_metadata):
"""Test formatting of progress when using pages unit."""
mock_metadata.return_value = {"max_progress": 200}
book = Book.objects.create(
item=self.item,
user=self.user,
status=Status.IN_PROGRESS.value,
progress=150,
progress_unit=ProgressUnit.PAGES,
)
# Mock max_progress annotation which usually comes from MediaManager
book.max_progress = 300
self.assertEqual(book.formatted_progress, "150 / 300")
23 changes: 16 additions & 7 deletions src/app/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ def track_modal(
"""Return the tracking form for a media item."""
instance_id = request.GET.get("instance_id")
if instance_id:
media = BasicMedia.objects.get_media(
media = BasicMedia.objects.get_media_prefetch(
request.user,
media_type,
instance_id,
Expand All @@ -447,14 +447,14 @@ def track_modal(
media = None
else:
# no specific instance, try to find the first one
user_medias = BasicMedia.objects.filter_media(
user_medias = BasicMedia.objects.filter_media_prefetch(
request.user,
media_id,
media_type,
source,
season_number=season_number,
)
media = user_medias.first()
media = user_medias[0] if user_medias else None
if media:
instance_id = media.id

Expand All @@ -470,17 +470,24 @@ def track_modal(
title = media.item
if media_type == MediaTypes.GAME.value:
initial_data["progress"] = helpers.minutes_to_hhmm(media.progress)
max_progress = getattr(media, "max_progress", None)
else:
title = services.get_media_metadata(
metadata = services.get_media_metadata(
media_type,
media_id,
source,
[season_number],
)["title"]
)
title = metadata["title"]
max_progress = metadata.get("max_progress")
if media_type == MediaTypes.SEASON.value:
title += f" S{season_number}"

form = get_form_class(media_type)(instance=media, initial=initial_data)
form = get_form_class(media_type)(
instance=media,
initial=initial_data,
user=request.user,
)

return render(
request,
Expand All @@ -489,6 +496,8 @@ def track_modal(
"title": title,
"form": form,
"media": media,
"media_type": media_type,
"max_progress": max_progress,
"return_url": request.GET["return_url"],
},
)
Expand Down Expand Up @@ -531,7 +540,7 @@ def media_save(request):

# Validate the form and save the instance if it's valid
form_class = get_form_class(media_type)
form = form_class(request.POST, instance=instance)
form = form_class(request.POST, instance=instance, user=request.user)
if form.is_valid():
form.save()
logger.info("%s saved successfully.", form.instance)
Expand Down
Loading
Loading