Skip to content
Open
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
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.11]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
pip install coverage pytest pytest-django factory_boy
- name: Run tests with coverage
run: |
coverage run --source=penaltyparty -m pytest -q
coverage xml -i
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml
1 change: 1 addition & 0 deletions penaltyparty/pp/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# tests package
62 changes: 62 additions & 0 deletions penaltyparty/pp/tests/factories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import factory
from factory import post_generation
from factory.django import DjangoModelFactory

from penaltyparty.pp import models


class QuestionFactory(DjangoModelFactory):
class Meta:
model = models.Question

question_text = factory.Sequence(lambda n: f"Question {n}")
status = models.Question.STATUS.active


class AnswerFactory(DjangoModelFactory):
class Meta:
model = models.Answer

question = factory.SubFactory(QuestionFactory)
answer_text = factory.Sequence(lambda n: f"Answer {n}")
is_correct = False
status = models.Answer.STATUS.active


class TestGroupFactory(DjangoModelFactory):
class Meta:
model = models.TestGroup

group_name = factory.Sequence(lambda n: f"Group{n}")
owner_email = factory.LazyAttribute(lambda o: f"{o.group_name.lower()}@example.com")
info_for_test_takers = ""

@post_generation
def questions(self, create, extracted, **kwargs):
if not create:
return
if extracted:
self.questions.set(extracted)
else:
qs = models.Question.objects.all()[:5]
if qs:
self.questions.set(qs)


class TestAttemptFactory(DjangoModelFactory):
class Meta:
model = models.TestAttempt

participant_name = factory.Sequence(lambda n: f"Participant{n}")
test_group = factory.SubFactory(TestGroupFactory)


class TestAttemptAnswerFactory(DjangoModelFactory):
class Meta:
model = models.TestAttemptAnswer

attempt = factory.SubFactory(TestAttemptFactory)
answer = factory.SubFactory(AnswerFactory)
question_text = factory.SelfAttribute("answer.question.question_text")
answer_text = factory.SelfAttribute("answer.answer_text")
is_correct = factory.SelfAttribute("answer.is_correct")
89 changes: 89 additions & 0 deletions penaltyparty/pp/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from django.test import TestCase
from django.db import IntegrityError

from penaltyparty.pp.models import Question, Answer, TestGroup, TestAttempt, TestAttemptAnswer
from .factories import (
QuestionFactory,
AnswerFactory,
TestGroupFactory,
TestAttemptFactory,
TestAttemptAnswerFactory,
)


class QuestionModelTests(TestCase):
def test_answers_random_and_correct(self):
question = QuestionFactory()
answers_list = [AnswerFactory(question=question, is_correct=(i == 0)) for i in range(4)]

random_answers = question.answers_random()
self.assertCountEqual([a.id for a in random_answers], [a.id for a in answers_list])

self.assertEqual(question.correct_answer().id, answers_list[0].id)

def test_correct_answer_raises_when_no_correct_exists(self):
question = QuestionFactory()
AnswerFactory(question=question, is_correct=False)
AnswerFactory(question=question, is_correct=False)

with self.assertRaises(Answer.DoesNotExist):
question.correct_answer()

def test_active_manager_returns_only_active_questions(self):
active_question = QuestionFactory(status=Question.STATUS.active)
QuestionFactory(status=Question.STATUS.inactive)

qs = Question.active.all()
self.assertIn(active_question, qs)
self.assertEqual(qs.count(), 1)


class TestAttemptModelTests(TestCase):
def test_next_question_and_final_score(self):
questions = [QuestionFactory() for _ in range(3)]
test_group = TestGroupFactory(questions=questions)
attempt = TestAttemptFactory(test_group=test_group)

chosen_question = attempt.next_question()
self.assertIn(chosen_question.id, [q.id for q in questions])

for question in questions:
answer = AnswerFactory(question=question, is_correct=(question == questions[0]))
TestAttemptAnswerFactory(attempt=attempt, answer=answer)

self.assertIsNone(attempt.next_question())

attempt.set_final_score()
self.assertEqual(attempt.final_answered, attempt.answers.count())
self.assertIsNotNone(attempt.final_correct)

if attempt.final_answered:
percent = attempt.correct_percentage()
self.assertIsInstance(percent, int)

