Skip to content

Commit 0bfb5e4

Browse files
Travis GilbertTravis Gilbert
authored andcommitted
fix(admin): harden workflow — 9 fixes for review queue, audit trail, CI
Fix 1: Wire send_buyer_status_email() into review_queue.review_update_status Fix 2: Enforce requires_transition_note() in review_queue (was missing; admin had it) Fix 3: Add missing `redirect` to submission.py module-level imports (latent NameError) Fix 4: Create StatusLog entries for every assign/unassign action (4 locations): - admin.py assign_to_me bulk action (loop replaces .update()) - admin.py clear_assignee bulk action (loop replaces .update()) - admin_api.py assign_to_me HTMX endpoint - review_queue.py auto-claim on status update Fix 5: Append "[SYSTEM] email failed" note to StatusLog on send failure in _bulk_set_status Fix 6: Replace broken pylint.yml matrix with real CI (Python 3.13, Django test runner) Fix 7: Add applications/tests/ package with 16 tests covering: - ReviewQueueTests: queue redirect, empty state, status log, auto-claim, invalid transition, note enforcement, buyer email, no-email paths - AdminAPITests: assign + audit log, pending count JSON, doc review save/reject - TransitionEnforcementTests: map completeness, terminal states, reopen path Fix 8: Split pending_count into action_needed + waiting_on_buyer (backward-compat) Fix 9: Add export_csv bulk action to ApplicationAdmin (CSV download for staff) Also converts applications/tests.py → applications/tests/ package so new tests can live alongside the existing e2e suite.
1 parent b41daa8 commit 0bfb5e4

9 files changed

Lines changed: 445 additions & 53 deletions

File tree

.github/workflows/ci.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, develop]
6+
pull_request:
7+
branches: [main, develop]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Set up Python 3.13
16+
uses: actions/setup-python@v5
17+
with:
18+
python-version: "3.13"
19+
20+
- name: Install dependencies
21+
run: |
22+
python -m pip install --upgrade pip
23+
pip install -r requirements.txt
24+
25+
- name: Run tests
26+
env:
27+
SECRET_KEY: "ci-test-key-not-for-production"
28+
DATABASE_URL: "sqlite:///test.db"
29+
DEBUG: "True"
30+
STAFF_NOTIFICATION_EMAIL: "test@example.com"
31+
run: |
32+
python manage.py test applications --verbosity=2

.github/workflows/pylint.yml

Lines changed: 0 additions & 23 deletions
This file was deleted.

