File size: 11,871 Bytes
ffd36e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from django.db import transaction
from django.db.models import Exists, OuterRef
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import generics, permissions, serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView

from apps.resources.models import Resource, ResourceCheckpoint, SkillResource
from apps.roles.models import UserTargetRole
from apps.skills.models import Skill

from .models import UserCheckpointProgress, UserProgress
from .serializers import UserProgressSerializer
from .services import (
    apply_upgrade,
    compute_upgrade_suggestions,
    dismiss_upgrade,
)


class UserProgressListView(generics.ListAPIView):
    # Bounded per-user (≀ few dozen tracked resources). Intentionally
    # unpaginated so the frontend can render the full learning plan in one
    # pass. Revisit if a user ever tracks hundreds of resources.
    serializer_class = UserProgressSerializer
    permission_classes = [permissions.IsAuthenticated]
    pagination_class = None

    def get_queryset(self):
        # Annotate `resource_has_checkpoints` as a single Exists subquery so
        # UserProgressSerializer can read it without per-row queries.
        #
        # Also annotate `in_current_plan_annot`: whether each tracked resource
        # is linked to a skill required by the user's *active* target role.
        # Computed against a Python list of the active role's skill ids to
        # avoid a fragile nested OuterRef; no active target β†’ empty list β†’
        # __in=[] β†’ all False (matches the progress-write 403 gate). Annotate,
        # NEVER filter β€” the frontend renders out-of-plan rows as read-only and
        # the e2e flow asserts the full tracked-resource count.
        active_target = (
            UserTargetRole.objects
            .filter(user=self.request.user, is_active=True)
            .select_related('role')
            .first()
        )
        target_skill_ids = (
            list(active_target.role.role_skills.values_list('skill_id', flat=True))
            if active_target else []
        )
        return (
            UserProgress.objects
            .filter(user=self.request.user)
            .select_related('resource')
            .annotate(
                resource_has_checkpoints=Exists(
                    ResourceCheckpoint.objects.filter(
                        resource=OuterRef('resource_id'),
                    )
                ),
                in_current_plan_annot=Exists(
                    SkillResource.objects.filter(
                        resource=OuterRef('resource_id'),
                        skill_id__in=target_skill_ids,
                    )
                ),
            )
        )


class ResourceProgressView(APIView):
    """Per-resource progress. Supports manual-slider mode when the resource

    has no checkpoints β€” otherwise the progress int is read-only and driven

    by the checkpoint rollup.

    """
    serializer_class = UserProgressSerializer
    permission_classes = [permissions.IsAuthenticated]

    @extend_schema(responses=UserProgressSerializer)
    def get(self, request, resource_id):
        resource = get_object_or_404(Resource, id=resource_id)
        # Read-only: never write on GET. A get_or_create here pollutes the
        # progress table on mere reads and races with concurrent requests.
        # Synthesize an unsaved zero-progress instance when none exists β€” its
        # id serializes to None and has_checkpoints falls back to the resource.
        progress = (
            UserProgress.objects
            .filter(user=request.user, resource=resource)
            .first()
            or UserProgress(user=request.user, resource=resource)
        )
        return Response(UserProgressSerializer(progress).data)

    @extend_schema(

        request=inline_serializer(

            name='ResourceProgressUpdate',

            fields={

                'progress': serializers.IntegerField(min_value=0, max_value=100),

            },

        ),

        responses=UserProgressSerializer,

    )
    @transaction.atomic
    def post(self, request, resource_id):
        resource = get_object_or_404(Resource, id=resource_id)

        active_target = (
            UserTargetRole.objects
            .filter(user=request.user, is_active=True)
            .select_related('role')
            .first()
        )
        if active_target is None:
            return Response(
                {'detail': 'Select an active target role before tracking progress.'},
                status=status.HTTP_403_FORBIDDEN,
            )
        target_skill_ids = active_target.role.role_skills.values_list('skill_id', flat=True)
        if not resource.skillresource_set.filter(skill_id__in=target_skill_ids).exists():
            return Response(
                {'detail': 'Resource is not part of your current learning plan.'},
                status=status.HTTP_403_FORBIDDEN,
            )

        progress, _ = UserProgress.objects.get_or_create(
            user=request.user, resource=resource,
        )

        if resource.checkpoints.exists():
            return Response(
                {'detail': 'Resource has checkpoints; update per-checkpoint progress instead.'},
                status=status.HTTP_400_BAD_REQUEST,
            )

        new_progress = request.data.get('progress')
        if new_progress is None:
            return Response(
                {'detail': 'progress (0-100) is required.'},
                status=status.HTTP_400_BAD_REQUEST,
            )
        try:
            new_progress = int(new_progress)
        except (TypeError, ValueError):
            return Response(
                {'detail': 'progress must be an integer 0-100.'},
                status=status.HTTP_400_BAD_REQUEST,
            )
        if not 0 <= new_progress <= 100:
            return Response(
                {'detail': 'progress must be in range 0-100.'},
                status=status.HTTP_400_BAD_REQUEST,
            )

        now = timezone.now()
        progress.progress = new_progress
        if new_progress == 0:
            progress.status = 'NOT_STARTED'
            progress.completed_at = None
        elif new_progress == 100:
            progress.status = 'COMPLETED'
            if not progress.started_at:
                progress.started_at = now
            if not progress.completed_at:
                progress.completed_at = now
        else:
            progress.status = 'IN_PROGRESS'
            if not progress.started_at:
                progress.started_at = now
            progress.completed_at = None
        progress.save()
        return Response(UserProgressSerializer(progress).data)


