rating and comment system complete

This commit is contained in:
2026-08-05 12:25:59 -05:00
parent 26e9179be4
commit aed6a7c24d
9 changed files with 407 additions and 1 deletions
@@ -0,0 +1,51 @@
# Generated by Django 6.0.3 on 2026-08-05 16:55
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0012_taglist_auto'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='project',
name='rating_count',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='project',
name='rating_score',
field=models.IntegerField(default=0),
),
migrations.CreateModel(
name='Comment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('body', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='library.project')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Rating',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='library.project')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to=settings.AUTH_USER_MODEL)),
],
options={
'constraints': [models.UniqueConstraint(fields=('project', 'user'), name='uniq_rating_project_user')],
},
),
]
+38
View File
@@ -113,6 +113,8 @@ class Project(models.Model):
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
rating_score = models.IntegerField(default=0)
rating_count = models.PositiveIntegerField(default=0)
def __str__(self):
return self.title
@@ -341,3 +343,39 @@ class ProjectDraft(models.Model):
def __str__(self):
return f'Draft for {self.user.username}'
class Rating(models.Model):
"""One user's up/down vote on a project (Steam-style overall rating).
A user may hold a single vote per project; changing it edits this row.
``rating_score``/``rating_count`` on Project mirror the aggregate."""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='ratings')
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='ratings',
)
value = models.BooleanField(default=True) # True=positive/up, False=negative/down
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['project', 'user'], name='uniq_rating_project_user'),
]
def __str__(self):
return f'{"+" if self.value else "-"}{self.user.username} on {self.project.slug}'
class Comment(models.Model):
"""A forum-style comment on a project. Unlimited; body is Markdown."""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='comments')
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='comments',
)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f'{self.user.username} on {self.project.slug}: {self.body[:40]}'
+4
View File
@@ -20,6 +20,10 @@ urlpatterns = [
path('packs/<slug:slug>/gallery/<int:asset_id>/thumb/', views.asset_thumbnail, name='asset_thumbnail'),
path('packs/<slug:slug>/gallery/<int:asset_id>/delete/', views.asset_delete, name='asset_delete'),
path('packs/<slug:slug>/contributors/', views.contributors, name='contributors'),
path('packs/<slug:slug>/rate/', views.rate_project, name='rate_project'),
path('packs/<slug:slug>/comments/add/', views.add_comment, name='add_comment'),
path('packs/<slug:slug>/comments/<int:comment_id>/edit/', views.edit_comment, name='edit_comment'),
path('packs/<slug:slug>/comments/<int:comment_id>/delete/', views.delete_comment, name='delete_comment'),
path('api/files/<uuid:file_id>/', views.file_request, name='file_request'),
path('api/uploads/', views.api_upload_temp, name='api_upload_temp'),
path('api/uploads/<uuid:upload_uuid>/delete/', views.api_delete_temp, name='api_delete_temp'),
+132
View File
@@ -19,11 +19,13 @@ from django.utils.text import slugify
from common.markdown import render_markdown
from .forms import ContributorForm, ProjectForm, VersionForm
from .models import (
Comment,
FileIndex,
Project,
ProjectAsset,
ProjectContributor,
ProjectDraft,
Rating,
Tag,
TagCategory,
TagList,
@@ -259,6 +261,10 @@ def browse(request):
projects = projects.annotate(_downloads=Sum('versions__downloads')).order_by(
F('_downloads').desc(nulls_last=True)
)
elif sort == 'most_liked':
projects = projects.order_by('-rating_score')
elif sort == 'most_voted':
projects = projects.order_by('-rating_count')
elif sort == 'name':
projects = projects.order_by('title')
else:
@@ -373,6 +379,21 @@ def project_detail(request, slug):
if loader and loader not in mod_loaders:
mod_loaders.append(loader)
# Ratings + forum.
rating_agg = project.ratings.aggregate(
positive=Count('pk', filter=Q(value=True)),
total=Count('pk'),
)
rating_positive = rating_agg['positive'] or 0
rating_total = rating_agg['total'] or 0
rating_negative = rating_total - rating_positive
rating_percent = round(rating_positive / rating_total * 100) if rating_total else 0
user_rating = None
if request.user.is_authenticated:
user_rating = project.ratings.filter(user=request.user).values_list('value', flat=True).first()
comments = project.comments.select_related('user').order_by('-created_at')[:200]
can_comment = request.user.is_authenticated
autoload_plugins_json = ''
if plugins_manifest:
members = [
@@ -397,6 +418,15 @@ def project_detail(request, slug):
'mod_manifest': mod_manifest,
'mod_loaders': mod_loaders,
'mod_count': mod_count,
'rating_stats': {
'positive': rating_positive,
'negative': rating_negative,
'total': rating_total,
'percent': rating_percent,
},
'user_rating': user_rating,
'comments': comments,
'can_comment': can_comment,
'autoload_plugins_json': autoload_plugins_json,
'anim_stats': anim_stats,
}
@@ -1311,6 +1341,108 @@ def contributors(request, slug):
})
# ---------- Ratings & Forum ----------
def refresh_rating_fields(project):
"""Recompute Project.rating_score (net) + rating_count from its votes."""
agg = project.ratings.aggregate(
up=Count('pk', filter=Q(value=True)),
total=Count('pk'),
)
up = agg['up'] or 0
total = agg['total'] or 0
project.rating_count = total
project.rating_score = 2 * up - total
project.save(update_fields=['rating_score', 'rating_count'])
def _require_rating_user(request, project):
"""Redirect guests to login; return None when allowed."""
if request.user.is_authenticated:
return None
return redirect('{}?next={}'.format(
reverse('profiles:login'),
request.path,
))
def rate_project(request, slug):
"""Set / change / clear the current user's vote on a project."""
project = get_object_or_404(Project, slug=slug)
if (login_redirect := _require_rating_user(request, project)) is not None:
return login_redirect
if request.method != 'POST':
return redirect('library:project_detail', slug=slug)
value_raw = request.POST.get('value', '').strip()
if value_raw in ('1', 'up'):
value = True
elif value_raw in ('0', 'down'):
value = False
else:
value = None # clear the vote
rating, created = Rating.objects.get_or_create(
project=project, user=request.user, defaults={'value': value if value is not None else True},
)
if value is None:
rating.delete()
messages.success(request, 'Your rating was removed.')
elif created or rating.value != value:
rating.value = value
rating.save(update_fields=['value', 'updated_at'])
messages.success(request, 'Your rating was updated.')
else:
messages.info(request, 'Your rating is unchanged.')
refresh_rating_fields(project)
return redirect(reverse('library:project_detail', args=[slug]) + '#rating')
def add_comment(request, slug):
project = get_object_or_404(Project, slug=slug)
if (login_redirect := _require_rating_user(request, project)) is not None:
return login_redirect
if request.method == 'POST':
body = request.POST.get('body', '').strip()
if body:
Comment.objects.create(project=project, user=request.user, body=body)
messages.success(request, 'Comment added.')
else:
messages.error(request, 'Comment cannot be empty.')
return redirect(reverse('library:project_detail', args=[slug]) + '#forum')
def edit_comment(request, slug, comment_id):
project = get_object_or_404(Project, slug=slug)
if (login_redirect := _require_rating_user(request, project)) is not None:
return login_redirect
comment = get_object_or_404(Comment, pk=comment_id, project=project)
if comment.user_id != request.user.pk and not request.user.is_staff:
return HttpResponseForbidden('You can only edit your own comments.')
if request.method == 'POST':
body = request.POST.get('body', '').strip()
if body:
comment.body = body
comment.save(update_fields=['body', 'updated_at'])
messages.success(request, 'Comment updated.')
else:
messages.error(request, 'Comment cannot be empty.')
return redirect(reverse('library:project_detail', args=[slug]) + '#forum')
def delete_comment(request, slug, comment_id):
project = get_object_or_404(Project, slug=slug)
if (login_redirect := _require_rating_user(request, project)) is not None:
return login_redirect
comment = get_object_or_404(Comment, pk=comment_id, project=project)
if comment.user_id != request.user.pk and not request.user.is_staff:
return HttpResponseForbidden('You can only delete your own comments.')
if request.method == 'POST':
comment.delete()
messages.success(request, 'Comment deleted.')
return redirect(reverse('library:project_detail', args=[slug]) + '#forum')
# ---------- API (autocomplete) ----------
def api_tags_autocomplete(request):