Autosave: 20260131-125943

This commit is contained in:
Flatlogic Bot 2026-01-31 12:59:43 +00:00
parent 181163257f
commit f7bc2da356
16 changed files with 2289 additions and 42 deletions

13
config/csrf_settings.tmp Normal file
View 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
]

View File

@ -23,10 +23,14 @@ DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
ALLOWED_HOSTS = [
"127.0.0.1",
"localhost",
"grassrootscrm.flatlogic.app",
os.getenv("HOST_FQDN", ""),
]
CSRF_TRUSTED_ORIGINS = [
"https://grassrootscrm.flatlogic.app",
]
CSRF_TRUSTED_ORIGINS += [
origin for origin in [
os.getenv("HOST_FQDN", ""),
os.getenv("CSRF_TRUSTED_ORIGIN", "")
@ -64,6 +68,7 @@ MIDDLEWARE = [
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'core.middleware.LoginRequiredMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
@ -181,3 +186,6 @@ if EMAIL_USE_SSL:
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
GOOGLE_MAPS_API_KEY = os.getenv("GOOGLE_MAPS_API_KEY", "AIzaSyAluZTEjH-RSiGJUHnfrSqWbcAXCGzGOq4")
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'index'
LOGOUT_REDIRECT_URL = 'login'

View File

@ -22,6 +22,7 @@ from django.conf.urls.static import static
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("core.urls")),
path("accounts/", include("django.contrib.auth.urls")),
]
if settings.DEBUG:

Binary file not shown.

View File

@ -21,7 +21,7 @@ from .models import (
from .forms import (
VoterImportForm, EventImportForm, EventParticipationImportForm,
DonationImportForm, InteractionImportForm, VoterLikelihoodImportForm,
VolunteerImportForm
VolunteerImportForm, VotingRecordImportForm
)
logger = logging.getLogger(__name__)
@ -108,6 +108,13 @@ VOTER_LIKELIHOOD_MAPPABLE_FIELDS = [
('likelihood', 'Likelihood'),
]
VOTING_RECORD_MAPPABLE_FIELDS = [
('voter_id', 'Voter ID'),
('election_date', 'Election Date'),
('election_description', 'Election Description'),
('primary_party', 'Primary Party'),
]
class BaseImportAdminMixin:
def download_errors(self, request):
logger.info(f"download_errors called for {self.model._meta.model_name}")
@ -131,6 +138,15 @@ class BaseImportAdminMixin:
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):
model = TenantUserRole
extra = 1
@ -323,25 +339,7 @@ class VoterAdmin(BaseImportAdminMixin, admin.ModelAdmin):
yard_sign_choices = dict(Voter.YARD_SIGN_CHOICES)
yard_sign_reverse = {v.lower(): k for k, v in yard_sign_choices.items()}
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:
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}")
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():
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)}
@ -917,11 +915,13 @@ class VolunteerAdmin(BaseImportAdminMixin, admin.ModelAdmin):
class VolunteerEventAdmin(admin.ModelAdmin):
list_display = ('volunteer', 'event', 'role')
list_filter = ('event__tenant', 'event', 'role')
autocomplete_fields = ["volunteer", "event"]
@admin.register(EventParticipation)
class EventParticipationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
list_display = ('voter', 'event', 'participation_status')
list_filter = ('event__tenant', 'event', 'participation_status')
autocomplete_fields = ["voter", "event"]
change_list_template = "admin/eventparticipation_change_list.html"
def get_urls(self):
@ -1122,6 +1122,7 @@ class DonationAdmin(BaseImportAdminMixin, admin.ModelAdmin):
list_display = ('id', 'voter', 'date', 'amount', 'method')
list_filter = ('voter__tenant', 'date', 'method')
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id')
autocomplete_fields = ["voter"]
change_list_template = "admin/donation_change_list.html"
def get_urls(self):
@ -1311,6 +1312,7 @@ class InteractionAdmin(BaseImportAdminMixin, admin.ModelAdmin):
list_display = ('id', 'voter', 'volunteer', 'type', 'date', 'description')
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')
autocomplete_fields = ["voter", "volunteer"]
change_list_template = "admin/interaction_change_list.html"
def get_urls(self):
@ -1512,6 +1514,7 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
list_display = ('id', 'voter', 'election_type', 'likelihood')
list_filter = ('voter__tenant', 'election_type', 'likelihood')
search_fields = ('voter__first_name', 'voter__last_name', 'voter__voter_id')
autocomplete_fields = ["voter"]
change_list_template = "admin/voterlikelihood_change_list.html"
def get_urls(self):
@ -1613,15 +1616,6 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
# Pre-fetch election types for this 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:
reader = csv.DictReader(f)
@ -1635,7 +1629,7 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
print(f"DEBUG: Starting likelihood import. Tenant: {tenant.name}")
total_processed = 0
for chunk in chunk_reader(reader, batch_size):
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)]
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)
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.modified = True
@ -1806,4 +1800,283 @@ class VoterLikelihoodAdmin(BaseImportAdminMixin, admin.ModelAdmin):
class CampaignSettingsAdmin(admin.ModelAdmin):
list_display = ('tenant', 'donation_goal', 'twilio_from_number')
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

File diff suppressed because it is too large Load Diff

View File

@ -296,3 +296,12 @@ class VolunteerEventAddForm(forms.ModelForm):
for field in self.fields.values():
field.widget.attrs.update({'class': 'form-control'})
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
View 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

View 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 %}

View File

@ -50,7 +50,13 @@
<div class="d-flex align-items-center">
<a href="/admin/" class="btn btn-outline-primary btn-sm me-2">Admin Panel</a>
{% 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 %}
</div>
</div>
@ -111,4 +117,4 @@
});
</script>
</body>
</html>
</html>

View File

@ -51,8 +51,7 @@
<th>Name</th>
<th>Email</th>
<th>Phone</th>
<th>Interests</th>
<th class="pe-4 text-end">Actions</th>
<th class="pe-4">Interests</th>
</tr>
</thead>
<tbody>
@ -68,20 +67,17 @@
</td>
<td>{{ volunteer.email }}</td>
<td>{{ volunteer.phone|default:"-" }}</td>
<td>
<td class="pe-4">
{% for interest in volunteer.interests.all %}
<span class="badge bg-info-subtle text-info border border-info-subtle">{{ interest.name }}</span>
{% empty %}
<span class="text-muted small">No interests listed</span>
{% endfor %}
</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>
{% empty %}
<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>
<a href="{% url 'volunteer_add' %}" class="btn btn-link">Add the first volunteer</a>
</td>
@ -208,4 +204,4 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
</script>
{% endblock %}
{% endblock %}

View 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 %}