diff --git a/api/dashboard/campus/campus_views.py b/api/dashboard/campus/campus_views.py index f4c72280e..bbfa6d28f 100644 --- a/api/dashboard/campus/campus_views.py +++ b/api/dashboard/campus/campus_views.py @@ -394,6 +394,54 @@ def get(self, request, org_id=None): return CustomResponse(response=serializer.data).get_success_response() +class CampusProgramParticipationAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + if user_org_link.org is None: + return CustomResponse( + general_message="Campus lead has no college" + ).get_failure_response() + + campus = ( + Organization.objects.filter( + id=user_org_link.org.id, + org_type=OrganizationType.COLLEGE.value, + ) + .values("id", "title", "code") + .annotate( + program_count=Count("learning_circle_org_id", distinct=True), + participant_count=Count( + "learning_circle_org_id__user_circle_link_circle__user", + filter=Q( + learning_circle_org_id__user_circle_link_circle__accepted=True + ), + distinct=True, + ), + participation_count=Count( + "learning_circle_org_id__user_circle_link_circle", + filter=Q( + learning_circle_org_id__user_circle_link_circle__accepted=True + ), + ), + ) + .first() + ) + + return CustomResponse( + response={"campuses": [campus] if campus else []}, + general_message="Campus program participation fetched successfully", + ).get_success_response() + + class ChangeStudentTypeAPI(APIView): authentication_classes = [CustomizePermission] diff --git a/api/dashboard/campus/urls.py b/api/dashboard/campus/urls.py index 73216d680..266c1e1b0 100644 --- a/api/dashboard/campus/urls.py +++ b/api/dashboard/campus/urls.py @@ -38,6 +38,11 @@ campus_views.WeeklyKarmaAPI.as_view(), name="weekly-karma-insights-individual", ), + path( + "program-participation/", + campus_views.CampusProgramParticipationAPI.as_view(), + name="program-participation", + ), path( "change-student-type//", campus_views.ChangeStudentTypeAPI.as_view(), diff --git a/api/dashboard/college/college_view.py b/api/dashboard/college/college_view.py index 7c529b736..4d7a1ff5c 100644 --- a/api/dashboard/college/college_view.py +++ b/api/dashboard/college/college_view.py @@ -23,7 +23,11 @@ def get(self, request, college_code=None): colleges, request, search_fields=["org__title"], - sort_fields={'org': 'org'}, + sort_fields={ + 'org': 'org__title', + 'level': 'level', + 'created_at': 'created_at', + }, ) serializer = CollegeListSerializer( paginated_queryset.get("queryset"), many=True diff --git a/api/dashboard/ig/dash_ig_serializer.py b/api/dashboard/ig/dash_ig_serializer.py index a8157d91d..091fc095f 100644 --- a/api/dashboard/ig/dash_ig_serializer.py +++ b/api/dashboard/ig/dash_ig_serializer.py @@ -88,3 +88,20 @@ class Meta: "created_by", "updated_by", ] + + +class IGTopContributorSerializer(serializers.Serializer): + full_name = serializers.CharField() + muid = serializers.CharField() + karma_earned = serializers.IntegerField() + + +class IGTaskSummarySerializer(serializers.Serializer): + ig_id = serializers.CharField() + ig_name = serializers.CharField() + ig_code = serializers.CharField() + total_tasks_completed = serializers.IntegerField() + total_karma_awarded = serializers.IntegerField() + unique_contributors = serializers.IntegerField() + top_contributors = IGTopContributorSerializer(many=True) + date_range = serializers.DictField(child=serializers.DateField(allow_null=True), allow_null=True) diff --git a/api/dashboard/ig/dash_ig_view.py b/api/dashboard/ig/dash_ig_view.py index 6a886a81d..21a87ba15 100644 --- a/api/dashboard/ig/dash_ig_view.py +++ b/api/dashboard/ig/dash_ig_view.py @@ -1,7 +1,10 @@ -from django.db.models import Count +from datetime import datetime + +from django.db.models import Count, Sum, Q +from django.db.models.functions import Coalesce from rest_framework.views import APIView -from db.task import InterestGroup +from db.task import InterestGroup, KarmaActivityLog from utils.permission import CustomizePermission from utils.permission import JWTUtils, role_required from utils.response import CustomResponse @@ -10,6 +13,7 @@ from .dash_ig_serializer import ( InterestGroupSerializer, InterestGroupCreateUpdateSerializer, + IGTaskSummarySerializer, ) import json from django.utils.decorators import method_decorator @@ -341,3 +345,99 @@ def get(self, request): return CustomResponse( response={"interestGroup": serializer.data} ).get_success_response() + + +class IGTaskSummaryAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([ + RoleType.ADMIN.value, + RoleType.FELLOW.value, + RoleType.ASSOCIATE.value, + ]) + def get(self, request, ig_id): + ig = ( + InterestGroup.objects.filter(id=ig_id) + .values("id", "name", "code") + .first() + ) + + if not ig: + return CustomResponse( + general_message="Interest Group not found" + ).get_failure_response(status_code=404, http_status_code=404) + + from_param = request.query_params.get("from_date") + to_param = request.query_params.get("to_date") + from_date = self._parse_date(from_param) if from_param else None + if from_param and from_date is None: + return CustomResponse( + general_message="Invalid date format. Use YYYY-MM-DD" + ).get_failure_response() + to_date = self._parse_date(to_param) if to_param else None + if to_param and to_date is None: + return CustomResponse( + general_message="Invalid date format. Use YYYY-MM-DD" + ).get_failure_response() + + if from_date and to_date and from_date > to_date: + return CustomResponse( + general_message="from_date cannot be after to_date" + ).get_failure_response() + + filters = Q(task__ig_id=ig_id) + if from_date: + filters &= Q(created_at__date__gte=from_date) + if to_date: + filters &= Q(created_at__date__lte=to_date) + + activity_qs = KarmaActivityLog.objects.filter(filters) + summary_values = activity_qs.aggregate( + total_tasks=Count("id"), + total_karma=Coalesce(Sum("karma"), 0), + unique_contributors=Count("user", distinct=True), + ) + + top_contributors_queryset = ( + activity_qs.filter(user__isnull=False) + .values("user__full_name", "user__muid") + .annotate(karma_earned=Coalesce(Sum("karma"), 0)) + .order_by("-karma_earned", "user__full_name")[:5] + ) + top_contributors = [ + { + "full_name": entry["user__full_name"], + "muid": entry["user__muid"], + "karma_earned": entry["karma_earned"], + } + for entry in top_contributors_queryset + ] + + serializer = IGTaskSummarySerializer( + data={ + "ig_id": ig["id"], + "ig_name": ig["name"], + "ig_code": ig["code"], + "total_tasks_completed": summary_values.get("total_tasks", 0) or 0, + "total_karma_awarded": summary_values.get("total_karma", 0) or 0, + "unique_contributors": summary_values.get("unique_contributors", 0) or 0, + "top_contributors": top_contributors, + "date_range": { + "from_date": from_date, + "to_date": to_date, + }, + } + ) + serializer.is_valid(raise_exception=True) + + return CustomResponse( + response=serializer.data, + general_message="Task summary fetched successfully", + ).get_success_response() + + @staticmethod + def _parse_date(value: str): + try: + return datetime.strptime(value, "%Y-%m-%d").date() + except ValueError: + return None diff --git a/api/dashboard/ig/urls.py b/api/dashboard/ig/urls.py index ee69cfa49..2c47761aa 100644 --- a/api/dashboard/ig/urls.py +++ b/api/dashboard/ig/urls.py @@ -6,6 +6,7 @@ path('', dash_ig_view.InterestGroupAPI.as_view()), # for get data and create new interest groups path('list/', dash_ig_view.InterestGroupListApi.as_view()), # for public listing without admin permission path('csv/', dash_ig_view.InterestGroupCSV.as_view()), # for IG data CSV download + path('ig//task-summary/', dash_ig_view.IGTaskSummaryAPI.as_view()), path('/', dash_ig_view.InterestGroupAPI.as_view()), # for edit and delete path('get//', dash_ig_view.InterestGroupGetAPI.as_view()), # for edit and delete ] diff --git a/api/dashboard/karma/__init__.py b/api/dashboard/karma/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/dashboard/karma/karma_views.py b/api/dashboard/karma/karma_views.py new file mode 100644 index 000000000..d400d0fa4 --- /dev/null +++ b/api/dashboard/karma/karma_views.py @@ -0,0 +1,57 @@ +from django.db.models import Case, Count, F, IntegerField, Value, When +from django.db.models.functions import Coalesce, Floor +from rest_framework.views import APIView + +from db.user import User +from utils.permission import CustomizePermission, role_required +from utils.response import CustomResponse +from utils.types import RoleType + + +class KarmaHistogramAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value]) + def get(self, request): + student_karma_queryset = ( + User.objects.filter(user_role_link_user__role__title=RoleType.STUDENT.value) + .distinct() + .annotate(karma_value=Coalesce("wallet_user__karma", Value(0))) + .annotate( + karma_bucket=Case( + When(karma_value__lte=100, then=Value(0)), + default=Floor((F("karma_value") - Value(1)) / Value(100)), + output_field=IntegerField(), + ) + ) + .values("karma_bucket") + .annotate(users=Count("id")) + .order_by("karma_bucket") + ) + + bucket_counts = { + entry["karma_bucket"]: entry["users"] for entry in student_karma_queryset + } + + if bucket_counts: + max_bucket = max(bucket_counts.keys()) + ranges = [] + for bucket in range(max_bucket + 1): + if bucket == 0: + range_label = "0-100" + else: + range_label = f"{bucket * 100 + 1}-{(bucket + 1) * 100}" + + ranges.append( + { + "range": range_label, + "users": bucket_counts.get(bucket, 0), + } + ) + else: + ranges = [] + + return CustomResponse( + response={"ranges": ranges}, + general_message="Karma histogram fetched successfully", + ).get_success_response() diff --git a/api/dashboard/karma/urls.py b/api/dashboard/karma/urls.py new file mode 100644 index 000000000..1738770cd --- /dev/null +++ b/api/dashboard/karma/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import karma_views + +urlpatterns = [ + path("histogram/", karma_views.KarmaHistogramAPI.as_view(), name="karma-histogram"), +] diff --git a/api/dashboard/student/__init__.py b/api/dashboard/student/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/dashboard/student/student_views.py b/api/dashboard/student/student_views.py new file mode 100644 index 000000000..3f389dc94 --- /dev/null +++ b/api/dashboard/student/student_views.py @@ -0,0 +1,65 @@ +from django.db.models import Count, ExpressionWrapper, F, IntegerField, OuterRef, Subquery, Value +from django.db.models.functions import Coalesce +from rest_framework.views import APIView + +from db.task import UserIgLink +from db.learning_circle import UserCircleLink +from db.user import User +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType + + +class StudentParticipationBreakdownAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value]) + def get(self, request): + ig_count_queryset = ( + UserIgLink.objects.filter(user_id=OuterRef("pk")) + .values("user_id") + .annotate(total_igs=Count("ig", distinct=True)) + .values("total_igs") + ) + + circle_count_queryset = ( + UserCircleLink.objects.filter(user_id=OuterRef("pk"), accepted=True) + .values("user_id") + .annotate(total_circles=Count("circle", distinct=True)) + .values("total_circles") + ) + + students = ( + User.objects.filter(user_role_link_user__role__title=RoleType.STUDENT.value) + .distinct() + .select_related("wallet_user") + .annotate( + user_id=F("id"), + karma=Coalesce("wallet_user__karma", Value(0)), + ig_count=Coalesce( + Subquery(ig_count_queryset, output_field=IntegerField()), Value(0) + ), + circle_count=Coalesce( + Subquery(circle_count_queryset, output_field=IntegerField()), Value(0) + ), + ) + .annotate( + total_participation=ExpressionWrapper( + F("ig_count") + F("circle_count"), output_field=IntegerField() + ) + ) + .values( + "user_id", + "full_name", + "karma", + "ig_count", + "circle_count", + "total_participation", + ) + .order_by("-karma", "full_name") + ) + + return CustomResponse( + response={"students": list(students)}, + general_message="Student participation breakdown fetched successfully", + ).get_success_response() diff --git a/api/dashboard/student/urls.py b/api/dashboard/student/urls.py new file mode 100644 index 000000000..49d446d42 --- /dev/null +++ b/api/dashboard/student/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from . import student_views + +urlpatterns = [ + path( + "participation-breakdown/", + student_views.StudentParticipationBreakdownAPI.as_view(), + name="participation-breakdown", + ), +] diff --git a/api/dashboard/urls.py b/api/dashboard/urls.py index ada6b63cf..35deadfee 100644 --- a/api/dashboard/urls.py +++ b/api/dashboard/urls.py @@ -6,6 +6,8 @@ path("zonal/", include("api.dashboard.zonal.urls")), path("district/", include("api.dashboard.district.urls")), path("campus/", include("api.dashboard.campus.urls")), + path("student/", include("api.dashboard.student.urls")), + path("karma/", include("api.dashboard.karma.urls")), path("roles/", include("api.dashboard.roles.urls")), path("ig/", include("api.dashboard.ig.urls")), path("task/", include("api.dashboard.task.urls")), diff --git a/api/launchpad/launchpad_views.py b/api/launchpad/launchpad_views.py index 7d970e119..7d7772388 100644 --- a/api/launchpad/launchpad_views.py +++ b/api/launchpad/launchpad_views.py @@ -4,6 +4,8 @@ from decouple import config from django.utils import timezone from django.db.models import Sum, Max, Prefetch, F, OuterRef, Subquery, IntegerField, Count, Q, Prefetch +from django.db.models.functions import TruncDate +from django.utils.dateparse import parse_date from rest_framework.views import APIView from django.core.files.storage import FileSystemStorage from decouple import config as decouple_config @@ -544,6 +546,154 @@ def delete(self, request, job_id): return CustomResponse( general_message="Job not found." ).get_failure_response() + + +class JobAnalyticsAPI(APIView): + authentication_classes = [LaunchpadJWTPermission] + + def get(self, request, job_id): + user_type = request.auth.get("user_type") + company_id = request.auth.get("id") + + if user_type != "company": + return CustomResponse( + general_message="Only companies can view job analytics." + ).get_unauthorized_response() + + job = LaunchpadJobs.objects.filter(id=job_id).only("id", "title", "company_id").first() + if not job: + return CustomResponse( + general_message="Job not found." + ).get_failure_response(status_code=404, http_status_code=404) + + if job.company_id != company_id: + return CustomResponse( + general_message="You can only view analytics for jobs posted by your company." + ).get_unauthorized_response() + + application_stats = LaunchpadJobApplications.objects.filter(job_id=job_id).aggregate( + total_applications=Count("id"), + accepted=Count("id", filter=Q(status="accepted")), + rejected=Count("id", filter=Q(status="rejected")), + interview_scheduled=Count("id", filter=Q(status="interview_scheduled")), + invited=Count("id", filter=Q(status="invited")), + applied=Count("id", filter=Q(status="applied")), + ) + + return CustomResponse( + response={ + "job_id": job.id, + "job_title": job.title, + "total_applications": application_stats.get("total_applications", 0) or 0, + "accepted": application_stats.get("accepted", 0) or 0, + "rejected": application_stats.get("rejected", 0) or 0, + "interview_scheduled": application_stats.get("interview_scheduled", 0) or 0, + "invited": application_stats.get("invited", 0) or 0, + "applied": application_stats.get("applied", 0) or 0, + }, + general_message="Job analytics fetched successfully", + ).get_success_response() + + +class JobTrendsAnalyticsAPI(APIView): + authentication_classes = [LaunchpadJWTPermission] + + def get(self, request): + user_type = request.auth.get("user_type") + company_id = request.auth.get("id") + + if user_type != "company": + return CustomResponse( + general_message="Only companies can view application trends." + ).get_unauthorized_response() + + from_param = request.query_params.get("from_date") + to_param = request.query_params.get("to_date") + from_date = self._parse_date(from_param) if from_param else None + if from_param and from_date is None: + return CustomResponse( + general_message="Invalid date format. Use YYYY-MM-DD" + ).get_failure_response() + + to_date = self._parse_date(to_param) if to_param else None + if to_param and to_date is None: + return CustomResponse( + general_message="Invalid date format. Use YYYY-MM-DD" + ).get_failure_response() + + if from_date and to_date and from_date > to_date: + return CustomResponse( + general_message="from_date cannot be after to_date" + ).get_failure_response() + + filters = Q(job__company_id=company_id) + if from_date: + filters &= Q(created_at__date__gte=from_date) + if to_date: + filters &= Q(created_at__date__lte=to_date) + + trends = ( + LaunchpadJobApplications.objects.filter(filters) + .annotate(date=TruncDate("created_at")) + .values("date") + .annotate(applications=Count("id")) + .order_by("date") + ) + + return CustomResponse( + response={ + "trends": [ + { + "date": item["date"].isoformat() if item["date"] else None, + "applications": item["applications"], + } + for item in trends + ] + }, + general_message="Application trends fetched successfully", + ).get_success_response() + + @staticmethod + def _parse_date(value: str): + return parse_date(value) + + +class JobsSummaryAnalyticsAPI(APIView): + authentication_classes = [LaunchpadJWTPermission] + + def get(self, request): + user_type = request.auth.get("user_type") + user_id = request.auth.get("id") + + if user_type != "company": + return CustomResponse( + general_message="Only companies can view jobs summary analytics." + ).get_unauthorized_response() + + jobs = LaunchpadJobs.objects.filter(company_id=user_id) + applications = LaunchpadJobApplications.objects.filter(job__company_id=user_id) + + summary = applications.aggregate( + total_applications=Count("id"), + accepted=Count("id", filter=Q(status="accepted")), + rejected=Count("id", filter=Q(status="rejected")), + interview_scheduled=Count("id", filter=Q(status="interview_scheduled")), + invited=Count("id", filter=Q(status="invited")), + applied=Count("id", filter=Q(status="applied")), + ) + + return CustomResponse( + response={ + "total_jobs": jobs.count(), + "total_applications": summary.get("total_applications", 0) or 0, + "accepted": summary.get("accepted", 0) or 0, + "rejected": summary.get("rejected", 0) or 0, + "interview_scheduled": summary.get("interview_scheduled", 0) or 0, + "invited": summary.get("invited", 0) or 0, + "applied": summary.get("applied", 0) or 0, + }, + general_message="Jobs summary analytics fetched successfully", + ).get_success_response() class ListJobsAPI(APIView): authentication_classes = [CustomizePermission, LaunchpadJWTPermission] diff --git a/api/launchpad/urls.py b/api/launchpad/urls.py index 575dd78f1..814514926 100644 --- a/api/launchpad/urls.py +++ b/api/launchpad/urls.py @@ -6,7 +6,7 @@ RefreshTokenAPI, CompanyVerifyAPI, ListJobsAPI, VerifyTaskAPI, ListLaunchpadStudentsAPI, SendJobInvitationsAPI, StudentJobInvitationsAPI, StudentApplyToJobAPI, AcceptedStudentsAPI, ScheduleInterviewAPI, ApplicationFinalDecisionAPI, CompanyListVerifiedAPI, DeleteCompanyAPI,JobAPI,ForgotPasswordAPI, ResetPasswordAPI, VerifyResetTokenAPI, ChangePasswordAPI, - DeleteCompanyAPI, + DeleteCompanyAPI, JobAnalyticsAPI, JobTrendsAnalyticsAPI, JobsSummaryAnalyticsAPI, ) urlpatterns = [ @@ -34,6 +34,9 @@ path('schedule-interview/', ScheduleInterviewAPI.as_view(), name='schedule-interview'), path('application-final-decision/', ApplicationFinalDecisionAPI.as_view(), name='application-final-decision'), path('delete-company/', DeleteCompanyAPI.as_view()), + path('analytics/jobs-summary/', JobsSummaryAnalyticsAPI.as_view()), + path('analytics/jobs//', JobAnalyticsAPI.as_view()), + path('analytics/trends/', JobTrendsAnalyticsAPI.as_view()), #<----------------------- old launchpad --------------------------> path("leaderboard/", launchpad_views.Leaderboard.as_view()), path(