Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
451 changes: 451 additions & 0 deletions docs/superpowers/plans/2026-06-21-quiz-clone.md

Large diffs are not rendered by default.

119 changes: 119 additions & 0 deletions docs/superpowers/specs/2026-06-21-quiz-clone-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Quiz Clone Feature — Design Spec

**Date:** 2026-06-21
**Status:** Approved

## Overview

Allow quiz editors to clone an existing quiz into a new draft. The clone copies all quiz metadata and question links (shallow — same `QuizQuestion` bank objects, new `QuizQuestionLink` rows). The clone is created immediately with an auto-generated code and the editor is redirected to its edit page.

---

## 1. Model method — `Quiz.clone(author)`

Location: `quiz/models.py`, added to the `Quiz` class.

```python
def clone(self, author: Profile) -> 'Quiz':
```

Runs inside a single atomic transaction. Steps:

1. **Generate unique code.** Try `{original_code}2`, `{original_code}3`, … `{original_code}9` — pick the first code not yet in the DB. Cap at 9 attempts; if all are taken, raise a `ValueError` (caller surfaces this as an error message).
2. **Copy scalar fields:** `description`, `time_limit`, `max_attempts`, `shuffle_questions`, `result_feedback`, `integrity_monitoring`, `is_organization_private`.
3. **Set derived fields:**
- `name` = `"Copy of {original_name}"`
- `code` = generated code from step 1
- `is_public` = `False`
- `start_time` = `None`
- `end_time` = `None`
4. **Save** the new `Quiz` instance.
5. **Copy M2M relationships:**
- `organizations` — copied as-is.
- `curators` — copied as-is.
- `testers` — copied as-is.
- `authors` — set to `[author]` only (the cloner becomes sole author).
6. **Copy question links.** For each `QuizQuestionLink` on the original quiz, create a new `QuizQuestionLink` on the clone with the same `question`, `points`, and `order`.
7. Return the new `Quiz` instance.

---

## 2. View & URL

### View

`QuizClone` in `quiz/views/editor.py`. Uses the existing `QuizEditorObjectMixin` which resolves `self.quiz` by code and enforces object-level edit permission.

```python
class QuizClone(QuizEditorObjectMixin, View):
def post(self, request, *args, **kwargs):
try:
clone = self.quiz.clone(request.profile)
except ValueError:
messages.error(request, _('Could not generate a unique code for the clone. Rename the original quiz first.'))
return redirect('quiz_edit', quiz=self.quiz.code)
messages.success(request, _('Quiz cloned. Update the name and settings before publishing.'))
return redirect('quiz_edit', quiz=clone.code)
```

Only `POST` is handled — `GET` returns 405.

### URL

Added to `quiz/urls.py` inside the `/<str:quiz>` group:

```python
path('/clone', editor.QuizClone.as_view(), name='quiz_clone'),
```

---

## 3. UI entry points

Both use a plain `<form method="post">` with a CSRF token — no JavaScript required.

### Quiz edit page (`quiz/quiz_form.html`)

A **Clone** button in the action bar, shown only when `editing=True` (i.e. existing quiz, not the "New Quiz" form). Placed alongside the Save button.

### Quiz detail page (`quiz/quiz_detail.html`)

A **Clone** button shown only when `request.user.is_authenticated and quiz.is_editable_by(request.user)`. Positioned in the editor action area.

---

## 4. Error handling

| Scenario | Behaviour |
|---|---|
| All 8 suffix codes (`{code}2`–`{code}9`) are taken | `messages.error`, redirect back to original quiz edit page |
| Non-editor hits `/clone` | `QuizEditorObjectMixin` raises `Http404` |
| GET request to `/clone` | 405 Method Not Allowed |

---

## 5. Tests

New file `quiz/tests/test_clone.py` (or added to `test_models.py`):

| Test | What it verifies |
|---|---|
| `test_clone_copies_fields` | Scalar fields copied, `is_public=False`, `start_time=None`, `end_time=None` |
| `test_clone_name_prefixed` | Clone `name` starts with `"Copy of "` |
| `test_clone_copies_question_links` | New `QuizQuestionLink` rows exist with correct `question`, `points`, `order` |
| `test_clone_question_links_are_shallow` | Link points to same `QuizQuestion` pk, not a new one |
| `test_clone_author_is_cloner` | `clone.authors.all()` contains only the cloning profile |
| `test_clone_copies_m2m` | `organizations`, `curators`, `testers` are copied |
| `test_clone_code_generation` | When `{code}2` is free, clone gets `{code}2` |
| `test_clone_code_generation_collision` | When `{code}2`–`{code}4` are taken, clone gets `{code}5` |
| `test_clone_code_generation_exhausted` | All suffixes taken → `ValueError` raised |
| `test_clone_view_post_redirects` | Editor POST → 302 to clone edit page |
| `test_clone_view_non_editor_404` | Non-editor POST → 404 |

---

## 6. Out of scope (v1)

