diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml new file mode 100644 index 000000000..b5df2b149 --- /dev/null +++ b/.github/workflows/triage.yml @@ -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 }} diff --git a/api/dashboard/campus/analytics_views.py b/api/dashboard/campus/analytics_views.py index 129220fc5..834518134 100644 --- a/api/dashboard/campus/analytics_views.py +++ b/api/dashboard/campus/analytics_views.py @@ -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( @@ -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 = [ { diff --git a/api/dashboard/campus/campus_views.py b/api/dashboard/campus/campus_views.py index b7a477ed6..9ea675ebe 100644 --- a/api/dashboard/campus/campus_views.py +++ b/api/dashboard/campus/campus_views.py @@ -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 @@ -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")) @@ -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: @@ -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() @@ -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), ) ) @@ -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", @@ -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: @@ -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() @@ -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), ) ) @@ -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", @@ -810,6 +860,7 @@ 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") @@ -817,32 +868,43 @@ def get(self, request, org_id=None): ) 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 ) @@ -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) @@ -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", }, @@ -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 diff --git a/api/dashboard/campus/dash_campus_helper.py b/api/dashboard/campus/dash_campus_helper.py index eeabdbc5a..307ef7cc4 100644 --- a/api/dashboard/campus/dash_campus_helper.py +++ b/api/dashboard/campus/dash_campus_helper.py @@ -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): diff --git a/api/dashboard/campus/dashboard_views.py b/api/dashboard/campus/dashboard_views.py index 3065208ff..49a26272a 100644 --- a/api/dashboard/campus/dashboard_views.py +++ b/api/dashboard/campus/dashboard_views.py @@ -41,10 +41,12 @@ def _member_funnel(org): onboarded = UserOrganizationLink.objects.filter(org=org, verified=True).count() active = UserOrganizationLink.objects.filter( org=org, + verified=True, user__wallet_user__karma_last_updated_at__gte=timezone.now() - timedelta(days=30), ).count() level_2_plus = UserOrganizationLink.objects.filter( org=org, + verified=True, user__user_lvl_link_user__level__level_order__gte=2, ).count() circle_leads = UserCircleLink.objects.filter( @@ -124,7 +126,9 @@ def _campus_stats(org, since): active_circles = LearningCircle.objects.filter(org=org).count() period_karma = KarmaActivityLog.objects.filter( user__user_organization_link_user__org=org, + user__user_organization_link_user__verified=True, created_at__gte=since, + appraiser_approved=True, ).aggregate(total=Sum("karma")).get("total") or 0 return [ {"key": "active_members", "label": "Active members", "value": active_members, "delta": active_members, "delta_type": "increase", "period": "30d"}, diff --git a/api/dashboard/campus/events_views.py b/api/dashboard/campus/events_views.py index 27b3e2633..c5a1f8167 100644 --- a/api/dashboard/campus/events_views.py +++ b/api/dashboard/campus/events_views.py @@ -4,6 +4,7 @@ from rest_framework.views import APIView from db.organization import UserOrganizationLink +from db.events import Event from db.user import User, Role, UserRoleLink from utils.permission import CustomizePermission, JWTUtils, role_required from utils.response import CustomResponse @@ -57,6 +58,9 @@ def get(self, request): if status := params.get("status"): events = events.filter(status=status) + else: + # Default view excludes unpublished drafts; pass ?status=draft to see them. + events = events.exclude(status=Event.Status.DRAFT.value) if scope := params.get("scope"): events = events.filter(scope=scope) @@ -188,6 +192,7 @@ def get(self, request): campus_user_ids = UserOrganizationLink.objects.filter( org=org, org__org_type=OrganizationType.COLLEGE.value, + is_alumni=False, ).values_list("user_id", flat=True) # Extracted all non-execom system roles to properly support listing dynamic custom/IG roles diff --git a/api/dashboard/campus/serializers.py b/api/dashboard/campus/serializers.py index bf2feabeb..fd48f6aef 100644 --- a/api/dashboard/campus/serializers.py +++ b/api/dashboard/campus/serializers.py @@ -53,34 +53,42 @@ def get_campus_level(self, obj): return None def get_total_members(self, obj): - return obj.user_organization_link_org.values('user').distinct().count() + return obj.user_organization_link_org.filter(verified=True).values('user').distinct().count() def get_active_members(self, obj): return obj.user_organization_link_org.filter( + verified=True, user__discord_id__isnull=False, user__exist_in_guild=True ).values('user').distinct().count() def get_total_karma(self, obj): from db.task import Wallet - users_in_org = obj.user_organization_link_org.values_list('user', flat=True) + users_in_org = obj.user_organization_link_org.filter( + verified=True + ).values_list('user', flat=True) return Wallet.objects.filter( user__in=users_in_org ).aggregate(total_karma=Sum("karma"))["total_karma"] or 0 def get_rank(self, obj): - org_karma_dict = ( + # (org, user, karma) collapsed via .distinct() so a user with more than + # one org-link row to the same college can't inflate that college's + # karma total relative to every other college in this ranking. + rows = ( UserOrganizationLink.objects.filter( - org__org_type=OrganizationType.COLLEGE.value + org__org_type=OrganizationType.COLLEGE.value, + verified=True, ) - .values("org") - .annotate(total_karma=Sum("user__wallet_user__karma")) - ).order_by("-total_karma", "org__created_at") + .values("org", "user", "user__wallet_user__karma") + .distinct() + .order_by("org__created_at") + ) - rank_dict = { - data["org"]: data["total_karma"] if data["total_karma"] is not None else 0 - for data in org_karma_dict - } + rank_dict = {} + for row in rows: + org_id = row["org"] + rank_dict[org_id] = rank_dict.get(org_id, 0) + (row["user__wallet_user__karma"] or 0) sorted_rank_dict = dict( sorted(rank_dict.items(), key=lambda x: x[1], reverse=True) @@ -144,6 +152,7 @@ def get_lead(self, obj): campus_lead = User.objects.filter( user_organization_link_user__org=obj.org, user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + user_organization_link_user__verified=True, user_role_link_user__role__title=RoleType.CAMPUS_LEAD.value, ).first() if campus_lead: @@ -152,6 +161,7 @@ def get_lead(self, obj): enabler = User.objects.filter( user_organization_link_user__org=obj.org, user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + user_organization_link_user__verified=True, user_role_link_user__role__title=RoleType.LEAD_ENABLER.value, ).first() if enabler: @@ -169,42 +179,51 @@ def get_campus_level(self, obj): def get_total_members(self, obj): - return obj.org.user_organization_link_org.count() + return obj.org.user_organization_link_org.filter(verified=True).values('user').distinct().count() def get_active_members(self, obj): + from db.task import Wallet last_month = DateTimeUtils.get_current_utc_time() - timedelta( weeks=26 ) # 6months - return obj.org.user_organization_link_org.filter( - verified=True, - user__wallet_user__isnull=False, - user__wallet_user__karma_last_updated_at__gte=last_month, + verified_user_ids = obj.org.user_organization_link_org.filter( + verified=True + ).values_list("user_id", flat=True).distinct() + return Wallet.objects.filter( + user_id__in=verified_user_ids, + karma_last_updated_at__gte=last_month, ).count() def get_total_karma(self, obj): - return ( - obj.org.user_organization_link_org.filter( - org__org_type=OrganizationType.COLLEGE.value, - verified=True, - user__wallet_user__isnull=False, - ).aggregate(total_karma=Sum("user__wallet_user__karma"))["total_karma"] - or 0 - ) + from db.task import Wallet + + verified_user_ids = obj.org.user_organization_link_org.filter( + org__org_type=OrganizationType.COLLEGE.value, + verified=True, + ).values_list("user_id", flat=True).distinct() + return Wallet.objects.filter( + user_id__in=verified_user_ids + ).aggregate(total_karma=Sum("karma"))["total_karma"] or 0 def get_rank(self, obj): - org_karma_dict = ( + # (org, user, karma) collapsed via .distinct() so a user with more than + # one org-link row to the same college can't inflate that college's + # karma total relative to every other college in this ranking. + rows = ( UserOrganizationLink.objects.filter( - org__org_type=OrganizationType.COLLEGE.value + org__org_type=OrganizationType.COLLEGE.value, + verified=True, ) - .values("org") - .annotate(total_karma=Sum("user__wallet_user__karma")) - ).order_by("-total_karma", "org__created_at") + .values("org", "user", "user__wallet_user__karma") + .distinct() + .order_by("org__created_at") + ) - rank_dict = { - data["org"]: data["total_karma"] if data["total_karma"] is not None else 0 - for data in org_karma_dict - } + rank_dict = {} + for row in rows: + org_id = row["org"] + rank_dict[org_id] = rank_dict.get(org_id, 0) + (row["user__wallet_user__karma"] or 0) sorted_rank_dict = dict( sorted(rank_dict.items(), key=lambda x: x[1], reverse=True) @@ -216,19 +235,27 @@ def get_rank(self, obj): return position + 1 def get_karma_last_7_days(self, obj): seven_days_ago = DateTimeUtils.get_current_utc_time() - timedelta(days=7) + verified_user_ids = obj.org.user_organization_link_org.filter( + verified=True + ).values_list("user_id", flat=True).distinct() return ( KarmaActivityLog.objects.filter( - user__user_organization_link_user__org=obj.org, + user_id__in=verified_user_ids, created_at__gte=seven_days_ago, + appraiser_approved=True, ).aggregate(total_karma=Sum("karma"))["total_karma"] or 0 ) def get_karma_last_30_days(self, obj): thirty_days_ago = DateTimeUtils.get_current_utc_time() - timedelta(days=30) + verified_user_ids = obj.org.user_organization_link_org.filter( + verified=True + ).values_list("user_id", flat=True).distinct() return ( KarmaActivityLog.objects.filter( - user__user_organization_link_user__org=obj.org, + user_id__in=verified_user_ids, created_at__gte=thirty_days_ago, + appraiser_approved=True, ).aggregate(total_karma=Sum("karma"))["total_karma"] or 0 ) def get_active_ig_count(self, obj): @@ -305,14 +332,19 @@ def to_representation(self, instance): today = DateTimeUtils.get_current_utc_time().date() date_range = [today - timedelta(days=i) for i in range(7)] + verified_user_ids = instance.user_organization_link_org.filter( + verified=True + ).values_list("user_id", flat=True).distinct() + for date in date_range: karma_logs = KarmaActivityLog.objects.filter( - user__user_organization_link_user__org=instance, + user_id__in=verified_user_ids, created_at__date=date, + appraiser_approved=True, ).aggregate( karma=Sum("karma"), ) - response[str(date)] = karma_logs.get("karma", 0) + response[str(date)] = karma_logs["karma"] or 0 return response @@ -498,8 +530,11 @@ def get_lead_name(self, obj): def get_campus_ig_member_count(self, obj): return UserIgLink.objects.filter( ig=obj.ig, + is_active=True, + assignment_type=UserIgLink.AssignmentType.LEARNER, user__user_organization_link_user__org=obj.org, - ).count() + user__user_organization_link_user__verified=True, + ).values('user').distinct().count() class CampusIGChapterCreateSerializer(serializers.ModelSerializer): diff --git a/api/dashboard/college/college_view.py b/api/dashboard/college/college_view.py index 73771ea03..8a5b53584 100644 --- a/api/dashboard/college/college_view.py +++ b/api/dashboard/college/college_view.py @@ -70,21 +70,29 @@ def patch(self, request): existing_links = UserOrganizationLink.objects.filter( user_id=user_id, org__org_type=OrganizationType.COLLEGE.value - ) + ).order_by('-created_at', '-id') if existing_links.exists(): - update_data = { - 'org': new_organization, - 'verified': False - } - - if department_id is not None: - update_data['department'] = department - - existing_links.update(**update_data) current_link = existing_links.select_related('department').first() - current_department = current_link.department - + + # Dedupe: a user should only ever have one college link. Drop any + # extras so the final .get() below can't raise MultipleObjectsReturned. + duplicate_ids = list( + existing_links.exclude(id=current_link.id).values_list('id', flat=True) + ) + if duplicate_ids: + UserOrganizationLink.objects.filter(id__in=duplicate_ids).delete() + + current_link.org = new_organization + # Auto-verified: there is no manual campus-transfer approval + # flow, so leaving this False would strand the student + # unverified at both the old and new campus indefinitely. + current_link.verified = True + if department_id is not None: + current_link.department = department + current_link.save() + final_department = current_link.department + if department_id is not None: if department: message = ( @@ -97,24 +105,25 @@ def patch(self, request): f"Department removed" ) else: - if current_department: + if final_department: message = ( f"College updated successfully to {new_organization.title}. " - f"Department remains {current_department.title}" + f"Department remains {final_department.title}" ) else: message = f"College updated successfully to {new_organization.title}" - + action = "updated" else: - UserOrganizationLink.objects.create( + new_link = UserOrganizationLink.objects.create( user=user, org=new_organization, department=department, - verified=False, + verified=True, created_by=user, ) - + final_department = new_link.department + if department: message = ( f"College linked successfully to {new_organization.title}. " @@ -122,14 +131,8 @@ def patch(self, request): ) else: message = f"College linked successfully to {new_organization.title}" - - action = "created" - final_link = UserOrganizationLink.objects.select_related('department').get( - user_id=user_id, - org__org_type=OrganizationType.COLLEGE.value - ) - final_department = final_link.department + action = "created" return CustomResponse( general_message=message, diff --git a/api/dashboard/college/serializer.py b/api/dashboard/college/serializer.py index 835b2c4d0..063fcd444 100644 --- a/api/dashboard/college/serializer.py +++ b/api/dashboard/college/serializer.py @@ -45,11 +45,14 @@ def get_no_of_lc(self, obj): return {'lc_count': learning_circle_count, 'no_of_lc_increased': no_of_lc_increased} def get_number_of_members(self, obj): - member_count = obj.org.user_organization_link_org.all().count() + member_count = obj.org.user_organization_link_org.filter( + verified=True + ).values('user').distinct().count() no_of_members_increased = obj.org.user_organization_link_org.filter( + verified=True, created_at__gte=DateTimeUtils.get_current_utc_time() - timedelta(days=30) - ).count() + ).values('user').distinct().count() return {'member_count': member_count, 'no_of_members_increased': no_of_members_increased} def get_total_karma(self, obj): @@ -61,6 +64,7 @@ def get_total_karma(self, obj): total_karma_gained = ( KarmaActivityLog.objects.filter( user__in=user_org_links.values('user'), + appraiser_approved=True, ).aggregate(total_karma=Sum("karma"))["total_karma"] or 0 ) @@ -68,16 +72,16 @@ def get_total_karma(self, obj): total_karma_increased = ( KarmaActivityLog.objects.filter( user__in=user_org_links.values('user'), + appraiser_approved=True, created_at__gte=DateTimeUtils.get_current_utc_time() - timedelta( days=30), ).aggregate(total_karma=Sum("karma"))["total_karma"] or 0 ) - try: + if total_karma_gained: increased_percentage = (total_karma_increased / total_karma_gained) * 100 - except Exception as e: + else: increased_percentage = 0 - return increased_percentage return {'total_karma_gained': total_karma_gained, 'total_karma_increased': total_karma_increased, 'increased_percentage': increased_percentage} diff --git a/api/dashboard/company/company_views.py b/api/dashboard/company/company_views.py index ca44e4911..d817e47c7 100644 --- a/api/dashboard/company/company_views.py +++ b/api/dashboard/company/company_views.py @@ -397,12 +397,7 @@ def get(self, request): general_message="Verified company profile not found." ).get_failure_response(status_code=404) - from db.organization import Organization - from utils.types import OrganizationType - org = Organization.objects.filter( - title=company.name, - org_type=OrganizationType.COMPANY.value, - ).first() + org = company.org if not org: return CustomResponse( diff --git a/api/dashboard/company/serializers.py b/api/dashboard/company/serializers.py index d69a11431..f773e7423 100644 --- a/api/dashboard/company/serializers.py +++ b/api/dashboard/company/serializers.py @@ -1,5 +1,6 @@ import uuid from rest_framework import serializers +from django.db import transaction from django.utils.text import slugify from db.company import Company @@ -197,10 +198,21 @@ def validate(self, data): def update(self, instance, validated_data): validated_data['updated_at'] = DateTimeUtils.get_current_utc_time() validated_data['updated_by'] = self.context.get("user_id", instance.company_user_id) - + + new_name = validated_data.get("name") + rename = new_name and new_name != instance.name + for attr, value in validated_data.items(): setattr(instance, attr, value) - instance.save() + + with transaction.atomic(): + instance.save() + + if rename and instance.org: + instance.org.title = new_name + instance.org.updated_at = DateTimeUtils.get_current_utc_time() + instance.org.save(update_fields=["title", "updated_at"]) + return instance class CompanyListSerializer(serializers.ModelSerializer): @@ -262,7 +274,7 @@ def update(self, instance, validated_data): instance.rejection_reason = None # ── Ensure the company's Organization row exists ───────────────── - org = Organization.objects.filter( + org = instance.org or Organization.objects.filter( title=instance.name, org_type=OrganizationType.COMPANY.value, ).first() @@ -278,6 +290,7 @@ def update(self, instance, validated_data): created_at=DateTimeUtils.get_current_utc_time(), updated_at=DateTimeUtils.get_current_utc_time(), ) + instance.org = org # ── Link the company creator to the org ────────────────────────── from db.organization import UserOrganizationLink @@ -377,10 +390,7 @@ def validate(self, data): ) # ── Resolve company → Organization row ────────────────────────────── - org = Organization.objects.filter( - title=company.name, - org_type=OrganizationType.COMPANY.value, - ).first() + org = company.org if not org: raise serializers.ValidationError( "Company organization record not found. Ensure the company is verified." diff --git a/api/dashboard/company/task_views.py b/api/dashboard/company/task_views.py index ca87c283d..3f49a7b40 100644 --- a/api/dashboard/company/task_views.py +++ b/api/dashboard/company/task_views.py @@ -83,9 +83,8 @@ def get(self, request): if approval_status: queryset = queryset.filter(approval_status=approval_status) else: - # Exclude soft-deleted tasks (active=False after being previously approved) - # Only exclude tasks that are inactive AND already approved/rejected (not pending) - queryset = queryset.exclude(active=False, approval_status="approved") + # Exclude soft-deleted tasks (active=False), regardless of approval_status + queryset = queryset.exclude(active=False) paginated = CommonUtils.get_paginated_queryset( queryset, diff --git a/api/dashboard/events/manage_views.py b/api/dashboard/events/manage_views.py index be2358e40..bb6a72823 100644 --- a/api/dashboard/events/manage_views.py +++ b/api/dashboard/events/manage_views.py @@ -67,13 +67,9 @@ def _get_user_company_org_ids(user_id, roles): if RoleType.COMPANY.value in roles: from db.company import Company - from db.organization import Organization - from utils.types import OrganizationType company = Company.objects.filter(company_user_id=user_id, status="verified").first() - if company: - org = Organization.objects.filter(title=company.name, org_type=OrganizationType.COMPANY.value).first() - if org: - company_org_ids.add(org.id) + if company and company.org: + company_org_ids.add(company.org_id) if RoleType.MENTOR.value in roles: from db.user import MentorScopeGrant diff --git a/api/dashboard/events/meta_views.py b/api/dashboard/events/meta_views.py index 95eeae691..c0978be50 100644 --- a/api/dashboard/events/meta_views.py +++ b/api/dashboard/events/meta_views.py @@ -9,10 +9,68 @@ from utils.permission import CustomizePermission, JWTUtils from utils.response import CustomResponse from utils.types import RoleType -from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse, OpenApiParameter +from drf_spectacular.openapi import OpenApiTypes from rest_framework import serializers as s +class LinkableEventsAPI(APIView): + """ + GET /events/meta/linkable-events/ + Returns events the caller may attach a task to, for the task + create/edit "Linked Event" picker. + + Reuses the exact predicate EventTaskPublicListAPI trusts when deciding + whether a task's linked event is visible to the caller (live events, + status PUBLISHED/ONGOING, same scope filter). Sharing the predicate is + load-bearing: if this picker offered an event that the task list's + accessible_event_ids check later rejected, an admin could create a task + that immediately disappeared from their own list. + """ + + @extend_schema(tags=['Dashboard - Events'], description="Retrieve Linkable Events.", + parameters=[ + OpenApiParameter( + "search", + OpenApiTypes.STR, + OpenApiParameter.QUERY, + required=False, + description="Filter by event title (case-insensitive contains).", + ), + ], + responses={200: inline_serializer( + name='LinkableEventItem', + fields={ + 'id': s.CharField(), + 'title': s.CharField(), + 'start_datetime': s.DateTimeField(), + }, + many=True, + )}, + ) + def get(self, request): + from api.dashboard.events.public_views import _get_viewer_id, _build_scope_filter + from api.dashboard.events.serializers import get_live_events + + viewer_id = _get_viewer_id(request) + scope_filter = _build_scope_filter(viewer_id) + + events = get_live_events().filter( + scope_filter, + status__in=[Event.Status.PUBLISHED, Event.Status.ONGOING], + ) + + search = request.query_params.get("search") + if search: + events = events.filter(title__icontains=search) + + events = events.order_by("-start_datetime").values( + "id", "title", "start_datetime" + )[:100] + + return CustomResponse(response=list(events)).get_success_response() + + class EventCategoriesAPI(APIView): """ GET /events/meta/categories/ @@ -122,15 +180,15 @@ def get(self, request): # Company: user with Company role in a company org or UserMentor with COMPANY_MENTOR company_options = {} if RoleType.COMPANY.value in roles: - user_orgs = UserOrganizationLink.objects.filter( - user_id=user_id, verified=True - ).select_related('org') - for link in user_orgs: - if link.org.org_type == 'Company': - company_options[link.org.id] = { - 'id': link.org.id, - 'title': link.org.title, - } + from db.company import Company + company = Company.objects.filter( + company_user_id=user_id, status="verified" + ).select_related('org').first() + if company and company.org: + company_options[company.org.id] = { + 'id': company.org.id, + 'title': company.org.title, + } if RoleType.MENTOR.value in roles: from db.user import MentorScopeGrant diff --git a/api/dashboard/events/urls.py b/api/dashboard/events/urls.py index 7d32f8b17..4e0a00fe6 100644 --- a/api/dashboard/events/urls.py +++ b/api/dashboard/events/urls.py @@ -9,6 +9,7 @@ path('meta/organizer-options/', meta_views.OrganizerOptionsAPI.as_view()), path('meta/collaboration-targets/', meta_views.CollaborationTargetsAPI.as_view()), path('meta/event-type-scope/', meta_views.EventTypesScopesAPI.as_view()), + path('meta/linkable-events/', meta_views.LinkableEventsAPI.as_view()), # ── Scoped Feeds ────────────────────────────────────────── # cluster must come BEFORE ig// to avoid being swallowed by wildcard diff --git a/api/dashboard/ig/dash_ig_view.py b/api/dashboard/ig/dash_ig_view.py index a2a7abd90..5ee15666a 100644 --- a/api/dashboard/ig/dash_ig_view.py +++ b/api/dashboard/ig/dash_ig_view.py @@ -672,6 +672,9 @@ def get(self, request): general_message=f"Invalid status. Must be one of: {', '.join(valid_statuses)}" ).get_failure_response() ig_queryset = ig_queryset.filter(status=status_filter) + else: + # Default ("All") view excludes cancelled requests; use ?status=cancelled to see them. + ig_queryset = ig_queryset.exclude(status='cancelled') paginated_queryset = CommonUtils.get_paginated_queryset( ig_queryset, diff --git a/api/dashboard/ig/services.py b/api/dashboard/ig/services.py index a5abbc473..2f5ef196f 100644 --- a/api/dashboard/ig/services.py +++ b/api/dashboard/ig/services.py @@ -9,14 +9,20 @@ def get_campus_igs(org_id: str): """ return InterestGroup.objects.filter( user_ig_link_ig__user__user_organization_link_user__org_id=org_id, - user_ig_link_ig__user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value + user_ig_link_ig__user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + user_ig_link_ig__user__user_organization_link_user__verified=True, + user_ig_link_ig__is_active=True, + user_ig_link_ig__assignment_type=UserIgLink.AssignmentType.LEARNER, ).annotate( campus_member_count=Count( - 'user_ig_link_ig__user', + 'user_ig_link_ig__user', distinct=True, filter=Q( user_ig_link_ig__user__user_organization_link_user__org_id=org_id, - user_ig_link_ig__user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value + user_ig_link_ig__user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + user_ig_link_ig__user__user_organization_link_user__verified=True, + user_ig_link_ig__is_active=True, + user_ig_link_ig__assignment_type=UserIgLink.AssignmentType.LEARNER, ) ) ).distinct() @@ -27,6 +33,9 @@ def get_top_igs(org_id: str): def get_ig_members(ig_id: str, org_id: str): return UserIgLink.objects.filter( ig_id=ig_id, + is_active=True, + assignment_type=UserIgLink.AssignmentType.LEARNER, user__user_organization_link_user__org_id=org_id, - user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value + user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + user__user_organization_link_user__verified=True, ).select_related('user', 'user__wallet_user', 'user__user_lvl_link_user__level') diff --git a/api/dashboard/learningcircle/services.py b/api/dashboard/learningcircle/services.py index 63827df87..563caba38 100644 --- a/api/dashboard/learningcircle/services.py +++ b/api/dashboard/learningcircle/services.py @@ -8,7 +8,7 @@ def get_campus_learning_circles(org_id: str): """ return LearningCircle.objects.filter(org_id=org_id).annotate( member_count=Count('user_circle_link_circle__user', distinct=True, filter=Q(user_circle_link_circle__accepted=True)), - meeting_count=Count('circle_meeting_log_circle_id'), + meeting_count=Count('circle_meeting_log_circle_id', distinct=True), last_meeting_time=Max('circle_meeting_log_circle_id__meet_time') ) diff --git a/api/dashboard/mentor/mentor_views.py b/api/dashboard/mentor/mentor_views.py index f9ca18e8b..513d4d0ab 100644 --- a/api/dashboard/mentor/mentor_views.py +++ b/api/dashboard/mentor/mentor_views.py @@ -101,14 +101,17 @@ def get(self, request): from .dash_mentor_helper import get_mentor_company organization = get_mentor_company(mentor) - return CustomResponse( - response={ - "status": mentor.status, - "organization": organization, - "verified_by": getattr(mentor.verified_by, "full_name", None) if mentor.verified_by else None, - "verified_at": mentor.verified_at, - } - ).get_success_response() + response = { + "status": mentor.status, + "organization": organization, + "verified_by": getattr(mentor.verified_by, "full_name", None) if mentor.verified_by else None, + "verified_at": mentor.verified_at, + } + + if mentor.status == UserMentor.Status.REJECTED: + response["rejection_reason"] = mentor.verification_note + + return CustomResponse(response=response).get_success_response() class MentorActivityListAPI(APIView): permission_classes = [CustomizePermission] diff --git a/api/dashboard/task/dash_task_serializer.py b/api/dashboard/task/dash_task_serializer.py index fed1dbb8b..62ae42f2a 100644 --- a/api/dashboard/task/dash_task_serializer.py +++ b/api/dashboard/task/dash_task_serializer.py @@ -115,6 +115,17 @@ class Meta: "event_id", ) + def validate_event_id(self, value): + if not value: + return value + + try: + uuid.UUID(str(value)) + except (ValueError, TypeError): + raise serializers.ValidationError("Invalid event id.") + + return value + class TaskImportSerializer(serializers.ModelSerializer): created_by_id = serializers.CharField(required=True, allow_null=False) diff --git a/api/dashboard/task/dash_task_view.py b/api/dashboard/task/dash_task_view.py index a2d0b6111..1b3b7fb5c 100644 --- a/api/dashboard/task/dash_task_view.py +++ b/api/dashboard/task/dash_task_view.py @@ -386,11 +386,17 @@ def post(self, request): # create except: skill_ids = [] - serializer = TaskModifySerializer(data=mutable_data) + serializer = TaskModifySerializer(data=mutable_data, context={"request": request}) if not serializer.is_valid(): return CustomResponse(message=serializer.errors).get_failure_response() + event_fk_id = serializer.validated_data.get("event_fk_id") + if event_fk_id and not _is_event_accessible(request, event_fk_id): + return CustomResponse( + message={"event_id": ["Selected event does not exist or is not accessible."]} + ).get_failure_response() + task = serializer.save() # Handle skill links @@ -475,13 +481,22 @@ def put(self, request, task_id): # edit general_message="Task not found." ).get_failure_response(status_code=404, http_status_code=status.HTTP_404_NOT_FOUND) - serializer = TaskModifySerializer(task, data=mutable_data, partial=True) + serializer = TaskModifySerializer( + task, data=mutable_data, partial=True, context={"request": request} + ) if not serializer.is_valid(): return CustomResponse(message=serializer.errors).get_failure_response() + if "event_fk_id" in serializer.validated_data: + event_fk_id = serializer.validated_data.get("event_fk_id") + if event_fk_id and not _is_event_accessible(request, event_fk_id): + return CustomResponse( + message={"event_id": ["Selected event does not exist or is not accessible."]} + ).get_failure_response() + serializer.save() - + # Handle skill links if provided if skill_ids is not None: self._save_task_skills(task_id, skill_ids, user_id) @@ -927,6 +942,28 @@ def get(self, request): return CustomResponse(response=task_types).get_success_response() +def _is_event_accessible(request, event_id): + """ + Authorization check: is this event live (PUBLISHED/ONGOING) and within + the caller's visibility scope? Lives in the view layer, not the + serializer, since this is a permission decision, not a data-integrity + one. Reuses the same predicate accessible_event_ids/linkable-events + trust, so a task can never link to an event the caller can't see. + """ + from api.dashboard.events.public_views import _get_viewer_id, _build_scope_filter + from api.dashboard.events.serializers import get_live_events + from db.events import Event + + viewer_id = _get_viewer_id(request) + scope_filter = _build_scope_filter(viewer_id) + + return get_live_events().filter( + scope_filter, + status__in=[Event.Status.PUBLISHED, Event.Status.ONGOING], + id=event_id, + ).exists() + + class EventDropDownApi(APIView): authentication_classes = [CustomizePermission] diff --git a/db/company.py b/db/company.py index 7da36a36c..138384646 100644 --- a/db/company.py +++ b/db/company.py @@ -4,6 +4,7 @@ class Company(models.Model): id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) company_user = models.OneToOneField('User', on_delete=models.CASCADE, related_name='company_profile') + org = models.ForeignKey('Organization', on_delete=models.SET_NULL, null=True, blank=True, related_name='companies') name = models.CharField(max_length=75, unique=True) logo = models.TextField(null=True, blank=True) description = models.TextField() diff --git a/db/learning_circle.py b/db/learning_circle.py index 59063a8aa..082f3089f 100644 --- a/db/learning_circle.py +++ b/db/learning_circle.py @@ -18,7 +18,11 @@ class LearningCircle(models.Model): title = models.CharField(max_length=100, blank=False, null=False) created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column="created_by", related_name="learning_circle_created_by") - created_at = models.DateTimeField(auto_now=True) + # Was `auto_now=True` until 2026-07-20, which overwrote created_at on every save(). + # Rows saved before that fix carry a created_at that reflects their last edit, not + # their actual creation time, and this cannot be recovered without a manual + # DB-level backfill (there is no other historical source for the true value). + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: