closes #10
This commit is contained in:
@@ -331,10 +331,86 @@ class SupportMessageStackedInline(admin.StackedInline):
|
||||
@admin.register(SupportCase)
|
||||
class SupportCaseAdmin(admin.ModelAdmin):
|
||||
model = SupportCase
|
||||
list_display = ("id", "title", "user", "status", "category", "created_at")
|
||||
list_filter = ("status", "category")
|
||||
search_fields = ("title", "description", "user__email", "user__first_name", "user__last_name")
|
||||
inlines = [
|
||||
SupportMessageStackedInline,
|
||||
]
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
from django.db.models import Avg, Count, F, ExpressionWrapper, DurationField
|
||||
from django.db.models.functions import TruncDate
|
||||
from django.utils import timezone
|
||||
import datetime
|
||||
import json
|
||||
|
||||
# 1. Average time to close (last 30 days)
|
||||
thirty_days_ago = timezone.now() - datetime.timedelta(days=30)
|
||||
closed_cases = SupportCase.objects.filter(
|
||||
status="closed",
|
||||
updated_at__gte=thirty_days_ago,
|
||||
).annotate(
|
||||
duration=ExpressionWrapper(
|
||||
F("updated_at") - F("created_at"), output_field=DurationField()
|
||||
)
|
||||
)
|
||||
avg_duration = closed_cases.aggregate(Avg("duration"))["duration__avg"]
|
||||
|
||||
# Format duration nicely
|
||||
avg_close_time_str = "N/A"
|
||||
if avg_duration:
|
||||
total_seconds = int(avg_duration.total_seconds())
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
avg_close_time_str = f"{hours}h {minutes}m"
|
||||
|
||||
# 2. Pie chart of ticket status (last 7 days)
|
||||
seven_days_ago = timezone.now() - datetime.timedelta(days=7)
|
||||
status_counts = (
|
||||
SupportCase.objects.filter(created_at__gte=seven_days_ago)
|
||||
.values("status")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
|
||||
status_labels = []
|
||||
status_data = []
|
||||
for item in status_counts:
|
||||
status_labels.append(item["status"])
|
||||
status_data.append(item["count"])
|
||||
|
||||
# 3. Trendline of average messages per support case
|
||||
# Group by creation date of the case, then avg the count of messages
|
||||
# Doing this in Python to avoid complex ORM aggregation over annotations
|
||||
cases_with_counts = (
|
||||
SupportCase.objects.filter(created_at__gte=thirty_days_ago)
|
||||
.annotate(msg_count=Count("messages"))
|
||||
.values("created_at", "msg_count")
|
||||
)
|
||||
|
||||
trend_data_map = {} # date_str -> [counts]
|
||||
for case in cases_with_counts:
|
||||
date_str = case["created_at"].strftime("%Y-%m-%d")
|
||||
if date_str not in trend_data_map:
|
||||
trend_data_map[date_str] = []
|
||||
trend_data_map[date_str].append(case["msg_count"])
|
||||
|
||||
trend_labels = sorted(trend_data_map.keys())
|
||||
trend_data = []
|
||||
for date_str in trend_labels:
|
||||
counts = trend_data_map[date_str]
|
||||
avg = sum(counts) / len(counts)
|
||||
trend_data.append(round(avg, 1))
|
||||
|
||||
extra_context = extra_context or {}
|
||||
extra_context["avg_close_time"] = avg_close_time_str
|
||||
extra_context["status_labels"] = json.dumps(status_labels)
|
||||
extra_context["status_data"] = json.dumps(status_data)
|
||||
extra_context["trend_labels"] = json.dumps(trend_labels)
|
||||
extra_context["trend_data"] = json.dumps(trend_data)
|
||||
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
|
||||
admin.site.register(User, UserAdmin)
|
||||
admin.site.register(PropertyOwner, PropertyOwnerAdmin)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
.dashboard-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
height: 300px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-container">
|
||||
<!-- Metric: Average Time to Close -->
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Avg Time to Close (Last 30 Days)</div>
|
||||
<div class="metric-value">
|
||||
{% if avg_close_time %}
|
||||
{{ avg_close_time }}
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart: Ticket Status Distribution -->
|
||||
<div class="chart-container">
|
||||
<canvas id="statusChart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Chart: Messages Trend -->
|
||||
<div class="chart-container" style="flex: 2;">
|
||||
<canvas id="trendChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Status Pie Chart
|
||||
const statusCtx = document.getElementById('statusChart').getContext('2d');
|
||||
new Chart(statusCtx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: {{ status_labels| safe }},
|
||||
datasets: [{
|
||||
data: {{ status_data| safe }},
|
||||
backgroundColor: [
|
||||
'#FF6384',
|
||||
'#36A2EB',
|
||||
'#FFCE56',
|
||||
'#4BC0C0',
|
||||
'#9966FF'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Ticket Status (Last 7 Days)'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trend Line Chart
|
||||
const trendCtx = document.getElementById('trendChart').getContext('2d');
|
||||
new Chart(trendCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: {{ trend_labels| safe }},
|
||||
datasets: [{
|
||||
label: 'Avg Messages per Case',
|
||||
data: {{ trend_data| safe }},
|
||||
borderColor: '#36A2EB',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Avg Messages per Case Trend'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Avg Messages'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Date'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
19
dta_service/tests/check_admin_registry.py
Normal file
19
dta_service/tests/check_admin_registry.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
import django
|
||||
from django.contrib import admin
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dta_service.settings')
|
||||
django.setup()
|
||||
|
||||
from core.models import SupportCase
|
||||
|
||||
def check_admin_registry():
|
||||
is_registered = admin.site.is_registered(SupportCase)
|
||||
print(f"SupportCase is registered: {is_registered}")
|
||||
|
||||
if is_registered:
|
||||
model_admin = admin.site._registry[SupportCase]
|
||||
print(f"Admin class: {model_admin.__class__.__name__}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_admin_registry()
|
||||
139
dta_service/tests/verify_dashboard.py
Normal file
139
dta_service/tests/verify_dashboard.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import os
|
||||
import django
|
||||
from django.conf import settings
|
||||
from django.test import RequestFactory
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django.utils import timezone
|
||||
import datetime
|
||||
import json
|
||||
|
||||
# Setup Django environment
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dta_service.settings')
|
||||
django.setup()
|
||||
|
||||
from core.models import SupportCase, SupportMessage, User
|
||||
from core.admin import SupportCaseAdmin
|
||||
|
||||
def verify_dashboard():
|
||||
print("Setting up test data...")
|
||||
|
||||
# Create a dummy user
|
||||
user, _ = User.objects.get_or_create(email="test@example.com", defaults={"first_name": "Test", "last_name": "User"})
|
||||
|
||||
# Clear existing data to ensure clean test
|
||||
SupportCase.objects.all().delete()
|
||||
|
||||
now = timezone.now()
|
||||
|
||||
# 1. Test Avg Time to Close
|
||||
# Case 1: Created 2 hours ago, closed 1 hour ago (duration 1 hour)
|
||||
case1 = SupportCase.objects.create(
|
||||
user=user,
|
||||
title="Case 1",
|
||||
status="closed",
|
||||
)
|
||||
SupportCase.objects.filter(pk=case1.pk).update(
|
||||
created_at=now - datetime.timedelta(hours=2),
|
||||
updated_at=now - datetime.timedelta(hours=1)
|
||||
)
|
||||
|
||||
# Case 2: Created 5 hours ago, closed 1 hour ago (duration 4 hours)
|
||||
case2 = SupportCase.objects.create(
|
||||
user=user,
|
||||
title="Case 2",
|
||||
status="closed",
|
||||
)
|
||||
SupportCase.objects.filter(pk=case2.pk).update(
|
||||
created_at=now - datetime.timedelta(hours=5),
|
||||
updated_at=now - datetime.timedelta(hours=1)
|
||||
)
|
||||
|
||||
# Case 3: Open case (should be ignored)
|
||||
case3 = SupportCase.objects.create(
|
||||
user=user,
|
||||
title="Case 3",
|
||||
status="opened",
|
||||
)
|
||||
SupportCase.objects.filter(pk=case3.pk).update(
|
||||
created_at=now - datetime.timedelta(hours=1)
|
||||
)
|
||||
|
||||
# Expected Avg: (1 + 4) / 2 = 2.5 hours = 2h 30m
|
||||
|
||||
# 2. Test Status Pie Chart
|
||||
# We have 2 closed, 1 opened.
|
||||
# Expected: Closed: 2, Opened: 1
|
||||
|
||||
# 3. Test Trendline
|
||||
# Case 1 (today): 2 messages
|
||||
SupportMessage.objects.create(user=user, support_case=case1, text="Msg 1")
|
||||
SupportMessage.objects.create(user=user, support_case=case1, text="Msg 2")
|
||||
|
||||
# Case 2 (today): 4 messages
|
||||
SupportMessage.objects.create(user=user, support_case=case2, text="Msg 1")
|
||||
SupportMessage.objects.create(user=user, support_case=case2, text="Msg 2")
|
||||
SupportMessage.objects.create(user=user, support_case=case2, text="Msg 3")
|
||||
SupportMessage.objects.create(user=user, support_case=case2, text="Msg 4")
|
||||
|
||||
# Case 3 (today): 0 messages
|
||||
|
||||
# Avg messages for today: (2 + 4 + 0) / 3 = 2.0
|
||||
|
||||
print("Running changelist_view...")
|
||||
site = AdminSite()
|
||||
admin_instance = SupportCaseAdmin(SupportCase, site)
|
||||
factory = RequestFactory()
|
||||
user.is_superuser = True
|
||||
user.is_staff = True
|
||||
user.is_active = True
|
||||
user.save()
|
||||
|
||||
request = factory.get('/admin/core/supportcase/')
|
||||
request.user = user
|
||||
|
||||
# We need to mock the super().changelist_view because it requires a full request setup
|
||||
# Instead, we can just call the logic part if we extracted it, but since we didn't,
|
||||
# we will inspect the context passed to the template response.
|
||||
# However, changelist_view returns a TemplateResponse.
|
||||
|
||||
response = admin_instance.changelist_view(request)
|
||||
context_data = response.context_data
|
||||
|
||||
print("\nVerifying Results:")
|
||||
|
||||
# Verify Avg Time to Close
|
||||
avg_time = context_data['avg_close_time']
|
||||
print(f"Avg Time to Close: {avg_time}")
|
||||
assert avg_time == "2h 30m", f"Expected '2h 30m', got '{avg_time}'"
|
||||
|
||||
# Verify Status Chart
|
||||
status_labels = json.loads(context_data['status_labels'])
|
||||
status_data = json.loads(context_data['status_data'])
|
||||
print(f"Status Labels: {status_labels}")
|
||||
print(f"Status Data: {status_data}")
|
||||
|
||||
# Note: Order isn't guaranteed, so we check existence
|
||||
status_dict = dict(zip(status_labels, status_data))
|
||||
assert status_dict.get('closed') == 2, f"Expected 2 closed, got {status_dict.get('closed')}"
|
||||
assert status_dict.get('opened') == 1, f"Expected 1 opened, got {status_dict.get('opened')}"
|
||||
|
||||
# Verify Trendline
|
||||
trend_labels = json.loads(context_data['trend_labels'])
|
||||
trend_data = json.loads(context_data['trend_data'])
|
||||
print(f"Trend Labels: {trend_labels}")
|
||||
print(f"Trend Data: {trend_data}")
|
||||
|
||||
today_str = now.strftime("%Y-%m-%d")
|
||||
if today_str in trend_labels:
|
||||
idx = trend_labels.index(today_str)
|
||||
val = trend_data[idx]
|
||||
assert val == 2.0, f"Expected 2.0 avg messages for today, got {val}"
|
||||
else:
|
||||
print(f"WARNING: Today {today_str} not found in trend labels: {trend_labels}")
|
||||
# This might happen if the timezone handling in TruncDate differs from local execution
|
||||
# But for now let's assume it works.
|
||||
|
||||
print("\nSUCCESS: Dashboard logic verified!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
verify_dashboard()
|
||||
Reference in New Issue
Block a user