- Deep-copying `QuizQuestion` objects (questions remain shared bank items).
- Custom code/name input before cloning (clone-then-edit workflow chosen).
- Cloning individual questions in the question bank.
21 changes: 12 additions & 9 deletions locale/vi/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -8158,6 +8158,18 @@ msgstr "câu hỏi chưa trả lời."
msgid "Submit the quiz now?"
msgstr "Nộp bài kiểm tra ngay bây giờ?"

#: templates/quiz/detail.html:418
msgid "Please"
msgstr "Vui lòng"

#: templates/quiz/detail.html:418
msgid "log in"
msgstr "đăng nhập"

#: templates/quiz/detail.html:418
msgid "to take this quiz."
msgstr "để làm bài kiểm tra này."

#: templates/registration/activate.html:3
#, python-format
msgid "%(key)s is an invalid activation key."
Expand Down Expand Up @@ -9389,12 +9401,3 @@ msgstr "Chọn tất cả"

#~ msgid "Continue"
#~ msgstr "Tiếp tục"

#~ msgid "Please"
#~ msgstr "Vui lòng"

#~ msgid "log in"
#~ msgstr "Đăng nhập"

#~ msgid "to take this quiz."
#~ msgstr "để làm bài kiểm tra này."
22 changes: 22 additions & 0 deletions quiz/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,31 @@ class QuizQuestionLinkInline(admin.TabularInline):
fields = ('question', 'order', 'points')


def clone_quiz_action(modeladmin, request, queryset):
cloned, errors = 0, []
author = getattr(request, 'profile', None)
for quiz in queryset:
try:
quiz.clone(author or quiz.authors.first())
cloned += 1
except ValueError as e:
errors.append(str(e))
if cloned:
modeladmin.message_user(
request,
_('Cloned %(n)d quiz(zes). Edit the copies to update their names and settings.') % {'n': cloned},
messages.SUCCESS)
for err in errors:
modeladmin.message_user(request, err, messages.ERROR)


clone_quiz_action.short_description = _l('Clone selected quizzes')


@admin.register(Quiz)
class QuizAdmin(admin.ModelAdmin):
form = QuizAdminForm
actions = (clone_quiz_action,)
list_display = ('code', 'name', 'time_limit', 'is_public')
list_filter = ('is_public', 'is_organization_private')
search_fields = ('code', 'name')
Expand Down
43 changes: 42 additions & 1 deletion quiz/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from functools import cached_property

from django.core.validators import MinValueValidator, RegexValidator
from django.db import models
from django.db import models, transaction
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
Expand Down Expand Up @@ -271,6 +271,47 @@ def regrade_attempts(self):
count += 1
return count

def clone(self, author):
suffixes = [str(i) for i in range(2, 10)]
new_code = None
for suffix in suffixes:
candidate = f'{self.code}{suffix}'
if not Quiz.objects.filter(code=candidate).exists():
new_code = candidate
break
if new_code is None:
raise ValueError(
f'Cannot generate a unique code for clone of {self.code!r}')
with transaction.atomic():
clone_quiz = Quiz.objects.create(
code=new_code,
name=f'Copy of {self.name}',
description=self.description,
time_limit=self.time_limit,
max_attempts=self.max_attempts,
shuffle_questions=self.shuffle_questions,
result_feedback=self.result_feedback,
integrity_monitoring=self.integrity_monitoring,
is_organization_private=self.is_organization_private,
is_public=False,
start_time=None,
end_time=None,
)
clone_quiz.authors.set([author])
clone_quiz.organizations.set(self.organizations.all())
clone_quiz.curators.set(self.curators.all())
clone_quiz.testers.set(self.testers.all())
QuizQuestionLink.objects.bulk_create([
QuizQuestionLink(
quiz=clone_quiz,
question_id=link.question_id,
points=link.points,
order=link.order,
)
for link in self.question_links.all()
])
return clone_quiz

