diff --git a/cypress/e2e/1.Login/login.cy.js b/cypress/e2e/1.Login/login.cy.js index a3880286..cf3e547a 100644 --- a/cypress/e2e/1.Login/login.cy.js +++ b/cypress/e2e/1.Login/login.cy.js @@ -1,50 +1,50 @@ -// describe('Log In Feature: Test Invalid login', () => { -// it('should not log in', () => { -// cy.visit('/accounts/login/'); -// cy.title().should('contain', 'Log in'); -// // cy.get('#cu-privacy-notice-button').click(); -// cy.get('#guest-login').click(); -// cy.get('form[name="login_local"] div.login-local-form') -// .should('be.visible'); -// cy.get('#id_username').type('foo'); -// cy.get('#id_username').blur(); -// cy.get('#id_password').type('foo'); -// cy.get('#id_password').blur(); -// cy.get('form[name="login_local"] button[type="submit"]').click(); -// cy.title().should('contain', 'Log in'); -// }); -// }); +describe('Log In Feature: Test Invalid login', () => { + it('should not log in', () => { + cy.visit('/accounts/login/'); + cy.title().should('contain', 'Log in'); + // cy.get('#cu-privacy-notice-button').click(); + cy.get('#guest-login').click(); + cy.get('form[name="login_local"] div.login-local-form') + .should('be.visible'); + cy.get('#id_username').type('foo'); + cy.get('#id_username').blur(); + cy.get('#id_password').type('foo'); + cy.get('#id_password').blur(); + cy.get('form[name="login_local"] button[type="submit"]').click(); + cy.title().should('contain', 'Log in'); + }); +}); -// describe('Log In Feature: Test Instructor Login', () => { -// it('Logs in as faculty_one', () => { -// cy.visit('/accounts/login/'); -// cy.title().should('contain', 'Log in'); -// cy.get('#guest-login').click(); -// cy.get('form[name="login_local"] div.login-local-form') -// .should('be.visible'); -// cy.get('#id_username').type('faculty_one'); -// cy.get('#id_username').blur(); -// cy.get('#id_password').type('test'); -// cy.get('#id_password').blur(); -// cy.get('form[name="login_local"] button[type="submit"]').click(); -// cy.title().should('contain', 'My Courses'); -// cy.get('.navbar').should('contain', 'Faculty One'); -// }); -// }); +describe('Log In Feature: Test Instructor Login', () => { + it('Logs in as faculty_one', () => { + cy.visit('/accounts/login/'); + cy.title().should('contain', 'Log in'); + cy.get('#guest-login').click(); + cy.get('form[name="login_local"] div.login-local-form') + .should('be.visible'); + cy.get('#id_username').type('faculty_one'); + cy.get('#id_username').blur(); + cy.get('#id_password').type('test'); + cy.get('#id_password').blur(); + cy.get('form[name="login_local"] button[type="submit"]').click(); + cy.title().should('contain', 'My Courses'); + cy.get('.navbar').should('contain', 'Faculty One'); + }); +}); -// describe('Log In Feature: Test Student Login', () => { -// it('should test student login', () => { -// cy.visit('/accounts/login/'); -// cy.title().should('contain', 'Log in'); -// cy.get('#guest-login').click(); -// cy.get('form[name="login_local"] div.login-local-form') -// .should('be.visible'); -// cy.get('#id_username').type('faculty_one'); -// cy.get('#id_username').blur(); -// cy.get('#id_password').type('test'); -// cy.get('#id_password').blur(); -// cy.get('form[name="login_local"] button[type="submit"]').click(); -// cy.title().should('contain', 'My Courses'); -// cy.get('.navbar').should('contain', 'Faculty One'); -// }); -// }); +describe('Log In Feature: Test Student Login', () => { + it('should test student login', () => { + cy.visit('/accounts/login/'); + cy.title().should('contain', 'Log in'); + cy.get('#guest-login').click(); + cy.get('form[name="login_local"] div.login-local-form') + .should('be.visible'); + cy.get('#id_username').type('faculty_one'); + cy.get('#id_username').blur(); + cy.get('#id_password').type('test'); + cy.get('#id_password').blur(); + cy.get('form[name="login_local"] button[type="submit"]').click(); + cy.title().should('contain', 'My Courses'); + cy.get('.navbar').should('contain', 'Faculty One'); + }); +}); diff --git a/cypress/e2e/1.Login/visibility.cy.js b/cypress/e2e/1.Login/visibility.cy.js new file mode 100644 index 00000000..3837865f --- /dev/null +++ b/cypress/e2e/1.Login/visibility.cy.js @@ -0,0 +1,29 @@ +describe('Visibility Controls', () => { + beforeEach(() => { + cy.resetTestDB(); + }); + + it('Student does not see toggle buttons and respects visibility', () => { + cy.login('faculty_one', 'test'); + cy.visit('/course/1/simulations/'); + + // Wait for page to load + cy.get('.section-sim-dashboard').should('be.visible'); + + // Verify NO toggle buttons + cy.contains('Show to Students').should('not.exist'); + cy.contains('Hide from Students').should('not.exist'); + }); + + it('Student does not see toggle buttons and respects visibility', () => { + cy.login('student_one', 'test'); + cy.visit('/course/1/simulations/'); + + // Wait for page to load + cy.get('.section-sim-dashboard').should('be.visible'); + + // Verify NO toggle buttons + cy.contains('Show to Students').should('not.exist'); + cy.contains('Hide from Students').should('not.exist'); + }); +}) diff --git a/cypress/e2e/2.Sim1/navigate.cy.js b/cypress/e2e/2.Sim1/navigate.cy.js index 5eb6e64b..9298fab6 100644 --- a/cypress/e2e/2.Sim1/navigate.cy.js +++ b/cypress/e2e/2.Sim1/navigate.cy.js @@ -4,8 +4,6 @@ describe('Navigate to Sim1 from login', () => { cy.visit('/'); cy.title().should('contain', 'My Courses'); cy.get('[data-cy="navbar"]').should('contain', 'Faculty One'); - cy.get('[data-cy="course-1"]') - .should('contain', 'course 0'); cy.get('[data-cy="course-1-link"]').click(); cy.get('[data-cy="sim-1"]').should('contain', 'Simulation 1'); cy.title().should('contain', 'Simulation'); diff --git a/media/js/src/app.jsx b/media/js/src/app.jsx index 5f8f950c..4e6fd51a 100644 --- a/media/js/src/app.jsx +++ b/media/js/src/app.jsx @@ -11,6 +11,7 @@ const isSuperUser = window.MetricsMentor.currentUser.is_superuser; const coursePk = getCoursePk(); export const App = () => { + const [initialVisibleSims, setInitialVisibleSims] = useState([]); const [isFaculty, setIsFaculty] = useState(null); useEffect(() => { @@ -18,6 +19,16 @@ export const App = () => { const facultyStatus = appContainer ? appContainer.dataset.isFaculty === 'True' : false; setIsFaculty(facultyStatus); + + if (appContainer && appContainer.dataset.visibleSimulations) { + try { + const parsed = JSON.parse( + appContainer.dataset.visibleSimulations); + setInitialVisibleSims(parsed); + } catch (e) { + console.error('Failed to parse visible simulations', e); + } + } }, []); if (isFaculty === null) { @@ -30,20 +41,25 @@ export const App = () => { } /> - {(isSuperUser || isFaculty || coursePk === 6) && ( + isFaculty={isFaculty} + initialVisibleSims={initialVisibleSims} />} /> + {(isSuperUser || isFaculty || coursePk === 6 + || initialVisibleSims.includes(1)) && ( } /> )} - {(isSuperUser || isFaculty || coursePk === 6) && ( + {(isSuperUser || isFaculty || coursePk === 6 + || initialVisibleSims.includes(2)) && ( } /> )} - {(isSuperUser || isFaculty || coursePk === 6) && ( + {(isSuperUser || isFaculty || coursePk === 6 + || initialVisibleSims.includes(3)) && ( } /> )} - {(isSuperUser || isFaculty || coursePk === 6) && ( + {(isSuperUser || isFaculty || coursePk === 6 + || initialVisibleSims.includes(4)) && ( } /> )} diff --git a/media/js/src/containers/dashboard.jsx b/media/js/src/containers/dashboard.jsx index 6f8e1a06..cc4d70f1 100644 --- a/media/js/src/containers/dashboard.jsx +++ b/media/js/src/containers/dashboard.jsx @@ -3,21 +3,59 @@ import { useParams, Link } from 'react-router-dom'; import { Footer } from '../footer'; import PropTypes from 'prop-types'; import { Katex } from '../utils/katexComponent'; -import { getCoursePk } from '../utils/utils'; +import { getCoursePk, toggleVisibility } from '../utils/utils'; +import { useState } from 'react'; -export const Dashboard = ({ isSuperUser, isFaculty}) => { +export const Dashboard = ({ isSuperUser, isFaculty, initialVisibleSims }) => { let { courseId } = useParams(); const coursePk = getCoursePk(); + const [visibleSimulations, setVisibleSimulations] = useState( + initialVisibleSims || [] + ); + + const handleToggle = async(simId) => { + const result = await toggleVisibility(coursePk, simId); + if (result.status === 'success') { + setVisibleSimulations(prev => { + if (result.is_visible) { + return [...prev, simId]; + } else { + return prev.filter(id => id !== simId); + } + }); + } + }; + + const isVisible = (simId) => { + if (isSuperUser || isFaculty) return true; + return visibleSimulations.includes(simId); + }; + + const renderToggle = (simId) => { + if (!isSuperUser) return null; + const isShown = visibleSimulations.includes(simId); + return ( + + ); + }; return ( <>
- {(isSuperUser || isFaculty || coursePk === 6) && ( + {isVisible(1) && (
+ {renderToggle(1)}

@@ -46,9 +84,10 @@ export const Dashboard = ({ isSuperUser, isFaculty}) => {

)} - {(isSuperUser || isFaculty || coursePk === 6) && ( + {isVisible(2) && (
+ {renderToggle(2)}

@@ -73,9 +112,10 @@ export const Dashboard = ({ isSuperUser, isFaculty}) => {

)} - {(isSuperUser || isFaculty || coursePk === 6) && ( + {isVisible(3) && (
+ {renderToggle(3)}

@@ -102,9 +142,10 @@ export const Dashboard = ({ isSuperUser, isFaculty}) => {

)} - {(isSuperUser || isFaculty || coursePk === 6) && ( + {isVisible(4) && (
+ {renderToggle(4)}

@@ -138,5 +179,6 @@ export const Dashboard = ({ isSuperUser, isFaculty}) => { Dashboard.propTypes = { isSuperUser: PropTypes.bool, - isFaculty: PropTypes.bool + isFaculty: PropTypes.bool, + initialVisibleSims: PropTypes.array }; \ No newline at end of file diff --git a/media/js/src/utils/utils.jsx b/media/js/src/utils/utils.jsx index 9d8968fa..ffdd712b 100644 --- a/media/js/src/utils/utils.jsx +++ b/media/js/src/utils/utils.jsx @@ -252,4 +252,23 @@ export const createSubmission = async( export const getCoursePk = () => { const simContainer = document.querySelector('#react-root'); return simContainer ? Number(simContainer.dataset.course) : ''; +}; + +/** + * Toggles visibility of a simulation. + * @param {number} coursePk + * @param {number} simulationId + * @returns {Promise} + */ +export const toggleVisibility = async(coursePk, simulationId) => { + try { + const response = await authedFetch('/api/toggle-visibility/', 'POST', { + course_id: coursePk, + simulation_id: simulationId + }); + return await response.json(); + } catch (error) { + console.error('Error toggling visibility:', error); + return { status: 'error' }; + } }; \ No newline at end of file diff --git a/metricsmentor/main/migrations/0003_simulationvisibility.py b/metricsmentor/main/migrations/0003_simulationvisibility.py new file mode 100644 index 00000000..86ea4aa5 --- /dev/null +++ b/metricsmentor/main/migrations/0003_simulationvisibility.py @@ -0,0 +1,26 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('courseaffils', '0001_initial'), + ('main', '0002_answer_active_quizsubmission_active'), + ] + + operations = [ + migrations.CreateModel( + name='SimulationVisibility', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('simulation', models.IntegerField(choices=[(1, 'Simulation 1'), (2, 'Simulation 2'), (3, 'Simulation 3'), (4, 'Simulation 4'), (5, 'Simulation 5'), (6, 'Simulation 6'), (7, 'Simulation 7')])), + ('is_visible', models.BooleanField(default=False)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='courseaffils.Course')), + ], + options={ + 'verbose_name_plural': 'Simulation Visibilities', + 'unique_together': {('course', 'simulation')}, + }, + ), + ] diff --git a/metricsmentor/main/models.py b/metricsmentor/main/models.py index fb17c478..600abc15 100644 --- a/metricsmentor/main/models.py +++ b/metricsmentor/main/models.py @@ -34,3 +34,19 @@ class Answer(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) active = models.BooleanField(default=True) + + +class SimulationVisibility(models.Model): + course = models.ForeignKey(Course, on_delete=models.CASCADE) + simulation = models.IntegerField(choices=SIMULATIONS) + is_visible = models.BooleanField(default=False) + + class Meta: + unique_together = ('course', 'simulation') + verbose_name_plural = "Simulation Visibilities" + + def __str__(self): + return ( + f"{self.course} - {self.get_simulation_display()} - " + f"{'Visible' if self.is_visible else 'Hidden'}" + ) diff --git a/metricsmentor/main/tests/test_views.py b/metricsmentor/main/tests/test_views.py index e6f354ef..51e6bd0a 100644 --- a/metricsmentor/main/tests/test_views.py +++ b/metricsmentor/main/tests/test_views.py @@ -353,3 +353,69 @@ def test_delete_submission_not_found(self): response_data = json.loads(response.content) self.assertEqual(response_data['status'], 'error') self.assertEqual(response_data['message'], 'Quiz submission not found') + + +class ToggleVisibilityViewTest(CourseTestMixin, TestCase): + def test_toggle_visibility_superuser(self): + self.setup_course() + self.client.force_login(self.superuser) + + url = reverse('toggle-visibility') + data = { + 'course_id': self.registrar_course.pk, + 'simulation_id': 1 + } + + # Test enabling visibility + response = self.client.post( + url, + data=json.dumps(data), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content) + self.assertTrue(response_data['is_visible']) + + # Test disabling visibility + response = self.client.post( + url, + data=json.dumps(data), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content) + self.assertFalse(response_data['is_visible']) + + def test_toggle_visibility_permission_denied(self): + self.setup_course() + self.client.force_login(self.student) + + url = reverse('toggle-visibility') + data = { + 'course_id': self.registrar_course.pk, + 'simulation_id': 1 + } + + response = self.client.post( + url, + data=json.dumps(data), + content_type='application/json' + ) + self.assertEqual(response.status_code, 403) + + def test_toggle_visibility_faculty_denied(self): + self.setup_course() + self.client.force_login(self.faculty) + + url = reverse('toggle-visibility') + data = { + 'course_id': self.registrar_course.pk, + 'simulation_id': 1 + } + + response = self.client.post( + url, + data=json.dumps(data), + content_type='application/json' + ) + self.assertEqual(response.status_code, 403) diff --git a/metricsmentor/main/views.py b/metricsmentor/main/views.py index 51420995..71f00fe5 100644 --- a/metricsmentor/main/views.py +++ b/metricsmentor/main/views.py @@ -1,5 +1,6 @@ import re + from courseaffils.columbia import CourseStringTemplate, CanvasTemplate from courseaffils.models import Course from courseaffils.views import get_courses_for_user @@ -19,7 +20,9 @@ from lti_provider.models import LTICourseContext from metricsmentor.main.utils import send_template_email from metricsmentor.mixins import LoggedInCourseMixin -from metricsmentor.main.models import Answer, QuizSubmission +from metricsmentor.main.models import ( + Answer, QuizSubmission, SimulationVisibility +) from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.utils.decorators import method_decorator @@ -71,13 +74,62 @@ def get_context_data(self, **kwargs): course = Course.objects.get(pk=course_id) is_faculty = course.is_true_faculty(self.request.user) + # Get visible simulations + visible_sims = SimulationVisibility.objects.filter( + course=course, + is_visible=True + ).values_list('simulation', flat=True) + + # Convert to list for JSON serialization + visible_simulations = list(visible_sims) + return { 'user': self.request.user, 'course': course, - 'is_faculty': is_faculty + 'is_faculty': is_faculty, + 'visible_simulations': json.dumps(visible_simulations) } +class ToggleVisibilityView(LoginRequiredMixin, View): + def post(self, request, *args, **kwargs): + if not request.user.is_superuser: + return JsonResponse({ + 'status': 'error', + 'message': 'Unauthorized' + }, + status=403 + ) + + try: + data = json.loads(request.body) + course_id = data.get('course_id') + simulation_id = data.get('simulation_id') + + course = Course.objects.get(pk=course_id) + + visibility, created = SimulationVisibility.objects.get_or_create( + course=course, + simulation=simulation_id + ) + + # Toggle visibility + visibility.is_visible = not visibility.is_visible + visibility.save() + + return JsonResponse({ + 'status': 'success', + 'is_visible': visibility.is_visible, + 'simulation_id': simulation_id + }) + + except Exception: + return JsonResponse( + {'status': 'error', 'message': 'An internal error occurred'}, + status=500 + ) + + class LTICourseCreate(LoginRequiredMixin, View): def notify_staff(self, course): diff --git a/metricsmentor/templates/main/simulation_dashboard.html b/metricsmentor/templates/main/simulation_dashboard.html index 4071da70..29bf97c8 100644 --- a/metricsmentor/templates/main/simulation_dashboard.html +++ b/metricsmentor/templates/main/simulation_dashboard.html @@ -12,8 +12,8 @@ {% block content %}
+ data-semester="{{ course.info.termyear }}" data-coursetitle="{{ course.title }}" + data-is-faculty="{{ is_faculty }}" data-visible-simulations="{{ visible_simulations }}">
{% endblock %} diff --git a/metricsmentor/urls.py b/metricsmentor/urls.py index 89fc7c66..5de2f77a 100755 --- a/metricsmentor/urls.py +++ b/metricsmentor/urls.py @@ -51,6 +51,8 @@ re_path(r'^course/(?P\d+)/api/create-sub/$', views.CreateSubmission.as_view(), name='create_submission'), + path('api/toggle-visibility/', views.ToggleVisibilityView.as_view(), + name='toggle-visibility'), re_path('^contact/', include('contactus.urls')), path('course//save_answer/', views.SaveAnswer.as_view(), name='save_answer'),