search, index, models
- SHA
acc213cb13722fdf51683fd6b8965d1dcaf54ae2- Parents
-
01e7229 - Tree
f321baa
acc213c
acc213cb13722fdf51683fd6b8965d1dcaf54ae201e7229
f321baa| Status | File | + | - |
|---|---|---|---|
| M |
memorial/admin.py
|
5 | 2 |
| A |
memorial/migrations/0002_person_class_year.py
|
20 | 0 |
| M |
memorial/models.py
|
17 | 0 |
| M |
memorial/serializers.py
|
20 | 3 |
| M |
memorial/urls.py
|
3 | 1 |
| M |
memorial/views.py
|
98 | 4 |
memorial/admin.pymodified@@ -12,8 +12,8 @@ class ConflictAdmin(admin.ModelAdmin): | ||
| 12 | 12 | |
| 13 | 13 | @admin.register(Person) |
| 14 | 14 | class PersonAdmin(admin.ModelAdmin): |
| 15 | - list_display = ['display_name', 'conflict', 'rank', 'date_of_death'] | |
| 16 | - list_filter = ['conflict', 'rank'] | |
| 15 | + list_display = ['display_name', 'class_year', 'conflict', 'rank', 'date_of_death'] | |
| 16 | + list_filter = ['conflict', 'class_year', 'rank'] | |
| 17 | 17 | search_fields = ['first_name', 'last_name', 'unit'] |
| 18 | 18 | autocomplete_fields = ['conflict'] |
| 19 | 19 | |
@@ -21,6 +21,9 @@ class PersonAdmin(admin.ModelAdmin): | ||
| 21 | 21 | ('Name', { |
| 22 | 22 | 'fields': ('first_name', 'middle_name', 'last_name', 'suffix') |
| 23 | 23 | }), |
| 24 | + ('VMI Information', { | |
| 25 | + 'fields': ('class_year',) | |
| 26 | + }), | |
| 24 | 27 | ('Military Information', { |
| 25 | 28 | 'fields': ('conflict', 'rank', 'unit', 'date_of_death') |
| 26 | 29 | }), |
memorial/migrations/0002_person_class_year.pyadded@@ -0,0 +1,20 @@ | ||
| 1 | +# Generated by Django 5.0.6 on 2025-07-13 17:11 | |
| 2 | + | |
| 3 | +from django.db import migrations, models | |
| 4 | + | |
| 5 | + | |
| 6 | +class Migration(migrations.Migration): | |
| 7 | + | |
| 8 | + dependencies = [ | |
| 9 | + ("memorial", "0001_initial"), | |
| 10 | + ] | |
| 11 | + | |
| 12 | + operations = [ | |
| 13 | + migrations.AddField( | |
| 14 | + model_name="person", | |
| 15 | + name="class_year", | |
| 16 | + field=models.IntegerField( | |
| 17 | + blank=True, help_text="VMI graduation year (e.g., 1965)", null=True | |
| 18 | + ), | |
| 19 | + ), | |
| 20 | + ] | |
memorial/models.pymodified@@ -31,6 +31,13 @@ class Person(models.Model): | ||
| 31 | 31 | middle_name = models.CharField(max_length=100, blank=True) |
| 32 | 32 | suffix = models.CharField(max_length=20, blank=True) # Jr., III, etc. |
| 33 | 33 | |
| 34 | + # VMI info | |
| 35 | + class_year = models.IntegerField( | |
| 36 | + null=True, | |
| 37 | + blank=True, | |
| 38 | + help_text="VMI graduation year (e.g., 1965)" | |
| 39 | + ) | |
| 40 | + | |
| 34 | 41 | # Military info |
| 35 | 42 | conflict = models.ForeignKey( |
| 36 | 43 | Conflict, |
@@ -62,6 +69,8 @@ class Person(models.Model): | ||
| 62 | 69 | full_name = f"{self.first_name} {self.middle_name} {self.last_name}" |
| 63 | 70 | if self.suffix: |
| 64 | 71 | full_name = f"{full_name} {self.suffix}" |
| 72 | + if self.class_year: | |
| 73 | + full_name = f"{full_name} '{str(self.class_year)[2:]}" # e.g., John Doe '65 | |
| 65 | 74 | return full_name |
| 66 | 75 | |
| 67 | 76 | @property |
@@ -78,5 +87,13 @@ class Person(models.Model): | ||
| 78 | 87 | name_parts.append(self.suffix) |
| 79 | 88 | return ' '.join(name_parts) |
| 80 | 89 | |
| 90 | + @property | |
| 91 | + def full_display_name(self): | |
| 92 | + """Display name with class year""" | |
| 93 | + name = self.display_name | |
| 94 | + if self.class_year: | |
| 95 | + name = f"{name} '{str(self.class_year)[2:]}" | |
| 96 | + return name | |
| 97 | + | |
| 81 | 98 | def get_absolute_url(self): |
| 82 | 99 | return reverse('person-detail', kwargs={'pk': self.pk}) |
memorial/serializers.pymodified@@ -5,15 +5,17 @@ from .models import Conflict, Person | ||
| 5 | 5 | class PersonListSerializer(serializers.ModelSerializer): |
| 6 | 6 | """Lightweight serializer for listing people""" |
| 7 | 7 | display_name = serializers.ReadOnlyField() |
| 8 | + full_display_name = serializers.ReadOnlyField() | |
| 8 | 9 | |
| 9 | 10 | class Meta: |
| 10 | 11 | model = Person |
| 11 | - fields = ['id', 'display_name', 'rank', 'unit'] | |
| 12 | + fields = ['id', 'display_name', 'full_display_name', 'rank', 'unit', 'class_year'] | |
| 12 | 13 | |
| 13 | 14 | |
| 14 | 15 | class PersonDetailSerializer(serializers.ModelSerializer): |
| 15 | 16 | """Full details for individual person""" |
| 16 | 17 | display_name = serializers.ReadOnlyField() |
| 18 | + full_display_name = serializers.ReadOnlyField() | |
| 17 | 19 | conflict_name = serializers.CharField(source='conflict.name', read_only=True) |
| 18 | 20 | pdf_url = serializers.SerializerMethodField() |
| 19 | 21 | |
@@ -21,8 +23,8 @@ class PersonDetailSerializer(serializers.ModelSerializer): | ||
| 21 | 23 | model = Person |
| 22 | 24 | fields = [ |
| 23 | 25 | 'id', 'first_name', 'middle_name', 'last_name', 'suffix', |
| 24 | - 'display_name', 'rank', 'unit', 'date_of_death', | |
| 25 | - 'conflict', 'conflict_name', 'pdf_key', 'pdf_url' | |
| 26 | + 'display_name', 'full_display_name', 'class_year', 'rank', 'unit', | |
| 27 | + 'date_of_death', 'conflict', 'conflict_name', 'pdf_key', 'pdf_url' | |
| 26 | 28 | ] |
| 27 | 29 | |
| 28 | 30 | def get_pdf_url(self, obj): |
@@ -33,6 +35,21 @@ class PersonDetailSerializer(serializers.ModelSerializer): | ||
| 33 | 35 | return None |
| 34 | 36 | |
| 35 | 37 | |
| 38 | +class PersonSearchSerializer(serializers.ModelSerializer): | |
| 39 | + """Serializer for search results""" | |
| 40 | + display_name = serializers.ReadOnlyField() | |
| 41 | + full_display_name = serializers.ReadOnlyField() | |
| 42 | + conflict_name = serializers.CharField(source='conflict.name', read_only=True) | |
| 43 | + conflict_id = serializers.IntegerField(source='conflict.id', read_only=True) | |
| 44 | + | |
| 45 | + class Meta: | |
| 46 | + model = Person | |
| 47 | + fields = [ | |
| 48 | + 'id', 'display_name', 'full_display_name', 'class_year', | |
| 49 | + 'rank', 'unit', 'date_of_death', 'conflict_name', 'conflict_id' | |
| 50 | + ] | |
| 51 | + | |
| 52 | + | |
| 36 | 53 | class ConflictSerializer(serializers.ModelSerializer): |
| 37 | 54 | """Conflict with casualty count""" |
| 38 | 55 | casualty_count = serializers.ReadOnlyField() |
memorial/urls.pymodified@@ -1,6 +1,6 @@ | ||
| 1 | 1 | from django.urls import path, include |
| 2 | 2 | from rest_framework.routers import DefaultRouter |
| 3 | -from .views import ConflictViewSet, PersonViewSet | |
| 3 | +from .views import ConflictViewSet, PersonViewSet, memorial_index, search_filters | |
| 4 | 4 | |
| 5 | 5 | router = DefaultRouter() |
| 6 | 6 | router.register('conflicts', ConflictViewSet) |
@@ -8,4 +8,6 @@ router.register('persons', PersonViewSet) | ||
| 8 | 8 | |
| 9 | 9 | urlpatterns = [ |
| 10 | 10 | path('', include(router.urls)), |
| 11 | + path('index/', memorial_index, name='memorial-index'), | |
| 12 | + path('search-filters/', search_filters, name='search-filters'), | |
| 11 | 13 | ] |
memorial/views.pymodified@@ -1,11 +1,14 @@ | ||
| 1 | 1 | from rest_framework import viewsets, status |
| 2 | -from rest_framework.decorators import action | |
| 2 | +from rest_framework.decorators import action, api_view | |
| 3 | 3 | from rest_framework.response import Response |
| 4 | 4 | from django.http import HttpResponse |
| 5 | +from django.db.models import Q | |
| 6 | +from datetime import datetime | |
| 5 | 7 | from .models import Conflict, Person |
| 6 | 8 | from .serializers import ( |
| 7 | 9 | ConflictSerializer, ConflictDetailSerializer, |
| 8 | - PersonListSerializer, PersonDetailSerializer | |
| 10 | + PersonListSerializer, PersonDetailSerializer, | |
| 11 | + PersonSearchSerializer | |
| 9 | 12 | ) |
| 10 | 13 | |
| 11 | 14 | |
@@ -26,6 +29,8 @@ class PersonViewSet(viewsets.ReadOnlyModelViewSet): | ||
| 26 | 29 | def get_serializer_class(self): |
| 27 | 30 | if self.action == 'list': |
| 28 | 31 | return PersonListSerializer |
| 32 | + elif self.action == 'search': | |
| 33 | + return PersonSearchSerializer | |
| 29 | 34 | return PersonDetailSerializer |
| 30 | 35 | |
| 31 | 36 | def get_queryset(self): |
@@ -33,7 +38,69 @@ class PersonViewSet(viewsets.ReadOnlyModelViewSet): | ||
| 33 | 38 | conflict_id = self.request.query_params.get('conflict', None) |
| 34 | 39 | if conflict_id is not None: |
| 35 | 40 | queryset = queryset.filter(conflict_id=conflict_id) |
| 36 | - return queryset | |
| 41 | + # Always order by last name, then first name for consistency | |
| 42 | + return queryset.order_by('last_name', 'first_name') | |
| 43 | + | |
| 44 | + @action(detail=False, methods=['get']) | |
| 45 | + def search(self, request): | |
| 46 | + """Search and filter people""" | |
| 47 | + queryset = Person.objects.all() | |
| 48 | + | |
| 49 | + # Name search (across all name fields) | |
| 50 | + search_term = request.query_params.get('q', '') | |
| 51 | + if search_term: | |
| 52 | + queryset = queryset.filter( | |
| 53 | + Q(first_name__icontains=search_term) | | |
| 54 | + Q(middle_name__icontains=search_term) | | |
| 55 | + Q(last_name__icontains=search_term) | | |
| 56 | + Q(suffix__icontains=search_term) | |
| 57 | + ) | |
| 58 | + | |
| 59 | + # Class year filter (can be comma-separated) | |
| 60 | + class_years = request.query_params.get('class_year', '') | |
| 61 | + if class_years: | |
| 62 | + years = [int(y.strip()) for y in class_years.split(',') if y.strip().isdigit()] | |
| 63 | + if years: | |
| 64 | + queryset = queryset.filter(class_year__in=years) | |
| 65 | + | |
| 66 | + # Conflict filter (can be comma-separated) | |
| 67 | + conflict_ids = request.query_params.get('conflict', '') | |
| 68 | + if conflict_ids: | |
| 69 | + ids = [int(id.strip()) for id in conflict_ids.split(',') if id.strip().isdigit()] | |
| 70 | + if ids: | |
| 71 | + queryset = queryset.filter(conflict_id__in=ids) | |
| 72 | + | |
| 73 | + # Date range filter | |
| 74 | + date_from = request.query_params.get('date_from', '') | |
| 75 | + date_to = request.query_params.get('date_to', '') | |
| 76 | + no_date = request.query_params.get('no_date', '').lower() == 'true' | |
| 77 | + | |
| 78 | + if no_date: | |
| 79 | + queryset = queryset.filter(date_of_death__isnull=True) | |
| 80 | + else: | |
| 81 | + if date_from: | |
| 82 | + try: | |
| 83 | + date_from_parsed = datetime.strptime(date_from, '%Y-%m-%d').date() | |
| 84 | + queryset = queryset.filter(date_of_death__gte=date_from_parsed) | |
| 85 | + except ValueError: | |
| 86 | + pass | |
| 87 | + | |
| 88 | + if date_to: | |
| 89 | + try: | |
| 90 | + date_to_parsed = datetime.strptime(date_to, '%Y-%m-%d').date() | |
| 91 | + queryset = queryset.filter(date_of_death__lte=date_to_parsed) | |
| 92 | + except ValueError: | |
| 93 | + pass | |
| 94 | + | |
| 95 | + # Order results | |
| 96 | + queryset = queryset.order_by('last_name', 'first_name') | |
| 97 | + | |
| 98 | + # Paginate if needed (for now, return all for infinite scroll) | |
| 99 | + serializer = PersonSearchSerializer(queryset, many=True) | |
| 100 | + return Response({ | |
| 101 | + 'count': queryset.count(), | |
| 102 | + 'results': serializer.data | |
| 103 | + }) | |
| 37 | 104 | |
| 38 | 105 | @action(detail=True, methods=['get']) |
| 39 | 106 | def pdf(self, request, pk=None): |
@@ -53,4 +120,31 @@ class PersonViewSet(viewsets.ReadOnlyModelViewSet): | ||
| 53 | 120 | f"S3 Key: {person.pdf_key}\n" |
| 54 | 121 | f"This will redirect to S3 presigned URL in production", |
| 55 | 122 | content_type="text/plain" |
| 56 | - ) | |
| 123 | + ) | |
| 124 | + | |
| 125 | + | |
| 126 | +@api_view(['GET']) | |
| 127 | +def memorial_index(request): | |
| 128 | + """Get all conflicts with their casualties for the memorial index""" | |
| 129 | + conflicts = Conflict.objects.all() | |
| 130 | + data = [] | |
| 131 | + | |
| 132 | + for conflict in conflicts: | |
| 133 | + casualties = Person.objects.filter(conflict=conflict).order_by('last_name', 'first_name') | |
| 134 | + conflict_data = ConflictSerializer(conflict).data | |
| 135 | + conflict_data['casualties'] = PersonListSerializer(casualties, many=True).data | |
| 136 | + data.append(conflict_data) | |
| 137 | + | |
| 138 | + return Response(data) | |
| 139 | + | |
| 140 | + | |
| 141 | +@api_view(['GET']) | |
| 142 | +def search_filters(request): | |
| 143 | + """Get available filter options for the search page""" | |
| 144 | + conflicts = Conflict.objects.all().values('id', 'name', 'start_year', 'end_year') | |
| 145 | + class_years = Person.objects.exclude(class_year__isnull=True).values_list('class_year', flat=True).distinct().order_by('class_year') | |
| 146 | + | |
| 147 | + return Response({ | |
| 148 | + 'conflicts': list(conflicts), | |
| 149 | + 'class_years': list(class_years) | |
| 150 | + }) | |