430 lines
15 KiB
Python
430 lines
15 KiB
Python
import hashlib
|
|
import json
|
|
from datetime import date, timedelta
|
|
|
|
from django.contrib import messages
|
|
from django.contrib.auth import get_user_model
|
|
from django.db.models import Count
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.utils import timezone
|
|
from django.utils.text import slugify
|
|
from django.views.decorators.http import require_http_methods
|
|
|
|
from src.accounts.models import ADMIN_ROLES, Role, Setting
|
|
from src.projects.models import BoardRolePermission
|
|
from src.tickets.models import (
|
|
Ticket,
|
|
TicketComment,
|
|
TicketHistory,
|
|
TicketPriority,
|
|
)
|
|
|
|
User = get_user_model()
|
|
|
|
COLOR_PALETTE = [
|
|
("blue", "#3b82f6", "Blue"),
|
|
("emerald", "#10b981", "Emerald"),
|
|
("green", "#22c55e", "Green"),
|
|
("indigo", "#6366f1", "Indigo"),
|
|
("orange", "#f97316", "Orange"),
|
|
("purple", "#a855f7", "Purple"),
|
|
("red", "#ef4444", "Red"),
|
|
("rose", "#f43f5e", "Rose"),
|
|
("sky", "#0ea5e9", "Sky"),
|
|
("teal", "#14b8a6", "Teal"),
|
|
("violet", "#8b5cf6", "Violet"),
|
|
("amber", "#f59e0b", "Amber"),
|
|
("yellow", "#eab308", "Yellow"),
|
|
("pink", "#ec4899", "Pink"),
|
|
("cyan", "#06b6d4", "Cyan"),
|
|
("lime", "#84cc16", "Lime"),
|
|
("slate", "#64748b", "Slate"),
|
|
("gray", "#6b7280", "Gray"),
|
|
("zinc", "#71717a", "Zinc"),
|
|
]
|
|
|
|
# ── Leaderboard ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def leaderboard(request):
|
|
days = int(request.GET.get("days", 7))
|
|
top_n = int(request.GET.get("top", 10))
|
|
since = timezone.now() - timedelta(days=days)
|
|
|
|
user_ids = list(User.objects.filter(is_active=True).values_list("id", flat=True))
|
|
|
|
tickets_created_map = dict(
|
|
Ticket.objects.filter(created_by_id__in=user_ids, created_at__gte=since)
|
|
.values("created_by_id")
|
|
.annotate(count=Count("id"))
|
|
.values_list("created_by_id", "count")
|
|
)
|
|
status_changes_map = dict(
|
|
TicketHistory.objects.filter(user_id__in=user_ids, created_at__gte=since)
|
|
.values("user_id")
|
|
.annotate(count=Count("id"))
|
|
.values_list("user_id", "count")
|
|
)
|
|
comments_map = dict(
|
|
TicketComment.objects.filter(user_id__in=user_ids, created_at__gte=since)
|
|
.values("user_id")
|
|
.annotate(count=Count("id"))
|
|
.values_list("user_id", "count")
|
|
)
|
|
active_days_map = dict(
|
|
TicketHistory.objects.filter(user_id__in=user_ids, created_at__gte=since)
|
|
.values("user_id")
|
|
.annotate(count=Count("created_at", distinct=True))
|
|
.values_list("user_id", "count")
|
|
)
|
|
|
|
users = User.objects.filter(id__in=user_ids).only(
|
|
"id", "first_name", "last_name", "username"
|
|
)
|
|
leaderboard_data = []
|
|
|
|
for user in users:
|
|
created = tickets_created_map.get(user.id, 0)
|
|
changes = status_changes_map.get(user.id, 0)
|
|
comments = comments_map.get(user.id, 0)
|
|
days_active = active_days_map.get(user.id, 0)
|
|
|
|
score = (changes * 5) + (created * 2) + (comments * 2) + (days_active * 1)
|
|
|
|
if score > 0 or created > 0 or comments > 0:
|
|
leaderboard_data.append(
|
|
{
|
|
"user": user,
|
|
"score": score,
|
|
"tickets_created": created,
|
|
"status_changes": changes,
|
|
"comments_made": comments,
|
|
"active_days": days_active,
|
|
}
|
|
)
|
|
|
|
leaderboard_data.sort(key=lambda x: x["score"], reverse=True)
|
|
leaderboard_data = leaderboard_data[:top_n]
|
|
|
|
for entry in leaderboard_data:
|
|
name = entry["user"].get_full_name() or entry["user"].username
|
|
h = hashlib.md5(name.encode()).hexdigest()[:6]
|
|
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
entry["avatar_color"] = f"rgb({r},{g},{b})"
|
|
|
|
context = {
|
|
"leaderboard": leaderboard_data,
|
|
"days": days,
|
|
"top_n": top_n,
|
|
}
|
|
|
|
# For HTMX requests, render only the partial content (avoids nesting full page inside main)
|
|
if request.META.get("HTTP_HX_REQUEST"):
|
|
return render(request, "leaderboard/partials/content.html", context)
|
|
|
|
return render(request, "leaderboard/pages/index.html", context)
|
|
|
|
|
|
# ── User Contributions (Heatmap) ──────────────────────────────────────────
|
|
|
|
|
|
def user_contributions(request):
|
|
current_year = timezone.now().year
|
|
year_str = request.GET.get("year")
|
|
|
|
if year_str and year_str.isdigit():
|
|
target_year = int(year_str)
|
|
else:
|
|
target_year = current_year
|
|
|
|
tz = timezone.get_current_timezone()
|
|
# Start and end of the target year
|
|
since = timezone.make_aware(timezone.datetime(target_year, 1, 1), timezone=tz)
|
|
|
|
if target_year == current_year:
|
|
end = timezone.now().date()
|
|
else:
|
|
end = timezone.datetime(target_year, 12, 31).date()
|
|
|
|
# Get available years for the tabs
|
|
first_history = TicketHistory.objects.order_by("created_at").first()
|
|
start_year = first_history.created_at.year if first_history else current_year
|
|
available_years = list(range(start_year, current_year + 1))
|
|
|
|
# Ensure target_year is in available_years
|
|
if target_year not in available_years and target_year <= current_year:
|
|
available_years.append(target_year)
|
|
available_years.sort(reverse=True)
|
|
|
|
user_ids = list(
|
|
User.objects.filter(is_active=True).values_list("id", flat=True)[:10]
|
|
)
|
|
|
|
tickets_created_map = dict(
|
|
Ticket.objects.filter(created_by_id__in=user_ids, created_at__year=target_year)
|
|
.values("created_by_id")
|
|
.annotate(count=Count("id"))
|
|
.values_list("created_by_id", "count")
|
|
)
|
|
status_changes_map = dict(
|
|
TicketHistory.objects.filter(user_id__in=user_ids, created_at__year=target_year)
|
|
.values("user_id")
|
|
.annotate(count=Count("id"))
|
|
.values_list("user_id", "count")
|
|
)
|
|
comments_map = dict(
|
|
TicketComment.objects.filter(user_id__in=user_ids, created_at__year=target_year)
|
|
.values("user_id")
|
|
.annotate(count=Count("id"))
|
|
.values_list("user_id", "count")
|
|
)
|
|
active_days_map = dict(
|
|
TicketHistory.objects.filter(user_id__in=user_ids, created_at__year=target_year)
|
|
.values("user_id")
|
|
.annotate(count=Count("created_at", distinct=True))
|
|
.values_list("user_id", "count")
|
|
)
|
|
|
|
all_history = TicketHistory.objects.filter(
|
|
user_id__in=user_ids, created_at__year=target_year
|
|
).values("user_id", "created_at")
|
|
|
|
history_by_user = {}
|
|
for h in all_history:
|
|
uid = h["user_id"]
|
|
if uid not in history_by_user:
|
|
history_by_user[uid] = []
|
|
history_by_user[uid].append(h["created_at"])
|
|
|
|
users = User.objects.filter(id__in=user_ids).only(
|
|
"id", "first_name", "last_name", "username"
|
|
)
|
|
contributions = []
|
|
|
|
for user in users:
|
|
uid = user.id
|
|
created = tickets_created_map.get(uid, 0)
|
|
changes = status_changes_map.get(uid, 0)
|
|
comments = comments_map.get(uid, 0)
|
|
days_active = active_days_map.get(uid, 0)
|
|
|
|
heatmap_values = {}
|
|
current = since.date()
|
|
while current <= end:
|
|
heatmap_values[current.isoformat()] = 0
|
|
current += timedelta(days=1)
|
|
|
|
for dt in history_by_user.get(uid, []):
|
|
day = dt.date().isoformat()
|
|
if day in heatmap_values:
|
|
heatmap_values[day] += 1
|
|
|
|
max_val = max(heatmap_values.values()) if heatmap_values else 1
|
|
|
|
weeks = []
|
|
week = []
|
|
sorted_days = sorted(heatmap_values.keys())
|
|
for day_str in sorted_days:
|
|
day_date = date.fromisoformat(day_str)
|
|
count = heatmap_values[day_str]
|
|
if count == 0:
|
|
level = 0
|
|
elif count <= max_val // 4:
|
|
level = 1
|
|
elif count <= max_val // 2:
|
|
level = 2
|
|
elif count <= int(max_val / 1.5):
|
|
level = 3
|
|
else:
|
|
level = 4
|
|
week.append(
|
|
{
|
|
"date": day_str,
|
|
"count": count,
|
|
"level": level,
|
|
"day": day_date.weekday(),
|
|
}
|
|
)
|
|
if day_date.weekday() == 6:
|
|
weeks.append(week)
|
|
week = []
|
|
if week:
|
|
weeks.append(week)
|
|
|
|
contributions.append(
|
|
{
|
|
"user": user,
|
|
"tickets_created": created,
|
|
"status_changes": changes,
|
|
"comments_made": comments,
|
|
"active_days": days_active,
|
|
"weeks": weeks,
|
|
"max_val": max_val,
|
|
}
|
|
)
|
|
|
|
return render(
|
|
request,
|
|
"contributions/pages/index.html",
|
|
{
|
|
"contributions": contributions,
|
|
"target_year": target_year,
|
|
"available_years": available_years,
|
|
},
|
|
)
|
|
|
|
|
|
# ── Ticket Priorities ─────────────────────────────────────────────────────
|
|
|
|
|
|
@require_http_methods(["GET"])
|
|
def ticket_priorities(request):
|
|
priorities = TicketPriority.objects.all().order_by("name")
|
|
return render(request, "tickets/pages/priorities.html", {"priorities": priorities})
|
|
|
|
|
|
@require_http_methods(["POST"])
|
|
def ticket_priority_add(request):
|
|
if not request.user.is_admin:
|
|
messages.error(request, "Permission denied.")
|
|
return redirect("ticket-priorities")
|
|
name = request.POST.get("name", "").strip()
|
|
color = request.POST.get("color", "#6B7280")
|
|
if name:
|
|
TicketPriority.objects.create(name=name, color=color)
|
|
messages.success(request, "Priority added successfully.")
|
|
else:
|
|
messages.error(request, "Priority name is required.")
|
|
return redirect("ticket-priorities")
|
|
|
|
|
|
@require_http_methods(["POST"])
|
|
def ticket_priority_edit(request, priority_id):
|
|
if not request.user.is_admin:
|
|
messages.error(request, "Permission denied.")
|
|
return redirect("ticket-priorities")
|
|
priority = get_object_or_404(TicketPriority, pk=priority_id)
|
|
name = request.POST.get("name", "").strip()
|
|
color = request.POST.get("color", priority.color)
|
|
if name:
|
|
priority.name = name
|
|
priority.color = color
|
|
priority.save(update_fields=["name", "color"])
|
|
messages.success(request, "Priority updated successfully.")
|
|
else:
|
|
messages.error(request, "Priority name is required.")
|
|
return redirect("ticket-priorities")
|
|
|
|
|
|
@require_http_methods(["POST"])
|
|
def ticket_priority_delete(request, priority_id):
|
|
if not request.user.is_admin:
|
|
messages.error(request, "Permission denied.")
|
|
return redirect("ticket-priorities")
|
|
priority = get_object_or_404(TicketPriority, pk=priority_id)
|
|
priority.delete()
|
|
messages.success(request, "Priority deleted successfully.")
|
|
return redirect("ticket-priorities")
|
|
|
|
|
|
# ── Roles CRUD ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def roles_list(request):
|
|
if not request.user.is_admin:
|
|
messages.error(request, "Permission denied.")
|
|
return redirect("dashboard")
|
|
roles = Role.objects.all().order_by("name")
|
|
return render(
|
|
request,
|
|
"accounts/pages/roles.html",
|
|
{"roles": roles, "admin_roles": ADMIN_ROLES},
|
|
)
|
|
|
|
|
|
@require_http_methods(["POST"])
|
|
def role_add(request):
|
|
if not request.user.is_admin:
|
|
messages.error(request, "Permission denied.")
|
|
return redirect("roles-list")
|
|
name = request.POST.get("name", "").strip()
|
|
if name:
|
|
slug = slugify(name)
|
|
if not slug:
|
|
messages.error(request, "Could not generate a valid slug from that name.")
|
|
elif Role.objects.filter(slug=slug).exists():
|
|
messages.error(request, f"Role with slug '{slug}' already exists.")
|
|
else:
|
|
Role.objects.create(name=name, slug=slug, is_system=False)
|
|
messages.success(request, f"Role '{name}' created.")
|
|
else:
|
|
messages.error(request, "Name is required.")
|
|
return redirect("roles-list")
|
|
|
|
|
|
@require_http_methods(["POST"])
|
|
def role_delete(request, role_id):
|
|
if not request.user.is_admin:
|
|
messages.error(request, "Permission denied.")
|
|
return redirect("roles-list")
|
|
role = get_object_or_404(Role, pk=role_id)
|
|
if role.is_system:
|
|
messages.error(request, "System roles cannot be deleted.")
|
|
return redirect("roles-list")
|
|
user_count = role.users.count()
|
|
if user_count:
|
|
messages.error(
|
|
request,
|
|
f"Cannot delete role '{role.name}' — it is assigned to {user_count} user(s).",
|
|
)
|
|
return redirect("roles-list")
|
|
BoardRolePermission.objects.filter(role=role.slug).delete()
|
|
role.delete()
|
|
messages.success(request, f"Role '{role.name}' deleted.")
|
|
return redirect("roles-list")
|
|
|
|
|
|
# ── UI Settings ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def ui_settings(request):
|
|
the_user = request.user
|
|
user_settings = {}
|
|
if the_user.is_authenticated:
|
|
for s in Setting.objects.filter(user=the_user, group="ui"):
|
|
user_settings[s.key] = s.value
|
|
|
|
if request.method == "POST":
|
|
navigation_style = request.POST.get("navigation_style", "sidebar")
|
|
color_theme = request.POST.get("color_theme", "blue")
|
|
valid_nav = ("sidebar", "top")
|
|
valid_colors = {c[0] for c in COLOR_PALETTE}
|
|
if navigation_style not in valid_nav:
|
|
navigation_style = "sidebar"
|
|
if color_theme not in valid_colors:
|
|
color_theme = "blue"
|
|
|
|
Setting.objects.update_or_create(
|
|
user=the_user,
|
|
key="navigation_style",
|
|
defaults={"value": navigation_style, "group": "ui"},
|
|
)
|
|
Setting.objects.update_or_create(
|
|
user=the_user,
|
|
key="color_theme",
|
|
defaults={"value": color_theme, "group": "ui"},
|
|
)
|
|
messages.success(request, "UI settings saved successfully.")
|
|
return redirect("ui-settings")
|
|
|
|
return render(
|
|
request,
|
|
"settings/pages/ui.html",
|
|
{
|
|
"navigation_style": user_settings.get("navigation_style", "sidebar"),
|
|
"color_theme": user_settings.get("color_theme", "blue"),
|
|
"color_palette": COLOR_PALETTE,
|
|
"color_map": json.dumps({c[0]: c[1] for c in COLOR_PALETTE}),
|
|
},
|
|
)
|