annoucment logic implemented, fixing looks
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementsConfig(AppConfig):
|
||||||
|
name = 'announcements'
|
||||||
@@ -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…'}),
|
||||||
|
}
|
||||||
@@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}'
|
||||||
@@ -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()
|
||||||
@@ -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')
|
||||||
@@ -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/<int:pk>/', views.announcement_detail, name='detail'),
|
||||||
|
path('announcements/<int:pk>/edit/', views.announcement_edit, name='edit'),
|
||||||
|
path('announcements/<int:pk>/delete/', views.announcement_delete, name='delete'),
|
||||||
|
path('announcements/<int:pk>/comments/add/', views.announcement_add_comment, name='add_comment'),
|
||||||
|
path('announcements/<int:pk>/comments/<int:comment_id>/edit/', views.announcement_edit_comment, name='edit_comment'),
|
||||||
|
path('announcements/<int:pk>/comments/<int:comment_id>/delete/', views.announcement_delete_comment, name='delete_comment'),
|
||||||
|
]
|
||||||
@@ -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'))
|
||||||
@@ -43,6 +43,7 @@ INSTALLED_APPS = [
|
|||||||
'landing',
|
'landing',
|
||||||
'library',
|
'library',
|
||||||
'profiles',
|
'profiles',
|
||||||
|
'announcements',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ urlpatterns = [
|
|||||||
path('', include('landing.urls')),
|
path('', include('landing.urls')),
|
||||||
path('', include('profiles.urls')),
|
path('', include('profiles.urls')),
|
||||||
path('', include('library.urls')),
|
path('', include('library.urls')),
|
||||||
|
path('', include('announcements.urls')),
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from library.models import (
|
|||||||
Version,
|
Version,
|
||||||
VersionFile,
|
VersionFile,
|
||||||
)
|
)
|
||||||
|
from announcements.models import Announcement, AnnouncementRead, Notification
|
||||||
|
|
||||||
|
|
||||||
def _under_lockout(request):
|
def _under_lockout(request):
|
||||||
@@ -84,6 +85,26 @@ def home(request):
|
|||||||
creators = (
|
creators = (
|
||||||
get_user_model().objects.filter(projects__isnull=False).distinct().count()
|
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 = {
|
context = {
|
||||||
'latest_projects': latest_projects,
|
'latest_projects': latest_projects,
|
||||||
'stats': {
|
'stats': {
|
||||||
@@ -92,6 +113,11 @@ def home(request):
|
|||||||
'downloads': total_downloads,
|
'downloads': total_downloads,
|
||||||
'versions': Version.objects.count(),
|
'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)
|
return render(request, 'landing/home.html', context)
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ from .models import (
|
|||||||
slugify_tag,
|
slugify_tag,
|
||||||
)
|
)
|
||||||
from .storage import delete_file_index, move_file_index, refresh_file_index, store_temp_file
|
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 (
|
from .zips import (
|
||||||
inject_animationframework,
|
inject_animationframework,
|
||||||
parse_mods_manifest,
|
parse_mods_manifest,
|
||||||
@@ -425,6 +427,12 @@ def project_detail(request, slug):
|
|||||||
comments = project.comments.select_related('user').order_by('-created_at')[:200]
|
comments = project.comments.select_related('user').order_by('-created_at')[:200]
|
||||||
can_comment = request.user.is_authenticated
|
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 = ''
|
autoload_plugins_json = ''
|
||||||
if plugins_manifest:
|
if plugins_manifest:
|
||||||
members = [
|
members = [
|
||||||
@@ -1219,6 +1227,10 @@ def project_edit(request, slug):
|
|||||||
project.sync_creator_tags(actor=request.user)
|
project.sync_creator_tags(actor=request.user)
|
||||||
_sync_auto_tags(project, request.user)
|
_sync_auto_tags(project, request.user)
|
||||||
messages.success(request, 'Project updated.')
|
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)
|
return redirect('library:project_detail', slug=project.slug)
|
||||||
else:
|
else:
|
||||||
form = ProjectForm(initial={
|
form = ProjectForm(initial={
|
||||||
@@ -1311,6 +1323,10 @@ def version_upload(request, slug):
|
|||||||
if missing_animation_id and project.category == 'non_pack':
|
if missing_animation_id and project.category == 'non_pack':
|
||||||
messages.warning(request, ANIMATION_API_WARNING)
|
messages.warning(request, ANIMATION_API_WARNING)
|
||||||
messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.')
|
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)
|
return redirect('library:project_detail', slug=project.slug)
|
||||||
|
|
||||||
version_files = _pending_kind(request.user, 'version')
|
version_files = _pending_kind(request.user, 'version')
|
||||||
@@ -1422,6 +1438,10 @@ def asset_upload(request, slug):
|
|||||||
temp.save(update_fields=['status'])
|
temp.save(update_fields=['status'])
|
||||||
count += 1
|
count += 1
|
||||||
messages.success(request, f'{count} media file{"s" if count != 1 else ""} added to the gallery.')
|
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 redirect('library:project_detail', slug=project.slug)
|
||||||
|
|
||||||
return render(request, 'library/asset_upload.html', {
|
return render(request, 'library/asset_upload.html', {
|
||||||
@@ -1581,6 +1601,10 @@ def add_comment(request, slug):
|
|||||||
body = request.POST.get('body', '').strip()
|
body = request.POST.get('body', '').strip()
|
||||||
if body:
|
if body:
|
||||||
Comment.objects.create(project=project, user=request.user, body=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.')
|
messages.success(request, 'Comment added.')
|
||||||
else:
|
else:
|
||||||
messages.error(request, 'Comment cannot be empty.')
|
messages.error(request, 'Comment cannot be empty.')
|
||||||
|
|||||||
@@ -1516,23 +1516,6 @@ a.deletelink {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
margin-bottom: 28px;
|
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 {
|
.home-placeholder-text {
|
||||||
color: var(--md-sys-color-on-surface-variant, #a6adc8);
|
color: var(--md-sys-color-on-surface-variant, #a6adc8);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -1618,7 +1601,9 @@ a.deletelink {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
border: 1px solid var(--md-sys-color-outline);
|
border: 1px solid var(--md-sys-color-outline);
|
||||||
}
|
}
|
||||||
.stat-card i {
|
.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-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-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; }
|
.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; }
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load markdown %}
|
||||||
|
|
||||||
|
{% block title %}{{ announcement.title }} - Packs Site{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="announce-detail">
|
||||||
|
<article class="card announce-card">
|
||||||
|
<h1>{{ announcement.title }}</h1>
|
||||||
|
<p class="announce-meta">
|
||||||
|
<i class="fas fa-user"></i> {{ announcement.created_by.username|default:"Staff" }}
|
||||||
|
<span class="separator">•</span>
|
||||||
|
{{ announcement.created_at|date:"F j, Y, g:i a" }}
|
||||||
|
</p>
|
||||||
|
<div class="markdown-body">{{ announcement.body|markdown }}</div>
|
||||||
|
{% if user.is_staff %}
|
||||||
|
<div class="announce-admin">
|
||||||
|
<a href="{% url 'announcements:edit' announcement.pk %}" class="btn btn-secondary btn-sm"><i class="fas fa-edit"></i> Edit</a>
|
||||||
|
<form method="post" action="{% url 'announcements:delete' announcement.pk %}" class="inline" onsubmit="return confirm('Delete this announcement and all its comments?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-secondary btn-sm"><i class="fas fa-trash"></i> Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2><i class="fas fa-comments"></i> Discussion</h2>
|
||||||
|
|
||||||
|
{% if can_comment %}
|
||||||
|
<form method="post" action="{% url 'announcements:add_comment' announcement.pk %}" class="comment-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<textarea name="body" rows="4" placeholder="Write a comment… Markdown supported." required></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="fas fa-paper-plane"></i> Comment</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-hint"><a href="{% url 'profiles:login' %}?next={{ request.path }}" class="btn btn-secondary btn-sm">Log in</a> to join the discussion.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="comment-list">
|
||||||
|
{% for comment in comments %}
|
||||||
|
<div class="comment" id="comment-{{ comment.pk }}">
|
||||||
|
<div class="comment-head">
|
||||||
|
<strong>{{ comment.user.username }}</strong>
|
||||||
|
<span class="comment-time">{{ comment.created_at|timesince }} ago</span>
|
||||||
|
{% if comment.updated_at != comment.created_at %}<span class="comment-edited">· edited</span>{% endif %}
|
||||||
|
{% if can_comment and comment.user == user %}
|
||||||
|
<span class="comment-actions">
|
||||||
|
<a href="#" class="comment-edit btn btn-secondary btn-sm" data-comment="{{ comment.pk }}"><i class="fas fa-edit"></i> Edit</a>
|
||||||
|
<form method="post" action="{% url 'announcements:delete_comment' announcement.pk comment.pk %}" class="inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="link-btn" title="Delete"><i class="fas fa-trash"></i></button>
|
||||||
|
</form>
|
||||||
|
</span>
|
||||||
|
{% elif can_comment and user.is_staff %}
|
||||||
|
<span class="comment-actions">
|
||||||
|
<form method="post" action="{% url 'announcements:delete_comment' announcement.pk comment.pk %}" class="inline" title="Delete this comment">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-secondary btn-sm"><i class="fas fa-trash"></i> Delete</button>
|
||||||
|
</form>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="comment-body markdown-body">{{ comment.body|markdown }}</div>
|
||||||
|
{% if can_comment and comment.user == user %}
|
||||||
|
<form method="post" action="{% url 'announcements:edit_comment' announcement.pk comment.pk %}" class="comment-edit-form" data-comment="{{ comment.pk }}" hidden>
|
||||||
|
{% csrf_token %}
|
||||||
|
<textarea name="body" rows="3" required>{{ comment.body }}</textarea>
|
||||||
|
<div class="comment-edit-actions">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm comment-edit-cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="empty-hint">No comments yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
document.querySelectorAll('.comment-edit').forEach(link => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = link.dataset.comment;
|
||||||
|
const body = document.querySelector(`#comment-${id} .comment-body`);
|
||||||
|
const form = document.querySelector(`#comment-${id} .comment-edit-form`);
|
||||||
|
if (body) body.hidden = true;
|
||||||
|
if (form) form.hidden = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.comment-edit-cancel').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const form = btn.closest('.comment-edit-form');
|
||||||
|
const id = form.dataset.comment;
|
||||||
|
const body = document.querySelector(`#comment-${id} .comment-body`);
|
||||||
|
if (body) body.hidden = false;
|
||||||
|
form.hidden = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}{% if editing %}Edit announcement{% else %}New announcement{% endif %} - Packs Site{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% if editing %}<i class="fas fa-edit"></i> Edit announcement{% else %}<i class="fas fa-plus"></i> New announcement{% endif %}</h1>
|
||||||
|
|
||||||
|
<form method="post" class="announce-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if form.non_field_errors %}<div class="form-errors">{{ form.non_field_errors }}</div>{% endif %}
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.title.label_tag }}
|
||||||
|
{{ form.title.errors }}
|
||||||
|
{{ form.title }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.body.label_tag }}
|
||||||
|
{{ form.body.errors }}
|
||||||
|
{{ form.body }}
|
||||||
|
<p class="bio-help"><i class="fas fa-info-circle"></i> Supports Markdown — headings, lists, links, code, tables and more.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
{{ form.published }}
|
||||||
|
Publish (visible to everyone)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="announce-form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">{% if editing %}Save changes{% else %}Publish{% endif %}</button>
|
||||||
|
<a href="{% if editing %}{% url 'announcements:detail' announcement.pk %}{% else %}{% url 'announcements:list' %}{% endif %}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load markdown %}
|
||||||
|
|
||||||
|
{% block title %}Announcements - Packs Site{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="home-section-header">
|
||||||
|
<h1><i class="fas fa-bullhorn"></i> Announcements</h1>
|
||||||
|
{% if user.is_staff %}
|
||||||
|
<a href="{% url 'announcements:create' %}" class="btn btn-primary"><i class="fas fa-plus"></i> New announcement</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="announce-list">
|
||||||
|
{% for a in page %}
|
||||||
|
<article class="card announce-card">
|
||||||
|
<h2>
|
||||||
|
<a href="{% url 'announcements:detail' a.pk %}">{{ a.title }}</a>
|
||||||
|
{% if user.is_authenticated and a.pk not in read_ids %}
|
||||||
|
<span class="announce-badge">new</span>
|
||||||
|
{% endif %}
|
||||||
|
</h2>
|
||||||
|
<div class="markdown-body announce-excerpt">{{ a.body|markdown|truncatechars_html:320 }}</div>
|
||||||
|
<p class="announce-meta">
|
||||||
|
<i class="fas fa-user"></i> {{ a.created_by.username|default:"Staff" }}
|
||||||
|
<span class="separator">•</span>
|
||||||
|
{{ a.created_at|date:"F j, Y" }}
|
||||||
|
<span class="separator">•</span>
|
||||||
|
<i class="fas fa-comments"></i> {{ a.comments_count }} comment{{ a.comments_count|pluralize }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
{% empty %}
|
||||||
|
<p class="empty-hint">No announcements yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if page.has_other_pages %}
|
||||||
|
<div class="pagination">
|
||||||
|
{% if page.has_previous %}
|
||||||
|
<a href="?page={{ page.previous_page_number }}">«</a>
|
||||||
|
{% endif %}
|
||||||
|
<span>Page {{ page.number }} of {{ page.paginator.num_pages }}</span>
|
||||||
|
{% if page.has_next %}
|
||||||
|
<a href="?page={{ page.next_page_number }}">»</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Your alerts - Packs Site{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="home-section-header">
|
||||||
|
<h1><i class="fas fa-bell"></i> Your alerts</h1>
|
||||||
|
<form method="post" action="{% url 'announcements:mark_all_read' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.path }}">
|
||||||
|
<button type="submit" class="btn btn-secondary"><i class="fas fa-check-double"></i> Mark all read</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<ul class="notify-list">
|
||||||
|
{% for n in page %}
|
||||||
|
<li class="notify-item{% if not n.read %} notify-unread{% endif %}">
|
||||||
|
<a href="{% url 'library:project_detail' n.project.slug %}">{{ n.text }}</a>
|
||||||
|
<span class="announce-meta">{{ n.created_at|timesince }} ago{% if not n.read %} · <span class="notify-new">new</span>{% endif %}</span>
|
||||||
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li class="empty-hint">You have no alerts yet.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if page.has_other_pages %}
|
||||||
|
<div class="pagination">
|
||||||
|
{% if page.has_previous %}
|
||||||
|
<a href="?page={{ page.previous_page_number }}">«</a>
|
||||||
|
{% endif %}
|
||||||
|
<span>Page {{ page.number }} of {{ page.paginator.num_pages }}</span>
|
||||||
|
{% if page.has_next %}
|
||||||
|
<a href="?page={{ page.next_page_number }}">»</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -45,6 +45,9 @@
|
|||||||
<a href="{% url 'profiles:user_list' %}">
|
<a href="{% url 'profiles:user_list' %}">
|
||||||
<i class="fas fa-users"></i><span class="nav-text"> Users</span>
|
<i class="fas fa-users"></i><span class="nav-text"> Users</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{% url 'announcements:list' %}">
|
||||||
|
<i class="fas fa-bullhorn"></i><span class="nav-text"> Announcements</span>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,44 @@
|
|||||||
|
|
||||||
<div class="home-placeholder">
|
<div class="home-placeholder">
|
||||||
<h2><i class="fas fa-bullhorn"></i> Announcements</h2>
|
<h2><i class="fas fa-bullhorn"></i> Announcements</h2>
|
||||||
<p class="home-placeholder-text">Placeholder — news and updates will live here.</p>
|
<div class="announce-panel">
|
||||||
|
{% for a in announcements %}
|
||||||
|
<div class="announce-item{% if a.pk not in global_read_ids and user.is_authenticated %} announce-unread{% endif %}">
|
||||||
|
<a href="{% url 'announcements:detail' a.pk %}">{{ a.title }}</a>
|
||||||
|
<span class="announce-meta">{{ a.created_at|date:"M j" }}{% if a.comments_count %} · {{ a.comments_count }} comment{{ a.comments_count|pluralize }}{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="empty-hint">No announcements yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if announcements_total > 4 %}
|
||||||
|
<a href="{% url 'announcements:list' %}" class="announce-more">View all ({{ announcements_total }}) <i class="fas fa-arrow-right"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<h2 class="announce-subhead"><i class="fas fa-bell"></i> For you
|
||||||
|
{% if unread_notifications %}<span class="announce-badge">{{ unread_notifications }}</span>{% endif %}
|
||||||
|
</h2>
|
||||||
|
<div class="announce-panel">
|
||||||
|
{% for n in notifications %}
|
||||||
|
<div class="announce-item{% if not n.read %} announce-unread{% endif %}">
|
||||||
|
<a href="{% url 'library:project_detail' n.project.slug %}">{{ n.text }}</a>
|
||||||
|
<span class="announce-meta">{{ n.created_at|timesince }} ago</span>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="empty-hint">Nothing new for you yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="announce-actions">
|
||||||
|
<a href="{% url 'announcements:personal' %}" class="announce-more">All your alerts <i class="fas fa-arrow-right"></i></a>
|
||||||
|
{% if unread_notifications %}
|
||||||
|
<form method="post" action="{% url 'announcements:mark_all_read' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="link-btn">Mark all read</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="home-stats">
|
<div class="home-stats">
|
||||||
|
|||||||
Reference in New Issue
Block a user