from http import HTTPStatus import json from typing import Callable from bs4 import BeautifulSoup import pytest from generic.models import CidUser, CidUserGroup, UserUserGroup, ExamBase from generic.views import ExamViews from django.contrib.auth.models import User, Group from django.urls import reverse from rich.pretty import pprint def AssertNotFound(client, url): """Helper to quickly test for urls that should return a 404 (NOT FOUND) error""" response = client.get(url) assert ( response.status_code == HTTPStatus.NOT_FOUND ), f"Response content: {response.content}" def create_cid_user_and_groups(db, marker=None): group1 = CidUserGroup.objects.create(name="Group1") group2 = CidUserGroup.objects.create(name="Group2") cid_user_1000 = CidUser.objects.create(cid=1000, passcode="ABCD", group=group1) cid_user_1001 = CidUser.objects.create(cid=1001, passcode="EFGH", group=group1) cid_user_2001 = CidUser.objects.create(cid=2001, passcode="IJKL", group=group2) assert cid_user_1000.cid == 1000 assert cid_user_1001.cid == 1001 # Create UserUsers user1 = User.objects.create_user( username="user1", email="test@email.com", password="test1234" ) user2 = User.objects.create_user( username="user2", email="test2@email.com", password="test1234" ) usergroup1 = UserUserGroup.objects.create(name="Group1") usergroup1.users.add(user1) usergroup2 = UserUserGroup.objects.create(name="Group2") usergroup2.users.add(user2) #if marker is not None: # marker_group = Group.objects.create(name=f"{marker}_marker") # #marker_group = Group.objects.create(name=f"{marker}_marker") marker = User.objects.create_user( username="marker", email="test@marker.com", password="test1234" ) def create_exam(db, ExamViews: ExamViews): exam: ExamBase = ExamViews.Exam.objects.create( name="Cid Exam", exam_mode=True, active=True ) cidgroup1: CidUserGroup = CidUserGroup.objects.get(name="Group1") usergroup1: UserUserGroup = UserUserGroup.objects.get(name="Group1") exam.cid_user_groups.add(cidgroup1) exam.valid_cid_users.add(*cidgroup1.ciduser_set.all()) # exam.valid_user_users.add(user1) exam.valid_user_users.add(*usergroup1.users.all()) assert exam return exam class ExamTester: def __init__(self, db, client, app_name, ExamViews) -> None: self.db = db self.client = client self.app_name = app_name create_cid_user_and_groups(db) self.exam = exam = create_exam(db, ExamViews) cidgroup1: CidUserGroup = CidUserGroup.objects.get(name="Group1") self.answers_per_question = 1 match app_name: case "physics": assert exam in cidgroup1.physics_cid_user_groups.all() self.answers_per_question = 5 case "sbas": assert exam in cidgroup1.sba_cid_user_groups.all() cid_user_1001 = CidUser.objects.get(cid=1001) cid_user_1000 = CidUser.objects.get(cid=1000) self.cid_user_2001 = cid_user_2001 = CidUser.objects.get(cid=2001) user1 = User.objects.get(username="user1") user2 = User.objects.get(username="user2") self.all_users = [cid_user_1000, cid_user_1001, cid_user_2001, user1, user2] self.cid_users = [cid_user_1000, cid_user_1001, cid_user_2001] self.invalid_users = [cid_user_2001, user2] self.current_user = None self.generated_questions = [] def load_user(self, user): self.client.logout() self.current_user = user if isinstance(self.current_user, CidUser): self.testing_cid_user = True else: self.testing_cid_user = False self.client.force_login(user) def create_questions(self, create_question_func: Callable): self.create_question_func = create_question_func self.generated_questions = [ create_question_func(self.exam), create_question_func(self.exam), create_question_func(self.exam), ] def add_question(self): self.generated_questions.append(self.create_question_func(self.exam)) def check_active_exams(self, n): active_exams = self.client.get(reverse(f"{self.app_name}:active_exams")) assert active_exams.status_code == 200 assert ( len(json.loads(active_exams.content)["exams"]) == n ), f"Current user: {self.current_user}" def check_onsite_start_page(self): start_page_res = self.client.get( reverse(f"{self.app_name}:exam_start", kwargs={"pk": self.exam.pk}) ) # Check user access to the start page assert start_page_res.status_code == HTTPStatus.OK start_page_soup = BeautifulSoup(start_page_res.content, "html.parser") if ( self.current_user in self.cid_users or self.current_user in self.invalid_users ): assert "Enter your CID and passcode" in str(start_page_soup) else: assert "Start Exam" in str(start_page_soup) def check_take_page(self): for n, question in enumerate(self.generated_questions): if self.testing_cid_user: take_url = reverse( f"{self.app_name}:exam_take", kwargs={ "pk": self.exam.pk, "sk": n, "cid": self.current_user.cid, "passcode": self.current_user.passcode, }, ) else: take_url = reverse( f"{self.app_name}:exam_take_user", kwargs={ "pk": self.exam.pk, "sk": n, }, ) cid_take_res = self.client.get(take_url) # Check that users that have not been added cannot access the take pages if self.current_user in self.invalid_users: assert cid_take_res.status_code == HTTPStatus.NOT_FOUND continue else: assert ( cid_take_res.status_code == HTTPStatus.OK ), f"Current user: {self.current_user}" cid_take_soup = BeautifulSoup(cid_take_res.content, "html.parser") # Check correct user is displayed if self.testing_cid_user: display_user = str(self.current_user.cid) else: display_user = self.current_user.username assert display_user in cid_take_soup.find("span", {"id": "user-id"}).string # Check review mode alert not visible assert cid_take_soup.find("div", {"class": "review-mode-alert"}) is None # Check that relevant buttons are being displayed assert cid_take_soup.find("button", {"name": "finish"}) is not None if n < len(self.generated_questions) - 1: assert cid_take_soup.find("button", {"name": "next"}) is not None if n > 0: assert cid_take_soup.find("button", {"name": "previous"}) is not None assert cid_take_soup.find("span", {"id": "question-number"}).string == str( n + 1 ) # + 1 for 0 offset assert cid_take_soup.find("span", {"id": "exam-length"}).string == str( len(self.generated_questions) ) # Check that stem text is displayed assert ( cid_take_soup.find("span", {"id": "question-stem"}).string == question.stem ) match self.app_name: case "physics": assert ( cid_take_soup.find("label", {"for": "id_a"}).string == question.a ) assert ( cid_take_soup.find("label", {"for": "id_b"}).string == question.b ) assert ( cid_take_soup.find("label", {"for": "id_c"}).string == question.c ) assert ( cid_take_soup.find("label", {"for": "id_d"}).string == question.d ) assert ( cid_take_soup.find("label", {"for": "id_e"}).string == question.e ) case "sbas": assert ( cid_take_soup.find("ul", {"class": "sba-answer-list"}) is not None ) assert ( len( cid_take_soup.find( "ul", {"class": "sba-answer-list"} ).find_all("li") ) == 5 ) # As we haven't submitted any answers they should all be blank assert ( cid_take_soup.find("input", {"type": "checkbox", "checked": True}) is None ) def check_take_overview(self, answered, total): if self.testing_cid_user: take_overview_url = reverse( f"{self.app_name}:exam_take_overview", kwargs={ "pk": self.exam.pk, "cid": self.current_user.cid, "passcode": self.current_user.passcode, }, ) else: take_overview_url = reverse( f"{self.app_name}:exam_take_overview_user", kwargs={ "pk": self.exam.pk, }, ) cid_take_overview_res = self.client.get(take_overview_url) # Check that users that have not been added cannot access the take pages if self.current_user in self.invalid_users: assert cid_take_overview_res.status_code == HTTPStatus.NOT_FOUND else: assert cid_take_overview_res.status_code == HTTPStatus.OK cid_take_overview_soup = BeautifulSoup( cid_take_overview_res.content, "html.parser" ) assert ( f"{answered} out of 3 questions answered" in cid_take_overview_soup.find( "div", attrs={"class": "overview-text"} ).text ) assert ( len( cid_take_overview_soup.find_all( "button", attrs={"class": "unanswered"} ) ) == total - answered ) def check_cid_scores_page(self): if self.testing_cid_user: # Get overview scores page (invalid passcode) AssertNotFound( self.client, reverse( f"cid_scores", kwargs={"cid": self.current_user.cid, "passcode": "AAAA"}, ), ) cid_scores_url = reverse( f"cid_scores", kwargs={ "cid": self.current_user.cid, "passcode": self.current_user.passcode, }, ) else: cid_scores_url = reverse(f"user_scores") # Get overview scores page cid_scores_res = self.client.get(cid_scores_url) assert cid_scores_res.status_code == HTTPStatus.OK cid_scores_soup = BeautifulSoup(cid_scores_res.content, "html.parser") if self.current_user in self.invalid_users: # page should load but we shouldn't be able to find the exam with pytest.raises(AttributeError): search_exam = ( cid_scores_soup.find("div", {"id": "exam-assigned"}) .find("ul", {"class": self.exam.app_name.lower()}) .find_all("li", attrs={"data-exam-id": self.exam.pk}) ) else: search_exam = ( cid_scores_soup.find("div", {"id": "exam-assigned"}) .find("ul", {"class": self.exam.app_name.lower()}) .find("li", attrs={"data-exam-id": self.exam.pk}) ) assert search_exam assert str(self.exam) in str(search_exam) assert len(search_exam.find("button", {"class": "start-button"})) > 0 # assert "Active" in assigned_exams # check it is active invalid_exam = cid_scores_soup.find_all( "li", attrs={"data-exam-id": self.exam.pk + 5} ) assert not invalid_exam # Check we can't find an invalid exam # Check that we have a results link results_exam = ( cid_scores_soup.find("div", {"id": "exam-results"}) .find("ul", {"class": self.exam.app_name}) .find_all("li", attrs={"data-exam-id": self.exam.pk}) ) assert results_exam assert str(self.exam) in str(results_exam) assert str(self.exam) + " test" not in str(results_exam) assert "Results Published" not in str( results_exam ) # It should not be published yet def check_cid_scores_user_page(self): if self.testing_cid_user: AssertNotFound( self.client, reverse( f"{self.exam.app_name}:exam_scores_cid_user", kwargs={ "pk": self.exam.pk, "cid": self.current_user.cid, "passcode": "AAAA", }, ), ) AssertNotFound( self.client, reverse( f"{self.exam.app_name}:exam_scores_cid_user", kwargs={ "pk": self.exam.pk, "cid": self.cid_user_2001.cid, "passcode": self.cid_user_2001.passcode, }, ), ) exam_scores_url = reverse( f"{self.exam.app_name}:exam_scores_cid_user", kwargs={ "pk": self.exam.pk, "cid": self.current_user.cid, "passcode": self.current_user.passcode, }, ) else: exam_scores_url = reverse( f"{self.exam.app_name}:exam_scores_user", kwargs={"pk": self.exam.pk}, ) self.exam_scores_url = exam_scores_url # Load the exam results page cid_exam_scores_res = self.client.get(exam_scores_url) if self.current_user in self.invalid_users: assert cid_exam_scores_res.status_code == HTTPStatus.NOT_FOUND return else: assert cid_exam_scores_res.status_code == HTTPStatus.OK cid_exam_scores_soup = BeautifulSoup(cid_exam_scores_res.content, "html.parser") # Check that we have an alert that exams are not published alert = cid_exam_scores_soup.find("div", {"class": "alert"}) assert "Results are not currently published." in str(alert) submitted_answers = cid_exam_scores_soup.find_all( "span", {"class": "submitted-user-answer"} ) # Check that we are showing enough questions / answers match self.app_name: case "physics": answer_lis = cid_exam_scores_soup.find_all( "li", {"class": "question-part"} ) assert len(answer_lis) == 5 * len(self.generated_questions) assert len(submitted_answers) == 5 * len(self.generated_questions) for ans in submitted_answers: assert ans.text.strip() == "True" case "sbas": answer_lis = cid_exam_scores_soup.find_all( "li", {"class": "user-answer-li"} ) assert len(answer_lis) == len(self.generated_questions) assert len(submitted_answers) == len(self.generated_questions) for ans in submitted_answers: assert ans.text.strip() == "a" # Check no correct answers are shown assert not cid_exam_scores_soup.find_all("span", {"class": "correct-answer"}) # Add another question to the exam self.add_question() # Load the exam results page (again) cid_exam_scores_res = self.client.get(exam_scores_url) cid_exam_scores_soup = BeautifulSoup(cid_exam_scores_res.content, "html.parser") submitted_answers = cid_exam_scores_soup.find_all( "span", {"class": "submitted-user-answer"} ) match self.app_name: case "physics": answer_lis = cid_exam_scores_soup.find_all( "li", {"class": "question-part"} ) assert len(answer_lis) == 5 * len(self.generated_questions) assert len(submitted_answers) == 5 * len(self.generated_questions) for ans in submitted_answers[:-5]: assert ans.text.strip() == "True" # The new question will not have any answers for ans in submitted_answers[-5:]: assert ans.text.strip() == "Not answered" case "sbas": answer_lis = cid_exam_scores_soup.find_all( "li", {"class": "user-answer-li"} ) assert len(answer_lis) == len(self.generated_questions) assert len(submitted_answers) == len(self.generated_questions) print(submitted_answers) for ans in submitted_answers[:3]: print("test1", ans) assert ans.text.strip() == "a" # The new question will not have any answers for ans in submitted_answers[3:]: print("test2", ans) assert ans.text.strip() == "Not answered" # Publish the results! self.exam.publish_results = True self.exam.save() # Load the exam results page (again) cid_exam_scores_res = self.client.get(exam_scores_url) cid_exam_scores_soup = BeautifulSoup(cid_exam_scores_res.content, "html.parser") assert ( cid_exam_scores_soup.find("div", {"class": "alert"}) is None ) # Check our warning banner is gone # Check that we are displaying the correct answers (correctly) correct_answers = cid_exam_scores_soup.find_all( "span", {"class": "correct-answer"} ) assert len(correct_answers) == self.answers_per_question * len( self.generated_questions ) n = 0 for i, question in enumerate(self.generated_questions): match self.app_name: case "physics": for ans in question.get_answers(): assert correct_answers[n].text.strip() == f"Correct answer: {ans}" n = n + 1 case "sbas": ans = question.get_correct_answer() assert correct_answers[i].text.strip() == f"Correct answer: {ans}" submitted_answers = cid_exam_scores_soup.find_all( "span", {"class": "submitted-user-answer"} ) assert len(submitted_answers) == self.answers_per_question * len( self.generated_questions ) print(submitted_answers) for ans in submitted_answers[:-self.answers_per_question]: print("------", ans.text.strip()) match self.app_name: case "physics": assert ans.text.strip() == "True" case "sbas": assert ans.text.strip() == "a" # The new question will not have any answers for ans in submitted_answers[-self.answers_per_question:]: assert ans.text.strip() == "Not answered" match self.app_name: case "physics": assert ( cid_exam_scores_soup.find("div", {"class": "score-overview"}) .find("span", {"id": "total-score"}) .string == "9" ) assert ( cid_exam_scores_soup.find("div", {"class": "score-overview"}) .find("span", {"id": "max-score"}) .string == "20" ) case "sbas": assert ( cid_exam_scores_soup.find("div", {"class": "score-overview"}) .find("span", {"id": "total-score"}) .string == "3" ) assert ( cid_exam_scores_soup.find("div", {"class": "score-overview"}) .find("span", {"id": "max-score"}) .string == "4" ) def check_cid_scores_user_page2(self): cid_exam_scores_res = self.client.get(self.exam_scores_url) cid_exam_scores_soup = BeautifulSoup(cid_exam_scores_res.content, "html.parser") # As we have answered correctly the new score should be increased by 1 * answer_count match self.app_name: case "physics": assert ( cid_exam_scores_soup.find("div", {"class": "score-overview"}) .find("span", {"id": "total-score"}) .string == "14" ), f"Invalid answer count, Current user: {self.current_user}" case "sbas": assert ( cid_exam_scores_soup.find("div", {"class": "score-overview"}) .find("span", {"id": "total-score"}) .string == "4" ), f"Invalid answer count, Current user: {self.current_user}" # Check what happens when exam is not active self.exam.active = False self.exam.save() start_page_res = self.client.get( reverse(f"{self.app_name}:exam_start", kwargs={"pk": self.exam.pk}) ) start_page_soup = BeautifulSoup(start_page_res.content, "html.parser") assert "Exam is currently inactive" in start_page_soup.text if self.testing_cid_user: cid_take_url = reverse( f"{self.app_name}:exam_take", kwargs={ "pk": self.exam.pk, "sk": 0, "cid": self.current_user.cid, "passcode": self.current_user.passcode, }, ) else: cid_take_url = reverse( f"{self.app_name}:exam_take_user", kwargs={ "pk": self.exam.pk, "sk": 0, }, ) cid_take_res = self.client.get(cid_take_url) cid_take_soup = BeautifulSoup(cid_take_res.content, "html.parser") print(cid_take_soup.text) assert "Exam is currently inactive" in cid_take_soup.text # Delete the last question, from both the list q = self.generated_questions.pop() # and the database q.delete()