def test_test_attempt_answer_unique_together_violated_raises_integrity_error(self):
question = QuestionFactory()
answer = AnswerFactory(question=question)
test_group = TestGroupFactory(questions=[question])
attempt = TestAttemptFactory(test_group=test_group)

TestAttemptAnswerFactory(attempt=attempt, answer=answer)

with self.assertRaises(IntegrityError):
TestAttemptAnswer.objects.create(
attempt=attempt,
answer=answer,
question_text=answer.question.question_text,
answer_text=answer.answer_text,
is_correct=answer.is_correct,
)

def test_correct_percentage_raises_on_zero_answered(self):
test_group = TestGroupFactory()
attempt = TestAttemptFactory(test_group=test_group)

attempt.set_final_score()
self.assertEqual(attempt.final_answered, 0)

with self.assertRaises(ZeroDivisionError):
attempt.correct_percentage()
167 changes: 167 additions & 0 deletions penaltyparty/pp/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
from django.test import TestCase, Client, override_settings
from django.urls import reverse
from django.core import mail

from penaltyparty.pp.models import TestGroup, TestAttempt, TestAttemptAnswer, Question
from .factories import QuestionFactory, AnswerFactory, TestGroupFactory, TestAttemptFactory, TestAttemptAnswerFactory


class IndexViewTests(TestCase):
def setUp(self):
self.client = Client()

def test_index_shows_random_question(self):
question = QuestionFactory()

response = self.client.get(reverse("index"))
self.assertEqual(response.status_code, 200)
self.assertIn("random_question", response.context)
self.assertEqual(response.context["random_question"].id, question.id)


@override_settings(TEST_GROUP_QUESTION_AMOUNT=5)
class TestGroupCreateViewTests(TestCase):
def setUp(self):
self.client = Client()

def test_create_view_creates_group_and_sends_email(self):
for _ in range(5):
QuestionFactory()

data = {
"group_name": "Testers",
"owner_email": "owner@example.com",
"info_for_test_takers": "Info",
}
create_response = self.client.post(reverse("test_group_create"), data)
self.assertEqual(create_response.status_code, 302)

test_group = TestGroup.objects.get(owner_email="owner@example.com")
self.assertEqual(test_group.questions.count(), 5)

self.assertEqual(len(mail.outbox), 1)
sent = mail.outbox[0]
self.assertIn("owner@example.com", sent.to)
self.assertTrue(sent.subject)
self.assertIn("Testers", sent.body)

def test_create_view_fails_when_not_enough_active_questions(self):
for _ in range(2):
QuestionFactory(status=Question.STATUS.active)

data = {
"group_name": "FewQuestions",
"owner_email": "owner@example.com",
"info_for_test_takers": "",
}
response = self.client.post(reverse("test_group_create"), data)
self.assertEqual(response.status_code, 500)

def test_create_view_invalid_form_shows_errors(self):
QuestionFactory()
data = {
"group_name": "NoEmail",
"info_for_test_takers": "Missing email",
}
response = self.client.post(reverse("test_group_create"), data)
self.assertEqual(response.status_code, 200)
form = response.context.get("form")
self.assertIsNotNone(form)
self.assertIn("owner_email", form.errors)


class TestGroupTakeViewTests(TestCase):
def setUp(self):
self.client = Client()

def test_take_view_creates_attempt_and_redirects(self):
question = QuestionFactory()
test_group = TestGroupFactory(questions=[question])

url = reverse("test_group_take", kwargs={"group_token": test_group.token_test_taker})
take_response = self.client.post(url, {"participant_name": "Alice"})
self.assertEqual(take_response.status_code, 302)

test_attempt = TestAttempt.objects.get(test_group=test_group, participant_name="Alice")
self.assertIsNotNone(test_attempt)

def test_take_view_404_for_invalid_group_token(self):
response = self.client.get(reverse("test_group_take", kwargs={"group_token": "nope"}))
self.assertEqual(response.status_code, 404)


class TestAttemptEnterAnswerViewTests(TestCase):
def setUp(self):
self.client = Client()

