diff --git a/csm_web/frontend/src/components/App.tsx b/csm_web/frontend/src/components/App.tsx
index 841364f28..66ca218cf 100644
--- a/csm_web/frontend/src/components/App.tsx
+++ b/csm_web/frontend/src/components/App.tsx
@@ -8,6 +8,7 @@ import { emptyRoles, Roles } from "../utils/user";
import CourseMenu from "./CourseMenu";
import Home from "./Home";
import Policies from "./Policies";
+import UserProfile from "./UserProfile";
import { DataExport } from "./data_export/DataExport";
import { EnrollmentMatcher } from "./enrollment_automation/EnrollmentMatcher";
import { Resources } from "./resource_aggregation/Resources";
@@ -41,6 +42,13 @@ const App = () => {
} />
} />
} />
+ {
+ // TODO: add route for profiles (/profile/:id/* element = {UserProfile})
+ // TODO: add route for your own profile /profile/*
+ // reference Section
+ }
+ } />
+ } />
} />
@@ -79,7 +87,7 @@ function Header(): React.ReactElement {
};
/**
- * Helper function to determine class name for the home NavLInk component;
+ * Helper function to determine class name for the home NavLnk component;
* is always active unless we're in another tab.
*/
const homeNavlinkClass = () => {
@@ -140,6 +148,9 @@ function Header(): React.ReactElement {
Policies
+
+ Profile
+
diff --git a/csm_web/frontend/src/components/CourseMenu.tsx b/csm_web/frontend/src/components/CourseMenu.tsx
index a4d67e90e..3796a4692 100644
--- a/csm_web/frontend/src/components/CourseMenu.tsx
+++ b/csm_web/frontend/src/components/CourseMenu.tsx
@@ -3,8 +3,8 @@ import React, { useEffect, useState } from "react";
import { Link, Route, Routes } from "react-router-dom";
import { DEFAULT_LONG_LOCALE_OPTIONS, DEFAULT_TIMEZONE } from "../utils/datetime";
-import { useUserInfo } from "../utils/queries/base";
import { useCourses } from "../utils/queries/courses";
+import { useUserInfo } from "../utils/queries/profiles";
import { Course as CourseType, UserInfo } from "../utils/types";
import LoadingSpinner from "./LoadingSpinner";
import Course from "./course/Course";
diff --git a/csm_web/frontend/src/components/UserProfile.tsx b/csm_web/frontend/src/components/UserProfile.tsx
new file mode 100644
index 000000000..933abd6e0
--- /dev/null
+++ b/csm_web/frontend/src/components/UserProfile.tsx
@@ -0,0 +1,228 @@
+import React, { useState, useEffect } from "react";
+import { useParams } from "react-router-dom";
+import { PermissionError } from "../utils/queries/helpers";
+import { useUserInfo, useUserInfoUpdateMutation } from "../utils/queries/profiles";
+import LoadingSpinner from "./LoadingSpinner";
+
+import "../css/base/form.scss";
+import "../css/base/table.scss";
+
+const UserProfile: React.FC = () => {
+ const { id } = useParams();
+ let userId = Number(id);
+ const { data: currUserData, isError: isCurrUserError, isLoading: currUserIsLoading } = useUserInfo();
+ const { data: requestedData, error: requestedError, isLoading: requestedIsLoading } = useUserInfo(userId);
+ const updateMutation = useUserInfoUpdateMutation(userId);
+ const [isEditing, setIsEditing] = useState(false);
+
+ const [formData, setFormData] = useState({
+ firstName: "",
+ lastName: "",
+ bio: "",
+ pronouns: "",
+ pronunciation: ""
+ });
+
+ const [showSaveSpinner, setShowSaveSpinner] = useState(false);
+ const [validationText, setValidationText] = useState("");
+
+ // Populate form data with fetched user data
+ useEffect(() => {
+ if (requestedData) {
+ setFormData({
+ firstName: requestedData.firstName || "",
+ lastName: requestedData.lastName || "",
+ bio: requestedData.bio || "",
+ pronouns: requestedData.pronouns || "",
+ pronunciation: requestedData.pronunciation || ""
+ });
+ }
+ }, [requestedData]);
+
+ if (requestedIsLoading || currUserIsLoading) {
+ return ;
+ }
+
+ if (requestedError || isCurrUserError) {
+ if (requestedError instanceof PermissionError) {
+ return
) => {
+ const { name, value } = e.target;
+ setFormData(prev => ({
+ ...prev,
+ [name]: value
+ }));
+ };
+
+ // Validate current form data
+ const validateFormData = (): boolean => {
+ if (!formData.firstName || !formData.lastName) {
+ setValidationText("First and last names must be specified.");
+ return false;
+ }
+
+ setValidationText("");
+ return true;
+ };
+
+ // Handle form submission
+ const handleFormSubmit = () => {
+ if (!validateFormData()) {
+ return;
+ }
+
+ setShowSaveSpinner(true);
+
+ updateMutation.mutate(
+ {
+ id: userId,
+ firstName: formData.firstName,
+ lastName: formData.lastName,
+ bio: formData.bio,
+ pronouns: formData.pronouns,
+ pronunciation: formData.pronunciation
+ },
+ {
+ onSuccess: () => {
+ setIsEditing(false); // Exit edit mode after successful save
+ console.log("Profile updated successfully");
+ setShowSaveSpinner(false);
+ },
+ onError: () => {
+ setValidationText("Error occurred on save.");
+ setShowSaveSpinner(false);
+ }
+ }
+ );
+ };
+
+ const isCurrUser = currUserData?.id === requestedData?.id || requestedData.isEditable;
+
+ // Toggle edit mode
+ const handleEditToggle = () => {
+ setIsEditing(true);
+ };
+
+ return (
+
+ );
+};
+
+export default UserProfile;
diff --git a/csm_web/frontend/src/components/course/CreateSectionModal.tsx b/csm_web/frontend/src/components/course/CreateSectionModal.tsx
index 6e7632f32..052e66d39 100644
--- a/csm_web/frontend/src/components/course/CreateSectionModal.tsx
+++ b/csm_web/frontend/src/components/course/CreateSectionModal.tsx
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { DAYS_OF_WEEK } from "../../utils/datetime";
-import { useUserEmails } from "../../utils/queries/base";
+import { useUserEmails } from "../../utils/queries/profiles";
import { useSectionCreateMutation } from "../../utils/queries/sections";
import { Spacetime } from "../../utils/types";
import Modal from "../Modal";
diff --git a/csm_web/frontend/src/components/course/SectionCard.tsx b/csm_web/frontend/src/components/course/SectionCard.tsx
index e7dfc7c20..b28c2dae3 100644
--- a/csm_web/frontend/src/components/course/SectionCard.tsx
+++ b/csm_web/frontend/src/components/course/SectionCard.tsx
@@ -90,13 +90,17 @@ export const SectionCard = ({
const iconWidth = "8em";
const iconHeight = "8em";
if (enrollmentSuccessful) {
+ const inlineIconWidth = "1.3em";
+ const inlineIconHeight = "1.3em";
return (
Successfully enrolled
-
- OK
-
+ To view and update your profile, click the button below
+
+
+ Profile
+
);
}
@@ -168,7 +172,7 @@ export const SectionCard = ({
)}
- {mentor.name}
+ {mentor.name}
{`${numStudentsEnrolled}/${capacity}`}
diff --git a/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx b/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx
index ff2f5a5aa..e3ce358b3 100644
--- a/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx
+++ b/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx
@@ -1,8 +1,7 @@
import { DateTime } from "luxon";
import React, { useState } from "react";
import { Link } from "react-router-dom";
-
-import { useUserEmails } from "../../utils/queries/base";
+import { useUserEmails } from "../../utils/queries/profiles";
import { useEnrollStudentMutation } from "../../utils/queries/sections";
import LoadingSpinner from "../LoadingSpinner";
import Modal from "../Modal";
diff --git a/csm_web/frontend/src/components/section/Section.tsx b/csm_web/frontend/src/components/section/Section.tsx
index 769d7919b..4d725134d 100644
--- a/csm_web/frontend/src/components/section/Section.tsx
+++ b/csm_web/frontend/src/components/section/Section.tsx
@@ -13,7 +13,6 @@ import "../../css/section.scss";
export default function Section(): React.ReactElement | null {
const { id } = useParams();
-
const { data: section, isSuccess: sectionLoaded, isError: sectionLoadError } = useSection(Number(id));
if (!sectionLoaded) {
diff --git a/csm_web/frontend/src/css/base/form.scss b/csm_web/frontend/src/css/base/form.scss
index 9bb70ac22..434a9a5a4 100644
--- a/csm_web/frontend/src/css/base/form.scss
+++ b/csm_web/frontend/src/css/base/form.scss
@@ -71,7 +71,7 @@
border: none;
}
-/* Neccessary for options to be legible on Windows */
+/* Necessary for options to be legible on Windows */
.form-select > option {
color: black;
diff --git a/csm_web/frontend/src/utils/queries/base.tsx b/csm_web/frontend/src/utils/queries/base.tsx
index a9dd33cef..5f9dfa326 100644
--- a/csm_web/frontend/src/utils/queries/base.tsx
+++ b/csm_web/frontend/src/utils/queries/base.tsx
@@ -10,7 +10,7 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { fetchNormalized } from "../api";
-import { Profile, RawUserInfo } from "../types";
+import { Profile } from "../types";
import { handleError, handlePermissionsError, handleRetry, ServerError } from "./helpers";
/**
@@ -34,47 +34,3 @@ export const useProfiles = (): UseQueryResult => {
handleError(queryResult);
return queryResult;
};
-
-/**
- * Hook to get the user's info.
- */
-export const useUserInfo = (): UseQueryResult => {
- const queryResult = useQuery(
- ["userinfo"],
- async () => {
- const response = await fetchNormalized("/userinfo");
- if (response.ok) {
- return await response.json();
- } else {
- handlePermissionsError(response.status);
- throw new ServerError("Failed to fetch user info");
- }
- },
- { retry: handleRetry }
- );
-
- handleError(queryResult);
- return queryResult;
-};
-
-/**
- * Hook to get a list of all user emails.
- */
-export const useUserEmails = (): UseQueryResult => {
- const queryResult = useQuery(
- ["users"],
- async () => {
- const response = await fetchNormalized("/users");
- if (response.ok) {
- return await response.json();
- } else {
- handlePermissionsError(response.status);
- throw new ServerError("Failed to fetch user info");
- }
- },
- { retry: handleRetry }
- );
-
- handleError(queryResult);
- return queryResult;
-};
diff --git a/csm_web/frontend/src/utils/queries/profiles.tsx b/csm_web/frontend/src/utils/queries/profiles.tsx
new file mode 100644
index 000000000..ac95050e9
--- /dev/null
+++ b/csm_web/frontend/src/utils/queries/profiles.tsx
@@ -0,0 +1,85 @@
+import { useMutation, UseMutationResult, useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query";
+
+import { fetchNormalized, fetchWithMethod, HTTP_METHODS } from "../api";
+import { RawUserInfo } from "../types";
+import { handleError, handlePermissionsError, handleRetry, ServerError } from "./helpers";
+
+/**
+ * Hook to get a list of all user emails.
+ */
+export const useUserEmails = (): UseQueryResult => {
+ const queryResult = useQuery(
+ ["users"],
+ async () => {
+ const response = await fetchNormalized("/users");
+ if (response.ok) {
+ return await response.json();
+ } else {
+ handlePermissionsError(response.status);
+ throw new ServerError("Failed to fetch user info");
+ }
+ },
+ { retry: handleRetry }
+ );
+
+ handleError(queryResult);
+ return queryResult;
+};
+
+/**
+ * Hook to get user info. If userId is provided, fetches details for that user;
+ * otherwise, fetches current user's info.
+ */
+export const useUserInfo = (userId?: number): UseQueryResult => {
+ const queryKey = userId ? ["userDetails", userId] : ["user"];
+
+ const queryResult = useQuery(
+ queryKey,
+ async () => {
+ const endpoint = userId ? `/user/${userId}` : "/user";
+ const response = await fetchNormalized(endpoint);
+ if (response.ok) {
+ return await response.json();
+ } else {
+ handlePermissionsError(response.status);
+ throw new ServerError(userId ? "Failed to fetch user details" : "Failed to fetch user info");
+ }
+ },
+ {
+ retry: handleRetry
+ }
+ );
+
+ handleError(queryResult);
+ return queryResult;
+};
+
+/**
+ * Hook to update a user's profile information.
+ */
+export const useUserInfoUpdateMutation = (
+ userId: number
+): UseMutationResult> => {
+ const queryClient = useQueryClient();
+ const mutationResult = useMutation>(
+ async (body: Partial) => {
+ const response = await fetchWithMethod(`/user/${userId}/update`, HTTP_METHODS.PUT, body);
+ if (response.ok) {
+ return;
+ } else {
+ handlePermissionsError(response.status);
+ throw new ServerError(`Failed to update user profile with ID ${userId}`);
+ }
+ },
+ {
+ onSuccess: () => {
+ // Invalidate queries related to the user's profile to ensure fresh data
+ queryClient.invalidateQueries(["userProfile", userId]);
+ },
+ retry: handleRetry
+ }
+ );
+
+ handleError(mutationResult);
+ return mutationResult;
+};
diff --git a/csm_web/frontend/src/utils/types.tsx b/csm_web/frontend/src/utils/types.tsx
index 78e9f2155..59825b758 100644
--- a/csm_web/frontend/src/utils/types.tsx
+++ b/csm_web/frontend/src/utils/types.tsx
@@ -36,6 +36,10 @@ export interface UserInfo {
lastName: string;
email: string;
priorityEnrollment?: DateTime;
+ bio: string;
+ pronouns: string;
+ pronunciation: string;
+ isEditable: boolean;
}
/**
diff --git a/csm_web/scheduler/migrations/0033_user_bio_user_pronouns_user_pronunciation.py b/csm_web/scheduler/migrations/0033_user_bio_user_pronouns_user_pronunciation.py
new file mode 100644
index 000000000..c3546eac9
--- /dev/null
+++ b/csm_web/scheduler/migrations/0033_user_bio_user_pronouns_user_pronunciation.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.2.7 on 2024-07-20 05:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("scheduler", "0032_word_of_the_day"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="bio",
+ field=models.CharField(default="", max_length=500),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="pronouns",
+ field=models.CharField(default="", max_length=20),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="pronunciation",
+ field=models.CharField(default="", max_length=50),
+ ),
+ ]
diff --git a/csm_web/scheduler/models.py b/csm_web/scheduler/models.py
index 9e367ab60..e7174803b 100644
--- a/csm_web/scheduler/models.py
+++ b/csm_web/scheduler/models.py
@@ -51,6 +51,13 @@ def week_bounds(date):
class User(AbstractUser):
priority_enrollment = models.DateTimeField(null=True, blank=True)
+ pronouns = models.CharField(max_length=20, default="", blank=True)
+ pronunciation = models.CharField(max_length=50, default="", blank=True)
+ # xTODO: configure to use the Django Settings bucket backend
+ # profile_pic = models.ImageField(upload_to='profiles/', null=True, blank=True)
+ # if profile picture is implemented
+ bio = models.CharField(max_length=500, default="", blank=True)
+
def can_enroll_in_course(self, course, bypass_enrollment_time=False):
"""Determine whether this user is allowed to enroll in the given course."""
# check restricted first
@@ -260,19 +267,15 @@ def save(self, *args, **kwargs):
):
if settings.DJANGO_ENV != settings.DEVELOPMENT:
logger.info(
- (
- " SO automatically created for student"
- " %s in course %s for date %s"
- ),
+ " SO automatically created for student"
+ " %s in course %s for date %s",
self.user.email,
course.name,
now.date(),
)
logger.info(
- (
- " Attendance automatically created for student"
- " %s in course %s for date %s"
- ),
+ " Attendance automatically created for student"
+ " %s in course %s for date %s",
self.user.email,
course.name,
now.date(),
diff --git a/csm_web/scheduler/serializers.py b/csm_web/scheduler/serializers.py
index cd31f7413..0ebdd045f 100644
--- a/csm_web/scheduler/serializers.py
+++ b/csm_web/scheduler/serializers.py
@@ -167,7 +167,16 @@ class Meta:
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
- fields = ("id", "email", "first_name", "last_name", "priority_enrollment")
+ fields = (
+ "id",
+ "email",
+ "first_name",
+ "last_name",
+ "priority_enrollment",
+ "bio",
+ "pronunciation",
+ "pronouns",
+ )
class ProfileSerializer(serializers.Serializer): # pylint: disable=abstract-method
diff --git a/csm_web/scheduler/tests/models/test_user.py b/csm_web/scheduler/tests/models/test_user.py
index f14141af0..5533988af 100644
--- a/csm_web/scheduler/tests/models/test_user.py
+++ b/csm_web/scheduler/tests/models/test_user.py
@@ -1,10 +1,23 @@
-import pytest
+import json
+import pytest
+from django.urls import reverse
+from scheduler.factories import (
+ CoordinatorFactory,
+ CourseFactory,
+ MentorFactory,
+ SectionFactory,
+ StudentFactory,
+ UserFactory,
+)
from scheduler.models import User
@pytest.mark.django_db
def test_create_user():
+ """
+ Test that a user can be created.
+ """
email = "test@berkeley.edu"
username = "test"
user, created = User.objects.get_or_create(email=email, username=username)
@@ -13,3 +26,197 @@ def test_create_user():
assert user.username == username
assert User.objects.count() == 1
assert User.objects.get(email=email).username == username
+
+
+# avoid pylint warning redefining name in outer scope
+@pytest.fixture(name="setup_permissions")
+def fixture_setup_permissions():
+ """
+ Setup users, courses, and sections for testing permissions
+ """
+ student_user = UserFactory(username="student_user")
+ other_student_user = UserFactory(username="other_student_user")
+ mentor_user = UserFactory(username="mentor_user")
+ other_mentor_user = UserFactory(username="other_mentor_user")
+ coordinator_user = UserFactory(username="coordinator_user")
+
+ # Create courses
+ course_a = CourseFactory(name="course_a")
+ course_b = CourseFactory(name="course_b")
+
+ # Assign mentors to courses
+ mentor_a = MentorFactory(user=mentor_user, course=course_a)
+ mentor_b = MentorFactory(user=other_mentor_user, course=course_b)
+ coordinator_a = CoordinatorFactory(user=coordinator_user, course=course_a)
+
+ # Create sections associated with the correct course via the mentor
+ section_a1 = SectionFactory(mentor=mentor_a)
+ section_b1 = SectionFactory(mentor=mentor_b)
+
+ # Ensure students are enrolled in sections that match their course
+ student_a1 = StudentFactory(user=student_user, section=section_a1, course=course_a)
+ other_student_a1 = StudentFactory(
+ user=other_student_user, section=section_a1, course=course_a
+ )
+
+ return {
+ "student_user": student_user,
+ "other_student_user": other_student_user,
+ "mentor_user": mentor_user,
+ "other_mentor_user": other_mentor_user,
+ "coordinator_user": coordinator_user,
+ "coordinator_a": coordinator_a,
+ "course_a": course_a,
+ "course_b": course_b,
+ "section_a1": section_a1,
+ "section_b1": section_b1,
+ "student_a1": student_a1,
+ "other_student_a1": other_student_a1,
+ }
+
+
+###############
+# Student tests
+###############
+
+
+@pytest.mark.django_db
+def test_student_view_own_profile(client, setup_permissions):
+ """
+ Test that a student can view their own profile.
+ """
+ student_user = setup_permissions["student_user"]
+ client.force_login(student_user)
+
+ response = client.get(reverse("user_retrieve", kwargs={"pk": student_user.pk}))
+ assert response.status_code == 200
+ assert response.data["email"] == student_user.email
+
+
+@pytest.mark.django_db
+def test_student_view_other_student_in_same_section(client, setup_permissions):
+ """
+ Test that a student can view another student in the same section.
+ """
+ student = setup_permissions["student_user"]
+ other_student = setup_permissions["other_student_user"]
+ client.force_login(student)
+ response = client.get(reverse("user_retrieve", kwargs={"pk": other_student.pk}))
+ assert response.status_code == 200
+ assert response.data["email"] == other_student.email
+
+
+@pytest.mark.django_db
+def test_student_view_mentors(client, setup_permissions):
+ """
+ Test that a student can view a mentor's profile.
+ """
+ student = setup_permissions["student_user"]
+ mentor = setup_permissions["mentor_user"]
+ client.force_login(student)
+ response = client.get(reverse("user_retrieve", kwargs={"pk": mentor.pk}))
+ assert response.status_code == 200
+ assert response.data["email"] == mentor.email
+
+
+@pytest.mark.django_db
+def test_student_edit_own_profile(client, setup_permissions):
+ """
+ Test that a student can edit their own profile.
+ """
+ student = setup_permissions["student_user"]
+ client.force_login(student)
+ edit_url = reverse("user_update", kwargs={"pk": student.pk})
+ response = client.put(
+ edit_url,
+ data=json.dumps({"first_name": "NewName"}),
+ content_type="application/json",
+ )
+ assert response.status_code == 200
+ student.refresh_from_db()
+ assert student.first_name == "NewName"
+
+
+##############
+# Mentor tests
+##############
+
+
+@pytest.mark.django_db
+def test_mentor_view_own_profile(client, setup_permissions):
+ """
+ Test that a mentor can view their own profile.
+ """
+ mentor_user = setup_permissions["mentor_user"]
+ client.force_login(mentor_user)
+
+ response = client.get(reverse("user_retrieve", kwargs={"pk": mentor_user.pk}))
+ assert response.status_code == 200
+ assert response.data["email"] == mentor_user.email
+
+
+@pytest.mark.django_db
+def test_mentor_view_students_in_course(client, setup_permissions):
+ """
+ Test that a mentor can view student profiles in the course they teach.
+ """
+ mentor_user = setup_permissions["mentor_user"]
+ student_user = setup_permissions["student_user"]
+ client.force_login(mentor_user)
+
+ response = client.get(reverse("user_retrieve", kwargs={"pk": student_user.pk}))
+ assert response.status_code == 200
+ assert response.data["email"] == student_user.email
+
+
+@pytest.mark.django_db
+def test_mentor_cannot_edit_other_profiles(client, setup_permissions):
+ """
+ Test that a mentor cannot edit another student's or mentor's profile.
+ """
+ mentor_user = setup_permissions["mentor_user"]
+ other_student_user = setup_permissions["other_student_user"]
+ client.force_login(mentor_user)
+ response = client.put(
+ reverse("user_update", kwargs={"pk": other_student_user.pk}),
+ data=json.dumps({"first_name": "new_username"}),
+ content_type="application/json",
+ )
+ assert response.status_code == 403
+
+
+###################
+# Coordinator tests
+###################
+
+
+@pytest.mark.django_db
+def test_coordinator_view_all_profiles_in_course(client, setup_permissions):
+ """
+ Test that a coordinator can view all profiles in the course they coordinate.
+ """
+ coordinator_user = setup_permissions["coordinator_user"]
+ student_user = setup_permissions["student_user"]
+ client.force_login(coordinator_user)
+
+ response = client.get(reverse("user_retrieve", kwargs={"pk": student_user.pk}))
+ assert response.status_code == 200
+
+
+@pytest.mark.django_db
+def test_coordinator_edit_all_profiles_in_course(client, setup_permissions):
+ """
+ Test that a coordinator can edit all profiles in the course they coordinate.
+ """
+ coordinator_user = setup_permissions["coordinator_user"]
+ student_user = setup_permissions["student_user"]
+ client.force_login(coordinator_user)
+
+ response = client.put(
+ reverse("user_update", kwargs={"pk": student_user.pk}),
+ data=json.dumps({"first_name": "new_student_name"}),
+ content_type="application/json",
+ )
+ assert response.status_code == 200
+ student_user.refresh_from_db()
+ assert student_user.first_name == "new_student_name"
diff --git a/csm_web/scheduler/urls.py b/csm_web/scheduler/urls.py
index 8c60cd334..370f4e48d 100644
--- a/csm_web/scheduler/urls.py
+++ b/csm_web/scheduler/urls.py
@@ -15,7 +15,9 @@
urlpatterns = router.urls
urlpatterns += [
- path("userinfo/", views.userinfo, name="userinfo"),
+ path("user/", views.user_info, name="user"),
+ path("user//", views.user_retrieve, name="user_retrieve"),
+ path("user//update/", views.user_update, name="user_update"),
path("matcher/active/", views.matcher.active),
path("matcher//slots/", views.matcher.slots),
path("matcher//preferences/", views.matcher.preferences),
diff --git a/csm_web/scheduler/views/__init__.py b/csm_web/scheduler/views/__init__.py
index 55ed65f31..e59b95948 100644
--- a/csm_web/scheduler/views/__init__.py
+++ b/csm_web/scheduler/views/__init__.py
@@ -6,4 +6,4 @@
from .section import SectionViewSet
from .spacetime import SpacetimeViewSet
from .student import StudentViewSet
-from .user import UserViewSet, userinfo
+from .user import UserViewSet, user_info, user_retrieve, user_update
diff --git a/csm_web/scheduler/views/user.py b/csm_web/scheduler/views/user.py
index aeef94408..395663045 100644
--- a/csm_web/scheduler/views/user.py
+++ b/csm_web/scheduler/views/user.py
@@ -1,11 +1,11 @@
-from rest_framework.exceptions import PermissionDenied
-from rest_framework.response import Response
from rest_framework import status
from rest_framework.decorators import api_view
+from rest_framework.exceptions import PermissionDenied
+from rest_framework.response import Response
+from scheduler.serializers import UserSerializer
+from ..models import Coordinator, Mentor, Student, User
from .utils import viewset_with
-from ..models import Coordinator, User
-from scheduler.serializers import UserSerializer
class UserViewSet(*viewset_with("list")):
@@ -13,6 +13,9 @@ class UserViewSet(*viewset_with("list")):
queryset = User.objects.all()
def list(self, request):
+ """
+ Lists the emails of all users in the system. Only accessible by coordinators and superusers.
+ """
if not (
request.user.is_superuser
or Coordinator.objects.filter(user=request.user).exists()
@@ -23,12 +26,116 @@ def list(self, request):
return Response(self.queryset.order_by("email").values_list("email", flat=True))
+def user_editable(user):
+ """
+ Returns True if the user is allowed to edit their profile
+ """
+ coordinator_courses = Coordinator.objects.filter(user=user).values_list(
+ "course", flat=True
+ )
+ if Coordinator.objects.filter(user=user, course__in=coordinator_courses).exists():
+ return True
+ return False
+
+
+def has_permission(request_user, target_user):
+ """
+ Returns True if the user has permission to access or edit the target user's profile
+ """
+
+ if request_user.is_superuser:
+ return True
+ if request_user == target_user:
+ return True
+
+ # if the target user is a mentor, return True
+ if Mentor.objects.filter(user=target_user).exists():
+ return True
+
+ # if requestor is a student, get all the sections they are in
+ # if the target user is a student in any of those sections, return True
+ if Student.objects.filter(user=request_user).exists():
+ if Student.objects.filter(user=target_user).exists():
+ request_user_sections = Student.objects.filter(
+ user=request_user
+ ).values_list("section", flat=True)
+ target_user_sections = Student.objects.filter(user=target_user).values_list(
+ "section", flat=True
+ )
+ if set(request_user_sections) & set(target_user_sections):
+ return True
+
+ # if requestor is a mentor, get all the courses they mentor
+ # if the target user is a student or mentor in any of those courses, return True
+ if Mentor.objects.filter(user=request_user).exists():
+ mentor_courses = Mentor.objects.filter(user=request_user).values_list(
+ "course", flat=True
+ )
+
+ if Student.objects.filter(user=target_user, course__in=mentor_courses).exists():
+ return True
+
+ # if requestor is a coordinator, get all the courses they coordinate
+ # if the target user is a student or mentor in any of those courses, return True
+ if Coordinator.objects.filter(user=request_user).exists():
+ coordinator_courses = Coordinator.objects.filter(user=request_user).values_list(
+ "course", flat=True
+ )
+ if Student.objects.filter(
+ user=target_user, course__in=coordinator_courses
+ ).exists():
+ return True
+ if Coordinator.objects.filter(
+ user=target_user, course__in=coordinator_courses
+ ).exists():
+ return True
+
+ return False
+
+
@api_view(["GET"])
-def userinfo(request):
+def user_retrieve(request, pk):
"""
- Get user info for request user
+ Retrieve user profile. Only accessible by superusers and the user themselves.
+ """
+ try:
+ user = User.objects.get(pk=pk)
+ except User.DoesNotExist:
+ return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
+
+ if not has_permission(request.user, user):
+ raise PermissionDenied("You do not have permission to access this profile")
+
+ serializer = UserSerializer(user)
+ return Response({**serializer.data, "isEditable": user_editable(request.user)})
+
- TODO: perhaps replace this with a viewset when we establish profiles
+@api_view(["PUT"])
+def user_update(request, pk):
+ """
+ Update user profile. Only accessible by Coordinators and the user themselves.
+ """
+ try:
+ user = User.objects.get(pk=pk)
+ except User.DoesNotExist:
+ return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
+
+ if request.user == user:
+ pass
+ elif not user_editable(request.user):
+ raise PermissionDenied("You do not have permission to edit this profile")
+
+ serializer = UserSerializer(user, data=request.data, partial=True)
+ if serializer.is_valid():
+ serializer.save()
+ return Response(serializer.data, status=status.HTTP_200_OK)
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+
+@api_view(["GET"])
+def user_info(request):
+ """
+ Get user info for request user
"""
serializer = UserSerializer(request.user)
return Response(serializer.data, status=status.HTTP_200_OK)
diff --git a/cypress/e2e/course/restricted-courses.cy.ts b/cypress/e2e/course/restricted-courses.cy.ts
index b4dad30e1..6ad2bf7b4 100644
--- a/cypress/e2e/course/restricted-courses.cy.ts
+++ b/cypress/e2e/course/restricted-courses.cy.ts
@@ -72,7 +72,7 @@ describe("whitelisted courses", () => {
cy.login();
cy.intercept("/api/courses").as("get-courses");
- cy.intercept("/api/userinfo").as("get-userinfo");
+ cy.intercept("/api/user").as("get-user");
cy.visit("/");
cy.wait("@get-courses");
@@ -82,7 +82,7 @@ describe("whitelisted courses", () => {
// view courses
cy.contains(".primary-btn", /add course/i).click();
- cy.wait("@get-userinfo");
+ cy.wait("@get-user");
cy.contains(".page-title", /which course/i).should("be.visible");
// should have two buttons at the top
@@ -126,7 +126,7 @@ describe("whitelisted courses", () => {
cy.login();
cy.intercept("/api/courses").as("get-courses");
- cy.intercept("/api/userinfo").as("get-userinfo");
+ cy.intercept("/api/user").as("get-user");
cy.visit("/");
cy.wait("@get-courses");
@@ -136,7 +136,7 @@ describe("whitelisted courses", () => {
// view courses
cy.contains(".primary-btn", /add course/i).click();
- cy.wait("@get-userinfo");
+ cy.wait("@get-user");
cy.contains(".page-title", /which course/i).should("be.visible");
// should have two buttons at the top
diff --git a/docker-compose.yml b/docker-compose.yml
index 9bcf67a8b..eb0966c81 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -52,6 +52,10 @@ services:
source: ./
target: /opt/csm_web
read_only: true
+ # output from migrations
+ - type: bind
+ source: ./csm_web/scheduler/migrations/
+ target: /opt/csm_web/csm_web/scheduler/migrations/
depends_on:
postgres:
condition: service_healthy