vmi-virtual-memorial/vmi-wd-backend / 812ace5

Browse files

submission feature stable

Authored by espadonne
SHA
812ace579119d3923e78b2b76e7c1cd995840fa2
Parents
908ccc8
Tree
9135ecf

7 changed files

StatusFile+-
M memorial/admin.py 106 3
A memorial/migrations/0005_contribution.py 110 0
M memorial/models.py 91 1
M memorial/serializers.py 105 2
M memorial/urls.py 28 2
M memorial/views.py 264 25
M vmi_wardead_be/settings.py 138 36
memorial/admin.pymodified
@@ -3,7 +3,10 @@ from django.contrib import admin
33
 from django import forms
44
 from django.core.files.storage import default_storage
55
 from django.conf import settings
6
-from .models import Conflict, Person
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
710
 import os
811
 
912
 
@@ -76,7 +79,7 @@ class ConflictAdmin(admin.ModelAdmin):
7679
 @admin.register(Person)
7780
 class PersonAdmin(admin.ModelAdmin):
7881
     form = PersonAdminForm
79
-    list_display = ['display_name', 'class_year', 'conflict', 'rank', 'get_death_date_display', 'has_pdf', 'has_description']
82
+    list_display = ['display_name', 'class_year', 'conflict', 'rank', 'get_death_date_display', 'has_pdf', 'has_description', 'contribution_count']
8083
     list_filter = ['conflict', 'class_year', 'rank', 'death_date_precision']
8184
     search_fields = ['first_name', 'last_name', 'unit']
8285
     autocomplete_fields = ['conflict']
@@ -121,4 +124,104 @@ class PersonAdmin(admin.ModelAdmin):
121124
     def has_description(self, obj):
122125
         return bool(obj.death_description)
123126
     has_description.boolean = True
124
-    has_description.short_description = 'Has Description'
127
+    has_description.short_description = 'Has Description'
128
+    
129
+    def contribution_count(self, obj):
130
+        """Show number of contributions"""
131
+        total = obj.contributions.count()
132
+        approved = obj.contributions.filter(status='approved').count()
133
+        pending = obj.contributions.filter(status='pending').count()
134
+        
135
+        if pending > 0:
136
+            return format_html(
137
+                '<span style="color: green;">{}</span> / '
138
+                '<span style="color: orange;">{}</span> / '
139
+                '<span>{}</span>',
140
+                approved, pending, total
141
+            )
142
+        return f"{approved} / {total}"
143
+    contribution_count.short_description = 'Contributions (A/P/T)'
144
+
145
+
146
+@admin.register(Contribution)
147
+class ContributionAdmin(admin.ModelAdmin):
148
+    list_display = [
149
+        'id', 'person_link', 'contributor_email', 'content_type', 
150
+        'status_badge', 'submitted_at', 'reviewed_by'
151
+    ]
152
+    list_filter = ['status', 'content_type', 'submitted_at', 'reviewed_at']
153
+    search_fields = [
154
+        'person__first_name', 'person__last_name', 
155
+        'contributor_email', 'content_text'
156
+    ]
157
+    readonly_fields = [
158
+        'person', 'contributor_email', 'content_type', 'content_text',
159
+        'image_preview', 'submitted_at', 'reviewed_at', 'reviewed_by'
160
+    ]
161
+    
162
+    fieldsets = (
163
+        ('Contribution Info', {
164
+            'fields': ('person', 'contributor_email', 'submitted_at')
165
+        }),
166
+        ('Content', {
167
+            'fields': ('content_type', 'content_text', 'image_preview')
168
+        }),
169
+        ('Review Status', {
170
+            'fields': ('status', 'reviewed_at', 'reviewed_by', 'rejection_reason')
171
+        })
172
+    )
173
+    
174
+    actions = ['approve_contributions', 'reject_contributions']
175
+    
176
+    def person_link(self, obj):
177
+        """Link to person in admin"""
178
+        url = reverse('admin:memorial_person_change', args=[obj.person.id])
179
+        return format_html('<a href="{}">{}</a>', url, obj.person.full_display_name)
180
+    person_link.short_description = 'Person'
181
+    
182
+    def status_badge(self, obj):
183
+        """Color-coded status badge"""
184
+        colors = {
185
+            'pending': '#FFA500',
186
+            'approved': '#28a745',
187
+            'rejected': '#dc3545'
188
+        }
189
+        return format_html(
190
+            '<span style="background-color: {}; color: white; padding: 3px 10px; '
191
+            'border-radius: 3px; font-weight: bold;">{}</span>',
192
+            colors.get(obj.status, '#666'),
193
+            obj.get_status_display()
194
+        )
195
+    status_badge.short_description = 'Status'
196
+    
197
+    def image_preview(self, obj):
198
+        """Preview of uploaded image"""
199
+        if obj.content_image:
200
+            return mark_safe(
201
+                f'<img src="{obj.content_image.url}" '
202
+                f'style="max-width: 300px; max-height: 300px;" />'
203
+            )
204
+        return "No image"
205
+    image_preview.short_description = 'Image Preview'
206
+    
207
+    def approve_contributions(self, request, queryset):
208
+        """Bulk approve action"""
209
+        count = 0
210
+        for contribution in queryset.filter(status='pending'):
211
+            contribution.approve(request.user)
212
+            count += 1
213
+        self.message_user(request, f"{count} contributions approved.")
214
+    approve_contributions.short_description = "Approve selected contributions"
215
+    
216
+    def reject_contributions(self, request, queryset):
217
+        """Bulk reject action"""
218
+        count = 0
219
+        for contribution in queryset.filter(status='pending'):
220
+            contribution.reject(request.user, "Bulk rejection")
221
+            count += 1
222
+        self.message_user(request, f"{count} contributions rejected.")
223
+    reject_contributions.short_description = "Reject selected contributions"
224
+    
225
+    def has_add_permission(self, request):
226
+        """Prevent manual addition through admin"""
227
+        return False
memorial/migrations/0005_contribution.pyadded
@@ -0,0 +1,110 @@
1
+# Generated by Django 5.0.6 on 2025-07-18 20:20
2
+
3
+import django.db.models.deletion
4
+from django.conf import settings
5
+from django.db import migrations, models
6
+
7
+
8
+class Migration(migrations.Migration):
9
+
10
+    dependencies = [
11
+        ("memorial", "0004_person_death_date_precision"),
12
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13
+    ]
14
+
15
+    operations = [
16
+        migrations.CreateModel(
17
+            name="Contribution",
18
+            fields=[
19
+                (
20
+                    "id",
21
+                    models.BigAutoField(
22
+                        auto_created=True,
23
+                        primary_key=True,
24
+                        serialize=False,
25
+                        verbose_name="ID",
26
+                    ),
27
+                ),
28
+                (
29
+                    "contributor_email",
30
+                    models.EmailField(
31
+                        help_text="Email of the person submitting this contribution",
32
+                        max_length=254,
33
+                    ),
34
+                ),
35
+                (
36
+                    "content_type",
37
+                    models.CharField(
38
+                        choices=[
39
+                            ("text", "Text"),
40
+                            ("image", "Image"),
41
+                            ("both", "Both"),
42
+                        ],
43
+                        default="text",
44
+                        max_length=10,
45
+                    ),
46
+                ),
47
+                (
48
+                    "content_text",
49
+                    models.TextField(
50
+                        blank=True, help_text="Text content of the contribution"
51
+                    ),
52
+                ),
53
+                (
54
+                    "content_image",
55
+                    models.ImageField(
56
+                        blank=True,
57
+                        help_text="Image contribution",
58
+                        null=True,
59
+                        upload_to="contributions/",
60
+                    ),
61
+                ),
62
+                (
63
+                    "status",
64
+                    models.CharField(
65
+                        choices=[
66
+                            ("pending", "Pending"),
67
+                            ("approved", "Approved"),
68
+                            ("rejected", "Rejected"),
69
+                        ],
70
+                        default="pending",
71
+                        max_length=10,
72
+                    ),
73
+                ),
74
+                ("submitted_at", models.DateTimeField(auto_now_add=True)),
75
+                ("reviewed_at", models.DateTimeField(blank=True, null=True)),
76
+                ("rejection_reason", models.TextField(blank=True)),
77
+                (
78
+                    "person",
79
+                    models.ForeignKey(
80
+                        on_delete=django.db.models.deletion.CASCADE,
81
+                        related_name="contributions",
82
+                        to="memorial.person",
83
+                    ),
84
+                ),
85
+                (
86
+                    "reviewed_by",
87
+                    models.ForeignKey(
88
+                        blank=True,
89
+                        null=True,
90
+                        on_delete=django.db.models.deletion.SET_NULL,
91
+                        related_name="reviewed_contributions",
92
+                        to=settings.AUTH_USER_MODEL,
93
+                    ),
94
+                ),
95
+            ],
96
+            options={
97
+                "ordering": ["-submitted_at"],
98
+                "indexes": [
99
+                    models.Index(
100
+                        fields=["person", "status"],
101
+                        name="memorial_co_person__8f13d8_idx",
102
+                    ),
103
+                    models.Index(
104
+                        fields=["status", "submitted_at"],
105
+                        name="memorial_co_status_0bff92_idx",
106
+                    ),
107
+                ],
108
+            },
109
+        ),
110
+    ]
memorial/models.pymodified
@@ -1,5 +1,6 @@
11
 from django.db import models
