Enhance Supervisor Dashboard and Trainee Feedback Features
- Redesigned the supervisor overview page to include a header card with supervisor details and trainee statistics. - Implemented a tabbed interface for viewing trainees and their recent attempts. - Improved the trainee detail page with a profile card and a table for displaying attempt history. - Added a new view for supervisors to provide feedback on case collections, including outstanding feedback items and previous conversations. - Updated URL routing to support new feedback functionality. - Enhanced backend logic to ensure proper sharing permissions for exam attempts and case collections. - Added comprehensive tests for supervisor access to trainee data and feedback functionalities.
This commit is contained in:
@@ -11,6 +11,9 @@ import datetime
|
||||
|
||||
class TestExamCollections:
|
||||
def test_list_url(self, db, client):
|
||||
from django.contrib.auth.models import User
|
||||
user = User.objects.create_user(username="test_list_user", password="password")
|
||||
client.force_login(user)
|
||||
# Test with no content
|
||||
response = client.get(reverse("generic:examcollection_list"))
|
||||
assert response.status_code == 200
|
||||
@@ -22,11 +25,12 @@ class TestExamCollections:
|
||||
assert response.status_code == 404
|
||||
|
||||
collection = ExamCollection.objects.create(name="test collection", date=datetime.date.today())
|
||||
collection.author.add(user)
|
||||
|
||||
response = client.get(reverse("generic:examcollection_list"))
|
||||
assert response.status_code == 200
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
assert collection.name in soup.find("li", class_="collection").text
|
||||
assert collection.name in soup.find("a", class_="stretched-link").text
|
||||
|
||||
|
||||
response = client.get(reverse("generic:examcollection_detail", args=( collection.pk, )))
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import Client
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from generic.models import Supervisor, UserProfile, CidUserExam
|
||||
from physics.models import Exam as PhysicsExam
|
||||
from atlas.models import CaseCollection, Case, CaseDetail
|
||||
|
||||
@pytest.fixture
|
||||
def supervisor_user(db):
|
||||
return User.objects.create_user(username="supervisor_user", password="password", email="supervisor@test.com")
|
||||
|
||||
@pytest.fixture
|
||||
def supervisor_model(db, supervisor_user):
|
||||
return Supervisor.objects.create(name="Test Supervisor", email="supervisor@test.com", user=supervisor_user)
|
||||
|
||||
@pytest.fixture
|
||||
def other_supervisor_user(db):
|
||||
return User.objects.create_user(username="other_supervisor", password="password", email="other@test.com")
|
||||
|
||||
@pytest.fixture
|
||||
def other_supervisor(db, other_supervisor_user):
|
||||
return Supervisor.objects.create(name="Other Supervisor", email="other@test.com", user=other_supervisor_user)
|
||||
|
||||
@pytest.fixture
|
||||
def trainee_user(db, supervisor_model):
|
||||
user = User.objects.create_user(username="trainee", password="password", email="trainee@test.com")
|
||||
profile = user.userprofile
|
||||
profile.supervisor = supervisor_model
|
||||
profile.save()
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def physics_exam(db):
|
||||
return PhysicsExam.objects.create(name="Test Physics Exam", exam_mode=True)
|
||||
|
||||
@pytest.fixture
|
||||
def case_collection(db):
|
||||
collection = CaseCollection.objects.create(name="Test Case Collection")
|
||||
# Enable messaging
|
||||
collection.feedback_once_collection_complete = True
|
||||
collection.save()
|
||||
return collection
|
||||
|
||||
@pytest.fixture
|
||||
def case_model(db):
|
||||
return Case.objects.create(title="Test Case")
|
||||
|
||||
@pytest.fixture
|
||||
def case_detail(db, case_collection, case_model):
|
||||
return CaseDetail.objects.create(collection=case_collection, case=case_model, sort_order=0)
|
||||
|
||||
@pytest.fixture
|
||||
def exam_attempt(db, trainee_user, physics_exam):
|
||||
ct = ContentType.objects.get_for_model(PhysicsExam)
|
||||
return CidUserExam.objects.create(
|
||||
content_type=ct,
|
||||
object_id=physics_exam.pk,
|
||||
user_user=trainee_user,
|
||||
share_with_supervisor=False
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def collection_attempt(db, trainee_user, case_collection):
|
||||
ct = ContentType.objects.get_for_model(CaseCollection)
|
||||
return CidUserExam.objects.create(
|
||||
content_type=ct,
|
||||
object_id=case_collection.pk,
|
||||
user_user=trainee_user,
|
||||
share_with_supervisor=False
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_supervisor_overview_access(
|
||||
supervisor_user,
|
||||
other_supervisor_user,
|
||||
supervisor_model,
|
||||
trainee_user,
|
||||
physics_exam,
|
||||
exam_attempt,
|
||||
case_collection,
|
||||
collection_attempt,
|
||||
):
|
||||
client = Client()
|
||||
url = reverse("generic:supervisor_overview", kwargs={"pk": supervisor_model.pk})
|
||||
|
||||
# Anonymous blocked
|
||||
response = client.get(url)
|
||||
assert response.status_code == 302
|
||||
|
||||
# Other supervisor blocked
|
||||
client.force_login(other_supervisor_user)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 403
|
||||
|
||||
# Authorized supervisor allowed
|
||||
client.force_login(supervisor_user)
|
||||
|
||||
# First test with private attempts
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert b"Test Physics Exam" in response.content
|
||||
assert b"Test Case Collection" in response.content
|
||||
assert b"Locked" in response.content
|
||||
|
||||
# Now test with shared attempts
|
||||
exam_attempt.share_with_supervisor = True
|
||||
exam_attempt.save()
|
||||
collection_attempt.share_with_supervisor = True
|
||||
collection_attempt.save()
|
||||
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert b"Test Physics Exam" in response.content
|
||||
assert b"Test Case Collection" in response.content
|
||||
assert b"0 / 0 cases" in response.content # collection progress check
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_supervisor_trainee_access(supervisor_user, other_supervisor_user, supervisor_model, trainee_user):
|
||||
client = Client()
|
||||
url = reverse("generic:supervisor_trainee", kwargs={"pk": supervisor_model.pk, "trainee_id": trainee_user.pk})
|
||||
|
||||
# Other supervisor blocked
|
||||
client.force_login(other_supervisor_user)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 403
|
||||
|
||||
# Authorized supervisor allowed
|
||||
client.force_login(supervisor_user)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_exam_scores_user_supervisor_sharing(supervisor_user, other_supervisor_user, supervisor_model, trainee_user, physics_exam, exam_attempt):
|
||||
client = Client()
|
||||
url = reverse("physics:exam_scores_user_supervisor", kwargs={"pk": physics_exam.pk, "user_id": trainee_user.pk})
|
||||
|
||||
# Other supervisor blocked regardless
|
||||
client.force_login(other_supervisor_user)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 403
|
||||
|
||||
# Authorized supervisor blocked when not shared
|
||||
client.force_login(supervisor_user)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 403
|
||||
|
||||
# Share and check allowed
|
||||
exam_attempt.share_with_supervisor = True
|
||||
exam_attempt.save()
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Unshare, but set results_supervisor_visible = True
|
||||
exam_attempt.share_with_supervisor = False
|
||||
exam_attempt.save()
|
||||
physics_exam.results_supervisor_visible = True
|
||||
physics_exam.save()
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_supervisor_collection_feedback_sharing(supervisor_user, supervisor_model, trainee_user, case_collection, collection_attempt):
|
||||
client = Client()
|
||||
url = reverse("atlas:collection_history_user", kwargs={
|
||||
"exam_id": case_collection.pk,
|
||||
"user_pk": trainee_user.pk
|
||||
})
|
||||
|
||||
# Supervisor blocked when not shared
|
||||
client.force_login(supervisor_user)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 403
|
||||
|
||||
# Supervisor allowed when shared
|
||||
collection_attempt.share_with_supervisor = True
|
||||
collection_attempt.save()
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_collection_case_review_thread_supervisor_access(supervisor_user, supervisor_model, trainee_user, case_collection, collection_attempt, case_model, case_detail):
|
||||
client = Client()
|
||||
url = reverse("atlas:collection_case_review_thread", kwargs={
|
||||
"exam_id": case_collection.pk,
|
||||
"cid_user_exam_id": collection_attempt.pk,
|
||||
"case_id": case_model.pk
|
||||
})
|
||||
|
||||
# Blocked when attempt is not shared
|
||||
client.force_login(supervisor_user)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 403
|
||||
|
||||
# Allowed when shared
|
||||
collection_attempt.share_with_supervisor = True
|
||||
collection_attempt.save()
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Post a feedback reply as supervisor
|
||||
response = client.post(url, {
|
||||
"action": "add_message",
|
||||
"message_type": "FB",
|
||||
"message": "Nice job trainee!"
|
||||
}, HTTP_X_REQUESTED_WITH="XMLHttpRequest")
|
||||
assert response.status_code == 200
|
||||
assert b"Nice job trainee!" in response.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_collection_case_view_take_review_user(
|
||||
supervisor_user,
|
||||
other_supervisor_user,
|
||||
supervisor_model,
|
||||
trainee_user,
|
||||
case_collection,
|
||||
case_model,
|
||||
case_detail,
|
||||
collection_attempt,
|
||||
):
|
||||
"""Supervisors can view a trainee's case answers in read-only mode.
|
||||
|
||||
- Unshared attempts are blocked (403).
|
||||
- Shared attempts return 200 with the reviewer banner present.
|
||||
- Other supervisors with no relationship to the trainee are blocked.
|
||||
- A collection author can always view the answers.
|
||||
"""
|
||||
client = Client()
|
||||
url = reverse(
|
||||
"atlas:collection_case_view_take_review_user",
|
||||
kwargs={"pk": case_collection.pk, "case_number": 0, "user_pk": trainee_user.pk},
|
||||
)
|
||||
|
||||
# Anonymous user is redirected to login
|
||||
response = client.get(url)
|
||||
assert response.status_code == 302
|
||||
|
||||
# Authorized supervisor blocked when attempt not shared
|
||||
client.force_login(supervisor_user)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 403
|
||||
|
||||
# Authorized supervisor allowed when attempt is shared
|
||||
collection_attempt.share_with_supervisor = True
|
||||
collection_attempt.save()
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
# Reviewer banner should be present
|
||||
assert b"Reviewer mode" in response.content
|
||||
assert b"read-only" in response.content
|
||||
|
||||
# Other supervisor (not linked to trainee) is still blocked even when shared
|
||||
client.force_login(other_supervisor_user)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 403
|
||||
|
||||
# Collection author can always view the answers
|
||||
author_user = User.objects.create_user(username="author_user", password="password")
|
||||
case_collection.author.add(author_user)
|
||||
client.force_login(author_user)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert b"Reviewer mode" in response.content
|
||||
Reference in New Issue
Block a user