def get_ranking(self):
best = {}
for attempt in self.attempts.filter(is_submitted=True).select_related(
Expand Down
139 changes: 139 additions & 0 deletions quiz/tests/test_clone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone

from quiz.models import Quiz
from quiz.tests.util import create_organization, create_question, create_quiz, create_user


class QuizCloneModelTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.cloner = create_user(username='cloner', user_permissions=('edit_own_quiz',))
cls.other_editor = create_user(username='othercloner', user_permissions=('edit_own_quiz',))
cls.tester = create_user(username='clonetester')
cls.org = create_organization(name='cloneorg', admins=())
cls.q1 = create_question(title='clone q1', code='cloneq1')
cls.q2 = create_question(title='clone q2', code='cloneq2')
cls.quiz = create_quiz(
code='clonesrc',
name='Source Quiz',
description='A description',
time_limit=45,
max_attempts=2,
shuffle_questions=True,
result_feedback='score_only',
integrity_monitoring=False,
is_organization_private=True,
is_public=True,
start_time=timezone.now(),
end_time=timezone.now(),
authors=(cls.cloner.profile,),
curators=(cls.other_editor.profile,),
testers=(cls.tester.profile,),
organizations=(cls.org,),
questions=((cls.q1, 2.5), (cls.q2, 1.0)),
)

def test_clone_copies_scalar_fields(self):
clone = self.quiz.clone(self.cloner.profile)
self.assertEqual(clone.description, 'A description')
self.assertEqual(clone.time_limit, 45)
self.assertEqual(clone.max_attempts, 2)
self.assertTrue(clone.shuffle_questions)
self.assertEqual(clone.result_feedback, 'score_only')
self.assertFalse(clone.integrity_monitoring)
self.assertTrue(clone.is_organization_private)

def test_clone_resets_public_and_times(self):
clone = self.quiz.clone(self.cloner.profile)
self.assertFalse(clone.is_public)
self.assertIsNone(clone.start_time)
self.assertIsNone(clone.end_time)

def test_clone_name_prefixed(self):
clone = self.quiz.clone(self.cloner.profile)
self.assertEqual(clone.name, 'Copy of Source Quiz')

def test_clone_copies_question_links(self):
clone = self.quiz.clone(self.cloner.profile)
links = list(clone.question_links.order_by('order'))
self.assertEqual(len(links), 2)
self.assertEqual(links[0].points, 2.5)
self.assertEqual(links[1].points, 1.0)
self.assertEqual(links[0].order, 0)
self.assertEqual(links[1].order, 1)

def test_clone_question_links_are_shallow(self):
clone = self.quiz.clone(self.cloner.profile)
clone_qids = set(clone.question_links.values_list('question_id', flat=True))
src_qids = set(self.quiz.question_links.values_list('question_id', flat=True))
self.assertEqual(clone_qids, src_qids)

def test_clone_author_is_cloner(self):
clone = self.quiz.clone(self.cloner.profile)
self.assertEqual(list(clone.authors.all()), [self.cloner.profile])

def test_clone_copies_m2m(self):
clone = self.quiz.clone(self.cloner.profile)
self.assertIn(self.org, clone.organizations.all())
self.assertIn(self.other_editor.profile, clone.curators.all())
self.assertIn(self.tester.profile, clone.testers.all())

def test_clone_code_first_suffix(self):
clone = self.quiz.clone(self.cloner.profile)
self.assertEqual(clone.code, 'clonesrc2')

def test_clone_code_skips_taken_suffixes(self):
base = create_quiz(code='clonesrck', authors=(self.cloner.profile,))
create_quiz(code='clonesrck2')
create_quiz(code='clonesrck3')
clone = base.clone(self.cloner.profile)
self.assertEqual(clone.code, 'clonesrck4')

def test_clone_code_exhausted_raises(self):
base = create_quiz(code='clonesrce', authors=(self.cloner.profile,))
for i in range(2, 10):
create_quiz(code=f'clonesrce{i}')
with self.assertRaises(ValueError):
base.clone(self.cloner.profile)


class QuizCloneViewTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.author = create_user(
username='cloneviewauthor', user_permissions=('edit_own_quiz',))
cls.stranger = create_user(
username='clonestranger', user_permissions=('edit_own_quiz',))
cls.q = create_question(title='view clone q', code='viewcloneq')
cls.quiz = create_quiz(
code='viewclonesrc',
authors=(cls.author.profile,),
questions=((cls.q, 1.0),),
)

def test_clone_post_redirects_to_edit(self):
self.client.force_login(self.author)
resp = self.client.post(
reverse('quiz_clone', kwargs={'quiz': 'viewclonesrc'}))
self.assertRedirects(
resp, reverse('quiz_edit', kwargs={'quiz': 'viewclonesrc2'}))

def test_clone_creates_quiz_and_links(self):
self.client.force_login(self.author)
self.client.post(reverse('quiz_clone', kwargs={'quiz': 'viewclonesrc'}))
clone = Quiz.objects.get(code='viewclonesrc2')
self.assertEqual(clone.question_links.count(), 1)

def test_clone_non_editor_returns_404(self):
self.client.force_login(self.stranger)
resp = self.client.post(
reverse('quiz_clone', kwargs={'quiz': 'viewclonesrc'}))
self.assertEqual(resp.status_code, 404)

def test_clone_get_returns_405(self):
self.client.force_login(self.author)
resp = self.client.get(
reverse('quiz_clone', kwargs={'quiz': 'viewclonesrc'}))
self.assertEqual(resp.status_code, 405)
1 change: 1 addition & 0 deletions quiz/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
path('/start', student.QuizStart.as_view(), name='quiz_start'),
path('/ranking', student.QuizRanking.as_view(), name='quiz_ranking'),
path('/edit', editor.QuizEdit.as_view(), name='quiz_edit'),
path('/clone', editor.QuizClone.as_view(), name='quiz_clone'),
path('/attempts', editor.QuizAttempts.as_view(), name='quiz_attempts'),
path('/attempt/<int:attempt>', include([
path('', student.QuizTake.as_view(), name='quiz_take'),
Expand Down
Loading
Loading