applications/admin.py

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
organized fieldsets, inline documents, and automatic status audit logging.
66
"""
77

8+
import csv
9+
810
from django import forms
11+
from django.http import HttpResponse
912
from django.contrib import admin, messages
1013
from django_smartbase_admin.admin.admin_base import SBAdminTableInline
1114
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
@@ -543,6 +546,7 @@ class ApplicationAdmin(SBAdmin):
543546
"mark_declined",
544547
"assign_to_me",
545548
"clear_assignee",
549+
"export_csv",
546550
)
547551

548552
fieldsets = (
@@ -1196,6 +1200,16 @@ def _bulk_set_status(self, request, queryset, new_status):
11961200
)
11971201
if outcome == "failed":
11981202
email_failures += 1
1203+
log = StatusLog.objects.filter(
1204+
application_id=app.id,
1205+
to_status=new_status,
1206+
).order_by("-changed_at").first()
1207+
if log:
1208+
log.notes = (
1209+
(log.notes or "")
1210+
+ "\n[SYSTEM] Buyer notification email failed. Manual follow-up needed."
1211+
).strip()
1212+
log.save(update_fields=["notes"])
11991213

12001214
msg = f"Updated {len(ids)} application(s)."
12011215
if skipped_bad_transition:
@@ -1228,10 +1242,28 @@ def mark_declined(self, request, queryset):
12281242
@admin.action(description="Set me as reviewer")
12291243
def assign_to_me(self, request, queryset):
12301244
try:
1231-
updated = queryset.exclude(assigned_to=request.user).update(
1232-
assigned_to=request.user,
1233-
updated_at=timezone.now(),
1234-
)
1245+
eligible = queryset.exclude(assigned_to=request.user)
1246+
updated = 0
1247+
reviewer_name = request.user.get_full_name() or request.user.get_username()
1248+
for app in eligible:
1249+
old_assignee = app.assigned_to
1250+
app.assigned_to = request.user
1251+
app.save(update_fields=["assigned_to", "updated_at"])
1252+
StatusLog.objects.create(
1253+
application=app,
1254+
from_status=app.status,
1255+
to_status=app.status,
1256+
changed_by=request.user,
1257+
notes=(
1258+
f"Reviewer changed to {reviewer_name}"
1259+
+ (
1260+
f" (was: {old_assignee.get_full_name() or old_assignee.get_username()})"
1261+
if old_assignee
1262+
else " (was: unassigned)"
1263+
)
1264+
),
1265+
)
1266+
updated += 1
12351267
self.message_user(request, f"You are now reviewer for {updated} application(s).")
12361268
except Exception as e:
12371269
self.message_user(
@@ -1241,11 +1273,69 @@ def assign_to_me(self, request, queryset):
12411273
)
12421274

12431275
@admin.action(description="Remove reviewer")
1276+
@admin.action(description="Export selected as CSV")
1277+
def export_csv(self, request, queryset):
1278+
"""Export selected applications to a downloadable CSV file."""
1279+
response = HttpResponse(content_type="text/csv")
1280+
response["Content-Disposition"] = 'attachment; filename="applications_export.csv"'
1281+
1282+
writer = csv.writer(response)
1283+
writer.writerow([
1284+
"Reference #",
1285+
"First Name",
1286+
"Last Name",
1287+
"Email",
1288+
"Phone",
1289+
"Property Address",
1290+
"Parcel ID",
1291+
"Program",
1292+
"Purchase Type",
1293+
"Offer Amount",
1294+
"Status",
1295+
"Reviewer",
1296+
"Submitted",
1297+
"Last Updated",
1298+
])
1299+
1300+
for app in queryset.select_related("assigned_to").order_by("-submitted_at"):
1301+
reviewer = ""
1302+
if app.assigned_to:
1303+
reviewer = app.assigned_to.get_full_name() or app.assigned_to.get_username()
1304+
writer.writerow([
1305+
app.reference_number,
1306+
app.first_name,
1307+
app.last_name,
1308+
app.email,
1309+
app.phone,
1310+
app.property_address,
1311+
app.parcel_id,
1312+
app.get_program_type_display(),
1313+
app.get_purchase_type_display(),
1314+
f"${app.offer_amount:,.2f}" if app.offer_amount else "",
1315+
app.get_status_display(),
1316+
reviewer,
1317+
app.submitted_at.strftime("%Y-%m-%d %I:%M %p") if app.submitted_at else "",
1318+
app.updated_at.strftime("%Y-%m-%d %I:%M %p") if app.updated_at else "",
1319+
])
1320+
1321+
return response
1322+
12441323
def clear_assignee(self, request, queryset):
1245-
updated = queryset.exclude(assigned_to=None).update(
1246-
assigned_to=None,
1247-
updated_at=timezone.now(),
1248-
)
1324+
eligible = queryset.exclude(assigned_to=None)
1325+
updated = 0
1326+
for app in eligible:
1327+
old_assignee = app.assigned_to
1328+
old_name = old_assignee.get_full_name() or old_assignee.get_username()
1329+
app.assigned_to = None
1330+
app.save(update_fields=["assigned_to", "updated_at"])
1331+
StatusLog.objects.create(
1332+
application=app,
1333+
from_status=app.status,
1334+
to_status=app.status,
1335+
changed_by=request.user,
1336+
notes=f"Reviewer removed (was: {old_name})",
1337+
)
1338+
updated += 1
12491339
self.message_user(request, f"Removed reviewer from {updated} application(s).")
12501340

12511341
def save_model(self, request, obj, form, change):

applications/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)