diff --git a/docker-site/main/admin.py b/docker-site/main/admin.py
index 3d1d346..3845ee2 100644
--- a/docker-site/main/admin.py
+++ b/docker-site/main/admin.py
@@ -1,5 +1,5 @@
from django.contrib import admin
-from .models import UserProfile, Category, Post, Project, PublicationIRIS, UserProfilePublicationIRIS, IRISImportLog, MeetingRoom, RoomReservation, ShortLink, HistoryMilestone, ResearchArea, DashboardCard, ExternalRedirects, WikiPage, WikiPageVersion, WikiPageChangeRequest, WikiImage
+from .models import UserProfile, Category, Post, PostAttachment, Project, PublicationIRIS, UserProfilePublicationIRIS, IRISImportLog, MeetingRoom, RoomReservation, ShortLink, HistoryMilestone, ResearchArea, DashboardCard, ExternalRedirects, WikiPage, WikiPageVersion, WikiPageChangeRequest, WikiImage
admin.site.site_header = "AImageLab Admin"
admin.site.site_title = "AImageLab Admin"
@@ -44,7 +44,15 @@ class CategoryAdmin(admin.ModelAdmin):
pass
class PostAdmin(admin.ModelAdmin):
- pass
+ search_fields = ('title', 'slug', 'description', 'content')
+
+
+@admin.register(PostAttachment)
+class PostAttachmentAdmin(admin.ModelAdmin):
+ list_display = ('name', 'post', 'uploaded_at')
+ list_filter = ('uploaded_at',)
+ search_fields = ('name', 'post__title')
+ autocomplete_fields = ['post']
class ProjectAdmin(admin.ModelAdmin):
list_display = ('title', 'name', 'project_type', 'start_date', 'end_date')
diff --git a/docker-site/main/migrations/0013_postattachment.py b/docker-site/main/migrations/0013_postattachment.py
new file mode 100644
index 0000000..e457e36
--- /dev/null
+++ b/docker-site/main/migrations/0013_postattachment.py
@@ -0,0 +1,27 @@
+# Generated by Django 5.2.11 on 2026-05-02 00:00
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('main', '0012_externalredirects'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='PostAttachment',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(help_text='Display name for this attachment', max_length=200)),
+ ('file', models.FileField(help_text='Attachment file', upload_to='news_attachments/%Y/%m/')),
+ ('uploaded_at', models.DateTimeField(auto_now_add=True)),
+ ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='main.post')),
+ ],
+ options={
+ 'ordering': ['name', '-uploaded_at'],
+ },
+ ),
+ ]
diff --git a/docker-site/main/models.py b/docker-site/main/models.py
index 986a33a..50d87ed 100644
--- a/docker-site/main/models.py
+++ b/docker-site/main/models.py
@@ -345,6 +345,21 @@ def __str__(self):
return self.title
+class PostAttachment(models.Model):
+ """Named file attachment for a news post."""
+
+ post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='attachments')
+ name = models.CharField(max_length=200, help_text="Display name for this attachment")
+ file = models.FileField(upload_to='news_attachments/%Y/%m/', help_text="Attachment file")
+ uploaded_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ['name', '-uploaded_at']
+
+ def __str__(self):
+ return f"{self.name} ({self.post.title})"
+
+
class ShortLink(models.Model):
"""Short URL redirect model (Go links)"""
diff --git a/docker-site/main/templates/main/contacts.html b/docker-site/main/templates/main/contacts.html
index 4524f81..bca037d 100644
--- a/docker-site/main/templates/main/contacts.html
+++ b/docker-site/main/templates/main/contacts.html
@@ -18,13 +18,13 @@
Connect with Us
diff --git a/docker-site/main/templates/main/post_edit.html b/docker-site/main/templates/main/post_edit.html
index c9f1afc..8ebe167 100644
--- a/docker-site/main/templates/main/post_edit.html
+++ b/docker-site/main/templates/main/post_edit.html
@@ -102,6 +102,33 @@
+
+
{% endblock %}
@@ -462,6 +530,16 @@
const modalMessage = document.getElementById('modal-message');
const modalCloseBtn = document.getElementById('modal-close-btn');
+ const attachmentModal = document.getElementById('attachment-modal');
+ const openAttachmentModalBtn = document.getElementById('open-attachment-modal');
+ const addAttachmentBtn = document.getElementById('add-attachment-btn');
+ const attachmentNameInput = document.getElementById('attachment-name');
+ let attachmentFileInput = document.getElementById('attachment-file');
+ const pendingAttachmentsList = document.getElementById('pending-attachments-list');
+ const pendingAttachmentsEmpty = document.getElementById('pending-attachments-empty');
+ const attachmentHiddenInputs = document.getElementById('attachment-hidden-inputs');
+ let stagedAttachmentIndex = 0;
+
function showModal(title, message, showClose = false) {
modalTitle.innerHTML = title;
modalMessage.innerHTML = message;
@@ -475,6 +553,119 @@
modalCloseBtn.addEventListener('click', hideModal);
+ if (openAttachmentModalBtn && attachmentModal) {
+ openAttachmentModalBtn.addEventListener('click', function() {
+ attachmentModal.showModal();
+ });
+ }
+
+ if (addAttachmentBtn) {
+ addAttachmentBtn.addEventListener('click', function() {
+ const attachmentName = attachmentNameInput.value.trim();
+ const attachmentFile = attachmentFileInput.files[0];
+
+ if (!attachmentName || !attachmentFile) {
+ showModal(
+ ' Attachment Required',
+ 'Please provide both attachment name and file.',
+ true
+ );
+ if (typeof lucide !== 'undefined') {
+ lucide.createIcons();
+ }
+ return;
+ }
+
+ const wrapper = document.createElement('div');
+ const attachmentIndex = stagedAttachmentIndex;
+ wrapper.dataset.attachmentIndex = attachmentIndex;
+ wrapper.style.display = 'none';
+
+ const hiddenNameInput = document.createElement('input');
+ hiddenNameInput.type = 'hidden';
+ hiddenNameInput.name = 'new_attachment_names';
+ hiddenNameInput.value = attachmentName;
+
+ // Move the actual file input (which already holds the selected file) into the
+ // hidden wrapper so the browser submits it reliably with the form.
+ // DataTransfer.files assignment is not reliable across all browsers.
+ const fileInputParent = attachmentFileInput.parentNode;
+ const fileInputClass = attachmentFileInput.className;
+ attachmentFileInput.name = 'new_attachments';
+ wrapper.appendChild(hiddenNameInput);
+ wrapper.appendChild(attachmentFileInput);
+ attachmentHiddenInputs.appendChild(wrapper);
+
+ // Replace the moved input with a fresh one for the next attachment.
+ const newFileInput = document.createElement('input');
+ newFileInput.type = 'file';
+ newFileInput.id = 'attachment-file';
+ newFileInput.className = fileInputClass;
+ newFileInput.required = true;
+ fileInputParent.appendChild(newFileInput);
+ attachmentFileInput = newFileInput;
+
+ // Build the pending list item using DOM methods to prevent XSS.
+ const pendingItem = document.createElement('div');
+ pendingItem.className = 'flex items-center justify-between gap-2 border border-base-300 rounded-md px-3 py-2';
+ pendingItem.dataset.attachmentIndex = attachmentIndex;
+
+ const contentDiv = document.createElement('div');
+ contentDiv.className = 'min-w-0';
+
+ const namePara = document.createElement('p');
+ namePara.className = 'text-sm font-medium break-words';
+ namePara.textContent = attachmentName;
+
+ const filePara = document.createElement('p');
+ filePara.className = 'text-xs opacity-70 break-all';
+ filePara.textContent = attachmentFile.name;
+
+ contentDiv.appendChild(namePara);
+ contentDiv.appendChild(filePara);
+
+ const removeBtn = document.createElement('button');
+ removeBtn.type = 'button';
+ removeBtn.className = 'btn btn-ghost btn-xs';
+ removeBtn.dataset.removeStaged = attachmentIndex;
+ removeBtn.textContent = 'Remove';
+
+ pendingItem.appendChild(contentDiv);
+ pendingItem.appendChild(removeBtn);
+ pendingAttachmentsList.appendChild(pendingItem);
+ pendingAttachmentsEmpty.style.display = 'none';
+
+ attachmentNameInput.value = '';
+ attachmentFileInput.value = '';
+ attachmentModal.close();
+ stagedAttachmentIndex += 1;
+ });
+ }
+
+ if (pendingAttachmentsList) {
+ pendingAttachmentsList.addEventListener('click', function(event) {
+ const removeBtn = event.target.closest('[data-remove-staged]');
+ if (!removeBtn) {
+ return;
+ }
+
+ const targetIndex = removeBtn.getAttribute('data-remove-staged');
+ const hiddenWrapper = attachmentHiddenInputs.querySelector(`[data-attachment-index="${targetIndex}"]`);
+ const listItem = pendingAttachmentsList.querySelector(`[data-attachment-index="${targetIndex}"]`);
+
+ if (hiddenWrapper) {
+ hiddenWrapper.remove();
+ }
+ if (listItem) {
+ listItem.remove();
+ }
+
+ if (!pendingAttachmentsList.querySelector('[data-attachment-index]')) {
+ pendingAttachmentsEmpty.style.display = 'block';
+ }
+ });
+ }
+
const uploadBtn = document.getElementById('upload-image-btn');
const fileInput = document.getElementById('post-image-upload');
diff --git a/docker-site/main/templates/main/single.html b/docker-site/main/templates/main/single.html
index def1ddb..8c03e9c 100644
--- a/docker-site/main/templates/main/single.html
+++ b/docker-site/main/templates/main/single.html
@@ -43,6 +43,20 @@
{{ post.content|markdownify|linebreaks }}
+
+ {% if attachments %}
+
+ {% endif %}
diff --git a/docker-site/main/views.py b/docker-site/main/views.py
index 31970fc..c998b53 100644
--- a/docker-site/main/views.py
+++ b/docker-site/main/views.py
@@ -12,12 +12,11 @@
from urllib.parse import quote
from django_ratelimit.decorators import ratelimit
import logging
-from .models import UserProfile, Post, Category, Project, PublicationIRIS, MeetingRoom, RoomReservation, ShortLink, HistoryMilestone, ResearchArea, DashboardCard, WikiPage, WikiPageVersion, WikiPageChangeRequest, WikiImage
+from .models import UserProfile, Post, PostAttachment, Category, Project, PublicationIRIS, MeetingRoom, RoomReservation, ShortLink, HistoryMilestone, ResearchArea, DashboardCard, ExternalRedirects, WikiPage, WikiPageVersion, WikiPageChangeRequest, WikiImage, IRISImportLog
logger = logging.getLogger(__name__)
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.db.models import Q, Case, When, IntegerField, F
-from .models import UserProfile, Post, Category, Project, PublicationIRIS, MeetingRoom, RoomReservation, ShortLink, HistoryMilestone, ResearchArea, DashboardCard, ExternalRedirects, WikiPage, WikiPageVersion, WikiPageChangeRequest, WikiImage, IRISImportLog
from datetime import datetime, timedelta
from django.http import FileResponse, Http404
@@ -424,7 +423,8 @@ def post_single(request, slug):
post = get_object_or_404(Post, slug=slug)
else:
post = get_object_or_404(Post, slug=slug, is_published=True)
- return render(request, 'main/single.html', {'post': post})
+ attachments = post.attachments.all()
+ return render(request, 'main/single.html', {'post': post, 'attachments': attachments})
def privacy_policy(request):
"""Privacy Policy page view."""
@@ -467,6 +467,9 @@ def make_unique_post_slug(raw_slug, exclude_post_id=None):
cover = request.FILES.get('cover')
thumbnail = request.FILES.get('thumbnail')
category_ids = request.POST.getlist('categories')
+ attachment_names = request.POST.getlist('new_attachment_names')
+ attachment_files = request.FILES.getlist('new_attachments')
+ remove_attachment_ids = request.POST.getlist('remove_attachment_ids')
is_pinned = request.POST.get('is_pinned') == 'on'
# Handle event_date
@@ -522,6 +525,9 @@ def make_unique_post_slug(raw_slug, exclude_post_id=None):
post.is_published = is_published
post.save()
post.categories.set(category_ids)
+
+ if remove_attachment_ids:
+ PostAttachment.objects.filter(post=post, id__in=remove_attachment_ids).delete()
else:
# Create new post
post = Post.objects.create(
@@ -536,6 +542,19 @@ def make_unique_post_slug(raw_slug, exclude_post_id=None):
is_pinned=is_pinned,
)
post.categories.set(category_ids)
+
+ for idx, attachment_file in enumerate(attachment_files):
+ attachment_name = ''
+ if idx < len(attachment_names):
+ attachment_name = attachment_names[idx].strip()
+ if not attachment_name:
+ attachment_name = os.path.splitext(attachment_file.name)[0]
+
+ PostAttachment.objects.create(
+ post=post,
+ name=attachment_name[:200],
+ file=attachment_file,
+ )
if action == 'publish':
django_messages.success(request, 'Post published successfully!')