diff --git a/backend/siarnaq/api/user/serializers.py b/backend/siarnaq/api/user/serializers.py index ffe0ab7a6..b1f9b4c2f 100644 --- a/backend/siarnaq/api/user/serializers.py +++ b/backend/siarnaq/api/user/serializers.py @@ -34,7 +34,7 @@ def update(self, *args, **kwargs): class UserProfilePrivateSerializer(UserProfilePublicSerializer): # Couuntry field requires special serialization. # See https://github.com/SmileyChris/django-countries#django-rest-framework - country = CountryField(name_only=True) + country = CountryField() class Meta: model = UserProfile diff --git a/backend/siarnaq/api/user/views.py b/backend/siarnaq/api/user/views.py index fe3d2f8f0..b1a8f39cb 100644 --- a/backend/siarnaq/api/user/views.py +++ b/backend/siarnaq/api/user/views.py @@ -96,6 +96,16 @@ def teams(self, request, pk=None): teams_dict = {team["episode"]: team for team in serializer.data} return Response(teams_dict) + @extend_schema( + request={ + "multipart/form-data": { + "type": "object", + "properties": { + "resume": {"type": "string", "format": "binary"}, + }, + } + }, + ) @action( detail=False, methods=["get", "put"], @@ -144,7 +154,17 @@ def resume(self, request): case _: raise RuntimeError(f"Fallthrough! Was {request.method} implemented?") - @extend_schema(responses={status.HTTP_204_NO_CONTENT: None}) + @extend_schema( + responses={status.HTTP_204_NO_CONTENT: None}, + request={ + "multipart/form-data": { + "type": "object", + "properties": { + "avatar": {"type": "string", "format": "binary"}, + }, + } + }, + ) @action( detail=False, methods=["post"], diff --git a/frontend2/schema.yml b/frontend2/schema.yml index 1dd69a7d2..a3dbb37fc 100644 --- a/frontend2/schema.yml +++ b/frontend2/schema.yml @@ -1678,16 +1678,13 @@ paths: - user requestBody: content: - application/json: - schema: - $ref: '#/components/schemas/UserAvatarRequest' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/UserAvatarRequest' multipart/form-data: schema: - $ref: '#/components/schemas/UserAvatarRequest' - required: true + type: object + properties: + avatar: + type: string + format: binary security: - jwtAuth: [] responses: @@ -1781,16 +1778,13 @@ paths: - user requestBody: content: - application/json: - schema: - $ref: '#/components/schemas/UserResumeRequest' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/UserResumeRequest' multipart/form-data: schema: - $ref: '#/components/schemas/UserResumeRequest' - required: true + type: object + properties: + resume: + type: string + format: binary security: - jwtAuth: [] responses: @@ -3165,15 +3159,6 @@ components: - tournament - user - username - UserAvatarRequest: - type: object - properties: - avatar: - type: string - format: binary - writeOnly: true - required: - - avatar UserCreate: type: object properties: @@ -3462,15 +3447,6 @@ components: - ready - reason - url - UserResumeRequest: - type: object - properties: - resume: - type: string - format: binary - writeOnly: true - required: - - resume securitySchemes: jwtAuth: type: http diff --git a/frontend2/src/api/_autogen/.openapi-generator/FILES b/frontend2/src/api/_autogen/.openapi-generator/FILES index 45a18ccf0..4c07de7b8 100644 --- a/frontend2/src/api/_autogen/.openapi-generator/FILES +++ b/frontend2/src/api/_autogen/.openapi-generator/FILES @@ -66,7 +66,6 @@ models/TokenVerifyRequest.ts models/Tournament.ts models/TournamentRound.ts models/TournamentSubmission.ts -models/UserAvatarRequest.ts models/UserCreate.ts models/UserCreateRequest.ts models/UserPassed.ts @@ -79,6 +78,5 @@ models/UserProfilePublicRequest.ts models/UserPublic.ts models/UserPublicRequest.ts models/UserResume.ts -models/UserResumeRequest.ts models/index.ts runtime.ts diff --git a/frontend2/src/api/_autogen/apis/UserApi.ts b/frontend2/src/api/_autogen/apis/UserApi.ts index 2f4dd8e2a..7ac94c3e0 100644 --- a/frontend2/src/api/_autogen/apis/UserApi.ts +++ b/frontend2/src/api/_autogen/apis/UserApi.ts @@ -23,14 +23,12 @@ import type { ResetToken, ResetTokenRequest, TeamPublic, - UserAvatarRequest, UserCreate, UserCreateRequest, UserPrivate, UserPrivateRequest, UserPublic, UserResume, - UserResumeRequest, } from '../models'; import { EmailFromJSON, @@ -49,8 +47,6 @@ import { ResetTokenRequestToJSON, TeamPublicFromJSON, TeamPublicToJSON, - UserAvatarRequestFromJSON, - UserAvatarRequestToJSON, UserCreateFromJSON, UserCreateToJSON, UserCreateRequestFromJSON, @@ -63,8 +59,6 @@ import { UserPublicToJSON, UserResumeFromJSON, UserResumeToJSON, - UserResumeRequestFromJSON, - UserResumeRequestToJSON, } from '../models'; export interface UserPasswordResetConfirmCreateRequest { @@ -80,7 +74,7 @@ export interface UserPasswordResetValidateTokenCreateRequest { } export interface UserUAvatarCreateRequest { - userAvatarRequest: UserAvatarRequest; + avatar?: Blob; } export interface UserUCreateRequest { @@ -96,7 +90,7 @@ export interface UserUMeUpdateRequest { } export interface UserUResumeUpdateRequest { - userResumeRequest: UserResumeRequest; + resume?: Blob; } export interface UserURetrieveRequest { @@ -215,16 +209,10 @@ export class UserApi extends runtime.BaseAPI { * Update uploaded avatar. */ async userUAvatarCreateRaw(requestParameters: UserUAvatarCreateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { - if (requestParameters.userAvatarRequest === null || requestParameters.userAvatarRequest === undefined) { - throw new runtime.RequiredError('userAvatarRequest','Required parameter requestParameters.userAvatarRequest was null or undefined when calling userUAvatarCreate.'); - } - const queryParameters: any = {}; const headerParameters: runtime.HTTPHeaders = {}; - headerParameters['Content-Type'] = 'application/json'; - if (this.configuration && this.configuration.accessToken) { const token = this.configuration.accessToken; const tokenString = await token("jwtAuth", []); @@ -233,12 +221,32 @@ export class UserApi extends runtime.BaseAPI { headerParameters["Authorization"] = `Bearer ${tokenString}`; } } + const consumes: runtime.Consume[] = [ + { contentType: 'multipart/form-data' }, + ]; + // @ts-ignore: canConsumeForm may be unused + const canConsumeForm = runtime.canConsumeForm(consumes); + + let formParams: { append(param: string, value: any): any }; + let useForm = false; + // use FormData to transmit files using content-type "multipart/form-data" + useForm = canConsumeForm; + if (useForm) { + formParams = new FormData(); + } else { + formParams = new URLSearchParams(); + } + + if (requestParameters.avatar !== undefined) { + formParams.append('avatar', requestParameters.avatar as any); + } + const response = await this.request({ path: `/api/user/u/avatar/`, method: 'POST', headers: headerParameters, query: queryParameters, - body: UserAvatarRequestToJSON(requestParameters.userAvatarRequest), + body: formParams, }, initOverrides); return new runtime.VoidApiResponse(response); @@ -247,7 +255,7 @@ export class UserApi extends runtime.BaseAPI { /** * Update uploaded avatar. */ - async userUAvatarCreate(requestParameters: UserUAvatarCreateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + async userUAvatarCreate(requestParameters: UserUAvatarCreateRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { await this.userUAvatarCreateRaw(requestParameters, initOverrides); } @@ -442,16 +450,10 @@ export class UserApi extends runtime.BaseAPI { * Retrieve or update the uploaded resume. */ async userUResumeUpdateRaw(requestParameters: UserUResumeUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { - if (requestParameters.userResumeRequest === null || requestParameters.userResumeRequest === undefined) { - throw new runtime.RequiredError('userResumeRequest','Required parameter requestParameters.userResumeRequest was null or undefined when calling userUResumeUpdate.'); - } - const queryParameters: any = {}; const headerParameters: runtime.HTTPHeaders = {}; - headerParameters['Content-Type'] = 'application/json'; - if (this.configuration && this.configuration.accessToken) { const token = this.configuration.accessToken; const tokenString = await token("jwtAuth", []); @@ -460,12 +462,32 @@ export class UserApi extends runtime.BaseAPI { headerParameters["Authorization"] = `Bearer ${tokenString}`; } } + const consumes: runtime.Consume[] = [ + { contentType: 'multipart/form-data' }, + ]; + // @ts-ignore: canConsumeForm may be unused + const canConsumeForm = runtime.canConsumeForm(consumes); + + let formParams: { append(param: string, value: any): any }; + let useForm = false; + // use FormData to transmit files using content-type "multipart/form-data" + useForm = canConsumeForm; + if (useForm) { + formParams = new FormData(); + } else { + formParams = new URLSearchParams(); + } + + if (requestParameters.resume !== undefined) { + formParams.append('resume', requestParameters.resume as any); + } + const response = await this.request({ path: `/api/user/u/resume/`, method: 'PUT', headers: headerParameters, query: queryParameters, - body: UserResumeRequestToJSON(requestParameters.userResumeRequest), + body: formParams, }, initOverrides); return new runtime.JSONApiResponse(response, (jsonValue) => UserResumeFromJSON(jsonValue)); @@ -474,7 +496,7 @@ export class UserApi extends runtime.BaseAPI { /** * Retrieve or update the uploaded resume. */ - async userUResumeUpdate(requestParameters: UserUResumeUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + async userUResumeUpdate(requestParameters: UserUResumeUpdateRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { const response = await this.userUResumeUpdateRaw(requestParameters, initOverrides); return await response.value(); } diff --git a/frontend2/src/api/_autogen/models/UserAvatarRequest.ts b/frontend2/src/api/_autogen/models/UserAvatarRequest.ts deleted file mode 100644 index 78f412142..000000000 --- a/frontend2/src/api/_autogen/models/UserAvatarRequest.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 0.0.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { exists, mapValues } from '../runtime'; -/** - * - * @export - * @interface UserAvatarRequest - */ -export interface UserAvatarRequest { - /** - * - * @type {Blob} - * @memberof UserAvatarRequest - */ - avatar: Blob; -} - -/** - * Check if a given object implements the UserAvatarRequest interface. - */ -export function instanceOfUserAvatarRequest(value: object): boolean { - let isInstance = true; - isInstance = isInstance && "avatar" in value; - - return isInstance; -} - -export function UserAvatarRequestFromJSON(json: any): UserAvatarRequest { - return UserAvatarRequestFromJSONTyped(json, false); -} - -export function UserAvatarRequestFromJSONTyped(json: any, ignoreDiscriminator: boolean): UserAvatarRequest { - if ((json === undefined) || (json === null)) { - return json; - } - return { - - 'avatar': json['avatar'], - }; -} - -export function UserAvatarRequestToJSON(value?: UserAvatarRequest | null): any { - if (value === undefined) { - return undefined; - } - if (value === null) { - return null; - } - return { - - 'avatar': value.avatar, - }; -} - diff --git a/frontend2/src/api/_autogen/models/UserProfilePrivateRequest.ts b/frontend2/src/api/_autogen/models/UserProfilePrivateRequest.ts index 3cffe1073..d04544dfa 100644 --- a/frontend2/src/api/_autogen/models/UserProfilePrivateRequest.ts +++ b/frontend2/src/api/_autogen/models/UserProfilePrivateRequest.ts @@ -90,7 +90,7 @@ export function UserProfilePrivateRequestFromJSONTyped(json: any, ignoreDiscrimi return json; } return { - + 'gender': GenderEnumFromJSON(json['gender']), 'gender_details': !exists(json, 'gender_details') ? undefined : json['gender_details'], 'school': !exists(json, 'school') ? undefined : json['school'], @@ -108,7 +108,7 @@ export function UserProfilePrivateRequestToJSON(value?: UserProfilePrivateReques return null; } return { - + 'gender': GenderEnumToJSON(value.gender), 'gender_details': value.gender_details, 'school': value.school, diff --git a/frontend2/src/api/_autogen/models/UserResumeRequest.ts b/frontend2/src/api/_autogen/models/UserResumeRequest.ts deleted file mode 100644 index a27268b56..000000000 --- a/frontend2/src/api/_autogen/models/UserResumeRequest.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 0.0.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { exists, mapValues } from '../runtime'; -/** - * - * @export - * @interface UserResumeRequest - */ -export interface UserResumeRequest { - /** - * - * @type {Blob} - * @memberof UserResumeRequest - */ - resume: Blob; -} - -/** - * Check if a given object implements the UserResumeRequest interface. - */ -export function instanceOfUserResumeRequest(value: object): boolean { - let isInstance = true; - isInstance = isInstance && "resume" in value; - - return isInstance; -} - -export function UserResumeRequestFromJSON(json: any): UserResumeRequest { - return UserResumeRequestFromJSONTyped(json, false); -} - -export function UserResumeRequestFromJSONTyped(json: any, ignoreDiscriminator: boolean): UserResumeRequest { - if ((json === undefined) || (json === null)) { - return json; - } - return { - - 'resume': json['resume'], - }; -} - -export function UserResumeRequestToJSON(value?: UserResumeRequest | null): any { - if (value === undefined) { - return undefined; - } - if (value === null) { - return null; - } - return { - - 'resume': value.resume, - }; -} - diff --git a/frontend2/src/api/_autogen/models/index.ts b/frontend2/src/api/_autogen/models/index.ts index 080c3963a..5d85304c8 100644 --- a/frontend2/src/api/_autogen/models/index.ts +++ b/frontend2/src/api/_autogen/models/index.ts @@ -59,7 +59,6 @@ export * from './TokenVerifyRequest'; export * from './Tournament'; export * from './TournamentRound'; export * from './TournamentSubmission'; -export * from './UserAvatarRequest'; export * from './UserCreate'; export * from './UserCreateRequest'; export * from './UserPassed'; @@ -72,4 +71,3 @@ export * from './UserProfilePublicRequest'; export * from './UserPublic'; export * from './UserPublicRequest'; export * from './UserResume'; -export * from './UserResumeRequest'; diff --git a/frontend2/src/api/user/useUser.ts b/frontend2/src/api/user/useUser.ts index 0d6e27ed7..2f792465c 100644 --- a/frontend2/src/api/user/useUser.ts +++ b/frontend2/src/api/user/useUser.ts @@ -190,8 +190,8 @@ export const useAvatarUpload = ( ): UseMutationResult => useMutation({ mutationKey: userMutationKeys.avatarUpload({ episodeId }), - mutationFn: async ({ userAvatarRequest }: UserUAvatarCreateRequest) => { - await toast.promise(avatarUpload({ userAvatarRequest }), { + mutationFn: async (userAvatarRequest: UserUAvatarCreateRequest) => { + await toast.promise(avatarUpload(userAvatarRequest), { loading: "Uploading new avatar...", success: "Uploaded new avatar!", error: "Error uploading new avatar.", @@ -214,8 +214,8 @@ export const useResumeUpload = ( ): UseMutationResult => useMutation({ mutationKey: userMutationKeys.resumeUpload({ episodeId }), - mutationFn: async ({ userResumeRequest }: UserUResumeUpdateRequest) => { - await toast.promise(resumeUpload({ userResumeRequest }), { + mutationFn: async (userResumeRequest: UserUResumeUpdateRequest) => { + await toast.promise(resumeUpload(userResumeRequest), { loading: "Uploading new resume...", success: "Uploaded new resume!", error: "Error uploading new resume.", diff --git a/frontend2/src/api/user/userApi.ts b/frontend2/src/api/user/userApi.ts index aca3ded37..4fe34f145 100644 --- a/frontend2/src/api/user/userApi.ts +++ b/frontend2/src/api/user/userApi.ts @@ -87,20 +87,20 @@ export const updateCurrentUser = async ({ * Upload a new avatar for the currently logged in user. * @param userAvatarRequest The avatar file. */ -export const avatarUpload = async ({ - userAvatarRequest, -}: UserUAvatarCreateRequest): Promise => { - await API.userUAvatarCreate({ userAvatarRequest }); +export const avatarUpload = async ( + userAvatarRequest: UserUAvatarCreateRequest, +): Promise => { + await API.userUAvatarCreate(userAvatarRequest); }; /** * Upload a resume for the currently logged in user. * @param userResumeRequest The resume file. */ -export const resumeUpload = async ({ - userResumeRequest, -}: UserUResumeUpdateRequest): Promise => { - await API.userUResumeUpdate({ userResumeRequest }); +export const resumeUpload = async ( + userResumeRequest: UserUResumeUpdateRequest, +): Promise => { + await API.userUResumeUpdate(userResumeRequest); }; /** diff --git a/frontend2/src/views/Account.tsx b/frontend2/src/views/Account.tsx index aa506f26a..3c8a8be96 100644 --- a/frontend2/src/views/Account.tsx +++ b/frontend2/src/views/Account.tsx @@ -1,93 +1,127 @@ -import React from "react"; +import React, { useState } from "react"; import { PageTitle } from "../components/elements/BattlecodeStyle"; import Input from "../components/elements/Input"; import TextArea from "../components/elements/TextArea"; - +import Loading from "../components/Loading"; import { useCurrentUser } from "../contexts/CurrentUserContext"; import SectionCard from "../components/SectionCard"; import SelectMenu from "../components/elements/SelectMenu"; +import { type Maybe } from "../utils/utilTypes"; +import { + GenderEnum, + type CountryEnum, + type PatchedUserPrivateRequest, +} from "../api/_autogen"; import { COUNTRIES } from "../api/apiTypes"; - -import { GenderEnum, type CountryEnum } from "../api/_autogen"; +import { FIELD_REQUIRED_ERROR_MSG } from "../utils/constants"; +import { type SubmitHandler, useForm } from "react-hook-form"; import Button from "../components/elements/Button"; import FormLabel from "../components/elements/FormLabel"; +import { + useUpdateCurrentUserInfo, + useAvatarUpload, + useResumeUpload, +} from "../api/user/useUser"; +import { useEpisodeId } from "../contexts/EpisodeContext"; +import { useQueryClient } from "@tanstack/react-query"; +import { type QueryClient } from "@tanstack/query-core"; +// import { +// downloadResume +// } from "../api/user/userApi"; + +interface FileInput { + file: FileList; +} const Account: React.FC = () => { + const { episodeId } = useEpisodeId(); + const queryClient = useQueryClient(); + const uploadAvatar = useAvatarUpload({ episodeId }, queryClient); + const uploadResume = useResumeUpload({ episodeId }, queryClient); const { user } = useCurrentUser(); + // TODO: fix downloadResume() - this is not working + // const resumeLink = downloadResume(); + // console.log(resumeLink); + + const { register: avatarRegister, handleSubmit: handleAvatarSubmit } = + useForm(); + + const { register: resumeRegister, handleSubmit: handleResumeSubmit } = + useForm(); + + const onAvatarSubmit: SubmitHandler = async (data) => { + if (uploadAvatar.isPending) return; + await uploadAvatar.mutateAsync({ avatar: data.file[0] }); + }; + + const onResumeSubmit: SubmitHandler = async (data) => { + if (uploadResume.isPending) return; + await uploadResume.mutateAsync({ resume: data.file[0] }); + }; return (
User Settings
- -
-
- -
- {`${user?.first_name ?? ""} ${user?.last_name ?? ""}`} -
-
- -
-
- - - - - - required - label="Country" - placeholder="Select country" - options={Object.entries(COUNTRIES).map(([code, name]) => ({ - value: code as CountryEnum, - label: name, - }))} - /> - - - - required - label="Gender identity" - placeholder="Select gender" - options={[ - { value: GenderEnum.F, label: "Female" }, - { value: GenderEnum.M, label: "Male" }, - { value: GenderEnum.N, label: "Non-binary" }, - { - value: GenderEnum.Star, - label: "Prefer to self describe", - }, - { value: GenderEnum.QuestionMark, label: "Rather not say" }, - ]} - /> -
- -