diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index bcabe2c..ac91214 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index d062e20..92d31a3 100644 Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index fcae47b..7fdfe9b 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 57229ea..a732775 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 51419e3..d9e576b 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 40b43cc..54572ac 100644 --- a/core/admin.py +++ b/core/admin.py @@ -35,6 +35,7 @@ VOTER_MAPPABLE_FIELDS = [ ('zip_code', 'Zip Code'), ('county', 'County'), ('phone', 'Phone'), + ('phone_type', 'Phone Type'), ('email', 'Email'), ('district', 'District'), ('precinct', 'Precinct'), @@ -203,7 +204,7 @@ class VolunteerEventInline(admin.TabularInline): @admin.register(Voter) class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): list_display = ('first_name', 'last_name', 'nickname', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state', 'prior_state') - list_filter = ('tenant', 'candidate_support', 'is_targeted', 'yard_sign', 'district', 'city', 'state', 'prior_state') + list_filter = ('tenant', 'candidate_support', 'is_targeted', 'phone_type', 'yard_sign', 'district', 'city', 'state', 'prior_state') search_fields = ('first_name', 'last_name', 'nickname', 'voter_id', 'address', 'city', 'state', 'prior_state', 'zip_code', 'county') inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline] readonly_fields = ('address',) @@ -317,12 +318,34 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin): if 'window_sticker' in voter_data: if voter_data['window_sticker'] not in dict(Voter.WINDOW_STICKER_CHOICES): voter_data['window_sticker'] = 'none' + if 'phone_type' in voter_data: + pt_val = str(voter_data['phone_type']).lower() + pt_choices = dict(Voter.PHONE_TYPE_CHOICES) + if pt_val not in pt_choices: + # Try to match by display name + found = False + for k, v in pt_choices.items(): + if v.lower() == pt_val: + voter_data['phone_type'] = k + found = True + break + if not found: + voter_data['phone_type'] = 'cell' + else: + voter_data['phone_type'] = pt_val - Voter.objects.update_or_create( + voter, created = Voter.objects.get_or_create( tenant=tenant, voter_id=voter_id, - defaults=voter_data ) + for key, value in voter_data.items(): + setattr(voter, key, value) + + # Flag that coordinates were provided in the import to avoid geocoding + if "latitude" in voter_data and "longitude" in voter_data: + voter._coords_provided_in_import = True + + voter.save() count += 1 except Exception as e: logger.error(f"Error importing: {e}") diff --git a/core/forms.py b/core/forms.py index d9ca81b..a281af4 100644 --- a/core/forms.py +++ b/core/forms.py @@ -7,7 +7,7 @@ class VoterForm(forms.ModelForm): fields = [ 'first_name', 'last_name', 'nickname', 'birthdate', 'address_street', 'city', 'state', 'prior_state', 'zip_code', 'county', 'latitude', 'longitude', - 'phone', 'email', 'voter_id', 'district', 'precinct', + 'phone', 'phone_type', 'email', 'voter_id', 'district', 'precinct', 'registration_date', 'is_targeted', 'candidate_support', 'yard_sign', 'window_sticker' ] widgets = { @@ -30,6 +30,55 @@ class VoterForm(forms.ModelForm): self.fields['candidate_support'].widget.attrs.update({'class': 'form-select'}) self.fields['yard_sign'].widget.attrs.update({'class': 'form-select'}) self.fields['window_sticker'].widget.attrs.update({'class': 'form-select'}) + self.fields['phone_type'].widget.attrs.update({'class': 'form-select'}) + +class AdvancedVoterSearchForm(forms.Form): + MONTH_CHOICES = [ + ('', 'Any Month'), + (1, 'January'), (2, 'February'), (3, 'March'), (4, 'April'), + (5, 'May'), (6, 'June'), (7, 'July'), (8, 'August'), + (9, 'September'), (10, 'October'), (11, 'November'), (12, 'December') + ] + + first_name = forms.CharField(required=False) + last_name = forms.CharField(required=False) + voter_id = forms.CharField(required=False, label="Voter ID") + birth_month = forms.ChoiceField(choices=MONTH_CHOICES, required=False, label="Birth Month") + city = forms.CharField(required=False) + zip_code = forms.CharField(required=False) + district = forms.CharField(required=False) + precinct = forms.CharField(required=False) + phone_type = forms.ChoiceField( + choices=[('', 'Any')] + Voter.PHONE_TYPE_CHOICES, + required=False + ) + is_targeted = forms.BooleanField(required=False, label="Targeted Only") + candidate_support = forms.ChoiceField( + choices=[('', 'Any')] + Voter.SUPPORT_CHOICES, + required=False + ) + yard_sign = forms.ChoiceField( + choices=[('', 'Any')] + Voter.YARD_SIGN_CHOICES, + required=False + ) + window_sticker = forms.ChoiceField( + choices=[('', 'Any')] + Voter.WINDOW_STICKER_CHOICES, + required=False + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + if isinstance(field.widget, forms.CheckboxInput): + field.widget.attrs.update({'class': 'form-check-input'}) + else: + field.widget.attrs.update({'class': 'form-control'}) + + self.fields['birth_month'].widget.attrs.update({'class': 'form-select'}) + self.fields['candidate_support'].widget.attrs.update({'class': 'form-select'}) + self.fields['yard_sign'].widget.attrs.update({'class': 'form-select'}) + self.fields['window_sticker'].widget.attrs.update({'class': 'form-select'}) + self.fields['phone_type'].widget.attrs.update({'class': 'form-select'}) class InteractionForm(forms.ModelForm): class Meta: @@ -173,4 +222,4 @@ class VolunteerImportForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'}) - self.fields['file'].widget.attrs.update({'class': 'form-control'}) + self.fields['file'].widget.attrs.update({'class': 'form-control'}) \ No newline at end of file diff --git a/core/migrations/0021_voter_phone_type.py b/core/migrations/0021_voter_phone_type.py new file mode 100644 index 0000000..81f3f7a --- /dev/null +++ b/core/migrations/0021_voter_phone_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-01-26 16:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0020_remove_volunteer_name'), + ] + + operations = [ + migrations.AddField( + model_name='voter', + name='phone_type', + field=models.CharField(choices=[('home', 'Home Phone'), ('cell', 'Cell Phone'), ('work', 'Work Phone')], default='cell', max_length=10), + ), + ] diff --git a/core/migrations/__pycache__/0021_voter_phone_type.cpython-311.pyc b/core/migrations/__pycache__/0021_voter_phone_type.cpython-311.pyc new file mode 100644 index 0000000..af9f7b9 Binary files /dev/null and b/core/migrations/__pycache__/0021_voter_phone_type.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 9198711..e847349 100644 --- a/core/models.py +++ b/core/models.py @@ -126,6 +126,11 @@ class Voter(models.Model): ('wants', 'Wants Sticker'), ('has', 'Has Sticker'), ] + PHONE_TYPE_CHOICES = [ + ('home', 'Home Phone'), + ('cell', 'Cell Phone'), + ('work', 'Work Phone'), + ] tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='voters') voter_id = models.CharField(max_length=50, blank=True) @@ -143,6 +148,7 @@ class Voter(models.Model): latitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True) longitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True) phone = models.CharField(max_length=20, blank=True) + phone_type = models.CharField(max_length=10, choices=PHONE_TYPE_CHOICES, default='cell') email = models.EmailField(blank=True) district = models.CharField(max_length=100, blank=True) precinct = models.CharField(max_length=100, blank=True) @@ -239,9 +245,12 @@ class Voter(models.Model): self.state != orig.state or self.zip_code != orig.zip_code) - # Detect if coordinates were changed in this transaction (e.g., from a form) coords_provided = (self.latitude != orig.latitude or self.longitude != orig.longitude) + # If specifically provided in import, treat as provided even if same as DB + if getattr(self, "_coords_provided_in_import", False): + coords_provided = True + # Auto-geocode if address changed AND coordinates were NOT manually updated if address_changed and not coords_provided: should_geocode = True diff --git a/core/templates/core/index.html b/core/templates/core/index.html index cde8d7b..8000f67 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -30,7 +30,7 @@ -
+
@@ -42,6 +42,11 @@
+
diff --git a/core/templates/core/voter_advanced_search.html b/core/templates/core/voter_advanced_search.html new file mode 100644 index 0000000..731ed40 --- /dev/null +++ b/core/templates/core/voter_advanced_search.html @@ -0,0 +1,197 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Advanced Voter Search