22
 from django.urls import reverse
3
+from django.utils import timezone
34
 
45
 
56
 class Conflict(models.Model):
@@ -128,4 +129,93 @@ class Person(models.Model):
128129
             return self.date_of_death.strftime('%B %d, %Y')
129130
     
130131
     def get_absolute_url(self):
131
-        return reverse('person-detail', kwargs={'pk': self.pk})
132
+        return reverse('person-detail', kwargs={'pk': self.pk})
133
+
134
+
135
+class ContributionStatus(models.TextChoices):
136
+    PENDING = 'pending', 'Pending'
137
+    APPROVED = 'approved', 'Approved'
138
+    REJECTED = 'rejected', 'Rejected'
139
+
140
+
141
+class Contribution(models.Model):
142
+    """Community contributions for person records"""
143
+    
144
+    # Link to person
145
+    person = models.ForeignKey(
146
+        'Person', 
147
+        on_delete=models.CASCADE, 
148
+        related_name='contributions'
149
+    )
150
+    
151
+    # Contributor info
152
+    contributor_email = models.EmailField(
153
+        help_text="Email of the person submitting this contribution"
154
+    )
155
+    
156
+    # Content
157
+    content_type = models.CharField(
158
+        max_length=10,
159
+        choices=[
160
+            ('text', 'Text'),
161
+            ('image', 'Image'),
162
+            ('both', 'Both'),
163
+        ],
164
+        default='text'
165
+    )
166
+    content_text = models.TextField(
167
+        blank=True,
168
+        help_text="Text content of the contribution"
169
+    )
170
+    content_image = models.ImageField(
171
+        upload_to='contributions/',
172
+        blank=True,
173
+        null=True,
174
+        help_text="Image contribution"
175
+    )
176
+    
177
+    # Moderation
178
+    status = models.CharField(
179
+        max_length=10,
180
+        choices=ContributionStatus.choices,
181
+        default=ContributionStatus.PENDING
182
+    )
183
+    
184
+    # Timestamps
185
+    submitted_at = models.DateTimeField(auto_now_add=True)
186
+    reviewed_at = models.DateTimeField(null=True, blank=True)
187
+    
188
+    # Review info
189
+    reviewed_by = models.ForeignKey(
190
+        'auth.User',
191
+        on_delete=models.SET_NULL,
192
+        null=True,
193
+        blank=True,
194
+        related_name='reviewed_contributions'
195
+    )
196
+    rejection_reason = models.TextField(blank=True)
197
+    
198
+    class Meta:
199
+        ordering = ['-submitted_at']
200
+        indexes = [
201
+            models.Index(fields=['person', 'status']),
202
+            models.Index(fields=['status', 'submitted_at']),
203
+        ]
204
+    
205
+    def __str__(self):
206
+        return f"Contribution for {self.person} by {self.contributor_email} ({self.status})"
207
+    
208
+    def approve(self, user):
209
+        """Approve this contribution"""
210
+        self.status = ContributionStatus.APPROVED
211
+        self.reviewed_by = user
212
+        self.reviewed_at = timezone.now()
213
+        self.save()
214
+    
215
+    def reject(self, user, reason=''):
216
+        """Reject this contribution"""
217
+        self.status = ContributionStatus.REJECTED
218
+        self.reviewed_by = user
219
+        self.reviewed_at = timezone.now()
220
+        self.rejection_reason = reason
221
+        self.save()
memorial/serializers.pymodified
@@ -1,5 +1,5 @@
11
 from rest_framework import serializers
