File size: 8,285 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
"""Skill-upgrade suggestion service — closed learning loop (Module B).



See research/08-next-modules-build-plan.md §Module B. This is a stateless

derivation: suggestions are recomputed on demand from UserProgress +

SkillResource + UserSkill state. The only persisted bit is the dismissal

timestamp on UserSkill.dismissed_upgrade_at.

"""
from __future__ import annotations

from dataclasses import asdict, dataclass
from typing import Iterable

from django.db import transaction
from django.db.models import Max
from django.utils import timezone

from apps.resources.models import SkillResource
from apps.skills.models import Skill, UserSkill
from apps.skills.utils import proficiency_to_level

from .models import UserProgress


# Proficiency bump ladder — deterministic, documented.
# <40 → 60, 40–59 → 70, 60–79 → 85, 80–99 → 100, 100 → no suggestion.
_BUMPS: list[tuple[int, int]] = [
    (40, 60),
    (60, 70),
    (80, 85),
    (100, 100),
]


def _suggested_proficiency(current: int) -> int | None:
    if current >= 100:
        return None
    for upper, target in _BUMPS:
        if current < upper:
            return target
    return None


@dataclass
class UpgradeSuggestion:
    skill_id: int
    skill_name: str
    current_proficiency: int
    suggested_proficiency: int
    current_level: str
    suggested_level: str
    source_resource_ids: list[int]

    def to_dict(self) -> dict:
        return asdict(self)


def _completed_progress_by_skill(user) -> dict[int, list[dict]]:
    """Return {skill_id: [{resource_id, completed_at}, ...]} from COMPLETED

    UserProgress rows whose resources link to skills.

    """
    completed = (
        UserProgress.objects
        .filter(user=user, status='COMPLETED')
        .values_list('resource_id', 'completed_at')
    )
    resource_rows = list(completed)
    if not resource_rows:
        return {}

    resource_ids = [rid for rid, _ in resource_rows]
    completed_at_by_res = dict(resource_rows)

    links = SkillResource.objects.filter(resource_id__in=resource_ids)

    by_skill: dict[int, list[dict]] = {}
    for link in links:
        by_skill.setdefault(link.skill_id, []).append({
            'resource_id': link.resource_id,
            'completed_at': completed_at_by_res[link.resource_id],
        })
    return by_skill


def _user_skills_by_id(user, skill_ids: Iterable[int]) -> dict[int, UserSkill]:
    return {
        us.skill_id: us
        for us in UserSkill.objects.filter(
            user=user, skill_id__in=list(skill_ids),
        ).select_related('skill')
    }


def compute_upgrade_suggestions(user) -> list[dict]:
    """Pending upgrade suggestions for a user.



    One suggestion per skill where:

      * user has ≥1 COMPLETED resource linked to the skill

      * bump map yields a proficiency > user's current proficiency

      * the suggestion is not currently dismissed (or dismissal predates the

        most recent completion — self-heal)

    """
    completions_by_skill = _completed_progress_by_skill(user)
    if not completions_by_skill:
        return []

    skill_ids = list(completions_by_skill.keys())
    user_skills = _user_skills_by_id(user, skill_ids)
    skills_by_id = {
        s.id: s for s in Skill.objects.filter(id__in=skill_ids)
    }

    suggestions: list[UpgradeSuggestion] = []

    for skill_id, completions in completions_by_skill.items():
        skill = skills_by_id.get(skill_id)
        if skill is None:
            continue

        us = user_skills.get(skill_id)
        current_prof = us.proficiency if us else 0
        target = _suggested_proficiency(current_prof)
        if target is None or target <= current_prof:
            continue

        if us and us.dismissed_upgrade_at is not None:
            most_recent = max(
                (c['completed_at'] for c in completions
                 if c['completed_at'] is not None),
                default=None,
            )
            if most_recent is None or most_recent <= us.dismissed_upgrade_at:
                continue

        source_ids = sorted(
            {c['resource_id'] for c in completions},
        )
        suggestions.append(UpgradeSuggestion(
            skill_id=skill_id,
            skill_name=skill.skill_name,
            current_proficiency=current_prof,
            suggested_proficiency=target,
            current_level=proficiency_to_level(current_prof) if us
            else 'BEGINNER',
            suggested_level=proficiency_to_level(target),
            source_resource_ids=source_ids,
        ))

    suggestions.sort(key=lambda s: s.skill_name)
    return [s.to_dict() for s in suggestions]


@transaction.atomic
def apply_upgrade(user, skill_id: int) -> dict:
    """Apply the pending upgrade for the given skill.



    Returns {'applied': bool, 'user_skill': {...}, 'already_applied': bool}.

    Idempotent per completion: repeated calls without a new completion return

    already_applied=True. After a successful apply, dismissed_upgrade_at is

    stamped with the latest completion timestamp so `compute_upgrade_suggestions`

    (which already uses this field as a suppression cursor) won't re-surface the

    suggestion until a newer completion arrives.

    """
    skill = Skill.objects.get(id=skill_id)

    completions = _completed_progress_by_skill(user).get(skill_id, [])
    if not completions:
        return {
            'applied': False,
            'already_applied': False,
            'reason': 'no_completed_resource',
        }

    latest_completion = max(
        (c['completed_at'] for c in completions if c['completed_at'] is not None),
        default=None,
    )

    us = UserSkill.objects.filter(user=user, skill=skill).first()
    current_prof = us.proficiency if us else 0

    # Suppression gate: if the latest completion is not newer than the last
    # apply/dismiss cursor, there's nothing new to act on.
    if us and us.dismissed_upgrade_at is not None and latest_completion is not None:
        if latest_completion <= us.dismissed_upgrade_at:
            return {
                'applied': False,
                'already_applied': True,
                'user_skill': _serialize_user_skill(us),
            }

    target = _suggested_proficiency(current_prof)
    if target is None or target <= current_prof:
        return {
            'applied': False,
            'already_applied': True,
            'user_skill': _serialize_user_skill(us) if us else None,
        }

    new_level = proficiency_to_level(target)
    cursor = latest_completion or timezone.now()
    # update_or_create absorbs the first-apply race: two concurrent applies
    # with no prior UserSkill would otherwise collide on unique(user, skill)
    # → IntegrityError → 500. The earlier `us` read is kept for the suppression
    # gate; this is now the single write path for both create and update.
    us, _ = UserSkill.objects.update_or_create(
        user=user, skill=skill,
        defaults={
            'proficiency': target,
            'user_level': new_level,
            'dismissed_upgrade_at': cursor,
        },
    )

    return {
        'applied': True,
        'already_applied': False,
        'user_skill': _serialize_user_skill(us),
    }


@transaction.atomic
def dismiss_upgrade(user, skill_id: int) -> dict:
    skill = Skill.objects.get(id=skill_id)
    us, _ = UserSkill.objects.get_or_create(
        user=user, skill=skill,
        defaults={'proficiency': 0, 'user_level': 'BEGINNER'},
    )
    us.dismissed_upgrade_at = timezone.now()
    us.save(update_fields=['dismissed_upgrade_at', 'updated_at'])
    return {
        'dismissed': True,
        'user_skill': _serialize_user_skill(us),
    }


def _serialize_user_skill(us: UserSkill | None) -> dict | None:
    if us is None:
        return None
    return {
        'skill_id': us.skill_id,
        'skill_name': us.skill.skill_name,
        'proficiency': us.proficiency,
        'user_level': us.user_level,
        'dismissed_upgrade_at': us.dismissed_upgrade_at,
    }