diff --git a/backend/siarnaq/api/teams/views.py b/backend/siarnaq/api/teams/views.py index 97fd22642..bab9527a6 100644 --- a/backend/siarnaq/api/teams/views.py +++ b/backend/siarnaq/api/teams/views.py @@ -159,7 +159,17 @@ def join(self, request, pk=None, *, episode_id): logger.debug("team_join", message="User has joined team.", team=team.pk) return Response(None, status=status.HTTP_204_NO_CONTENT) - @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 5f96700f9..50120da02 100644 --- a/frontend2/schema.yml +++ b/frontend2/schema.yml @@ -158,7 +158,7 @@ paths: type: array items: type: integer - description: A list of teams to filter for. Defaults to just your own team. + description: A list of teams to filter for. Defaults to your own team. tags: - compete security: @@ -1290,16 +1290,13 @@ paths: - team requestBody: content: - application/json: - schema: - $ref: '#/components/schemas/TeamAvatarRequest' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/TeamAvatarRequest' multipart/form-data: schema: - $ref: '#/components/schemas/TeamAvatarRequest' - required: true + type: object + properties: + avatar: + type: string + format: binary security: - jwtAuth: [] responses: @@ -2749,15 +2746,6 @@ components: type: boolean required: - invocation - TeamAvatarRequest: - type: object - properties: - avatar: - type: string - format: binary - writeOnly: true - required: - - avatar TeamCreate: type: object properties: diff --git a/frontend2/src/api/_autogen/.openapi-generator/FILES b/frontend2/src/api/_autogen/.openapi-generator/FILES index 2d1757da7..49e39f640 100644 --- a/frontend2/src/api/_autogen/.openapi-generator/FILES +++ b/frontend2/src/api/_autogen/.openapi-generator/FILES @@ -48,7 +48,6 @@ models/StyleEnum.ts models/Submission.ts models/SubmissionDownload.ts models/SubmissionReportRequest.ts -models/TeamAvatarRequest.ts models/TeamCreate.ts models/TeamCreateRequest.ts models/TeamJoinRequest.ts diff --git a/frontend2/src/api/_autogen/apis/TeamApi.ts b/frontend2/src/api/_autogen/apis/TeamApi.ts index 960c2aa8b..96fb55e49 100644 --- a/frontend2/src/api/_autogen/apis/TeamApi.ts +++ b/frontend2/src/api/_autogen/apis/TeamApi.ts @@ -19,7 +19,6 @@ import type { PaginatedClassRequirementList, PaginatedTeamPublicList, PatchedTeamPrivateRequest, - TeamAvatarRequest, TeamCreate, TeamCreateRequest, TeamJoinRequest, @@ -38,8 +37,6 @@ import { PaginatedTeamPublicListToJSON, PatchedTeamPrivateRequestFromJSON, PatchedTeamPrivateRequestToJSON, - TeamAvatarRequestFromJSON, - TeamAvatarRequestToJSON, TeamCreateFromJSON, TeamCreateToJSON, TeamCreateRequestFromJSON, @@ -89,7 +86,7 @@ export interface TeamRequirementRetrieveRequest { export interface TeamTAvatarCreateRequest { episodeId: string; - teamAvatarRequest: TeamAvatarRequest; + avatar?: Blob; } export interface TeamTCreateRequest { @@ -394,16 +391,10 @@ export class TeamApi extends runtime.BaseAPI { throw new runtime.RequiredError('episodeId','Required parameter requestParameters.episodeId was null or undefined when calling teamTAvatarCreate.'); } - if (requestParameters.teamAvatarRequest === null || requestParameters.teamAvatarRequest === undefined) { - throw new runtime.RequiredError('teamAvatarRequest','Required parameter requestParameters.teamAvatarRequest was null or undefined when calling teamTAvatarCreate.'); - } - 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", []); @@ -412,12 +403,32 @@ export class TeamApi 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/team/{episode_id}/t/avatar/`.replace(`{${"episode_id"}}`, encodeURIComponent(String(requestParameters.episodeId))), method: 'POST', headers: headerParameters, query: queryParameters, - body: TeamAvatarRequestToJSON(requestParameters.teamAvatarRequest), + body: formParams, }, initOverrides); return new runtime.VoidApiResponse(response); diff --git a/frontend2/src/api/_autogen/models/TeamAvatarRequest.ts b/frontend2/src/api/_autogen/models/TeamAvatarRequest.ts deleted file mode 100644 index 34b25ad73..000000000 --- a/frontend2/src/api/_autogen/models/TeamAvatarRequest.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 TeamAvatarRequest - */ -export interface TeamAvatarRequest { - /** - * - * @type {Blob} - * @memberof TeamAvatarRequest - */ - avatar: Blob; -} - -/** - * Check if a given object implements the TeamAvatarRequest interface. - */ -export function instanceOfTeamAvatarRequest(value: object): boolean { - let isInstance = true; - isInstance = isInstance && "avatar" in value; - - return isInstance; -} - -export function TeamAvatarRequestFromJSON(json: any): TeamAvatarRequest { - return TeamAvatarRequestFromJSONTyped(json, false); -} - -export function TeamAvatarRequestFromJSONTyped(json: any, ignoreDiscriminator: boolean): TeamAvatarRequest { - if ((json === undefined) || (json === null)) { - return json; - } - return { - - 'avatar': json['avatar'], - }; -} - -export function TeamAvatarRequestToJSON(value?: TeamAvatarRequest | 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 d04544dfa..3cffe1073 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/index.ts b/frontend2/src/api/_autogen/models/index.ts index 8d8127cd6..7d5ab4f73 100644 --- a/frontend2/src/api/_autogen/models/index.ts +++ b/frontend2/src/api/_autogen/models/index.ts @@ -41,7 +41,6 @@ export * from './StyleEnum'; export * from './Submission'; export * from './SubmissionDownload'; export * from './SubmissionReportRequest'; -export * from './TeamAvatarRequest'; export * from './TeamCreate'; export * from './TeamCreateRequest'; export * from './TeamJoinRequest'; diff --git a/frontend2/src/api/compete/competeKeys.ts b/frontend2/src/api/compete/competeKeys.ts index a51124c9c..30e4f7557 100644 --- a/frontend2/src/api/compete/competeKeys.ts +++ b/frontend2/src/api/compete/competeKeys.ts @@ -147,6 +147,9 @@ export const competeMutationKeys = { uploadSub: ({ episodeId }: { episodeId: string }) => ["compete", episodeId, "submit"] as const, + downloadSub: ({ episodeId }: { episodeId: string }) => + ["compete", episodeId, "submit", "download"] as const, + // --- SCRIMMAGES --- // requestScrim: ({ episodeId }: { episodeId: string }) => ["compete", episodeId, "scrimmage", "request"] as const, diff --git a/frontend2/src/api/compete/useCompete.ts b/frontend2/src/api/compete/useCompete.ts index c912cec53..91edd78dd 100644 --- a/frontend2/src/api/compete/useCompete.ts +++ b/frontend2/src/api/compete/useCompete.ts @@ -22,6 +22,7 @@ import type { CompeteSubmissionCreateRequest, CompeteSubmissionListRequest, CompeteSubmissionTournamentListRequest, + CompeteSubmissionDownloadRetrieveRequest, HistoricalRating, PaginatedMatchList, PaginatedScrimmageRequestList, @@ -38,6 +39,7 @@ import { rejectScrimmage, requestScrimmage, uploadSubmission, + downloadSubmission, } from "./competeApi"; import toast from "react-hot-toast"; import { buildKey } from "../helpers"; @@ -535,3 +537,30 @@ export const useCancelScrimmage = ( }); }, }); + +/** + * For downloading a submission. + */ +export const useDownloadSubmission = ({ + episodeId, +}: { + episodeId: string; +}): UseMutationResult< + void, + Error, + CompeteSubmissionDownloadRetrieveRequest, + unknown +> => + useMutation({ + mutationKey: competeMutationKeys.downloadSub({ episodeId }), + mutationFn: async ({ + episodeId, + id, + }: CompeteSubmissionDownloadRetrieveRequest) => { + await toast.promise(downloadSubmission({ episodeId, id }), { + loading: "Downloading submission...", + success: "Downloaded submission!", + error: "Error downloading submission.", + }); + }, + }); diff --git a/frontend2/src/api/loaders/teamProfileLoader.ts b/frontend2/src/api/loaders/teamProfileLoader.ts index 8a2c96eaf..39fb30efb 100644 --- a/frontend2/src/api/loaders/teamProfileLoader.ts +++ b/frontend2/src/api/loaders/teamProfileLoader.ts @@ -1,11 +1,7 @@ import type { QueryClient } from "@tanstack/react-query"; import type { LoaderFunction } from "react-router-dom"; -import { matchListFactory } from "../compete/competeFactories"; import { buildKey } from "../helpers"; -import { - otherTeamInfoFactory, - searchTeamsFactory, -} from "../team/teamFactories"; +import { otherTeamInfoFactory } from "../team/teamFactories"; // loader for other team's public profile pages export const teamProfileLoader = diff --git a/frontend2/src/api/team/teamApi.ts b/frontend2/src/api/team/teamApi.ts index 962b764ae..b565c5dc5 100644 --- a/frontend2/src/api/team/teamApi.ts +++ b/frontend2/src/api/team/teamApi.ts @@ -151,13 +151,12 @@ export const searchTeams = async ({ /** * Upload a new avatar for the currently logged in user's team. * @param episodeId The current episode's ID. - * @param avatar The avatar file. + * @param teamAvatarRequest The avatar file. */ -export const teamAvatarUpload = async ({ - episodeId, - teamAvatarRequest, -}: TeamTAvatarCreateRequest): Promise => { - await API.teamTAvatarCreate({ episodeId, teamAvatarRequest }); +export const teamAvatarUpload = async ( + teamAvatarRequest: TeamTAvatarCreateRequest, +): Promise => { + await API.teamTAvatarCreate(teamAvatarRequest); }; /** diff --git a/frontend2/src/api/team/teamKeys.ts b/frontend2/src/api/team/teamKeys.ts index 9b4685bee..b9cc0b549 100644 --- a/frontend2/src/api/team/teamKeys.ts +++ b/frontend2/src/api/team/teamKeys.ts @@ -59,7 +59,7 @@ export const teamMutationKeys = { update: ({ episodeId }: { episodeId: string }) => ["team", "update", episodeId] as const, - avatar: ({ episodeId }: { episodeId: string }) => + avatarUpload: ({ episodeId }: { episodeId: string }) => ["team", "avatar", episodeId] as const, report: ({ episodeId }: { episodeId: string }) => diff --git a/frontend2/src/api/team/useTeam.ts b/frontend2/src/api/team/useTeam.ts index 6231ec6cf..af62da899 100644 --- a/frontend2/src/api/team/useTeam.ts +++ b/frontend2/src/api/team/useTeam.ts @@ -8,7 +8,6 @@ import { import type { PaginatedTeamPublicList, PatchedTeamPrivateRequest, - TeamAvatarRequest, TeamCreate, TeamJoinRequest, TeamPrivate, @@ -198,11 +197,12 @@ export const useUpdateTeamAvatar = ( episodeId: string; }, queryClient: QueryClient, -): UseMutationResult => +): UseMutationResult => useMutation({ - mutationKey: teamMutationKeys.avatar({ episodeId }), - mutationFn: async (teamAvatarRequest: TeamAvatarRequest) => { - await toast.promise(teamAvatarUpload({ episodeId, teamAvatarRequest }), { + mutationKey: teamMutationKeys.avatarUpload({ episodeId }), + // We pass in a Blob because we already have the episodeId + mutationFn: async (avatar: Blob) => { + await toast.promise(teamAvatarUpload({ episodeId, avatar }), { loading: "Uploading team avatar...", success: "Uploaded team avatar!", error: "Error uploading team avatar.", diff --git a/frontend2/src/api/user/useUser.ts b/frontend2/src/api/user/useUser.ts index 2f792465c..c91ad8270 100644 --- a/frontend2/src/api/user/useUser.ts +++ b/frontend2/src/api/user/useUser.ts @@ -19,11 +19,12 @@ import type { } from "../_autogen"; import { userMutationKeys, userQueryKeys } from "./userKeys"; import { - avatarUpload, createUser, doResetPassword, resumeUpload, updateCurrentUser, + downloadResume, + userAvatarUpload, } from "./userApi"; import { toast } from "react-hot-toast"; import { login } from "../auth/authApi"; @@ -184,14 +185,14 @@ export const useResetPassword = ({ /** * For uploading a new avatar for the currently logged in user. */ -export const useAvatarUpload = ( +export const useUpdateUserAvatar = ( { episodeId }: { episodeId: string }, queryClient: QueryClient, ): UseMutationResult => useMutation({ mutationKey: userMutationKeys.avatarUpload({ episodeId }), mutationFn: async (userAvatarRequest: UserUAvatarCreateRequest) => { - await toast.promise(avatarUpload(userAvatarRequest), { + await toast.promise(userAvatarUpload(userAvatarRequest), { loading: "Uploading new avatar...", success: "Uploaded new avatar!", error: "Error uploading new avatar.", @@ -228,3 +229,22 @@ export const useResumeUpload = ( }); }, }); + +/** + * For downloading the resume of the currently logged in user. + */ +export const useDownloadResume = ({ + episodeId, +}: { + episodeId: string; +}): UseMutationResult => + useMutation({ + mutationKey: userMutationKeys.resumeDownload({ episodeId }), + mutationFn: async () => { + await toast.promise(downloadResume(), { + loading: "Downloading resume...", + success: "Downloaded resume!", + error: "Error downloading resume.", + }); + }, + }); diff --git a/frontend2/src/api/user/userApi.ts b/frontend2/src/api/user/userApi.ts index 4fe34f145..f5eb1ce09 100644 --- a/frontend2/src/api/user/userApi.ts +++ b/frontend2/src/api/user/userApi.ts @@ -87,7 +87,7 @@ export const updateCurrentUser = async ({ * Upload a new avatar for the currently logged in user. * @param userAvatarRequest The avatar file. */ -export const avatarUpload = async ( +export const userAvatarUpload = async ( userAvatarRequest: UserUAvatarCreateRequest, ): Promise => { await API.userUAvatarCreate(userAvatarRequest); diff --git a/frontend2/src/components/tables/scrimmaging/TeamsTable.tsx b/frontend2/src/components/tables/scrimmaging/TeamsTable.tsx index 83d7c2728..ffae05388 100644 --- a/frontend2/src/components/tables/scrimmaging/TeamsTable.tsx +++ b/frontend2/src/components/tables/scrimmaging/TeamsTable.tsx @@ -122,7 +122,10 @@ const TeamsTable: React.FC = ({ header: "Team", key: "team", value: (team) => ( - + {trimString(team.name, 13)} ), diff --git a/frontend2/src/components/tables/submissions/SubHistoryTable.tsx b/frontend2/src/components/tables/submissions/SubHistoryTable.tsx index 6907d8ffb..f63e45764 100644 --- a/frontend2/src/components/tables/submissions/SubHistoryTable.tsx +++ b/frontend2/src/components/tables/submissions/SubHistoryTable.tsx @@ -3,6 +3,8 @@ import { type PaginatedSubmissionList, StatusBccEnum, } from "../../../api/_autogen"; +import { useEpisodeId } from "contexts/EpisodeContext"; +import { useDownloadSubmission } from "../../../api/compete/useCompete"; import type { Maybe } from "../../../utils/utilTypes"; import { NavLink } from "react-router-dom"; import { dateTime } from "../../../utils/dateTime"; @@ -32,6 +34,9 @@ const SubHistoryTable: React.FC = ({ page, handlePage, }) => { + const { episodeId } = useEpisodeId(); + const downloadSubmission = useDownloadSubmission({ episodeId }); + return ( = ({ { header: "", key: "download", - value: (sub) => "Download", + value: (sub) => ( + + ), }, ]} /> diff --git a/frontend2/src/views/Account.tsx b/frontend2/src/views/Account.tsx index ef4813d28..0eb4fd279 100644 --- a/frontend2/src/views/Account.tsx +++ b/frontend2/src/views/Account.tsx @@ -18,16 +18,14 @@ import { type SubmitHandler, useForm } from "react-hook-form"; import Button from "../components/elements/Button"; import FormLabel from "../components/elements/FormLabel"; import { + useDownloadResume, useUpdateCurrentUserInfo, - useAvatarUpload, + useUpdateUserAvatar, 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; @@ -36,12 +34,10 @@ interface FileInput { const Account: React.FC = () => { const { episodeId } = useEpisodeId(); const queryClient = useQueryClient(); - const uploadAvatar = useAvatarUpload({ episodeId }, queryClient); + const uploadAvatar = useUpdateUserAvatar({ episodeId }, queryClient); const uploadResume = useResumeUpload({ episodeId }, queryClient); + const downloadResume = useDownloadResume({ episodeId }); const { user } = useCurrentUser(); - // TODO: fix downloadResume() - this is not working - // const resumeLink = downloadResume(); - // console.log(resumeLink); const { register: avatarRegister, handleSubmit: handleAvatarSubmit } = useForm(); @@ -70,7 +66,7 @@ const Account: React.FC = () => { )} -
+
{ loading={uploadResume.isPending} disabled={uploadResume.isPending} /> -

- {user?.profile?.has_resume ?? false - ? "Resume uploaded!" - : "No resume uploaded."} -

- {/*

- {resumeLink} -

*/} + {user?.profile?.has_resume ?? false ? ( +

+ Resume uploaded!{" "} + +

+ ) : ( +

No resume uploaded.

+ )}
diff --git a/frontend2/src/views/MyTeam.tsx b/frontend2/src/views/MyTeam.tsx index ed1c41815..208d45b48 100644 --- a/frontend2/src/views/MyTeam.tsx +++ b/frontend2/src/views/MyTeam.tsx @@ -9,26 +9,37 @@ import Modal from "components/Modal"; import EligibilitySettings from "components/team/EligibilitySettings"; import ScrimmageSettings from "components/team/ScrimmageSettings"; import { useEpisodeId } from "contexts/EpisodeContext"; -import { useLeaveTeam, useUpdateTeam, useUserTeam } from "api/team/useTeam"; +import { + useLeaveTeam, + useUpdateTeam, + useUserTeam, + useUpdateTeamAvatar, +} from "api/team/useTeam"; import { useQueryClient } from "@tanstack/react-query"; import JoinTeam from "./JoinTeam"; import Loading from "components/Loading"; import { type SubmitHandler, useForm } from "react-hook-form"; +import { FIELD_REQUIRED_ERROR_MSG } from "utils/constants"; +import FormLabel from "components/elements/FormLabel"; interface InfoFormInput { quote: string; biography: string; } +interface AvatarInput { + file: FileList; +} + const MyTeam: React.FC = () => { const { episodeId } = useEpisodeId(); const queryClient = useQueryClient(); const { - register, - handleSubmit, - formState: { isDirty }, - reset, + register: registerInfo, + handleSubmit: handleInfoSubmit, + formState: { isDirty: isInfoDirty }, + reset: resetInfo, } = useForm(); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); @@ -50,6 +61,27 @@ const MyTeam: React.FC = () => { }, ); + const onInfoSubmit: SubmitHandler = async (data) => { + if (updateTeam.isPending) return; + await updateTeam.mutateAsync({ + profile: { + quote: data.quote, + biography: data.biography, + }, + }); + resetInfo(); + }; + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + const onLeaveTeam: EventHandler> = async ( + event, + ) => { + if (leaveTeam.isPending) return; + event.preventDefault(); + await leaveTeam.mutateAsync(); + setIsLeaveModalOpen(false); + }; + const membersList = useMemo(() => { return (
@@ -69,26 +101,6 @@ const MyTeam: React.FC = () => { ); }, [teamData]); - const onSubmit: SubmitHandler = (data) => { - if (updateTeam.isPending) return; - updateTeam.mutate({ - profile: { - quote: data.quote, - biography: data.biography, - }, - }); - reset(); - }; - - // eslint-disable-next-line @typescript-eslint/no-misused-promises - const onLeaveTeam: EventHandler> = ( - event, - ) => { - if (leaveTeam.isPending) return; - event.preventDefault(); - leaveTeam.mutate(); - }; - if (teamData.isLoading) { return ; } else if (!teamData.isSuccess) { @@ -103,7 +115,7 @@ const MyTeam: React.FC = () => {
@@ -123,17 +135,17 @@ const MyTeam: React.FC = () => { />