Skip to content

Commit c6eef83

Browse files
committed
feat: run command from admin
1 parent af29c50 commit c6eef83

7 files changed

Lines changed: 539 additions & 0 deletions

File tree

src/ol_openedx_uai_content_customization/README.rst

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,39 @@ Options
137137
Print what would be created without writing anything to the modulestore.
138138
Use this to verify CSV mapping before committing.
139139

140+
Run from Django Admin
141+
~~~~~~~~~~~~~~~~~~~~~
142+
143+
You can run the same workflow from CMS Django admin using an async job model:
144+
145+
1. Open **Django Admin** and navigate to **UAI Course Generation Jobs**.
146+
2. Create a new job and upload:
147+
148+
- ``customized_csv``
149+
- ``video_assets_csv``
150+
151+
3. Optionally set:
152+
153+
- ``username`` (defaults to ``studio_worker``)
154+
- ``dry_run``
155+
156+
4. Save the job, select it in the changelist, then run action
157+
**Run selected UAI generation job(s)**.
158+
5. Track progress via ``status`` and inspect command logs in ``output``.
159+
160+
Status values:
161+
162+
* ``pending`` - job queued to run.
163+
* ``running`` - task is currently executing.
164+
* ``succeeded`` - command completed successfully.
165+
* ``failed`` - command failed; check ``output`` for details.
166+
167+
.. note::
168+
169+
Admin execution is asynchronous and requires a running Celery worker.
170+
Use ``dry_run`` first for large CSV uploads to validate mappings before
171+
creating courses.
172+
140173
How It Works
141174
~~~~~~~~~~~~
142175

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Django admin for UAI course generation jobs."""
2+
3+
from django.contrib import admin, messages
4+
5+
from ol_openedx_uai_content_customization.models import UAICourseGenerationJob
6+
from ol_openedx_uai_content_customization.tasks import run_uai_course_generation_job
7+
8+
9+
@admin.register(UAICourseGenerationJob)
10+
class UAICourseGenerationJobAdmin(admin.ModelAdmin):
11+
"""Admin UI for creating and running UAI generation jobs."""
12+
13+
list_display = (
14+
"id",
15+
"status",
16+
"dry_run",
17+
"username",
18+
"created_by",
19+
"created_at",
20+
"started_at",
21+
"completed_at",
22+
)
23+
list_filter = ("status", "dry_run", "created_at")
24+
search_fields = ("username", "created_by__username", "task_id")
25+
readonly_fields = (
26+
"status",
27+
"output",
28+
"task_id",
29+
"created_by",
30+
"started_at",
31+
"completed_at",
32+
"created_at",
33+
"updated_at",
34+
)
35+
actions = ("run_selected_jobs",)
36+
37+
@admin.action(description="Run selected UAI generation job(s)")
38+
def run_selected_jobs(self, request, queryset):
39+
"""Queue selected jobs for asynchronous execution."""
40+
queued = 0
41+
skipped = 0
42+
43+
for job in queryset:
44+
if job.status == UAICourseGenerationJob.Status.RUNNING:
45+
skipped += 1
46+
continue
47+
48+
job.status = UAICourseGenerationJob.Status.PENDING
49+
job.started_at = None
50+
job.completed_at = None
51+
job.output = ""
52+
task = run_uai_course_generation_job.delay(job.id)
53+
job.task_id = task.id or ""
54+
job.save(
55+
update_fields=[
56+
"status",
57+
"started_at",
58+
"completed_at",
59+
"output",
60+
"task_id",
61+
"updated_at",
62+
]
63+
)
64+
queued += 1
65+
66+
if queued:
67+
self.message_user(
68+
request,
69+
f"Queued {queued} UAI generation job(s).",
70+
level=messages.SUCCESS,
71+
)
72+
73+
if skipped:
74+
self.message_user(
75+
request,
76+
f"Skipped {skipped} running job(s).",
77+
level=messages.WARNING,
78+
)
79+
80+
def save_model(self, request, obj, form, change):
81+
"""Stamp job creator for new records."""
82+
if not change and not obj.created_by:
83+
obj.created_by = request.user
84+
super().save_model(request, obj, form, change)
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Generated by Django 5.2.14 on 2026-05-19 09:32
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
initial = True
10+
11+
dependencies = [
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="UAICourseGenerationJob",
18+
fields=[
19+
(
20+
"id",
21+
models.AutoField(
22+
auto_created=True,
23+
primary_key=True,
24+
serialize=False,
25+
verbose_name="ID",
26+
),
27+
),
28+
(
29+
"customized_csv",
30+
models.FileField(
31+
help_text="Customized video metadata CSV.",
32+
upload_to="ol_openedx_uai_content_customization/jobs/",
33+
),
34+
),
35+
(
36+
"video_assets_csv",
37+
models.FileField(
38+
help_text="Open edX video asset CSV.",
39+
upload_to="ol_openedx_uai_content_customization/jobs/",
40+
),
41+
),
42+
(
43+
"username",
44+
models.CharField(
45+
default="studio_worker",
46+
help_text="Platform username used to create/publish courses.",
47+
max_length=150,
48+
),
49+
),
50+
(
51+
"dry_run",
52+
models.BooleanField(
53+
default=False,
54+
help_text=(
55+
"When enabled, validate and report actions "
56+
"without writing data."
57+
),
58+
),
59+
),
60+
(
61+
"status",
62+
models.CharField(
63+
choices=[
64+
("pending", "Pending"),
65+
("running", "Running"),
66+
("succeeded", "Succeeded"),
67+
("failed", "Failed"),
68+
],
69+
db_index=True,
70+
default="pending",
71+
max_length=16,
72+
),
73+
),
74+
(
75+
"output",
76+
models.TextField(
77+
blank=True, help_text="Captured command output and failures."
78+
),
79+
),
80+
(
81+
"task_id",
82+
models.CharField(
83+
blank=True,
84+
help_text="Celery task ID for tracking asynchronous execution.",
85+
max_length=255,
86+
),
87+
),
88+
("started_at", models.DateTimeField(blank=True, null=True)),
89+
("completed_at", models.DateTimeField(blank=True, null=True)),
90+
("created_at", models.DateTimeField(auto_now_add=True)),
91+
("updated_at", models.DateTimeField(auto_now=True)),
92+
(
93+
"created_by",
94+
models.ForeignKey(
95+
blank=True,
96+
null=True,
97+
on_delete=django.db.models.deletion.SET_NULL,
98+
related_name="uai_course_generation_jobs",
99+
to=settings.AUTH_USER_MODEL,
100+
),
101+
),
102+
],
103+
options={
104+
"ordering": ("-created_at",),
105+
},
106+
),
107+
]

src/ol_openedx_uai_content_customization/ol_openedx_uai_content_customization/migrations/__init__.py

Whitespace-only changes.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Models for ol-openedx-uai-content-customization plugin."""
2+
3+
from django.conf import settings
4+
from django.core.exceptions import ValidationError
5+
from django.db import models
6+
7+
8+
class UAICourseGenerationJob(models.Model):
9+
"""Admin-managed job to run ``generate_uai_courses`` asynchronously."""
10+
11+
objects = models.Manager()
12+
13+
class Status(models.TextChoices):
14+
"""Allowed execution states for a generation job."""
15+
16+
PENDING = "pending", "Pending"
17+
RUNNING = "running", "Running"
18+
SUCCEEDED = "succeeded", "Succeeded"
19+
FAILED = "failed", "Failed"
20+
21+
customized_csv = models.FileField(
22+
upload_to="ol_openedx_uai_content_customization/jobs/",
23+
help_text="Customized video metadata CSV.",
24+
)
25+
video_assets_csv = models.FileField(
26+
upload_to="ol_openedx_uai_content_customization/jobs/",
27+
help_text="Open edX video asset CSV.",
28+
)
29+
username = models.CharField(
30+
max_length=150,
31+
default="studio_worker",
32+
help_text="Platform username used to create/publish courses.",
33+
)
34+
dry_run = models.BooleanField(
35+
default=False,
36+
help_text="When enabled, validate and report actions without writing data.",
37+
)
38+
status = models.CharField(
39+
max_length=16,
40+
choices=Status.choices,
41+
default=Status.PENDING,
42+
db_index=True,
43+
)
44+
output = models.TextField(
45+
blank=True,
46+
help_text="Captured command output and failures.",
47+
)
48+
task_id = models.CharField(
49+
max_length=255,
50+
blank=True,
51+
help_text="Celery task ID for tracking asynchronous execution.",
52+
)
53+
created_by = models.ForeignKey(
54+
settings.AUTH_USER_MODEL,
55+
null=True,
56+
blank=True,
57+
on_delete=models.SET_NULL,
58+
related_name="uai_course_generation_jobs",
59+
)
60+
started_at = models.DateTimeField(null=True, blank=True)
61+
completed_at = models.DateTimeField(null=True, blank=True)
62+
created_at = models.DateTimeField(auto_now_add=True)
63+
updated_at = models.DateTimeField(auto_now=True)
64+
65+
class Meta:
66+
"""Meta options for UAICourseGenerationJob."""
67+
68+
app_label = "ol_openedx_uai_content_customization"
69+
ordering = ("-created_at",)
70+
71+
def __str__(self):
72+
"""Return concise job identification for admin listings."""
73+
return f"UAI Generation Job #{self.pk} ({self.status})"
74+
75+
def save(self, *args, **kwargs):
76+
"""Persist only validated job records."""
77+
self.full_clean()
78+
super().save(*args, **kwargs)
79+
80+
def clean(self):
81+
"""Validate uploaded files are CSVs."""
82+
super().clean()
83+
errors = {}
84+
85+
if self.customized_csv and not self.customized_csv.name.lower().endswith(
86+
".csv"
87+
):
88+
errors["customized_csv"] = "File must have a .csv extension."
89+
90+
if self.video_assets_csv and not self.video_assets_csv.name.lower().endswith(
91+
".csv"
92+
):
93+
errors["video_assets_csv"] = "File must have a .csv extension."
94+
95+
if errors:
96+
raise ValidationError(errors)

0 commit comments

Comments
 (0)