rating and comment system complete
This commit is contained in:
+51
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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]}'
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user