Skip to content

Commit ad5bce0

Browse files
asadali145Copilot
andauthored
feat: command to reset attempts and rescore problem (#789)
* feat: command to reset attempts and rescore promlem * fmt * install in LMS plus refactoring * more changes * fix docs * review chnages * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * refactor --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 7d132c9 commit ad5bce0

7 files changed

Lines changed: 372 additions & 4 deletions

File tree

src/ol_openedx_course_sync/README.rst

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@ For detailed installation instructions, please refer to the `plugin installation
1515

1616
Installation required in:
1717

18-
* CMS
18+
* CMS (for course sync functionality)
19+
* LMS (for problem attempts reset and rescore functionality)
1920

2021
Configuration
21-
=============
22+
==============
23+
24+
CMS Configuration
25+
-----------------
2226

2327
* Add a setting ``OL_OPENEDX_COURSE_SYNC_SERVICE_WORKER_USERNAME`` for the service worker and all the sync operations will be done on behalf of this user.
2428

@@ -33,10 +37,57 @@ Configuration
3337
Usage
3438
-----
3539

40+
Course Sync (CMS)
41+
~~~~~~~~~~~~~~~~~
42+
3643
* Install the plugin and run the migrations in the CMS.
3744
* Add the parent/source organization in the CMS admin model `CourseSyncOrganization`.
3845
* Course sync will only work for this organization. It will treat all the courses under this organization as parent/source courses.
3946
* The plugin will automatically add course re-runs created from the CMS as the child courses.
4047
* The organization can be different for the reruns.
4148
* Target/rerun courses can be managed in the CMS admin model `CourseSyncMapping`.
4249
* Now, any changes made in the source course will be synced to the target courses.
50+
51+
Problem Actions (LMS)
52+
~~~~~~~~~~~~~~~~~~~~~
53+
54+
The plugin provides a management command to reset learner attempts or rescore problems across the source course and all its synced target courses.
55+
56+
**Command:** ``sync_problem_actions``
57+
58+
**Syntax:**
59+
60+
.. code-block:: bash
61+
62+
python manage.py lms sync_problem_actions <action> <source_course_key> <problem_id> [OPTIONS]
63+
64+
**Actions:**
65+
66+
* ``reset_attempts``: Resets learner attempts for a problem
67+
* ``rescore``: Rescores learners for a problem
68+
69+
**Options:**
70+
71+
* ``--username USERNAME``: Username to run the task as (default: 'courses_service_worker')
72+
* ``--only-if-higher`` / ``--no-only-if-higher``: Whether to rescore only if the new score is higher (default: True)
73+
74+
**Examples:**
75+
76+
Reset attempts for a problem across all synced courses:
77+
78+
.. code-block:: bash
79+
80+
python manage.py lms sync_problem_actions reset_attempts \
81+
"course-v1:ORG+COURSE+RUN" \
82+
"block-v1:ORG+COURSE+RUN+type@problem+block@abc123" \
83+
--username courses_service_worker
84+
85+
Rescore a problem for all learners across all synced courses:
86+
87+
.. code-block:: bash
88+
89+
python manage.py lms sync_problem_actions rescore \
90+
"course-v1:ORG+COURSE+RUN" \
91+
"block-v1:ORG+COURSE+RUN+type@problem+block@abc123" \
92+
--username courses_service_worker \
93+
--only-if-higher

src/ol_openedx_course_sync/ol_openedx_course_sync/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,11 @@ class OLOpenEdxCourseSyncConfig(AppConfig):
3434
},
3535
SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: "settings.common"},
3636
},
37+
ProjectType.LMS: {
38+
SettingsType.PRODUCTION: {
39+
PluginSettings.RELATIVE_PATH: "settings.production"
40+
},
41+
SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: "settings.common"},
42+
},
3743
},
3844
}

src/ol_openedx_course_sync/ol_openedx_course_sync/management/__init__.py

Whitespace-only changes.

src/ol_openedx_course_sync/ol_openedx_course_sync/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
"""
2+
Management command to reset attempts or rescore problem for synced courses.
3+
4+
Resets attempts or rescores learners for a problem across all courses in a sync mapping
5+
(source course and all target courses).
6+
7+
Usage:
8+
python manage.py lms sync_problem_actions <action> \
9+
<source_course_key> <problem_id> [OPTIONS]
10+
11+
Actions:
12+
reset_attempts: Resets learner attempts for a problem
13+
rescore: Rescores learner for a problem
14+
15+
Options:
16+
--username USERNAME
17+
Username to run the task as (default: 'courses_service_worker')
18+
--only-if-higher / --no-only-if-higher
19+
Whether to rescore only if the new score is higher (default: True)
20+
21+
Examples:
22+
python manage.py lms sync_problem_actions reset_attempts \
23+
"course-v1:ORG+COURSE+RUN" \
24+
"block-v1:ORG+COURSE+RUN+type@problem+block@abc123" \
25+
--username courses_service_worker
26+
27+
python manage.py lms sync_problem_actions rescore \
28+
"course-v1:ORG+COURSE+RUN" \
29+
"block-v1:ORG+COURSE+RUN+type@problem+block@abc123" \
30+
--username courses_service_worker \
31+
--only-if-higher
32+
"""
33+
34+
import argparse
35+
36+
from django.contrib.auth import get_user_model
37+
from django.core.management.base import BaseCommand, CommandError
38+
from django.test.client import RequestFactory
39+
from lms.djangoapps.instructor_task import api as task_api
40+
from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError
41+
from opaque_keys import InvalidKeyError
42+
from opaque_keys.edx.keys import CourseKey, UsageKey
43+
from xmodule.modulestore.exceptions import ItemNotFoundError
44+
45+
from ol_openedx_course_sync.utils import get_syncable_course_mappings
46+
47+
User = get_user_model()
48+
49+
ACTION_RESET_ATTEMPTS = "reset_attempts"
50+
ACTION_RESCORE = "rescore"
51+
VALID_ACTIONS = [ACTION_RESET_ATTEMPTS, ACTION_RESCORE]
52+
53+
STATUS_SUBMITTED = "submitted"
54+
STATUS_ALREADY_RUNNING = "already_running"
55+
STATUS_FAILED = "failed"
56+
57+
58+
class Command(BaseCommand):
59+
"""
60+
Reset attempts or rescore problem for all synced courses.
61+
"""
62+
63+
help = "Reset attempts or rescore problem across all synced courses"
64+
65+
def add_arguments(self, parser):
66+
"""Add command arguments."""
67+
parser.add_argument(
68+
"action",
69+
type=str,
70+
choices=VALID_ACTIONS,
71+
help="Action to perform: 'reset_attempts' or 'rescore'",
72+
)
73+
parser.add_argument(
74+
"source_course_key",
75+
type=str,
76+
help="Source course key (e.g., 'course-v1:ORG+COURSE+RUN')",
77+
)
78+
parser.add_argument(
79+
"problem_id",
80+
type=str,
81+
help=(
82+
"Problem usage key "
83+
"(e.g., 'block-v1:ORG+COURSE+RUN+type@problem+block@id')"
84+
),
85+
)
86+
parser.add_argument(
87+
"--username",
88+
type=str,
89+
default="courses_service_worker",
90+
help="Username to run the task as (default: 'courses_service_worker')",
91+
)
92+
parser.add_argument(
93+
"--only-if-higher",
94+
action=argparse.BooleanOptionalAction,
95+
default=True,
96+
help=(
97+
"Whether to rescore only if the new score is higher "
98+
"(default: True, use --no-only-if-higher to disable)"
99+
),
100+
)
101+
102+
def handle(self, **options):
103+
"""Execute the command."""
104+
action = options["action"]
105+
source_course_key_str = options["source_course_key"]
106+
problem_id_str = options["problem_id"]
107+
username = options["username"]
108+
only_if_higher = options["only_if_higher"]
109+
110+
try:
111+
source_course_key = CourseKey.from_string(source_course_key_str)
112+
except InvalidKeyError as exc:
113+
error_msg = f"Invalid source course key: {source_course_key_str}"
114+
raise CommandError(error_msg) from exc
115+
116+
try:
117+
problem_usage_key = UsageKey.from_string(problem_id_str)
118+
except InvalidKeyError as exc:
119+
error_msg = f"Invalid problem usage key: {problem_id_str}"
120+
raise CommandError(error_msg) from exc
121+
122+
courses = self._get_synced_courses(source_course_key)
123+
try:
124+
request_obj = self._make_shell_request(username)
125+
except User.DoesNotExist as exc:
126+
error_msg = f"User not found: {username}"
127+
raise CommandError(error_msg) from exc
128+
129+
if action == ACTION_RESET_ATTEMPTS:
130+
results = self._submit_for_courses(
131+
request_obj, courses, problem_usage_key, ACTION_RESET_ATTEMPTS
132+
)
133+
elif action == ACTION_RESCORE:
134+
results = self._submit_for_courses(
135+
request_obj,
136+
courses,
137+
problem_usage_key,
138+
ACTION_RESCORE,
139+
only_if_higher=only_if_higher,
140+
)
141+
142+
self._print_summary(results, action)
143+
144+
def _get_synced_courses(self, source_course_key):
145+
"""
146+
Get all courses for a sync mapping (source + all targets).
147+
148+
Args:
149+
source_course_key: CourseKey of the source course
150+
151+
Returns:
152+
List of course key strings
153+
"""
154+
courses = [str(source_course_key)]
155+
mappings = get_syncable_course_mappings(source_course_key)
156+
if mappings:
157+
courses.extend(str(mapping.target_course) for mapping in mappings)
158+
return courses
159+
160+
def _make_shell_request(self, username):
161+
"""
162+
Create a request object for shell execution.
163+
164+
Args:
165+
username: Username to associate with the request
166+
167+
Returns:
168+
Request object with user context
169+
170+
Raises:
171+
User.DoesNotExist: If user not found
172+
"""
173+
user = User.objects.get(username=username)
174+
req = RequestFactory().post(
175+
"/shell/instructor-task",
176+
HTTP_USER_AGENT="lms-shell",
177+
REMOTE_ADDR="127.0.0.1",
178+
SERVER_NAME="localhost",
179+
)
180+
req.user = user
181+
return req
182+
183+
def _submit_for_courses(
184+
self,
185+
request,
186+
course_keys,
187+
problem_usage_key,
188+
action,
189+
*,
190+
only_if_higher=False,
191+
):
192+
"""
193+
Submit reset/rescore tasks for courses.
194+
195+
Args:
196+
request: Request object with user context
197+
course_keys: List of course keys
198+
problem_usage_key: UsageKey of the problem
199+
action: Action to perform (reset_attempts or rescore)
200+
only_if_higher: Only rescore if new score is higher (rescore only)
201+
202+
Returns:
203+
List of result dictionaries
204+
"""
205+
results = []
206+
207+
for course_id_str in course_keys:
208+
try:
209+
course_key = CourseKey.from_string(course_id_str)
210+
mapped_problem_key = problem_usage_key.map_into_course(course_key)
211+
212+
if action == ACTION_RESET_ATTEMPTS:
213+
task = task_api.submit_reset_problem_attempts_for_all_students(
214+
request, mapped_problem_key
215+
)
216+
elif action == ACTION_RESCORE:
217+
task = task_api.submit_rescore_problem_for_all_students(
218+
request, mapped_problem_key, only_if_higher=only_if_higher
219+
)
220+
221+
row = {
222+
"course_id": course_id_str,
223+
"action": action,
224+
"mapped_problem_id": str(mapped_problem_key),
225+
"task_id": task.task_id,
226+
"status": STATUS_SUBMITTED,
227+
}
228+
if action == ACTION_RESCORE:
229+
row["only_if_higher"] = only_if_higher
230+
231+
results.append(row)
232+
if action == ACTION_RESET_ATTEMPTS:
233+
self.stdout.write(
234+
"OK | "
235+
f"{action.upper()} | {course_id_str} | {mapped_problem_key} "
236+
f"| task={task.task_id}"
237+
)
238+
elif action == ACTION_RESCORE:
239+
self.stdout.write(
240+
"OK | "
241+
f"{action.upper()} | {course_id_str} | {mapped_problem_key} "
242+
f"| only_if_higher={only_if_higher} | task={task.task_id}"
243+
)
244+
245+
except AlreadyRunningError as exc:
246+
row = {
247+
"course_id": course_id_str,
248+
"action": action,
249+
"status": STATUS_ALREADY_RUNNING,
250+
"error": str(exc),
251+
}
252+
results.append(row)
253+
self.stdout.write(
254+
self.style.WARNING(
255+
f"SKIP(already running) | {action} | {course_id_str} | {exc}"
256+
)
257+
)
258+
259+
except (InvalidKeyError, ItemNotFoundError, ValueError) as exc:
260+
row = {
261+
"course_id": course_id_str,
262+
"action": action,
263+
"status": STATUS_FAILED,
264+
"error": str(exc),
265+
}
266+
results.append(row)
267+
self.stdout.write(
268+
self.style.ERROR(f"FAIL | {action} | {course_id_str} | {exc}")
269+
)
270+
271+
except Exception as exc: # pylint: disable=broad-except # noqa: BLE001
272+
row = {
273+
"course_id": course_id_str,
274+
"action": action,
275+
"status": STATUS_FAILED,
276+
"error": str(exc),
277+
}
278+
results.append(row)
279+
self.stdout.write(
280+
self.style.ERROR(f"FAIL | {action} | {course_id_str} | {exc}")
281+
)
282+
283+
return results
284+
285+
def _print_summary(self, results, action):
286+
"""Print summary of operation."""
287+
submitted = sum(1 for r in results if r["status"] == STATUS_SUBMITTED)
288+
already_running = sum(
289+
1 for r in results if r["status"] == STATUS_ALREADY_RUNNING
290+
)
291+
failed = sum(1 for r in results if r["status"] == STATUS_FAILED)
292+
293+
self.stdout.write("\n" + "=" * 50)
294+
self.stdout.write(f"{action.upper()} Summary")
295+
self.stdout.write("=" * 50)
296+
self.stdout.write(f"Total courses: {len(results)}")
297+
self.stdout.write(self.style.SUCCESS(f"Submitted: {submitted}"))
298+
if already_running:
299+
self.stdout.write(
300+
self.style.WARNING(f"Already running: {already_running}")
301+
)
302+
if failed:
303+
self.stdout.write(self.style.ERROR(f"Failed: {failed}"))
304+
self.stdout.write("=" * 50)

0 commit comments

Comments
 (0)