submission feature stable
- SHA
812ace579119d3923e78b2b76e7c1cd995840fa2- Parents
-
908ccc8 - Tree
9135ecf
812ace5
812ace579119d3923e78b2b76e7c1cd995840fa2908ccc8
9135ecf| Status | File | + | - |
|---|---|---|---|
| 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 | |||
| 3 | from django import forms | 3 | from django import forms |
| 4 | from django.core.files.storage import default_storage | 4 | from django.core.files.storage import default_storage |
| 5 | from django.conf import settings | 5 | 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 | ||
| 7 | import os | 10 | import os |
| 8 | 11 | ||
| 9 | 12 | ||
@@ -76,7 +79,7 @@ class ConflictAdmin(admin.ModelAdmin): | |||
| 76 | @admin.register(Person) | 79 | @admin.register(Person) |
| 77 | class PersonAdmin(admin.ModelAdmin): | 80 | class PersonAdmin(admin.ModelAdmin): |
| 78 | form = PersonAdminForm | 81 | 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'] |
| 80 | list_filter = ['conflict', 'class_year', 'rank', 'death_date_precision'] | 83 | list_filter = ['conflict', 'class_year', 'rank', 'death_date_precision'] |
| 81 | search_fields = ['first_name', 'last_name', 'unit'] | 84 | search_fields = ['first_name', 'last_name', 'unit'] |
| 82 | autocomplete_fields = ['conflict'] | 85 | autocomplete_fields = ['conflict'] |
@@ -121,4 +124,104 @@ class PersonAdmin(admin.ModelAdmin): | |||
| 121 | def has_description(self, obj): | 124 | def has_description(self, obj): |
| 122 | return bool(obj.death_description) | 125 | return bool(obj.death_description) |
| 123 | has_description.boolean = True | 126 | 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 @@ | |||
| 1 | from django.db import models | 1 | from django.db import models |
| 2 | from django.urls import reverse | 2 | from django.urls import reverse |
| 3 | +from django.utils import timezone | ||
| 3 | 4 | ||
| 4 | 5 | ||
| 5 | class Conflict(models.Model): | 6 | class Conflict(models.Model): |
@@ -128,4 +129,93 @@ class Person(models.Model): | |||
| 128 | return self.date_of_death.strftime('%B %d, %Y') | 129 | return self.date_of_death.strftime('%B %d, %Y') |
| 129 | 130 | ||
| 130 | def get_absolute_url(self): | 131 | 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 @@ | |||
| 1 | from rest_framework import serializers | 1 | from rest_framework import serializers |
| 2 | -from .models import Conflict, Person | 2 | +from .models import Conflict, Person, Contribution |
| 3 | 3 | ||
| 4 | 4 | ||
| 5 | class PersonListSerializer(serializers.ModelSerializer): | 5 | class PersonListSerializer(serializers.ModelSerializer): |
@@ -78,4 +78,107 @@ class ConflictDetailSerializer(serializers.ModelSerializer): | |||
| 78 | fields = [ | 78 | fields = [ |
| 79 | 'id', 'name', 'start_year', 'end_year', | 79 | 'id', 'name', 'start_year', 'end_year', |
| 80 | 'description', 'casualty_count', 'order', 'casualties' | 80 | '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 | ||
| 1 | from django.urls import path, include | 2 | from django.urls import path, include |
| 2 | from rest_framework.routers import DefaultRouter | 3 | 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 | +) | ||
| 4 | 11 | ||
| 12 | +# Main router | ||
| 5 | router = DefaultRouter() | 13 | router = DefaultRouter() |
| 6 | router.register('conflicts', ConflictViewSet) | 14 | router.register('conflicts', ConflictViewSet) |
| 7 | router.register('persons', PersonViewSet) | 15 | router.register('persons', PersonViewSet) |
| 8 | 16 | ||
| 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 | + | ||
| 9 | urlpatterns = [ | 21 | 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 | ||
| 11 | path('index/', memorial_index, name='memorial-index'), | 26 | path('index/', memorial_index, name='memorial-index'), |
| 12 | path('search-filters/', search_filters, name='search-filters'), | 27 | 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'), | ||
| 13 | path('test-s3/', test_s3_connection, name='test-s3'), | 35 | path('test-s3/', test_s3_connection, name='test-s3'), |
| 36 | + | ||
| 37 | + # Include routers | ||
| 38 | + path('', include(router.urls)), | ||
| 39 | + path('', include(persons_router.urls)), | ||
| 14 | ] | 40 | ] |
memorial/views.pymodified@@ -1,20 +1,38 @@ | |||
| 1 | -from rest_framework import viewsets, status | 1 | +from rest_framework import viewsets, status, permissions |
| 2 | -from rest_framework.decorators import action, api_view | 2 | +from rest_framework.decorators import action, api_view, permission_classes, authentication_classes, parser_classes |
| 3 | from rest_framework.response import Response | 3 | 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 | ||
| 5 | from django.db.models import Q | 10 | from django.db.models import Q |
| 11 | +from django.core.exceptions import ValidationError | ||
| 12 | +from django.core.files.uploadedfile import UploadedFile | ||
| 6 | from datetime import datetime | 13 | from datetime import datetime |
| 7 | import boto3 | 14 | import boto3 |
| 8 | from botocore.exceptions import ClientError | 15 | from botocore.exceptions import ClientError |
| 9 | from django.conf import settings | 16 | 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 | ||
| 11 | from .serializers import ( | 21 | from .serializers import ( |
| 12 | ConflictSerializer, ConflictDetailSerializer, | 22 | ConflictSerializer, ConflictDetailSerializer, |
| 13 | PersonListSerializer, PersonDetailSerializer, | 23 | PersonListSerializer, PersonDetailSerializer, |
| 14 | - PersonSearchSerializer | 24 | + PersonSearchSerializer, PersonDetailSerializerWithContributions, |
| 25 | + ContributionSerializer, ContributionCreateSerializer, | ||
| 26 | + ContributionPublicSerializer, ContributionReviewSerializer | ||
| 15 | ) | 27 | ) |
| 16 | 28 | ||
| 17 | 29 | ||
| 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 | + | ||
| 18 | class ConflictViewSet(viewsets.ReadOnlyModelViewSet): | 36 | class ConflictViewSet(viewsets.ReadOnlyModelViewSet): |
| 19 | """API endpoints for conflicts""" | 37 | """API endpoints for conflicts""" |
| 20 | queryset = Conflict.objects.all() | 38 | queryset = Conflict.objects.all() |
@@ -34,7 +52,7 @@ class PersonViewSet(viewsets.ReadOnlyModelViewSet): | |||
| 34 | return PersonListSerializer | 52 | return PersonListSerializer |
| 35 | elif self.action == 'search': | 53 | elif self.action == 'search': |
| 36 | return PersonSearchSerializer | 54 | return PersonSearchSerializer |
| 37 | - return PersonDetailSerializer | 55 | + return PersonDetailSerializerWithContributions |
| 38 | 56 | ||
| 39 | def get_queryset(self): | 57 | def get_queryset(self): |
| 40 | queryset = super().get_queryset() | 58 | queryset = super().get_queryset() |
@@ -42,20 +60,16 @@ class PersonViewSet(viewsets.ReadOnlyModelViewSet): | |||
| 42 | 60 | ||
| 43 | if conflict_id is not None: | 61 | if conflict_id is not None: |
| 44 | queryset = queryset.filter(conflict_id=conflict_id) | 62 | queryset = queryset.filter(conflict_id=conflict_id) |
| 45 | - # For conflict-specific queries, order by class year ascending (earliest first), then name | ||
| 46 | return queryset.extra( | 63 | return queryset.extra( |
| 47 | select={'class_year_null': 'class_year IS NULL'}, | 64 | select={'class_year_null': 'class_year IS NULL'}, |
| 48 | order_by=['class_year_null', 'class_year', 'last_name', 'first_name'] | 65 | order_by=['class_year_null', 'class_year', 'last_name', 'first_name'] |
| 49 | ) | 66 | ) |
| 50 | 67 | ||
| 51 | - # Allow ordering by different fields via query param | ||
| 52 | order_by = self.request.query_params.get('order_by', 'name') | 68 | order_by = self.request.query_params.get('order_by', 'name') |
| 53 | 69 | ||
| 54 | if order_by == 'class_year': | 70 | if order_by == 'class_year': |
| 55 | - # Order by class year ascending (earliest first), then name | ||
| 56 | return queryset.order_by('class_year', 'last_name', 'first_name') | 71 | return queryset.order_by('class_year', 'last_name', 'first_name') |
| 57 | else: | 72 | else: |
| 58 | - # Default ordering by name | ||
| 59 | return queryset.order_by('last_name', 'first_name') | 73 | return queryset.order_by('last_name', 'first_name') |
| 60 | 74 | ||
| 61 | @action(detail=False, methods=['get']) | 75 | @action(detail=False, methods=['get']) |
@@ -109,13 +123,11 @@ class PersonViewSet(viewsets.ReadOnlyModelViewSet): | |||
| 109 | except ValueError: | 123 | except ValueError: |
| 110 | pass | 124 | pass |
| 111 | 125 | ||
| 112 | - # Order results - by class year ascending (earliest first) then name | ||
| 113 | queryset = queryset.extra( | 126 | queryset = queryset.extra( |
| 114 | select={'class_year_null': 'class_year IS NULL'}, | 127 | select={'class_year_null': 'class_year IS NULL'}, |
| 115 | order_by=['class_year_null', 'class_year', 'last_name', 'first_name'] | 128 | order_by=['class_year_null', 'class_year', 'last_name', 'first_name'] |
| 116 | ) | 129 | ) |
| 117 | 130 | ||
| 118 | - # Paginate if needed (for now, return all for infinite scroll) | ||
| 119 | serializer = PersonSearchSerializer(queryset, many=True) | 131 | serializer = PersonSearchSerializer(queryset, many=True) |
| 120 | return Response({ | 132 | return Response({ |
| 121 | 'count': queryset.count(), | 133 | 'count': queryset.count(), |
@@ -146,7 +158,6 @@ class PersonViewSet(viewsets.ReadOnlyModelViewSet): | |||
| 146 | import os | 158 | import os |
| 147 | file_path = os.path.join(settings.MEDIA_ROOT, person.pdf_key) | 159 | file_path = os.path.join(settings.MEDIA_ROOT, person.pdf_key) |
| 148 | response = FileResponse(open(file_path, 'rb'), content_type='application/pdf') | 160 | response = FileResponse(open(file_path, 'rb'), content_type='application/pdf') |
| 149 | - # FIXED: Allow iframe embedding for PDF viewer | ||
| 150 | response['X-Frame-Options'] = 'SAMEORIGIN' | 161 | response['X-Frame-Options'] = 'SAMEORIGIN' |
| 151 | return response | 162 | return response |
| 152 | except FileNotFoundError: | 163 | except FileNotFoundError: |
@@ -164,7 +175,6 @@ class PersonViewSet(viewsets.ReadOnlyModelViewSet): | |||
| 164 | region_name=settings.AWS_S3_REGION_NAME | 175 | region_name=settings.AWS_S3_REGION_NAME |
| 165 | ) | 176 | ) |
| 166 | 177 | ||
| 167 | - # Generate presigned URL | ||
| 168 | presigned_url = s3_client.generate_presigned_url( | 178 | presigned_url = s3_client.generate_presigned_url( |
| 169 | 'get_object', | 179 | 'get_object', |
| 170 | Params={ | 180 | Params={ |
@@ -174,19 +184,155 @@ class PersonViewSet(viewsets.ReadOnlyModelViewSet): | |||
| 174 | ExpiresIn=3600 # URL expires in 1 hour | 184 | ExpiresIn=3600 # URL expires in 1 hour |
| 175 | ) | 185 | ) |
| 176 | 186 | ||
| 177 | - # FIXED: Return a redirect response instead of JSON | ||
| 178 | - # This allows the iframe to load the PDF directly from S3 | ||
| 179 | response = HttpResponseRedirect(presigned_url) | 187 | response = HttpResponseRedirect(presigned_url) |
| 180 | - # Allow iframe embedding for PDF viewer | ||
| 181 | response['X-Frame-Options'] = 'SAMEORIGIN' | 188 | response['X-Frame-Options'] = 'SAMEORIGIN' |
| 182 | return response | 189 | return response |
| 183 | 190 | ||
| 184 | except ClientError as e: | 191 | except ClientError as e: |
| 185 | - print(f"Error generating presigned URL: {e}") | ||
| 186 | return Response( | 192 | return Response( |
| 187 | {"error": "Failed to generate PDF URL"}, | 193 | {"error": "Failed to generate PDF URL"}, |
| 188 | status=status.HTTP_500_INTERNAL_SERVER_ERROR | 194 | status=status.HTTP_500_INTERNAL_SERVER_ERROR |
| 189 | ) | 195 | ) |
| 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 | + }) | ||
| 190 | 336 | ||
| 191 | 337 | ||
| 192 | @api_view(['GET']) | 338 | @api_view(['GET']) |
@@ -196,8 +342,6 @@ def memorial_index(request): | |||
| 196 | data = [] | 342 | data = [] |
| 197 | 343 | ||
| 198 | for conflict in conflicts: | 344 | for conflict in conflicts: |
| 199 | - # Order by class year ascending (earliest first), then by name | ||
| 200 | - # Use raw SQL ordering to put nulls last | ||
| 201 | casualties = Person.objects.filter(conflict=conflict).extra( | 345 | casualties = Person.objects.filter(conflict=conflict).extra( |
| 202 | select={'class_year_null': 'class_year IS NULL'}, | 346 | select={'class_year_null': 'class_year IS NULL'}, |
| 203 | order_by=['class_year_null', 'class_year', 'last_name', 'first_name'] | 347 | order_by=['class_year_null', 'class_year', 'last_name', 'first_name'] |
@@ -220,14 +364,55 @@ def search_filters(request): | |||
| 220 | 'class_years': list(class_years) | 364 | 'class_years': list(class_years) |
| 221 | }) | 365 | }) |
| 222 | 366 | ||
| 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 | + | ||
| 223 | @api_view(['GET']) | 408 | @api_view(['GET']) |
| 409 | +@permission_classes([permissions.IsAdminUser]) | ||
| 224 | def test_s3_connection(request): | 410 | def test_s3_connection(request): |
| 225 | - """Test S3 configuration in production""" | 411 | + """Test S3 configuration in production - Admin only""" |
| 226 | try: | 412 | try: |
| 227 | import boto3 | 413 | import boto3 |
| 228 | from django.conf import settings | 414 | from django.conf import settings |
| 229 | 415 | ||
| 230 | - # Check if credentials are set | ||
| 231 | if not settings.AWS_ACCESS_KEY_ID: | 416 | if not settings.AWS_ACCESS_KEY_ID: |
| 232 | return Response({ | 417 | return Response({ |
| 233 | "error": "AWS_ACCESS_KEY_ID not configured", | 418 | "error": "AWS_ACCESS_KEY_ID not configured", |
@@ -235,7 +420,6 @@ def test_s3_connection(request): | |||
| 235 | "bucket": settings.AWS_STORAGE_BUCKET_NAME | 420 | "bucket": settings.AWS_STORAGE_BUCKET_NAME |
| 236 | }) | 421 | }) |
| 237 | 422 | ||
| 238 | - # Try to connect to S3 | ||
| 239 | s3_client = boto3.client( | 423 | s3_client = boto3.client( |
| 240 | 's3', | 424 | 's3', |
| 241 | aws_access_key_id=settings.AWS_ACCESS_KEY_ID, | 425 | aws_access_key_id=settings.AWS_ACCESS_KEY_ID, |
@@ -243,7 +427,6 @@ def test_s3_connection(request): | |||
| 243 | region_name=settings.AWS_S3_REGION_NAME | 427 | region_name=settings.AWS_S3_REGION_NAME |
| 244 | ) | 428 | ) |
| 245 | 429 | ||
| 246 | - # List objects | ||
| 247 | response = s3_client.list_objects_v2( | 430 | response = s3_client.list_objects_v2( |
| 248 | Bucket=settings.AWS_STORAGE_BUCKET_NAME, | 431 | Bucket=settings.AWS_STORAGE_BUCKET_NAME, |
| 249 | MaxKeys=5 | 432 | MaxKeys=5 |
@@ -267,4 +450,60 @@ def test_s3_connection(request): | |||
| 267 | "error": str(e), | 450 | "error": str(e), |
| 268 | "type": type(e).__name__, | 451 | "type": type(e).__name__, |
| 269 | "debug": settings.DEBUG | 452 | "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') | |||
| 19 | # SECURITY WARNING: don't run with debug turned on in production! | 19 | # SECURITY WARNING: don't run with debug turned on in production! |
| 20 | DEBUG = config('DEBUG', default=False, cast=bool) | 20 | DEBUG = config('DEBUG', default=False, cast=bool) |
| 21 | 21 | ||
| 22 | -ALLOWED_HOSTS = ['localhost', '127.0.0.1', '.up.railway.app'] # Railway domains | 22 | +# Production-ready allowed hosts |
| 23 | - | 23 | +if DEBUG: |
| 24 | -# CSRF Trusted Origins for Railway | 24 | + ALLOWED_HOSTS = ['localhost', '127.0.0.1', '.up.railway.app'] |
| 25 | -CSRF_TRUSTED_ORIGINS = [ | 25 | +else: |
| 26 | - 'https://*.up.railway.app', | 26 | + ALLOWED_HOSTS = [ |
| 27 | - 'http://localhost:3000', | 27 | + '.up.railway.app', |
| 28 | - 'http://127.0.0.1:3000', | 28 | + 'vmimemorial.com', |
| 29 | -] | 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 | + ] | ||
| 30 | 48 | ||
| 31 | 49 | ||
| 32 | # Application definition | 50 | # Application definition |
| 33 | - | ||
| 34 | INSTALLED_APPS = [ | 51 | INSTALLED_APPS = [ |
| 35 | 'django.contrib.admin', | 52 | 'django.contrib.admin', |
| 36 | 'django.contrib.auth', | 53 | 'django.contrib.auth', |
@@ -45,8 +62,8 @@ INSTALLED_APPS = [ | |||
| 45 | 62 | ||
| 46 | MIDDLEWARE = [ | 63 | MIDDLEWARE = [ |
| 47 | 'django.middleware.security.SecurityMiddleware', | 64 | 'django.middleware.security.SecurityMiddleware', |
| 48 | - 'whitenoise.middleware.WhiteNoiseMiddleware', # Add this for static files | 65 | + 'whitenoise.middleware.WhiteNoiseMiddleware', |
| 49 | - 'corsheaders.middleware.CorsMiddleware', # Add this before CommonMiddleware | 66 | + 'corsheaders.middleware.CorsMiddleware', |
| 50 | 'django.contrib.sessions.middleware.SessionMiddleware', | 67 | 'django.contrib.sessions.middleware.SessionMiddleware', |
| 51 | 'django.middleware.common.CommonMiddleware', | 68 | 'django.middleware.common.CommonMiddleware', |
| 52 | 'django.middleware.csrf.CsrfViewMiddleware', | 69 | 'django.middleware.csrf.CsrfViewMiddleware', |
@@ -77,16 +94,12 @@ WSGI_APPLICATION = 'vmi_wardead_be.wsgi.application' | |||
| 77 | 94 | ||
| 78 | 95 | ||
| 79 | # Database | 96 | # Database |
| 80 | -# https://docs.djangoproject.com/en/5.0/ref/settings/#databases | ||
| 81 | - | ||
| 82 | DATABASES = { | 97 | DATABASES = { |
| 83 | 'default': dj_database_url.parse(config('DATABASE_URL')) | 98 | 'default': dj_database_url.parse(config('DATABASE_URL')) |
| 84 | } | 99 | } |
| 85 | 100 | ||
| 86 | 101 | ||
| 87 | # Password validation | 102 | # Password validation |
| 88 | -# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators | ||
| 89 | - | ||
| 90 | AUTH_PASSWORD_VALIDATORS = [ | 103 | AUTH_PASSWORD_VALIDATORS = [ |
| 91 | { | 104 | { |
| 92 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', | 105 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', |
@@ -104,45 +117,80 @@ AUTH_PASSWORD_VALIDATORS = [ | |||
| 104 | 117 | ||
| 105 | 118 | ||
| 106 | # Internationalization | 119 | # Internationalization |
| 107 | -# https://docs.djangoproject.com/en/5.0/topics/i18n/ | ||
| 108 | - | ||
| 109 | LANGUAGE_CODE = 'en-us' | 120 | LANGUAGE_CODE = 'en-us' |
| 110 | - | 121 | +TIME_ZONE = 'America/New_York' # VMI's location |
| 111 | -TIME_ZONE = 'America/New_York' # Adjust for VMI's location | ||
| 112 | - | ||
| 113 | USE_I18N = True | 122 | USE_I18N = True |
| 114 | - | ||
| 115 | USE_TZ = True | 123 | USE_TZ = True |
| 116 | 124 | ||
| 117 | 125 | ||
| 118 | -# Static files (CSS, JavaScript, Images) | 126 | +# Static files |
| 119 | -# https://docs.djangoproject.com/en/5.0/howto/static-files/ | ||
| 120 | - | ||
| 121 | STATIC_URL = 'static/' | 127 | STATIC_URL = 'static/' |
| 122 | STATIC_ROOT = BASE_DIR / 'staticfiles' | 128 | STATIC_ROOT = BASE_DIR / 'staticfiles' |
| 123 | 129 | ||
| 124 | # Default primary key field type | 130 | # Default primary key field type |
| 125 | -# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field | ||
| 126 | - | ||
| 127 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' | 131 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' |
| 128 | 132 | ||
| 129 | -# CORS settings for Next.js frontend | 133 | +# CORS settings - Environment-dependent |
| 130 | -CORS_ALLOWED_ORIGINS = [ | 134 | +if DEBUG: |
| 131 | - "http://localhost:3000", | 135 | + CORS_ALLOW_ALL_ORIGINS = False |
| 132 | - "http://127.0.0.1:3000", | 136 | + CORS_ALLOWED_ORIGINS = [ |
| 133 | - "https://vmimemorial.com", | 137 | + "http://localhost:3000", |
| 134 | - "https://www.vmimemorial.com", | 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', | ||
| 135 | ] | 166 | ] |
| 136 | 167 | ||
| 168 | + | ||
| 137 | # REST Framework settings | 169 | # REST Framework settings |
| 138 | REST_FRAMEWORK = { | 170 | REST_FRAMEWORK = { |
| 139 | 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', | 171 | 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', |
| 140 | 'PAGE_SIZE': 100, | 172 | 'PAGE_SIZE': 100, |
| 141 | 'DEFAULT_PERMISSION_CLASSES': [ | 173 | 'DEFAULT_PERMISSION_CLASSES': [ |
| 142 | 'rest_framework.permissions.IsAuthenticatedOrReadOnly', | 174 | '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 | + } | ||
| 144 | } | 187 | } |
| 145 | 188 | ||
| 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 | + | ||
| 146 | # AWS S3 Configuration | 194 | # AWS S3 Configuration |
| 147 | AWS_ACCESS_KEY_ID = config('AWS_ACCESS_KEY_ID', default='') | 195 | AWS_ACCESS_KEY_ID = config('AWS_ACCESS_KEY_ID', default='') |
| 148 | AWS_SECRET_ACCESS_KEY = config('AWS_SECRET_ACCESS_KEY', default='') | 196 | AWS_SECRET_ACCESS_KEY = config('AWS_SECRET_ACCESS_KEY', default='') |
@@ -156,7 +204,7 @@ AWS_S3_OBJECT_PARAMETERS = { | |||
| 156 | AWS_S3_FILE_OVERWRITE = False | 204 | AWS_S3_FILE_OVERWRITE = False |
| 157 | AWS_S3_SIGNATURE_VERSION = 's3v4' | 205 | AWS_S3_SIGNATURE_VERSION = 's3v4' |
| 158 | 206 | ||
| 159 | -# Media files | 207 | +# Media files configuration |
| 160 | MEDIA_URL = '/media/' | 208 | MEDIA_URL = '/media/' |
| 161 | MEDIA_ROOT = BASE_DIR / 'media' | 209 | MEDIA_ROOT = BASE_DIR / 'media' |
| 162 | 210 | ||
@@ -165,12 +213,66 @@ if not DEBUG and AWS_ACCESS_KEY_ID: | |||
| 165 | DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' | 213 | DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' |
| 166 | MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/' | 214 | MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/' |
| 167 | else: | 215 | else: |
| 168 | - # Use local file storage in development | ||
| 169 | DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' | 216 | DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' |
| 170 | 217 | ||
| 171 | # Security settings for production | 218 | # Security settings for production |
| 172 | if not DEBUG: | 219 | if not DEBUG: |
| 220 | + # Security headers and HTTPS | ||
| 173 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') | 221 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') |
| 174 | SECURE_SSL_REDIRECT = True | 222 | 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 | ||
| 175 | SESSION_COOKIE_SECURE = True | 228 | 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') | ||