diff --git a/config/__pycache__/__init__.cpython-311.pyc b/config/__pycache__/__init__.cpython-311.pyc index 423a636..b2a6eee 100644 Binary files a/config/__pycache__/__init__.cpython-311.pyc and b/config/__pycache__/__init__.cpython-311.pyc differ diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index 96bce55..c8bcb91 100644 Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc index 0b85e94..c5a37af 100644 Binary files a/config/__pycache__/urls.cpython-311.pyc and b/config/__pycache__/urls.cpython-311.pyc differ diff --git a/config/__pycache__/wsgi.cpython-311.pyc b/config/__pycache__/wsgi.cpython-311.pyc index 9c49e09..945c6e2 100644 Binary files a/config/__pycache__/wsgi.cpython-311.pyc and b/config/__pycache__/wsgi.cpython-311.pyc differ diff --git a/core/__pycache__/__init__.cpython-311.pyc b/core/__pycache__/__init__.cpython-311.pyc index 74b1112..c3e6f00 100644 Binary files a/core/__pycache__/__init__.cpython-311.pyc and b/core/__pycache__/__init__.cpython-311.pyc differ diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index a5ed392..9eeed8d 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/apps.cpython-311.pyc b/core/__pycache__/apps.cpython-311.pyc index 6f131d4..c93069c 100644 Binary files a/core/__pycache__/apps.cpython-311.pyc and b/core/__pycache__/apps.cpython-311.pyc differ diff --git a/core/__pycache__/context_processors.cpython-311.pyc b/core/__pycache__/context_processors.cpython-311.pyc index 75bf223..3df58b8 100644 Binary files a/core/__pycache__/context_processors.cpython-311.pyc and b/core/__pycache__/context_processors.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000..fb593c8 Binary files /dev/null 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 e061640..9c700fe 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 5a69659..d1c2c24 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 2a36fd6..2b1d115 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 8c38f3f..5395bfb 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,69 @@ from django.contrib import admin +from .models import ( + Tenant, TenantUserRole, InteractionType, DonationMethod, ElectionType, Voter, + VotingRecord, Event, EventParticipation, Donation, Interaction, VoterLikelihood +) -# Register your models here. +class TenantUserRoleInline(admin.TabularInline): + model = TenantUserRole + extra = 1 + +@admin.register(Tenant) +class TenantAdmin(admin.ModelAdmin): + list_display = ('name', 'slug', 'created_at') + search_fields = ('name',) + inlines = [TenantUserRoleInline] + +@admin.register(TenantUserRole) +class TenantUserRoleAdmin(admin.ModelAdmin): + list_display = ('user', 'tenant', 'role') + list_filter = ('tenant', 'role') + search_fields = ('user__username', 'tenant__name') + +@admin.register(InteractionType) +class InteractionTypeAdmin(admin.ModelAdmin): + list_display = ('name', 'tenant') + list_filter = ('tenant',) + +@admin.register(DonationMethod) +class DonationMethodAdmin(admin.ModelAdmin): + list_display = ('name', 'tenant') + list_filter = ('tenant',) + +@admin.register(ElectionType) +class ElectionTypeAdmin(admin.ModelAdmin): + list_display = ('name', 'tenant') + list_filter = ('tenant',) + +class VotingRecordInline(admin.TabularInline): + model = VotingRecord + extra = 1 + +class DonationInline(admin.TabularInline): + model = Donation + extra = 1 + +class InteractionInline(admin.TabularInline): + model = Interaction + extra = 1 + +class VoterLikelihoodInline(admin.TabularInline): + model = VoterLikelihood + extra = 1 + +@admin.register(Voter) +class VoterAdmin(admin.ModelAdmin): + list_display = ('first_name', 'last_name', 'voter_id', 'tenant', 'district', 'candidate_support') + list_filter = ('tenant', 'candidate_support', 'yard_sign', 'district') + search_fields = ('first_name', 'last_name', 'voter_id') + inlines = [VotingRecordInline, DonationInline, InteractionInline, VoterLikelihoodInline] + +@admin.register(Event) +class EventAdmin(admin.ModelAdmin): + list_display = ('event_type', 'date', 'tenant') + list_filter = ('tenant', 'date') + +@admin.register(EventParticipation) +class EventParticipationAdmin(admin.ModelAdmin): + list_display = ('voter', 'event') + list_filter = ('event__tenant', 'event') \ No newline at end of file diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..3e108a6 --- /dev/null +++ b/core/forms.py @@ -0,0 +1,82 @@ +from django import forms +from .models import Voter, Interaction, Donation, VoterLikelihood, InteractionType, DonationMethod, ElectionType, Event, EventParticipation + +class VoterForm(forms.ModelForm): + class Meta: + model = Voter + fields = [ + 'first_name', 'last_name', 'street_address', 'city', 'state', 'zip_code', + 'latitude', 'longitude', 'phone', 'email', + 'voter_id', 'district', 'precinct', 'registration_date', + 'candidate_support', 'yard_sign' + ] + widgets = { + 'registration_date': forms.DateInput(attrs={'type': 'date'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) + self.fields['candidate_support'].widget.attrs.update({'class': 'form-select'}) + self.fields['yard_sign'].widget.attrs.update({'class': 'form-select'}) + +class InteractionForm(forms.ModelForm): + class Meta: + model = Interaction + fields = ['type', 'date', 'description', 'notes'] + widgets = { + 'date': forms.DateInput(attrs={'type': 'date'}), + 'notes': forms.Textarea(attrs={'rows': 2}), + } + + def __init__(self, *args, tenant=None, **kwargs): + super().__init__(*args, **kwargs) + if tenant: + self.fields['type'].queryset = InteractionType.objects.filter(tenant=tenant) + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) + self.fields['type'].widget.attrs.update({'class': 'form-select'}) + +class DonationForm(forms.ModelForm): + class Meta: + model = Donation + fields = ['date', 'method', 'amount'] + widgets = { + 'date': forms.DateInput(attrs={'type': 'date'}), + } + + def __init__(self, *args, tenant=None, **kwargs): + super().__init__(*args, **kwargs) + if tenant: + self.fields['method'].queryset = DonationMethod.objects.filter(tenant=tenant) + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) + self.fields['method'].widget.attrs.update({'class': 'form-select'}) + +class VoterLikelihoodForm(forms.ModelForm): + class Meta: + model = VoterLikelihood + fields = ['election_type', 'likelihood'] + + def __init__(self, *args, tenant=None, **kwargs): + super().__init__(*args, **kwargs) + if tenant: + self.fields['election_type'].queryset = ElectionType.objects.filter(tenant=tenant) + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) + self.fields['election_type'].widget.attrs.update({'class': 'form-select'}) + self.fields['likelihood'].widget.attrs.update({'class': 'form-select'}) + +class EventParticipationForm(forms.ModelForm): + class Meta: + model = EventParticipation + fields = ['event'] + + def __init__(self, *args, tenant=None, **kwargs): + super().__init__(*args, **kwargs) + if tenant: + self.fields['event'].queryset = Event.objects.filter(tenant=tenant) + for field in self.fields.values(): + field.widget.attrs.update({'class': 'form-control'}) + self.fields['event'].widget.attrs.update({'class': 'form-select'}) \ No newline at end of file diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..371e2a0 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 5.2.7 on 2026-01-24 05:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Tenant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('slug', models.SlugField(blank=True, unique=True)), + ('description', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Voter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('voter_id', models.CharField(blank=True, max_length=50)), + ('first_name', models.CharField(max_length=100)), + ('last_name', models.CharField(max_length=100)), + ('address', models.TextField(blank=True)), + ('phone', models.CharField(blank=True, max_length=20)), + ('email', models.EmailField(blank=True, max_length=254)), + ('geocode', models.CharField(blank=True, max_length=100)), + ('district', models.CharField(blank=True, max_length=100)), + ('precinct', models.CharField(blank=True, max_length=100)), + ('registration_date', models.DateField(blank=True, null=True)), + ('candidate_support', models.CharField(choices=[('unknown', 'Unknown'), ('supporting', 'Supporting'), ('not_supporting', 'Not Supporting')], default='unknown', max_length=20)), + ('yard_sign', models.CharField(choices=[('none', 'None'), ('wants', 'Wants a yard sign'), ('has', 'Has a yard sign')], default='none', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='voters', to='core.tenant')), + ], + ), + migrations.CreateModel( + name='InteractionType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interaction_types', to='core.tenant')), + ], + options={ + 'unique_together': {('tenant', 'name')}, + }, + ), + migrations.CreateModel( + name='ElectionType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='election_types', to='core.tenant')), + ], + options={ + 'unique_together': {('tenant', 'name')}, + }, + ), + migrations.CreateModel( + name='DonationMethod', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='donation_methods', to='core.tenant')), + ], + options={ + 'unique_together': {('tenant', 'name')}, + }, + ), + ] diff --git a/core/migrations/0002_donation_event_eventparticipation_interaction_and_more.py b/core/migrations/0002_donation_event_eventparticipation_interaction_and_more.py new file mode 100644 index 0000000..e395f53 --- /dev/null +++ b/core/migrations/0002_donation_event_eventparticipation_interaction_and_more.py @@ -0,0 +1,75 @@ +# Generated by Django 5.2.7 on 2026-01-24 05:18 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Donation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('method', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.donationmethod')), + ('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='donations', to='core.voter')), + ], + ), + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('event_type', models.CharField(max_length=100)), + ('description', models.TextField(blank=True)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='core.tenant')), + ], + ), + migrations.CreateModel( + name='EventParticipation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participations', to='core.event')), + ('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_participations', to='core.voter')), + ], + ), + migrations.CreateModel( + name='Interaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('description', models.CharField(max_length=255)), + ('notes', models.TextField(blank=True)), + ('type', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.interactiontype')), + ('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interactions', to='core.voter')), + ], + ), + migrations.CreateModel( + name='VotingRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('election_date', models.DateField()), + ('election_description', models.CharField(max_length=255)), + ('primary_party', models.CharField(blank=True, max_length=100)), + ('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='voting_records', to='core.voter')), + ], + ), + migrations.CreateModel( + name='VoterLikelihood', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('likelihood', models.CharField(choices=[('not_likely', 'Not Likely'), ('somewhat_likely', 'Somewhat Likely'), ('very_likely', 'Very Likely')], max_length=20)), + ('election_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.electiontype')), + ('voter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likelihoods', to='core.voter')), + ], + options={ + 'unique_together': {('voter', 'election_type')}, + }, + ), + ] diff --git a/core/migrations/0003_tenantuserrole.py b/core/migrations/0003_tenantuserrole.py new file mode 100644 index 0000000..574946f --- /dev/null +++ b/core/migrations/0003_tenantuserrole.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.7 on 2026-01-24 05:27 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_donation_event_eventparticipation_interaction_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TenantUserRole', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('system_admin', 'System Administrator'), ('campaign_admin', 'Campaign Administrator'), ('campaign_staff', 'Campaign Staff')], max_length=20)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_roles', to='core.tenant')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tenant_roles', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'tenant', 'role')}, + }, + ), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000..df898f7 Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0002_donation_event_eventparticipation_interaction_and_more.cpython-311.pyc b/core/migrations/__pycache__/0002_donation_event_eventparticipation_interaction_and_more.cpython-311.pyc new file mode 100644 index 0000000..6c15850 Binary files /dev/null and b/core/migrations/__pycache__/0002_donation_event_eventparticipation_interaction_and_more.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0003_tenantuserrole.cpython-311.pyc b/core/migrations/__pycache__/0003_tenantuserrole.cpython-311.pyc new file mode 100644 index 0000000..7827bf9 Binary files /dev/null and b/core/migrations/__pycache__/0003_tenantuserrole.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/__init__.cpython-311.pyc b/core/migrations/__pycache__/__init__.cpython-311.pyc index 9c833c8..154497b 100644 Binary files a/core/migrations/__pycache__/__init__.cpython-311.pyc and b/core/migrations/__pycache__/__init__.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 71a8362..7de862b 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,161 @@ from django.db import models +from django.utils.text import slugify +from django.contrib.auth.models import User -# Create your models here. +class Tenant(models.Model): + name = models.CharField(max_length=255) + slug = models.SlugField(unique=True, blank=True) + description = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name) + super().save(*args, **kwargs) + + def __str__(self): + return self.name + +class TenantUserRole(models.Model): + ROLE_CHOICES = [ + ('system_admin', 'System Administrator'), + ('campaign_admin', 'Campaign Administrator'), + ('campaign_staff', 'Campaign Staff'), + ] + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tenant_roles') + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='user_roles') + role = models.CharField(max_length=20, choices=ROLE_CHOICES) + + class Meta: + unique_together = ('user', 'tenant', 'role') + + def __str__(self): + return f"{self.user.username} - {self.tenant.name} ({self.role})" + +class InteractionType(models.Model): + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='interaction_types') + name = models.CharField(max_length=100) + + class Meta: + unique_together = ('tenant', 'name') + + def __str__(self): + return f"{self.name} ({self.tenant.name})" + +class DonationMethod(models.Model): + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='donation_methods') + name = models.CharField(max_length=100) + + class Meta: + unique_together = ('tenant', 'name') + + def __str__(self): + return f"{self.name} ({self.tenant.name})" + +class ElectionType(models.Model): + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='election_types') + name = models.CharField(max_length=100) + + class Meta: + unique_together = ('tenant', 'name') + + def __str__(self): + return f"{self.name} ({self.tenant.name})" + +class Voter(models.Model): + SUPPORT_CHOICES = [ + ('unknown', 'Unknown'), + ('supporting', 'Supporting'), + ('not_supporting', 'Not Supporting'), + ] + YARD_SIGN_CHOICES = [ + ('none', 'None'), + ('wants', 'Wants a yard sign'), + ('has', 'Has a yard sign'), + ] + + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='voters') + voter_id = models.CharField(max_length=50, blank=True) + first_name = models.CharField(max_length=100) + last_name = models.CharField(max_length=100) + + # Separated address fields + street_address = models.CharField(max_length=255, blank=True) + city = models.CharField(max_length=100, blank=True) + state = models.CharField(max_length=50, blank=True) + zip_code = models.CharField(max_length=20, blank=True) + latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + + phone = models.CharField(max_length=20, blank=True) + email = models.EmailField(blank=True) + district = models.CharField(max_length=100, blank=True) + precinct = models.CharField(max_length=100, blank=True) + registration_date = models.DateField(null=True, blank=True) + candidate_support = models.CharField(max_length=20, choices=SUPPORT_CHOICES, default='unknown') + yard_sign = models.CharField(max_length=20, choices=YARD_SIGN_CHOICES, default='none') + + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.first_name} {self.last_name}" + +class VotingRecord(models.Model): + voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='voting_records') + election_date = models.DateField() + election_description = models.CharField(max_length=255) + primary_party = models.CharField(max_length=100, blank=True) + + def __str__(self): + return f"{self.voter} - {self.election_description}" + +class Event(models.Model): + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='events') + date = models.DateField() + event_type = models.CharField(max_length=100) + description = models.TextField(blank=True) + + def __str__(self): + return f"{self.event_type} on {self.date}" + +class EventParticipation(models.Model): + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='participations') + voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='event_participations') + + def __str__(self): + return f"{self.voter} at {self.event}" + +class Donation(models.Model): + voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='donations') + date = models.DateField() + method = models.ForeignKey(DonationMethod, on_delete=models.SET_NULL, null=True) + amount = models.DecimalField(max_digits=10, decimal_places=2) + + def __str__(self): + return f"{self.voter} - {self.amount} on {self.date}" + +class Interaction(models.Model): + voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='interactions') + type = models.ForeignKey(InteractionType, on_delete=models.SET_NULL, null=True) + date = models.DateField() + description = models.CharField(max_length=255) + notes = models.TextField(blank=True) + + def __str__(self): + return f"{self.voter} - {self.type} on {self.date}" + +class VoterLikelihood(models.Model): + LIKELIHOOD_CHOICES = [ + ('not_likely', 'Not Likely'), + ('somewhat_likely', 'Somewhat Likely'), + ('very_likely', 'Very Likely'), + ] + voter = models.ForeignKey(Voter, on_delete=models.CASCADE, related_name='likelihoods') + election_type = models.ForeignKey(ElectionType, on_delete=models.CASCADE) + likelihood = models.CharField(max_length=20, choices=LIKELIHOOD_CHOICES) + + class Meta: + unique_together = ('voter', 'election_type') + + def __str__(self): + return f"{self.voter} - {self.election_type}: {self.get_likelihood_display()}" \ No newline at end of file diff --git a/core/templates/base.html b/core/templates/base.html index 1e7e5fb..d40c799 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -1,25 +1,67 @@ - - - {% block title %}Knowledge Base{% endblock %} - {% if project_description %} - - - - {% endif %} - {% if project_image_url %} - - - {% endif %} - {% load static %} - - {% block head %}{% endblock %} + + + {% block title %}Grassroots Campaign Manager{% endblock %} + + + + + + + {% load static %} + + + {% if project_description %} + + {% endif %} + + {% block head %}{% endblock %} - - {% block content %}{% endblock %} - + - +
+ {% block content %}{% endblock %} +
+ + + + + + + \ No newline at end of file diff --git a/core/templates/core/index.html b/core/templates/core/index.html index faec813..7be5adc 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,145 +1,64 @@ -{% extends "base.html" %} - -{% block title %}{{ project_name }}{% endblock %} - -{% block head %} - - - - -{% endblock %} +{% extends 'base.html' %} +{% load static %} {% block content %} -
-
-

