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

+ + + {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 ( +
+

User Profile

+
+
+ + {isEditing ? ( + + ) : ( + //

{formData.profileImage}

+ + )} +
+
+ + {isEditing ? ( + + ) : ( +

{formData.firstName}

+ )} +
+
+ + {isEditing ? ( + + ) : ( +

{formData.lastName}

+ )} +
+
+ + {isEditing ? ( + + ) : ( +

{formData.pronunciation}

+ )} +
+
+ + {isEditing ? ( + + ) : ( +

{formData.pronouns}

+ )} +
+
+ +

{requestedData?.email}

+
+
+ + {isEditing ? ( +