Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0e940c0
Create triage workflow for issues and PRs
awindsr Jul 20, 2026
c4688b6
Merge pull request #3035 from gtech-mulearn/awindsr-patch-1
awindsr Jul 20, 2026
f469c8c
feat(task-management): add linkable events API and task event validation
DevWithPranav Jul 20, 2026
e14e50a
refactor(task-event): move event authorization check to view layer
DevWithPranav Jul 20, 2026
d92eec1
Merge pull request #3036 from DevWithPranav/dev
DevWithPranav Jul 20, 2026
a265fce
Merge pull request #3037 from gtech-mulearn/dev
DevWithPranav Jul 20, 2026
9271fc2
feat(dashboard/mentor): include rejection reason in mentor status res…
DevWithPranav Jul 20, 2026
2df45c6
Merge pull request #3038 from DevWithPranav/dev
DevWithPranav Jul 20, 2026
30f8849
Merge pull request #3039 from gtech-mulearn/dev
DevWithPranav Jul 20, 2026
5c53584
refactor(company): link company to organization via foreign key
DevWithPranav Jul 20, 2026
036a6ca
refactor(company): sync organization title on rename and use FK in ev…
DevWithPranav Jul 20, 2026
63e7b11
fix(company): wrap rename in transaction and hide all soft-deleted tasks
DevWithPranav Jul 20, 2026
406e783
feat(dashboard/ig): hide cancelled interest group requests by default
DevWithPranav Jul 20, 2026
de73125
refactor(dashboard/events): use company foreign key for organizer opt…
DevWithPranav Jul 20, 2026
9879bbd
Merge pull request #3040 from DevWithPranav/dev
DevWithPranav Jul 20, 2026
b576842
Merge pull request #3041 from gtech-mulearn/dev
DevWithPranav Jul 20, 2026
78eff6e
fix(dashboard): correct analytics counts and deduplicate user relations
DevWithPranav Jul 20, 2026
52c6e22
fix(dashboard): refine student leaderboard and IG member verification
DevWithPranav Jul 20, 2026
b9e061e
Merge pull request #3042 from DevWithPranav/dev
DevWithPranav Jul 20, 2026
71baff7
Merge pull request #3043 from gtech-mulearn/dev
DevWithPranav Jul 20, 2026
c12d237
feat: implement campus dashboard analytics and management views with …
DevWithPranav Jul 20, 2026
7ab9be8
Merge pull request #3044 from DevWithPranav/dev
DevWithPranav Jul 20, 2026
305430a
Merge pull request #3045 from gtech-mulearn/dev
DevWithPranav Jul 20, 2026
141df05
feat: add campus dashboard views and serializers to manage college da…
DevWithPranav Jul 20, 2026
842c813
Merge pull request #3046 from DevWithPranav/dev
DevWithPranav Jul 20, 2026
9e043d8
Merge pull request #3047 from gtech-mulearn/dev
DevWithPranav Jul 20, 2026
43abcee
feat: implement campus dashboard API views for managing and retrievin…
DevWithPranav Jul 20, 2026
2324915
Merge pull request #3048 from DevWithPranav/dev
DevWithPranav Jul 20, 2026
50be930
Merge pull request #3049 from gtech-mulearn/dev
DevWithPranav Jul 20, 2026
df23c2f
feat: implement campus dashboard API views for managing organization …
DevWithPranav Jul 20, 2026
3e38446
feat: implement dashboard campus and college serializers and API view…
DevWithPranav Jul 20, 2026
d47966a
Merge pull request #3050 from DevWithPranav/dev
DevWithPranav Jul 20, 2026
595238f
Merge pull request #3051 from gtech-mulearn/dev
DevWithPranav Jul 20, 2026
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
28 changes: 28 additions & 0 deletions .github/workflows/triage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Board intake — the ONLY automation in the standard: add every new issue
# & PR to the µLearn Delivery project (#16). Status defaults to Triage via
# the project's "item added" built-in workflow.
# Everything else (triage decisions, labels, QA, releases) is manual.
#
# Token: ADD_TO_PROJECT_TOKEN — PAT with org `Projects: read-write` scope
# (GITHUB_TOKEN cannot write to org-level Projects).

name: Add to Delivery board

on:
issues:
types: [opened, reopened]
pull_request_target:
types: [opened, reopened]

permissions: {}

jobs:
add-to-project:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Add to µLearn Delivery
uses: actions/add-to-project@v1.0.2
with:
project-url: https://github.com/orgs/gtech-mulearn/projects/16
github-token: ${{ secrets.ADD_TO_PROJECT_TOKEN }}
31 changes: 22 additions & 9 deletions api/dashboard/campus/analytics_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ def get(self, request):

