diff --git a/api/README.md b/api/README.md
index db21ab6c..e5996008 100644
--- a/api/README.md
+++ b/api/README.md
@@ -385,6 +385,8 @@ The Issue endpoint supports the most comprehensive filtering options:
- `upc` - UPC code
- `page_count` - Number of pages
- `rating` - Content rating object
+ - `average_rating` - Average community star rating (1.0 to 5.0, null if no ratings). Not included in the list endpoint.
+ - `rating_count` - Total number of community star ratings. Not included in the list endpoint.
- `arcs` - Story arcs
- `characters` - Characters appearing
- `teams` - Teams appearing
diff --git a/api/v1_0/serializers/issue.py b/api/v1_0/serializers/issue.py
index 51f4e3e5..ceb4c76a 100644
--- a/api/v1_0/serializers/issue.py
+++ b/api/v1_0/serializers/issue.py
@@ -292,6 +292,10 @@ class IssueReadSerializer(serializers.ModelSerializer):
series = IssueSeriesSerializer(read_only=True)
reprints = ReprintSerializer(many=True, read_only=True)
rating = RatingSerializer(read_only=True)
+ average_rating = serializers.DecimalField(
+ max_digits=3, decimal_places=2, coerce_to_string=False, read_only=True
+ )
+ rating_count = serializers.IntegerField(read_only=True)
resource_url = serializers.SerializerMethodField("get_resource_url")
def get_resource_url(self, obj: Issue) -> str:
@@ -327,6 +331,8 @@ class Meta:
"desc",
"image",
"cover_hash",
+ "average_rating",
+ "rating_count",
"arcs",
"credits",
"characters",
diff --git a/api/views.py b/api/views.py
index fb366a8f..aa8ab151 100644
--- a/api/views.py
+++ b/api/views.py
@@ -1,5 +1,16 @@
from django.db import models
-from django.db.models import Avg, Case, Count, F, IntegerField, Prefetch, Q, Sum, When
+from django.db.models import (
+ Avg,
+ Case,
+ Count,
+ DecimalField,
+ F,
+ IntegerField,
+ Prefetch,
+ Q,
+ Sum,
+ When,
+)
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone
@@ -356,30 +367,39 @@ class IssueViewSet(
def get_queryset(self):
if self.action == "list":
return Issue.objects.select_related("series", "series__series_type")
- return Issue.objects.select_related(
- "series",
- "series__series_type",
- "series__publisher",
- "series__imprint",
- "rating",
- ).prefetch_related(
- "series__genres",
- "arcs",
- "characters",
- "teams",
- "universes",
- "variants",
- Prefetch(
- "credits_set",
- queryset=Credits.objects.order_by("creator__name")
- .distinct("creator__name")
- .select_related("creator")
- .prefetch_related("role"),
- ),
- Prefetch(
- "reprints",
- queryset=Issue.objects.select_related("series", "series__series_type"),
- ),
+ return (
+ Issue.objects.select_related(
+ "series",
+ "series__series_type",
+ "series__publisher",
+ "series__imprint",
+ "rating",
+ )
+ .prefetch_related(
+ "series__genres",
+ "arcs",
+ "characters",
+ "teams",
+ "universes",
+ "variants",
+ Prefetch(
+ "credits_set",
+ queryset=Credits.objects.order_by("creator__name")
+ .distinct("creator__name")
+ .select_related("creator")
+ .prefetch_related("role"),
+ ),
+ Prefetch(
+ "reprints",
+ queryset=Issue.objects.select_related("series", "series__series_type"),
+ ),
+ )
+ .annotate(
+ average_rating=Avg(
+ "ratings__rating", output_field=DecimalField(max_digits=3, decimal_places=2)
+ ),
+ rating_count=Count("ratings", distinct=True),
+ )
)
def get_serializer_class(self):
diff --git a/comicsdb/models/common.py b/comicsdb/models/common.py
index 691160e1..318f2c59 100644
--- a/comicsdb/models/common.py
+++ b/comicsdb/models/common.py
@@ -4,6 +4,10 @@
from django.db.models.functions import Now
from django.utils.text import slugify
+MIN_RATING = 1
+MAX_RATING = 5
+RATING_CHOICES = [(i, str(i)) for i in range(MIN_RATING, MAX_RATING + 1)]
+
def generate_slug_from_name(instance):
base_slug = (
@@ -44,3 +48,17 @@ class CommonInfo(models.Model):
class Meta:
abstract = True
+
+
+class AbstractRating(models.Model):
+ """Abstract base for a user's 1-5 star rating of a related object."""
+
+ rating = models.PositiveSmallIntegerField(
+ choices=RATING_CHOICES,
+ help_text="Star rating (1-5)",
+ )
+ created_on = models.DateTimeField(db_default=Now())
+ modified = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ abstract = True
diff --git a/comicsdb/templates/comicsdb/issue_detail.html b/comicsdb/templates/comicsdb/issue_detail.html
index e5d207bc..db9aac3a 100644
--- a/comicsdb/templates/comicsdb/issue_detail.html
+++ b/comicsdb/templates/comicsdb/issue_detail.html
@@ -64,6 +64,7 @@
{{ issue }}
{{ issue.rating.name }}
{% endif %}
+ {% include "partials/rating_summary.html" with rated_object=issue average_rating=average_rating rating_count=rating_count %}
{# Navigation Bar #}
@@ -628,6 +629,8 @@ Story Arc{{ arcs|pluralize }}
{% endif %}
{% endwith %}
+
+ {% include "partials/rating_widget.html" with rated_object=issue rate_url_name="issue-ratings:rate" rate_url_arg=issue.pk user_rating=user_rating average_rating=average_rating rating_count=rating_count show_ratings=True can_rate=user.is_authenticated %}
Identification Numbers
diff --git a/comicsdb/views/issue.py b/comicsdb/views/issue.py
index 96de4f27..1efd3541 100644
--- a/comicsdb/views/issue.py
+++ b/comicsdb/views/issue.py
@@ -6,7 +6,7 @@
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.core.exceptions import ObjectDoesNotExist
from django.db import IntegrityError, transaction
-from django.db.models import Prefetch
+from django.db.models import Avg, Count, DecimalField, Prefetch
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
@@ -32,6 +32,7 @@
build_active_filters,
)
from comicsdb.views.mixins import LazyLoadMixin, SlugRedirectView
+from issue_ratings.models import IssueRating
from wish_list.models import WishListItem
TOTAL_WEEKS_YEAR = 52
@@ -67,7 +68,9 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
class IssueDetail(DetailView):
model = Issue
queryset = (
- Issue.objects.select_related("series", "series__publisher", "series__series_type", "rating")
+ Issue.objects.select_related(
+ "series", "series__publisher", "series__series_type", "rating", "edited_by"
+ )
.defer(
"created_on",
"cover_hash",
@@ -122,17 +125,31 @@ class IssueDetail(DetailView):
)
)
+ def get_queryset(self):
+ return (
+ super()
+ .get_queryset()
+ .annotate(
+ average_rating=Avg(
+ "ratings__rating", output_field=DecimalField(max_digits=3, decimal_places=2)
+ ),
+ rating_count=Count("ratings", distinct=True),
+ )
+ )
+
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
issue = context["object"]
try:
next_issue = issue.get_next_by_cover_date(series=issue.series)
+ next_issue.series = issue.series
except ObjectDoesNotExist:
next_issue = None
try:
previous_issue = issue.get_previous_by_cover_date(series=issue.series)
+ previous_issue.series = issue.series
except ObjectDoesNotExist:
previous_issue = None
@@ -189,6 +206,13 @@ def get_context_data(self, **kwargs):
wish_list__user=self.request.user,
issue=issue,
).exists()
+ context["user_rating"] = IssueRating.objects.filter(
+ issue=issue,
+ user=self.request.user,
+ ).first()
+
+ context["average_rating"] = issue.average_rating
+ context["rating_count"] = issue.rating_count
return context
diff --git a/comicsdb/views/ratings.py b/comicsdb/views/ratings.py
new file mode 100644
index 00000000..b61fd304
--- /dev/null
+++ b/comicsdb/views/ratings.py
@@ -0,0 +1,38 @@
+"""
+Shared helpers for HTMX star-rating update views.
+"""
+
+from comicsdb.models.common import MAX_RATING, MIN_RATING
+
+
+def parse_rating_action(raw_value):
+ """
+ Parse a raw HTMX POST 'rating' value into an action.
+
+ Returns ("set", rating) for a value in [MIN_RATING, MAX_RATING],
+ ("clear", None) for the 0 sentinel, or None if the input is missing,
+ non-numeric, or otherwise out of range (caller should no-op).
+ """
+ if not raw_value:
+ return None
+ try:
+ rating = int(raw_value)
+ except ValueError:
+ return None
+ if MIN_RATING <= rating <= MAX_RATING:
+ return ("set", rating)
+ if rating == 0:
+ return ("clear", None)
+ return None
+
+
+def apply_rating_update(model, lookup, raw_value):
+ """Set/update or clear a `model` row identified by `lookup` from a raw POST value."""
+ action = parse_rating_action(raw_value)
+ if action is None:
+ return
+ kind, rating = action
+ if kind == "set":
+ model.objects.update_or_create(**lookup, defaults={"rating": rating})
+ else:
+ model.objects.filter(**lookup).delete()
diff --git a/issue_ratings/__init__.py b/issue_ratings/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/issue_ratings/admin.py b/issue_ratings/admin.py
new file mode 100644
index 00000000..8f224c4f
--- /dev/null
+++ b/issue_ratings/admin.py
@@ -0,0 +1,12 @@
+from django.contrib import admin
+
+from issue_ratings.models import IssueRating
+
+
+@admin.register(IssueRating)
+class IssueRatingAdmin(admin.ModelAdmin):
+ list_display = ("issue", "user", "rating", "modified")
+ list_filter = ("rating", "created_on")
+ search_fields = ("issue__series__name", "user__username")
+ readonly_fields = ("created_on", "modified")
+ autocomplete_fields = ["issue", "user"]
diff --git a/issue_ratings/apps.py b/issue_ratings/apps.py
new file mode 100644
index 00000000..3b2f26c0
--- /dev/null
+++ b/issue_ratings/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class IssueRatingsConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "issue_ratings"
diff --git a/issue_ratings/management/__init__.py b/issue_ratings/management/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/issue_ratings/management/commands/__init__.py b/issue_ratings/management/commands/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/issue_ratings/management/commands/backfill_issue_ratings.py b/issue_ratings/management/commands/backfill_issue_ratings.py
new file mode 100644
index 00000000..ab730c7f
--- /dev/null
+++ b/issue_ratings/management/commands/backfill_issue_ratings.py
@@ -0,0 +1,60 @@
+"""
+One-time backfill: sync existing user_collection ratings into issue_ratings.
+
+Populates issue_ratings.IssueRating from CollectionItem.rating values that were
+set before the two apps were kept in sync via user_collection.signals.
+"""
+
+from django.core.management.base import BaseCommand
+from django.db import transaction
+
+from issue_ratings.models import IssueRating
+from user_collection.models import CollectionItem
+
+
+class Command(BaseCommand):
+ help = "Backfill IssueRating rows from existing CollectionItem ratings."
+
+ def add_arguments(self, parser) -> None:
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Report what would change without writing to the database",
+ )
+
+ def handle(self, *args, **options) -> None:
+ dry_run = options["dry_run"]
+ rated_items = CollectionItem.objects.filter(rating__isnull=False).select_related(
+ "issue", "user"
+ )
+
+ created_count = 0
+ updated_count = 0
+ unchanged_count = 0
+
+ with transaction.atomic():
+ for item in rated_items.iterator():
+ existing = IssueRating.objects.filter(issue=item.issue, user=item.user).first()
+
+ if existing is None:
+ created_count += 1
+ IssueRating.objects.create(issue=item.issue, user=item.user, rating=item.rating)
+ elif existing.rating != item.rating:
+ updated_count += 1
+ existing.rating = item.rating
+ existing.save(update_fields=["rating"])
+ else:
+ unchanged_count += 1
+
+ if dry_run:
+ transaction.set_rollback(True)
+
+ verb = "Would sync" if dry_run else "Synced"
+ total = created_count + updated_count + unchanged_count
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"{verb} {total} rated collection item(s): "
+ f"{created_count} to create, {updated_count} to update, "
+ f"{unchanged_count} already in sync."
+ )
+ )
diff --git a/issue_ratings/migrations/0001_initial.py b/issue_ratings/migrations/0001_initial.py
new file mode 100644
index 00000000..ba72161f
--- /dev/null
+++ b/issue_ratings/migrations/0001_initial.py
@@ -0,0 +1,63 @@
+# Generated by Django 6.0.7 on 2026-07-13 15:56
+
+import django.db.models.deletion
+import django.db.models.functions.datetime
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ ("comicsdb", "0052_alter_historicalissue_price_currency_and_more"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="IssueRating",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+ ),
+ ),
+ (
+ "rating",
+ models.PositiveSmallIntegerField(
+ choices=[(1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5")],
+ help_text="Star rating (1-5) for this issue",
+ ),
+ ),
+ (
+ "created_on",
+ models.DateTimeField(db_default=django.db.models.functions.datetime.Now()),
+ ),
+ ("modified", models.DateTimeField(auto_now=True)),
+ (
+ "issue",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="ratings",
+ to="comicsdb.issue",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="issue_ratings",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "indexes": [
+ models.Index(fields=["issue", "user"], name="issue_ratin_issue_i_f20318_idx")
+ ],
+ "unique_together": {("issue", "user")},
+ },
+ ),
+ ]
diff --git a/issue_ratings/migrations/0002_alter_issuerating_rating.py b/issue_ratings/migrations/0002_alter_issuerating_rating.py
new file mode 100644
index 00000000..44c9d7bd
--- /dev/null
+++ b/issue_ratings/migrations/0002_alter_issuerating_rating.py
@@ -0,0 +1,20 @@
+# Generated by Django 6.0.7 on 2026-07-13 16:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("issue_ratings", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="issuerating",
+ name="rating",
+ field=models.PositiveSmallIntegerField(
+ choices=[(1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5")],
+ help_text="Star rating (1-5)",
+ ),
+ ),
+ ]
diff --git a/issue_ratings/migrations/__init__.py b/issue_ratings/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/issue_ratings/models.py b/issue_ratings/models.py
new file mode 100644
index 00000000..ab6f010e
--- /dev/null
+++ b/issue_ratings/models.py
@@ -0,0 +1,29 @@
+from django.db import models
+
+from comicsdb.models.common import AbstractRating
+from comicsdb.models.issue import Issue
+from users.models import CustomUser
+
+
+class IssueRating(AbstractRating):
+ """User ratings for issues."""
+
+ issue = models.ForeignKey(
+ Issue,
+ on_delete=models.CASCADE,
+ related_name="ratings",
+ )
+ user = models.ForeignKey(
+ CustomUser,
+ on_delete=models.CASCADE,
+ related_name="issue_ratings",
+ )
+
+ class Meta:
+ unique_together = ["issue", "user"]
+ indexes = [
+ models.Index(fields=["issue", "user"]),
+ ]
+
+ def __str__(self) -> str:
+ return f"{self.user.username} - {self.issue}: {self.rating}"
diff --git a/issue_ratings/urls.py b/issue_ratings/urls.py
new file mode 100644
index 00000000..f7c3e9b8
--- /dev/null
+++ b/issue_ratings/urls.py
@@ -0,0 +1,9 @@
+from django.urls import path
+
+from issue_ratings.views import update_issue_rating
+
+app_name = "issue-ratings"
+
+urlpatterns = [
+ path("
/rate/", update_issue_rating, name="rate"),
+]
diff --git a/issue_ratings/views.py b/issue_ratings/views.py
new file mode 100644
index 00000000..1ea826b9
--- /dev/null
+++ b/issue_ratings/views.py
@@ -0,0 +1,63 @@
+from django.contrib.auth.decorators import login_required
+from django.db.models import Avg, Count
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404
+from django.template.loader import render_to_string
+from django.views.decorators.http import require_POST
+
+from comicsdb.models.issue import Issue
+from comicsdb.views.ratings import apply_rating_update
+from issue_ratings.models import IssueRating
+
+
+@login_required
+@require_POST
+def update_issue_rating(request, pk):
+ """HTMX view to update the rating of an issue."""
+ issue = get_object_or_404(Issue, pk=pk)
+
+ apply_rating_update(
+ IssueRating,
+ {"issue": issue, "user": request.user},
+ request.POST.get("rating"),
+ )
+
+ # Get user's current rating and average
+ user_rating = IssueRating.objects.filter(
+ issue=issue,
+ user=request.user,
+ ).first()
+
+ # Calculate average rating
+ avg_data = issue.ratings.aggregate(
+ avg=Avg("rating"),
+ count=Count("id"),
+ )
+
+ # Return the updated rating widget, plus an out-of-band update for the
+ # average-rating summary shown in the page header so the two stay in sync.
+ widget_html = render_to_string(
+ "partials/rating_widget.html",
+ {
+ "rated_object": issue,
+ "rate_url_name": "issue-ratings:rate",
+ "rate_url_arg": issue.pk,
+ "user_rating": user_rating,
+ "average_rating": avg_data["avg"],
+ "rating_count": avg_data["count"],
+ "show_ratings": True,
+ "can_rate": True,
+ },
+ request=request,
+ )
+ summary_html = render_to_string(
+ "partials/rating_summary.html",
+ {
+ "rated_object": issue,
+ "average_rating": avg_data["avg"],
+ "rating_count": avg_data["count"],
+ "oob": True,
+ },
+ request=request,
+ )
+ return HttpResponse(widget_html + summary_html)
diff --git a/metron/settings.py b/metron/settings.py
index 3d35da04..c4e0bb51 100644
--- a/metron/settings.py
+++ b/metron/settings.py
@@ -78,6 +78,7 @@
"user_collection",
"pull_list",
"wish_list",
+ "issue_ratings",
"polls",
"timeline",
]
diff --git a/metron/urls.py b/metron/urls.py
index 8fcf6f70..5d33efd4 100644
--- a/metron/urls.py
+++ b/metron/urls.py
@@ -25,6 +25,7 @@
team as team_urls,
universe as universe_urls,
)
+from issue_ratings import urls as issue_ratings_urls
from polls import urls as polls_urls
from pull_list import urls as pull_list_urls
from reading_lists import urls as reading_lists_urls
@@ -59,6 +60,7 @@
path("", include(home_urls)),
path("imprint/", include(imprint_urls)),
path("issue/", include(issue_urls)),
+ path("issue-ratings/", include(issue_ratings_urls)),
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
path("publisher/", include(publisher_urls)),
path("polls/", include(polls_urls)),
diff --git a/pyproject.toml b/pyproject.toml
index 20bb7232..87102c23 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -99,7 +99,9 @@ combine-as-imports = true
"comicsdb/models/__init__.py" = ["I001"]
"comicsdb/migrations/*.py" = ["E501", "N806"]
"django_nyt/migrations/*.py" = ["E501", "N806"]
+"issue_ratings/migrations/*.py" = ["E501", "N806"]
"pull_list/migrations/*.py" = ["E501", "N806"]
+"reading_lists/migrations/*.py" = ["E501", "N806"]
"tests/*/*.py" = ["PLR2004", "PT004", "S101", "PLR0913"]
"users/apps.py" = ["PLC0415",]
"users/migrations/*.py" = ["E501", "N806"]
diff --git a/reading_lists/TECHNICAL.md b/reading_lists/TECHNICAL.md
index 54486703..eeff1b9e 100644
--- a/reading_lists/TECHNICAL.md
+++ b/reading_lists/TECHNICAL.md
@@ -639,12 +639,14 @@ elif rating == 0:
**Response:**
-Returns the `reading_list_rating.html` partial template with context:
+Returns the shared `partials/rating_widget.html` partial template (also used by `issue_ratings`) with context:
-- `reading_list`: The rated reading list
+- `rated_object`: The rated reading list
+- `rate_url_name` / `rate_url_arg`: URL name and argument used to build the rate/clear endpoint
- `user_rating`: User's updated rating object
- `average_rating`: Recalculated average rating
- `rating_count`: Updated rating count
+- `show_ratings` / `can_rate`: Always `True` here, since the view already rejected private lists and self-rating before reaching this response
**URL:** `/reading-lists//rate/`
@@ -1202,7 +1204,7 @@ The web interface provides a dropdown with:
| `remove_issue_confirm.html` | Remove issue confirmation | Issue details |
| `partials/readinglist_card.html` | List card (grid item) | Cover thumbnail, list type tag, year range, rating stars, privacy badge, truncated description |
| `partials/readinglist_filter.html` | Filter/search panel | Quick search + collapsible advanced filters, active-filter tag |
-| `partials/reading_list_rating.html` | Rating component | HTMX-powered star rating, average display |
+| `partials/rating_widget.html` (shared, in top-level `templates/partials/`) | Rating component | HTMX-powered star rating, average display; also used by `issue_ratings` |
| `partials/readinglist_item.html` | Single item display | Issue type badge, inline edit button, remove button |
| `partials/readinglist_item_edit.html` | Single item edit form | Issue type dropdown, save/cancel buttons |
| `partials/readinglist_item_list.html` | Item list + load-more button | Rendered by `ReadingListItemsLoadMore`; re-renders itself and the load-more button via an out-of-band (`hx-swap-oob`) swap |
@@ -1253,16 +1255,16 @@ htmx.onLoad(function() {
### Rating Partial Template
-**File:** `partials/reading_list_rating.html`
+**File:** `partials/rating_widget.html` (shared with `issue_ratings`, lives in the top-level `templates/partials/` directory)
The rating system uses HTMX for instant, no-reload rating updates:
```html
diff --git a/reading_lists/migrations/0011_alter_readinglistrating_rating.py b/reading_lists/migrations/0011_alter_readinglistrating_rating.py
new file mode 100644
index 00000000..749c3e5a
--- /dev/null
+++ b/reading_lists/migrations/0011_alter_readinglistrating_rating.py
@@ -0,0 +1,20 @@
+# Generated by Django 6.0.7 on 2026-07-13 16:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("reading_lists", "0010_add_creator_list_type"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="readinglistrating",
+ name="rating",
+ field=models.PositiveSmallIntegerField(
+ choices=[(1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5")],
+ help_text="Star rating (1-5)",
+ ),
+ ),
+ ]
diff --git a/reading_lists/models.py b/reading_lists/models.py
index 1bb059ab..4e7a2f05 100644
--- a/reading_lists/models.py
+++ b/reading_lists/models.py
@@ -4,7 +4,7 @@
from django.urls import reverse
from sorl.thumbnail import ImageField
-from comicsdb.models.common import CommonInfo, pre_save_slug
+from comicsdb.models.common import AbstractRating, CommonInfo, pre_save_slug
from comicsdb.models.issue import Issue
from comicsdb.models.publisher import Publisher
from users.models import CustomUser
@@ -192,7 +192,7 @@ def __str__(self) -> str:
return f"{self.reading_list.name} - {self.issue} (Order: {self.order})"
-class ReadingListRating(models.Model):
+class ReadingListRating(AbstractRating):
"""User ratings for reading lists."""
reading_list = models.ForeignKey(
@@ -205,12 +205,6 @@ class ReadingListRating(models.Model):
on_delete=models.CASCADE,
related_name="reading_list_ratings",
)
- rating = models.PositiveSmallIntegerField(
- choices=[(i, str(i)) for i in range(1, 6)],
- help_text="Star rating (1-5) for this reading list",
- )
- created_on = models.DateTimeField(db_default=models.functions.Now())
- modified = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ["reading_list", "user"]
diff --git a/reading_lists/templates/reading_lists/partials/reading_list_rating.html b/reading_lists/templates/reading_lists/partials/reading_list_rating.html
deleted file mode 100644
index 6012c57e..00000000
--- a/reading_lists/templates/reading_lists/partials/reading_list_rating.html
+++ /dev/null
@@ -1,104 +0,0 @@
-
- {% if not reading_list.is_private %}
- {# Display average rating #}
-
- Average Rating:
- {% if average_rating %}
-
- {% for star_num in "12345" %}
-
- {% endfor %}
-
- {{ average_rating|floatformat:1 }} ({{ rating_count }} rating{{ rating_count|pluralize }})
- {% else %}
- No ratings yet
- {% endif %}
-
- {# User's rating (if authenticated and not the owner) #}
- {% if user.is_authenticated and reading_list.user != user %}
-
-
Your Rating:
-
- {% for star_num in "12345" %}
-
- {% endfor %}
- {% if user_rating %}
-
- {% endif %}
-
-
- {% endif %}
- {% endif %}
-
-
diff --git a/reading_lists/templates/reading_lists/readinglist_detail.html b/reading_lists/templates/reading_lists/readinglist_detail.html
index 2062d2b1..d10a92b3 100644
--- a/reading_lists/templates/reading_lists/readinglist_detail.html
+++ b/reading_lists/templates/reading_lists/readinglist_detail.html
@@ -272,7 +272,7 @@ List details
{{ reading_list.modified|date:"F d, Y" }}
- {% include "reading_lists/partials/reading_list_rating.html" %}
+ {% include "partials/rating_widget.html" with rated_object=reading_list rate_url_name="reading-list:rate" rate_url_arg=reading_list.slug user_rating=user_rating average_rating=average_rating rating_count=rating_count show_ratings=show_ratings can_rate=can_rate %}
{% if series_breakdown %}
diff --git a/reading_lists/views.py b/reading_lists/views.py
index 999a0d80..6c049f7d 100644
--- a/reading_lists/views.py
+++ b/reading_lists/views.py
@@ -18,6 +18,7 @@
from comicsdb.models.credits import Credits, Role
from comicsdb.models.issue import Issue
from comicsdb.views.mixins import LazyLoadMixin, SearchMixin
+from comicsdb.views.ratings import apply_rating_update
from reading_lists.forms import (
AddIssuesFromArcForm,
AddIssuesFromSeriesForm,
@@ -35,10 +36,6 @@
# Pagination constant for reading list detail view
READING_LIST_DETAIL_PAGINATE_BY = 50
-# Rating constants
-MIN_RATING = 1
-MAX_RATING = 5
-
_NON_FILTER_PARAMS = {"page"}
_FILTER_LABELS = {
@@ -463,6 +460,10 @@ def get_context_data(self, **kwargs):
context["user_rating"] = user_rating
context["average_rating"] = reading_list.average_rating
context["rating_count"] = reading_list.rating_count
+ context["show_ratings"] = not reading_list.is_private
+ context["can_rate"] = (
+ self.request.user.is_authenticated and reading_list.user != self.request.user
+ )
# Add annotated year data to context
context["start_year"] = reading_list.start_year_annotated
@@ -849,24 +850,11 @@ def update_reading_list_rating(request, slug):
if reading_list.user == request.user:
return HttpResponseForbidden("Cannot rate your own reading list")
- rating_value = request.POST.get("rating")
- if rating_value:
- try:
- rating = int(rating_value)
- if MIN_RATING <= rating <= MAX_RATING:
- # Update or create rating
- ReadingListRating.objects.update_or_create(
- reading_list=reading_list,
- user=request.user,
- defaults={"rating": rating},
- )
- elif rating == 0: # Allow clearing the rating
- ReadingListRating.objects.filter(
- reading_list=reading_list,
- user=request.user,
- ).delete()
- except ValueError:
- pass
+ apply_rating_update(
+ ReadingListRating,
+ {"reading_list": reading_list, "user": request.user},
+ request.POST.get("rating"),
+ )
# Get user's current rating and average
user_rating = ReadingListRating.objects.filter(
@@ -883,12 +871,16 @@ def update_reading_list_rating(request, slug):
# Return the updated rating partial
return render(
request,
- "reading_lists/partials/reading_list_rating.html",
+ "partials/rating_widget.html",
{
- "reading_list": reading_list,
+ "rated_object": reading_list,
+ "rate_url_name": "reading-list:rate",
+ "rate_url_arg": reading_list.slug,
"user_rating": user_rating,
"average_rating": avg_data["avg"],
"rating_count": avg_data["count"],
+ "show_ratings": True,
+ "can_rate": True,
},
)
diff --git a/templates/partials/rating_summary.html b/templates/partials/rating_summary.html
new file mode 100644
index 00000000..03d83b23
--- /dev/null
+++ b/templates/partials/rating_summary.html
@@ -0,0 +1,25 @@
+{% comment %}
+Compact average-rating summary (stars + count), used in page headers.
+
+Shares state with partials/rating_widget.html. Pass oob=True when rendering
+from a rating-update view so this refreshes via HTMX out-of-band swap
+alongside the main rating widget, instead of going stale until reload.
+
+Required context:
+ rated_object - the instance being rated
+ average_rating - float average, or None
+ rating_count - int
+ oob - bool, optional; adds hx-swap-oob
+{% endcomment %}
+
+ {% if average_rating %}
+ ·
+
+ {% for star_num in "12345" %}
+
+ {% endfor %}
+
+ {{ average_rating|floatformat:1 }} ({{ rating_count }} rating{{ rating_count|pluralize }})
+ {% endif %}
+
diff --git a/templates/partials/rating_widget.html b/templates/partials/rating_widget.html
new file mode 100644
index 00000000..a9bcfaa6
--- /dev/null
+++ b/templates/partials/rating_widget.html
@@ -0,0 +1,115 @@
+{% comment %}
+Generic 1-5 star rating widget, shared by reading_lists and issue_ratings.
+
+Required context:
+ rated_object - the instance being rated
+ rate_url_name - namespaced view name, e.g. "reading-list:rate" or "issue-ratings:rate"
+ rate_url_arg - the identifier the rate URL expects (slug, pk, ...)
+ user_rating - the requesting user's rating instance, or None
+ average_rating - float average, or None
+ rating_count - int
+ show_ratings - bool; whole widget renders only if true
+ can_rate - bool; gates the interactive "Your Rating" stars
+{% endcomment %}
+{% if show_ratings %}
+
+
+{% endif %}
diff --git a/tests/comicsdb/test_api_issue.py b/tests/comicsdb/test_api_issue.py
index 4976ff2e..c478d5e3 100644
--- a/tests/comicsdb/test_api_issue.py
+++ b/tests/comicsdb/test_api_issue.py
@@ -15,6 +15,7 @@
from comicsdb.models.series import Series
from comicsdb.models.team import Team
from comicsdb.models.universe import Universe
+from issue_ratings.models import IssueRating
@pytest.fixture
@@ -340,6 +341,39 @@ def test_filter_by_role_id_no_match(api_client_with_credentials, basic_issue: Is
assert resp.data["count"] == 0
+def test_list_excludes_rating_fields(api_client_with_credentials, list_of_issues):
+ """The list endpoint is high-traffic; rating fields are only exposed on detail."""
+ resp = api_client_with_credentials.get(reverse("api:issue-list"))
+ assert resp.status_code == status.HTTP_200_OK
+ assert "average_rating" not in resp.data["results"][0]
+ assert "rating_count" not in resp.data["results"][0]
+
+
+def test_detail_rating_fields_no_ratings(api_client_with_credentials, issue_with_arc: Issue):
+ resp = api_client_with_credentials.get(
+ reverse("api:issue-detail", kwargs={"pk": issue_with_arc.pk})
+ )
+ assert resp.status_code == status.HTTP_200_OK
+ assert resp.data["average_rating"] is None
+ assert resp.data["rating_count"] == 0
+
+
+def test_detail_rating_fields_with_ratings(
+ api_client_with_credentials, issue_with_arc: Issue, create_user
+):
+ user1 = create_user(username="api_rater_1")
+ user2 = create_user(username="api_rater_2")
+ IssueRating.objects.create(issue=issue_with_arc, user=user1, rating=5)
+ IssueRating.objects.create(issue=issue_with_arc, user=user2, rating=3)
+
+ resp = api_client_with_credentials.get(
+ reverse("api:issue-detail", kwargs={"pk": issue_with_arc.pk})
+ )
+ assert resp.status_code == status.HTTP_200_OK
+ assert resp.data["average_rating"] == 4.0
+ assert resp.data["rating_count"] == 2
+
+
def test_filter_by_multiple_role_ids(
api_client_with_credentials, basic_issue: Issue, john_byrne: Creator, writer: Role
):
diff --git a/tests/comicsdb/test_issue_views.py b/tests/comicsdb/test_issue_views.py
index 99b19ace..80291b79 100644
--- a/tests/comicsdb/test_issue_views.py
+++ b/tests/comicsdb/test_issue_views.py
@@ -10,6 +10,7 @@
from comicsdb.models.issue import Issue
from comicsdb.models.publisher import Publisher
from comicsdb.models.series import Series, SeriesType
+from issue_ratings.models import IssueRating
from wish_list.models import WishList, WishListItem
HTML_OK_CODE = 200
@@ -48,6 +49,28 @@ def test_issue_detail_on_wish_list_true_when_on_list(basic_issue, auto_login_use
assert resp.context["on_wish_list"] is True
+def test_issue_detail_rating_context_no_ratings(basic_issue, auto_login_user):
+ client, _ = auto_login_user()
+ resp = client.get(f"/issue/{basic_issue.slug}/")
+ assert resp.status_code == HTML_OK_CODE
+ assert resp.context["average_rating"] is None
+ assert resp.context["rating_count"] == 0
+ assert resp.context["user_rating"] is None
+
+
+def test_issue_detail_rating_context_with_ratings(basic_issue, auto_login_user, create_user):
+ client, user = auto_login_user()
+ other_user = create_user(username="other_rater")
+ IssueRating.objects.create(issue=basic_issue, user=user, rating=4)
+ IssueRating.objects.create(issue=basic_issue, user=other_user, rating=2)
+
+ resp = client.get(f"/issue/{basic_issue.slug}/")
+ assert resp.status_code == HTML_OK_CODE
+ assert resp.context["average_rating"] == 3.0
+ assert resp.context["rating_count"] == 2
+ assert resp.context["user_rating"].rating == 4
+
+
# Issue Search
def test_issue_search_view_url_exists_at_desired_location(auto_login_user):
client, _ = auto_login_user()
diff --git a/tests/issue_ratings/__init__.py b/tests/issue_ratings/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/issue_ratings/conftest.py b/tests/issue_ratings/conftest.py
new file mode 100644
index 00000000..b74430ac
--- /dev/null
+++ b/tests/issue_ratings/conftest.py
@@ -0,0 +1,61 @@
+"""Fixtures for issue_ratings app tests."""
+
+from datetime import date
+
+import pytest
+
+from comicsdb.models.issue import Issue
+from comicsdb.models.publisher import Publisher
+from comicsdb.models.series import Series
+
+
+@pytest.fixture
+def rating_user(db, create_user):
+ """Create a user for issue rating tests."""
+ return create_user(username="rating_user")
+
+
+@pytest.fixture
+def other_rating_user(db, create_user):
+ """Create another user for issue rating tests."""
+ return create_user(username="other_rating_user")
+
+
+@pytest.fixture
+def rating_publisher(create_user):
+ """Create a publisher for issue rating tests."""
+ user = create_user()
+ return Publisher.objects.create(
+ name="Rating Test Publisher", slug="rating-test-publisher", edited_by=user, created_by=user
+ )
+
+
+@pytest.fixture
+def rating_series(create_user, rating_publisher, single_issue_type):
+ """Create a series for issue rating tests."""
+ user = create_user()
+ return Series.objects.create(
+ name="Rating Test Series",
+ slug="rating-test-series",
+ publisher=rating_publisher,
+ volume="1",
+ year_began=2020,
+ series_type=single_issue_type,
+ status=Series.Status.ONGOING,
+ edited_by=user,
+ created_by=user,
+ )
+
+
+@pytest.fixture
+def rating_issue(create_user, rating_series):
+ """Create an issue for issue rating tests."""
+ user = create_user()
+ return Issue.objects.create(
+ series=rating_series,
+ number="1",
+ slug="rating-test-series-1",
+ cover_date=date(2020, 1, 1),
+ edited_by=user,
+ created_by=user,
+ )
diff --git a/tests/issue_ratings/test_backfill_command.py b/tests/issue_ratings/test_backfill_command.py
new file mode 100644
index 00000000..055aee93
--- /dev/null
+++ b/tests/issue_ratings/test_backfill_command.py
@@ -0,0 +1,95 @@
+"""Tests for the backfill_issue_ratings management command."""
+
+import io
+
+from django.core.management import call_command
+from django.db.models.signals import post_save
+
+from issue_ratings.models import IssueRating
+from user_collection.models import CollectionItem
+from user_collection.signals import sync_issue_rating_from_collection_item
+
+
+def create_legacy_rated_item(**kwargs):
+ """Create a CollectionItem with a rating without the sync signal firing.
+
+ Simulates data that predates the user_collection -> issue_ratings sync feature.
+ """
+ post_save.disconnect(
+ sync_issue_rating_from_collection_item,
+ sender=CollectionItem,
+ dispatch_uid="post_save_sync_issue_rating_from_collection_item",
+ )
+ try:
+ return CollectionItem.objects.create(**kwargs)
+ finally:
+ post_save.connect(
+ sync_issue_rating_from_collection_item,
+ sender=CollectionItem,
+ dispatch_uid="post_save_sync_issue_rating_from_collection_item",
+ )
+
+
+def run_backfill(**options):
+ out = io.StringIO()
+ call_command("backfill_issue_ratings", stdout=out, **options)
+ return out.getvalue()
+
+
+def test_backfill_creates_missing_issue_ratings(rating_issue, rating_user):
+ item = create_legacy_rated_item(issue=rating_issue, user=rating_user, rating=4)
+ assert not IssueRating.objects.filter(issue=item.issue, user=item.user).exists()
+
+ output = run_backfill()
+
+ rating = IssueRating.objects.get(issue=item.issue, user=item.user)
+ assert rating.rating == 4
+ assert "1 to create" in output
+
+
+def test_backfill_updates_conflicting_issue_ratings(rating_issue, rating_user):
+ item = create_legacy_rated_item(issue=rating_issue, user=rating_user, rating=5)
+ IssueRating.objects.create(issue=item.issue, user=item.user, rating=2)
+
+ output = run_backfill()
+
+ rating = IssueRating.objects.get(issue=item.issue, user=item.user)
+ assert rating.rating == 5
+ assert "1 to update" in output
+
+
+def test_backfill_leaves_matching_ratings_unchanged(rating_issue, rating_user):
+ item = create_legacy_rated_item(issue=rating_issue, user=rating_user, rating=3)
+ IssueRating.objects.create(issue=item.issue, user=item.user, rating=3)
+
+ output = run_backfill()
+
+ assert "1 already in sync" in output
+
+
+def test_backfill_ignores_collection_items_without_rating(rating_issue, rating_user):
+ create_legacy_rated_item(issue=rating_issue, user=rating_user, rating=None)
+
+ output = run_backfill()
+
+ assert not IssueRating.objects.filter(issue=rating_issue, user=rating_user).exists()
+ assert "0 rated collection item(s)" in output
+
+
+def test_backfill_dry_run_makes_no_changes(rating_issue, rating_user):
+ item = create_legacy_rated_item(issue=rating_issue, user=rating_user, rating=4)
+
+ output = run_backfill(dry_run=True)
+
+ assert not IssueRating.objects.filter(issue=item.issue, user=item.user).exists()
+ assert "Would sync" in output
+ assert "1 to create" in output
+
+
+def test_backfill_is_idempotent(rating_issue, rating_user):
+ create_legacy_rated_item(issue=rating_issue, user=rating_user, rating=4)
+
+ run_backfill()
+ second_run_output = run_backfill()
+
+ assert "0 to create, 0 to update" in second_run_output
diff --git a/tests/issue_ratings/test_views.py b/tests/issue_ratings/test_views.py
new file mode 100644
index 00000000..0895d619
--- /dev/null
+++ b/tests/issue_ratings/test_views.py
@@ -0,0 +1,86 @@
+"""Tests for issue_ratings views."""
+
+from django.db.models import Avg
+from django.urls import reverse
+
+from issue_ratings.models import IssueRating
+
+HTTP_200_OK = 200
+HTTP_302_FOUND = 302
+
+
+class TestIssueRating:
+ """Test cases for issue rating functionality."""
+
+ def test_update_rating_requires_login(self, client, rating_issue):
+ """Test that rating requires authentication."""
+ url = reverse("issue-ratings:rate", kwargs={"pk": rating_issue.pk})
+ resp = client.post(url, data={"rating": "5"})
+ assert resp.status_code == HTTP_302_FOUND
+ assert "/login/" in resp.url
+
+ def test_update_rating_set_rating(self, client, rating_issue, test_password, rating_user):
+ """Test setting a rating on an issue."""
+ client.login(username=rating_user.username, password=test_password)
+ url = reverse("issue-ratings:rate", kwargs={"pk": rating_issue.pk})
+
+ resp = client.post(url, data={"rating": "5"})
+ assert resp.status_code == HTTP_200_OK
+
+ rating = IssueRating.objects.filter(issue=rating_issue, user=rating_user).first()
+ assert rating is not None
+ assert rating.rating == 5
+
+ def test_update_rating_updates_existing(self, client, rating_issue, test_password, rating_user):
+ """Test that rating twice updates rather than duplicates the row."""
+ client.login(username=rating_user.username, password=test_password)
+ url = reverse("issue-ratings:rate", kwargs={"pk": rating_issue.pk})
+
+ client.post(url, data={"rating": "2"})
+ client.post(url, data={"rating": "4"})
+
+ ratings = IssueRating.objects.filter(issue=rating_issue, user=rating_user)
+ assert ratings.count() == 1
+ assert ratings.first().rating == 4
+
+ def test_update_rating_clear_rating(self, client, rating_issue, test_password, rating_user):
+ """Test clearing a rating."""
+ IssueRating.objects.create(issue=rating_issue, user=rating_user, rating=5)
+
+ client.login(username=rating_user.username, password=test_password)
+ url = reverse("issue-ratings:rate", kwargs={"pk": rating_issue.pk})
+
+ resp = client.post(url, data={"rating": "0"})
+ assert resp.status_code == HTTP_200_OK
+
+ assert not IssueRating.objects.filter(issue=rating_issue, user=rating_user).exists()
+
+ def test_update_rating_invalid_values(self, client, rating_issue, test_password, rating_user):
+ """Test that invalid rating values are rejected."""
+ client.login(username=rating_user.username, password=test_password)
+ url = reverse("issue-ratings:rate", kwargs={"pk": rating_issue.pk})
+
+ resp = client.post(url, data={"rating": "10"})
+ assert resp.status_code == HTTP_200_OK
+ assert not IssueRating.objects.filter(issue=rating_issue, user=rating_user).exists()
+
+ resp = client.post(url, data={"rating": "-1"})
+ assert resp.status_code == HTTP_200_OK
+ assert not IssueRating.objects.filter(issue=rating_issue, user=rating_user).exists()
+
+ resp = client.post(url, data={"rating": "not-a-number"})
+ assert resp.status_code == HTTP_200_OK
+ assert not IssueRating.objects.filter(issue=rating_issue, user=rating_user).exists()
+
+ def test_average_rating_calculation(self, rating_issue, create_user):
+ """Test that average rating is calculated correctly."""
+ user1 = create_user(username="avg_user1")
+ user2 = create_user(username="avg_user2")
+ user3 = create_user(username="avg_user3")
+
+ IssueRating.objects.create(issue=rating_issue, user=user1, rating=5)
+ IssueRating.objects.create(issue=rating_issue, user=user2, rating=3)
+ IssueRating.objects.create(issue=rating_issue, user=user3, rating=4)
+
+ avg_rating = rating_issue.ratings.aggregate(avg=Avg("rating"))["avg"]
+ assert avg_rating == 4.0
diff --git a/tests/user_collection/test_collection_update.py b/tests/user_collection/test_collection_update.py
index 5795b065..bb342c91 100644
--- a/tests/user_collection/test_collection_update.py
+++ b/tests/user_collection/test_collection_update.py
@@ -3,6 +3,7 @@
from django.urls import reverse
from rest_framework import status
+from issue_ratings.models import IssueRating
from user_collection.models import CollectionItem, ReadDate
@@ -30,6 +31,18 @@ def test_patch_updates_rating(api_client, collection_user, collection_item):
assert collection_item.rating == 4
+def test_patch_rating_syncs_to_issue_rating(api_client, collection_user, collection_item):
+ """PATCHing a collection item's rating syncs the community IssueRating too."""
+ api_client.force_authenticate(user=collection_user)
+
+ api_client.patch(
+ reverse("api:collection-detail", args=[collection_item.id]),
+ {"rating": 4},
+ )
+ rating = IssueRating.objects.get(issue=collection_item.issue, user=collection_user)
+ assert rating.rating == 4
+
+
def test_patch_rating_creates_no_read_date_rows(api_client, collection_user, collection_item):
"""Updating rating via PATCH does not create any ReadDate rows."""
api_client.force_authenticate(user=collection_user)
diff --git a/tests/user_collection/test_signals.py b/tests/user_collection/test_signals.py
new file mode 100644
index 00000000..13a36912
--- /dev/null
+++ b/tests/user_collection/test_signals.py
@@ -0,0 +1,70 @@
+"""Tests for user_collection signals."""
+
+from issue_ratings.models import IssueRating
+from user_collection.models import CollectionItem
+
+
+def test_setting_collection_rating_creates_issue_rating(collection_user, collection_item):
+ """Saving a CollectionItem with a rating creates a matching community IssueRating."""
+ assert not IssueRating.objects.filter(
+ issue=collection_item.issue, user=collection_user
+ ).exists()
+
+ collection_item.rating = 4
+ collection_item.save(update_fields=["rating"])
+
+ rating = IssueRating.objects.get(issue=collection_item.issue, user=collection_user)
+ assert rating.rating == 4
+
+
+def test_updating_collection_rating_updates_issue_rating(collection_user, collection_item):
+ """Changing an existing collection rating updates the IssueRating rather than duplicating it."""
+ collection_item.rating = 2
+ collection_item.save(update_fields=["rating"])
+
+ collection_item.rating = 5
+ collection_item.save(update_fields=["rating"])
+
+ ratings = IssueRating.objects.filter(issue=collection_item.issue, user=collection_user)
+ assert ratings.count() == 1
+ assert ratings.first().rating == 5
+
+
+def test_clearing_collection_rating_deletes_issue_rating(collection_user, collection_item):
+ """Clearing a collection rating removes the corresponding IssueRating."""
+ collection_item.rating = 3
+ collection_item.save(update_fields=["rating"])
+ assert IssueRating.objects.filter(issue=collection_item.issue, user=collection_user).exists()
+
+ collection_item.rating = None
+ collection_item.save(update_fields=["rating"])
+
+ assert not IssueRating.objects.filter(
+ issue=collection_item.issue, user=collection_user
+ ).exists()
+
+
+def test_sync_overwrites_independently_set_issue_rating(collection_user, collection_item):
+ """Setting a collection rating overwrites a pre-existing, independently-set community rating."""
+ IssueRating.objects.create(issue=collection_item.issue, user=collection_user, rating=1)
+
+ collection_item.rating = 5
+ collection_item.save(update_fields=["rating"])
+
+ rating = IssueRating.objects.get(issue=collection_item.issue, user=collection_user)
+ assert rating.rating == 5
+
+
+def test_sync_scoped_to_issue_and_user(collection_user, other_collection_user, collection_issue_1):
+ """Syncing only ever touches the (issue, user) pair for the saved collection item."""
+ CollectionItem.objects.create(
+ user=other_collection_user,
+ issue=collection_issue_1,
+ quantity=1,
+ rating=2,
+ )
+
+ ratings = IssueRating.objects.filter(issue=collection_issue_1)
+ assert ratings.count() == 1
+ assert ratings.get().user == other_collection_user
+ assert ratings.get().rating == 2
diff --git a/tests/user_collection/test_views.py b/tests/user_collection/test_views.py
index ac19dd8d..71a0121a 100644
--- a/tests/user_collection/test_views.py
+++ b/tests/user_collection/test_views.py
@@ -8,6 +8,7 @@
from comicsdb.models.issue import Issue
from comicsdb.models.series import Series
+from issue_ratings.models import IssueRating
from user_collection.models import CollectionItem, ReadDate
HTTP_200_OK = 200
@@ -1130,6 +1131,22 @@ def test_update_rating_set_rating(
collection_item.refresh_from_db()
assert collection_item.rating == 3
+ def test_update_rating_syncs_to_issue_rating(
+ self, client, collection_user, collection_item, test_password
+ ):
+ """Rating a collection item through the HTMX endpoint syncs the community IssueRating."""
+ client.login(username=collection_user.username, password=test_password)
+ url = reverse("user_collection:rate", kwargs={"pk": collection_item.pk})
+
+ client.post(url, {"rating": "4"})
+ rating = IssueRating.objects.get(issue=collection_item.issue, user=collection_user)
+ assert rating.rating == 4
+
+ client.post(url, {"rating": "0"})
+ assert not IssueRating.objects.filter(
+ issue=collection_item.issue, user=collection_user
+ ).exists()
+
def test_update_rating_all_valid_values(
self, client, collection_user, collection_item, test_password
):
diff --git a/user_collection/apps.py b/user_collection/apps.py
index 7293a21f..b5fe26a8 100644
--- a/user_collection/apps.py
+++ b/user_collection/apps.py
@@ -1,6 +1,17 @@
from django.apps import AppConfig
+from django.db.models.signals import post_save
+
+from user_collection.signals import sync_issue_rating_from_collection_item
class UserCollectionConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "user_collection"
+
+ def ready(self):
+ collection_item = self.get_model("CollectionItem")
+ post_save.connect(
+ sync_issue_rating_from_collection_item,
+ sender=collection_item,
+ dispatch_uid="post_save_sync_issue_rating_from_collection_item",
+ )
diff --git a/user_collection/signals.py b/user_collection/signals.py
new file mode 100644
index 00000000..5828e299
--- /dev/null
+++ b/user_collection/signals.py
@@ -0,0 +1,15 @@
+def sync_issue_rating_from_collection_item(sender, instance, **kwargs):
+ """Keep the community IssueRating in sync with a user's personal collection rating."""
+ from issue_ratings.models import IssueRating # noqa: PLC0415
+
+ if instance.rating is not None:
+ IssueRating.objects.update_or_create(
+ issue_id=instance.issue_id,
+ user_id=instance.user_id,
+ defaults={"rating": instance.rating},
+ )
+ else:
+ IssueRating.objects.filter(
+ issue_id=instance.issue_id,
+ user_id=instance.user_id,
+ ).delete()
diff --git a/user_collection/views.py b/user_collection/views.py
index 793e16b7..4211c513 100644
--- a/user_collection/views.py
+++ b/user_collection/views.py
@@ -27,13 +27,10 @@
from comicsdb.models.issue import Issue
from comicsdb.models.publisher import Publisher
from comicsdb.models.series import Series, SeriesType
+from comicsdb.views.ratings import parse_rating_action
from user_collection.forms import AddIssuesFromSeriesForm, CollectionItemForm
from user_collection.models import GRADE_CHOICES, CollectionItem, ReadDate
-# Rating constants
-MIN_RATING = 1
-MAX_RATING = 5
-
class CollectionListView(LoginRequiredMixin, ListView):
"""Display the current user's collection items."""
@@ -481,18 +478,11 @@ def update_rating(request, pk):
"""HTMX view to update the rating of a collection item."""
item = get_object_or_404(CollectionItem, pk=pk, user=request.user)
- rating_value = request.POST.get("rating")
- if rating_value:
- try:
- rating = int(rating_value)
- # Allow ratings from MIN_RATING to MAX_RATING
- if MIN_RATING <= rating <= MAX_RATING:
- item.rating = rating
- elif rating == 0: # Allow clearing the rating
- item.rating = None
- item.save(update_fields=["rating"])
- except ValueError:
- pass
+ action = parse_rating_action(request.POST.get("rating"))
+ if action is not None:
+ kind, rating = action
+ item.rating = rating if kind == "set" else None
+ item.save(update_fields=["rating"])
# Return the updated star rating partial
return render(