Analyzing your requirements and generating your app…

-
- Loading… -
-

AppWizzy AI is collecting your requirements and applying the first changes.

-

This page will refresh automatically as the plan is implemented.

-

- Runtime: Django {{ django_version }} · Python {{ python_version }} - — UTC {{ current_time|date:"Y-m-d H:i:s" }} -

-
-
- +
+ {% if not selected_tenant %} +
+
+

Welcome to Grassroots

+

Select a campaign to begin managing your voter database and organizing your efforts.

+ +
+ {% for tenant in tenants %} +
+
+
+

{{ tenant.name }}

+

{{ tenant.description|truncatewords:20 }}

+ Select Campaign +
+
+
+ {% empty %} +
+
No campaigns found. Please create one in the admin.
+
+ {% endfor %} +
+
+
+ {% else %} +
+
+
+

Dashboard: {{ selected_tenant.name }}

+ Switch Campaign +
+
+ +
+
+
+
Voters Registry
+

{{ selected_tenant.voters.count }}

+ View Registry +
+
+
+ +
+
+
+
Quick Voter Search
+
+ + +
+
+
+
+
+ {% endif %} +
{% endblock %} \ No newline at end of file diff --git a/core/templates/core/voter_detail.html b/core/templates/core/voter_detail.html new file mode 100644 index 0000000..25aaebb --- /dev/null +++ b/core/templates/core/voter_detail.html @@ -0,0 +1,782 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
+ + + + +
+
+
+
+
+ {{ voter.first_name|first }}{{ voter.last_name|first }} +
+
+
+
+

