diff --git a/src/accounts/views.py b/src/accounts/views.py index 6e6d83f..e43c74e 100644 --- a/src/accounts/views.py +++ b/src/accounts/views.py @@ -1,5 +1,5 @@ import requests -from decouple import config +from django.conf import settings from django.contrib.auth import get_user_model from rest_framework import generics, permissions, status from rest_framework.decorators import api_view, permission_classes @@ -45,9 +45,9 @@ class GoogleLoginView(APIView): token_url = "https://oauth2.googleapis.com/token" data = { "code": code, - "client_id": config("GOOGLE_CLIENT_ID"), - "client_secret": config("GOOGLE_CLIENT_SECRET"), - "redirect_uri": config("GOOGLE_REDIRECT_URI"), + "client_id": settings.GOOGLE_CLIENT_ID, + "client_secret": settings.GOOGLE_CLIENT_SECRET, + "redirect_uri": settings.GOOGLE_REDIRECT_URI, "grant_type": "authorization_code", } @@ -109,8 +109,8 @@ class GoogleAuthUrlView(APIView): permission_classes = (permissions.AllowAny,) def get(self, request): - client_id = config("GOOGLE_CLIENT_ID") - redirect_uri = config("GOOGLE_REDIRECT_URI") + client_id = settings.GOOGLE_CLIENT_ID + redirect_uri = settings.GOOGLE_REDIRECT_URI url = ( f"https://accounts.google.com/o/oauth2/v2/auth" f"?client_id={client_id}" diff --git a/src/config/board_views.py b/src/config/board_views.py new file mode 100644 index 0000000..24fc40c --- /dev/null +++ b/src/config/board_views.py @@ -0,0 +1,73 @@ +from django.db.models import Count +from django.shortcuts import get_object_or_404, render +from django.views.decorators.http import require_http_methods + +from src.epics.models import Epic +from src.projects.models import Project +from src.tickets.models import Ticket, TicketPriority, TicketStatus + + +def project_board(request, project_id=None): + projects = Project.objects.annotate( + member_count=Count("members", distinct=True), + ticket_count=Count("tickets", distinct=True), + ) + return render( + request, + "projects/board.html", + { + "projects": projects, + "selected_project_id": project_id, + }, + ) + + +@require_http_methods(["GET"]) +def board_data(request, project_id): + project = get_object_or_404(Project, pk=project_id) + statuses = TicketStatus.objects.filter(project=project).order_by("sort_order") + tickets = ( + Ticket.objects.filter(project=project) + .select_related("status", "priority", "epic", "created_by") + .prefetch_related("assignees") + ) + + sort = request.GET.get("sort", "created_at_desc") + if sort == "created_at_asc": + tickets = tickets.order_by("created_at") + elif sort == "name_asc": + tickets = tickets.order_by("name") + elif sort == "name_desc": + tickets = tickets.order_by("-name") + elif sort == "due_date_asc": + tickets = tickets.order_by("due_date") + elif sort == "priority": + tickets = tickets.order_by("priority__name") + else: + tickets = tickets.order_by("-created_at") + + columns = [] + for status in statuses: + col_tickets = [t for t in tickets if t.status_id == status.id] + columns.append( + { + "status": status, + "tickets": col_tickets, + "count": len(col_tickets), + } + ) + + priorities = TicketPriority.objects.all() + epics = Epic.objects.filter(project=project) + + return render( + request, + "projects/partials/board_columns.html", + { + "project": project, + "columns": columns, + "priorities": priorities, + "epics": epics, + "sort": sort, + }, + ) diff --git a/src/config/context_processors.py b/src/config/context_processors.py new file mode 100644 index 0000000..cbeb8b7 --- /dev/null +++ b/src/config/context_processors.py @@ -0,0 +1,20 @@ +from django.contrib.auth import get_user_model + +from src.accounts.models import Setting +from src.tickets.models import Ticket + + +def site_stats(request): + User = get_user_model() + ctx = { + "total_users": User.objects.filter(is_active=True).count(), + "total_tickets": Ticket.objects.count(), + } + user_ui_settings = {} + if request.user.is_authenticated: + for s in Setting.objects.filter(user=request.user, group="ui"): + user_ui_settings[s.key] = s.value + ctx["user_ui_settings"] = user_ui_settings + ctx["navigation_style"] = user_ui_settings.get("navigation_style", "sidebar") + ctx["color_theme"] = user_ui_settings.get("color_theme", "blue") + return ctx diff --git a/src/config/list_views.py b/src/config/list_views.py new file mode 100644 index 0000000..2eb0835 --- /dev/null +++ b/src/config/list_views.py @@ -0,0 +1,112 @@ +import json + +from django.contrib.auth import get_user_model +from django.db.models import Count +from django.shortcuts import render +from django.utils import timezone + +from src.epics.models import Epic +from src.notifications.models import Notification +from src.projects.models import Project +from src.tickets.models import Ticket, TicketPriority, TicketStatus + +User = get_user_model() + + +def projects_list(request): + projects = Project.objects.annotate( + member_count=Count("members", distinct=True), + ticket_count=Count("tickets", distinct=True), + ) + return render( + request, + "projects/list.html", + { + "projects": projects, + }, + ) + + +def tickets_list(request): + tickets = ( + Ticket.objects.select_related("status", "priority", "project", "created_by") + .prefetch_related("assignees") + .order_by("-created_at") + ) + + if not request.user.is_superuser: + user_project_ids = request.user.projects.values_list("id", flat=True) + tickets = tickets.filter(project_id__in=user_project_ids) + + project_id = request.GET.get("project") + status_id = request.GET.get("status") + priority_id = request.GET.get("priority") + + if project_id: + tickets = tickets.filter(project_id=project_id) + if status_id: + tickets = tickets.filter(status_id=status_id) + if priority_id: + tickets = tickets.filter(priority_id=priority_id) + + if not request.user.is_superuser: + user_project_ids = request.user.projects.values_list("id", flat=True) + projects = Project.objects.filter(id__in=user_project_ids) + statuses = TicketStatus.objects.filter(project_id__in=user_project_ids) + epics = Epic.objects.filter(project_id__in=user_project_ids) + else: + projects = Project.objects.all() + statuses = TicketStatus.objects.all() + epics = Epic.objects.all() + + priorities = TicketPriority.objects.all() + users = User.objects.filter(is_active=True).order_by("username") + + today = timezone.now().date() + + # JSON serialized data for Alpine.js bulk operations + statuses_json = json.dumps( + [{"id": s.id, "name": s.name, "project_id": s.project_id} for s in statuses] + ) + epics_json = json.dumps( + [{"id": e.id, "name": e.name, "project_id": e.project_id} for e in epics] + ) + users_json = json.dumps( + [{"id": u.id, "name": u.get_full_name() or u.username} for u in users] + ) + priorities_json = json.dumps( + [{"id": p.id, "name": p.name, "color": p.color} for p in priorities] + ) + ticket_ids_json = json.dumps([t.id for t in tickets]) + ticket_project_map_json = json.dumps({t.id: t.project_id for t in tickets}) + + return render( + request, + "tickets/list.html", + { + "tickets": tickets, + "projects": projects, + "statuses": statuses, + "priorities": priorities, + "users": users, + "epics": epics, + "today": today, + "statuses_json": statuses_json, + "epics_json": epics_json, + "users_json": users_json, + "priorities_json": priorities_json, + "ticket_ids_json": ticket_ids_json, + "ticket_project_map_json": ticket_project_map_json, + }, + ) + + +def notifications_unread_count(request): + count = Notification.objects.filter(user=request.user, read_at__isnull=True).count() + return render( + request, + "dashboard/partials/notif_badge.html", + { + "count": count, + }, + ) diff --git a/src/config/page_views.py b/src/config/page_views.py new file mode 100644 index 0000000..1fe5735 --- /dev/null +++ b/src/config/page_views.py @@ -0,0 +1,1068 @@ +import secrets +import string +from datetime import date, timedelta + +import openpyxl +from django.contrib import messages +from django.contrib.auth import get_user_model +from django.core.paginator import Paginator +from django.db.models import Count, Prefetch, Q +from django.http import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone +from django.views.decorators.http import require_http_methods + +from src.accounts.models import Setting +from src.epics.models import Epic +from src.external.models import ExternalAccess +from src.notifications.models import Notification +from src.projects.models import Project +from src.tickets.models import ( + Ticket, + TicketComment, + TicketHistory, + TicketPriority, + TicketStatus, + TicketUser, +) + +User = get_user_model() + + +# ── Project Timeline (Gantt) ────────────────────────────────────────────── + + +def project_timeline(request): + projects = Project.objects.filter( + start_date__isnull=False, end_date__isnull=False + ).order_by("start_date") + + today = date.today() + for p in projects: + total = (p.end_date - p.start_date).days or 1 + elapsed = (today - p.start_date).days + p.progress = max(0, min(100, int(elapsed / total * 100))) + remaining = (p.end_date - today).days + if remaining < 0: + p.status_label = "Overdue" + p.status_color = "text-red-500" + p.bar_color = "#ef4444" + elif remaining <= 7: + p.status_label = "Approaching Deadline" + p.status_color = "text-yellow-500" + p.bar_color = "#f59e0b" + elif p.progress >= 100: + p.status_label = "Completed" + p.status_color = "text-emerald-500" + p.bar_color = "#10b981" + elif p.progress > 0: + p.status_label = "In Progress" + p.status_color = "text-blue-500" + p.bar_color = "#3b82f6" + else: + p.status_label = "Not Started" + p.status_color = "text-gray-400" + p.bar_color = "#9ca3af" + + return render( + request, + "projects/timeline.html", + { + "projects": projects, + }, + ) + + +# ── Ticket Timeline (Gantt) ─────────────────────────────────────────────── + + +def ticket_timeline(request, project_id=None): + projects = Project.objects.annotate( + ticket_count=Count("tickets"), + member_count=Count("members", distinct=True), + ) + selected_project = None + tickets = [] + if project_id: + selected_project = get_object_or_404(Project, pk=project_id) + tickets = ( + Ticket.objects.filter(project=selected_project) + .select_related("status", "priority") + .filter(due_date__isnull=False) + .order_by("start_date", "due_date") + ) + + return render( + request, + "tickets/timeline.html", + { + "projects": projects, + "selected_project": selected_project, + "selected_project_id": project_id, + "tickets": tickets, + }, + ) + + +# ── Epics Overview ──────────────────────────────────────────────────────── + + +def epics_overview(request, project_id=None): + projects = Project.objects.annotate( + ticket_count=Count("tickets"), + member_count=Count("members", distinct=True), + ) + selected_project = None + epics = [] + if project_id: + selected_project = get_object_or_404(Project, pk=project_id) + epics = ( + Epic.objects.filter(project=selected_project) + .prefetch_related( + "tickets__status", "tickets__priority", "tickets__assignees" + ) + .order_by("sort_order") + ) + + return render( + request, + "epics/overview.html", + { + "projects": projects, + "selected_project": selected_project, + "selected_project_id": project_id, + "epics": epics, + }, + ) + + +# ── 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) + + users = User.objects.filter(is_active=True) + leaderboard_data = [] + + for user in users: + tickets_created = Ticket.objects.filter( + created_by=user, created_at__gte=since + ).count() + status_changes = TicketHistory.objects.filter( + user=user, created_at__gte=since + ).count() + comments_made = TicketComment.objects.filter( + user=user, created_at__gte=since + ).count() + active_days = ( + TicketHistory.objects.filter(user=user, created_at__gte=since) + .dates("created_at", "day") + .count() + ) + + score = ( + (status_changes * 5) + + (tickets_created * 2) + + (comments_made * 2) + + (active_days * 1) + ) + + if score > 0 or tickets_created > 0 or comments_made > 0: + leaderboard_data.append( + { + "user": user, + "score": score, + "tickets_created": tickets_created, + "status_changes": status_changes, + "comments_made": comments_made, + "active_days": active_days, + } + ) + + leaderboard_data.sort(key=lambda x: x["score"], reverse=True) + leaderboard_data = leaderboard_data[:top_n] + + ranks = ["🥇", "🥈", "🥉", "🏅"] + + return render( + request, + "leaderboard/index.html", + { + "leaderboard": leaderboard_data, + "ranks": ranks, + "days": days, + "top_n": top_n, + }, + ) + + +# ── User Contributions (Heatmap) ────────────────────────────────────────── + + +def user_contributions(request): + range_days = int(request.GET.get("range", 30)) + since = timezone.now() - timedelta(days=range_days) + + users = User.objects.filter(is_active=True)[:10] + contributions = [] + + for user in users: + tickets_created = Ticket.objects.filter( + created_by=user, created_at__gte=since + ).count() + status_changes = TicketHistory.objects.filter( + user=user, created_at__gte=since + ).count() + comments_made = TicketComment.objects.filter( + user=user, created_at__gte=since + ).count() + active_days = ( + TicketHistory.objects.filter(user=user, created_at__gte=since) + .dates("created_at", "day") + .count() + ) + + # Build heatmap data + heatmap_values = {} + current = since.date() + end = timezone.now().date() + while current <= end: + heatmap_values[current.isoformat()] = 0 + current += timedelta(days=1) + + for h in TicketHistory.objects.filter(user=user, created_at__gte=since): + day = h.created_at.date().isoformat() + if day in heatmap_values: + heatmap_values[day] += 1 + + max_val = max(heatmap_values.values()) if heatmap_values else 1 + + # Group into weeks (list of lists of day objects) + weeks = [] + week = [] + sorted_days = sorted(heatmap_values.keys()) + for i, day_str in enumerate(sorted_days): + if i > 0 and sorted_days[i - 1] < day_str: + pass + 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: # Sunday + weeks.append(week) + week = [] + if week: + weeks.append(week) + + contributions.append( + { + "user": user, + "tickets_created": tickets_created, + "status_changes": status_changes, + "comments_made": comments_made, + "active_days": active_days, + "weeks": weeks, + "max_val": max_val, + } + ) + + return render( + request, + "contributions/index.html", + { + "contributions": contributions, + "range_days": range_days, + }, + ) + + +# ── Ticket Detail ────────────────────────────────────────────────────────── + + +@require_http_methods(["GET"]) +def ticket_detail(request, ticket_id): + comments_qs = TicketComment.objects.select_related("user").order_by("created_at") + histories_qs = TicketHistory.objects.select_related("user", "status").order_by( + "-created_at" + ) + + ticket = get_object_or_404( + Ticket.objects.select_related( + "status", "priority", "project", "created_by", "epic" + ).prefetch_related( + Prefetch("comments", queryset=comments_qs), + Prefetch("histories", queryset=histories_qs), + "assignees", + ), + pk=ticket_id, + ) + return render(request, "tickets/detail.html", {"ticket": ticket}) + + +# ── Ticket Comment Add ──────────────────────────────────────────────────── + + +@require_http_methods(["POST"]) +def ticket_comment_add(request, ticket_id): + ticket = get_object_or_404(Ticket, pk=ticket_id) + comment_text = request.POST.get("comment", "").strip() + if comment_text: + TicketComment.objects.create( + ticket=ticket, + user=request.user, + comment=comment_text, + ) + comments = ( + TicketComment.objects.select_related("user") + .filter(ticket=ticket) + .order_by("created_at") + ) + return render(request, "tickets/partials/comments.html", {"comments": comments}) + + +# ── Ticket Update Description ──────────────────────────────────────────── + + +@require_http_methods(["POST"]) +def ticket_update_description(request, ticket_id): + ticket = get_object_or_404(Ticket, pk=ticket_id) + description = request.POST.get("description", "").strip() + ticket.description = description + ticket.save(update_fields=["description", "updated_at"]) + if description: + return HttpResponse(f'
{description}
') + return HttpResponse( + '

No description set.

' + ) + + +# ── Ticket Update Field ────────────────────────────────────────────────── + + +@require_http_methods(["POST"]) +def ticket_update_field(request, ticket_id): + ticket = get_object_or_404(Ticket, pk=ticket_id) + field = request.POST.get("field") + value = request.POST.get("value", "").strip() + + if field in ("name",): + ticket.name = value + ticket.save(update_fields=["name", "updated_at"]) + return HttpResponse(value) + elif field in ("start_date", "due_date"): + if value: + setattr(ticket, field, value) + else: + setattr(ticket, field, None) + ticket.save(update_fields=[field, "updated_at"]) + return HttpResponse(value or "-") + return JsonResponse({"error": "Invalid field"}, status=400) + + +# ── Tickets Export ────────────────────────────────────────────────────── + + +@require_http_methods(["GET"]) +def tickets_export(request): + tickets = ( + Ticket.objects.select_related( + "status", "priority", "project", "created_by", "epic" + ) + .prefetch_related("assignees") + .order_by("-created_at") + ) + + if not request.user.is_superuser: + user_project_ids = request.user.projects.values_list("id", flat=True) + tickets = tickets.filter(project_id__in=user_project_ids) + + project_id = request.GET.get("project") + status_id = request.GET.get("status") + priority_id = request.GET.get("priority") + + if project_id: + tickets = tickets.filter(project_id=project_id) + if status_id: + tickets = tickets.filter(status_id=status_id) + if priority_id: + tickets = tickets.filter(priority_id=priority_id) + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Tickets" + + headers = [ + "UUID", + "Name", + "Description", + "Project", + "Status", + "Priority", + "Epic", + "Assignees", + "Start Date", + "Due Date", + "Created By", + "Created At", + "Updated At", + ] + ws.append(headers) + + for t in tickets: + ws.append( + [ + t.uuid, + t.name, + t.description or "", + t.project.name, + t.status.name, + t.priority.name if t.priority else "", + t.epic.name if t.epic else "", + ", ".join(a.get_full_name() or a.username for a in t.assignees.all()), + t.start_date or "", + t.due_date or "", + t.created_by.get_full_name() or t.created_by.username + if t.created_by + else "", + t.created_at.replace(tzinfo=None) if t.created_at else "", + t.updated_at.replace(tzinfo=None) if t.updated_at else "", + ] + ) + + response = HttpResponse( + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + response["Content-Disposition"] = ( + f'attachment; filename="tickets-export-{date.today().isoformat()}.xlsx"' + ) + wb.save(response) + return response + + +# ── Tickets Import ────────────────────────────────────────────────────── + + +@require_http_methods(["GET", "POST"]) +def tickets_import(request): + result = None + + if request.method == "POST" and request.FILES.get("file"): + excel_file = request.FILES["file"] + created_count = 0 + errors = [] + + try: + wb = openpyxl.load_workbook(excel_file, read_only=True) + ws = wb.active + rows = list(ws.iter_rows(values_only=True)) + if not rows: + errors.append("Empty file.") + else: + headers = [str(h).strip().lower() if h else "" for h in rows[0]] + expected = [ + "name", + "project", + "status", + "priority", + "epic", + "start date", + "due date", + "description", + "assignees", + ] + col_map = {} + for i, h in enumerate(headers): + for e in expected: + if h == e: + col_map[e] = i + + if "name" not in col_map: + errors.append("Missing required column: Name") + if "project" not in col_map: + errors.append("Missing required column: Project") + + for row_idx, row in enumerate(rows[1:], start=2): + if all(v is None or str(v).strip() == "" for v in row): + continue + row_errors = [] + try: + name = ( + str(row[col_map["name"]]).strip() + if col_map.get("name") is not None and row[col_map["name"]] + else "" + ) + if not name: + row_errors.append("Name is required") + + project_str = ( + str(row[col_map["project"]]).strip() + if col_map.get("project") is not None + and row[col_map["project"]] + else "" + ) + if not project_str: + row_errors.append("Project is required") + + if row_errors: + errors.append(f"Row {row_idx}: {'; '.join(row_errors)}") + continue + + # Find project by name or prefix + project = Project.objects.filter(name=project_str).first() + if not project: + project = Project.objects.filter( + ticket_prefix=project_str + ).first() + if not project: + errors.append( + f"Row {row_idx}: Project '{project_str}' not found" + ) + continue + + # Find status by name within project + status = None + if col_map.get("status") is not None and row[col_map["status"]]: + status_str = str(row[col_map["status"]]).strip() + status = TicketStatus.objects.filter( + project=project, name__iexact=status_str + ).first() + if not status: + status = TicketStatus.objects.filter( + project=project + ).first() + if not status: + errors.append( + f"Row {row_idx}: No status found for project '{project.name}'" + ) + continue + + # Find priority by name + priority = None + if ( + col_map.get("priority") is not None + and row[col_map["priority"]] + ): + priority_str = str(row[col_map["priority"]]).strip() + priority = TicketPriority.objects.filter( + name__iexact=priority_str + ).first() + + # Find epic by name within project + epic = None + if col_map.get("epic") is not None and row[col_map["epic"]]: + epic_str = str(row[col_map["epic"]]).strip() + epic = Epic.objects.filter( + project=project, name__iexact=epic_str + ).first() + + # Parse dates + start_date = None + if ( + col_map.get("start date") is not None + and row[col_map["start date"]] + ): + try: + start_date = row[col_map["start date"]] + if hasattr(start_date, "date"): + start_date = start_date.date() + except (ValueError, TypeError): + pass + + due_date = None + if ( + col_map.get("due date") is not None + and row[col_map["due date"]] + ): + try: + due_date = row[col_map["due date"]] + if hasattr(due_date, "date"): + due_date = due_date.date() + except (ValueError, TypeError): + pass + + description = "" + if ( + col_map.get("description") is not None + and row[col_map["description"]] + ): + description = str(row[col_map["description"]]).strip() + + # Create ticket + ticket = Ticket.objects.create( + project=project, + status=status, + priority=priority, + epic=epic, + name=name, + description=description or None, + start_date=start_date, + due_date=due_date, + created_by=request.user, + ) + + # Add assignees + if ( + col_map.get("assignees") is not None + and row[col_map["assignees"]] + ): + assignees_str = str(row[col_map["assignees"]]).strip() + if assignees_str: + for part in assignees_str.split(","): + part = part.strip() + if not part: + continue + user = User.objects.filter( + email__iexact=part + ).first() + if not user: + user = User.objects.filter( + username__iexact=part + ).first() + if user: + TicketUser.objects.get_or_create( + ticket=ticket, user=user + ) + else: + errors.append( + f"Row {row_idx}: Assignee '{part}' not found" + ) + + created_count += 1 + except Exception as e: + errors.append(f"Row {row_idx}: {str(e)}") + + wb.close() + except Exception as e: + errors.append(f"File error: {str(e)}") + + result = {"created": created_count, "errors": errors} + + return render(request, "tickets/import.html", {"result": result}) + + +# ── Notifications ────────────────────────────────────────────────────────── + + +@require_http_methods(["GET"]) +def notifications_list(request): + qs = Notification.objects.filter(user=request.user) + filter_param = request.GET.get("filter", "all") + if filter_param == "unread": + qs = qs.filter(read_at__isnull=True) + elif filter_param == "read": + qs = qs.filter(read_at__isnull=False) + paginator = Paginator(qs, 20) + page_number = request.GET.get("page", 1) + page_obj = paginator.get_page(page_number) + unread_count = Notification.objects.filter( + user=request.user, read_at__isnull=True + ).count() + return render( + request, + "notifications/list.html", + { + "notifications": page_obj, + "page_obj": page_obj, + "unread_count": unread_count, + "filter": filter_param, + }, + ) + + +@require_http_methods(["GET"]) +def notifications_dropdown(request): + notifications = Notification.objects.filter( + user=request.user, read_at__isnull=True + )[:10] + return render( + request, + "dashboard/partials/notif_dropdown.html", + { + "notifications": notifications, + }, + ) + + +@require_http_methods(["GET"]) +def notification_mark_read(request, notification_id): + notif = get_object_or_404(Notification, pk=notification_id, user=request.user) + notif.read_at = timezone.now() + notif.save(update_fields=["read_at"]) + next_url = request.GET.get("next") + if next_url: + return redirect(next_url) + return JsonResponse({"status": "ok"}) + + +@require_http_methods(["GET"]) +def notification_mark_all_read(request): + Notification.objects.filter(user=request.user, read_at__isnull=True).update( + read_at=timezone.now() + ) + return redirect("notifications-list") + + +# ── Ticket Priorities ───────────────────────────────────────────────────── + + +@require_http_methods(["GET"]) +def ticket_priorities(request): + priorities = TicketPriority.objects.all().order_by("name") + return render(request, "tickets/priorities.html", {"priorities": priorities}) + + +@require_http_methods(["POST"]) +def ticket_priority_add(request): + 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): + 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): + priority = get_object_or_404(TicketPriority, pk=priority_id) + priority.delete() + messages.success(request, "Priority deleted successfully.") + return redirect("ticket-priorities") + + +# ── Users List ──────────────────────────────────────────────────────────── + + +@require_http_methods(["GET"]) +def users_list(request): + qs = User.objects.filter(is_active=True).annotate( + project_count=Count("projects", distinct=True), + ticket_count=Count("created_tickets", distinct=True), + comment_count=Count("ticket_comments", distinct=True), + ) + search = request.GET.get("search", "").strip() + if search: + qs = qs.filter( + Q(first_name__icontains=search) + | Q(last_name__icontains=search) + | Q(email__icontains=search) + | Q(username__icontains=search) + ) + return render(request, "users/list.html", {"users": qs, "search": search}) + + +# ── UI Settings ──────────────────────────────────────────────────────────── + + +def ui_settings(request): + user_settings = {} + if request.user.is_authenticated: + for s in Setting.objects.filter(user=request.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") + if navigation_style not in ("sidebar", "top"): + navigation_style = "sidebar" + if color_theme not in ("blue", "green", "purple", "orange", "red"): + color_theme = "blue" + Setting.objects.update_or_create( + user=request.user, + key="navigation_style", + defaults={"value": navigation_style, "group": "ui"}, + ) + Setting.objects.update_or_create( + user=request.user, + key="color_theme", + defaults={"value": color_theme, "group": "ui"}, + ) + messages.success(request, "UI settings saved successfully.") + + themes = [ + ("blue", "#3b82f6", "Blue"), + ("green", "#10b981", "Green"), + ("purple", "#8b5cf6", "Purple"), + ("orange", "#f59e0b", "Orange"), + ("red", "#ef4444", "Red"), + ] + return render( + request, + "settings/ui.html", + { + "navigation_style": user_settings.get("navigation_style", "sidebar"), + "color_theme": user_settings.get("color_theme", "blue"), + "themes": themes, + }, + ) + + +# ── External Access Management ────────────────────────────────────────── + + +@require_http_methods(["GET"]) +def external_access_list(request): + accesses = ExternalAccess.objects.select_related("project").all() + return render(request, "external/list.html", {"accesses": accesses}) + + +@require_http_methods(["POST"]) +def external_access_create(request, project_id): + project = get_object_or_404(Project, pk=project_id) + ExternalAccess.objects.create(project=project) + messages.success(request, "External access created successfully.") + return redirect("external-access-list") + + +@require_http_methods(["POST"]) +def external_access_regenerate(request, access_id): + access = get_object_or_404(ExternalAccess, pk=access_id) + access.password = "".join( + secrets.choice(string.ascii_letters + string.digits) for _ in range(8) + ) + access.save(update_fields=["password"]) + messages.success(request, "Password regenerated successfully.") + return redirect("external-access-list") + + +@require_http_methods(["POST"]) +def external_access_toggle(request, access_id): + access = get_object_or_404(ExternalAccess, pk=access_id) + access.is_active = not access.is_active + access.save(update_fields=["is_active"]) + status = "activated" if access.is_active else "deactivated" + messages.success(request, f"External access {status} successfully.") + return redirect("external-access-list") + + +# ── External Client Access (public, no auth) ──────────────────────────── + + +def external_access_view(request, token): + access = get_object_or_404(ExternalAccess, access_token=token) + authenticated = request.session.get("ext_token") == token + + if request.method == "POST": + password = request.POST.get("password", "") + if password == access.password and access.is_active: + request.session["ext_token"] = token + access.last_accessed_at = timezone.now() + access.save(update_fields=["last_accessed_at"]) + return redirect("external-dashboard", token=token) + return render( + request, + "external/access.html", + { + "access": access, + "error": "Invalid password or access is disabled.", + "authenticated": False, + }, + ) + + if authenticated and access.is_active: + tickets = ( + Ticket.objects.filter(project=access.project) + .select_related("status", "priority") + .order_by("-created_at") + ) + return render( + request, + "external/access.html", + { + "access": access, + "authenticated": True, + "tickets": tickets, + }, + ) + + return render( + request, + "external/access.html", + { + "access": access, + "authenticated": False, + }, + ) + + +@require_http_methods(["POST"]) +def external_access_logout(request, token): + if request.session.get("ext_token") == token: + del request.session["ext_token"] + return redirect("external-dashboard", token=token) + + +# ── Bulk Operations ──────────────────────────────────────────────────────── + + +@require_http_methods(["POST"]) +def tickets_bulk_status(request): + import json + + data = json.loads(request.body) + ticket_ids = data.get("ticket_ids", []) + status_id = data.get("status_id") + + if not ticket_ids or not status_id: + return JsonResponse( + {"success": False, "error": "Missing ticket_ids or status_id"}, status=400 + ) + + status = get_object_or_404(TicketStatus, pk=status_id) + tickets = Ticket.objects.filter(pk__in=ticket_ids).select_related("status") + + if not request.user.is_superuser: + user_project_ids = request.user.projects.values_list("id", flat=True) + tickets = tickets.filter(project_id__in=user_project_ids) + + count = 0 + for ticket in tickets: + old_name = ticket.status.name + ticket.status = status + ticket.save(update_fields=["status", "updated_at"]) + + TicketHistory.objects.create( + ticket=ticket, + user=request.user, + status=status, + ) + + notify_users = list(ticket.assignees.all()) + if ticket.created_by and ticket.created_by not in notify_users: + notify_users.append(ticket.created_by) + + for u in notify_users: + Notification.objects.create( + user=u, + type="status_changed", + title=f"Ticket {ticket.uuid} status changed", + message=f"Status changed from {old_name} to {status.name} by {request.user.get_full_name() or request.user.username}", + data={"ticket_id": ticket.id, "ticket_uuid": ticket.uuid}, + ) + + count += 1 + + return JsonResponse({"success": True, "count": count}) + + +@require_http_methods(["POST"]) +def tickets_bulk_priority(request): + import json + + data = json.loads(request.body) + ticket_ids = data.get("ticket_ids", []) + priority_id = data.get("priority_id") + + if not ticket_ids or not priority_id: + return JsonResponse( + {"success": False, "error": "Missing ticket_ids or priority_id"}, status=400 + ) + + priority = get_object_or_404(TicketPriority, pk=priority_id) + tickets = Ticket.objects.filter(pk__in=ticket_ids) + + if not request.user.is_superuser: + user_project_ids = request.user.projects.values_list("id", flat=True) + tickets = tickets.filter(project_id__in=user_project_ids) + + count = tickets.update(priority=priority, updated_at=timezone.now()) + + return JsonResponse({"success": True, "count": count}) + + +@require_http_methods(["POST"]) +def tickets_bulk_assign(request): + import json + + data = json.loads(request.body) + ticket_ids = data.get("ticket_ids", []) + user_id = data.get("user_id") + + if not ticket_ids or not user_id: + return JsonResponse( + {"success": False, "error": "Missing ticket_ids or user_id"}, status=400 + ) + + user_to_assign = get_object_or_404(User, pk=user_id, is_active=True) + tickets = Ticket.objects.filter(pk__in=ticket_ids) + + if not request.user.is_superuser: + user_project_ids = request.user.projects.values_list("id", flat=True) + tickets = tickets.filter(project_id__in=user_project_ids) + + count = 0 + for ticket in tickets: + _, created = TicketUser.objects.get_or_create( + ticket=ticket, user=user_to_assign + ) + if created: + count += 1 + + return JsonResponse({"success": True, "count": count}) + + +@require_http_methods(["POST"]) +def tickets_bulk_epic(request): + import json + + data = json.loads(request.body) + ticket_ids = data.get("ticket_ids", []) + epic_id = data.get("epic_id") + + if not ticket_ids: + return JsonResponse( + {"success": False, "error": "Missing ticket_ids"}, status=400 + ) + + epic = None + if epic_id is not None: + epic = get_object_or_404(Epic, pk=epic_id) + + tickets = Ticket.objects.filter(pk__in=ticket_ids) + + if not request.user.is_superuser: + user_project_ids = request.user.projects.values_list("id", flat=True) + tickets = tickets.filter(project_id__in=user_project_ids) + + count = 0 + for ticket in tickets: + ticket.epic = epic + ticket.save(update_fields=["epic", "updated_at"]) + count += 1 + + return JsonResponse({"success": True, "count": count}) diff --git a/src/config/views.py b/src/config/views.py new file mode 100644 index 0000000..c936080 --- /dev/null +++ b/src/config/views.py @@ -0,0 +1,214 @@ +import json +from datetime import date, timedelta + +from django.contrib.auth import get_user_model +from django.db.models import Count, Q +from django.http import JsonResponse +from django.shortcuts import render +from django.utils import timezone +from django.views.decorators.http import require_http_methods + +from src.projects.models import Project +from src.tickets.models import Ticket, TicketHistory + +User = get_user_model() + + +def dashboard(request): + return render( + request, + "dashboard/index.html", + { + "project_count": Project.objects.count(), + "ticket_count": Ticket.objects.count(), + "user_count": User.objects.count(), + "my_assigned": Ticket.objects.filter(assignees=request.user).count(), + }, + ) + + +@require_http_methods(["GET"]) +def stats_cards(request): + user = request.user + now = timezone.now() + today = now.date() + week_start = today - timedelta(days=today.weekday()) + + if user.is_superuser: + total_projects = Project.objects.count() + total_tickets = Ticket.objects.count() + team_members = User.objects.count() + else: + total_projects = Project.objects.filter(members=user).count() + total_tickets = ( + Ticket.objects.filter(Q(project__members=user) | Q(assignees=user)) + .distinct() + .count() + ) + team_members = ( + User.objects.filter(project_memberships__project__members=user) + .distinct() + .count() + ) + + my_assigned = Ticket.objects.filter(assignees=user).count() + my_created = Ticket.objects.filter(created_by=user).count() + my_overdue = Ticket.objects.filter( + assignees=user, due_date__lt=today, status__is_completed=False + ).count() + completed_this_week = Ticket.objects.filter( + Q(assignees=user) | Q(created_by=user), + status__is_completed=True, + updated_at__gte=week_start, + ).count() + new_this_week = Ticket.objects.filter( + Q(assignees=user) | Q(created_by=user), + created_at__gte=week_start, + ).count() + + return render( + request, + "dashboard/partials/stats_cards.html", + { + "total_projects": total_projects, + "total_tickets": total_tickets, + "team_members": team_members, + "my_assigned": my_assigned, + "my_created": my_created, + "my_overdue": my_overdue, + "completed_this_week": completed_this_week, + "new_this_week": new_this_week, + "is_superuser": user.is_superuser, + }, + ) + + +@require_http_methods(["GET"]) +def tickets_per_project_chart(request): + data = list( + Project.objects.annotate(count=Count("tickets")).values( + "name", "count", "color" + ) + ) + max_count = max((d["count"] for d in data), default=1) + for d in data: + d["pct"] = round(d["count"] / max_count * 100, 1) if max_count else 0 + return render( + request, + "dashboard/partials/tickets_per_project_chart.html", + { + "chart_data": data, + }, + ) + + +@require_http_methods(["GET"]) +def user_statistics_data(request): + users = User.objects.annotate( + project_count=Count("projects", distinct=True), + assigned_count=Count("assigned_tickets", distinct=True), + ).values("username", "project_count", "assigned_count")[:10] + + return JsonResponse( + { + "labels": [u["username"] for u in users], + "projects": [u["project_count"] for u in users], + "assigned": [u["assigned_count"] for u in users], + } + ) + + +@require_http_methods(["GET"]) +def monthly_trend_chart(request): + now = timezone.now() + months = [] + for i in range(5, -1, -1): + m = now.month - i + y = now.year + while m < 1: + m += 12 + y -= 1 + months.append({"year": y, "month": m, "label": date(y, m, 1).strftime("%b %Y")}) + + data = [] + for m in months: + count = Ticket.objects.filter( + created_at__year=m["year"], + created_at__month=m["month"], + ).count() + data.append({"label": m["label"], "count": count}) + + return render( + request, + "dashboard/partials/monthly_trend_chart.html", + { + "chart_data": json.dumps(data), + }, + ) + + +@require_http_methods(["GET"]) +def recent_activity(request): + histories = TicketHistory.objects.select_related( + "ticket", "user", "status", "ticket__project" + ).order_by("-created_at")[:20] + + return render( + request, + "dashboard/partials/recent_activity.html", + { + "histories": histories, + }, + ) + + +@require_http_methods(["GET"]) +def project_timeline_widget(request): + filter_type = request.GET.get("filter", "pinned") + qs = Project.objects.filter(start_date__isnull=False, end_date__isnull=False) + + pinned_count = qs.filter(pinned_date__isnull=False).count() + all_count = qs.count() + + if filter_type == "pinned": + qs = qs.filter(pinned_date__isnull=False).order_by("-pinned_date") + else: + qs = qs.order_by("start_date") + + today = date.today() + for p in qs: + total = (p.end_date - p.start_date).days or 1 + elapsed = (today - p.start_date).days + p.progress = max(0, min(100, int(elapsed / total * 100))) + remaining = (p.end_date - today).days + if remaining < 0: + p.status_label = "Overdue" + p.status_color = "badge-error" + p.bar_color = "bg-red-500" + elif remaining <= 7: + p.status_label = "Approaching Deadline" + p.status_color = "badge-warning" + p.bar_color = "bg-yellow-500" + elif p.progress >= 100: + p.status_label = "Completed" + p.status_color = "badge-success" + p.bar_color = "bg-emerald-500" + elif p.progress > 0: + p.status_label = "In Progress" + p.status_color = "badge-info" + p.bar_color = "bg-blue-500" + else: + p.status_label = "Not Started" + p.status_color = "badge-ghost" + p.bar_color = "bg-gray-400" + + return render( + request, + "dashboard/partials/project_timeline.html", + { + "projects": qs, + "current_filter": filter_type, + "pinned_count": pinned_count, + "all_count": all_count, + }, + ) diff --git a/src/notes/serializers.py b/src/notes/serializers.py index 5cb12d6..b70c454 100644 --- a/src/notes/serializers.py +++ b/src/notes/serializers.py @@ -1,4 +1,4 @@ -from apps.accounts.serializers import UserSerializer +from src.accounts.serializers import UserSerializer from rest_framework import serializers from src.notes.models import ProjectNote