qs = KarmaActivityLog.objects.filter(
user__user_organization_link_user__org=org,
created_at__gte=start_date
user__user_organization_link_user__verified=True,
created_at__gte=start_date,
appraiser_approved=True,
).annotate(
date=TruncDate('created_at')
).values('date').annotate(
Expand Down Expand Up @@ -111,14 +113,25 @@ def get(self, request):
if cached_data:
return CustomResponse(response=cached_data).get_success_response()

current_month = timezone.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
previous_month = (current_month - timedelta(days=1)).replace(day=1)

current_members = UserOrganizationLink.objects.filter(org=org, is_alumni=False, created_at__gte=current_month).count()
prev_members = UserOrganizationLink.objects.filter(org=org, is_alumni=False, created_at__gte=previous_month, created_at__lt=current_month).count()

current_lcs = lc_services.get_campus_learning_circles(org.id).filter(created_at__gte=current_month).count()
prev_lcs = lc_services.get_campus_learning_circles(org.id).filter(created_at__gte=previous_month, created_at__lt=current_month).count()
# True rolling 30-day windows so the "period: 30d" label is accurate
# regardless of what day of the month it is (a calendar-month-to-date
# window would under-report early in the month).
now = timezone.now()
current_window_start = now - timedelta(days=30)
previous_window_start = now - timedelta(days=60)

current_members = UserOrganizationLink.objects.filter(
org=org, is_alumni=False, verified=True, created_at__gte=current_window_start
).count()
prev_members = UserOrganizationLink.objects.filter(
org=org, is_alumni=False, verified=True,
created_at__gte=previous_window_start, created_at__lt=current_window_start,
).count()

current_lcs = lc_services.get_campus_learning_circles(org.id).filter(created_at__gte=current_window_start).count()
prev_lcs = lc_services.get_campus_learning_circles(org.id).filter(
created_at__gte=previous_window_start, created_at__lt=current_window_start
).count()

data = [
{
Expand Down
131 changes: 108 additions & 23 deletions api/dashboard/campus/campus_views.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from django.db.models import Count, F,Sum, Subquery, OuterRef
from django.db.models import Count, F,Sum, Subquery, OuterRef, IntegerField
from django.db.models.functions import Coalesce
from django.db.models import Q
from django.db import transaction
from rest_framework.views import APIView
from collections import defaultdict
import uuid
from db.organization import Organization, UserOrganizationLink
from db.task import Level, Wallet, InterestGroup
from db.task import Level, Wallet, InterestGroup, UserIgLink
from db.campus import CampusIGChapter, CampusSocialLink
from db.learning_circle import UserCircleLink
from db.user import User, Role, UserRoleLink
from utils.permission import CustomizePermission, JWTUtils, role_required
from utils.response import CustomResponse
Expand Down Expand Up @@ -182,8 +184,11 @@ def get(self, request, org_id=None):
students=Count(
"user_lvl_link_level__user",
filter=Q(
user_lvl_link_level__user__user_organization_link_user__org=org
user_lvl_link_level__user__user_organization_link_user__org=org,
user_lvl_link_level__user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value,
user_lvl_link_level__user__user_organization_link_user__verified=True,
),
distinct=True,
)
).values(level=F("level_order"), students=F("students"))

Expand Down Expand Up @@ -219,10 +224,12 @@ def get(self, request):
wallet_filters = Q(
user__user_organization_link_user__org=user_org_link.org,
user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value,
user__user_organization_link_user__verified=True,
)
user_filters = Q(
user_organization_link_user__org=user_org_link.org,
user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value,
user_organization_link_user__verified=True,
)

if is_alumni is not None:
Expand All @@ -246,6 +253,26 @@ def get(self, request):
)
ranks = {user["user_id"]: i + 1 for i, user in enumerate(rank)}

# Correlated subqueries (not joined Counts) so they can't be inflated
# by the ig_id/category .filter() calls applied to user_filters above,
# which also traverse user_ig_link_user.
ig_count_sq = (
UserIgLink.objects.filter(
user_id=OuterRef("pk"),
is_active=True,
assignment_type=UserIgLink.AssignmentType.LEARNER,
)
.values("user_id")
.annotate(count=Count("id", distinct=True))
.values("count")
)
lc_count_sq = (
UserCircleLink.objects.filter(user_id=OuterRef("pk"), accepted=True)
.values("user_id")
.annotate(count=Count("id", distinct=True))
.values("count")
)

user_org_links = (
User.objects.filter(user_filters)
.distinct()
Expand All @@ -255,11 +282,13 @@ def get(self, request):
mobile_=F("mobile"),
karma=F("wallet_user__karma"),
level=F("user_lvl_link_user__level__name"),
join_date=F("created_at"),
join_date=F("user_organization_link_user__created_at"), # org join date, not account signup date
last_karma_gained=F("wallet_user__karma_last_updated_at"),
department=F("user_organization_link_user__department__title"),
graduation_year=F("user_organization_link_user__graduation_year"),
is_alumni=F("user_organization_link_user__is_alumni"),
ig_count=Coalesce(Subquery(ig_count_sq, output_field=IntegerField()), 0),
lc_count=Coalesce(Subquery(lc_count_sq, output_field=IntegerField()), 0),
)
)