{{ voter.first_name }} {{ voter.last_name }}

+ +
+

+ {{ voter.address|default:"No address on file" }} +

+
+ Voter ID: {{ voter.voter_id|default:"N/A" }} + District: {{ voter.district|default:"-" }} + Precinct: {{ voter.precinct|default:"-" }} +
+
+
+ {% if voter.candidate_support == 'supporting' %} +
Supporting
+ {% elif voter.candidate_support == 'not_supporting' %} +
Not Supporting
+ {% else %} +
Unknown Support
+ {% endif %} +
+
+
+
+ +
+ +
+
+
+
Contact Information
+
+
+
    +
  • + + {{ voter.email|default:"N/A" }} +
  • +
  • + + {{ voter.phone|default:"N/A" }} +
  • +
  • + + {{ voter.registration_date|date:"M d, Y"|default:"Unknown" }} +
  • +
+
+
+ +
+
+
Likelihood to Vote
+ +
+
+ {% for likelihood in likelihoods %} +
+
+ {{ likelihood.election_type.name }} + {% if likelihood.likelihood == 'very_likely' %} + Very Likely + {% elif likelihood.likelihood == 'somewhat_likely' %} + Somewhat Likely + {% else %} + Not Likely + {% endif %} +
+
+ + +
+
+ {% empty %} +

