Autosave: 20260131-125943
This commit is contained in:
parent
181163257f
commit
f7bc2da356
Binary file not shown.
Binary file not shown.
13
config/csrf_settings.tmp
Normal file
13
config/csrf_settings.tmp
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
|
"https://grassrootscrm.flatlogic.app",
|
||||||
|
]
|
||||||
|
CSRF_TRUSTED_ORIGINS += [
|
||||||
|
origin for origin in [
|
||||||
|
os.getenv("HOST_FQDN", ""),
|
||||||
|
os.getenv("CSRF_TRUSTED_ORIGIN", "")
|
||||||
|
] if origin
|
||||||
|
]
|
||||||
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
|
f"https://{host}" if not host.startswith(("http://", "https://")) else host
|
||||||
|
for host in CSRF_TRUSTED_ORIGINS
|
||||||
|
]
|
||||||
@ -23,10 +23,14 @@ DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
|
|||||||
ALLOWED_HOSTS = [
|
ALLOWED_HOSTS = [
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
"localhost",
|
"localhost",
|
||||||
|
"grassrootscrm.flatlogic.app",
|
||||||
os.getenv("HOST_FQDN", ""),
|
os.getenv("HOST_FQDN", ""),
|
||||||
]
|
]
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = [
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
|
"https://grassrootscrm.flatlogic.app",
|
||||||
|
]
|
||||||
|
CSRF_TRUSTED_ORIGINS += [
|
||||||
origin for origin in [
|
origin for origin in [
|
||||||
os.getenv("HOST_FQDN", ""),
|
os.getenv("HOST_FQDN", ""),
|
||||||
os.getenv("CSRF_TRUSTED_ORIGIN", "")
|
os.getenv("CSRF_TRUSTED_ORIGIN", "")
|
||||||
@ -64,6 +68,7 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'core.middleware.LoginRequiredMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
|
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
|
||||||
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
@ -181,3 +186,6 @@ if EMAIL_USE_SSL:
|
|||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
GOOGLE_MAPS_API_KEY = os.getenv("GOOGLE_MAPS_API_KEY", "AIzaSyAluZTEjH-RSiGJUHnfrSqWbcAXCGzGOq4")
|
GOOGLE_MAPS_API_KEY = os.getenv("GOOGLE_MAPS_API_KEY", "AIzaSyAluZTEjH-RSiGJUHnfrSqWbcAXCGzGOq4")
|
||||||
|
LOGIN_URL = 'login'
|
||||||
|
LOGIN_REDIRECT_URL = 'index'
|
||||||
|
LOGOUT_REDIRECT_URL = 'login'
|
||||||
|
|||||||
@ -22,6 +22,7 @@ from django.conf.urls.static import static
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("", include("core.urls")),
|
path("", include("core.urls")),
|
||||||
|
path("accounts/", include("django.contrib.auth.urls")),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/middleware.cpython-311.pyc
Normal file
BIN
core/__pycache__/middleware.cpython-311.pyc
Normal file
Binary file not shown.
337
core/admin.py
337
core/admin.py
@ -21,7 +21,7 @@ from .models import (
|
|||||||
from .forms import (
|
from .forms import (
|
||||||
VoterImportForm, EventImportForm, EventParticipationImportForm,
|
VoterImportForm, EventImportForm, EventParticipationImportForm,
|
||||||
DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm,
|
DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm,
|
||||||
VolunteerImportForm
|
VolunteerImportForm, VotingRecordImportForm
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -108,6 +108,13 @@ VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [
|
|||||||
('likelihood', 'Likelihood'),
|
('likelihood', 'Likelihood'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
VOTING_RECORD_MAPPABLE_FIELDS = [
|
||||||
|
('voter_id', 'Voter ID'),
|
||||||
|
('election_date', 'Election Date'),
|
||||||
|
('election_description', 'Election Description'),
|
||||||
|
('primary_party', 'Primary Party'),
|
||||||
|
]
|
||||||
|
|
||||||
class BaseImportAdminMixin:
|
class BaseImportAdminMixin:
|
||||||
def download_errors(self, request):
|
def download_errors(self, request):
|
||||||
logger.info(f"download_errors called for {self.model._meta.model_name}")
|
logger.info(f"download_errors called for {self.model._meta.model_name}")
|
||||||
@ -131,6 +138,15 @@ class BaseImportAdminMixin:
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def chunk_reader(self, reader, size):
|
||||||
|
chunk = []
|
||||||
|
for row in reader:
|
||||||
|
chunk.append(row)
|
||||||
|
if len(chunk) == size:
|
||||||
|
yield chunk
|
||||||
|
chunk = []
|
||||||
|
if chunk:
|
||||||
|
yield chunk
|
||||||
class TenantUserRoleInline(admin.TabularInline):
|
class TenantUserRoleInline(admin.TabularInline):
|
||||||
model = TenantUserRole
|
model = TenantUserRole
|
||||||
extra = 1
|
extra = 1
|
||||||
@ -323,25 +339,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
yard_sign_choices = dict(Voter.YARD_SIGN_CHOICES)
|
yard_sign_choices = dict(Voter.YARD_SIGN_CHOICES)
|
||||||
yard_sign_reverse = {v.lower(): k for k, v in yard_sign_choices.items()}
|
yard_sign_reverse = {v.lower(): k for k, v in yard_sign_choices.items()}
|
||||||
window_sticker_choices = dict(Voter.WINDOW_STICKER_CHOICES)
|
window_sticker_choices = dict(Voter.WINDOW_STICKER_CHOICES)
|
||||||
window_sticker_reverse = {v.lower(): k for k, v in window_sticker_choices.items()}
|
|
||||||
phone_type_choices = dict(Voter.PHONE_TYPE_CHOICES)
|
|
||||||
phone_type_reverse = {v.lower(): k for k, v in phone_type_choices.items()}
|
|
||||||
|
|
||||||
valid_fields = {f.name for f in Voter._meta.get_fields()}
|
|
||||||
mapped_fields = {f for f in mapping.keys() if f in valid_fields}
|
|
||||||
# Ensure derived/special fields are in update_fields
|
|
||||||
update_fields = list(mapped_fields | {"address", "phone", "secondary_phone", "secondary_phone_type", "longitude", "latitude"})
|
|
||||||
if "voter_id" in update_fields: update_fields.remove("voter_id")
|
|
||||||
|
|
||||||
def chunk_reader(reader, size):
|
|
||||||
chunk = []
|
|
||||||
for row in reader:
|
|
||||||
chunk.append(row)
|
|
||||||
if len(chunk) == size:
|
|
||||||
yield chunk
|
|
||||||
chunk = []
|
|
||||||
if chunk:
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
with open(file_path, "r", encoding="utf-8-sig") as f:
|
with open(file_path, "r", encoding="utf-8-sig") as f:
|
||||||
reader = csv.DictReader(f)
|
reader = csv.DictReader(f)
|
||||||
@ -352,7 +350,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
print(f"DEBUG: Starting voter import. Tenant: {tenant.name}. Voter ID column: {v_id_col}")
|
print(f"DEBUG: Starting voter import. Tenant: {tenant.name}. Voter ID column: {v_id_col}")
|
||||||
|
|
||||||
total_processed = 0
|
total_processed = 0
|
||||||
for chunk_index, chunk in enumerate(chunk_reader(reader, batch_size)):
|
for chunk_index, chunk in enumerate(self.chunk_reader(reader, batch_size)):
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)]
|
voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)]
|
||||||
existing_voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids)}
|
existing_voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids)}
|
||||||
@ -917,11 +915,13 @@ class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
class VolunteerEventAdmin(admin.ModelAdmin):
|
class VolunteerEventAdmin(admin.ModelAdmin):
|
||||||
list_display = ('volunteer', 'event', 'role')
|
list_display = ('volunteer', 'event', 'role')
|
||||||
list_filter = ('event__tenant', 'event', 'role')
|
list_filter = ('event__tenant', 'event', 'role')
|
||||||
|
autocomplete_fields = ["volunteer", "event"]
|
||||||
|
|
||||||
@admin.register(EventParticipation)
|
@admin.register(EventParticipation)
|
||||||
class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||||
list_display = ('voter', 'event', 'participation_status')
|
list_display = ('voter', 'event', 'participation_status')
|
||||||
list_filter = ('event__tenant', 'event', 'participation_status')
|
list_filter = ('event__tenant', 'event', 'participation_status')
|
||||||
|
autocomplete_fields = ["voter", "event"]
|
||||||
change_list_template = "admin/eventparticipation_change_list.html"
|
change_list_template = "admin/eventparticipation_change_list.html"
|
||||||
|
|
||||||
def get_urls(self):
|
def get_urls(self):
|
||||||
@ -1122,6 +1122,7 @@ class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
list_display = ('id', 'voter', 'date', 'amount', 'method')
|
list_display = ('id', 'voter', 'date', 'amount', 'method')
|
||||||
list_filter = ('voter__tenant', 'date', 'method')
|
list_filter = ('voter__tenant', 'date', 'method')
|
||||||
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id')
|
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id')
|
||||||
|
autocomplete_fields = ["voter"]
|
||||||
change_list_template = "admin/donation_change_list.html"
|
change_list_template = "admin/donation_change_list.html"
|
||||||
|
|
||||||
def get_urls(self):
|
def get_urls(self):
|
||||||
@ -1311,6 +1312,7 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
list_display = ('id', 'voter', 'volunteer', 'type', 'date', 'description')
|
list_display = ('id', 'voter', 'volunteer', 'type', 'date', 'description')
|
||||||
list_filter = ('voter__tenant', 'type', 'date', 'volunteer')
|
list_filter = ('voter__tenant', 'type', 'date', 'volunteer')
|
||||||
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description', 'volunteer__first_name', 'volunteer__last_name')
|
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'description', 'volunteer__first_name', 'volunteer__last_name')
|
||||||
|
autocomplete_fields = ["voter", "volunteer"]
|
||||||
change_list_template = "admin/interaction_change_list.html"
|
change_list_template = "admin/interaction_change_list.html"
|
||||||
|
|
||||||
def get_urls(self):
|
def get_urls(self):
|
||||||
@ -1512,6 +1514,7 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
list_display = ('id', 'voter', 'election_type', 'likelihood')
|
list_display = ('id', 'voter', 'election_type', 'likelihood')
|
||||||
list_filter = ('voter__tenant', 'election_type', 'likelihood')
|
list_filter = ('voter__tenant', 'election_type', 'likelihood')
|
||||||
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id')
|
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id')
|
||||||
|
autocomplete_fields = ["voter"]
|
||||||
change_list_template = "admin/voterlikelihood_change_list.html"
|
change_list_template = "admin/voterlikelihood_change_list.html"
|
||||||
|
|
||||||
def get_urls(self):
|
def get_urls(self):
|
||||||
@ -1613,15 +1616,6 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
# Pre-fetch election types for this tenant
|
# Pre-fetch election types for this tenant
|
||||||
election_types = {et.name: et for et in ElectionType.objects.filter(tenant=tenant)}
|
election_types = {et.name: et for et in ElectionType.objects.filter(tenant=tenant)}
|
||||||
|
|
||||||
def chunk_reader(reader, size):
|
|
||||||
chunk = []
|
|
||||||
for row in reader:
|
|
||||||
chunk.append(row)
|
|
||||||
if len(chunk) == size:
|
|
||||||
yield chunk
|
|
||||||
chunk = []
|
|
||||||
if chunk:
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
with open(file_path, "r", encoding="utf-8-sig") as f:
|
with open(file_path, "r", encoding="utf-8-sig") as f:
|
||||||
reader = csv.DictReader(f)
|
reader = csv.DictReader(f)
|
||||||
@ -1635,7 +1629,7 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
print(f"DEBUG: Starting likelihood import. Tenant: {tenant.name}")
|
print(f"DEBUG: Starting likelihood import. Tenant: {tenant.name}")
|
||||||
|
|
||||||
total_processed = 0
|
total_processed = 0
|
||||||
for chunk in chunk_reader(reader, batch_size):
|
for chunk in self.chunk_reader(reader, batch_size):
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)]
|
voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)]
|
||||||
et_names = [str(row.get(et_col)).strip() for row in chunk if row.get(et_col)]
|
et_names = [str(row.get(et_col)).strip() for row in chunk if row.get(et_col)]
|
||||||
@ -1751,7 +1745,7 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
|
|
||||||
success_msg = f"Import complete: {count} likelihoods created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped with no changes, {skipped_no_id} skipped missing data, {errors} errors)"
|
success_msg = f"Import complete: {count} likelihoods created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped with no changes, {skipped_no_id} skipped missing data, {errors} errors)"
|
||||||
self.message_user(success_msg)
|
self.message_user(request, success_msg)
|
||||||
|
|
||||||
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows
|
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows
|
||||||
request.session.modified = True
|
request.session.modified = True
|
||||||
@ -1806,4 +1800,283 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
|||||||
class CampaignSettingsAdmin(admin.ModelAdmin):
|
class CampaignSettingsAdmin(admin.ModelAdmin):
|
||||||
list_display = ('tenant', 'donation_goal', 'twilio_from_number')
|
list_display = ('tenant', 'donation_goal', 'twilio_from_number')
|
||||||
list_filter = ('tenant',)
|
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')
|
||||||
|
|
||||||
|
@admin.register(VotingRecord)
|
||||||
|
class VotingRecordAdmin(BaseImportAdminMixin, admin.ModelAdmin):
|
||||||
|
list_display = ('voter', 'election_date', 'election_description', 'primary_party')
|
||||||
|
list_filter = ('voter__tenant', 'election_date', 'primary_party')
|
||||||
|
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id', 'election_description')
|
||||||
|
autocomplete_fields = ["voter"]
|
||||||
|
change_list_template = "admin/votingrecord_change_list.html"
|
||||||
|
|
||||||
|
def changelist_view(self, request, extra_context=None):
|
||||||
|
extra_context = extra_context or {}
|
||||||
|
from core.models import Tenant
|
||||||
|
extra_context["tenants"] = Tenant.objects.all()
|
||||||
|
return super().changelist_view(request, extra_context=extra_context)
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
urls = super().get_urls()
|
||||||
|
my_urls = [
|
||||||
|
path('download-errors/', self.admin_site.admin_view(self.download_errors), name='votingrecord-download-errors'),
|
||||||
|
path('import-voting-records/', self.admin_site.admin_view(self.import_voting_records), name='import-voting-records'),
|
||||||
|
]
|
||||||
|
return my_urls + urls
|
||||||
|
|
||||||
|
def import_voting_records(self, request):
|
||||||
|
if request.method == "POST":
|
||||||
|
if "_preview" in request.POST:
|
||||||
|
file_path = request.POST.get('file_path')
|
||||||
|
tenant_id = request.POST.get('tenant')
|
||||||
|
tenant = Tenant.objects.get(id=tenant_id)
|
||||||
|
mapping = {k: request.POST.get(f"map_{k}") for k, _ in VOTING_RECORD_MAPPABLE_FIELDS if request.POST.get(f"map_{k}")}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8-sig') as f:
|
||||||
|
# Optimization: Fast count and partial preview
|
||||||
|
total_count = sum(1 for line in f) - 1
|
||||||
|
f.seek(0)
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
preview_rows = []
|
||||||
|
voter_ids_for_preview = set()
|
||||||
|
|
||||||
|
v_id_col = mapping.get('voter_id')
|
||||||
|
ed_col = mapping.get('election_date')
|
||||||
|
desc_col = mapping.get('election_description')
|
||||||
|
|
||||||
|
if not v_id_col or not ed_col or not desc_col:
|
||||||
|
raise ValueError("Missing mapping for Voter ID, Election Date, or Description")
|
||||||
|
|
||||||
|
for i, row in enumerate(reader):
|
||||||
|
if i < 10:
|
||||||
|
preview_rows.append(row)
|
||||||
|
v_id = row.get(v_id_col)
|
||||||
|
if v_id: voter_ids_for_preview.add(str(v_id).strip())
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
existing_records = set(VotingRecord.objects.filter(
|
||||||
|
voter__tenant=tenant,
|
||||||
|
voter__voter_id__in=voter_ids_for_preview
|
||||||
|
).values_list("voter__voter_id", "election_date", "election_description"))
|
||||||
|
|
||||||
|
preview_data = []
|
||||||
|
for row in preview_rows:
|
||||||
|
v_id = str(row.get(v_id_col, '')).strip()
|
||||||
|
e_date_raw = row.get(ed_col)
|
||||||
|
e_desc = str(row.get(desc_col, '')).strip()
|
||||||
|
|
||||||
|
# Try to parse date for accurate comparison in preview
|
||||||
|
e_date = None
|
||||||
|
if e_date_raw:
|
||||||
|
for fmt in ["%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d"]:
|
||||||
|
try:
|
||||||
|
e_date = datetime.strptime(str(e_date_raw).strip(), fmt).date()
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
action = "update" if (v_id, e_date, e_desc) in existing_records else "create"
|
||||||
|
preview_data.append({
|
||||||
|
"action": action,
|
||||||
|
"identifier": f"Voter: {v_id}, Election: {e_desc}",
|
||||||
|
"details": f"Date: {e_date or e_date_raw}"
|
||||||
|
})
|
||||||
|
|
||||||
|
context = self.admin_site.each_context(request)
|
||||||
|
context.update({
|
||||||
|
"title": "Import Preview",
|
||||||
|
"total_count": total_count,
|
||||||
|
"create_count": "N/A",
|
||||||
|
"update_count": "N/A",
|
||||||
|
"preview_data": preview_data,
|
||||||
|
"mapping": mapping,
|
||||||
|
"file_path": file_path,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"action_url": request.path,
|
||||||
|
"opts": self.model._meta,
|
||||||
|
})
|
||||||
|
return render(request, "admin/import_preview.html", context)
|
||||||
|
except Exception as e:
|
||||||
|
self.message_user(request, f"Error processing preview: {e}", level=messages.ERROR)
|
||||||
|
return redirect("..")
|
||||||
|
|
||||||
|
elif "_import" in request.POST:
|
||||||
|
file_path = request.POST.get('file_path')
|
||||||
|
tenant_id = request.POST.get('tenant')
|
||||||
|
tenant = Tenant.objects.get(id=tenant_id)
|
||||||
|
mapping = {k: request.POST.get(f"map_{k}") for k, _ in VOTING_RECORD_MAPPABLE_FIELDS if request.POST.get(f"map_{k}")}
|
||||||
|
|
||||||
|
try:
|
||||||
|
count = 0
|
||||||
|
created_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
skipped_no_change = 0
|
||||||
|
skipped_no_id = 0
|
||||||
|
errors = 0
|
||||||
|
failed_rows = []
|
||||||
|
batch_size = 500
|
||||||
|
|
||||||
|
with open(file_path, "r", encoding="utf-8-sig") as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
v_id_col = mapping.get("voter_id")
|
||||||
|
ed_col = mapping.get("election_date")
|
||||||
|
desc_col = mapping.get("election_description")
|
||||||
|
party_col = mapping.get("primary_party")
|
||||||
|
|
||||||
|
if not v_id_col or not ed_col or not desc_col:
|
||||||
|
raise ValueError("Missing mapping for Voter ID, Election Date, or Description")
|
||||||
|
|
||||||
|
print(f"DEBUG: Starting voting record import. Tenant: {tenant.name}")
|
||||||
|
|
||||||
|
total_processed = 0
|
||||||
|
for chunk in self.chunk_reader(reader, batch_size):
|
||||||
|
with transaction.atomic():
|
||||||
|
voter_ids = [str(row.get(v_id_col)).strip() for row in chunk if row.get(v_id_col)]
|
||||||
|
|
||||||
|
# Fetch existing voters
|
||||||
|
voters = {v.voter_id: v for v in Voter.objects.filter(tenant=tenant, voter_id__in=voter_ids).only("id", "voter_id")}
|
||||||
|
|
||||||
|
# Fetch existing records
|
||||||
|
existing_records = {
|
||||||
|
(vr.voter.voter_id, vr.election_date, vr.election_description): vr
|
||||||
|
for vr in VotingRecord.objects.filter(
|
||||||
|
voter__tenant=tenant,
|
||||||
|
voter__voter_id__in=voter_ids
|
||||||
|
).select_related("voter")
|
||||||
|
}
|
||||||
|
|
||||||
|
to_create = []
|
||||||
|
to_update = []
|
||||||
|
processed_in_batch = set()
|
||||||
|
|
||||||
|
for row in chunk:
|
||||||
|
total_processed += 1
|
||||||
|
try:
|
||||||
|
raw_v_id = row.get(v_id_col)
|
||||||
|
raw_ed = row.get(ed_col)
|
||||||
|
raw_desc = row.get(desc_col)
|
||||||
|
party = str(row.get(party_col, '')).strip() if party_col else ""
|
||||||
|
|
||||||
|
if not raw_v_id or not raw_ed or not raw_desc:
|
||||||
|
skipped_no_id += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
v_id = str(raw_v_id).strip()
|
||||||
|
desc = str(raw_desc).strip()
|
||||||
|
|
||||||
|
# Parse date
|
||||||
|
e_date = None
|
||||||
|
val = str(raw_ed).strip()
|
||||||
|
for fmt in ["%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%Y/%m/%d"]:
|
||||||
|
try:
|
||||||
|
e_date = datetime.strptime(val, fmt).date()
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not e_date:
|
||||||
|
row["Import Error"] = f"Invalid date format: {val}"
|
||||||
|
failed_rows.append(row)
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (v_id, e_date, desc) in processed_in_batch:
|
||||||
|
continue
|
||||||
|
processed_in_batch.add((v_id, e_date, desc))
|
||||||
|
|
||||||
|
voter = voters.get(v_id)
|
||||||
|
if not voter:
|
||||||
|
row["Import Error"] = f"Voter {v_id} not found"
|
||||||
|
failed_rows.append(row)
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
vr = existing_records.get((v_id, e_date, desc))
|
||||||
|
created = False
|
||||||
|
if not vr:
|
||||||
|
vr = VotingRecord(voter=voter, election_date=e_date, election_description=desc, primary_party=party)
|
||||||
|
created = True
|
||||||
|
|
||||||
|
if not created and vr.primary_party == party:
|
||||||
|
skipped_no_change += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
vr.primary_party = party
|
||||||
|
|
||||||
|
if created:
|
||||||
|
to_create.append(vr)
|
||||||
|
created_count += 1
|
||||||
|
else:
|
||||||
|
to_update.append(vr)
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"DEBUG: Error importing row {total_processed}: {e}")
|
||||||
|
row["Import Error"] = str(e)
|
||||||
|
failed_rows.append(row)
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
if to_create:
|
||||||
|
VotingRecord.objects.bulk_create(to_create)
|
||||||
|
if to_update:
|
||||||
|
VotingRecord.objects.bulk_update(to_update, ["primary_party"], batch_size=250)
|
||||||
|
|
||||||
|
print(f"DEBUG: Voting record import progress: {total_processed} processed. {count} created/updated. {skipped_no_change} skipped (no change). {skipped_no_id} skipped (no ID/Data). {errors} errors.")
|
||||||
|
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
|
||||||
|
success_msg = f"Import complete: {count} voting records created/updated. ({created_count} new, {updated_count} updated, {skipped_no_change} skipped with no changes, {skipped_no_id} skipped missing data, {errors} errors)"
|
||||||
|
self.message_user(request, success_msg)
|
||||||
|
|
||||||
|
request.session[f"{self.model._meta.model_name}_import_errors"] = failed_rows
|
||||||
|
request.session.modified = True
|
||||||
|
if errors > 0:
|
||||||
|
error_url = reverse("admin:votingrecord-download-errors")
|
||||||
|
self.message_user(request, mark_safe(f"Failed to import {errors} rows. <a href='{error_url}' download>Download failed records</a>"), level=messages.WARNING)
|
||||||
|
return redirect("..")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"DEBUG: Voting record import failed: {e}")
|
||||||
|
self.message_user(request, f"Error processing file: {e}", level=messages.ERROR)
|
||||||
|
return redirect("..")
|
||||||
|
else:
|
||||||
|
form = VotingRecordImportForm(request.POST, request.FILES)
|
||||||
|
if form.is_valid():
|
||||||
|
csv_file = request.FILES['file']
|
||||||
|
tenant = form.cleaned_data['tenant']
|
||||||
|
|
||||||
|
if not csv_file.name.endswith('.csv'):
|
||||||
|
self.message_user(request, "Please upload a CSV file.", level=messages.ERROR)
|
||||||
|
return redirect("..")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:
|
||||||
|
for chunk in csv_file.chunks():
|
||||||
|
tmp.write(chunk)
|
||||||
|
file_path = tmp.name
|
||||||
|
|
||||||
|
with open(file_path, 'r', encoding='utf-8-sig') as f:
|
||||||
|
reader = csv.reader(f)
|
||||||
|
headers = next(reader)
|
||||||
|
|
||||||
|
context = self.admin_site.each_context(request)
|
||||||
|
context.update({
|
||||||
|
'title': "Map Voting Record Fields",
|
||||||
|
'headers': headers,
|
||||||
|
'model_fields': VOTING_RECORD_MAPPABLE_FIELDS,
|
||||||
|
'tenant_id': tenant.id,
|
||||||
|
'file_path': file_path,
|
||||||
|
'action_url': request.path,
|
||||||
|
'opts': self.model._meta,
|
||||||
|
})
|
||||||
|
return render(request, "admin/import_mapping.html", context)
|
||||||
|
else:
|
||||||
|
form = VotingRecordImportForm()
|
||||||
|
|
||||||
|
context = self.admin_site.each_context(request)
|
||||||
|
context['form'] = form
|
||||||
|
context['title'] = "Import Voting Records"
|
||||||
|
context['opts'] = self.model._meta
|
||||||
|
return render(request, "admin/import_csv.html", context)
|
||||||
|
|||||||
1816
core/admin.py.bak
Normal file
1816
core/admin.py.bak
Normal file
File diff suppressed because it is too large
Load Diff
@ -296,3 +296,12 @@ class VolunteerEventAddForm(forms.ModelForm):
|
|||||||
for field in self.fields.values():
|
for field in self.fields.values():
|
||||||
field.widget.attrs.update({'class': 'form-control'})
|
field.widget.attrs.update({'class': 'form-control'})
|
||||||
self.fields['volunteer'].widget.attrs.update({'class': 'form-select'})
|
self.fields['volunteer'].widget.attrs.update({'class': 'form-select'})
|
||||||
|
|
||||||
|
class VotingRecordImportForm(forms.Form):
|
||||||
|
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), label="Campaign")
|
||||||
|
file = forms.FileField(label="Select CSV file")
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['tenant'].widget.attrs.update({'class': 'form-control form-select'})
|
||||||
|
self.fields['file'].widget.attrs.update({'class': 'form-control'})
|
||||||
35
core/middleware.py
Normal file
35
core/middleware.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
class LoginRequiredMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
path = request.path_info
|
||||||
|
|
||||||
|
# Allow access to login, logout, admin, and any other exempted paths
|
||||||
|
# We use try/except in case URLs are not defined yet
|
||||||
|
try:
|
||||||
|
login_url = reverse('login')
|
||||||
|
logout_url = reverse('logout')
|
||||||
|
except:
|
||||||
|
login_url = '/accounts/login/'
|
||||||
|
logout_url = '/accounts/logout/'
|
||||||
|
|
||||||
|
exempt_urls = [
|
||||||
|
login_url,
|
||||||
|
logout_url,
|
||||||
|
'/admin/',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Check if path starts with any of the exempt URLs
|
||||||
|
is_exempt = any(path.startswith(url) for url in exempt_urls)
|
||||||
|
|
||||||
|
if not is_exempt:
|
||||||
|
return redirect(f"{login_url}?next={path}")
|
||||||
|
|
||||||
|
response = self.get_response(request)
|
||||||
|
return response
|
||||||
38
core/templates/admin/votingrecord_change_list.html
Normal file
38
core/templates/admin/votingrecord_change_list.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{% extends "admin/change_list.html" %}
|
||||||
|
{% load i18n admin_urls static admin_list %}
|
||||||
|
|
||||||
|
{% block object-tools-items %}
|
||||||
|
<li>
|
||||||
|
<a href="import-voting-records/" class="addlink">Import Voting Records</a>
|
||||||
|
</li>
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block search %}
|
||||||
|
{{ block.super }}
|
||||||
|
<div class="tenant-filter-container" style="margin: 10px 0; padding: 15px; background: var(--darkened-bg, #f8f9fa); border: 1px solid var(--border-color, #dee2e6); border-radius: 4px; display: flex; align-items: center; color: var(--body-fg, #333);">
|
||||||
|
<label for="tenant-filter-select" style="font-weight: 600; margin-right: 15px; color: var(--body-fg, #333);">Filter by Tenant:</label>
|
||||||
|
<select id="tenant-filter-select" onchange="filterTenant(this.value)" style="padding: 6px 12px; border-radius: 4px; border: 1px solid var(--border-color, #ced4da); background-color: var(--body-bg, #fff); color: var(--body-fg, #333); min-width: 200px;">
|
||||||
|
<option value="" style="background-color: var(--body-bg); color: var(--body-fg);">-- All Tenants --</option>
|
||||||
|
{% for tenant in tenants %}
|
||||||
|
<option value="{{ tenant.id }}" {% if request.GET.voter__tenant__id__exact == tenant.id|stringformat:"s" %}selected{% endif %} style="background-color: var(--body-bg); color: var(--body-fg);">
|
||||||
|
{{ tenant.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function filterTenant(tenantId) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (tenantId) {
|
||||||
|
url.searchParams.set('voter__tenant__id__exact', tenantId);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('voter__tenant__id__exact');
|
||||||
|
}
|
||||||
|
// Reset to page 1 if filtering
|
||||||
|
url.searchParams.delete('p');
|
||||||
|
window.location.href = url.pathname + url.search;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -50,7 +50,13 @@
|
|||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<a href="/admin/" class="btn btn-outline-primary btn-sm me-2">Admin Panel</a>
|
<a href="/admin/" class="btn btn-outline-primary btn-sm me-2">Admin Panel</a>
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<span class="text-muted small me-2">{{ user.username }}</span>
|
<span class="text-muted small me-3">{{ user.username }}</span>
|
||||||
|
<form method="post" action="{% url 'logout' %}" class="d-inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-link nav-link d-inline p-0" style="text-decoration: none;">Logout</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'login' %}" class="btn btn-primary btn-sm">Login</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -111,4 +117,4 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -51,8 +51,7 @@
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Phone</th>
|
<th>Phone</th>
|
||||||
<th>Interests</th>
|
<th class="pe-4">Interests</th>
|
||||||
<th class="pe-4 text-end">Actions</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -68,20 +67,17 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ volunteer.email }}</td>
|
<td>{{ volunteer.email }}</td>
|
||||||
<td>{{ volunteer.phone|default:"-" }}</td>
|
<td>{{ volunteer.phone|default:"-" }}</td>
|
||||||
<td>
|
<td class="pe-4">
|
||||||
{% for interest in volunteer.interests.all %}
|
{% for interest in volunteer.interests.all %}
|
||||||
<span class="badge bg-info-subtle text-info border border-info-subtle">{{ interest.name }}</span>
|
<span class="badge bg-info-subtle text-info border border-info-subtle">{{ interest.name }}</span>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<span class="text-muted small">No interests listed</span>
|
<span class="text-muted small">No interests listed</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
<td class="pe-4 text-end">
|
|
||||||
<a href="{% url 'volunteer_detail' volunteer.id %}" class="btn btn-outline-primary btn-sm">View & Edit</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="text-center py-5 text-muted">
|
<td colspan="5" class="text-center py-5 text-muted">
|
||||||
<p class="mb-0">No volunteers found matching your search.</p>
|
<p class="mb-0">No volunteers found matching your search.</p>
|
||||||
<a href="{% url 'volunteer_add' %}" class="btn btn-link">Add the first volunteer</a>
|
<a href="{% url 'volunteer_add' %}" class="btn btn-link">Add the first volunteer</a>
|
||||||
</td>
|
</td>
|
||||||
@ -208,4 +204,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
52
core/templates/registration/login.html
Normal file
52
core/templates/registration/login.html
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm border-0">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<h2 class="text-center mb-4">Login</h2>
|
||||||
|
<p class="text-muted text-center mb-4">Please log in to access the Grassroots Campaign Manager.</p>
|
||||||
|
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
Your username and password didn't match. Please try again.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if next %}
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
Your account doesn't have access to this page. To proceed,
|
||||||
|
please log in with an account that has access.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
Please log in to see this page.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'login' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_username" class="form-label">Username</label>
|
||||||
|
<input type="text" name="username" autofocus maxlength="150" required id="id_username" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_password" class="form-label">Password</label>
|
||||||
|
<input type="password" name="password" required id="id_password" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">Login</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Loading…
x
Reference in New Issue
Block a user