From f5954247fc4c6756e5a1931647eecccce4a055d0 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Wed, 5 Aug 2026 15:50:32 -0500 Subject: [PATCH] annoucment logic implemented, fixing looks --- nonpacks/announcements/__init__.py | 0 nonpacks/announcements/admin.py | 3 + nonpacks/announcements/apps.py | 5 + nonpacks/announcements/forms.py | 13 ++ .../announcements/migrations/0001_initial.py | 75 +++++++ nonpacks/announcements/migrations/__init__.py | 0 nonpacks/announcements/models.py | 102 +++++++++ nonpacks/announcements/notifications.py | 32 +++ nonpacks/announcements/tests.py | 193 ++++++++++++++++++ nonpacks/announcements/urls.py | 18 ++ nonpacks/announcements/views.py | 173 ++++++++++++++++ nonpacks/common/settings.py | 1 + nonpacks/common/urls.py | 1 + nonpacks/landing/views.py | 26 +++ nonpacks/library/views.py | 24 +++ nonpacks/static/css/style.css | 102 +++++++-- .../announcements/announcement_detail.html | 108 ++++++++++ .../announcements/announcement_form.html | 33 +++ .../announcements/announcement_list.html | 48 +++++ .../announcements/notifications.html | 39 ++++ nonpacks/templates/base.html | 3 + nonpacks/templates/landing/home.html | 39 +++- 22 files changed, 1020 insertions(+), 18 deletions(-) create mode 100644 nonpacks/announcements/__init__.py create mode 100644 nonpacks/announcements/admin.py create mode 100644 nonpacks/announcements/apps.py create mode 100644 nonpacks/announcements/forms.py create mode 100644 nonpacks/announcements/migrations/0001_initial.py create mode 100644 nonpacks/announcements/migrations/__init__.py create mode 100644 nonpacks/announcements/models.py create mode 100644 nonpacks/announcements/notifications.py create mode 100644 nonpacks/announcements/tests.py create mode 100644 nonpacks/announcements/urls.py create mode 100644 nonpacks/announcements/views.py create mode 100644 nonpacks/templates/announcements/announcement_detail.html create mode 100644 nonpacks/templates/announcements/announcement_form.html create mode 100644 nonpacks/templates/announcements/announcement_list.html create mode 100644 nonpacks/templates/announcements/notifications.html diff --git a/nonpacks/announcements/__init__.py b/nonpacks/announcements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nonpacks/announcements/admin.py b/nonpacks/announcements/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/nonpacks/announcements/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/nonpacks/announcements/apps.py b/nonpacks/announcements/apps.py new file mode 100644 index 0000000..1a07a47 --- /dev/null +++ b/nonpacks/announcements/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AnnouncementsConfig(AppConfig): + name = 'announcements' diff --git a/nonpacks/announcements/forms.py b/nonpacks/announcements/forms.py new file mode 100644 index 0000000..66b64f3 --- /dev/null +++ b/nonpacks/announcements/forms.py @@ -0,0 +1,13 @@ +from django import forms + +from .models import Announcement + + +class AnnouncementForm(forms.ModelForm): + class Meta: + model = Announcement + fields = ['title', 'body', 'published'] + widgets = { + 'title': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Announcement title'}), + 'body': forms.Textarea(attrs={'class': 'form-textarea', 'rows': 12, 'placeholder': 'Write in Markdown…'}), + } diff --git a/nonpacks/announcements/migrations/0001_initial.py b/nonpacks/announcements/migrations/0001_initial.py new file mode 100644 index 0000000..3863ca8 --- /dev/null +++ b/nonpacks/announcements/migrations/0001_initial.py @@ -0,0 +1,75 @@ +# Generated by Django 6.0.3 on 2026-08-05 19:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('library', '0013_project_rating_count_project_rating_score_comment_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Announcement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('body', models.TextField(blank=True, default='')), + ('published', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='announcements', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at', '-pk'], + }, + ), + migrations.CreateModel( + name='AnnouncementComment', + 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)), + ('announcement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='announcements.announcement')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='announcement_comments', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['created_at', 'pk'], + }, + ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(choices=[('version', 'New version'), ('asset', 'New media'), ('edit', 'Project edited'), ('comment', 'New comment')], max_length=16)), + ('text', models.CharField(max_length=255)), + ('read', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='library.project')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at', '-pk'], + }, + ), + migrations.CreateModel( + name='AnnouncementRead', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('read_at', models.DateTimeField(auto_now_add=True)), + ('announcement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reads', to='announcements.announcement')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='announcement_reads', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('announcement', 'user'), name='uniq_announcement_read')], + }, + ), + ] diff --git a/nonpacks/announcements/migrations/__init__.py b/nonpacks/announcements/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nonpacks/announcements/models.py b/nonpacks/announcements/models.py new file mode 100644 index 0000000..9720a16 --- /dev/null +++ b/nonpacks/announcements/models.py @@ -0,0 +1,102 @@ +from django.conf import settings +from django.db import models + +from library.models import Project + + +class Announcement(models.Model): + """A staff-written, site-wide announcement (global). Kept forever.""" + title = models.CharField(max_length=200) + body = models.TextField(blank=True, default='') # Markdown + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name='announcements', + ) + published = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-created_at', '-pk'] + + def __str__(self): + return self.title + + @property + def comment_count(self): + return self.comments.count() + + +class AnnouncementRead(models.Model): + """Read receipt: one row per (announcement, user) the first time it's opened.""" + announcement = models.ForeignKey( + Announcement, on_delete=models.CASCADE, related_name='reads', + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='announcement_reads', + ) + read_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['announcement', 'user'], name='uniq_announcement_read', + ), + ] + + def __str__(self): + return f'{self.user.username} read {self.announcement.title}' + + +class AnnouncementComment(models.Model): + """A comment on an announcement page. Body is Markdown.""" + announcement = models.ForeignKey( + Announcement, on_delete=models.CASCADE, related_name='comments', + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='announcement_comments', + ) + body = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['created_at', 'pk'] + + def __str__(self): + return f'{self.user.username}: {self.body[:40]}' + + +class Notification(models.Model): + """A personal alert triggered by UGC activity. Retained at ~200 per user.""" + KIND_CHOICES = [ + ('version', 'New version'), + ('asset', 'New media'), + ('edit', 'Project edited'), + ('comment', 'New comment'), + ] + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='notifications', + ) + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='+', + ) + project = models.ForeignKey( + Project, on_delete=models.CASCADE, related_name='notifications', + ) + kind = models.CharField(max_length=16, choices=KIND_CHOICES) + text = models.CharField(max_length=255) + read = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at', '-pk'] + + def __str__(self): + return f'{self.user.username}: {self.text}' diff --git a/nonpacks/announcements/notifications.py b/nonpacks/announcements/notifications.py new file mode 100644 index 0000000..928bf8d --- /dev/null +++ b/nonpacks/announcements/notifications.py @@ -0,0 +1,32 @@ +from django.db import transaction + +from .models import Notification + +PERSONAL_NOTIFICATION_LIMIT = 200 + + +def notify_project_change(project, actor, kind, text): + """Notify the project's owner + contributors about a change, excluding the actor. + + Retention: keeps only the newest ~200 notifications per recipient.""" + if actor is None or not actor.is_authenticated: + return + recipient_ids = {project.owner_id} + recipient_ids.update(project.contributors.values_list('user_id', flat=True)) + recipient_ids.discard(actor.pk) + if not recipient_ids: + return + with transaction.atomic(): + for uid in recipient_ids: + Notification.objects.create( + user_id=uid, actor=actor, project=project, kind=kind, text=text, + ) + # Prune each recipient down to the newest limit. + for uid in recipient_ids: + stale = list( + Notification.objects.filter(user_id=uid) + .order_by('-created_at', '-pk') + .values_list('pk', flat=True)[PERSONAL_NOTIFICATION_LIMIT:] + ) + if stale: + Notification.objects.filter(pk__in=stale).delete() diff --git a/nonpacks/announcements/tests.py b/nonpacks/announcements/tests.py new file mode 100644 index 0000000..f71c1a7 --- /dev/null +++ b/nonpacks/announcements/tests.py @@ -0,0 +1,193 @@ +import io +import zipfile + +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from django.urls import reverse + +from library.models import Project, ProjectContributor + +from .models import ( + Announcement, + AnnouncementComment, + AnnouncementRead, + Notification, +) +from .notifications import notify_project_change + + +def _zip_bytes(entries=None): + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w') as zf: + for name, content in (entries or {}).items(): + zf.writestr(name, content) + return buf.getvalue() + + +class AnnouncementsTestCase(TestCase): + def setUp(self): + User = get_user_model() + self.staff = User.objects.create_user(username='Boss', password='pw', is_staff=True) + self.normal = User.objects.create_user(username='NormalUser', password='pw') + self.normal2 = User.objects.create_user(username='NormalUser2', password='pw') + self.project = Project.objects.create( + slug='test-pack', title='Test Pack', category='mod', owner=self.normal, + ) + cache.clear() + + def _auth(self, user=None): + if user is not None: + self.client.force_login(user) + session = self.client.session + session['authorized'] = True + session.save() + + def _upload_version(self, name, content): + self.client.post( + reverse('library:api_upload_temp'), + {'kind': 'version', 'file': SimpleUploadedFile(name, content, content_type='application/zip')}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + ) + + # --- Global announcements: staff management --- + + def test_staff_can_create_edit_delete(self): + self._auth(self.staff) + resp = self.client.post( + reverse('announcements:create'), + {'title': 'Welcome', 'body': 'Hello **world**', 'published': 'on'}, + ) + announcement = Announcement.objects.get(title='Welcome') + self.assertRedirects(resp, reverse('announcements:detail', args=[announcement.pk])) + self.assertTrue(announcement.published) + self.assertEqual(announcement.created_by, self.staff) + + resp = self.client.post( + reverse('announcements:edit', args=[announcement.pk]), + {'title': 'Welcome v2', 'body': 'Edited', 'published': 'on'}, + ) + announcement.refresh_from_db() + self.assertEqual(announcement.title, 'Welcome v2') + + self.client.post(reverse('announcements:delete', args=[announcement.pk])) + self.assertFalse(Announcement.objects.filter(pk=announcement.pk).exists()) + + def test_non_staff_cannot_manage(self): + self._auth(self.normal) + resp = self.client.post( + reverse('announcements:create'), + {'title': 'Nope', 'body': 'x', 'published': 'on'}, + ) + self.assertEqual(resp.status_code, 403) + self.assertFalse(Announcement.objects.exists()) + + def test_unpublished_hidden_from_list_and_detail(self): + draft = Announcement.objects.create( + title='Draft', body='secret', created_by=self.staff, published=False, + ) + published = Announcement.objects.create( + title='Live', body='ok', created_by=self.staff, published=True, + ) + self._auth(self.normal) + resp = self.client.get(reverse('announcements:list')) + self.assertContains(resp, 'Live') + self.assertNotContains(resp, 'Draft') + self.assertEqual(self.client.get(reverse('announcements:detail', args=[draft.pk])).status_code, 404) + self.assertEqual(self.client.get(reverse('announcements:detail', args=[published.pk])).status_code, 200) + + # --- Read receipts --- + + def test_detail_marks_read_once(self): + announcement = Announcement.objects.create(title='A', body='b', created_by=self.staff) + self._auth(self.normal) + self.assertEqual(self.client.get(reverse('announcements:detail', args=[announcement.pk])).status_code, 200) + self.assertEqual(AnnouncementRead.objects.filter(announcement=announcement, user=self.normal).count(), 1) + self.client.get(reverse('announcements:detail', args=[announcement.pk])) + self.assertEqual(AnnouncementRead.objects.filter(announcement=announcement, user=self.normal).count(), 1) + + # --- Announcement comments --- + + def test_comment_lifecycle(self): + announcement = Announcement.objects.create(title='A', body='b', created_by=self.staff) + self._auth(self.normal) + self.client.post(reverse('announcements:add_comment', args=[announcement.pk]), {'body': 'First!'}) + comment = AnnouncementComment.objects.get(announcement=announcement) + self.assertEqual(comment.user, self.normal) + + # Owner edits own comment. + self.client.post(reverse('announcements:edit_comment', args=[announcement.pk, comment.pk]), {'body': 'Edited!'}) + comment.refresh_from_db() + self.assertEqual(comment.body, 'Edited!') + + # Another non-staff user cannot edit or delete it. + self._auth(self.normal2) + self.assertEqual( + self.client.post(reverse('announcements:edit_comment', args=[announcement.pk, comment.pk]), {'body': 'x'}).status_code, + 403, + ) + self.assertEqual( + self.client.post(reverse('announcements:delete_comment', args=[announcement.pk, comment.pk])).status_code, + 403, + ) + + # Staff can delete any comment. + self._auth(self.staff) + self.client.post(reverse('announcements:delete_comment', args=[announcement.pk, comment.pk])) + self.assertFalse(AnnouncementComment.objects.filter(pk=comment.pk).exists()) + + # --- Personal notifications --- + + def test_version_change_notifies_owner_and_contributors_not_actor(self): + ProjectContributor.objects.create(project=self.project, user=self.normal2, added_by=self.normal) + self._auth(self.normal2) + self._upload_version('pack.zip', _zip_bytes()) + self.client.post(reverse('library:version_upload', args=[self.project.slug]), {'version_name': '1.0.0'}) + self.assertTrue(Notification.objects.filter(user=self.normal, kind='version', project=self.project).exists()) + self.assertFalse(Notification.objects.filter(user=self.normal2, kind='version').exists()) + + def test_comment_change_notifies_owner(self): + self._auth(self.normal2) + self.client.post( + reverse('library:add_comment', args=[self.project.slug]), + {'body': 'Nice pack'}, + ) + self.assertTrue(Notification.objects.filter(user=self.normal, kind='comment', project=self.project).exists()) + + def test_retention_prunes_to_200(self): + for i in range(210): + notify_project_change(self.project, self.normal2, 'version', f'change {i}') + self.assertEqual(Notification.objects.filter(user=self.normal).count(), 200) + + def test_mark_all_read(self): + for i in range(3): + notify_project_change(self.project, self.normal2, 'edit', f'edit {i}') + self._auth(self.normal) + self.client.post(reverse('announcements:mark_all_read')) + self.assertFalse(Notification.objects.filter(user=self.normal, read=False).exists()) + + def test_project_detail_auto_marks_read(self): + notify_project_change(self.project, self.normal2, 'comment', 'someone commented') + self._auth(self.normal) + self.client.get(reverse('library:project_detail', args=[self.project.slug])) + self.assertFalse(Notification.objects.filter(user=self.normal, read=False).exists()) + + # --- Home --- + + def test_home_guests_see_global_only(self): + Announcement.objects.create(title='Global', body='hi', created_by=self.staff) + notify_project_change(self.project, self.normal2, 'edit', 'edited it') + self._auth() + resp = self.client.get(reverse('landing:home')) + self.assertContains(resp, 'Global') + self.assertNotContains(resp, 'For you') + + def test_home_logged_sees_global_and_personal(self): + Announcement.objects.create(title='Global', body='hi', created_by=self.staff) + notify_project_change(self.project, self.normal2, 'edit', 'edited the pack') + self._auth(self.normal) + resp = self.client.get(reverse('landing:home')) + self.assertContains(resp, 'Global') + self.assertContains(resp, 'For you') + self.assertContains(resp, 'edited the pack') diff --git a/nonpacks/announcements/urls.py b/nonpacks/announcements/urls.py new file mode 100644 index 0000000..d715a59 --- /dev/null +++ b/nonpacks/announcements/urls.py @@ -0,0 +1,18 @@ +from django.urls import path + +from . import views + +app_name = 'announcements' + +urlpatterns = [ + path('announcements/', views.announcement_list, name='list'), + path('announcements/new/', views.announcement_create, name='create'), + path('announcements/personal/', views.personal_notifications, name='personal'), + path('announcements/mark-all-read/', views.mark_all_notifications_read, name='mark_all_read'), + path('announcements//', views.announcement_detail, name='detail'), + path('announcements//edit/', views.announcement_edit, name='edit'), + path('announcements//delete/', views.announcement_delete, name='delete'), + path('announcements//comments/add/', views.announcement_add_comment, name='add_comment'), + path('announcements//comments//edit/', views.announcement_edit_comment, name='edit_comment'), + path('announcements//comments//delete/', views.announcement_delete_comment, name='delete_comment'), +] diff --git a/nonpacks/announcements/views.py b/nonpacks/announcements/views.py new file mode 100644 index 0000000..8780674 --- /dev/null +++ b/nonpacks/announcements/views.py @@ -0,0 +1,173 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.core.paginator import Paginator +from django.db.models import Count +from django.http import HttpResponseForbidden +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse + +from common.markdown import render_markdown + +from .forms import AnnouncementForm +from .models import Announcement, AnnouncementComment, AnnouncementRead, Notification + + +def _staff_only(user): + return user.is_authenticated and user.is_staff + + +def announcement_list(request): + """Every published global announcement.""" + announcements = ( + Announcement.objects.filter(published=True) + .annotate(comments_count=Count('comments')) + .order_by('-created_at', '-pk') + ) + paginator = Paginator(announcements, 10) + page = paginator.get_page(request.GET.get('page')) + + read_ids = set() + if request.user.is_authenticated: + read_ids = set( + AnnouncementRead.objects.filter(user=request.user) + .values_list('announcement_id', flat=True) + ) + return render(request, 'announcements/announcement_list.html', { + 'page': page, + 'read_ids': read_ids, + }) + + +def announcement_detail(request, pk): + """One announcement + its comment section. Marks it read on view.""" + announcement = get_object_or_404( + Announcement.objects.annotate(comments_count=Count('comments')), + pk=pk, published=True, + ) + if request.user.is_authenticated: + AnnouncementRead.objects.get_or_create( + announcement=announcement, user=request.user, + ) + comments = announcement.comments.select_related('user').all() + return render(request, 'announcements/announcement_detail.html', { + 'announcement': announcement, + 'comments': comments, + 'can_comment': request.user.is_authenticated, + }) + + +@login_required +def announcement_create(request): + if not _staff_only(request.user): + return HttpResponseForbidden('Only staff can manage announcements.') + if request.method == 'POST': + form = AnnouncementForm(request.POST) + if form.is_valid(): + form.instance.created_by = request.user + form.save() + messages.success(request, 'Announcement published.') + return redirect('announcements:detail', pk=form.instance.pk) + else: + form = AnnouncementForm() + return render(request, 'announcements/announcement_form.html', { + 'form': form, + 'editing': False, + }) + + +@login_required +def announcement_edit(request, pk): + if not _staff_only(request.user): + return HttpResponseForbidden('Only staff can manage announcements.') + announcement = get_object_or_404(Announcement, pk=pk) + if request.method == 'POST': + form = AnnouncementForm(request.POST, instance=announcement) + if form.is_valid(): + form.save() + messages.success(request, 'Announcement updated.') + return redirect('announcements:detail', pk=announcement.pk) + else: + form = AnnouncementForm(instance=announcement) + return render(request, 'announcements/announcement_form.html', { + 'form': form, + 'announcement': announcement, + 'editing': True, + }) + + +@login_required +def announcement_delete(request, pk): + if not _staff_only(request.user): + return HttpResponseForbidden('Only staff can manage announcements.') + announcement = get_object_or_404(Announcement, pk=pk) + if request.method == 'POST': + announcement.delete() + messages.success(request, 'Announcement deleted.') + return redirect('announcements:list') + + +@login_required +def announcement_add_comment(request, pk): + announcement = get_object_or_404(Announcement, pk=pk) + if request.method == 'POST': + body = request.POST.get('body', '').strip() + if body: + AnnouncementComment.objects.create( + announcement=announcement, user=request.user, body=body, + ) + messages.success(request, 'Comment added.') + else: + messages.error(request, 'Comment cannot be empty.') + return redirect('announcements:detail', pk=announcement.pk) + + +@login_required +def announcement_edit_comment(request, pk, comment_id): + announcement = get_object_or_404(Announcement, pk=pk) + comment = get_object_or_404( + AnnouncementComment, pk=comment_id, announcement=announcement, + ) + 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('announcements:detail', pk=announcement.pk) + + +@login_required +def announcement_delete_comment(request, pk, comment_id): + announcement = get_object_or_404(Announcement, pk=pk) + comment = get_object_or_404( + AnnouncementComment, pk=comment_id, announcement=announcement, + ) + 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('announcements:detail', pk=announcement.pk) + + +@login_required +def personal_notifications(request): + """The user's own personal announcement page.""" + notifications = Notification.objects.filter(user=request.user) + paginator = Paginator(notifications, 20) + page = paginator.get_page(request.GET.get('page')) + return render(request, 'announcements/notifications.html', { + 'page': page, + }) + + +@login_required +def mark_all_notifications_read(request): + if request.method == 'POST': + Notification.objects.filter(user=request.user, read=False).update(read=True) + messages.success(request, 'All notifications marked as read.') + return redirect(request.POST.get('next') or reverse('announcements:personal')) diff --git a/nonpacks/common/settings.py b/nonpacks/common/settings.py index e4e9a42..a741fe9 100644 --- a/nonpacks/common/settings.py +++ b/nonpacks/common/settings.py @@ -43,6 +43,7 @@ INSTALLED_APPS = [ 'landing', 'library', 'profiles', + 'announcements', ] MIDDLEWARE = [ diff --git a/nonpacks/common/urls.py b/nonpacks/common/urls.py index 0d42dc1..2934e92 100644 --- a/nonpacks/common/urls.py +++ b/nonpacks/common/urls.py @@ -8,5 +8,6 @@ urlpatterns = [ path('', include('landing.urls')), path('', include('profiles.urls')), path('', include('library.urls')), + path('', include('announcements.urls')), path('admin/', admin.site.urls), ] diff --git a/nonpacks/landing/views.py b/nonpacks/landing/views.py index d6b6979..09d216a 100644 --- a/nonpacks/landing/views.py +++ b/nonpacks/landing/views.py @@ -34,6 +34,7 @@ from library.models import ( Version, VersionFile, ) +from announcements.models import Announcement, AnnouncementRead, Notification def _under_lockout(request): @@ -84,6 +85,26 @@ def home(request): creators = ( get_user_model().objects.filter(projects__isnull=False).distinct().count() ) + + announcements = ( + Announcement.objects.filter(published=True) + .annotate(comments_count=Count('comments')) + .order_by('-created_at', '-pk')[:4] + ) + announcements_total = Announcement.objects.filter(published=True).count() + global_read_ids = set() + notifications = [] + unread_notifications = 0 + if request.user.is_authenticated: + global_read_ids = set( + AnnouncementRead.objects.filter(user=request.user) + .values_list('announcement_id', flat=True) + ) + notifications = list(Notification.objects.filter(user=request.user)[:5]) + unread_notifications = ( + Notification.objects.filter(user=request.user, read=False).count() + ) + context = { 'latest_projects': latest_projects, 'stats': { @@ -92,6 +113,11 @@ def home(request): 'downloads': total_downloads, 'versions': Version.objects.count(), }, + 'announcements': announcements, + 'announcements_total': announcements_total, + 'global_read_ids': global_read_ids, + 'notifications': notifications, + 'unread_notifications': unread_notifications, } return render(request, 'landing/home.html', context) diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py index f71170b..7a6b614 100644 --- a/nonpacks/library/views.py +++ b/nonpacks/library/views.py @@ -35,6 +35,8 @@ from .models import ( slugify_tag, ) from .storage import delete_file_index, move_file_index, refresh_file_index, store_temp_file +from announcements.models import Notification +from announcements.notifications import notify_project_change from .zips import ( inject_animationframework, parse_mods_manifest, @@ -425,6 +427,12 @@ def project_detail(request, slug): comments = project.comments.select_related('user').order_by('-created_at')[:200] can_comment = request.user.is_authenticated + # Visiting the UGC page marks the user's personal notifications for it read. + if request.user.is_authenticated: + Notification.objects.filter( + user=request.user, project=project, read=False, + ).update(read=True) + autoload_plugins_json = '' if plugins_manifest: members = [ @@ -1219,6 +1227,10 @@ def project_edit(request, slug): project.sync_creator_tags(actor=request.user) _sync_auto_tags(project, request.user) messages.success(request, 'Project updated.') + notify_project_change( + project, request.user, 'edit', + f'{request.user.username} edited {project.title}', + ) return redirect('library:project_detail', slug=project.slug) else: form = ProjectForm(initial={ @@ -1311,6 +1323,10 @@ def version_upload(request, slug): if missing_animation_id and project.category == 'non_pack': messages.warning(request, ANIMATION_API_WARNING) messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.') + notify_project_change( + project, request.user, 'version', + f'{request.user.username} uploaded version {form.cleaned_data["version_name"]} on {project.title}', + ) return redirect('library:project_detail', slug=project.slug) version_files = _pending_kind(request.user, 'version') @@ -1422,6 +1438,10 @@ def asset_upload(request, slug): temp.save(update_fields=['status']) count += 1 messages.success(request, f'{count} media file{"s" if count != 1 else ""} added to the gallery.') + notify_project_change( + project, request.user, 'asset', + f'{request.user.username} added {count} new media file{"s" if count != 1 else ""} to {project.title}', + ) return redirect('library:project_detail', slug=project.slug) return render(request, 'library/asset_upload.html', { @@ -1581,6 +1601,10 @@ def add_comment(request, slug): body = request.POST.get('body', '').strip() if body: Comment.objects.create(project=project, user=request.user, body=body) + notify_project_change( + project, request.user, 'comment', + f'{request.user.username} commented on {project.title}', + ) messages.success(request, 'Comment added.') else: messages.error(request, 'Comment cannot be empty.') diff --git a/nonpacks/static/css/style.css b/nonpacks/static/css/style.css index e057bae..f7f0258 100644 --- a/nonpacks/static/css/style.css +++ b/nonpacks/static/css/style.css @@ -1516,23 +1516,6 @@ a.deletelink { align-items: stretch; margin-bottom: 28px; } -.home-placeholder { - background: var(--md-sys-color-surface); - border: 1px dashed var(--md-sys-color-outline-variant, #45475a); - border-radius: 16px; - padding: 24px 28px; - display: flex; - flex-direction: column; - justify-content: center; - text-align: center; - gap: 8px; -} -.home-placeholder h2 { - font-size: 1.1rem; - color: var(--md-sys-color-on-surface); - margin: 0; -} -.home-placeholder h2 i { color: var(--md-sys-color-primary); } .home-placeholder-text { color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.85rem; @@ -1618,7 +1601,9 @@ a.deletelink { display: flex; flex-direction: column; align-items: center; + justify-content: center; gap: 4px; + flex: 1; border: 1px solid var(--md-sys-color-outline); } .stat-card i { @@ -3777,3 +3762,86 @@ a.deletelink { .stats-toolbar { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.5rem; flex-wrap: wrap; } .stats-updated { color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.8rem; } .stats-error { color: var(--ctp-mocha-red); font-size: 0.8rem; } + +/* Announcements */ +.announce-detail { display: flex; flex-direction: column; gap: 20px; } +.home-placeholder { + background: var(--md-sys-color-surface); + border: 1px dashed var(--md-sys-color-outline-variant, #45475a); + border-radius: 16px; + padding: 16px 18px; + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + gap: 6px; +} +.home-placeholder h2 { + font-size: 0.95rem; + color: var(--md-sys-color-on-surface); + margin: 0; + text-align: left; +} +.home-placeholder h2 i { color: var(--md-sys-color-primary); } +.announce-panel { + display: flex; flex-direction: column; gap: 0.3rem; + max-height: 220px; overflow-y: auto; text-align: left; +} +.announce-item { + display: flex; align-items: baseline; justify-content: space-between; + gap: 0.5rem; padding: 0.22rem 0.45rem; border-radius: 6px; + background: var(--md-sys-color-surface-variant, #45475a); + font-size: 0.78rem; line-height: 1.3; +} +.announce-item a { + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--md-sys-color-on-surface, #cdd6f4); +} +.announce-item.announce-unread { outline: 1px solid var(--md-sys-color-primary, #89b4fa); } +.announce-meta { + color: var(--md-sys-color-on-surface-variant, #a6adc8); + font-size: 0.68rem; white-space: nowrap; flex-shrink: 0; +} +.announce-more { + display: inline-block; margin-top: 0.4rem; font-size: 0.75rem; + color: var(--md-sys-color-primary, #89b4fa); +} +.announce-subhead { margin-top: 0.8rem; } +.announce-badge { + display: inline-block; min-width: 1.1rem; text-align: center; + background: var(--ctp-mocha-red); color: #fff; border-radius: 999px; + font-size: 0.7rem; padding: 1px 5px; margin-left: 0.3rem; +} +.announce-actions { + display: flex; align-items: center; justify-content: space-between; + gap: 0.5rem; margin-top: 0.1rem; flex-wrap: wrap; +} +.announce-actions .announce-more { margin-top: 0; } +.announce-list { display: flex; flex-direction: column; gap: 1rem; } +.announce-card h1, .announce-card h2 { margin-top: 0; } +.announce-card h2 a { color: var(--md-sys-color-on-surface, #cdd6f4); } +.announce-excerpt { color: var(--md-sys-color-on-surface-variant, #a6adc8); font-size: 0.9rem; } +.announce-admin { display: flex; gap: 0.5rem; margin-top: 1rem; } +.announce-form { max-width: 720px; } +.announce-form textarea { width: 100%; } +.announce-form-actions { display: flex; gap: 0.5rem; margin-top: 1rem; } +.form-input { + width: 100%; padding: 0.55rem 0.7rem; border-radius: 8px; + border: 1px solid var(--md-sys-color-outline-variant, #45475a); + background: var(--md-sys-color-surface, #1e1e2e); + color: var(--md-sys-color-on-surface, #cdd6f4); font-family: inherit; +} +.form-textarea { + width: 100%; padding: 0.55rem 0.7rem; border-radius: 8px; + border: 1px solid var(--md-sys-color-outline-variant, #45475a); + background: var(--md-sys-color-surface, #1e1e2e); + color: var(--md-sys-color-on-surface, #cdd6f4); font-family: inherit; +} +.notify-list { list-style: none; margin: 0; padding: 0; } +.notify-item { + padding: 0.6rem 0.5rem; border-bottom: 1px solid var(--md-sys-color-outline-variant, #45475a); + font-size: 0.85rem; +} +.notify-item:last-child { border-bottom: none; } +.notify-item.notify-unread { background: var(--md-sys-color-surface-variant, #45475a); border-radius: 8px; } +.notify-new { color: var(--ctp-mocha-red); font-weight: 600; } diff --git a/nonpacks/templates/announcements/announcement_detail.html b/nonpacks/templates/announcements/announcement_detail.html new file mode 100644 index 0000000..e8f0a37 --- /dev/null +++ b/nonpacks/templates/announcements/announcement_detail.html @@ -0,0 +1,108 @@ +{% extends 'base.html' %} +{% load markdown %} + +{% block title %}{{ announcement.title }} - Packs Site{% endblock %} + +{% block content %} +
+
+

{{ announcement.title }}

+

+ {{ announcement.created_by.username|default:"Staff" }} + + {{ announcement.created_at|date:"F j, Y, g:i a" }} +

+
{{ announcement.body|markdown }}
+ {% if user.is_staff %} +
+ Edit +
+ {% csrf_token %} + +
+
+ {% endif %} +
+ +
+

Discussion

+ + {% if can_comment %} +
+ {% csrf_token %} + + +
+ {% else %} +

Log in to join the discussion.

+ {% endif %} + +
+ {% for comment in comments %} +
+
+ {{ comment.user.username }} + {{ comment.created_at|timesince }} ago + {% if comment.updated_at != comment.created_at %}· edited{% endif %} + {% if can_comment and comment.user == user %} + + Edit +
+ {% csrf_token %} + +
+
+ {% elif can_comment and user.is_staff %} + +
+ {% csrf_token %} + +
+
+ {% endif %} +
+
{{ comment.body|markdown }}
+ {% if can_comment and comment.user == user %} + + {% endif %} +
+ {% empty %} +

No comments yet.

+ {% endfor %} +
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/nonpacks/templates/announcements/announcement_form.html b/nonpacks/templates/announcements/announcement_form.html new file mode 100644 index 0000000..f50fbea --- /dev/null +++ b/nonpacks/templates/announcements/announcement_form.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} + +{% block title %}{% if editing %}Edit announcement{% else %}New announcement{% endif %} - Packs Site{% endblock %} + +{% block content %} +

{% if editing %} Edit announcement{% else %} New announcement{% endif %}

+ +
+ {% csrf_token %} + {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} +
+ {{ form.title.label_tag }} + {{ form.title.errors }} + {{ form.title }} +
+
+ {{ form.body.label_tag }} + {{ form.body.errors }} + {{ form.body }} +

Supports Markdown — headings, lists, links, code, tables and more.

+
+
+ +
+
+ + Cancel +
+
+{% endblock %} diff --git a/nonpacks/templates/announcements/announcement_list.html b/nonpacks/templates/announcements/announcement_list.html new file mode 100644 index 0000000..8314d4b --- /dev/null +++ b/nonpacks/templates/announcements/announcement_list.html @@ -0,0 +1,48 @@ +{% extends 'base.html' %} +{% load markdown %} + +{% block title %}Announcements - Packs Site{% endblock %} + +{% block content %} +
+

Announcements

+ {% if user.is_staff %} + New announcement + {% endif %} +
+ +
+ {% for a in page %} +
+

+ {{ a.title }} + {% if user.is_authenticated and a.pk not in read_ids %} + new + {% endif %} +

+
{{ a.body|markdown|truncatechars_html:320 }}
+

+ {{ a.created_by.username|default:"Staff" }} + + {{ a.created_at|date:"F j, Y" }} + + {{ a.comments_count }} comment{{ a.comments_count|pluralize }} +

+
+ {% empty %} +

No announcements yet.

+ {% endfor %} +
+ +{% if page.has_other_pages %} + +{% endif %} +{% endblock %} diff --git a/nonpacks/templates/announcements/notifications.html b/nonpacks/templates/announcements/notifications.html new file mode 100644 index 0000000..91f50f3 --- /dev/null +++ b/nonpacks/templates/announcements/notifications.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} + +{% block title %}Your alerts - Packs Site{% endblock %} + +{% block content %} +
+

Your alerts

+
+ {% csrf_token %} + + +
+
+ +
+
    + {% for n in page %} +
  • + {{ n.text }} + {{ n.created_at|timesince }} ago{% if not n.read %} · new{% endif %} +
  • + {% empty %} +
  • You have no alerts yet.
  • + {% endfor %} +
+
+ +{% if page.has_other_pages %} + +{% endif %} +{% endblock %} diff --git a/nonpacks/templates/base.html b/nonpacks/templates/base.html index f0e89bc..696c2fd 100644 --- a/nonpacks/templates/base.html +++ b/nonpacks/templates/base.html @@ -45,6 +45,9 @@ Users + + Announcements + {% endif %} diff --git a/nonpacks/templates/landing/home.html b/nonpacks/templates/landing/home.html index afc26c3..2a3c76d 100644 --- a/nonpacks/templates/landing/home.html +++ b/nonpacks/templates/landing/home.html @@ -19,7 +19,44 @@

Announcements

-

Placeholder — news and updates will live here.

+
+ {% for a in announcements %} +
+ {{ a.title }} + {{ a.created_at|date:"M j" }}{% if a.comments_count %} · {{ a.comments_count }} comment{{ a.comments_count|pluralize }}{% endif %} +
+ {% empty %} +

No announcements yet.

+ {% endfor %} +
+ {% if announcements_total > 4 %} + View all ({{ announcements_total }}) + {% endif %} + + {% if user.is_authenticated %} +

For you + {% if unread_notifications %}{{ unread_notifications }}{% endif %} +

+
+ {% for n in notifications %} +
+ {{ n.text }} + {{ n.created_at|timesince }} ago +
+ {% empty %} +

Nothing new for you yet.

+ {% endfor %} +
+
+ All your alerts + {% if unread_notifications %} +
+ {% csrf_token %} + +
+ {% endif %} +
+ {% endif %}