Expand All @@ -273,7 +302,7 @@ def get(self, request):
"karma": "wallet_user__karma",
"level": "user_lvl_link_user__level__level_order",
# "is_active": "karma_activity_log_user__created_at",
"join_date": "created_at",
"join_date": "user_organization_link_user__created_at",
"email": "email_",
"mobile": "mobile_",
"is_alumni": "is_alumni",
Expand Down Expand Up @@ -320,10 +349,12 @@ def get(self, request):
wallet_filters = Q(
user__user_organization_link_user__org=user_org_link.org,
user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value,
user__user_organization_link_user__verified=True,
)
user_filters = Q(
user_organization_link_user__org=user_org_link.org,
user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value,
user_organization_link_user__verified=True,
)

if is_alumni is not None:
Expand All @@ -347,6 +378,23 @@ def get(self, request):
)
ranks = {user["user_id"]: i + 1 for i, user in enumerate(rank)}

ig_count_sq = (
UserIgLink.objects.filter(
user_id=OuterRef("pk"),
is_active=True,
assignment_type=UserIgLink.AssignmentType.LEARNER,
)
.values("user_id")
.annotate(count=Count("id", distinct=True))
.values("count")
)
lc_count_sq = (
UserCircleLink.objects.filter(user_id=OuterRef("pk"), accepted=True)
.values("user_id")
.annotate(count=Count("id", distinct=True))
.values("count")
)

user_org_links = (
User.objects.filter(user_filters)
.distinct()
Expand All @@ -356,11 +404,13 @@ def get(self, request):
mobile_=F("mobile"),
karma=F("wallet_user__karma"),
level=F("user_lvl_link_user__level__name"),
join_date=F("created_at"),
join_date=F("user_organization_link_user__created_at"), # org join date, not account signup date
last_karma_gained=F("wallet_user__karma_last_updated_at"),
department=F("user_organization_link_user__department__title"),
graduation_year=F("user_organization_link_user__graduation_year"),
is_alumni=F("user_organization_link_user__is_alumni"),
ig_count=Coalesce(Subquery(ig_count_sq, output_field=IntegerField()), 0),
lc_count=Coalesce(Subquery(lc_count_sq, output_field=IntegerField()), 0),
)
)

Expand All @@ -374,7 +424,7 @@ def get(self, request):
"karma": "wallet_user__karma",
"level": "user_lvl_link_user__level__level_order",
# "is_active": "karma_activity_log_user__created_at",
"join_date": "created_at",
"join_date": "user_organization_link_user__created_at",
"email": "email_",
"mobile": "mobile_",
"is_alumni": "is_alumni",
Expand Down Expand Up @@ -810,39 +860,51 @@ def get(self, request, org_id=None):
Wallet.objects.filter(
user__user_organization_link_user__org=org,
user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value,
user__user_organization_link_user__verified=True,
)
.distinct()
.order_by("-karma","-created_at")
.values("user_id")
)
ranks = {r["user_id"]: i + 1 for i, r in enumerate(rank_qs)}


# 2. Base queryset - all students with annotations #

ig_count_subquery = (
UserIgLink.objects.filter(
user_id=OuterRef("id"),
is_active=True,
assignment_type=UserIgLink.AssignmentType.LEARNER,
)
.values("user_id")
.annotate(count=Count("id", distinct=True))
.values("count")
)

