diff --git a/common/static/common/styles/_extension.sass b/common/static/common/styles/_extension.sass
index d2b2b0f5..33127a35 100644
--- a/common/static/common/styles/_extension.sass
+++ b/common/static/common/styles/_extension.sass
@@ -383,6 +383,9 @@
width: 100%
.rating-form
+ .box
+ margin-left: calc(var(--spacer) * 2.5)
+
select
color: var(--color-warning)
@@ -391,6 +394,15 @@
&:focus
color: var(--color-warning)
+ textarea
+ min-height: calc(var(--spacer) * 6)
+
+.rating-reply
+ @extend blockquote
+
+ +margin(3, top)
+ margin-bottom: 0 !important
+
// TODO: consider adding component boxed nav generic to web-assets, and make variants on top of that
.nav-pills
@extend .dropdown-menu
diff --git a/extensions/models.py b/extensions/models.py
index 6623a199..9f8b3bfb 100644
--- a/extensions/models.py
+++ b/extensions/models.py
@@ -349,7 +349,6 @@ class Extension(CreatedModifiedMixin, TrackChangesMixin, models.Model):
return (
not self.has_maintainer(user)
and not self.ratings.filter(
- reply_to=None,
user_id=user.pk,
).exists()
)
@@ -401,13 +400,7 @@ class Extension(CreatedModifiedMixin, TrackChangesMixin, models.Model):
@property
def text_ratings_count(self) -> int:
- return len(
- [
- r
- for r in self.ratings.all()
- if r.text is not None and r.is_listed and r.reply_to is None
- ]
- )
+ return len([r for r in self.ratings.all() if r.text is not None and r.is_listed])
@property
def total_ratings_count(self) -> int:
diff --git a/extensions/templates/extensions/detail.html b/extensions/templates/extensions/detail.html
index d2fac6f9..23dffd93 100644
--- a/extensions/templates/extensions/detail.html
+++ b/extensions/templates/extensions/detail.html
@@ -302,6 +302,7 @@
+ {% with ratings_listed_count=extension.ratings.listed.count %}
{% if my_rating and not my_rating.is_listed %}
@@ -311,19 +312,20 @@
{% include "ratings/components/rating.html" with classes="mb-2" %}
{% endfor %}
- {% if not extension.ratings.listed.count and not my_rating %}
+ {% if not ratings_listed_count and not my_rating %}
{% trans "Be the first to review." %}
{% endif %}
{# Rating #}
- {% if extension.ratings.listed.count %}
+ {% if ratings_listed_count %}
{% include "ratings/components/summary.html" %}
{% endif %}
+ {% endwith %}
{% endblock extension_reviews %}
diff --git a/extensions/views/mixins.py b/extensions/views/mixins.py
index dfb9cafe..03fc6168 100644
--- a/extensions/views/mixins.py
+++ b/extensions/views/mixins.py
@@ -42,7 +42,10 @@ class ListedExtensionMixin:
"""Fetch a publicly listed extension by slug in the URL before dispatching the view."""
def dispatch(self, *args, **kwargs):
- self.extension = get_object_or_404(Extension.objects.listed, slug=self.kwargs['slug'])
+ self.extension = get_object_or_404(
+ Extension.objects.listed.prefetch_related('authors', 'ratings'),
+ slug=self.kwargs['slug'],
+ )
return super().dispatch(*args, **kwargs)
diff --git a/extensions/views/public.py b/extensions/views/public.py
index e3f20143..5b4b4265 100644
--- a/extensions/views/public.py
+++ b/extensions/views/public.py
@@ -231,6 +231,8 @@ class ExtensionDetailView(DetailView):
'preview_set',
'preview_set__file',
'ratings',
+ 'ratings__ratingreply',
+ 'ratings__ratingreply__user',
'ratings__user',
'team',
'versions',
@@ -255,6 +257,7 @@ class ExtensionDetailView(DetailView):
context['my_rating'] = ratings.models.Rating.get_for(
self.request.user.pk, self.object.pk
)
+ context['is_maintainer'] = self.object.has_maintainer(self.request.user)
extension = context['object']
# Add the image for "og:image" meta to the context
if extension.featured_image and extension.featured_image.is_listed:
diff --git a/ratings/admin.py b/ratings/admin.py
index 9c6cf1cc..4ea0bff7 100644
--- a/ratings/admin.py
+++ b/ratings/admin.py
@@ -7,40 +7,6 @@ from extensions.models import Version
from .models import Rating
-class RatingTypeFilter(admin.SimpleListFilter):
- # Human-readable title which will be displayed in the
- # right admin sidebar just above the filter options.
- title = 'Type'
-
- # Parameter for the filter that will be used in the URL query.
- parameter_name = 'type'
-
- def lookups(self, request, model_admin):
- """Return a list of lookup option tuples.
-
- The first element in each tuple is the coded value
- for the option that will appear in the URL query.
- The second element is the human-readable name for
- the option that will appear in the right sidebar.
- """
- return (
- ('rating', 'User Rating'),
- ('reply', 'Developer/Admin Reply'),
- )
-
- def queryset(self, request, queryset):
- """Return the filtered queryset.
-
- Filter based on the value provided in the query string
- and retrievable via `self.value()`.
- """
- if self.value() == 'rating':
- return queryset.filter(reply_to__isnull=True)
- elif self.value() == 'reply':
- return queryset.filter(reply_to__isnull=False)
- return queryset
-
-
class RatingAdmin(admin.ModelAdmin):
date_hierarchy = 'date_created'
search_fields = ('extension__slug', 'extension__name', 'user__email', 'user__full_name')
@@ -48,7 +14,6 @@ class RatingAdmin(admin.ModelAdmin):
'extension',
'version',
'user',
- 'reply_to',
)
readonly_fields = (
'date_created',
@@ -66,19 +31,17 @@ class RatingAdmin(admin.ModelAdmin):
'user',
'ip_address',
'score',
- 'is_reply',
'status',
'truncated_text',
)
- list_filter = ('status', RatingTypeFilter, 'score')
+ list_filter = ('status', 'score')
actions = ('delete_selected',)
- list_select_related = ('user',) # For extension/reply_to see get_queryset()
+ list_select_related = ('user',)
def get_queryset(self, request):
base_qs = Rating.objects.all()
return base_qs.prefetch_related(
Prefetch('version', queryset=Version.objects.all()),
- Prefetch('reply_to', queryset=base_qs),
)
def has_add_permission(self, request):
@@ -87,11 +50,5 @@ class RatingAdmin(admin.ModelAdmin):
def truncated_text(self, obj):
return truncatechars(obj.text, 140) if obj.text else ''
- def is_reply(self, obj):
- return bool(obj.reply_to)
-
- is_reply.boolean = True
- is_reply.admin_order_field = 'reply_to'
-
admin.site.register(Rating, RatingAdmin)
diff --git a/ratings/migrations/0007_ratingreply_and_more.py b/ratings/migrations/0007_ratingreply_and_more.py
new file mode 100644
index 00000000..a4ab9f7c
--- /dev/null
+++ b/ratings/migrations/0007_ratingreply_and_more.py
@@ -0,0 +1,58 @@
+# Generated by Django 4.2.11 on 2024-06-10 14:01
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('ratings', '0006_alter_ratingflag_user'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='RatingReply',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('date_created', models.DateTimeField(auto_now_add=True)),
+ ('date_modified', models.DateTimeField(auto_now=True)),
+ ('text', models.TextField(null=True)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.RemoveConstraint(
+ model_name='rating',
+ name='rating_one_review_per_user_key',
+ ),
+ migrations.RemoveIndex(
+ model_name='rating',
+ name='rating_latest_idx',
+ ),
+ migrations.RemoveField(
+ model_name='rating',
+ name='reply_to',
+ ),
+ migrations.AddIndex(
+ model_name='rating',
+ index=models.Index(fields=['is_latest', 'date_created'], name='rating_latest_idx'),
+ ),
+ migrations.AddConstraint(
+ model_name='rating',
+ constraint=models.UniqueConstraint(fields=('version', 'user'), name='rating_one_review_per_user_key'),
+ ),
+ migrations.AddField(
+ model_name='ratingreply',
+ name='rating',
+ field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='ratings.rating'),
+ ),
+ migrations.AddField(
+ model_name='ratingreply',
+ name='user',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
+ ),
+ ]
diff --git a/ratings/models.py b/ratings/models.py
index e55aa289..c42854a6 100644
--- a/ratings/models.py
+++ b/ratings/models.py
@@ -14,14 +14,13 @@ log = logging.getLogger(__name__)
class RatingManager(models.Manager):
- # TODO: figure out how to retrieve reviews "annotated" with replies, if any
@property
def listed(self):
- return self.filter(status=self.model.STATUSES.APPROVED, reply_to__isnull=True)
+ return self.filter(status=self.model.STATUSES.APPROVED)
@property
def unlisted(self):
- return self.exclude(status=self.models.STATUSES.APPROVED, reply_to__isnull=True)
+ return self.exclude(status=self.models.STATUSES.APPROVED)
@property
def listed_texts(self):
@@ -41,12 +40,6 @@ class Rating(CreatedModifiedMixin, TrackChangesMixin, models.Model):
'extensions.Version', related_name='ratings', on_delete=models.CASCADE
)
user = models.ForeignKey(User, related_name='ratings', on_delete=models.CASCADE)
- reply_to = models.OneToOneField(
- 'self',
- null=True,
- related_name='reply',
- on_delete=models.CASCADE,
- )
score = models.PositiveSmallIntegerField(null=True, choices=SCORES)
text = models.TextField(null=True)
@@ -74,14 +67,14 @@ class Rating(CreatedModifiedMixin, TrackChangesMixin, models.Model):
models.Index(fields=('version',), name='rating_version_id'),
models.Index(fields=('user',), name='rating_user_idx'),
models.Index(
- fields=('reply_to', 'is_latest', 'date_created'),
+ fields=('is_latest', 'date_created'),
name='rating_latest_idx',
),
models.Index(fields=('ip_address',), name='rating_ip_address_idx'),
]
constraints = [
models.UniqueConstraint(
- fields=('version', 'user', 'reply_to'), name='rating_one_review_per_user_key'
+ fields=('version', 'user'), name='rating_one_review_per_user_key'
),
]
@@ -98,7 +91,6 @@ class Rating(CreatedModifiedMixin, TrackChangesMixin, models.Model):
def get_for(cls, user_id: int, extension_id: int):
"""Get rating left by a given user for a given extension."""
return cls.objects.filter(
- reply_to=None,
user_id=user_id,
extension_id=extension_id,
).first()
@@ -131,12 +123,6 @@ class Rating(CreatedModifiedMixin, TrackChangesMixin, models.Model):
# user_responsible=user.
self.save()
- @classmethod
- def get_replies(cls, ratings):
- ratings = [r.id for r in ratings]
- qs = Rating.objects.filter(reply_to__in=ratings)
- return {r.reply_to_id: r for r in qs}
-
def post_save(sender, instance, created, **kwargs):
if kwargs.get('raw'):
return
@@ -147,10 +133,7 @@ class Rating(CreatedModifiedMixin, TrackChangesMixin, models.Model):
# when manipulating a Rating that indicates a real user made that
# change.
action = 'New' if created else 'Edited'
- if instance.reply_to:
- log.info(f'{action} reply to {instance.reply_to_id}: {instance.pk}')
- else:
- log.info(f'{action} rating: {instance.pk}')
+ log.info(f'{action} rating: {instance.pk}')
def get_report_url(self):
return reverse(
@@ -163,6 +146,16 @@ class Rating(CreatedModifiedMixin, TrackChangesMixin, models.Model):
],
)
+ def get_reply_url(self):
+ return reverse(
+ 'ratings:reply',
+ args=[
+ self.extension.type_slug,
+ self.extension.slug,
+ self.id,
+ ],
+ )
+
class RatingFlag(CreatedModifiedMixin, models.Model):
SPAM = 'review_flag_reason_spam'
@@ -190,3 +183,9 @@ class RatingFlag(CreatedModifiedMixin, models.Model):
constraints = [
models.UniqueConstraint(fields=('rating', 'user'), name='ratingflag_review_user_key')
]
+
+
+class RatingReply(CreatedModifiedMixin, models.Model):
+ rating = models.OneToOneField(Rating, on_delete=models.CASCADE)
+ user = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
+ text = models.TextField(null=True)
diff --git a/ratings/templates/ratings/components/rating.html b/ratings/templates/ratings/components/rating.html
index 18c5b5a1..05c4e95c 100644
--- a/ratings/templates/ratings/components/rating.html
+++ b/ratings/templates/ratings/components/rating.html
@@ -43,8 +43,30 @@
{% endif %}
+ {% if is_maintainer and not rating.ratingreply %}
+
+
+
+ {% endif %}
{{ rating.text|markdown }}
+
+ {% if rating.ratingreply %}
+
+
+ {{ rating.ratingreply.text|markdown }}
+
+ {% endif %}
diff --git a/ratings/templates/ratings/components/summary.html b/ratings/templates/ratings/components/summary.html
index 7690907f..ed479d8d 100644
--- a/ratings/templates/ratings/components/summary.html
+++ b/ratings/templates/ratings/components/summary.html
@@ -2,7 +2,8 @@
{% has_maintainer extension as is_maintainer %}
- {% if extension.text_ratings_count %}
+ {% with text_ratings_count=extension.text_ratings_count %}
+ {% if text_ratings_count %}
@@ -36,6 +37,7 @@
Be the first to review.
{% else %}
{% endif %}
+ {% endwith %}
{% if not is_maintainer and not my_rating %}
diff --git a/ratings/templates/ratings/rating_list.html b/ratings/templates/ratings/rating_list.html
index 51915cbc..b2e70db7 100644
--- a/ratings/templates/ratings/rating_list.html
+++ b/ratings/templates/ratings/rating_list.html
@@ -1,6 +1,5 @@
{% extends "extensions/base.html" %}
-{% load i18n %}
-{% load humanize %}
+{% load extensions humanize i18n %}
{% block page_title %}{{ extension.name }}{% endblock page_title %}
@@ -8,9 +7,11 @@
{% with latest=extension.latest_version author=extension.latest_version.file.user %}
- {% if extension.text_ratings_count and not score %}
- {{ extension.text_ratings_count }} {% endif %}Reviews{% if score %} with {{ score|apnumber }} stars
+ {% with text_ratings_count=extension.text_ratings_count %}
+ {% if text_ratings_count and not score %}
+ {{ text_ratings_count }} {% endif %}Reviews{% if score %} with {{ score|apnumber }} stars
{% endif %}
+ {% endwith %}
{% if score %}
diff --git a/ratings/templates/ratings/ratingreply_form.html b/ratings/templates/ratings/ratingreply_form.html
new file mode 100644
index 00000000..032b12c3
--- /dev/null
+++ b/ratings/templates/ratings/ratingreply_form.html
@@ -0,0 +1,36 @@
+{% extends "extensions/base.html" %}
+{% load i18n common %}
+
+{% block page_title %}Reply to rating{% endblock page_title %}
+
+{% block content %}
+
Reply to review
+
+
+ {% include "ratings/components/rating.html" with classes="mb-2" %}
+
+
+
+{% endblock content %}
diff --git a/ratings/tests/test_views.py b/ratings/tests/test_views.py
index 95682dd6..23404213 100644
--- a/ratings/tests/test_views.py
+++ b/ratings/tests/test_views.py
@@ -163,3 +163,18 @@ class AddRatingViewTest(TestCase):
rating = Rating.objects.first()
self.assertEqual(rating.score, 3)
self.assertEqual(rating.text, text)
+
+ def test_reply(self):
+ version = create_approved_version(ratings=[])
+ rating = RatingFactory(version=version, text='some text', status=Rating.STATUSES.APPROVED)
+
+ random_user = UserFactory()
+ self.client.force_login(random_user)
+ response = self.client.post(rating.get_reply_url(), {'text': 'some reply'})
+ self.assertEqual(response.status_code, 403)
+
+ self.client.force_login(version.extension.authors.first())
+ response = self.client.post(rating.get_reply_url(), {'text': 'some reply'})
+ self.assertEqual(response.status_code, 302)
+ rating.refresh_from_db()
+ self.assertTrue(hasattr(rating, 'ratingreply'))
diff --git a/ratings/urls.py b/ratings/urls.py
index e88706c2..c319a871 100644
--- a/ratings/urls.py
+++ b/ratings/urls.py
@@ -11,6 +11,11 @@ urlpatterns = [
[
path('
/reviews/', views.RatingsView.as_view(), name='for-extension'),
path('/reviews/new/', views.AddRatingView.as_view(), name='new'),
+ path(
+ '/reviews//reply/',
+ views.AddRatingReplyView.as_view(),
+ name='reply',
+ ),
]
),
)
diff --git a/ratings/views.py b/ratings/views.py
index b3459b5d..c213b119 100644
--- a/ratings/views.py
+++ b/ratings/views.py
@@ -5,7 +5,7 @@ from django.views.generic.edit import CreateView
from django.views.generic.list import ListView
from ratings.forms import AddRatingForm
-from ratings.models import Rating
+from ratings.models import Rating, RatingReply
import extensions.views.mixins
log = logging.getLogger(__name__)
@@ -31,7 +31,18 @@ class RatingsView(extensions.views.mixins.ListedExtensionMixin, ListView):
def get_queryset(self):
self._set_score_filter()
- queryset = super().get_queryset().filter(extension_id=self.extension.pk)
+ queryset = (
+ super()
+ .get_queryset()
+ .filter(extension_id=self.extension.pk)
+ .select_related(
+ 'extension',
+ 'ratingreply',
+ 'ratingreply__user',
+ 'user',
+ 'version',
+ )
+ )
if self.score:
queryset = queryset.filter(score=self.score)
return queryset.distinct()
@@ -39,6 +50,7 @@ class RatingsView(extensions.views.mixins.ListedExtensionMixin, ListView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['extension'] = self.extension
+ context['is_maintainer'] = self.extension.has_maintainer(self.request.user)
context['score'] = self.score
if self.request.user.is_authenticated:
context['my_rating'] = Rating.get_for(self.request.user.pk, self.extension.pk)
@@ -77,3 +89,39 @@ class AddRatingView(
def get_success_url(self) -> str:
return self.extension.get_ratings_url()
+
+
+class AddRatingReplyView(
+ extensions.views.mixins.ListedExtensionMixin,
+ UserPassesTestMixin,
+ LoginRequiredMixin,
+ CreateView,
+):
+ model = RatingReply
+ fields = ('text',)
+
+ def get_rating(self):
+ return Rating.objects.filter(pk=self.kwargs['pk'], extension=self.extension).first()
+
+ def test_func(self) -> bool:
+ # Check that user is replying to a rating for their exension and this is the first reply
+ rating = self.get_rating()
+ return (
+ rating
+ and not hasattr(rating, 'ratingreply')
+ and self.extension.has_maintainer(self.request.user)
+ )
+
+ def form_valid(self, form):
+ form.instance.rating = self.get_rating()
+ form.instance.user = self.request.user
+ return super().form_valid(form)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['extension'] = self.extension
+ context['rating'] = self.get_rating()
+ return context
+
+ def get_success_url(self) -> str:
+ return self.extension.get_ratings_url()
diff --git a/reviewers/views.py b/reviewers/views.py
index e5904dd2..4baff384 100644
--- a/reviewers/views.py
+++ b/reviewers/views.py
@@ -106,6 +106,7 @@ class ExtensionsApprovalDetailView(DetailView):
filtered_activity_types = {ApprovalActivity.ActivityType.COMMENT}
user = self.request.user
if self.object.has_maintainer(user):
+ ctx['is_maintainer'] = self.object.has_maintainer(self.request.user)
filtered_activity_types.add(ApprovalActivity.ActivityType.AWAITING_REVIEW)
if user.is_moderator or user.is_superuser:
filtered_activity_types.update(