Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions api/v1_0/serializers/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ class IssueReadSerializer(serializers.ModelSerializer):
series = IssueSeriesSerializer(read_only=True)
reprints = ReprintSerializer(many=True, read_only=True)
rating = RatingSerializer(read_only=True)
average_rating = serializers.FloatField(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:
Expand Down Expand Up @@ -327,6 +329,8 @@ class Meta:
"desc",
"image",
"cover_hash",
"average_rating",
"rating_count",
"arcs",
"credits",
"characters",
Expand Down
55 changes: 31 additions & 24 deletions api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,30 +356,37 @@ 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"),
rating_count=Count("ratings", distinct=True),
)
)

def get_serializer_class(self):
Expand Down
18 changes: 18 additions & 0 deletions comicsdb/models/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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
11 changes: 11 additions & 0 deletions comicsdb/templates/comicsdb/issue_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ <h1 class="title mb-2">{{ issue }}</h1>
<span class="tag is-warning is-light is-rounded ml-2"
title="{{ issue.rating.short_description }}">{{ issue.rating.name }}</span>
{% endif %}
{% if average_rating %}
<span class="has-text-grey">&middot;</span>
<span style="color: #ffce54;">
{% for star_num in "12345" %}
<i class="{% if star_num|add:'0' <= average_rating %}fas{% else %}far{% endif %} fa-star"></i>
{% endfor %}
</span>
<span class="has-text-grey ml-1">{{ average_rating|floatformat:1 }} ({{ rating_count }} rating{{ rating_count|pluralize }})</span>
{% endif %}
</p>
</header>
{# Navigation Bar #}
Expand Down Expand Up @@ -628,6 +637,8 @@ <h3 class="title is-6 mt-4">Story Arc{{ arcs|pluralize }}</h3>
</ul>
{% endif %}
{% endwith %}
<hr>
{% 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 %}
</div>
<div class="box">
<h2 class="title is-6">Identification Numbers</h2>
Expand Down
11 changes: 10 additions & 1 deletion comicsdb/views/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, Prefetch
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
Expand All @@ -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
Expand Down Expand Up @@ -189,6 +190,14 @@ 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()

avg_data = issue.ratings.aggregate(avg=Avg("rating"), count=Count("id"))
context["average_rating"] = avg_data["avg"]
context["rating_count"] = avg_data["count"]

return context

Expand Down
38 changes: 38 additions & 0 deletions comicsdb/views/ratings.py
Original file line number Diff line number Diff line change
@@ -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()
Empty file added issue_ratings/__init__.py
Empty file.
12 changes: 12 additions & 0 deletions issue_ratings/admin.py
Original file line number Diff line number Diff line change
@@ -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"]
6 changes: 6 additions & 0 deletions issue_ratings/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class IssueRatingsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "issue_ratings"
Empty file.
Empty file.
60 changes: 60 additions & 0 deletions issue_ratings/management/commands/backfill_issue_ratings.py
Original file line number Diff line number Diff line change
@@ -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."
)
)
63 changes: 63 additions & 0 deletions issue_ratings/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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")},
},
),
]
20 changes: 20 additions & 0 deletions issue_ratings/migrations/0002_alter_issuerating_rating.py
Original file line number Diff line number Diff line change
@@ -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)",
),
),
]
Empty file.
Loading
Loading