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