Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions api/dashboard/campus/campus_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
5 changes: 5 additions & 0 deletions api/dashboard/campus/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<str:member_id>/",
campus_views.ChangeStudentTypeAPI.as_view(),
Expand Down
6 changes: 5 additions & 1 deletion api/dashboard/college/college_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions api/dashboard/ig/dash_ig_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
104 changes: 102 additions & 2 deletions api/dashboard/ig/dash_ig_view.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,6 +13,7 @@
from .dash_ig_serializer import (
InterestGroupSerializer,
InterestGroupCreateUpdateSerializer,
IGTaskSummarySerializer,
)
import json
from django.utils.decorators import method_decorator
Expand Down Expand Up @@ -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)
Comment on lines +388 to +392

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The KarmaActivityLog model has appraiser_approved and peer_approved boolean fields (both nullable), indicating it stores submissions in all states: pending (None), approved (True), and rejected (False). Querying without an approval filter will count pending and rejected submissions as "tasks completed" and inflate total_karma_awarded with unearned karma. The summary should restrict to approved entries.

Suggested change
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)
filters = Q(task__ig_id=ig_id, appraiser_approved=True)
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
1 change: 1 addition & 0 deletions api/dashboard/ig/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<str:ig_id>/task-summary/', dash_ig_view.IGTaskSummaryAPI.as_view()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The URL path includes a redundant ig/ segment. The parent api/dashboard/urls.py already mounts this module under path("ig/", ...), so the full registered URL becomes /api/v1/dashboard/ig/ig/<ig_id>/task-summary/ — not the intended /api/v1/dashboard/ig/<ig_id>/task-summary/ shown in the PR description. All other entries in this file omit the ig/ prefix (e.g., <str:pk>/, list/).

Suggested change
path('ig/<str:ig_id>/task-summary/', dash_ig_view.IGTaskSummaryAPI.as_view()),
path('<str:ig_id>/task-summary/', dash_ig_view.IGTaskSummaryAPI.as_view(), name='ig-task-summary'),

path('<str:pk>/', dash_ig_view.InterestGroupAPI.as_view()), # for edit and delete
path('get/<str:pk>/', dash_ig_view.InterestGroupGetAPI.as_view()), # for edit and delete
]
Empty file added api/dashboard/karma/__init__.py
Empty file.
57 changes: 57 additions & 0 deletions api/dashboard/karma/karma_views.py
Original file line number Diff line number Diff line change
@@ -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()
7 changes: 7 additions & 0 deletions api/dashboard/karma/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.urls import path

from . import karma_views

urlpatterns = [
path("histogram/", karma_views.KarmaHistogramAPI.as_view(), name="karma-histogram"),
]
Empty file.
65 changes: 65 additions & 0 deletions api/dashboard/student/student_views.py
Original file line number Diff line number Diff line change
@@ -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()
Comment on lines +62 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 list(students) evaluates the entire User queryset into memory with no pagination. As the number of students grows, this response will become unbounded in size and execution time. The endpoint should use CommonUtils pagination like other listing endpoints in this codebase, or at minimum add a hard LIMIT.

Suggested change
return CustomResponse(
response={"students": list(students)},
general_message="Student participation breakdown fetched successfully",
).get_success_response()
paginated = CommonUtils.paginate_queryset(students, request)
return CustomResponse(
response={"students": list(paginated.get("queryset"))},
general_message="Student participation breakdown fetched successfully",
).get_success_response()

11 changes: 11 additions & 0 deletions api/dashboard/student/urls.py
Original file line number Diff line number Diff line change
@@ -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",
),
]
2 changes: 2 additions & 0 deletions api/dashboard/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
Loading