diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 9a8a512..598b795 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 86cf633..be8d15c 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 438a443..b0b9894 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 50a0514..48ff136 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 2d879dd..885e8ec 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 8466f7e..6303044 100644 --- a/core/admin.py +++ b/core/admin.py @@ -16,7 +16,7 @@ from .models import ( format_phone_number, Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, EventType, Voter, VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood, CampaignSettings, - Interest, Volunteer, VolunteerEvent, ParticipationStatus + Interest, Volunteer, VolunteerEvent, ParticipationStatus, VolunteerRole ) from .forms import ( VoterImportForm, EventImportForm, EventParticipationImportForm, @@ -179,6 +179,12 @@ class DonationMethodAdmin(admin.ModelAdmin): list_filter = ('tenant', 'is_active') search_fields = ('name',) +@admin.register(VolunteerRole) +class VolunteerRoleAdmin(admin.ModelAdmin): + list_display = ("name", "tenant", "is_active") + list_filter = ("tenant", "is_active") + search_fields = ("name",) + @admin.register(ElectionType) class ElectionTypeAdmin(admin.ModelAdmin): list_display = ('name', 'tenant', 'is_active') @@ -187,9 +193,10 @@ class ElectionTypeAdmin(admin.ModelAdmin): @admin.register(EventType) class EventTypeAdmin(admin.ModelAdmin): - list_display = ('name', 'tenant', 'is_active') + list_display = ('name', 'tenant', 'is_active', 'default_volunteer_role') list_filter = ('tenant', 'is_active') search_fields = ('name',) + filter_horizontal = ('available_roles',) @admin.register(ParticipationStatus) @@ -223,6 +230,7 @@ class DonationInline(admin.TabularInline): class InteractionInline(admin.TabularInline): model = Interaction extra = 1 + autocomplete_fields = ['voter', 'type', 'volunteer'] class VoterLikelihoodInline(admin.TabularInline): model = VoterLikelihood @@ -913,8 +921,8 @@ class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin): @admin.register(VolunteerEvent) class VolunteerEventAdmin(admin.ModelAdmin): - list_display = ('volunteer', 'event', 'role') - list_filter = ('event__tenant', 'event', 'role') + list_display = ('volunteer', 'event', 'role_type') + list_filter = ('event__tenant', 'event', 'role_type') autocomplete_fields = ["volunteer", "event"] @admin.register(EventParticipation) @@ -1798,9 +1806,9 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin): @admin.register(CampaignSettings) class CampaignSettingsAdmin(admin.ModelAdmin): - list_display = ('tenant', 'donation_goal', 'twilio_from_number') + list_display = ('tenant', 'donation_goal', 'twilio_from_number', 'timezone') list_filter = ('tenant',) - fields = ('tenant', 'donation_goal', 'twilio_account_sid', 'twilio_auth_token', 'twilio_from_number') + fields = ('tenant', 'donation_goal', 'twilio_account_sid', 'twilio_auth_token', 'twilio_from_number', 'timezone') @admin.register(VotingRecord) class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin): diff --git a/core/forms.py b/core/forms.py index 107a47f..d7377a1 100644 --- a/core/forms.py +++ b/core/forms.py @@ -6,9 +6,9 @@ class VoterForm(forms.ModelForm): model = Voter fields = [ 'first_name', 'last_name', 'nickname', 'birthdate', 'address_street', 'city', 'state', 'prior_state', - 'zip_code', 'county', 'latitude', 'longitude', + 'zip_code', 'county', 'neighborhood', 'latitude', 'longitude', 'phone', 'phone_type', 'secondary_phone', 'secondary_phone_type', 'email', 'voter_id', 'district', 'precinct', - 'registration_date', 'is_targeted', 'candidate_support', 'yard_sign', 'window_sticker', 'notes' + 'registration_date', 'is_targeted', 'door_visit', 'candidate_support', 'yard_sign', 'window_sticker', 'notes' ] widgets = { 'birthdate': forms.DateInput(attrs={'type': 'date'}), @@ -48,6 +48,7 @@ class AdvancedVoterSearchForm(forms.Form): birth_month = forms.ChoiceField(choices=MONTH_CHOICES, required=False, label="Birth Month") city = forms.CharField(required=False) zip_code = forms.CharField(required=False) + neighborhood = forms.CharField(required=False) district = forms.CharField(required=False) precinct = forms.CharField(required=False) phone_type = forms.ChoiceField( @@ -55,6 +56,7 @@ class AdvancedVoterSearchForm(forms.Form): required=False ) is_targeted = forms.BooleanField(required=False, label="Targeted Only") + door_visit = forms.BooleanField(required=False, label="Visited Only") candidate_support = forms.ChoiceField( choices=[('', 'Any')] + Voter.SUPPORT_CHOICES, required=False @@ -170,7 +172,7 @@ class EventParticipantAddForm(forms.ModelForm): class EventForm(forms.ModelForm): class Meta: model = Event - fields = ['name', 'date', 'start_time', 'end_time', 'event_type', 'description', 'location_name', 'address', 'city', 'state', 'zip_code', 'latitude', 'longitude'] + fields = ['name', 'date', 'start_time', 'end_time', 'event_type', 'default_volunteer_role', 'description', 'location_name', 'address', 'city', 'state', 'zip_code', 'latitude', 'longitude'] widgets = { 'date': forms.DateInput(attrs={'type': 'date'}), 'start_time': forms.TimeInput(attrs={'type': 'time'}), @@ -182,9 +184,11 @@ class EventForm(forms.ModelForm): super().__init__(*args, **kwargs) if tenant: self.fields['event_type'].queryset = EventType.objects.filter(tenant=tenant, is_active=True) + self.fields['default_volunteer_role'].queryset = VolunteerRole.objects.filter(tenant=tenant, is_active=True) for field in self.fields.values(): field.widget.attrs.update({'class': 'form-control'}) self.fields['event_type'].widget.attrs.update({'class': 'form-select'}) + self.fields['default_volunteer_role'].widget.attrs.update({'class': 'form-select'}) class VoterImportForm(forms.Form): tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign") @@ -270,7 +274,7 @@ class VolunteerForm(forms.ModelForm): class VolunteerEventForm(forms.ModelForm): class Meta: model = VolunteerEvent - fields = ['event', 'role'] + fields = ['event', 'role_type'] def __init__(self, *args, tenant=None, **kwargs): super().__init__(*args, **kwargs) @@ -283,7 +287,7 @@ class VolunteerEventForm(forms.ModelForm): class VolunteerEventAddForm(forms.ModelForm): class Meta: model = VolunteerEvent - fields = ['volunteer', 'role'] + fields = ['volunteer', 'role_type'] def __init__(self, *args, tenant=None, **kwargs): super().__init__(*args, **kwargs) @@ -304,4 +308,31 @@ class VotingRecordImportForm(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 DoorVisitLogForm(forms.Form): + OUTCOME_CHOICES = [ + ("No Answer Left Literature", "No Answer Left Literature"), + ("Spoke to voter", "Spoke to voter"), + ("No Access to House", "No Access to House"), + ] + outcome = forms.ChoiceField( + choices=OUTCOME_CHOICES, + widget=forms.RadioSelect(attrs={"class": "form-check-input"}), + label="Outcome" + ) + notes = forms.CharField( + widget=forms.Textarea(attrs={"class": "form-control", "rows": 3}), + required=False, + label="Notes" + ) + wants_yard_sign = forms.BooleanField( + required=False, + widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), + label="Wants a Yard Sign" + ) + candidate_support = forms.ChoiceField( + choices=Voter.SUPPORT_CHOICES, + initial="unknown", + widget=forms.Select(attrs={"class": "form-select"}), + label="Candidate Support" + ) diff --git a/core/migrations/0031_volunteerrole_event_default_volunteer_role_and_more.py b/core/migrations/0031_volunteerrole_event_default_volunteer_role_and_more.py new file mode 100644 index 0000000..52882c5 --- /dev/null +++ b/core/migrations/0031_volunteerrole_event_default_volunteer_role_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.7 on 2026-01-31 13:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0030_event_location_name'), + ] + + operations = [ + migrations.CreateModel( + name='VolunteerRole', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('is_active', models.BooleanField(default=True)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='volunteer_roles', to='core.tenant')), + ], + options={ + 'unique_together': {('tenant', 'name')}, + }, + ), + migrations.AddField( + model_name='event', + name='default_volunteer_role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_events', to='core.volunteerrole'), + ), + migrations.AddField( + model_name='eventtype', + name='available_roles', + field=models.ManyToManyField(blank=True, related_name='event_types', to='core.volunteerrole'), + ), + migrations.AddField( + model_name='volunteerevent', + name='role_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='volunteer_assignments', to='core.volunteerrole'), + ), + ] diff --git a/core/migrations/0032_alter_volunteerevent_role.py b/core/migrations/0032_alter_volunteerevent_role.py new file mode 100644 index 0000000..50957eb --- /dev/null +++ b/core/migrations/0032_alter_volunteerevent_role.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-02-01 00:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0031_volunteerrole_event_default_volunteer_role_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='volunteerevent', + name='role', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/core/migrations/0033_remove_volunteerevent_role.py b/core/migrations/0033_remove_volunteerevent_role.py new file mode 100644 index 0000000..0882b66 --- /dev/null +++ b/core/migrations/0033_remove_volunteerevent_role.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.7 on 2026-02-01 00:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0032_alter_volunteerevent_role'), + ] + + operations = [ + migrations.RemoveField( + model_name='volunteerevent', + name='role', + ), + ] diff --git a/core/migrations/0034_eventtype_default_volunteer_role.py b/core/migrations/0034_eventtype_default_volunteer_role.py new file mode 100644 index 0000000..99bf4e0 --- /dev/null +++ b/core/migrations/0034_eventtype_default_volunteer_role.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.7 on 2026-02-01 01:02 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0033_remove_volunteerevent_role'), + ] + + operations = [ + migrations.AddField( + model_name='eventtype', + name='default_volunteer_role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_event_types', to='core.volunteerrole'), + ), + ] diff --git a/core/migrations/0035_interaction_door_visit_interaction_neighborhood_and_more.py b/core/migrations/0035_interaction_door_visit_interaction_neighborhood_and_more.py new file mode 100644 index 0000000..653bbe1 --- /dev/null +++ b/core/migrations/0035_interaction_door_visit_interaction_neighborhood_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.7 on 2026-02-01 01:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0034_eventtype_default_volunteer_role'), + ] + + operations = [ + migrations.AddField( + model_name='interaction', + name='door_visit', + field=models.BooleanField(db_index=True, default=False), + ), + migrations.AddField( + model_name='interaction', + name='neighborhood', + field=models.CharField(blank=True, db_index=True, max_length=100), + ), + migrations.AddField( + model_name='volunteer', + name='door_visit', + field=models.BooleanField(db_index=True, default=False), + ), + migrations.AddField( + model_name='volunteer', + name='neighborhood', + field=models.CharField(blank=True, db_index=True, max_length=100), + ), + migrations.AddField( + model_name='voter', + name='door_visit', + field=models.BooleanField(db_index=True, default=False), + ), + migrations.AddField( + model_name='voter', + name='neighborhood', + field=models.CharField(blank=True, db_index=True, max_length=100), + ), + ] diff --git a/core/migrations/0036_remove_interaction_door_visit_and_more.py b/core/migrations/0036_remove_interaction_door_visit_and_more.py new file mode 100644 index 0000000..b04e32c --- /dev/null +++ b/core/migrations/0036_remove_interaction_door_visit_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.7 on 2026-02-01 01:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0035_interaction_door_visit_interaction_neighborhood_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='interaction', + name='door_visit', + ), + migrations.RemoveField( + model_name='interaction', + name='neighborhood', + ), + migrations.RemoveField( + model_name='volunteer', + name='door_visit', + ), + migrations.RemoveField( + model_name='volunteer', + name='neighborhood', + ), + ] diff --git a/core/migrations/0037_campaignsettings_timezone.py b/core/migrations/0037_campaignsettings_timezone.py new file mode 100644 index 0000000..2eb667b --- /dev/null +++ b/core/migrations/0037_campaignsettings_timezone.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-02-01 03:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0036_remove_interaction_door_visit_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='campaignsettings', + name='timezone', + field=models.CharField(default='America/Chicago', max_length=50), + ), + ] diff --git a/core/migrations/0038_alter_campaignsettings_timezone.py b/core/migrations/0038_alter_campaignsettings_timezone.py new file mode 100644 index 0000000..e985057 --- /dev/null +++ b/core/migrations/0038_alter_campaignsettings_timezone.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-02-01 03:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0037_campaignsettings_timezone'), + ] + + operations = [ + migrations.AlterField( + model_name='campaignsettings', + name='timezone', + field=models.CharField(choices=[('Africa/Abidjan', 'Africa/Abidjan'), ('Africa/Accra', 'Africa/Accra'), ('Africa/Addis_Ababa', 'Africa/Addis_Ababa'), ('Africa/Algiers', 'Africa/Algiers'), ('Africa/Asmara', 'Africa/Asmara'), ('Africa/Asmera', 'Africa/Asmera'), ('Africa/Bamako', 'Africa/Bamako'), ('Africa/Bangui', 'Africa/Bangui'), ('Africa/Banjul', 'Africa/Banjul'), ('Africa/Bissau', 'Africa/Bissau'), ('Africa/Blantyre', 'Africa/Blantyre'), ('Africa/Brazzaville', 'Africa/Brazzaville'), ('Africa/Bujumbura', 'Africa/Bujumbura'), ('Africa/Cairo', 'Africa/Cairo'), ('Africa/Casablanca', 'Africa/Casablanca'), ('Africa/Ceuta', 'Africa/Ceuta'), ('Africa/Conakry', 'Africa/Conakry'), ('Africa/Dakar', 'Africa/Dakar'), ('Africa/Dar_es_Salaam', 'Africa/Dar_es_Salaam'), ('Africa/Djibouti', 'Africa/Djibouti'), ('Africa/Douala', 'Africa/Douala'), ('Africa/El_Aaiun', 'Africa/El_Aaiun'), ('Africa/Freetown', 'Africa/Freetown'), ('Africa/Gaborone', 'Africa/Gaborone'), ('Africa/Harare', 'Africa/Harare'), ('Africa/Johannesburg', 'Africa/Johannesburg'), ('Africa/Juba', 'Africa/Juba'), ('Africa/Kampala', 'Africa/Kampala'), ('Africa/Khartoum', 'Africa/Khartoum'), ('Africa/Kigali', 'Africa/Kigali'), ('Africa/Kinshasa', 'Africa/Kinshasa'), ('Africa/Lagos', 'Africa/Lagos'), ('Africa/Libreville', 'Africa/Libreville'), ('Africa/Lome', 'Africa/Lome'), ('Africa/Luanda', 'Africa/Luanda'), ('Africa/Lubumbashi', 'Africa/Lubumbashi'), ('Africa/Lusaka', 'Africa/Lusaka'), ('Africa/Malabo', 'Africa/Malabo'), ('Africa/Maputo', 'Africa/Maputo'), ('Africa/Maseru', 'Africa/Maseru'), ('Africa/Mbabane', 'Africa/Mbabane'), ('Africa/Mogadishu', 'Africa/Mogadishu'), ('Africa/Monrovia', 'Africa/Monrovia'), ('Africa/Nairobi', 'Africa/Nairobi'), ('Africa/Ndjamena', 'Africa/Ndjamena'), ('Africa/Niamey', 'Africa/Niamey'), ('Africa/Nouakchott', 'Africa/Nouakchott'), ('Africa/Ouagadougou', 'Africa/Ouagadougou'), ('Africa/Porto-Novo', 'Africa/Porto-Novo'), ('Africa/Sao_Tome', 'Africa/Sao_Tome'), ('Africa/Timbuktu', 'Africa/Timbuktu'), ('Africa/Tripoli', 'Africa/Tripoli'), ('Africa/Tunis', 'Africa/Tunis'), ('Africa/Windhoek', 'Africa/Windhoek'), ('America/Adak', 'America/Adak'), ('America/Anchorage', 'America/Anchorage'), ('America/Anguilla', 'America/Anguilla'), ('America/Antigua', 'America/Antigua'), ('America/Araguaina', 'America/Araguaina'), ('America/Argentina/Buenos_Aires', 'America/Argentina/Buenos_Aires'), ('America/Argentina/Catamarca', 'America/Argentina/Catamarca'), ('America/Argentina/ComodRivadavia', 'America/Argentina/ComodRivadavia'), ('America/Argentina/Cordoba', 'America/Argentina/Cordoba'), ('America/Argentina/Jujuy', 'America/Argentina/Jujuy'), ('America/Argentina/La_Rioja', 'America/Argentina/La_Rioja'), ('America/Argentina/Mendoza', 'America/Argentina/Mendoza'), ('America/Argentina/Rio_Gallegos', 'America/Argentina/Rio_Gallegos'), ('America/Argentina/Salta', 'America/Argentina/Salta'), ('America/Argentina/San_Juan', 'America/Argentina/San_Juan'), ('America/Argentina/San_Luis', 'America/Argentina/San_Luis'), ('America/Argentina/Tucuman', 'America/Argentina/Tucuman'), ('America/Argentina/Ushuaia', 'America/Argentina/Ushuaia'), ('America/Aruba', 'America/Aruba'), ('America/Asuncion', 'America/Asuncion'), ('America/Atikokan', 'America/Atikokan'), ('America/Atka', 'America/Atka'), ('America/Bahia', 'America/Bahia'), ('America/Bahia_Banderas', 'America/Bahia_Banderas'), ('America/Barbados', 'America/Barbados'), ('America/Belem', 'America/Belem'), ('America/Belize', 'America/Belize'), ('America/Blanc-Sablon', 'America/Blanc-Sablon'), ('America/Boa_Vista', 'America/Boa_Vista'), ('America/Bogota', 'America/Bogota'), ('America/Boise', 'America/Boise'), ('America/Buenos_Aires', 'America/Buenos_Aires'), ('America/Cambridge_Bay', 'America/Cambridge_Bay'), ('America/Campo_Grande', 'America/Campo_Grande'), ('America/Cancun', 'America/Cancun'), ('America/Caracas', 'America/Caracas'), ('America/Catamarca', 'America/Catamarca'), ('America/Cayenne', 'America/Cayenne'), ('America/Cayman', 'America/Cayman'), ('America/Chicago', 'America/Chicago'), ('America/Chihuahua', 'America/Chihuahua'), ('America/Ciudad_Juarez', 'America/Ciudad_Juarez'), ('America/Coral_Harbour', 'America/Coral_Harbour'), ('America/Cordoba', 'America/Cordoba'), ('America/Costa_Rica', 'America/Costa_Rica'), ('America/Coyhaique', 'America/Coyhaique'), ('America/Creston', 'America/Creston'), ('America/Cuiaba', 'America/Cuiaba'), ('America/Curacao', 'America/Curacao'), ('America/Danmarkshavn', 'America/Danmarkshavn'), ('America/Dawson', 'America/Dawson'), ('America/Dawson_Creek', 'America/Dawson_Creek'), ('America/Denver', 'America/Denver'), ('America/Detroit', 'America/Detroit'), ('America/Dominica', 'America/Dominica'), ('America/Edmonton', 'America/Edmonton'), ('America/Eirunepe', 'America/Eirunepe'), ('America/El_Salvador', 'America/El_Salvador'), ('America/Ensenada', 'America/Ensenada'), ('America/Fort_Nelson', 'America/Fort_Nelson'), ('America/Fort_Wayne', 'America/Fort_Wayne'), ('America/Fortaleza', 'America/Fortaleza'), ('America/Glace_Bay', 'America/Glace_Bay'), ('America/Godthab', 'America/Godthab'), ('America/Goose_Bay', 'America/Goose_Bay'), ('America/Grand_Turk', 'America/Grand_Turk'), ('America/Grenada', 'America/Grenada'), ('America/Guadeloupe', 'America/Guadeloupe'), ('America/Guatemala', 'America/Guatemala'), ('America/Guayaquil', 'America/Guayaquil'), ('America/Guyana', 'America/Guyana'), ('America/Halifax', 'America/Halifax'), ('America/Havana', 'America/Havana'), ('America/Hermosillo', 'America/Hermosillo'), ('America/Indiana/Indianapolis', 'America/Indiana/Indianapolis'), ('America/Indiana/Knox', 'America/Indiana/Knox'), ('America/Indiana/Marengo', 'America/Indiana/Marengo'), ('America/Indiana/Petersburg', 'America/Indiana/Petersburg'), ('America/Indiana/Tell_City', 'America/Indiana/Tell_City'), ('America/Indiana/Vevay', 'America/Indiana/Vevay'), ('America/Indiana/Vincennes', 'America/Indiana/Vincennes'), ('America/Indiana/Winamac', 'America/Indiana/Winamac'), ('America/Indianapolis', 'America/Indianapolis'), ('America/Inuvik', 'America/Inuvik'), ('America/Iqaluit', 'America/Iqaluit'), ('America/Jamaica', 'America/Jamaica'), ('America/Jujuy', 'America/Jujuy'), ('America/Juneau', 'America/Juneau'), ('America/Kentucky/Louisville', 'America/Kentucky/Louisville'), ('America/Kentucky/Monticello', 'America/Kentucky/Monticello'), ('America/Knox_IN', 'America/Knox_IN'), ('America/Kralendijk', 'America/Kralendijk'), ('America/La_Paz', 'America/La_Paz'), ('America/Lima', 'America/Lima'), ('America/Los_Angeles', 'America/Los_Angeles'), ('America/Louisville', 'America/Louisville'), ('America/Lower_Princes', 'America/Lower_Princes'), ('America/Maceio', 'America/Maceio'), ('America/Managua', 'America/Managua'), ('America/Manaus', 'America/Manaus'), ('America/Marigot', 'America/Marigot'), ('America/Martinique', 'America/Martinique'), ('America/Matamoros', 'America/Matamoros'), ('America/Mazatlan', 'America/Mazatlan'), ('America/Mendoza', 'America/Mendoza'), ('America/Menominee', 'America/Menominee'), ('America/Merida', 'America/Merida'), ('America/Metlakatla', 'America/Metlakatla'), ('America/Mexico_City', 'America/Mexico_City'), ('America/Miquelon', 'America/Miquelon'), ('America/Moncton', 'America/Moncton'), ('America/Monterrey', 'America/Monterrey'), ('America/Montevideo', 'America/Montevideo'), ('America/Montreal', 'America/Montreal'), ('America/Montserrat', 'America/Montserrat'), ('America/Nassau', 'America/Nassau'), ('America/New_York', 'America/New_York'), ('America/Nipigon', 'America/Nipigon'), ('America/Nome', 'America/Nome'), ('America/Noronha', 'America/Noronha'), ('America/North_Dakota/Beulah', 'America/North_Dakota/Beulah'), ('America/North_Dakota/Center', 'America/North_Dakota/Center'), ('America/North_Dakota/New_Salem', 'America/North_Dakota/New_Salem'), ('America/Nuuk', 'America/Nuuk'), ('America/Ojinaga', 'America/Ojinaga'), ('America/Panama', 'America/Panama'), ('America/Pangnirtung', 'America/Pangnirtung'), ('America/Paramaribo', 'America/Paramaribo'), ('America/Phoenix', 'America/Phoenix'), ('America/Port-au-Prince', 'America/Port-au-Prince'), ('America/Port_of_Spain', 'America/Port_of_Spain'), ('America/Porto_Acre', 'America/Porto_Acre'), ('America/Porto_Velho', 'America/Porto_Velho'), ('America/Puerto_Rico', 'America/Puerto_Rico'), ('America/Punta_Arenas', 'America/Punta_Arenas'), ('America/Rainy_River', 'America/Rainy_River'), ('America/Rankin_Inlet', 'America/Rankin_Inlet'), ('America/Recife', 'America/Recife'), ('America/Regina', 'America/Regina'), ('America/Resolute', 'America/Resolute'), ('America/Rio_Branco', 'America/Rio_Branco'), ('America/Rosario', 'America/Rosario'), ('America/Santa_Isabel', 'America/Santa_Isabel'), ('America/Santarem', 'America/Santarem'), ('America/Santiago', 'America/Santiago'), ('America/Santo_Domingo', 'America/Santo_Domingo'), ('America/Sao_Paulo', 'America/Sao_Paulo'), ('America/Scoresbysund', 'America/Scoresbysund'), ('America/Shiprock', 'America/Shiprock'), ('America/Sitka', 'America/Sitka'), ('America/St_Barthelemy', 'America/St_Barthelemy'), ('America/St_Johns', 'America/St_Johns'), ('America/St_Kitts', 'America/St_Kitts'), ('America/St_Lucia', 'America/St_Lucia'), ('America/St_Thomas', 'America/St_Thomas'), ('America/St_Vincent', 'America/St_Vincent'), ('America/Swift_Current', 'America/Swift_Current'), ('America/Tegucigalpa', 'America/Tegucigalpa'), ('America/Thule', 'America/Thule'), ('America/Thunder_Bay', 'America/Thunder_Bay'), ('America/Tijuana', 'America/Tijuana'), ('America/Toronto', 'America/Toronto'), ('America/Tortola', 'America/Tortola'), ('America/Vancouver', 'America/Vancouver'), ('America/Virgin', 'America/Virgin'), ('America/Whitehorse', 'America/Whitehorse'), ('America/Winnipeg', 'America/Winnipeg'), ('America/Yakutat', 'America/Yakutat'), ('America/Yellowknife', 'America/Yellowknife'), ('Antarctica/Casey', 'Antarctica/Casey'), ('Antarctica/Davis', 'Antarctica/Davis'), ('Antarctica/DumontDUrville', 'Antarctica/DumontDUrville'), ('Antarctica/Macquarie', 'Antarctica/Macquarie'), ('Antarctica/Mawson', 'Antarctica/Mawson'), ('Antarctica/McMurdo', 'Antarctica/McMurdo'), ('Antarctica/Palmer', 'Antarctica/Palmer'), ('Antarctica/Rothera', 'Antarctica/Rothera'), ('Antarctica/South_Pole', 'Antarctica/South_Pole'), ('Antarctica/Syowa', 'Antarctica/Syowa'), ('Antarctica/Troll', 'Antarctica/Troll'), ('Antarctica/Vostok', 'Antarctica/Vostok'), ('Arctic/Longyearbyen', 'Arctic/Longyearbyen'), ('Asia/Aden', 'Asia/Aden'), ('Asia/Almaty', 'Asia/Almaty'), ('Asia/Amman', 'Asia/Amman'), ('Asia/Anadyr', 'Asia/Anadyr'), ('Asia/Aqtau', 'Asia/Aqtau'), ('Asia/Aqtobe', 'Asia/Aqtobe'), ('Asia/Ashgabat', 'Asia/Ashgabat'), ('Asia/Ashkhabad', 'Asia/Ashkhabad'), ('Asia/Atyrau', 'Asia/Atyrau'), ('Asia/Baghdad', 'Asia/Baghdad'), ('Asia/Bahrain', 'Asia/Bahrain'), ('Asia/Baku', 'Asia/Baku'), ('Asia/Bangkok', 'Asia/Bangkok'), ('Asia/Barnaul', 'Asia/Barnaul'), ('Asia/Beirut', 'Asia/Beirut'), ('Asia/Bishkek', 'Asia/Bishkek'), ('Asia/Brunei', 'Asia/Brunei'), ('Asia/Calcutta', 'Asia/Calcutta'), ('Asia/Chita', 'Asia/Chita'), ('Asia/Choibalsan', 'Asia/Choibalsan'), ('Asia/Chongqing', 'Asia/Chongqing'), ('Asia/Chungking', 'Asia/Chungking'), ('Asia/Colombo', 'Asia/Colombo'), ('Asia/Dacca', 'Asia/Dacca'), ('Asia/Damascus', 'Asia/Damascus'), ('Asia/Dhaka', 'Asia/Dhaka'), ('Asia/Dili', 'Asia/Dili'), ('Asia/Dubai', 'Asia/Dubai'), ('Asia/Dushanbe', 'Asia/Dushanbe'), ('Asia/Famagusta', 'Asia/Famagusta'), ('Asia/Gaza', 'Asia/Gaza'), ('Asia/Harbin', 'Asia/Harbin'), ('Asia/Hebron', 'Asia/Hebron'), ('Asia/Ho_Chi_Minh', 'Asia/Ho_Chi_Minh'), ('Asia/Hong_Kong', 'Asia/Hong_Kong'), ('Asia/Hovd', 'Asia/Hovd'), ('Asia/Irkutsk', 'Asia/Irkutsk'), ('Asia/Istanbul', 'Asia/Istanbul'), ('Asia/Jakarta', 'Asia/Jakarta'), ('Asia/Jayapura', 'Asia/Jayapura'), ('Asia/Jerusalem', 'Asia/Jerusalem'), ('Asia/Kabul', 'Asia/Kabul'), ('Asia/Kamchatka', 'Asia/Kamchatka'), ('Asia/Karachi', 'Asia/Karachi'), ('Asia/Kashgar', 'Asia/Kashgar'), ('Asia/Kathmandu', 'Asia/Kathmandu'), ('Asia/Katmandu', 'Asia/Katmandu'), ('Asia/Khandyga', 'Asia/Khandyga'), ('Asia/Kolkata', 'Asia/Kolkata'), ('Asia/Krasnoyarsk', 'Asia/Krasnoyarsk'), ('Asia/Kuala_Lumpur', 'Asia/Kuala_Lumpur'), ('Asia/Kuching', 'Asia/Kuching'), ('Asia/Kuwait', 'Asia/Kuwait'), ('Asia/Macao', 'Asia/Macao'), ('Asia/Macau', 'Asia/Macau'), ('Asia/Magadan', 'Asia/Magadan'), ('Asia/Makassar', 'Asia/Makassar'), ('Asia/Manila', 'Asia/Manila'), ('Asia/Muscat', 'Asia/Muscat'), ('Asia/Nicosia', 'Asia/Nicosia'), ('Asia/Novokuznetsk', 'Asia/Novokuznetsk'), ('Asia/Novosibirsk', 'Asia/Novosibirsk'), ('Asia/Omsk', 'Asia/Omsk'), ('Asia/Oral', 'Asia/Oral'), ('Asia/Phnom_Penh', 'Asia/Phnom_Penh'), ('Asia/Pontianak', 'Asia/Pontianak'), ('Asia/Pyongyang', 'Asia/Pyongyang'), ('Asia/Qatar', 'Asia/Qatar'), ('Asia/Qostanay', 'Asia/Qostanay'), ('Asia/Qyzylorda', 'Asia/Qyzylorda'), ('Asia/Rangoon', 'Asia/Rangoon'), ('Asia/Riyadh', 'Asia/Riyadh'), ('Asia/Saigon', 'Asia/Saigon'), ('Asia/Sakhalin', 'Asia/Sakhalin'), ('Asia/Samarkand', 'Asia/Samarkand'), ('Asia/Seoul', 'Asia/Seoul'), ('Asia/Shanghai', 'Asia/Shanghai'), ('Asia/Singapore', 'Asia/Singapore'), ('Asia/Srednekolymsk', 'Asia/Srednekolymsk'), ('Asia/Taipei', 'Asia/Taipei'), ('Asia/Tashkent', 'Asia/Tashkent'), ('Asia/Tbilisi', 'Asia/Tbilisi'), ('Asia/Tehran', 'Asia/Tehran'), ('Asia/Tel_Aviv', 'Asia/Tel_Aviv'), ('Asia/Thimbu', 'Asia/Thimbu'), ('Asia/Thimphu', 'Asia/Thimphu'), ('Asia/Tokyo', 'Asia/Tokyo'), ('Asia/Tomsk', 'Asia/Tomsk'), ('Asia/Ujung_Pandang', 'Asia/Ujung_Pandang'), ('Asia/Ulaanbaatar', 'Asia/Ulaanbaatar'), ('Asia/Ulan_Bator', 'Asia/Ulan_Bator'), ('Asia/Urumqi', 'Asia/Urumqi'), ('Asia/Ust-Nera', 'Asia/Ust-Nera'), ('Asia/Vientiane', 'Asia/Vientiane'), ('Asia/Vladivostok', 'Asia/Vladivostok'), ('Asia/Yakutsk', 'Asia/Yakutsk'), ('Asia/Yangon', 'Asia/Yangon'), ('Asia/Yekaterinburg', 'Asia/Yekaterinburg'), ('Asia/Yerevan', 'Asia/Yerevan'), ('Atlantic/Azores', 'Atlantic/Azores'), ('Atlantic/Bermuda', 'Atlantic/Bermuda'), ('Atlantic/Canary', 'Atlantic/Canary'), ('Atlantic/Cape_Verde', 'Atlantic/Cape_Verde'), ('Atlantic/Faeroe', 'Atlantic/Faeroe'), ('Atlantic/Faroe', 'Atlantic/Faroe'), ('Atlantic/Jan_Mayen', 'Atlantic/Jan_Mayen'), ('Atlantic/Madeira', 'Atlantic/Madeira'), ('Atlantic/Reykjavik', 'Atlantic/Reykjavik'), ('Atlantic/South_Georgia', 'Atlantic/South_Georgia'), ('Atlantic/St_Helena', 'Atlantic/St_Helena'), ('Atlantic/Stanley', 'Atlantic/Stanley'), ('Australia/ACT', 'Australia/ACT'), ('Australia/Adelaide', 'Australia/Adelaide'), ('Australia/Brisbane', 'Australia/Brisbane'), ('Australia/Broken_Hill', 'Australia/Broken_Hill'), ('Australia/Canberra', 'Australia/Canberra'), ('Australia/Currie', 'Australia/Currie'), ('Australia/Darwin', 'Australia/Darwin'), ('Australia/Eucla', 'Australia/Eucla'), ('Australia/Hobart', 'Australia/Hobart'), ('Australia/LHI', 'Australia/LHI'), ('Australia/Lindeman', 'Australia/Lindeman'), ('Australia/Lord_Howe', 'Australia/Lord_Howe'), ('Australia/Melbourne', 'Australia/Melbourne'), ('Australia/NSW', 'Australia/NSW'), ('Australia/North', 'Australia/North'), ('Australia/Perth', 'Australia/Perth'), ('Australia/Queensland', 'Australia/Queensland'), ('Australia/South', 'Australia/South'), ('Australia/Sydney', 'Australia/Sydney'), ('Australia/Tasmania', 'Australia/Tasmania'), ('Australia/Victoria', 'Australia/Victoria'), ('Australia/West', 'Australia/West'), ('Australia/Yancowinna', 'Australia/Yancowinna'), ('Brazil/Acre', 'Brazil/Acre'), ('Brazil/DeNoronha', 'Brazil/DeNoronha'), ('Brazil/East', 'Brazil/East'), ('Brazil/West', 'Brazil/West'), ('CET', 'CET'), ('CST6CDT', 'CST6CDT'), ('Canada/Atlantic', 'Canada/Atlantic'), ('Canada/Central', 'Canada/Central'), ('Canada/Eastern', 'Canada/Eastern'), ('Canada/Mountain', 'Canada/Mountain'), ('Canada/Newfoundland', 'Canada/Newfoundland'), ('Canada/Pacific', 'Canada/Pacific'), ('Canada/Saskatchewan', 'Canada/Saskatchewan'), ('Canada/Yukon', 'Canada/Yukon'), ('Chile/Continental', 'Chile/Continental'), ('Chile/EasterIsland', 'Chile/EasterIsland'), ('Cuba', 'Cuba'), ('EET', 'EET'), ('EST', 'EST'), ('EST5EDT', 'EST5EDT'), ('Egypt', 'Egypt'), ('Eire', 'Eire'), ('Etc/GMT', 'Etc/GMT'), ('Etc/GMT+0', 'Etc/GMT+0'), ('Etc/GMT+1', 'Etc/GMT+1'), ('Etc/GMT+10', 'Etc/GMT+10'), ('Etc/GMT+11', 'Etc/GMT+11'), ('Etc/GMT+12', 'Etc/GMT+12'), ('Etc/GMT+2', 'Etc/GMT+2'), ('Etc/GMT+3', 'Etc/GMT+3'), ('Etc/GMT+4', 'Etc/GMT+4'), ('Etc/GMT+5', 'Etc/GMT+5'), ('Etc/GMT+6', 'Etc/GMT+6'), ('Etc/GMT+7', 'Etc/GMT+7'), ('Etc/GMT+8', 'Etc/GMT+8'), ('Etc/GMT+9', 'Etc/GMT+9'), ('Etc/GMT-0', 'Etc/GMT-0'), ('Etc/GMT-1', 'Etc/GMT-1'), ('Etc/GMT-10', 'Etc/GMT-10'), ('Etc/GMT-11', 'Etc/GMT-11'), ('Etc/GMT-12', 'Etc/GMT-12'), ('Etc/GMT-13', 'Etc/GMT-13'), ('Etc/GMT-14', 'Etc/GMT-14'), ('Etc/GMT-2', 'Etc/GMT-2'), ('Etc/GMT-3', 'Etc/GMT-3'), ('Etc/GMT-4', 'Etc/GMT-4'), ('Etc/GMT-5', 'Etc/GMT-5'), ('Etc/GMT-6', 'Etc/GMT-6'), ('Etc/GMT-7', 'Etc/GMT-7'), ('Etc/GMT-8', 'Etc/GMT-8'), ('Etc/GMT-9', 'Etc/GMT-9'), ('Etc/GMT0', 'Etc/GMT0'), ('Etc/Greenwich', 'Etc/Greenwich'), ('Etc/UCT', 'Etc/UCT'), ('Etc/UTC', 'Etc/UTC'), ('Etc/Universal', 'Etc/Universal'), ('Etc/Zulu', 'Etc/Zulu'), ('Europe/Amsterdam', 'Europe/Amsterdam'), ('Europe/Andorra', 'Europe/Andorra'), ('Europe/Astrakhan', 'Europe/Astrakhan'), ('Europe/Athens', 'Europe/Athens'), ('Europe/Belfast', 'Europe/Belfast'), ('Europe/Belgrade', 'Europe/Belgrade'), ('Europe/Berlin', 'Europe/Berlin'), ('Europe/Bratislava', 'Europe/Bratislava'), ('Europe/Brussels', 'Europe/Brussels'), ('Europe/Bucharest', 'Europe/Bucharest'), ('Europe/Budapest', 'Europe/Budapest'), ('Europe/Busingen', 'Europe/Busingen'), ('Europe/Chisinau', 'Europe/Chisinau'), ('Europe/Copenhagen', 'Europe/Copenhagen'), ('Europe/Dublin', 'Europe/Dublin'), ('Europe/Gibraltar', 'Europe/Gibraltar'), ('Europe/Guernsey', 'Europe/Guernsey'), ('Europe/Helsinki', 'Europe/Helsinki'), ('Europe/Isle_of_Man', 'Europe/Isle_of_Man'), ('Europe/Istanbul', 'Europe/Istanbul'), ('Europe/Jersey', 'Europe/Jersey'), ('Europe/Kaliningrad', 'Europe/Kaliningrad'), ('Europe/Kiev', 'Europe/Kiev'), ('Europe/Kirov', 'Europe/Kirov'), ('Europe/Kyiv', 'Europe/Kyiv'), ('Europe/Lisbon', 'Europe/Lisbon'), ('Europe/Ljubljana', 'Europe/Ljubljana'), ('Europe/London', 'Europe/London'), ('Europe/Luxembourg', 'Europe/Luxembourg'), ('Europe/Madrid', 'Europe/Madrid'), ('Europe/Malta', 'Europe/Malta'), ('Europe/Mariehamn', 'Europe/Mariehamn'), ('Europe/Minsk', 'Europe/Minsk'), ('Europe/Monaco', 'Europe/Monaco'), ('Europe/Moscow', 'Europe/Moscow'), ('Europe/Nicosia', 'Europe/Nicosia'), ('Europe/Oslo', 'Europe/Oslo'), ('Europe/Paris', 'Europe/Paris'), ('Europe/Podgorica', 'Europe/Podgorica'), ('Europe/Prague', 'Europe/Prague'), ('Europe/Riga', 'Europe/Riga'), ('Europe/Rome', 'Europe/Rome'), ('Europe/Samara', 'Europe/Samara'), ('Europe/San_Marino', 'Europe/San_Marino'), ('Europe/Sarajevo', 'Europe/Sarajevo'), ('Europe/Saratov', 'Europe/Saratov'), ('Europe/Simferopol', 'Europe/Simferopol'), ('Europe/Skopje', 'Europe/Skopje'), ('Europe/Sofia', 'Europe/Sofia'), ('Europe/Stockholm', 'Europe/Stockholm'), ('Europe/Tallinn', 'Europe/Tallinn'), ('Europe/Tirane', 'Europe/Tirane'), ('Europe/Tiraspol', 'Europe/Tiraspol'), ('Europe/Ulyanovsk', 'Europe/Ulyanovsk'), ('Europe/Uzhgorod', 'Europe/Uzhgorod'), ('Europe/Vaduz', 'Europe/Vaduz'), ('Europe/Vatican', 'Europe/Vatican'), ('Europe/Vienna', 'Europe/Vienna'), ('Europe/Vilnius', 'Europe/Vilnius'), ('Europe/Volgograd', 'Europe/Volgograd'), ('Europe/Warsaw', 'Europe/Warsaw'), ('Europe/Zagreb', 'Europe/Zagreb'), ('Europe/Zaporozhye', 'Europe/Zaporozhye'), ('Europe/Zurich', 'Europe/Zurich'), ('Factory', 'Factory'), ('GB', 'GB'), ('GB-Eire', 'GB-Eire'), ('GMT', 'GMT'), ('GMT+0', 'GMT+0'), ('GMT-0', 'GMT-0'), ('GMT0', 'GMT0'), ('Greenwich', 'Greenwich'), ('HST', 'HST'), ('Hongkong', 'Hongkong'), ('Iceland', 'Iceland'), ('Indian/Antananarivo', 'Indian/Antananarivo'), ('Indian/Chagos', 'Indian/Chagos'), ('Indian/Christmas', 'Indian/Christmas'), ('Indian/Cocos', 'Indian/Cocos'), ('Indian/Comoro', 'Indian/Comoro'), ('Indian/Kerguelen', 'Indian/Kerguelen'), ('Indian/Mahe', 'Indian/Mahe'), ('Indian/Maldives', 'Indian/Maldives'), ('Indian/Mauritius', 'Indian/Mauritius'), ('Indian/Mayotte', 'Indian/Mayotte'), ('Indian/Reunion', 'Indian/Reunion'), ('Iran', 'Iran'), ('Israel', 'Israel'), ('Jamaica', 'Jamaica'), ('Japan', 'Japan'), ('Kwajalein', 'Kwajalein'), ('Libya', 'Libya'), ('MET', 'MET'), ('MST', 'MST'), ('MST7MDT', 'MST7MDT'), ('Mexico/BajaNorte', 'Mexico/BajaNorte'), ('Mexico/BajaSur', 'Mexico/BajaSur'), ('Mexico/General', 'Mexico/General'), ('NZ', 'NZ'), ('NZ-CHAT', 'NZ-CHAT'), ('Navajo', 'Navajo'), ('PRC', 'PRC'), ('PST8PDT', 'PST8PDT'), ('Pacific/Apia', 'Pacific/Apia'), ('Pacific/Auckland', 'Pacific/Auckland'), ('Pacific/Bougainville', 'Pacific/Bougainville'), ('Pacific/Chatham', 'Pacific/Chatham'), ('Pacific/Chuuk', 'Pacific/Chuuk'), ('Pacific/Easter', 'Pacific/Easter'), ('Pacific/Efate', 'Pacific/Efate'), ('Pacific/Enderbury', 'Pacific/Enderbury'), ('Pacific/Fakaofo', 'Pacific/Fakaofo'), ('Pacific/Fiji', 'Pacific/Fiji'), ('Pacific/Funafuti', 'Pacific/Funafuti'), ('Pacific/Galapagos', 'Pacific/Galapagos'), ('Pacific/Gambier', 'Pacific/Gambier'), ('Pacific/Guadalcanal', 'Pacific/Guadalcanal'), ('Pacific/Guam', 'Pacific/Guam'), ('Pacific/Honolulu', 'Pacific/Honolulu'), ('Pacific/Johnston', 'Pacific/Johnston'), ('Pacific/Kanton', 'Pacific/Kanton'), ('Pacific/Kiritimati', 'Pacific/Kiritimati'), ('Pacific/Kosrae', 'Pacific/Kosrae'), ('Pacific/Kwajalein', 'Pacific/Kwajalein'), ('Pacific/Majuro', 'Pacific/Majuro'), ('Pacific/Marquesas', 'Pacific/Marquesas'), ('Pacific/Midway', 'Pacific/Midway'), ('Pacific/Nauru', 'Pacific/Nauru'), ('Pacific/Niue', 'Pacific/Niue'), ('Pacific/Norfolk', 'Pacific/Norfolk'), ('Pacific/Noumea', 'Pacific/Noumea'), ('Pacific/Pago_Pago', 'Pacific/Pago_Pago'), ('Pacific/Palau', 'Pacific/Palau'), ('Pacific/Pitcairn', 'Pacific/Pitcairn'), ('Pacific/Pohnpei', 'Pacific/Pohnpei'), ('Pacific/Ponape', 'Pacific/Ponape'), ('Pacific/Port_Moresby', 'Pacific/Port_Moresby'), ('Pacific/Rarotonga', 'Pacific/Rarotonga'), ('Pacific/Saipan', 'Pacific/Saipan'), ('Pacific/Samoa', 'Pacific/Samoa'), ('Pacific/Tahiti', 'Pacific/Tahiti'), ('Pacific/Tarawa', 'Pacific/Tarawa'), ('Pacific/Tongatapu', 'Pacific/Tongatapu'), ('Pacific/Truk', 'Pacific/Truk'), ('Pacific/Wake', 'Pacific/Wake'), ('Pacific/Wallis', 'Pacific/Wallis'), ('Pacific/Yap', 'Pacific/Yap'), ('Poland', 'Poland'), ('Portugal', 'Portugal'), ('ROC', 'ROC'), ('ROK', 'ROK'), ('Singapore', 'Singapore'), ('Turkey', 'Turkey'), ('UCT', 'UCT'), ('US/Alaska', 'US/Alaska'), ('US/Aleutian', 'US/Aleutian'), ('US/Arizona', 'US/Arizona'), ('US/Central', 'US/Central'), ('US/East-Indiana', 'US/East-Indiana'), ('US/Eastern', 'US/Eastern'), ('US/Hawaii', 'US/Hawaii'), ('US/Indiana-Starke', 'US/Indiana-Starke'), ('US/Michigan', 'US/Michigan'), ('US/Mountain', 'US/Mountain'), ('US/Pacific', 'US/Pacific'), ('US/Samoa', 'US/Samoa'), ('UTC', 'UTC'), ('Universal', 'Universal'), ('W-SU', 'W-SU'), ('WET', 'WET'), ('Zulu', 'Zulu'), ('localtime', 'localtime')], default='America/Chicago', max_length=100), + ), + ] diff --git a/core/migrations/__pycache__/0031_volunteerrole_event_default_volunteer_role_and_more.cpython-311.pyc b/core/migrations/__pycache__/0031_volunteerrole_event_default_volunteer_role_and_more.cpython-311.pyc new file mode 100644 index 0000000..770ff1e Binary files /dev/null and b/core/migrations/__pycache__/0031_volunteerrole_event_default_volunteer_role_and_more.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0032_alter_volunteerevent_role.cpython-311.pyc b/core/migrations/__pycache__/0032_alter_volunteerevent_role.cpython-311.pyc new file mode 100644 index 0000000..1627d54 Binary files /dev/null and b/core/migrations/__pycache__/0032_alter_volunteerevent_role.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0033_remove_volunteerevent_role.cpython-311.pyc b/core/migrations/__pycache__/0033_remove_volunteerevent_role.cpython-311.pyc new file mode 100644 index 0000000..d03a815 Binary files /dev/null and b/core/migrations/__pycache__/0033_remove_volunteerevent_role.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0034_eventtype_default_volunteer_role.cpython-311.pyc b/core/migrations/__pycache__/0034_eventtype_default_volunteer_role.cpython-311.pyc new file mode 100644 index 0000000..2fc9e0f Binary files /dev/null and b/core/migrations/__pycache__/0034_eventtype_default_volunteer_role.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0035_interaction_door_visit_interaction_neighborhood_and_more.cpython-311.pyc b/core/migrations/__pycache__/0035_interaction_door_visit_interaction_neighborhood_and_more.cpython-311.pyc new file mode 100644 index 0000000..a53f1b3 Binary files /dev/null and b/core/migrations/__pycache__/0035_interaction_door_visit_interaction_neighborhood_and_more.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0036_remove_interaction_door_visit_and_more.cpython-311.pyc b/core/migrations/__pycache__/0036_remove_interaction_door_visit_and_more.cpython-311.pyc new file mode 100644 index 0000000..c65af28 Binary files /dev/null and b/core/migrations/__pycache__/0036_remove_interaction_door_visit_and_more.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0037_campaignsettings_timezone.cpython-311.pyc b/core/migrations/__pycache__/0037_campaignsettings_timezone.cpython-311.pyc new file mode 100644 index 0000000..a010cee Binary files /dev/null and b/core/migrations/__pycache__/0037_campaignsettings_timezone.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0038_alter_campaignsettings_timezone.cpython-311.pyc b/core/migrations/__pycache__/0038_alter_campaignsettings_timezone.cpython-311.pyc new file mode 100644 index 0000000..8290e60 Binary files /dev/null and b/core/migrations/__pycache__/0038_alter_campaignsettings_timezone.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 8e6a38e..569acdd 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,4 @@ +import zoneinfo from django.db import models from django.contrib.auth.models import User import json @@ -77,8 +78,20 @@ class ElectionType(models.Model): def __str__(self): return self.name -class EventType(models.Model): - tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='event_types') +class ParticipationStatus(models.Model): + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='participation_statuses') + name = models.CharField(max_length=100) + is_active = models.BooleanField(default=True) + + class Meta: + unique_together = ('tenant', 'name') + verbose_name_plural = 'Participation Statuses' + + def __str__(self): + return self.name + +class VolunteerRole(models.Model): + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='volunteer_roles') name = models.CharField(max_length=100) is_active = models.BooleanField(default=True) @@ -88,14 +101,15 @@ class EventType(models.Model): def __str__(self): return self.name -class ParticipationStatus(models.Model): - tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='participation_statuses') +class EventType(models.Model): + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='event_types') name = models.CharField(max_length=100) + available_roles = models.ManyToManyField(VolunteerRole, blank=True, related_name='event_types') + default_volunteer_role = models.ForeignKey(VolunteerRole, on_delete=models.SET_NULL, null=True, blank=True, related_name="default_for_event_types") is_active = models.BooleanField(default=True) class Meta: unique_together = ('tenant', 'name') - verbose_name_plural = 'Participation Statuses' def __str__(self): return self.name @@ -160,6 +174,8 @@ class Voter(models.Model): yard_sign = models.CharField(max_length=20, choices=YARD_SIGN_CHOICES, default='none', db_index=True) window_sticker = models.CharField(max_length=20, choices=WINDOW_STICKER_CHOICES, default='none', verbose_name='Window Sticker Status', db_index=True) notes = models.TextField(blank=True) + door_visit = models.BooleanField(default=False, db_index=True) + neighborhood = models.CharField(max_length=100, blank=True, db_index=True) created_at = models.DateTimeField(auto_now_add=True) @@ -288,6 +304,7 @@ class Event(models.Model): start_time = models.TimeField(null=True, blank=True) end_time = models.TimeField(null=True, blank=True) event_type = models.ForeignKey(EventType, on_delete=models.PROTECT, null=True) + default_volunteer_role = models.ForeignKey(VolunteerRole, on_delete=models.SET_NULL, null=True, blank=True, related_name='default_for_events') description = models.TextField(blank=True) location_name = models.CharField(max_length=255, blank=True) address = models.CharField(max_length=255, blank=True) @@ -327,10 +344,10 @@ class Volunteer(models.Model): class VolunteerEvent(models.Model): volunteer = models.ForeignKey(Volunteer, on_delete=models.CASCADE, related_name="event_assignments") event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="volunteer_assignments") - role = models.CharField(max_length=100) + role_type = models.ForeignKey(VolunteerRole, on_delete=models.SET_NULL, null=True, blank=True, related_name="volunteer_assignments") def __str__(self): - return f"{self.volunteer} at {self.event} as {self.role}" + return f"{self.volunteer} at {self.event} as {self.role_type or 'Assigned'}" class EventParticipation(models.Model): event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='participations') @@ -381,6 +398,7 @@ class CampaignSettings(models.Model): twilio_account_sid = models.CharField(max_length=100, blank=True, default='ACcd11acb5095cec6477245d385a2bf127') twilio_auth_token = models.CharField(max_length=100, blank=True, default='89ec830d0fa02ab0afa6c76084865713') twilio_from_number = models.CharField(max_length=20, blank=True, default='+18556945903') + timezone = models.CharField(max_length=100, default="America/Chicago", choices=[(tz, tz) for tz in sorted(zoneinfo.available_timezones())]) class Meta: verbose_name = 'Campaign Settings' diff --git a/core/templates/base.html b/core/templates/base.html index 73437e3..7a75e50 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -40,6 +40,9 @@ + diff --git a/core/templates/core/door_visits.html b/core/templates/core/door_visits.html new file mode 100644 index 0000000..f1d0bec --- /dev/null +++ b/core/templates/core/door_visits.html @@ -0,0 +1,251 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Door Visits

+
+ {{ households.paginator.count }} Unvisited Households +
+
+ +
+
+
Filters
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+
+
Unvisited Households
+
+
+ + + + + + + + + + + + {% for household in households %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
Target VotersNeighborhoodAddressCity, StateAction
+ {% for voter in household.target_voters %} + + {{ voter.first_name }} {{ voter.last_name }} + {% if not forloop.last %}, {% endif %} + {% endfor %} + + {% if household.neighborhood %} + {{ household.neighborhood }} + {% else %} + None + {% endif %} + {{ household.address_street }}{{ household.city }}, {{ household.state }} + +
+
+ +

No unvisited households found.

+

Try adjusting your filters or targeting more voters.

+
+
+
+ + {% if households.paginator.num_pages > 1 %} + + {% endif %} +
+
+ + + + + + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/event_detail.html b/core/templates/core/event_detail.html index 2b5bda5..9003941 100644 --- a/core/templates/core/event_detail.html +++ b/core/templates/core/event_detail.html @@ -34,6 +34,12 @@ {{ event.event_type }} + {% if event.default_volunteer_role %} +
+ + {{ event.default_volunteer_role }} +
+ {% endif %}
{{ event.date|date:"F d, Y" }} @@ -97,7 +103,11 @@ {{ v.volunteer.first_name }} {{ v.volunteer.last_name }} - {{ v.role }} + + + {{ v.role_type|default:"Assigned" }} + +
{% csrf_token %} @@ -229,7 +239,7 @@
@@ -269,9 +279,8 @@
- - {{ add_volunteer_form.role }} -
e.g., Driver, Caller, Organizer
+ + {{ add_volunteer_form.role_type }}
+
+ + {{ form.default_volunteer_role }} + {% if form.default_volunteer_role.errors %} +
{{ form.default_volunteer_role.errors }}
+ {% endif %} +
{{ form.date }} diff --git a/core/urls.py b/core/urls.py index c436512..c83001c 100644 --- a/core/urls.py +++ b/core/urls.py @@ -52,4 +52,8 @@ urlpatterns = [ path('volunteers/bulk-sms/', views.volunteer_bulk_send_sms, name='volunteer_bulk_send_sms'), path('events//volunteer/add/', views.event_add_volunteer, name='event_add_volunteer'), path('events/volunteer//delete/', views.event_remove_volunteer, name='event_remove_volunteer'), + + # Door Visits + path('door-visits/', views.door_visits, name='door_visits'), + path('door-visits/log/', views.log_door_visit, name='log_door_visit'), ] \ No newline at end of file diff --git a/core/views.py b/core/views.py index 74d8d4b..bf9b534 100644 --- a/core/views.py +++ b/core/views.py @@ -10,9 +10,10 @@ from django.shortcuts import render, redirect, get_object_or_404 from django.db.models import Q, Sum from django.contrib import messages from django.core.paginator import Paginator -from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer, ParticipationStatus, VolunteerEvent, Interest -from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm +from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event, EventType, InteractionType, DonationMethod, ElectionType, CampaignSettings, Volunteer, ParticipationStatus, VolunteerEvent, Interest, VolunteerRole +from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm, VoterImportForm, AdvancedVoterSearchForm, EventParticipantAddForm, EventForm, VolunteerForm, VolunteerEventForm, VolunteerEventAddForm, DoorVisitLogForm import logging +import zoneinfo from django.utils import timezone logger = logging.getLogger(__name__) @@ -700,7 +701,10 @@ def event_detail(request, event_id): # Form for adding a new participant add_form = EventParticipantAddForm(tenant=tenant) # Form for adding a new volunteer - add_volunteer_form = VolunteerEventAddForm(tenant=tenant) + default_role = event.default_volunteer_role + if not default_role and event.event_type: + default_role = event.event_type.default_volunteer_role + add_volunteer_form = VolunteerEventAddForm(tenant=tenant, initial={'role_type': default_role}) participation_statuses = ParticipationStatus.objects.filter(tenant=tenant, is_active=True) @@ -1163,3 +1167,167 @@ def volunteer_bulk_send_sms(request): messages.success(request, f"Bulk SMS process completed: {success_count} successful, {fail_count} failed/skipped.") return redirect('volunteer_list') + +def door_visits(request): + """ + Manage door knocking visits. Groups unvisited targeted voters by household. + """ + 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) + + # Filters from GET parameters + district_filter = request.GET.get('district', '').strip() + neighborhood_filter = request.GET.get('neighborhood', '').strip() + address_filter = request.GET.get('address', '').strip() + + # Initial queryset: unvisited targeted voters for this tenant + voters = Voter.objects.filter(tenant=tenant, door_visit=False, is_targeted=True) + + # Apply filters if provided + if district_filter: + voters = voters.filter(district__icontains=district_filter) + if neighborhood_filter: + voters = voters.filter(neighborhood__icontains=neighborhood_filter) + if address_filter: + voters = voters.filter(Q(address_street__icontains=address_filter) | Q(address__icontains=address_filter)) + + # Grouping by household (unique address) + households_dict = {} + for voter in voters: + # Key for grouping is the unique address components + key = (voter.address_street, voter.city, voter.state, voter.zip_code) + if key not in households_dict: + # Parse street name and number for sorting + street_number = "" + street_name = voter.address_street + match = re.match(r'^(\d+)\s+(.*)$', voter.address_street) + if match: + street_number = match.group(1) + street_name = match.group(2) + + try: + street_number_sort = int(street_number) + except ValueError: + street_number_sort = 0 + + households_dict[key] = { + 'address_street': voter.address_street, + 'city': voter.city, + 'state': voter.state, + 'zip_code': voter.zip_code, + 'neighborhood': voter.neighborhood, + 'district': voter.district, + 'street_name_sort': street_name.lower(), + 'street_number_sort': street_number_sort, + 'target_voters': [] + } + households_dict[key]['target_voters'].append(voter) + + households_list = list(households_dict.values()) + households_list.sort(key=lambda x: ( + (x['neighborhood'] or '').lower(), + x['street_name_sort'], + x['street_number_sort'] + )) + + paginator = Paginator(households_list, 50) + page_number = request.GET.get('page') + households_page = paginator.get_page(page_number) + + context = { + 'selected_tenant': tenant, + 'households': households_page, + 'district_filter': district_filter, + 'neighborhood_filter': neighborhood_filter, + 'address_filter': address_filter, + 'visit_form': DoorVisitLogForm(), + } + return render(request, 'core/door_visits.html', context) + +def log_door_visit(request): + """ + Mark all targeted voters at a specific address as visited, update their flags, + and create interaction records. + """ + selected_tenant_id = request.session.get("tenant_id") + if not selected_tenant_id: + return redirect('index') + + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + campaign_settings, _ = CampaignSettings.objects.get_or_create(tenant=tenant) + + # Get the volunteer linked to the current user + volunteer = Volunteer.objects.filter(user=request.user, tenant=tenant).first() + + if request.method == 'POST': + form = DoorVisitLogForm(request.POST) + if form.is_valid(): + address_street = request.POST.get('address_street') + city = request.POST.get('city') + state = request.POST.get('state') + zip_code = request.POST.get('zip_code') + + outcome = form.cleaned_data['outcome'] + notes = form.cleaned_data['notes'] + wants_yard_sign = form.cleaned_data['wants_yard_sign'] + candidate_support = form.cleaned_data['candidate_support'] + + # Determine date/time in campaign timezone + campaign_tz_name = campaign_settings.timezone or 'America/Chicago' + try: + tz = zoneinfo.ZoneInfo(campaign_tz_name) + except: + tz = zoneinfo.ZoneInfo('America/Chicago') + + interaction_date = timezone.now().astimezone(tz) + + # Get or create InteractionType + interaction_type, _ = InteractionType.objects.get_or_create(tenant=tenant, name="Door Visit") + + # Find targeted voters at this exact address + voters = Voter.objects.filter( + tenant=tenant, + address_street=address_street, + city=city, + state=state, + zip_code=zip_code, + is_targeted=True + ) + + if not voters.exists(): + messages.warning(request, f"No targeted voters found at {address_street}.") + return redirect('door_visits') + + for voter in voters: + # 1) Update voter flags + voter.door_visit = True + + # 2) If "Wants a Yard Sign" checkbox is selected + if wants_yard_sign: + voter.yard_sign = 'wants' + + # 3) Update support status if Supporting or Not Supporting + if candidate_support in ['supporting', 'not_supporting']: + voter.candidate_support = candidate_support + + voter.save() + + # 4) Create interaction + Interaction.objects.create( + voter=voter, + volunteer=volunteer, + type=interaction_type, + date=interaction_date, + description=outcome, + notes=notes + ) + + messages.success(request, f"Door visit logged for {address_street}.") + else: + messages.error(request, "There was an error in the visit log form.") + + return redirect('door_visits')