def test_enter_answer_get_and_post_creates_attempt_answer(self):
question = QuestionFactory()
answer_correct = AnswerFactory(question=question, is_correct=True)
AnswerFactory(question=question, is_correct=False)
test_group = TestGroupFactory(questions=[question])
attempt = TestAttemptFactory(test_group=test_group)

url = reverse("test_attempt_enter_answer", kwargs={"token": attempt.token, "question": question.pk})
get_response = self.client.get(url)
self.assertEqual(get_response.status_code, 200)

post_response = self.client.post(url, {"answer": str(answer_correct.id)})
self.assertEqual(post_response.status_code, 302)
self.assertTrue(TestAttemptAnswer.objects.filter(attempt=attempt, answer=answer_correct).exists())

def test_enter_answer_post_with_invalid_answer_id_returns_404(self):
question = QuestionFactory()
AnswerFactory(question=question, is_correct=True)
test_group = TestGroupFactory(questions=[question])
attempt = TestAttemptFactory(test_group=test_group)

url = reverse("test_attempt_enter_answer", kwargs={"token": attempt.token, "question": question.pk})
response = self.client.post(url, {"answer": "999999"})
self.assertEqual(response.status_code, 404)

def test_enter_answer_redirects_if_question_already_answered(self):
question = QuestionFactory()
answer = AnswerFactory(question=question, is_correct=True)
test_group = TestGroupFactory(questions=[question])
attempt = TestAttemptFactory(test_group=test_group)

TestAttemptAnswerFactory(attempt=attempt, answer=answer)

url = reverse("test_attempt_enter_answer", kwargs={"token": attempt.token, "question": question.pk})
response = self.client.get(url)
self.assertEqual(response.status_code, 302)


class TestAttemptNextQuestionViewTests(TestCase):
def setUp(self):
self.client = Client()

def test_next_question_redirects_to_enter_answer_when_questions_remain(self):
question = QuestionFactory()
test_group = TestGroupFactory(questions=[question])
attempt = TestAttemptFactory(test_group=test_group)

url = reverse("test_attempt_next_question", kwargs={"token": attempt.token})
response = self.client.get(url)
self.assertEqual(response.status_code, 302)

target = reverse("test_attempt_enter_answer", kwargs={"token": attempt.token, "question": question.pk})
self.assertTrue(response["Location"].endswith(target))

def test_next_question_marks_done_when_no_questions_remain(self):
questions = [QuestionFactory() for _ in range(2)]
test_group = TestGroupFactory(questions=questions)
attempt = TestAttemptFactory(test_group=test_group)

for question in questions:
answer = AnswerFactory(question=question, is_correct=True)
self.client.post(
reverse("test_attempt_enter_answer", kwargs={"token": attempt.token, "question": question.pk}),
{"answer": str(answer.id)},
)

done_response = self.client.get(reverse("test_attempt_next_question", kwargs={"token": attempt.token}))
self.assertEqual(done_response.status_code, 302)

done_url = reverse("test_attempt_done", kwargs={"token": attempt.token})
self.assertTrue(done_response["Location"].endswith(done_url))
6 changes: 3 additions & 3 deletions penaltyparty/pp/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
TestGroup,
)

TEST_GROUP_QUESTION_AMOUNT = 40
TEST_GROUP_QUESTION_AMOUNT = getattr(settings, "TEST_GROUP_QUESTION_AMOUNT", 40)


def index(request):
Expand All @@ -38,8 +38,9 @@ class TestGroupCreateView(CreateView):
template_name = "test_group_create.html"

def form_valid(self, form):
question_amount = getattr(settings, "TEST_GROUP_QUESTION_AMOUNT", TEST_GROUP_QUESTION_AMOUNT)
pks = Question.active.values_list("pk", flat=True)
random_pk = random.sample(list(pks), TEST_GROUP_QUESTION_AMOUNT)
random_pk = random.sample(list(pks), question_amount)
self.object = form.save()
self.object.questions.set(Question.objects.filter(pk__in=random_pk))

Expand Down Expand Up @@ -147,7 +148,6 @@ def get_context_data(self, **kwargs):
def form_valid(self, form):
answer = get_object_or_404(Answer, id=form.cleaned_data["answer"])

# Create a new TestAttemptAnswer record
TestAttemptAnswer.objects.update_or_create(
attempt=self.test_attempt,
answer=answer,
Expand Down
Loading