2
-from .models import Conflict, Person
2
+from .models import Conflict, Person, Contribution
33
 
44
 
55
 class PersonListSerializer(serializers.ModelSerializer):
@@ -78,4 +78,107 @@ class ConflictDetailSerializer(serializers.ModelSerializer):
7878
         fields = [
7979
             'id', 'name', 'start_year', 'end_year', 
8080
             'description', 'casualty_count', 'order', 'casualties'
81
-        ]
81
+        ]
82
+
83
+
84
+# Contribution serializers
85
+class ContributionSerializer(serializers.ModelSerializer):
86
+    """Base serializer for contributions"""
87
+    contributor_email = serializers.EmailField(required=True)
88
+    reviewed_by_username = serializers.CharField(
89
+        source='reviewed_by.username', 
90
+        read_only=True
91
+    )
92
+    
93
+    class Meta:
94
+        model = Contribution
95
+        fields = [
96
+            'id', 'person', 'contributor_email', 'content_type',
97
+            'content_text', 'content_image', 'status', 'submitted_at',
98
+            'reviewed_at', 'reviewed_by', 'reviewed_by_username',
99
+            'rejection_reason'
100
+        ]
101
+        read_only_fields = [
102
+            'id', 'status', 'submitted_at', 'reviewed_at', 
103
+            'reviewed_by', 'rejection_reason'
104
+        ]
105
+
106
+
107
+class ContributionCreateSerializer(serializers.ModelSerializer):
108
+    """Serializer for creating contributions"""
109
+    content_image = serializers.ImageField(required=False, allow_null=True)
110
+    
111
+    class Meta:
112
+        model = Contribution
113
+        fields = ['contributor_email', 'content_type', 'content_text', 'content_image']
114
+    
115
+    def validate(self, data):
116
+        """Ensure content matches content_type"""
117
+        content_type = data.get('content_type')
118
+        
119
+        if content_type in ['text', 'both'] and not data.get('content_text'):
120
+            raise serializers.ValidationError(
121
+                "Text content is required for text contributions"
122
+            )
123
+        
124
+        if content_type in ['image', 'both'] and not data.get('content_image'):
125
+            raise serializers.ValidationError(
126
+                "Image is required for image contributions"
127
+            )
128
+        
129
+        return data
130
+
131
+
132
+class ContributionPublicSerializer(serializers.ModelSerializer):
133
+    """Public view of approved contributions only"""
134
+    contributor_display = serializers.SerializerMethodField()
135
+    
136
+    class Meta:
137
+        model = Contribution
138
+        fields = [
139
+            'id', 'content_type', 'content_text', 'content_image',
140
+            'submitted_at', 'contributor_display'
141
+        ]
142
+    
143
+    def get_contributor_display(self, obj):
144
+        """Partially mask email for privacy"""
145
+        email = obj.contributor_email
146
+        parts = email.split('@')
147
+        if len(parts) == 2:
148
+            name = parts[0]
149
+            if len(name) > 2:
150
+                masked = name[0] + '*' * (len(name) - 2) + name[-1]
151
+            else:
152
+                masked = name[0] + '*'
153
+            return f"{masked}@{parts[1]}"
154
+        return "Anonymous"
155
+
156
+
157
+class ContributionReviewSerializer(serializers.Serializer):
158
+    """Serializer for approving/rejecting contributions"""
159
+    action = serializers.ChoiceField(choices=['approve', 'reject'])
160
+    rejection_reason = serializers.CharField(required=False, allow_blank=True)
161
+    
162
+    def validate(self, data):
163
+        if data['action'] == 'reject' and not data.get('rejection_reason'):
164
+            raise serializers.ValidationError(
165
+                "Rejection reason is required when rejecting a contribution"
166
+            )
167
+        return data
168
+
169
+
170
+class PersonDetailSerializerWithContributions(PersonDetailSerializer):
171
+    """Person details including approved contributions"""
172
+    contributions = ContributionPublicSerializer(many=True, read_only=True)
173
+    
174
+    class Meta(PersonDetailSerializer.Meta):
175
+        fields = PersonDetailSerializer.Meta.fields + ['contributions']
176
+    
177
+    def to_representation(self, instance):
178
+        data = super().to_representation(instance)
179
+        # Only show approved contributions
180
+        data['contributions'] = ContributionPublicSerializer(
181
+            instance.contributions.filter(status='approved'),
182
+            many=True
183
+        ).data
184
+        return data
memorial/urls.pymodified
@@ -1,14 +1,40 @@
1
+# memorial/urls.py
12
 from django.urls import path, include
