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" %} +
+
+
+
+ {% csrf_token %} + {% with form=form|add_form_classes %} +
+
+
+ {% include "common/components/field.html" with field=form.text focus=True placeholder="Enter your reply here..." %} + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} + +
+
+
+ {% endwith %} +
+
+{% 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(