diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index ccb9d81..257d103 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 def34ca..aaf24e4 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 d5fb453..e220ca6 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index a3fd971..46d66ff 100644 --- a/core/admin.py +++ b/core/admin.py @@ -11,7 +11,10 @@ from .models import ( Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter, VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings ) -from .forms import VoterImportForm, EventImportForm +from .forms import ( + VoterImportForm, EventImportForm, EventParticipationImportForm, + DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm +) logger = logging.getLogger(__name__) @@ -24,6 +27,7 @@ VOTER_MAPPABLE_FIELDS = [ ('address_street', 'Street Address'), ('city', 'City'), ('state', 'State'), + ('prior_state', 'Prior State'), ('zip_code', 'Zip Code'), ('county', 'County'), ('phone', 'Phone'), @@ -45,6 +49,35 @@ EVENT_MAPPABLE_FIELDS = [ ('description', 'Description'), ] +EVENT_PARTICIPATION_MAPPABLE_FIELDS = [ + ('voter_id', 'Voter ID'), + ('event_id', 'Event ID'), + ('event_date', 'Event Date'), + ('event_type', 'Event Type (Name)'), + ('participation_type', 'Participation Type'), +] + +DONATION_MAPPABLE_FIELDS = [ + ('voter_id', 'Voter ID'), + ('date', 'Date'), + ('amount', 'Amount'), + ('method', 'Donation Method (Name)'), +] + +INTERACTION_MAPPABLE_FIELDS = [ + ('voter_id', 'Voter ID'), + ('date', 'Date'), + ('type', 'Interaction Type (Name)'), + ('description', 'Description'), + ('notes', 'Notes'), +] + +VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [ + ('voter_id', 'Voter ID'), + ('election_type', 'Election Type (Name)'), + ('likelihood', 'Likelihood'), +] + class TenantUserRoleInline(admin.TabularInline): model = TenantUserRole extra = 1 @@ -107,9 +140,9 @@ class VoterLikelihoodInline(admin.TabularInline): @admin.register(Voter) class VoterAdmin(admin.ModelAdmin): - list_display = ('first_name', 'last_name', 'nickname', 'voter_id', 'tenant', 'district', 'candidate_support', 'is_targeted', 'city', 'state') - list_filter = ('tenant', 'candidate_support', 'is_targeted', 'yard_sign', 'district', 'city', 'state') - search_fields = ('first_name', 'last_name', 'nickname', 'voter_id', 'address', 'city', 'state', 'zip_code', 'county') + 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') + search_fields = ('first_name', 'last_name', 'nickname', 'voter_id', 'address', 'city', 'state', 'prior_state', 'zip_code', 'county') inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline] change_list_template = "admin/voter_change_list.html" @@ -223,7 +256,7 @@ class VoterAdmin(admin.ModelAdmin): @admin.register(Event) class EventAdmin(admin.ModelAdmin): - list_display = ('event_type', 'date', 'tenant') + list_display = ('id', 'event_type', 'date', 'tenant') list_filter = ('tenant', 'date', 'event_type') change_list_template = "admin/event_change_list.html" @@ -328,6 +361,496 @@ class EventAdmin(admin.ModelAdmin): class EventParticipationAdmin(admin.ModelAdmin): list_display = ('voter', 'event', 'participation_type') list_filter = ('event__tenant', 'event', 'participation_type') + change_list_template = "admin/eventparticipation_change_list.html" + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + path('import-event-participations/', self.admin_site.admin_view(self.import_event_participations), name='import-event-participations'), + ] + return my_urls + urls + + def import_event_participations(self, request): + if request.method == "POST": + if "_import" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + + mapping = {} + for field_name, _ in EVENT_PARTICIPATION_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + + try: + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.DictReader(f) + count = 0 + errors = 0 + for row in reader: + try: + voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None + participation_type = row.get(mapping.get('participation_type')) if mapping.get('participation_type') else 'invited' + + if not voter_id: + errors += 1 + continue + + try: + voter = Voter.objects.get(tenant=tenant, voter_id=voter_id) + except Voter.DoesNotExist: + logger.error(f"Voter not found: {voter_id} in tenant {tenant.name}") + errors += 1 + continue + + event = None + event_id = row.get(mapping.get('event_id')) if mapping.get('event_id') else None + if event_id: + try: + event = Event.objects.get(id=event_id, tenant=tenant) + except Event.DoesNotExist: + pass + + if not event: + event_date = row.get(mapping.get('event_date')) if mapping.get('event_date') else None + event_type_name = row.get(mapping.get('event_type')) if mapping.get('event_type') else None + if event_date and event_type_name: + try: + event_type = EventType.objects.get(tenant=tenant, name=event_type_name) + event = Event.objects.get(tenant=tenant, date=event_date, event_type=event_type) + except (EventType.DoesNotExist, Event.DoesNotExist): + pass + + if not event: + logger.error(f"Event not found for row") + errors += 1 + continue + + if participation_type not in dict(EventParticipation.PARTICIPATION_TYPE_CHOICES): + participation_type = 'invited' + + EventParticipation.objects.update_or_create( + event=event, + voter=voter, + defaults={'participation_type': participation_type} + ) + count += 1 + except Exception as e: + logger.error(f"Error importing participation row: {e}") + errors += 1 + + if os.path.exists(file_path): + os.remove(file_path) + self.message_user(request, f"Successfully imported {count} participations.") + if errors > 0: + self.message_user(request, f"Failed to import {errors} rows.", level=messages.WARNING) + return redirect("..") + except Exception as e: + self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) + return redirect("..") + else: + form = EventParticipationImportForm(request.POST, request.FILES) + if form.is_valid(): + csv_file = request.FILES['file'] + tenant = form.cleaned_data['tenant'] + + if not csv_file.name.endswith('.csv'): + self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) + return redirect("..") + + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in csv_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.reader(f) + headers = next(reader) + + context = self.admin_site.each_context(request) + context.update({ + 'title': "Map Participation Fields", + 'headers': headers, + 'model_fields': EVENT_PARTICIPATION_MAPPABLE_FIELDS, + 'tenant_id': tenant.id, + 'file_path': file_path, + 'action_url': request.path, + 'opts': self.model._meta, + }) + return render(request, "admin/import_mapping.html", context) + else: + form = EventParticipationImportForm() + + context = self.admin_site.each_context(request) + context['form'] = form + context['title'] = "Import Participations" + context['opts'] = self.model._meta + return render(request, "admin/import_csv.html", context) + +@admin.register(Donation) +class DonationAdmin(admin.ModelAdmin): + list_display = ('id', 'voter', 'date', 'amount', 'method') + list_filter = ('voter__tenant', 'date', 'method') + search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id') + change_list_template = "admin/donation_change_list.html" + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + path('import-donations/', self.admin_site.admin_view(self.import_donations), name='import-donations'), + ] + return my_urls + urls + + def import_donations(self, request): + if request.method == "POST": + if "_import" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + + mapping = {} + for field_name, _ in DONATION_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + + try: + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.DictReader(f) + count = 0 + errors = 0 + for row in reader: + try: + voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None + if not voter_id: + errors += 1 + continue + + try: + voter = Voter.objects.get(tenant=tenant, voter_id=voter_id) + except Voter.DoesNotExist: + errors += 1 + continue + + date = row.get(mapping.get('date')) + amount = row.get(mapping.get('amount')) + method_name = row.get(mapping.get('method')) + + if not date or not amount: + errors += 1 + continue + + method = None + if method_name: + method, _ = DonationMethod.objects.get_or_create( + tenant=tenant, + name=method_name + ) + + Donation.objects.create( + voter=voter, + date=date, + amount=amount, + method=method + ) + count += 1 + except Exception as e: + logger.error(f"Error importing donation row: {e}") + errors += 1 + + if os.path.exists(file_path): + os.remove(file_path) + self.message_user(request, f"Successfully imported {count} donations.") + if errors > 0: + self.message_user(request, f"Failed to import {errors} rows.", level=messages.WARNING) + return redirect("..") + except Exception as e: + self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) + return redirect("..") + else: + form = DonationImportForm(request.POST, request.FILES) + if form.is_valid(): + csv_file = request.FILES['file'] + tenant = form.cleaned_data['tenant'] + + if not csv_file.name.endswith('.csv'): + self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) + return redirect("..") + + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in csv_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.reader(f) + headers = next(reader) + + context = self.admin_site.each_context(request) + context.update({ + 'title': "Map Donation Fields", + 'headers': headers, + 'model_fields': DONATION_MAPPABLE_FIELDS, + 'tenant_id': tenant.id, + 'file_path': file_path, + 'action_url': request.path, + 'opts': self.model._meta, + }) + return render(request, "admin/import_mapping.html", context) + else: + form = DonationImportForm() + + context = self.admin_site.each_context(request) + context['form'] = form + context['title'] = "Import Donations" + context['opts'] = self.model._meta + return render(request, "admin/import_csv.html", context) + +@admin.register(Interaction) +class InteractionAdmin(admin.ModelAdmin): + list_display = ('id', 'voter', 'type', 'date', 'description') + list_filter = ('voter__tenant', 'type', 'date') + search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description') + change_list_template = "admin/interaction_change_list.html" + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + path('import-interactions/', self.admin_site.admin_view(self.import_interactions), name='import-interactions'), + ] + return my_urls + urls + + def import_interactions(self, request): + if request.method == "POST": + if "_import" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + + mapping = {} + for field_name, _ in INTERACTION_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + + try: + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.DictReader(f) + count = 0 + errors = 0 + for row in reader: + try: + voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None + if not voter_id: + errors += 1 + continue + + try: + voter = Voter.objects.get(tenant=tenant, voter_id=voter_id) + except Voter.DoesNotExist: + errors += 1 + continue + + date = row.get(mapping.get('date')) + type_name = row.get(mapping.get('type')) + description = row.get(mapping.get('description')) + notes = row.get(mapping.get('notes')) if mapping.get('notes') else '' + + if not date or not description: + errors += 1 + continue + + interaction_type = None + if type_name: + interaction_type, _ = InteractionType.objects.get_or_create( + tenant=tenant, + name=type_name + ) + + Interaction.objects.create( + voter=voter, + date=date, + type=interaction_type, + description=description, + notes=notes + ) + count += 1 + except Exception as e: + logger.error(f"Error importing interaction row: {e}") + errors += 1 + + if os.path.exists(file_path): + os.remove(file_path) + self.message_user(request, f"Successfully imported {count} interactions.") + if errors > 0: + self.message_user(request, f"Failed to import {errors} rows.", level=messages.WARNING) + return redirect("..") + except Exception as e: + self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) + return redirect("..") + else: + form = InteractionImportForm(request.POST, request.FILES) + if form.is_valid(): + csv_file = request.FILES['file'] + tenant = form.cleaned_data['tenant'] + + if not csv_file.name.endswith('.csv'): + self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) + return redirect("..") + + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in csv_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.reader(f) + headers = next(reader) + + context = self.admin_site.each_context(request) + context.update({ + 'title': "Map Interaction Fields", + 'headers': headers, + 'model_fields': INTERACTION_MAPPABLE_FIELDS, + 'tenant_id': tenant.id, + 'file_path': file_path, + 'action_url': request.path, + 'opts': self.model._meta, + }) + return render(request, "admin/import_mapping.html", context) + else: + form = InteractionImportForm() + + context = self.admin_site.each_context(request) + context['form'] = form + context['title'] = "Import Interactions" + context['opts'] = self.model._meta + return render(request, "admin/import_csv.html", context) + +@admin.register(VoterLikelihood) +class VoterLikelihoodAdmin(admin.ModelAdmin): + list_display = ('id', 'voter', 'election_type', 'likelihood') + list_filter = ('voter__tenant', 'election_type', 'likelihood') + search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id') + change_list_template = "admin/voterlikelihood_change_list.html" + + def get_urls(self): + urls = super().get_urls() + my_urls = [ + path('import-likelihoods/', self.admin_site.admin_view(self.import_likelihoods), name='import-likelihoods'), + ] + return my_urls + urls + + def import_likelihoods(self, request): + if request.method == "POST": + if "_import" in request.POST: + file_path = request.POST.get('file_path') + tenant_id = request.POST.get('tenant') + tenant = Tenant.objects.get(id=tenant_id) + + mapping = {} + for field_name, _ in VOTER_LIKELIHOOD_MAPPABLE_FIELDS: + mapping[field_name] = request.POST.get(f'map_{field_name}') + + try: + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.DictReader(f) + count = 0 + errors = 0 + for row in reader: + try: + voter_id = row.get(mapping.get('voter_id')) if mapping.get('voter_id') else None + if not voter_id: + errors += 1 + continue + + try: + voter = Voter.objects.get(tenant=tenant, voter_id=voter_id) + except Voter.DoesNotExist: + errors += 1 + continue + + election_type_name = row.get(mapping.get('election_type')) + likelihood_val = row.get(mapping.get('likelihood')) + + if not election_type_name or not likelihood_val: + errors += 1 + continue + + election_type, _ = ElectionType.objects.get_or_create( + tenant=tenant, + name=election_type_name + ) + + # Normalize likelihood + likelihood_choices = dict(VoterLikelihood.LIKELIHOOD_CHOICES) + normalized_likelihood = None + likelihood_val_lower = likelihood_val.lower().replace(' ', '_') + if likelihood_val_lower in likelihood_choices: + normalized_likelihood = likelihood_val_lower + else: + # Try to find by display name + for k, v in likelihood_choices.items(): + if v.lower() == likelihood_val.lower(): + normalized_likelihood = k + break + + if not normalized_likelihood: + errors += 1 + continue + + VoterLikelihood.objects.update_or_create( + voter=voter, + election_type=election_type, + defaults={'likelihood': normalized_likelihood} + ) + count += 1 + except Exception as e: + logger.error(f"Error importing likelihood row: {e}") + errors += 1 + + if os.path.exists(file_path): + os.remove(file_path) + self.message_user(request, f"Successfully imported {count} likelihoods.") + if errors > 0: + self.message_user(request, f"Failed to import {errors} rows.", level=messages.WARNING) + return redirect("..") + except Exception as e: + self.message_user(request, f"Error processing file: {e}", level=messages.ERROR) + return redirect("..") + else: + form = VoterLikelihoodImportForm(request.POST, request.FILES) + if form.is_valid(): + csv_file = request.FILES['file'] + tenant = form.cleaned_data['tenant'] + + if not csv_file.name.endswith('.csv'): + self.message_user(request, "Please upload a CSV file.", level=messages.ERROR) + return redirect("..") + + with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + for chunk in csv_file.chunks(): + tmp.write(chunk) + file_path = tmp.name + + with open(file_path, 'r', encoding='UTF-8') as f: + reader = csv.reader(f) + headers = next(reader) + + context = self.admin_site.each_context(request) + context.update({ + 'title': "Map Likelihood Fields", + 'headers': headers, + 'model_fields': VOTER_LIKELIHOOD_MAPPABLE_FIELDS, + 'tenant_id': tenant.id, + 'file_path': file_path, + 'action_url': request.path, + 'opts': self.model._meta, + }) + return render(request, "admin/import_mapping.html", context) + else: + form = VoterLikelihoodImportForm() + + context = self.admin_site.each_context(request) + context['form'] = form + context['title'] = "Import Likelihoods" + context['opts'] = self.model._meta + return render(request, "admin/import_csv.html", context) @admin.register(CampaignSettings) class CampaignSettingsAdmin(admin.ModelAdmin): diff --git a/core/forms.py b/core/forms.py index 836d8bd..8e811db 100644 --- a/core/forms.py +++ b/core/forms.py @@ -5,7 +5,7 @@ class VoterForm(forms.ModelForm): class Meta: model = Voter fields = [ - 'first_name', 'last_name', 'nickname', 'birthdate', 'address_street', 'city', 'state', + 'first_name', 'last_name', 'nickname', 'birthdate', 'address_street', 'city', 'state', 'prior_state', 'zip_code', 'county', 'latitude', 'longitude', 'phone', 'email', 'voter_id', 'district', 'precinct', 'registration_date', 'is_targeted', 'candidate_support', 'yard_sign', 'window_sticker' @@ -125,4 +125,40 @@ class EventImportForm(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'}) \ No newline at end of file + self.fields['file'].widget.attrs.update({'class': 'form-control'}) + +class EventParticipationImportForm(forms.Form): + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign") + file = forms.FileField(label="Select CSV file") + + 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'}) + +class DonationImportForm(forms.Form): + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign") + file = forms.FileField(label="Select CSV file") + + 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'}) + +class InteractionImportForm(forms.Form): + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign") + file = forms.FileField(label="Select CSV file") + + 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'}) + +class VoterLikelihoodImportForm(forms.Form): + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign") + file = forms.FileField(label="Select CSV file") + + 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'}) diff --git a/core/migrations/0012_voter_prior_state_alter_voter_state.py b/core/migrations/0012_voter_prior_state_alter_voter_state.py new file mode 100644 index 0000000..5ac16d4 --- /dev/null +++ b/core/migrations/0012_voter_prior_state_alter_voter_state.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-01-25 01:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0011_voter_birthdate_voter_nickname'), + ] + + operations = [ + migrations.AddField( + model_name='voter', + name='prior_state', + field=models.CharField(blank=True, max_length=2), + ), + migrations.AlterField( + model_name='voter', + name='state', + field=models.CharField(blank=True, max_length=2), + ), + ] diff --git a/core/migrations/__pycache__/0012_voter_prior_state_alter_voter_state.cpython-311.pyc b/core/migrations/__pycache__/0012_voter_prior_state_alter_voter_state.cpython-311.pyc new file mode 100644 index 0000000..a39dc29 Binary files /dev/null and b/core/migrations/__pycache__/0012_voter_prior_state_alter_voter_state.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 023259f..d77bba5 100644 --- a/core/models.py +++ b/core/models.py @@ -110,7 +110,8 @@ class Voter(models.Model): address = models.TextField(blank=True) address_street = models.CharField(max_length=255, blank=True) city = models.CharField(max_length=100, blank=True) - state = models.CharField(max_length=100, blank=True) + state = models.CharField(max_length=2, blank=True) + prior_state = models.CharField(max_length=2, blank=True) zip_code = models.CharField(max_length=20, blank=True) county = models.CharField(max_length=100, blank=True) latitude = models.DecimalField(max_digits=12, decimal_places=9, null=True, blank=True) diff --git a/core/templates/admin/donation_change_list.html b/core/templates/admin/donation_change_list.html new file mode 100644 index 0000000..a7cf7a0 --- /dev/null +++ b/core/templates/admin/donation_change_list.html @@ -0,0 +1,9 @@ +{% extends "admin/change_list.html" %} +{% block object-tools-items %} +
  • + + Import Donations + +
  • + {{ block.super }} +{% endblock %} diff --git a/core/templates/admin/eventparticipation_change_list.html b/core/templates/admin/eventparticipation_change_list.html new file mode 100644 index 0000000..201d73c --- /dev/null +++ b/core/templates/admin/eventparticipation_change_list.html @@ -0,0 +1,7 @@ +{% extends "admin/change_list.html" %} +{% block object-tools-items %} +
  • + Import Participants +
  • + {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/core/templates/admin/interaction_change_list.html b/core/templates/admin/interaction_change_list.html new file mode 100644 index 0000000..0f1a028 --- /dev/null +++ b/core/templates/admin/interaction_change_list.html @@ -0,0 +1,9 @@ +{% extends "admin/change_list.html" %} +{% block object-tools-items %} +
  • + + Import Interactions + +
  • + {{ block.super }} +{% endblock %} diff --git a/core/templates/admin/voterlikelihood_change_list.html b/core/templates/admin/voterlikelihood_change_list.html new file mode 100644 index 0000000..45091c2 --- /dev/null +++ b/core/templates/admin/voterlikelihood_change_list.html @@ -0,0 +1,9 @@ +{% extends "admin/change_list.html" %} +{% block object-tools-items %} +
  • + + Import Likelihoods + +
  • + {{ block.super }} +{% endblock %} diff --git a/core/templates/core/voter_detail.html b/core/templates/core/voter_detail.html index e54d851..5955dd9 100644 --- a/core/templates/core/voter_detail.html +++ b/core/templates/core/voter_detail.html @@ -93,6 +93,10 @@ {{ voter.birthdate|date:"M d, Y"|default:"N/A" }} +
  • + + {{ voter.prior_state|default:"N/A" }} +
  • {{ voter.registration_date|date:"M d, Y"|default:"Unknown" }} @@ -397,11 +401,15 @@ {{ voter_form.city }} -
    +
    {{ voter_form.state }}
    -
    +
    + + {{ voter_form.prior_state }} +
    +
    {{ voter_form.zip_code }}
    @@ -437,7 +445,7 @@
    - {{ voter_form.district }} + {{ voter_form.voter_id }}