23
 from rest_framework.routers import DefaultRouter
3
-from .views import ConflictViewSet, PersonViewSet, memorial_index, search_filters, test_s3_connection
4
+from rest_framework_nested import routers as nested_routers
5
+from .views import (
6
+    ConflictViewSet, PersonViewSet, ContributionViewSet,
7
+    memorial_index, search_filters, test_s3_connection,
8
+    pending_contributions, contribution_stats,
9
+    create_contribution, get_csrf_token
10
+)
411
 
12
+# Main router
513
 router = DefaultRouter()
614
 router.register('conflicts', ConflictViewSet)
715
 router.register('persons', PersonViewSet)
816
 
17
+# Nested router for person contributions (admin only except creation)
18
+persons_router = nested_routers.NestedDefaultRouter(router, 'persons', lookup='person')
19
+persons_router.register('contributions', ContributionViewSet, basename='person-contributions')
20
+
921
 urlpatterns = [
10
-    path('', include(router.urls)),
22
+    # CSRF token endpoint (must be first for frontend)
23
+    path('csrf/', get_csrf_token, name='csrf-token'),
24
+    
25
+    # Public endpoints
1126
     path('index/', memorial_index, name='memorial-index'),
1227
     path('search-filters/', search_filters, name='search-filters'),
28
+    
29
+    # Contribution creation (public with throttling and CSRF)
30
+    path('persons/<int:person_id>/contributions/', create_contribution, name='create-contribution'),
31
+    
32
+    # Admin-only endpoints
33
+    path('contributions/pending/', pending_contributions, name='pending-contributions'),
34
+    path('contributions/stats/', contribution_stats, name='contribution-stats'),
1335
     path('test-s3/', test_s3_connection, name='test-s3'),
36
+    
37
+    # Include routers
38
+    path('', include(router.urls)),
39
+    path('', include(persons_router.urls)),
1440
 ]
memorial/views.pymodified
@@ -1,20 +1,38 @@
1
-from rest_framework import viewsets, status
2
-from rest_framework.decorators import action, api_view
1
+from rest_framework import viewsets, status, permissions
2
+from rest_framework.decorators import action, api_view, permission_classes, authentication_classes, parser_classes
33
 from rest_framework.response import Response
4
-from django.http import HttpResponse, HttpResponseRedirect
4
+from rest_framework.parsers import MultiPartParser, FormParser
5
+from rest_framework.authentication import SessionAuthentication
6
+from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
7
+from django.views.decorators.csrf import ensure_csrf_cookie
8
+from django.middleware.csrf import get_token
9
+from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
510
 from django.db.models import Q
11
+from django.core.exceptions import ValidationError
12
+from django.core.files.uploadedfile import UploadedFile
613
 from datetime import datetime
714
 import boto3
815
 from botocore.exceptions import ClientError
916
 from django.conf import settings
10
-from .models import Conflict, Person
17
+from PIL import Image
18
+import io
19
+
20
+from .models import Conflict, Person, Contribution
1121
 from .serializers import (
1222
     ConflictSerializer, ConflictDetailSerializer,
1323
     PersonListSerializer, PersonDetailSerializer,
14
-    PersonSearchSerializer
24
+    PersonSearchSerializer, PersonDetailSerializerWithContributions,
25
+    ContributionSerializer, ContributionCreateSerializer,
26
+    ContributionPublicSerializer, ContributionReviewSerializer
1527
 )
1628
 
1729
 
30
+# Constants for contribution validation
31
+MAX_IMAGE_SIZE = 10 * 1024 * 1024  # 10MB
32
+ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
33
+MAX_IMAGE_DIMENSIONS = (4000, 4000)  # Max width/height
34
+
35
+
1836
 class ConflictViewSet(viewsets.ReadOnlyModelViewSet):
1937
     """API endpoints for conflicts"""
2038
     queryset = Conflict.objects.all()
@@ -34,7 +52,7 @@ class PersonViewSet(viewsets.ReadOnlyModelViewSet):
3452
             return PersonListSerializer
3553
         elif self.action == 'search':
3654
             return PersonSearchSerializer
37
-        return PersonDetailSerializer
55
+        return PersonDetailSerializerWithContributions
3856
     
3957
     def get_queryset(self):
4058
         queryset = super().get_queryset()
@@ -42,20 +60,16 @@ class PersonViewSet(viewsets.ReadOnlyModelViewSet):
4260
         
4361
         if conflict_id is not None:
4462
             queryset = queryset.filter(conflict_id=conflict_id)
45
-            # For conflict-specific queries, order by class year ascending (earliest first), then name
4663
             return queryset.extra(
4764
                 select={'class_year_null': 'class_year IS NULL'},
4865
                 order_by=['class_year_null', 'class_year', 'last_name', 'first_name']
4966
             )
5067
         
51
-        # Allow ordering by different fields via query param
5268
         order_by = self.request.query_params.get('order_by', 'name')
5369
         
5470
         if order_by == 'class_year':
55
-            # Order by class year ascending (earliest first), then name
5671
             return queryset.order_by('class_year', 'last_name', 'first_name')
5772
         else:
58
-            # Default ordering by name
5973
             return queryset.order_by('last_name', 'first_name')
6074
     
6175
     @action(detail=False, methods=['get'])
@@ -109,13 +123,11 @@ class PersonViewSet(viewsets.ReadOnlyModelViewSet):
109123
                 except ValueError:
110124
                     pass
111125
         
112
-        # Order results - by class year ascending (earliest first) then name
113126
         queryset = queryset.extra(
114127
             select={'class_year_null': 'class_year IS NULL'},
115128
             order_by=['class_year_null', 'class_year', 'last_name', 'first_name']
116129
         )
