| 1 |
# memorial/admin.py |
| 2 |
from django.contrib import admin |
| 3 |
from django import forms |
| 4 |
from django.core.files.storage import default_storage |
| 5 |
from django.conf import settings |
| 6 |
from django.utils.html import format_html |
| 7 |
from django.urls import reverse |
| 8 |
from django.utils.safestring import mark_safe |
| 9 |
from .models import Conflict, Person, Contribution, Award, PersonAward |
| 10 |
import os |
| 11 |
|
| 12 |
|
| 13 |
class PersonAdminForm(forms.ModelForm): |
| 14 |
pdf_file = forms.FileField( |
| 15 |
required=False, |
| 16 |
help_text="Upload a PDF file for this person's memorial" |
| 17 |
) |
| 18 |
|
| 19 |
class Meta: |
| 20 |
model = Person |
| 21 |
fields = '__all__' |
| 22 |
widgets = { |
| 23 |
'death_description': forms.Textarea(attrs={'rows': 4, 'cols': 80}), |
| 24 |
'date_of_death': forms.DateInput(attrs={'type': 'date'}), |
| 25 |
} |
| 26 |
|
| 27 |
def __init__(self, *args, **kwargs): |
| 28 |
super().__init__(*args, **kwargs) |
| 29 |
# Add help text for date fields |
| 30 |
self.fields['date_of_death'].help_text = ( |
| 31 |
"Enter the full date. Use January 1st for year-only dates, " |
| 32 |
"and the 1st of the month for month-year dates." |
| 33 |
) |
| 34 |
self.fields['death_date_precision'].help_text = ( |
| 35 |
"Select the precision of the date entered above. " |
| 36 |
"This controls how the date is displayed on the site." |
| 37 |
) |
| 38 |
|
| 39 |
def save(self, commit=True): |
| 40 |
instance = super().save(commit=False) |
| 41 |
|
| 42 |
# Handle PDF upload |
| 43 |
pdf_file = self.cleaned_data.get('pdf_file') |
| 44 |
if pdf_file: |
| 45 |
# Save instance first to get an ID if it's new |
| 46 |
if not instance.id: |
| 47 |
instance.save() |
| 48 |
|
| 49 |
# Add environment prefix to separate dev/prod files |
| 50 |
env_prefix = 'dev/' if settings.DEBUG else 'prod/' |
| 51 |
|
| 52 |
# Create a meaningful filename with actual ID |
| 53 |
filename = f"{env_prefix}memorials/{instance.last_name}_{instance.first_name}_{instance.id}.pdf" |
| 54 |
filename = filename.replace(' ', '_').lower() |
| 55 |
|
| 56 |
# Delete old file if pdf_key already exists and is different |
| 57 |
if instance.pdf_key and instance.pdf_key != filename: |
| 58 |
try: |
| 59 |
default_storage.delete(instance.pdf_key) |
| 60 |
except Exception: |
| 61 |
pass # Ignore errors when deleting old files |
| 62 |
|
| 63 |
# Save to S3 (or local storage in development) |
| 64 |
path = default_storage.save(filename, pdf_file) |
| 65 |
instance.pdf_key = path |
| 66 |
|
| 67 |
if commit: |
| 68 |
instance.save() |
| 69 |
return instance |
| 70 |
|
| 71 |
|
| 72 |
@admin.register(Conflict) |
| 73 |
class ConflictAdmin(admin.ModelAdmin): |
| 74 |
list_display = ['name', 'start_year', 'end_year', 'casualty_count', 'order'] |
| 75 |
list_editable = ['order'] |
| 76 |
search_fields = ['name'] |
| 77 |
|
| 78 |
|
| 79 |
class PersonAwardInline(admin.TabularInline): |
| 80 |
"""Inline for editing awards on Person admin page""" |
| 81 |
model = PersonAward |
| 82 |
extra = 1 |
| 83 |
autocomplete_fields = ['award'] |
| 84 |
fields = ['award', 'count', 'date_awarded', 'citation'] |
| 85 |
|
| 86 |
|
| 87 |
@admin.register(Person) |
| 88 |
class PersonAdmin(admin.ModelAdmin): |
| 89 |
form = PersonAdminForm |
| 90 |
list_display = ['display_name', 'class_year', 'conflict', 'rank', 'get_death_date_display', 'has_pdf', 'has_description', 'contribution_count', 'award_count'] |
| 91 |
list_filter = ['conflict', 'class_year', 'rank', 'death_date_precision'] |
| 92 |
search_fields = ['first_name', 'last_name', 'unit'] |
| 93 |
autocomplete_fields = ['conflict'] |
| 94 |
inlines = [PersonAwardInline] |
| 95 |
|
| 96 |
fieldsets = ( |
| 97 |
('Name', { |
| 98 |
'fields': ('first_name', 'middle_name', 'last_name', 'suffix') |
| 99 |
}), |
| 100 |
('VMI Information', { |
| 101 |
'fields': ('class_year', 'class_letter') |
| 102 |
}), |
| 103 |
('Military Information', { |
| 104 |
'fields': ('conflict', 'rank', 'unit') |
| 105 |
}), |
| 106 |
('Death Information', { |
| 107 |
'fields': ('date_of_death', 'death_date_precision'), |
| 108 |
'description': 'For partial dates: Use January 1st for year-only, and the 1st of the month for month-year dates.' |
| 109 |
}), |
| 110 |
('Death Details', { |
| 111 |
'fields': ('death_description',), |
| 112 |
'classes': ('wide',), # Makes the text field wider |
| 113 |
}), |
| 114 |
('Memorial Content', { |
| 115 |
'fields': ('pdf_file', 'pdf_key'), |
| 116 |
'description': 'Upload a PDF or view the current S3 key' |
| 117 |
}), |
| 118 |
) |
| 119 |
|
| 120 |
readonly_fields = ['pdf_key'] |
| 121 |
|
| 122 |
def get_death_date_display(self, obj): |
| 123 |
"""Display death date in list view""" |
| 124 |
return obj.death_date_display or 'Unknown' |
| 125 |
get_death_date_display.short_description = 'Date of Death' |
| 126 |
get_death_date_display.admin_order_field = 'date_of_death' |
| 127 |
|
| 128 |
def has_pdf(self, obj): |
| 129 |
return bool(obj.pdf_key) |
| 130 |
has_pdf.boolean = True |
| 131 |
has_pdf.short_description = 'Has PDF' |
| 132 |
|
| 133 |
def has_description(self, obj): |
| 134 |
return bool(obj.death_description) |
| 135 |
has_description.boolean = True |
| 136 |
has_description.short_description = 'Has Description' |
| 137 |
|
| 138 |
def contribution_count(self, obj): |
| 139 |
"""Show number of contributions""" |
| 140 |
total = obj.contributions.count() |
| 141 |
approved = obj.contributions.filter(status='approved').count() |
| 142 |
pending = obj.contributions.filter(status='pending').count() |
| 143 |
|
| 144 |
if pending > 0: |
| 145 |
return format_html( |
| 146 |
'<span style="color: green;">{}</span> / ' |
| 147 |
'<span style="color: orange;">{}</span> / ' |
| 148 |
'<span>{}</span>', |
| 149 |
approved, pending, total |
| 150 |
) |
| 151 |
return f"{approved} / {total}" |
| 152 |
contribution_count.short_description = 'Contributions (A/P/T)' |
| 153 |
|
| 154 |
def award_count(self, obj): |
| 155 |
"""Show number of awards""" |
| 156 |
return obj.person_awards.count() |
| 157 |
award_count.short_description = 'Awards' |
| 158 |
|
| 159 |
|
| 160 |
@admin.register(Award) |
| 161 |
class AwardAdmin(admin.ModelAdmin): |
| 162 |
list_display = ['name', 'recipient_count', 'order'] |
| 163 |
list_editable = ['order'] |
| 164 |
search_fields = ['name', 'short_description'] |
| 165 |
ordering = ['order', 'name'] |
| 166 |
|
| 167 |
fieldsets = ( |
| 168 |
('Award Information', { |
| 169 |
'fields': ('name', 'short_description', 'long_description') |
| 170 |
}), |
| 171 |
('Display', { |
| 172 |
'fields': ('image_filename', 'order') |
| 173 |
}), |
| 174 |
) |
| 175 |
|
| 176 |
|
| 177 |
@admin.register(Contribution) |
| 178 |
class ContributionAdmin(admin.ModelAdmin): |
| 179 |
list_display = [ |
| 180 |
'id', 'person_link', 'contributor_email', 'content_type', |
| 181 |
'status_badge', 'submitted_at', 'reviewed_by' |
| 182 |
] |
| 183 |
list_filter = ['status', 'content_type', 'submitted_at', 'reviewed_at'] |
| 184 |
search_fields = [ |
| 185 |
'person__first_name', 'person__last_name', |
| 186 |
'contributor_email', 'content_text' |
| 187 |
] |
| 188 |
readonly_fields = [ |
| 189 |
'person', 'contributor_email', 'content_type', 'content_text', |
| 190 |
'image_preview', 'submitted_at', 'reviewed_at', 'reviewed_by' |
| 191 |
] |
| 192 |
|
| 193 |
fieldsets = ( |
| 194 |
('Contribution Info', { |
| 195 |
'fields': ('person', 'contributor_email', 'submitted_at') |
| 196 |
}), |
| 197 |
('Content', { |
| 198 |
'fields': ('content_type', 'content_text', 'image_preview') |
| 199 |
}), |
| 200 |
('Review Status', { |
| 201 |
'fields': ('status', 'reviewed_at', 'reviewed_by', 'rejection_reason') |
| 202 |
}) |
| 203 |
) |
| 204 |
|
| 205 |
actions = ['approve_contributions', 'reject_contributions'] |
| 206 |
|
| 207 |
def person_link(self, obj): |
| 208 |
"""Link to person in admin""" |
| 209 |
url = reverse('admin:memorial_person_change', args=[obj.person.id]) |
| 210 |
return format_html('<a href="{}">{}</a>', url, obj.person.full_display_name) |
| 211 |
person_link.short_description = 'Person' |
| 212 |
|
| 213 |
def status_badge(self, obj): |
| 214 |
"""Color-coded status badge""" |
| 215 |
colors = { |
| 216 |
'pending': '#FFA500', |
| 217 |
'approved': '#28a745', |
| 218 |
'rejected': '#dc3545' |
| 219 |
} |
| 220 |
return format_html( |
| 221 |
'<span style="background-color: {}; color: white; padding: 3px 10px; ' |
| 222 |
'border-radius: 3px; font-weight: bold;">{}</span>', |
| 223 |
colors.get(obj.status, '#666'), |
| 224 |
obj.get_status_display() |
| 225 |
) |
| 226 |
status_badge.short_description = 'Status' |
| 227 |
|
| 228 |
def image_preview(self, obj): |
| 229 |
"""Preview of uploaded image""" |
| 230 |
if obj.content_image: |
| 231 |
return mark_safe( |
| 232 |
f'<img src="{obj.content_image.url}" ' |
| 233 |
f'style="max-width: 300px; max-height: 300px;" />' |
| 234 |
) |
| 235 |
return "No image" |
| 236 |
image_preview.short_description = 'Image Preview' |
| 237 |
|
| 238 |
def approve_contributions(self, request, queryset): |
| 239 |
"""Bulk approve action""" |
| 240 |
count = 0 |
| 241 |
for contribution in queryset.filter(status='pending'): |
| 242 |
contribution.approve(request.user) |
| 243 |
count += 1 |
| 244 |
self.message_user(request, f"{count} contributions approved.") |
| 245 |
approve_contributions.short_description = "Approve selected contributions" |
| 246 |
|
| 247 |
def reject_contributions(self, request, queryset): |
| 248 |
"""Bulk reject action""" |
| 249 |
count = 0 |
| 250 |
for contribution in queryset.filter(status='pending'): |
| 251 |
contribution.reject(request.user, "Bulk rejection") |
| 252 |
count += 1 |
| 253 |
self.message_user(request, f"{count} contributions rejected.") |
| 254 |
reject_contributions.short_description = "Reject selected contributions" |
| 255 |
|
| 256 |
def has_add_permission(self, request): |
| 257 |
"""Prevent manual addition through admin""" |
| 258 |
return False |