qs = (
User.objects.filter(
user_organization_link_user__org=org,
user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value,
user_organization_link_user__verified=True,
)
.distinct()
.annotate(
user_id=F("id"),
user_id=F("id"),
# existing fields from CampusStudentDetailsAPI
karma=F("wallet_user__karma"),
level=F("user_lvl_link_user__level__name"),
join_date=F("created_at"),
# org join date (fixed from created_at)
join_date=F("user_organization_link_user__created_at"), # org join date, not account signup date
last_karma_gained=F("wallet_user__karma_last_updated_at"),
department=F("user_organization_link_user__department__title"),
graduation_year=F("user_organization_link_user__graduation_year"),
is_alumni=F("user_organization_link_user__is_alumni"),

# new fields not in existing API
ig_count=Count(
"user_ig_link_user",
distinct=True
),
# Computed as a correlated subquery (not a joined Count) so it can't be
# inflated by the ig_id/category .filter() calls added below, which also
# traverse user_ig_link_user and would otherwise fan out the join.
ig_count=Coalesce(Subquery(ig_count_subquery, output_field=IntegerField()), 0),
)
.order_by("-wallet_user__karma", "-wallet_user__created_at") # ← rank 1 appears on page 1
)
Expand Down Expand Up @@ -873,6 +935,8 @@ def get(self, request, org_id=None):
qs = qs.filter(
user_organization_link_user__is_alumni=is_alumni_bool
)
# No default is_alumni filter: every verified campus member (alumni
# included) appears on the leaderboard unless ?is_alumni= is passed.
if search:
qs = qs.filter(
Q(full_name__icontains=search) | Q(muid__icontains=search)
Expand All @@ -890,7 +954,7 @@ def get(self, request, org_id=None):
"muid": "muid",
"karma": "wallet_user__karma",
"level": "user_lvl_link_user__level__level_order",
"join_date": "created_at",
"join_date": "user_organization_link_user__created_at",
"graduation_year": "user_organization_link_user__graduation_year",
"is_alumni": "user_organization_link_user__is_alumni",
},
Expand Down Expand Up @@ -955,29 +1019,50 @@ def get(self, request, org_id=None):
user=OuterRef("pk")
).values("karma")[:1]

# Subquery: each user's single "primary" category — their earliest
# active LEARNER IG membership (mentor/lead/moderator assignments are
# staff roles, not personal category membership, so they're excluded
# here just like every other IG-membership count in this app). A
# member can belong to IGs spanning several categories; without
# picking exactly one, their full karma would get added to every
# category they touch, making the bucket totals sum to several times
# the campus's actual total_karma.
primary_category_sq = (
UserIgLink.objects.filter(
user_id=OuterRef("pk"),
is_active=True,
assignment_type=UserIgLink.AssignmentType.LEARNER,
)
.order_by("created_at")
.values("ig__category")[:1]
)

# Single query — LEFT JOIN via isnull=False removed
# users with NO IG will have category=None → goes to "unclustered"
all_rows = (
User.objects.filter(
user_organization_link_user__org=org,
user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value,
user_organization_link_user__verified=True,
)
.annotate(
user_karma=Subquery(wallet_karma_sq),
primary_category=Subquery(primary_category_sq),
)
.annotate(user_karma=Subquery(wallet_karma_sq))
.values("id", "user_ig_link_user__ig__category", "user_karma")
.distinct() # 1 row per (user_id, category) — kills IG fan-out
.values("id", "primary_category", "user_karma")
.distinct() # 1 row per user — each user has exactly one primary_category
)

# Aggregate in Python
category_map = defaultdict(lambda: {"total_karma": 0, "member_count": 0, "seen_users": set()})

for row in all_rows: # streams from DB — no list() memory spike
category = row["user_ig_link_user__ig__category"] or "unclustered"
category = row["primary_category"] or "unclustered"
user_id = row["id"]
karma = row["user_karma"] or 0

# seen_users guards against edge case where
# a user has NO IG (category=None) but still appears multiple times
# due to multiple org links
# seen_users guards against edge case where a user appears more
# than once due to multiple org links
if user_id not in category_map[category]["seen_users"]:
category_map[category]["seen_users"].add(user_id)
category_map[category]["total_karma"] += karma
Expand Down
10 changes: 8 additions & 2 deletions api/dashboard/campus/dash_campus_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@


def get_user_college_link(user_id):
return UserOrganizationLink.objects.filter(
"""
Return the user's college org link, preferring a verified membership.
A newer unverified link (e.g. a pending college-transfer request) should
not silently override an established verified membership.
"""
links = UserOrganizationLink.objects.filter(
user_id=user_id,
org__org_type=OrganizationType.COLLEGE.value
).order_by("-created_at", "-id").first()
).order_by("-created_at", "-id")
return links.filter(verified=True).first() or links.first()


def is_approved_campus_mentor(user_id, org):
Expand Down
Loading
Loading