117130
         
118
-        # Paginate if needed (for now, return all for infinite scroll)
119131
         serializer = PersonSearchSerializer(queryset, many=True)
120132
         return Response({
121133
             'count': queryset.count(),
@@ -146,7 +158,6 @@ class PersonViewSet(viewsets.ReadOnlyModelViewSet):
146158
                 import os
147159
                 file_path = os.path.join(settings.MEDIA_ROOT, person.pdf_key)
148160
                 response = FileResponse(open(file_path, 'rb'), content_type='application/pdf')
149
-                # FIXED: Allow iframe embedding for PDF viewer
150161
                 response['X-Frame-Options'] = 'SAMEORIGIN'
151162
                 return response
152163
             except FileNotFoundError:
@@ -164,7 +175,6 @@ class PersonViewSet(viewsets.ReadOnlyModelViewSet):
164175
                 region_name=settings.AWS_S3_REGION_NAME
165176
             )
166177
             
167
-            # Generate presigned URL
168178
             presigned_url = s3_client.generate_presigned_url(
169179
                 'get_object',
170180
                 Params={
@@ -174,19 +184,155 @@ class PersonViewSet(viewsets.ReadOnlyModelViewSet):
174184
                 ExpiresIn=3600  # URL expires in 1 hour
175185
             )
176186
             
177
-            # FIXED: Return a redirect response instead of JSON
178
-            # This allows the iframe to load the PDF directly from S3
179187
             response = HttpResponseRedirect(presigned_url)
180
-            # Allow iframe embedding for PDF viewer
181188
             response['X-Frame-Options'] = 'SAMEORIGIN'
182189
             return response
183190
             
184191
         except ClientError as e:
185
-            print(f"Error generating presigned URL: {e}")
186192
             return Response(
187193
                 {"error": "Failed to generate PDF URL"}, 
188194
                 status=status.HTTP_500_INTERNAL_SERVER_ERROR
189195
             )
196
+    
197
+    @action(detail=True, methods=['get'])
198
+    def contributions(self, request, pk=None):
199
+        """Get approved contributions for a person"""
200
+        person = self.get_object()
201
+        contributions = person.contributions.filter(status='approved')
202
+        serializer = ContributionPublicSerializer(contributions, many=True)
203
+        return Response({
204
+            'count': contributions.count(),
205
+            'results': serializer.data
206
+        })
207
+
208
+
209
+class ContributionViewSet(viewsets.ModelViewSet):
210
+    """API endpoints for contributions - Admin only except for creation"""
211
+    queryset = Contribution.objects.all()
212
+    parser_classes = (MultiPartParser, FormParser)
213
+    
214
+    def get_serializer_class(self):
215
+        if self.action == 'create':
216
+            return ContributionCreateSerializer
217
+        elif self.action == 'review':
218
+            return ContributionReviewSerializer
219
+        return ContributionSerializer
220
+    
221
+    def get_permissions(self):
222
+        """
223
+        Anyone can create contributions (with throttling)
224
+        Only admin can list, update, or delete
225
+        """
226
+        if self.action == 'create':
227
+            return [permissions.AllowAny()]
228
+        return [permissions.IsAdminUser()]
229
+    
230
+    def get_throttles(self):
231
+        """Apply throttling to creation"""
232
+        if self.action == 'create':
233
+            return [AnonRateThrottle()]
234
+        return []
235
+    
236
+    def validate_image(self, image_file):
237
+        """Validate uploaded image"""
238
+        if image_file.size > MAX_IMAGE_SIZE:
239
+            raise ValidationError(f"Image size must be less than {MAX_IMAGE_SIZE // 1024 // 1024}MB")
240
+        
241
+        if image_file.content_type not in ALLOWED_IMAGE_TYPES:
242
+            raise ValidationError(f"Image type must be one of: {', '.join(ALLOWED_IMAGE_TYPES)}")
243
+        
244
+        try:
245
+            img = Image.open(image_file)
246
+            img.verify()
247
+            
248
+            image_file.seek(0)
249
+            img = Image.open(image_file)
250
+            
251
+            if img.width > MAX_IMAGE_DIMENSIONS[0] or img.height > MAX_IMAGE_DIMENSIONS[1]:
252
+                raise ValidationError(
253
+                    f"Image dimensions must be less than {MAX_IMAGE_DIMENSIONS[0]}x{MAX_IMAGE_DIMENSIONS[1]}"
254
+                )
255
+            
256
+            image_file.seek(0)
257
+            
258
+        except Exception as e:
259
+            raise ValidationError(f"Invalid image file: {str(e)}")
260
+    
261
+    def create(self, request, *args, **kwargs):
262
+        """Create a new contribution with validation"""
263
+        person_id = self.kwargs.get('person_pk')
264
+        
265
+        if not person_id:
266
+            return Response(
267
+                {"error": "Person ID not found in URL"}, 
268
+                status=status.HTTP_400_BAD_REQUEST
269
+            )
270
+        
271
+        try:
272
+            person = Person.objects.get(pk=person_id)
273
+        except Person.DoesNotExist:
274
+            return Response(
275
+                {"error": "Person not found"}, 
276
+                status=status.HTTP_404_NOT_FOUND
277
+            )
278
+        
279
+        serializer = self.get_serializer(data=request.data)
280
+        serializer.is_valid(raise_exception=True)
281
+        
282
+        # Validate image if provided
283
+        image = request.FILES.get('content_image')
284
+        if image:
285
+            try:
286
+                self.validate_image(image)
287
+            except ValidationError as e:
288
+                return Response(
289
+                    {"error": str(e)}, 
290
+                    status=status.HTTP_400_BAD_REQUEST
291
+                )
292
+        
293
+        contribution = serializer.save(person=person)
294
+        
295
+        return Response(
296
+            ContributionSerializer(contribution).data,
297
+            status=status.HTTP_201_CREATED
298
+        )
299
+    
300
+    @action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
301
+    def review(self, request, pk=None):
302
+        """Approve or reject a contribution"""
303
+        contribution = self.get_object()
304
+        serializer = self.get_serializer(data=request.data)
305
+        serializer.is_valid(raise_exception=True)
306
+        
307
+        action = serializer.validated_data['action']
308
+        
309
+        if action == 'approve':
310
+            contribution.approve(request.user)
311
+        else:  # reject
312
+            rejection_reason = serializer.validated_data.get('rejection_reason', '')
313
+            contribution.reject(request.user, rejection_reason)
314
+        
315
+        return Response(ContributionSerializer(contribution).data)
316
+    
317
+    def list(self, request, *args, **kwargs):
318
+        """List contributions with filtering (admin only)"""
319
+        queryset = self.get_queryset()
320
+        
321
+        status_filter = request.query_params.get('status')
322
+        if status_filter:
323
+            queryset = queryset.filter(status=status_filter)
324
+        
325
+        person_id = request.query_params.get('person')
326
+        if person_id:
327
+            queryset = queryset.filter(person_id=person_id)
328
+        
329
+        queryset = queryset.order_by('-submitted_at')
330
+        
331
+        serializer = self.get_serializer(queryset, many=True)
332
+        return Response({
333
+            'count': queryset.count(),
334
+            'results': serializer.data
335
+        })
190336
 
191337
 
192338
 @api_view(['GET'])
@@ -196,8 +342,6 @@ def memorial_index(request):
196342
     data = []
197343
     
198344
     for conflict in conflicts:
199
-        # Order by class year ascending (earliest first), then by name
200
-        # Use raw SQL ordering to put nulls last
201345
         casualties = Person.objects.filter(conflict=conflict).extra(
202346
             select={'class_year_null': 'class_year IS NULL'},
203347
             order_by=['class_year_null', 'class_year', 'last_name', 'first_name']
@@ -220,14 +364,55 @@ def search_filters(request):
220364
         'class_years': list(class_years)
221365
     })
222366
 
367
+
368
+# Admin-only views for managing contributions
369
+@api_view(['GET'])
370
+@permission_classes([permissions.IsAdminUser])
371
+def pending_contributions(request):
372
+    """Get all pending contributions for admin review"""
373
+    contributions = Contribution.objects.filter(status='pending').order_by('-submitted_at')
374
+    
375
+    data = []
376
+    for contrib in contributions:
377
+        contrib_data = ContributionSerializer(contrib).data
378
+        contrib_data['person_details'] = PersonListSerializer(contrib.person).data
379
+        data.append(contrib_data)
380
+    
381
+    return Response({
382
+        'count': contributions.count(),
383
+        'results': data
384
+    })
385
+
386
+
387
+@api_view(['GET'])
388
+@permission_classes([permissions.IsAdminUser])
389
+def contribution_stats(request):
390
+    """Get statistics about contributions"""
391
+    from django.db.models import Count
392
+    
393
+    stats = Contribution.objects.aggregate(
394
+        total=Count('id'),
395
+        pending=Count('id', filter=Q(status='pending')),
396
+        approved=Count('id', filter=Q(status='approved')),
397
+        rejected=Count('id', filter=Q(status='rejected'))
398
+    )
399
+    
400
+    recent = Contribution.objects.order_by('-submitted_at')[:10]
401
+    
402
+    return Response({
403
+        'stats': stats,
404
+        'recent': ContributionSerializer(recent, many=True).data
405
+    })
406
+
407
+
223408
 @api_view(['GET'])
409
+@permission_classes([permissions.IsAdminUser])
224410
 def test_s3_connection(request):
225
-    """Test S3 configuration in production"""
411
+    """Test S3 configuration in production - Admin only"""
226412
     try:
227413
         import boto3
228414
         from django.conf import settings
229415
         
230
-        # Check if credentials are set
231416
         if not settings.AWS_ACCESS_KEY_ID:
232417
             return Response({
233418
                 "error": "AWS_ACCESS_KEY_ID not configured",
@@ -235,7 +420,6 @@ def test_s3_connection(request):
235420
                 "bucket": settings.AWS_STORAGE_BUCKET_NAME
236421
             })
237422
         
238
-        # Try to connect to S3
239423
         s3_client = boto3.client(
240424
             's3',
241425
             aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
@@ -243,7 +427,6 @@ def test_s3_connection(request):
243427
             region_name=settings.AWS_S3_REGION_NAME
244428
         )
245429
         
246
-        # List objects
247430
         response = s3_client.list_objects_v2(
248431
             Bucket=settings.AWS_STORAGE_BUCKET_NAME,
249432
             MaxKeys=5
@@ -267,4 +450,60 @@ def test_s3_connection(request):
267450
             "error": str(e),
268451
             "type": type(e).__name__,
269452
             "debug": settings.DEBUG
270
-        }, status=500)
453
+        }, status=500)
454
+
455
+
456
+@api_view(['POST'])
457
+@parser_classes([MultiPartParser, FormParser])
458
+@permission_classes([permissions.AllowAny])
459
+def create_contribution(request, person_id):
460
+    """Simple endpoint to create a contribution with CSRF and throttling"""
461
+    # Apply throttling
462
+    throttle = AnonRateThrottle()
463
+    if not throttle.allow_request(request, None):
464
+        return Response(
465
+            {"error": "Rate limit exceeded. Please try again later."}, 
466
+            status=status.HTTP_429_TOO_MANY_REQUESTS
467
+        )
468
+    
469
+    try:
470
+        person = Person.objects.get(pk=person_id)
471
+    except Person.DoesNotExist:
472
+        return Response(
473
+            {"error": "Person not found"}, 
474
+            status=status.HTTP_404_NOT_FOUND
475
+        )
476
+    
477
+    serializer = ContributionCreateSerializer(data=request.data)
478
+    if not serializer.is_valid():
479
+        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
480
+    
481
+    # Validate image if provided
482
+    image = request.FILES.get('content_image')
483
+    if image:
484
+        if image.size > MAX_IMAGE_SIZE:
485
+            return Response(
486
+                {"error": f"Image size must be less than {MAX_IMAGE_SIZE // 1024 // 1024}MB"}, 
487
+                status=status.HTTP_400_BAD_REQUEST
488
+            )
489
+        
490
+        if image.content_type not in ALLOWED_IMAGE_TYPES:
491
+            return Response(
492
+                {"error": f"Image type must be one of: {', '.join(ALLOWED_IMAGE_TYPES)}"}, 
493
+                status=status.HTTP_400_BAD_REQUEST
494
+            )
495
+    
496
+    contribution = serializer.save(person=person)
497
+    
498
+    return Response(
499
+        ContributionSerializer(contribution).data,
500
+        status=status.HTTP_201_CREATED
501
+    )
502
+
503
+@api_view(['GET'])
504
+@ensure_csrf_cookie
505
+def get_csrf_token(request):
506
+    """Get CSRF token for frontend"""
507
+    return JsonResponse({
508
+        'csrfToken': get_token(request)
509
+    })
vmi_wardead_be/settings.pymodified
@@ -19,18 +19,35 @@ SECRET_KEY = config('SECRET_KEY')
1919
 # SECURITY WARNING: don't run with debug turned on in production!