+ Back to Registry +
+ +
+
+
+
+ + {{ form.first_name }} +
+
+ + {{ form.last_name }} +
+
+ + {{ form.voter_id }} +
+
+ + {{ form.birth_month }} +
+
+ + {{ form.city }} +
+
+ + {{ form.zip_code }} +
+
+ + {{ form.district }} +
+
+ + {{ form.precinct }} +
+
+ + {{ form.phone_type }} +
+
+ + {{ form.candidate_support }} +
+
+ + {{ form.yard_sign }} +
+
+ + {{ form.window_sticker }} +
+
+
+ {{ form.is_targeted }} + +
+
+
+ Clear Filters + +
+
+
+
+ +
+ {% csrf_token %} + + {% for key, value in request.GET.items %} + {% if key != 'csrfmiddlewaretoken' %} + + {% endif %} + {% endfor %} + +
+
+
Search Results ({{ voters.count }})
+
+ +
+
+ +
+
+
+ + + + + + + + + + + + + {% for voter in voters %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
+ + NameDistrictPhoneTarget VoterSupporter
+ + + + {{ voter.first_name }} {{ voter.last_name }} + +
{{ voter.address|default:"No address provided" }}
+
{{ voter.district|default:"-" }} + {{ voter.phone|default:"-" }} + {% if voter.phone %} +
{{ voter.get_phone_type_display }}
+ {% endif %} +
+ {% if voter.is_targeted %} + Yes + {% else %} + No + {% endif %} + + {% if voter.candidate_support == 'supporting' %} + Supporting + {% elif voter.candidate_support == 'not_supporting' %} + Not Supporting + {% else %} + Unknown + {% endif %} +
+

No voters found matching your search criteria.

+
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/voter_detail.html b/core/templates/core/voter_detail.html index 0e1f86f..c4d3b35 100644 --- a/core/templates/core/voter_detail.html +++ b/core/templates/core/voter_detail.html @@ -88,6 +88,9 @@
  • {{ voter.phone|default:"N/A" }} + {% if voter.phone %} + {{ voter.get_phone_type_display }} + {% endif %}
  • @@ -436,6 +439,10 @@ {{ voter_form.phone }}
  • + + {{ voter_form.phone_type }} +
    +
    {{ voter_form.email }}
    diff --git a/core/urls.py b/core/urls.py index a1d2ea2..0c1423e 100644 --- a/core/urls.py +++ b/core/urls.py @@ -5,6 +5,8 @@ urlpatterns = [ path('', views.index, name='index'), path('select-campaign//', views.select_campaign, name='select_campaign'), path('voters/', views.voter_list, name='voter_list'), + path('voters/advanced-search/', views.voter_advanced_search, name='voter_advanced_search'), + path('voters/export-csv/', views.export_voters_csv, name='export_voters_csv'), path('voters//', views.voter_detail, name='voter_detail'), path('voters//edit/', views.voter_edit, name='voter_edit'), path('voters//geocode/', views.voter_geocode, name='voter_geocode'), diff --git a/core/views.py b/core/views.py index bb4a0e2..8003067 100644 --- a/core/views.py +++ b/core/views.py @@ -1,12 +1,12 @@ import csv import io -from django.http import JsonResponse +from django.http import JsonResponse, HttpResponse from django.urls import reverse from django.shortcuts import render, redirect, get_object_or_404 from django.db.models import Q, Sum from django.contrib import messages from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer -from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm +from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm import logging from django.utils import timezone @@ -399,3 +399,135 @@ def voter_geocode(request, voter_id): }) return JsonResponse({'success': False, 'error': 'Invalid request method.'}) + +def voter_advanced_search(request): + """ + Advanced search for voters with multiple filters. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + messages.warning(request, "Please select a campaign first.") + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voters = Voter.objects.filter(tenant=tenant).order_by("last_name", "first_name") + + form = AdvancedVoterSearchForm(request.GET) + if form.is_valid(): + data = form.cleaned_data + if data.get('first_name'): + voters = voters.filter(first_name__icontains=data['first_name']) + if data.get('last_name'): + voters = voters.filter(last_name__icontains=data['last_name']) + if data.get('voter_id'): + voters = voters.filter(voter_id__icontains=data['voter_id']) + if data.get('birth_month'): + voters = voters.filter(birthdate__month=data['birth_month']) + if data.get('city'): + voters = voters.filter(city__icontains=data['city']) + if data.get('zip_code'): + voters = voters.filter(zip_code__icontains=data['zip_code']) + if data.get('district'): + voters = voters.filter(district__icontains=data['district']) + if data.get('precinct'): + voters = voters.filter(precinct__icontains=data['precinct']) + if data.get('phone_type'): + voters = voters.filter(phone_type=data['phone_type']) + if data.get('is_targeted'): + voters = voters.filter(is_targeted=True) + if data.get('candidate_support'): + voters = voters.filter(candidate_support=data['candidate_support']) + if data.get('yard_sign'): + voters = voters.filter(yard_sign=data['yard_sign']) + if data.get('window_sticker'): + voters = voters.filter(window_sticker=data['window_sticker']) + + context = { + 'form': form, + 'voters': voters, + 'selected_tenant': tenant, + } + return render(request, 'core/voter_advanced_search.html', context) + +def export_voters_csv(request): + """ + Exports selected or filtered voters to a CSV file. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + messages.warning(request, "Please select a campaign first.") + return redirect("index") + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + + if request.method != 'POST': + return redirect('voter_advanced_search') + + action = request.POST.get('action') + voters = Voter.objects.filter(tenant=tenant) + + if action == 'export_selected': + voter_ids = request.POST.getlist('selected_voters') + voters = voters.filter(id__in=voter_ids) + else: # export_all + # Re-apply filters from hidden inputs + # These are passed as filter_fieldname + filters = {} + for key, value in request.POST.items(): + if key.startswith('filter_') and value: + field_name = key.replace('filter_', '') + filters[field_name] = value + + # We can use the AdvancedVoterSearchForm to validate and apply filters + # but we need to pass data without the prefix + form = AdvancedVoterSearchForm(filters) + if form.is_valid(): + data = form.cleaned_data + if data.get('first_name'): + voters = voters.filter(first_name__icontains=data['first_name']) + if data.get('last_name'): + voters = voters.filter(last_name__icontains=data['last_name']) + if data.get('voter_id'): + voters = voters.filter(voter_id__icontains=data['voter_id']) + if data.get('birth_month'): + voters = voters.filter(birthdate__month=data['birth_month']) + if data.get('city'): + voters = voters.filter(city__icontains=data['city']) + if data.get('zip_code'): + voters = voters.filter(zip_code__icontains=data['zip_code']) + if data.get('district'): + voters = voters.filter(district__icontains=data['district']) + if data.get('precinct'): + voters = voters.filter(precinct__icontains=data['precinct']) + if data.get('phone_type'): + voters = voters.filter(phone_type=data['phone_type']) + if data.get('is_targeted'): + voters = voters.filter(is_targeted=True) + if data.get('candidate_support'): + voters = voters.filter(candidate_support=data['candidate_support']) + if data.get('yard_sign'): + voters = voters.filter(yard_sign=data['yard_sign']) + if data.get('window_sticker'): + voters = voters.filter(window_sticker=data['window_sticker']) + + voters = voters.order_by('last_name', 'first_name') + + response = HttpResponse(content_type='text/csv') + response['Content-Disposition'] = f'attachment; filename="voters_export_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv"' + + writer = csv.writer(response) + writer.writerow([ + 'Voter ID', 'First Name', 'Last Name', 'Nickname', 'Birthdate', + 'Address', 'City', 'State', 'Zip Code', 'Phone', 'Phone Type', 'Email', + 'District', 'Precinct', 'Is Targeted', 'Support', 'Yard Sign', 'Window Sticker' + ]) + + for voter in voters: + writer.writerow([ + voter.voter_id, voter.first_name, voter.last_name, voter.nickname, voter.birthdate, + voter.address, voter.city, voter.state, voter.zip_code, voter.phone, voter.get_phone_type_display(), voter.email, + voter.district, voter.precinct, 'Yes' if voter.is_targeted else 'No', + voter.get_candidate_support_display(), voter.get_yard_sign_display(), voter.get_window_sticker_display() + ]) + + return response \ No newline at end of file