@extend_schema(responses=OpenApiTypes.OBJECT)
class UpgradeSuggestionListView(APIView):
    permission_classes = [permissions.IsAuthenticated]

    def get(self, request):
        suggestions = compute_upgrade_suggestions(request.user)
        return Response({'suggestions': suggestions})


@extend_schema(

    request=None,

    responses={

        200: OpenApiTypes.OBJECT,

        404: OpenApiResponse(description='Skill not found.'),

    },

)
class UpgradeSuggestionApplyView(APIView):
    permission_classes = [permissions.IsAuthenticated]

    def post(self, request, skill_id):
        try:
            Skill.objects.get(id=skill_id)
        except Skill.DoesNotExist:
            return Response(
                {'detail': 'Skill not found.'},
                status=status.HTTP_404_NOT_FOUND,
            )
        result = apply_upgrade(request.user, skill_id)
        return Response(result)


@extend_schema(

    request=None,

    responses={

        200: OpenApiTypes.OBJECT,

        404: OpenApiResponse(description='Skill not found.'),

    },

)
class UpgradeSuggestionDismissView(APIView):
    permission_classes = [permissions.IsAuthenticated]

    def post(self, request, skill_id):
        try:
            Skill.objects.get(id=skill_id)
        except Skill.DoesNotExist:
            return Response(
                {'detail': 'Skill not found.'},
                status=status.HTTP_404_NOT_FOUND,
            )
        result = dismiss_upgrade(request.user, skill_id)
        return Response(result)


@extend_schema(

    request=inline_serializer(

        name='CheckpointToggleRequest',

        fields={

            'completed': serializers.BooleanField(

                required=False,

                help_text='Explicit target state. Omit to toggle current state.',

            ),

        },

    ),

    responses=UserProgressSerializer,

)
class CheckpointToggleView(APIView):
    serializer_class = UserProgressSerializer
    permission_classes = [permissions.IsAuthenticated]

    @transaction.atomic
    def post(self, request, checkpoint_id):
        checkpoint = get_object_or_404(
            ResourceCheckpoint.objects.select_related('resource'),
            id=checkpoint_id,
        )

        # Scope toggles to resources linked to the user's active target-role
        # skills. Without this, any authenticated user could fake progress on
        # arbitrary resources. An active target role is required β€” without one,
        # there is no learning plan to attribute progress to.
        active_target = (
            UserTargetRole.objects
            .filter(user=request.user, is_active=True)
            .select_related('role')
            .first()
        )
        if active_target is None:
            return Response(
                {'detail': 'Select an active target role before tracking progress.'},
                status=status.HTTP_403_FORBIDDEN,
            )
        target_skill_ids = active_target.role.role_skills.values_list('skill_id', flat=True)
        resource_in_plan = checkpoint.resource.skillresource_set.filter(
            skill_id__in=target_skill_ids,
        ).exists()
        if not resource_in_plan:
            return Response(
                {'detail': 'Resource is not part of your current learning plan.'},
                status=status.HTTP_403_FORBIDDEN,
            )

        # Materialize the UserProgress row race-safely, THEN lock it. A bare
        # select_for_update().first() locks nothing when no row exists yet, so
        # two concurrent first-toggles both create β†’ IntegrityError on
        # unique_together(user, resource) β†’ 500. get_or_create (savepoint-safe
        # inside this atomic) absorbs that race; the re-fetch with
        # select_for_update then takes the row lock for the sibling rollup so
        # concurrent toggles on sibling checkpoints see a consistent count.
        UserProgress.objects.get_or_create(
            user=request.user, resource=checkpoint.resource,
        )
        progress = UserProgress.objects.select_for_update().get(
            user=request.user, resource=checkpoint.resource,
        )

        cp_progress, _ = UserCheckpointProgress.objects.get_or_create(
            user=request.user, checkpoint=checkpoint,
        )
        desired = request.data.get('completed')

        if desired is None:
            cp_progress.completed_at = None if cp_progress.completed_at else timezone.now()
        else:
            cp_progress.completed_at = timezone.now() if desired else None
        cp_progress.save()

        progress.recalculate_from_checkpoints()
        return Response(UserProgressSerializer(progress).data)