diff --git a/csm_web/csm_web/settings.py b/csm_web/csm_web/settings.py
index 171c08b7..ff45f4b3 100644
--- a/csm_web/csm_web/settings.py
+++ b/csm_web/csm_web/settings.py
@@ -16,6 +16,7 @@
from factory.django import DjangoModelFactory
from rest_framework.serializers import ModelSerializer, Serializer
from sentry_sdk.integrations.django import DjangoIntegration
+from storages.backends.s3boto3 import S3Boto3Storage
# Analogous to RAILS_ENV, is one of {prod, staging, dev}. Defaults to dev. This default can
# be dangerous, but is worth it to avoid the hassle for developers setting the local ENV var
@@ -67,6 +68,7 @@
"frontend",
"django_extensions",
"django.contrib.postgres",
+ "storages",
]
SHELL_PLUS_SUBCLASSES_IMPORT = [ModelSerializer, Serializer, DjangoModelFactory]
@@ -175,11 +177,30 @@
AWS_QUERYSTRING_AUTH = False # public bucket
DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
+
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.1/howto/static-files/
STATIC_URL = "/static/"
+
+class ProfileImageStorage(S3Boto3Storage):
+ bucket_name = "csm-web-profile-pictures"
+ file_overwrite = True # should be true so that we replace one profile for user
+
+ def get_accessed_time(self, name):
+ # Implement logic to get the last accessed time
+ raise NotImplementedError("This backend does not support this method.")
+
+ def get_created_time(self, name):
+ # Implement logic to get the creation time
+ raise NotImplementedError("This backend does not support this method.")
+
+ def path(self, name):
+ # S3 does not support file paths
+ raise NotImplementedError("This backend does not support absolute paths.")
+
+
if DJANGO_ENV in (PRODUCTION, STAGING):
# Enables compression and caching
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
@@ -231,6 +252,8 @@
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
"DEFAULT_PARSER_CLASSES": [
"djangorestframework_camel_case.parser.CamelCaseJSONParser",
+ "rest_framework.parsers.FormParser",
+ "rest_framework.parsers.MultiPartParser",
],
}
diff --git a/csm_web/frontend/src/components/App.tsx b/csm_web/frontend/src/components/App.tsx
index 841364f2..66ca218c 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 a4d67e90..3796a469 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/ImageUploader.tsx b/csm_web/frontend/src/components/ImageUploader.tsx
new file mode 100644
index 00000000..828837d9
--- /dev/null
+++ b/csm_web/frontend/src/components/ImageUploader.tsx
@@ -0,0 +1,67 @@
+import React, { useState, useEffect } from "react";
+import { fetchWithMethod, HTTP_METHODS } from "../utils/api";
+
+// file size limits
+const MAX_SIZE_MB = 2;
+const MAX_FILE_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024;
+
+const ImageUploader = () => {
+ const [file, setFile] = useState(null);
+ const [status, setStatus] = useState("");
+
+ // useEffect(() => {
+ // if (file) {
+ // }
+ // }, [file]);
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ if (e.target.files && e.target.files[0]) {
+ const selectedFile = e.target.files[0];
+
+ if (selectedFile) {
+ if (selectedFile.size > MAX_FILE_SIZE_BYTES) {
+ setStatus(`File size exceeds max limit of ${MAX_SIZE_MB}MB.`);
+ } else {
+ setFile(selectedFile);
+ setStatus("");
+ }
+ }
+ }
+ };
+
+ const handleUpload = async () => {
+ try {
+ if (!file) {
+ setStatus("Please select a file to upload");
+ return;
+ }
+ const formData = new FormData();
+
+ formData.append("file", file);
+
+ const response = await fetchWithMethod(`user/upload_image/`, HTTP_METHODS.POST, formData, true);
+
+ console.log(response);
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ console.error("Error:", errorData.error || "Unknown error");
+ throw new Error(errorData.error || "Failed to upload file");
+ }
+ setStatus(`File uploaded successfully`);
+ } catch (error) {
+ setStatus(`Upload failed: ${(error as Error).message}`);
+ }
+ };
+
+ return (
+
+
Image Upload Tester
+
+
Upload
+ {status &&
{status}
}
+
+ );
+};
+
+export default ImageUploader;
diff --git a/csm_web/frontend/src/components/UserProfile.tsx b/csm_web/frontend/src/components/UserProfile.tsx
new file mode 100644
index 00000000..9b898779
--- /dev/null
+++ b/csm_web/frontend/src/components/UserProfile.tsx
@@ -0,0 +1,253 @@
+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 ImageUploader from "./ImageUploader";
+import LoadingSpinner from "./LoadingSpinner";
+
+import "../css/base/form.scss";
+import "../css/base/table.scss";
+
+interface UserInfo {
+ firstName: string;
+ lastName: string;
+ bio: string;
+ pronouns: string;
+ pronunciation: string;
+ profileImage: string;
+}
+
+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: "",
+ profileImage: ""
+ });
+
+ const [showSaveSpinner, setShowSaveSpinner] = useState(false);
+ const [validationText, setValidationText] = useState("");
+
+ // Populate form data with fetched user data
+ useEffect(() => {
+ if (requestedData) {
+ console.log(requestedData);
+ setFormData({
+ firstName: requestedData.firstName || "",
+ lastName: requestedData.lastName || "",
+ bio: requestedData.bio || "",
+ pronouns: requestedData.pronouns || "",
+ pronunciation: requestedData.pronunciation || "",
+ profileImage: requestedData.profileImage || ""
+ });
+ }
+ }, [requestedData]);
+
+ if (requestedIsLoading || currUserIsLoading) {
+ return ;
+ }
+
+ if (requestedError || isCurrUserError) {
+ if (requestedError instanceof PermissionError) {
+ return Permission Denied ;
+ } else {
+ return Failed to fetch user data ;
+ }
+ }
+
+ if (id === undefined && requestedData) {
+ userId = requestedData.id;
+ }
+
+ // Handle input changes
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ console.log("Changes");
+ 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 6e7632f3..052e66d3 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 e7dfc7c2..b28c2dae 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 ff2f5a5a..e3ce358b 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 769d7919..4d725134 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 9bb70ac2..434a9a5a 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 a9dd33ce..5f9dfa32 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 00000000..ac95050e
--- /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 78e9f215..e341f7a9 100644
--- a/csm_web/frontend/src/utils/types.tsx
+++ b/csm_web/frontend/src/utils/types.tsx
@@ -36,6 +36,11 @@ export interface UserInfo {
lastName: string;
email: string;
priorityEnrollment?: DateTime;
+ bio: string;
+ pronouns: string;
+ pronunciation: string;
+ isEditable: boolean;
+ profileImage?: string;
}
/**
diff --git a/csm_web/scheduler/admin.py b/csm_web/scheduler/admin.py
index 4a64342f..13418969 100644
--- a/csm_web/scheduler/admin.py
+++ b/csm_web/scheduler/admin.py
@@ -204,6 +204,8 @@ class UserAdmin(BasePermissionModelAdmin):
"first_name",
"last_name",
"priority_enrollment",
+ "bio",
+ "profile_image",
)
list_display = (
@@ -211,6 +213,8 @@ class UserAdmin(BasePermissionModelAdmin):
"name",
"email",
"priority_enrollment",
+ "bio",
+ "profile_image",
)
list_display_links = ("name",)
list_filter = ("is_active",)
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 00000000..c3546eac
--- /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/migrations/0034_user_profile_image_alter_user_bio_and_more.py b/csm_web/scheduler/migrations/0034_user_profile_image_alter_user_bio_and_more.py
new file mode 100644
index 00000000..b289d566
--- /dev/null
+++ b/csm_web/scheduler/migrations/0034_user_profile_image_alter_user_bio_and_more.py
@@ -0,0 +1,36 @@
+# Generated by Django 4.2.7 on 2024-10-16 21:42
+
+from django.db import migrations, models
+
+import csm_web.settings
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("scheduler", "0033_user_bio_user_pronouns_user_pronunciation"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="profile_image",
+ field=models.ImageField(
+ blank=True, storage=csm_web.settings.ProfileImageStorage(), upload_to=""
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="bio",
+ field=models.CharField(blank=True, default="", max_length=500),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="pronouns",
+ field=models.CharField(blank=True, default="", max_length=20),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="pronunciation",
+ field=models.CharField(blank=True, default="", max_length=50),
+ ),
+ ]
diff --git a/csm_web/scheduler/migrations/0035_alter_user_profile_image.py b/csm_web/scheduler/migrations/0035_alter_user_profile_image.py
new file mode 100644
index 00000000..f18f8255
--- /dev/null
+++ b/csm_web/scheduler/migrations/0035_alter_user_profile_image.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.2.7 on 2024-11-21 00:21
+
+import scheduler.models
+import scheduler.storage
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("scheduler", "0034_user_profile_image_alter_user_bio_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="profile_image",
+ field=models.ImageField(
+ blank=True,
+ storage=scheduler.storage.ProfileImageStorage(),
+ upload_to=scheduler.models.image_path,
+ ),
+ ),
+ ]
diff --git a/csm_web/scheduler/models.py b/csm_web/scheduler/models.py
index 9e367ab6..b7990e1f 100644
--- a/csm_web/scheduler/models.py
+++ b/csm_web/scheduler/models.py
@@ -11,6 +11,8 @@
from django.utils import functional, timezone
from rest_framework.serializers import ValidationError
+from .storage import ProfileImageStorage
+
logger = logging.getLogger(__name__)
logger.info = logger.warning
@@ -48,9 +50,24 @@ def week_bounds(date):
return week_start, week_end
+def image_path(instance, filename):
+ """Compute the full path for a profile image."""
+ # file will be uploaded to images//
+ extension = filename.rsplit(".", 1)[-1]
+ return f"images/{instance.id}.{extension}"
+
+
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)
+ # uploaded_at = models.DateTimeField(auto_now_add=True)
+ profile_image = models.ImageField(
+ storage=ProfileImageStorage(), upload_to=image_path, blank=True
+ )
+ 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 +277,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(),
@@ -313,7 +326,7 @@ class Mentor(Profile):
class Coordinator(Profile):
"""
- This profile is used to allow coordinators to acess the admin page.
+ This profile is used to allow coordinators to access the admin page.
"""
def save(self, *args, **kwargs):
diff --git a/csm_web/scheduler/serializers.py b/csm_web/scheduler/serializers.py
index cd31f741..dd134448 100644
--- a/csm_web/scheduler/serializers.py
+++ b/csm_web/scheduler/serializers.py
@@ -167,7 +167,17 @@ 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",
+ "profile_image",
+ )
class ProfileSerializer(serializers.Serializer): # pylint: disable=abstract-method
diff --git a/csm_web/scheduler/storage.py b/csm_web/scheduler/storage.py
new file mode 100644
index 00000000..8825144a
--- /dev/null
+++ b/csm_web/scheduler/storage.py
@@ -0,0 +1,18 @@
+from storages.backends.s3boto3 import S3Boto3Storage
+
+
+class ProfileImageStorage(S3Boto3Storage):
+ bucket_name = "csm-web-profile-pictures"
+ file_overwrite = True # should be true so that we replace one profile for user
+
+ def get_accessed_time(self, name):
+ # Implement logic to get the last accessed time
+ raise NotImplementedError("This backend does not support this method.")
+
+ def get_created_time(self, name):
+ # Implement logic to get the creation time
+ raise NotImplementedError("This backend does not support this method.")
+
+ def path(self, name):
+ # S3 does not support file paths
+ raise NotImplementedError("This backend does not support absolute paths.")
diff --git a/csm_web/scheduler/tests/models/test_user.py b/csm_web/scheduler/tests/models/test_user.py
index f14141af..5533988a 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 8c60cd33..b86df22d 100644
--- a/csm_web/scheduler/urls.py
+++ b/csm_web/scheduler/urls.py
@@ -15,7 +15,10 @@
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("user/upload_image/", views.upload_image, name="upload_image"),
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 55ed65f3..050b340a 100644
--- a/csm_web/scheduler/views/__init__.py
+++ b/csm_web/scheduler/views/__init__.py
@@ -6,4 +6,6 @@
from .section import SectionViewSet
from .spacetime import SpacetimeViewSet
from .student import StudentViewSet
-from .user import UserViewSet, userinfo
+
+# from .test import upload_image
+from .user import UserViewSet, upload_image, user_info, user_retrieve, user_update
diff --git a/csm_web/scheduler/views/user.py b/csm_web/scheduler/views/user.py
index aeef9440..ea232449 100644
--- a/csm_web/scheduler/views/user.py
+++ b/csm_web/scheduler/views/user.py
@@ -1,11 +1,17 @@
+import os
+from io import BytesIO
+
+from django.core.files.base import ContentFile
+from PIL import Image, UnidentifiedImageError
+from rest_framework import status
+from rest_framework.decorators import api_view, parser_classes
from rest_framework.exceptions import PermissionDenied
+from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
-from rest_framework import status
-from rest_framework.decorators import api_view
+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 +19,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 +32,239 @@ 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")
- TODO: perhaps replace this with a viewset when we establish profiles
+ serializer = UserSerializer(user)
+ return Response({**serializer.data, "isEditable": user_editable(request.user)})
+
+
+@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)
+
+
+@api_view(["POST"])
+@parser_classes([MultiPartParser, FormParser])
+def upload_image(request):
+ """
+ Uploads an image to the aws s3 bucket
+ """
+
+ if "file" not in request.FILES:
+ return Response(
+ {"error": "No file was uploaded"}, status=status.HTTP_400_BAD_REQUEST
+ )
+
+ image_file = request.FILES["file"]
+ ALLOWED_TYPES = ["JPEG", "PNG", "JPG"]
+
+ MAX_FILE_SIZE_MB = 5 # file limit
+ MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
+ TARGET_FILE_SIZE_MB = 2
+ TARGET_FILE_SIZE_BYTES = TARGET_FILE_SIZE_MB * 1024 * 1024
+ PROFILE_IMAGE_SIZE = (400, 400) # Fixed width and height for profile images
+
+ # Check file size
+ image_file.seek(0, os.SEEK_END)
+ file_size = image_file.tell()
+ # reset file pointer
+ image_file.seek(0)
+
+ if file_size > MAX_FILE_SIZE_BYTES:
+ return Response(
+ {"error": "Image size exceeds maximum allowed size of 5MB."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ try:
+ # Validate extension before opening
+ extension = image_file.name.rsplit(".", 1)[-1].upper()
+ if extension not in ALLOWED_TYPES:
+ return Response(
+ {
+ "error": (
+ f"Invalid image type: {extension}. Allowed types are:"
+ f" {ALLOWED_TYPES}."
+ )
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Open and validate the image
+ image = Image.open(image_file)
+ image.verify() # Check for corrupt files
+ image = Image.open(image_file) # reopen since verify closes
+
+ # Validate type
+ validate_image_type(image, ALLOWED_TYPES)
+
+ img_format = image.format
+ if not img_format:
+ return Response(
+ {"error": "Uploaded file is not a valid image."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # resize to less than specified width and height while preserving ratio
+ # different from image = image.size(PROFILE_IMAGE_SIZE, Image.LANCZOS)
+ image.thumbnail(PROFILE_IMAGE_SIZE, Image.LANCZOS)
+
+ if file_size > TARGET_FILE_SIZE_BYTES:
+ file = compress_image(image, TARGET_FILE_SIZE_BYTES, img_format)
+ else:
+ buffer = BytesIO()
+ if img_format == "JPEG" or img_format == "JPG":
+ image.save(buffer, format=img_format)
+ else:
+ image.save(buffer, format="PNG", optimze=True)
+ buffer.seek(0)
+ file = ContentFile(buffer.read(), name=image_file.name)
+
+ # Save the image to the user's profile
+ user = request.user
+ user.profile_image.save(file.name, file)
+
+ return Response(
+ {"message": "File uploaded successfully"}, status=status.HTTP_200_OK
+ )
+
+ except UnidentifiedImageError:
+ return Response(
+ {"error": "Uploaded file is not a valid image."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ except ValueError as e:
+ return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
+
+
+# Validation Helper Functions
+def validate_image_type(image, allowed_types):
+ """
+ Validates the image type
+ """
+ if image.format not in allowed_types:
+ raise ValueError(
+ f"Invalid image type: {image.format}. Allowed types are: {allowed_types}."
+ )
+
+
+# not very efficient but way to compress image until it meets the file target size.
+def compress_image(image, target_size_bytes, img_format):
+ """Compress the image until it's smaller than target_size_bytes."""
+ buffer = BytesIO()
+ quality = 95 # start with high quality
+ while True:
+ buffer.seek(0)
+ if img_format == "JPEG" or img_format == "JPG":
+ image.save(buffer, format=img_format, quality=quality)
+ else:
+ image.save(buffer, format="PNG", optimze=True)
+ if buffer.tell() <= target_size_bytes or quality <= 50:
+ break
+ quality -= 5 # decrease quality to reduce file size
+ buffer.seek(0)
+ return buffer
diff --git a/cypress/e2e/course/restricted-courses.cy.ts b/cypress/e2e/course/restricted-courses.cy.ts
index b4dad30e..6ad2bf7b 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 9bcf67a8..eb0966c8 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
diff --git a/package-lock.json b/package-lock.json
index 98e79e1e..c0be0485 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11345,7 +11345,8 @@
"node_modules/js-cookie": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
- "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ=="
+ "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==",
+ "license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
diff --git a/poetry.lock b/poetry.lock
index d7bc3703..877b021e 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
[[package]]
name = "appnope"
@@ -1012,6 +1012,98 @@ files = [
[package.dependencies]
ptyprocess = ">=0.5"
+[[package]]
+name = "pillow"
+version = "11.0.0"
+description = "Python Imaging Library (Fork)"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "pillow-11.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6619654954dc4936fcff82db8eb6401d3159ec6be81e33c6000dfd76ae189947"},
+ {file = "pillow-11.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3c5ac4bed7519088103d9450a1107f76308ecf91d6dabc8a33a2fcfb18d0fba"},
+ {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a65149d8ada1055029fcb665452b2814fe7d7082fcb0c5bed6db851cb69b2086"},
+ {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88a58d8ac0cc0e7f3a014509f0455248a76629ca9b604eca7dc5927cc593c5e9"},
+ {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c26845094b1af3c91852745ae78e3ea47abf3dbcd1cf962f16b9a5fbe3ee8488"},
+ {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1a61b54f87ab5786b8479f81c4b11f4d61702830354520837f8cc791ebba0f5f"},
+ {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:674629ff60030d144b7bca2b8330225a9b11c482ed408813924619c6f302fdbb"},
+ {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:598b4e238f13276e0008299bd2482003f48158e2b11826862b1eb2ad7c768b97"},
+ {file = "pillow-11.0.0-cp310-cp310-win32.whl", hash = "sha256:9a0f748eaa434a41fccf8e1ee7a3eed68af1b690e75328fd7a60af123c193b50"},
+ {file = "pillow-11.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5629742881bcbc1f42e840af185fd4d83a5edeb96475a575f4da50d6ede337c"},
+ {file = "pillow-11.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:ee217c198f2e41f184f3869f3e485557296d505b5195c513b2bfe0062dc537f1"},
+ {file = "pillow-11.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1c1d72714f429a521d8d2d018badc42414c3077eb187a59579f28e4270b4b0fc"},
+ {file = "pillow-11.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:499c3a1b0d6fc8213519e193796eb1a86a1be4b1877d678b30f83fd979811d1a"},
+ {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8b2351c85d855293a299038e1f89db92a2f35e8d2f783489c6f0b2b5f3fe8a3"},
+ {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4dba50cfa56f910241eb7f883c20f1e7b1d8f7d91c750cd0b318bad443f4d5"},
+ {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5ddbfd761ee00c12ee1be86c9c0683ecf5bb14c9772ddbd782085779a63dd55b"},
+ {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:45c566eb10b8967d71bf1ab8e4a525e5a93519e29ea071459ce517f6b903d7fa"},
+ {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b4fd7bd29610a83a8c9b564d457cf5bd92b4e11e79a4ee4716a63c959699b306"},
+ {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb929ca942d0ec4fac404cbf520ee6cac37bf35be479b970c4ffadf2b6a1cad9"},
+ {file = "pillow-11.0.0-cp311-cp311-win32.whl", hash = "sha256:006bcdd307cc47ba43e924099a038cbf9591062e6c50e570819743f5607404f5"},
+ {file = "pillow-11.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:52a2d8323a465f84faaba5236567d212c3668f2ab53e1c74c15583cf507a0291"},
+ {file = "pillow-11.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:16095692a253047fe3ec028e951fa4221a1f3ed3d80c397e83541a3037ff67c9"},
+ {file = "pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923"},
+ {file = "pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903"},
+ {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4"},
+ {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f"},
+ {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9"},
+ {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7"},
+ {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6"},
+ {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc"},
+ {file = "pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6"},
+ {file = "pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47"},
+ {file = "pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25"},
+ {file = "pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699"},
+ {file = "pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38"},
+ {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2"},
+ {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2"},
+ {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527"},
+ {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa"},
+ {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f"},
+ {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb"},
+ {file = "pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798"},
+ {file = "pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de"},
+ {file = "pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84"},
+ {file = "pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b"},
+ {file = "pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003"},
+ {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2"},
+ {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a"},
+ {file = "pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8"},
+ {file = "pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8"},
+ {file = "pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904"},
+ {file = "pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3"},
+ {file = "pillow-11.0.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2e46773dc9f35a1dd28bd6981332fd7f27bec001a918a72a79b4133cf5291dba"},
+ {file = "pillow-11.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2679d2258b7f1192b378e2893a8a0a0ca472234d4c2c0e6bdd3380e8dfa21b6a"},
+ {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda2616eb2313cbb3eebbe51f19362eb434b18e3bb599466a1ffa76a033fb916"},
+ {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ec184af98a121fb2da42642dea8a29ec80fc3efbaefb86d8fdd2606619045d"},
+ {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:8594f42df584e5b4bb9281799698403f7af489fba84c34d53d1c4bfb71b7c4e7"},
+ {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:c12b5ae868897c7338519c03049a806af85b9b8c237b7d675b8c5e089e4a618e"},
+ {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:70fbbdacd1d271b77b7721fe3cdd2d537bbbd75d29e6300c672ec6bb38d9672f"},
+ {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5178952973e588b3f1360868847334e9e3bf49d19e169bbbdfaf8398002419ae"},
+ {file = "pillow-11.0.0-cp39-cp39-win32.whl", hash = "sha256:8c676b587da5673d3c75bd67dd2a8cdfeb282ca38a30f37950511766b26858c4"},
+ {file = "pillow-11.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:94f3e1780abb45062287b4614a5bc0874519c86a777d4a7ad34978e86428b8dd"},
+ {file = "pillow-11.0.0-cp39-cp39-win_arm64.whl", hash = "sha256:290f2cc809f9da7d6d622550bbf4c1e57518212da51b6a30fe8e0a270a5b78bd"},
+ {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1187739620f2b365de756ce086fdb3604573337cc28a0d3ac4a01ab6b2d2a6d2"},
+ {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbbcb7b57dc9c794843e3d1258c0fbf0f48656d46ffe9e09b63bbd6e8cd5d0a2"},
+ {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d203af30149ae339ad1b4f710d9844ed8796e97fda23ffbc4cc472968a47d0b"},
+ {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a0d3b115009ebb8ac3d2ebec5c2982cc693da935f4ab7bb5c8ebe2f47d36f2"},
+ {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73853108f56df97baf2bb8b522f3578221e56f646ba345a372c78326710d3830"},
+ {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e58876c91f97b0952eb766123bfef372792ab3f4e3e1f1a2267834c2ab131734"},
+ {file = "pillow-11.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:224aaa38177597bb179f3ec87eeefcce8e4f85e608025e9cfac60de237ba6316"},
+ {file = "pillow-11.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5bd2d3bdb846d757055910f0a59792d33b555800813c3b39ada1829c372ccb06"},
+ {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375b8dd15a1f5d2feafff536d47e22f69625c1aa92f12b339ec0b2ca40263273"},
+ {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:daffdf51ee5db69a82dd127eabecce20729e21f7a3680cf7cbb23f0829189790"},
+ {file = "pillow-11.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7326a1787e3c7b0429659e0a944725e1b03eeaa10edd945a86dead1913383944"},
+ {file = "pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739"},
+]
+
+[package.extras]
+docs = ["furo", "olefile", "sphinx (>=8.1)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
+fpx = ["olefile"]
+mic = ["olefile"]
+tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
+typing = ["typing-extensions"]
+xmp = ["defusedxml"]
+
[[package]]
name = "platformdirs"
version = "4.0.0"
@@ -1411,6 +1503,7 @@ files = [
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
+ {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
@@ -1851,4 +1944,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "~3.9.13"
-content-hash = "7c8523f4cee51165a93c830925d0021e91a4a7e03ff01e269c06c20e1a6e4139"
+content-hash = "8192b153de85df7679e862d5c8cbcdb73df3781f6f55dd7aae01c0d6cbb2f62b"
diff --git a/pyproject.toml b/pyproject.toml
index 312f18fa..8dbfc899 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,6 +26,7 @@ drf-nested-forms = "^1.1.7"
factory-boy = "^3.2.1"
# misc
networkx = "^2.8.8"
+pillow = "^11.0.0"
# packages for testing
pytest = "^7.2.0"
diff --git a/requirements.txt b/requirements.txt
index ef14dd08..0be33696 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -37,6 +37,7 @@ oauthlib==3.2.2 ; python_full_version >= "3.9.13" and python_full_version < "3.1
packaging==23.2 ; python_full_version >= "3.9.13" and python_full_version < "3.10.0"
parso==0.8.3 ; python_full_version >= "3.9.13" and python_full_version < "3.10.0"
pexpect==4.8.0 ; python_full_version >= "3.9.13" and python_full_version < "3.10.0" and sys_platform != "win32"
+pillow==11.0.0 ; python_full_version >= "3.9.13" and python_full_version < "3.10.0"
pluggy==1.3.0 ; python_full_version >= "3.9.13" and python_full_version < "3.10.0"
prompt-toolkit==3.0.41 ; python_full_version >= "3.9.13" and python_full_version < "3.10.0"
psycopg2-binary==2.9.9 ; python_full_version >= "3.9.13" and python_full_version < "3.10.0"
diff --git a/runtime.txt b/runtime.txt
index c6f7782f..1f79d441 100644
--- a/runtime.txt
+++ b/runtime.txt
@@ -1 +1 @@
-python-3.9.13
+python-3.11.7