2020
 DEBUG = config('DEBUG', default=False, cast=bool)
2121
 
22
-ALLOWED_HOSTS = ['localhost', '127.0.0.1', '.up.railway.app']  # Railway domains
23
-
24
-# CSRF Trusted Origins for Railway
25
-CSRF_TRUSTED_ORIGINS = [
26
-    'https://*.up.railway.app',
27
-    'http://localhost:3000',
28
-    'http://127.0.0.1:3000',
29
-]
22
+# Production-ready allowed hosts
23
+if DEBUG:
24
+    ALLOWED_HOSTS = ['localhost', '127.0.0.1', '.up.railway.app']
25
+else:
26
+    ALLOWED_HOSTS = [
27
+        '.up.railway.app',
28
+        'vmimemorial.com',
29
+        'www.vmimemorial.com',
30
+        config('ALLOWED_HOST', default=''),  # Allow custom domain via env var
31
+    ]
32
+    # Remove empty strings
33
+    ALLOWED_HOSTS = [host for host in ALLOWED_HOSTS if host]
34
+
35
+# CSRF Trusted Origins
36
+if DEBUG:
37
+    CSRF_TRUSTED_ORIGINS = [
38
+        'http://localhost:3000',
39
+        'http://127.0.0.1:3000',
40
+        'https://*.up.railway.app',
41
+    ]
42
+else:
43
+    CSRF_TRUSTED_ORIGINS = [
44
+        'https://vmimemorial.com',
45
+        'https://www.vmimemorial.com',
46
+        'https://*.up.railway.app',
47
+    ]
3048
 