No likelihood data available.

+ {% endfor %} +
+
+ +
+
+
Campaign Assets
+
+
+
+
+ Yard Sign Status +
+
+ {% if voter.yard_sign == 'has' %} + Has Sign + {% elif voter.yard_sign == 'wants' %} + Wants Sign + {% else %} + None + {% endif %} +
+
+
+
+
+ + +
+ + + +
+ +
+
+
+
Interaction History
+ +
+
+ + + + + + + + + + + + {% for interaction in interactions %} + + + + + + + + {% empty %} + + {% endfor %} + +
DateTypeDescriptionNotesActions
{{ interaction.date|date:"M d, Y" }}{{ interaction.type.name }}{{ interaction.description }}{{ interaction.notes|truncatechars:30 }} + + +
No interactions recorded.
+
+
+
+ + +
+
+
+ + + + + + + + + + {% for record in voting_records %} + + + + + + {% empty %} + + {% endfor %} + +
Election DateDescriptionParty
{{ record.election_date|date:"M d, Y" }}{{ record.election_description }}{{ record.primary_party|default:"-" }}
No voting records found.
+
+
+
+ + +
+
+
+
Donation History
+ +
+
+ + + + + + + + + + + {% for donation in donations %} + + + + + + + {% empty %} + + {% endfor %} + +
DateMethodAmountActions
{{ donation.date|date:"M d, Y" }}{{ donation.method.name }}${{ donation.amount }} + + +
No donations recorded.
+
+
+
+ + +
+
+
+
Event Participation
+ +
+
+ + + + + + + + + + + {% for participation in event_participations %} + + + + + + + {% empty %} + + {% endfor %} + +
DateEvent TypeDescriptionActions
{{ participation.event.date|date:"M d, Y" }}{{ participation.event.event_type }}{{ participation.event.description|truncatechars:60 }} + + +
No event participations found.
+
+
+
+
+
+
+
+ + + + + + + + +{% for interaction in interactions %} + + + + +{% endfor %} + + + + + +{% for donation in donations %} + + + + +{% endfor %} + + + + + +{% for likelihood in likelihoods %} + + + + +{% endfor %} + + + + + +{% for participation in event_participations %} + + + + +{% endfor %} + + +{% endblock %} diff --git a/core/templates/core/voter_list.html b/core/templates/core/voter_list.html new file mode 100644 index 0000000..b4010e1 --- /dev/null +++ b/core/templates/core/voter_list.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Voter Registry

+ + Add New Voter +
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ + + + + + + + + + + + + {% for voter in voters %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
Voter IDNameDistrictSupportYard SignActions
{{ voter.voter_id|default:"N/A" }} +
{{ voter.first_name }} {{ voter.last_name }}
+
{{ voter.address|truncatechars:40 }}
+
{{ voter.district|default:"-" }} + {% if voter.candidate_support == 'supporting' %} + Supporting + {% elif voter.candidate_support == 'not_supporting' %} + Not Supporting + {% else %} + Unknown + {% endif %} + + {% if voter.yard_sign == 'has' %} + Has Sign + {% elif voter.yard_sign == 'wants' %} + Wants Sign + {% else %} + None + {% endif %} + + View 360° +
+

No voters found matching your search.

+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/core/urls.py b/core/urls.py index 6299e3d..9712077 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,26 @@ from django.urls import path - -from .views import home +from . import views urlpatterns = [ - path("", home, name="home"), + path('', views.index, name='index'), + path('select-campaign//', views.select_campaign, name='select_campaign'), + path('voters/', views.voter_list, name='voter_list'), + path('voters//', views.voter_detail, name='voter_detail'), + path('voters//edit/', views.voter_edit, name='voter_edit'), + + path('voters//interaction/add/', views.add_interaction, name='add_interaction'), + path('interaction//edit/', views.edit_interaction, name='edit_interaction'), + path('interaction//delete/', views.delete_interaction, name='delete_interaction'), + + path('voters//donation/add/', views.add_donation, name='add_donation'), + path('donation//edit/', views.edit_donation, name='edit_donation'), + path('donation//delete/', views.delete_donation, name='delete_donation'), + + path('voters//likelihood/add/', views.add_likelihood, name='add_likelihood'), + path('likelihood//edit/', views.edit_likelihood, name='edit_likelihood'), + path('likelihood//delete/', views.delete_likelihood, name='delete_likelihood'), + + path('voters//event-participation/add/', views.add_event_participation, name='add_event_participation'), + path('event-participation//edit/', views.edit_event_participation, name='edit_event_participation'), + path('event-participation//delete/', views.delete_event_participation, name='delete_event_participation'), ] diff --git a/core/views.py b/core/views.py index c9aed12..90f37ba 100644 --- a/core/views.py +++ b/core/views.py @@ -1,25 +1,270 @@ -import os -import platform +from django.shortcuts import render, redirect, get_object_or_404 +from django.db.models import Q +from django.contrib import messages +from .models import Voter, Tenant, Interaction, Donation, VoterLikelihood, EventParticipation, Event +from .forms import VoterForm, InteractionForm, DonationForm, VoterLikelihoodForm, EventParticipationForm -from django import get_version as django_version -from django.shortcuts import render -from django.utils import timezone - - -def home(request): - """Render the landing screen with loader and environment details.""" - host_name = request.get_host().lower() - agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" - now = timezone.now() +def index(request): + """ + Main landing page for Grassroots Campaign Manager. + Displays a list of campaigns if the user is logged in but hasn't selected one. + """ + tenants = Tenant.objects.all() + selected_tenant_id = request.session.get('tenant_id') + selected_tenant = None + if selected_tenant_id: + selected_tenant = Tenant.objects.filter(id=selected_tenant_id).first() context = { - "project_name": "New Style", - "agent_brand": agent_brand, - "django_version": django_version(), - "python_version": platform.python_version(), - "current_time": now, - "host_name": host_name, - "project_description": os.getenv("PROJECT_DESCRIPTION", ""), - "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), + 'tenants': tenants, + 'selected_tenant': selected_tenant, } - return render(request, "core/index.html", context) + return render(request, 'core/index.html', context) + +def select_campaign(request, tenant_id): + """ + Sets the selected campaign in the session. + """ + tenant = get_object_or_404(Tenant, id=tenant_id) + request.session['tenant_id'] = tenant.id + messages.success(request, f"You are now managing: {tenant.name}") + return redirect('index') + +def voter_list(request): + """ + List and search voters. Restricted to selected tenant. + """ + 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) + query = request.GET.get('q') + voters = Voter.objects.filter(tenant=tenant) + + if query: + query = query.strip() + search_filter = Q(first_name__icontains=query) | Q(last_name__icontains=query) | Q(voter_id__icontains=query) + + if " " in query: + parts = query.split() + if len(parts) >= 2: + first_part = parts[0] + last_part = " ".join(parts[1:]) + search_filter |= Q(first_name__icontains=first_part, last_name__icontains=last_part) + + voters = voters.filter(search_filter) + + context = { + 'voters': voters, + 'query': query, + 'selected_tenant': tenant + } + return render(request, 'core/voter_list.html', context) + +def voter_detail(request, voter_id): + """ + 360-degree view of a voter. + """ + 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) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + context = { + 'voter': voter, + 'selected_tenant': tenant, + 'voting_records': voter.voting_records.all().order_by('-election_date'), + 'donations': voter.donations.all().order_by('-date'), + 'interactions': voter.interactions.all().order_by('-date'), + 'event_participations': voter.event_participations.all().order_by('-event__date'), + 'likelihoods': voter.likelihoods.all(), + 'voter_form': VoterForm(instance=voter), + 'interaction_form': InteractionForm(tenant=tenant), + 'donation_form': DonationForm(tenant=tenant), + 'likelihood_form': VoterLikelihoodForm(tenant=tenant), + 'event_participation_form': EventParticipationForm(tenant=tenant), + } + return render(request, 'core/voter_detail.html', context) + +def voter_edit(request, voter_id): + """ + Update voter core demographics. + """ + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + form = VoterForm(request.POST, instance=voter) + if form.is_valid(): + form.save() + messages.success(request, "Voter profile updated successfully.") + return redirect('voter_detail', voter_id=voter.id) + return redirect('voter_detail', voter_id=voter.id) + +def add_interaction(request, voter_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + form = InteractionForm(request.POST, tenant=tenant) + if form.is_valid(): + interaction = form.save(commit=False) + interaction.voter = voter + interaction.save() + messages.success(request, "Interaction added.") + return redirect('voter_detail', voter_id=voter.id) + +def edit_interaction(request, interaction_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + interaction = get_object_or_404(Interaction, id=interaction_id, voter__tenant=tenant) + + if request.method == 'POST': + form = InteractionForm(request.POST, instance=interaction, tenant=tenant) + if form.is_valid(): + form.save() + messages.success(request, "Interaction updated.") + return redirect('voter_detail', voter_id=interaction.voter.id) + +def delete_interaction(request, interaction_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + interaction = get_object_or_404(Interaction, id=interaction_id, voter__tenant=tenant) + voter_id = interaction.voter.id + + if request.method == 'POST': + interaction.delete() + messages.success(request, "Interaction deleted.") + return redirect('voter_detail', voter_id=voter_id) + +def add_donation(request, voter_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + form = DonationForm(request.POST, tenant=tenant) + if form.is_valid(): + donation = form.save(commit=False) + donation.voter = voter + donation.save() + messages.success(request, "Donation recorded.") + return redirect('voter_detail', voter_id=voter.id) + +def edit_donation(request, donation_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + donation = get_object_or_404(Donation, id=donation_id, voter__tenant=tenant) + + if request.method == 'POST': + form = DonationForm(request.POST, instance=donation, tenant=tenant) + if form.is_valid(): + form.save() + messages.success(request, "Donation updated.") + return redirect('voter_detail', voter_id=donation.voter.id) + +def delete_donation(request, donation_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + donation = get_object_or_404(Donation, id=donation_id, voter__tenant=tenant) + voter_id = donation.voter.id + + if request.method == 'POST': + donation.delete() + messages.success(request, "Donation deleted.") + return redirect('voter_detail', voter_id=voter_id) + +def add_likelihood(request, voter_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + form = VoterLikelihoodForm(request.POST, tenant=tenant) + if form.is_valid(): + likelihood = form.save(commit=False) + likelihood.voter = voter + # Handle potential duplicate election_type + VoterLikelihood.objects.filter(voter=voter, election_type=likelihood.election_type).delete() + likelihood.save() + messages.success(request, "Likelihood updated.") + return redirect('voter_detail', voter_id=voter.id) + +def edit_likelihood(request, likelihood_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + likelihood = get_object_or_404(VoterLikelihood, id=likelihood_id, voter__tenant=tenant) + + if request.method == 'POST': + form = VoterLikelihoodForm(request.POST, instance=likelihood, tenant=tenant) + if form.is_valid(): + # Check for conflict with another record of same election_type + election_type = form.cleaned_data['election_type'] + if VoterLikelihood.objects.filter(voter=likelihood.voter, election_type=election_type).exclude(id=likelihood.id).exists(): + VoterLikelihood.objects.filter(voter=likelihood.voter, election_type=election_type).exclude(id=likelihood.id).delete() + form.save() + messages.success(request, "Likelihood updated.") + return redirect('voter_detail', voter_id=likelihood.voter.id) + +def delete_likelihood(request, likelihood_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + likelihood = get_object_or_404(VoterLikelihood, id=likelihood_id, voter__tenant=tenant) + voter_id = likelihood.voter.id + + if request.method == 'POST': + likelihood.delete() + messages.success(request, "Likelihood record deleted.") + return redirect('voter_detail', voter_id=voter_id) + +def add_event_participation(request, voter_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + voter = get_object_or_404(Voter, id=voter_id, tenant=tenant) + + if request.method == 'POST': + form = EventParticipationForm(request.POST, tenant=tenant) + if form.is_valid(): + participation = form.save(commit=False) + participation.voter = voter + # Avoid duplicate participation + if not EventParticipation.objects.filter(voter=voter, event=participation.event).exists(): + participation.save() + messages.success(request, "Event participation added.") + else: + messages.warning(request, "Voter is already participating in this event.") + return redirect('voter_detail', voter_id=voter.id) + +def edit_event_participation(request, participation_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + participation = get_object_or_404(EventParticipation, id=participation_id, voter__tenant=tenant) + + if request.method == 'POST': + form = EventParticipationForm(request.POST, instance=participation, tenant=tenant) + if form.is_valid(): + event = form.cleaned_data['event'] + if EventParticipation.objects.filter(voter=participation.voter, event=event).exclude(id=participation.id).exists(): + messages.warning(request, "Voter is already participating in that event.") + else: + form.save() + messages.success(request, "Event participation updated.") + return redirect('voter_detail', voter_id=participation.voter.id) + +def delete_event_participation(request, participation_id): + selected_tenant_id = request.session.get('tenant_id') + tenant = get_object_or_404(Tenant, id=selected_tenant_id) + participation = get_object_or_404(EventParticipation, id=participation_id, voter__tenant=tenant) + voter_id = participation.voter.id + + if request.method == 'POST': + participation.delete() + messages.success(request, "Event participation removed.") + return redirect('voter_detail', voter_id=voter_id) \ No newline at end of file diff --git a/db/config.php b/db/config.php index 586c03f..40e9d76 100644 --- a/db/config.php +++ b/db/config.php @@ -1,9 +1,9 @@