3149
 
3250
 # Application definition
33
-
3451
 INSTALLED_APPS = [
3552
     'django.contrib.admin',
3653
     'django.contrib.auth',
@@ -45,8 +62,8 @@ INSTALLED_APPS = [
4562
 
4663
 MIDDLEWARE = [
4764
     'django.middleware.security.SecurityMiddleware',
48
-    'whitenoise.middleware.WhiteNoiseMiddleware',  # Add this for static files
49
-    'corsheaders.middleware.CorsMiddleware',  # Add this before CommonMiddleware
65
+    'whitenoise.middleware.WhiteNoiseMiddleware',
66
+    'corsheaders.middleware.CorsMiddleware',
5067
     'django.contrib.sessions.middleware.SessionMiddleware',
5168
     'django.middleware.common.CommonMiddleware',
5269
     'django.middleware.csrf.CsrfViewMiddleware',
@@ -77,16 +94,12 @@ WSGI_APPLICATION = 'vmi_wardead_be.wsgi.application'
7794
 
7895
 
7996
 # Database
80
-# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
81
-
8297
 DATABASES = {
8398
     'default': dj_database_url.parse(config('DATABASE_URL'))
8499
 }
85100
 
86101
 
87102
 # Password validation
88
-# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
89
-
90103
 AUTH_PASSWORD_VALIDATORS = [
91104
     {
92105
         'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
@@ -104,45 +117,80 @@ AUTH_PASSWORD_VALIDATORS = [
104117
 
105118
 
106119
 # Internationalization
107
-# https://docs.djangoproject.com/en/5.0/topics/i18n/
108
-
109120
 LANGUAGE_CODE = 'en-us'
110
-
111
-TIME_ZONE = 'America/New_York'  # Adjust for VMI's location
112
-
121
+TIME_ZONE = 'America/New_York'  # VMI's location
113122
 USE_I18N = True
114
-
115123
 USE_TZ = True
116124
 
117125
 
118
-# Static files (CSS, JavaScript, Images)
119
-# https://docs.djangoproject.com/en/5.0/howto/static-files/
120
-
126
+# Static files
121127
 STATIC_URL = 'static/'
122128
 STATIC_ROOT = BASE_DIR / 'staticfiles'
123129
 
124130
 # Default primary key field type
125
-# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
126
-
127131
 DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
128132
 
129
-# CORS settings for Next.js frontend
130
-CORS_ALLOWED_ORIGINS = [
131
-    "http://localhost:3000",
132
-    "http://127.0.0.1:3000",
133
-    "https://vmimemorial.com",
134
-    "https://www.vmimemorial.com",
133
+# CORS settings - Environment-dependent
134
+if DEBUG:
135
+    CORS_ALLOW_ALL_ORIGINS = False
136
+    CORS_ALLOWED_ORIGINS = [
137
+        "http://localhost:3000",
138
+        "http://127.0.0.1:3000",
139
+    ]
140
+else:
141
+    CORS_ALLOW_ALL_ORIGINS = False
142
+    CORS_ALLOWED_ORIGINS = [
143
+        "https://vmimemorial.com",
144
+        "https://www.vmimemorial.com",
145
+    ]
146
+
147
+CORS_ALLOW_CREDENTIALS = True
148
+CORS_ALLOWED_METHODS = [
149
+    'DELETE',
150
+    'GET',
151
+    'OPTIONS',
152
+    'PATCH',
153
+    'POST',
154
+    'PUT',
155
+]
156
+CORS_ALLOWED_HEADERS = [
157
+    'accept',
158
+    'accept-encoding',
159
+    'authorization',
160
+    'content-type',
161
+    'dnt',
162
+    'origin',
163
+    'user-agent',
164
+    'x-csrftoken',
165
+    'x-requested-with',
135166
 ]
136167
 
168
+
137169
 # REST Framework settings
138170
 REST_FRAMEWORK = {
139171
     'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
140172
     'PAGE_SIZE': 100,
141173
     'DEFAULT_PERMISSION_CLASSES': [
142174
         'rest_framework.permissions.IsAuthenticatedOrReadOnly',
143
-    ]
175
+    ],
176
+    'DEFAULT_AUTHENTICATION_CLASSES': [
177
+        'rest_framework.authentication.SessionAuthentication',
178
+    ],
179
+    'DEFAULT_THROTTLE_CLASSES': [
180
+        'rest_framework.throttling.AnonRateThrottle',
181
+        'rest_framework.throttling.UserRateThrottle'
182
+    ],
183
+    'DEFAULT_THROTTLE_RATES': {
184
+        'anon': '60/hour',    # Anonymous users: 60 requests per hour
185
+        'user': '1000/hour'   # Authenticated users: 1000 per hour
186
+    }
144187
 }
145188
 
189
+# Contribution-specific settings
190
+CONTRIBUTION_IMAGE_MAX_SIZE = 10 * 1024 * 1024  # 10MB
191
+CONTRIBUTION_ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
192
+CONTRIBUTION_IMAGE_MAX_DIMENSIONS = (4000, 4000)
193
+
146194
 # AWS S3 Configuration
147195
 AWS_ACCESS_KEY_ID = config('AWS_ACCESS_KEY_ID', default='')
148196
 AWS_SECRET_ACCESS_KEY = config('AWS_SECRET_ACCESS_KEY', default='')
@@ -156,7 +204,7 @@ AWS_S3_OBJECT_PARAMETERS = {
156204
 AWS_S3_FILE_OVERWRITE = False
157205
 AWS_S3_SIGNATURE_VERSION = 's3v4'
158206
 
159
-# Media files
207
+# Media files configuration
160208
 MEDIA_URL = '/media/'
161209
 MEDIA_ROOT = BASE_DIR / 'media'
162210
 
@@ -165,12 +213,66 @@ if not DEBUG and AWS_ACCESS_KEY_ID:
165213
     DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
166214
     MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/'
167215
 else:
168
-    # Use local file storage in development
169216
     DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
170217
 
171218
 # Security settings for production
172219
 if not DEBUG:
220
+    # Security headers and HTTPS
173221
     SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
174222
     SECURE_SSL_REDIRECT = True
223
+    SECURE_HSTS_SECONDS = 31536000  # 1 year
224
+    SECURE_HSTS_INCLUDE_SUBDOMAINS = True
225
+    SECURE_HSTS_PRELOAD = True
226
+    
227
+    # Cookie security
175228
     SESSION_COOKIE_SECURE = True
176
-    CSRF_COOKIE_SECURE = True
229
+    CSRF_COOKIE_SECURE = True
230
+    SESSION_COOKIE_HTTPONLY = True
231
+    CSRF_COOKIE_HTTPONLY = True
232
+    
233
+    # Content security
234
+    SECURE_CONTENT_TYPE_NOSNIFF = True
235
+    SECURE_BROWSER_XSS_FILTER = True
236
+    X_FRAME_OPTIONS = 'DENY'  # Prevent clickjacking (except for PDF viewer)
237
+    
238
+    # Additional security
239
+    SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
240
+
241
+# Logging configuration for production
242
+if not DEBUG:
243
+    LOGGING = {
244
+        'version': 1,
245
+        'disable_existing_loggers': False,
246
+        'handlers': {
247
+            'console': {
248
+                'class': 'logging.StreamHandler',
249
+            },
250
+        },
251
+        'root': {
252
+            'handlers': ['console'],
253
+            'level': 'INFO',
254
+        },
255
+        'loggers': {
256
+            'django': {
257
+                'handlers': ['console'],
258
+                'level': 'INFO',
259
+                'propagate': False,
260
+            },
261
+            'memorial': {
262
+                'handlers': ['console'],
263
+                'level': 'INFO',
264
+                'propagate': False,
265
+            },
266
+        },
267
+    }
268
+
269
+# Email settings (for future contribution notifications)
270
+EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' if DEBUG else 'django.core.mail.backends.smtp.EmailBackend'
271
+if not DEBUG:
272
+    EMAIL_HOST = config('EMAIL_HOST', default='')
273
+    EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int)
274
+    EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool)
275
+    EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='')
276
+    EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')
277
+    DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@vmimemorial.com')
278
+    ADMIN_EMAIL = config('ADMIN_EMAIL', default='admin@vmimemorial.com')