From 06da37b499c4f9cfca863d37962ba4dcff52db09 Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Wed, 20 May 2026 10:45:12 -0400 Subject: [PATCH 1/7] Allow users to edit profile name --- drf_lint_baseline.json | 8 +- frontends/api/src/generated/v0/api.ts | 695 +++++++++--------- frontends/api/src/hooks/profile/index.ts | 14 +- .../DashboardPage/ProfileContent.tsx | 8 +- learning_resources/serializers.py | 2 +- main/middleware/apisix_user.py | 24 +- main/middleware/apisix_user_test.py | 11 +- main/serializers.py | 23 +- main/settings.py | 1 + openapi/specs/v0.yaml | 126 ++-- profiles/api.py | 23 + profiles/constants.py | 1 + profiles/serializers.py | 11 +- profiles/urls.py | 8 + profiles/views.py | 28 +- profiles/views_test.py | 20 +- pyproject.toml | 2 + uv.lock | 50 ++ widgets/serializers/widget_list.py | 2 +- 19 files changed, 605 insertions(+), 452 deletions(-) diff --git a/drf_lint_baseline.json b/drf_lint_baseline.json index 5698b9b76d..aea205a3bb 100644 --- a/drf_lint_baseline.json +++ b/drf_lint_baseline.json @@ -13,8 +13,8 @@ "channels/serializers.py:433:16:ORM001", "channels/serializers.py:501:12:ORM001", "channels/serializers.py:509:12:ORM001", - "profiles/serializers.py:132:31:ORM002", - "profiles/serializers.py:169:16:ORM002", - "profiles/serializers.py:391:15:ORM001", - "profiles/serializers.py:392:27:ORM001" + "profiles/serializers.py:137:31:ORM002", + "profiles/serializers.py:176:16:ORM002", + "profiles/serializers.py:398:15:ORM001", + "profiles/serializers.py:399:27:ORM001" ] diff --git a/frontends/api/src/generated/v0/api.ts b/frontends/api/src/generated/v0/api.ts index b4db550aaa..6d748bc1bd 100644 --- a/frontends/api/src/generated/v0/api.ts +++ b/frontends/api/src/generated/v0/api.ts @@ -3671,6 +3671,12 @@ export interface PatchedChannelWriteRequest { * @interface PatchedProfileRequest */ export interface PatchedProfileRequest { + /** + * Get the user\'s name + * @type {string} + * @memberof PatchedProfileRequest + */ + name?: string /** * * @type {string} @@ -4847,6 +4853,12 @@ export interface Profile { * @interface ProfileRequest */ export interface ProfileRequest { + /** + * Get the user\'s name + * @type {string} + * @memberof ProfileRequest + */ + name: string /** * * @type {string} @@ -6863,7 +6875,7 @@ export interface WidgetInstance { * @type {{ [key: string]: any; }} * @memberof WidgetInstance */ - configuration?: { [key: string]: any } + configuration: { [key: string]: any } /** * Renders the widget to json based on configuration * @type {{ [key: string]: any; }} @@ -9362,6 +9374,140 @@ export const ProfilesApiAxiosParamCreator = function ( configuration?: Configuration, ) { return { + /** + * Profile retrieve and update viewsets for the current user + * @param {PatchedProfileRequest} [PatchedProfileRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + profilesMePartialUpdate: async ( + PatchedProfileRequest?: PatchedProfileRequest, + options: RawAxiosRequestConfig = {}, + ): Promise => { + const localVarPath = `/api/v0/profiles/me/` + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = { + method: "PATCH", + ...baseOptions, + ...options, + } + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + localVarHeaderParameter["Content-Type"] = "application/json" + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + localVarRequestOptions.data = serializeDataIfNeeded( + PatchedProfileRequest, + localVarRequestOptions, + configuration, + ) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + /** + * Profile retrieve and update viewsets for the current user + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + profilesMeRetrieve: async ( + options: RawAxiosRequestConfig = {}, + ): Promise => { + const localVarPath = `/api/v0/profiles/me/` + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + } + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + /** + * Profile retrieve and update viewsets for the current user + * @param {ProfileRequest} ProfileRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + profilesMeUpdate: async ( + ProfileRequest: ProfileRequest, + options: RawAxiosRequestConfig = {}, + ): Promise => { + // verify required parameter 'ProfileRequest' is not null or undefined + assertParamExists("profilesMeUpdate", "ProfileRequest", ProfileRequest) + const localVarPath = `/api/v0/profiles/me/` + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = { + method: "PUT", + ...baseOptions, + ...options, + } + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + localVarHeaderParameter["Content-Type"] = "application/json" + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + localVarRequestOptions.data = serializeDataIfNeeded( + ProfileRequest, + localVarRequestOptions, + configuration, + ) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, /** * View for profile * @param {string} user__username @@ -9468,17 +9614,19 @@ export const ProfilesApiAxiosParamCreator = function ( /** * View for profile * @param {string} user__username - * @param {ProfileRequest} [ProfileRequest] + * @param {ProfileRequest} ProfileRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ profilesUpdate: async ( user__username: string, - ProfileRequest?: ProfileRequest, + ProfileRequest: ProfileRequest, options: RawAxiosRequestConfig = {}, ): Promise => { // verify required parameter 'user__username' is not null or undefined assertParamExists("profilesUpdate", "user__username", user__username) + // verify required parameter 'ProfileRequest' is not null or undefined + assertParamExists("profilesUpdate", "ProfileRequest", ProfileRequest) const localVarPath = `/api/v0/profiles/{user__username}/`.replace( `{${"user__username"}}`, encodeURIComponent(String(user__username)), @@ -9529,6 +9677,85 @@ export const ProfilesApiAxiosParamCreator = function ( export const ProfilesApiFp = function (configuration?: Configuration) { const localVarAxiosParamCreator = ProfilesApiAxiosParamCreator(configuration) return { + /** + * Profile retrieve and update viewsets for the current user + * @param {PatchedProfileRequest} [PatchedProfileRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async profilesMePartialUpdate( + PatchedProfileRequest?: PatchedProfileRequest, + options?: RawAxiosRequestConfig, + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.profilesMePartialUpdate( + PatchedProfileRequest, + options, + ) + const index = configuration?.serverIndex ?? 0 + const operationBasePath = + operationServerMap["ProfilesApi.profilesMePartialUpdate"]?.[index]?.url + return (axios, basePath) => + createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration, + )(axios, operationBasePath || basePath) + }, + /** + * Profile retrieve and update viewsets for the current user + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async profilesMeRetrieve( + options?: RawAxiosRequestConfig, + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.profilesMeRetrieve(options) + const index = configuration?.serverIndex ?? 0 + const operationBasePath = + operationServerMap["ProfilesApi.profilesMeRetrieve"]?.[index]?.url + return (axios, basePath) => + createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration, + )(axios, operationBasePath || basePath) + }, + /** + * Profile retrieve and update viewsets for the current user + * @param {ProfileRequest} ProfileRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async profilesMeUpdate( + ProfileRequest: ProfileRequest, + options?: RawAxiosRequestConfig, + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.profilesMeUpdate( + ProfileRequest, + options, + ) + const index = configuration?.serverIndex ?? 0 + const operationBasePath = + operationServerMap["ProfilesApi.profilesMeUpdate"]?.[index]?.url + return (axios, basePath) => + createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration, + )(axios, operationBasePath || basePath) + }, /** * View for profile * @param {string} user__username @@ -9591,13 +9818,13 @@ export const ProfilesApiFp = function (configuration?: Configuration) { /** * View for profile * @param {string} user__username - * @param {ProfileRequest} [ProfileRequest] + * @param {ProfileRequest} ProfileRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ async profilesUpdate( user__username: string, - ProfileRequest?: ProfileRequest, + ProfileRequest: ProfileRequest, options?: RawAxiosRequestConfig, ): Promise< (axios?: AxiosInstance, basePath?: string) => AxiosPromise @@ -9632,6 +9859,47 @@ export const ProfilesApiFactory = function ( ) { const localVarFp = ProfilesApiFp(configuration) return { + /** + * Profile retrieve and update viewsets for the current user + * @param {ProfilesApiProfilesMePartialUpdateRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + profilesMePartialUpdate( + requestParameters: ProfilesApiProfilesMePartialUpdateRequest = {}, + options?: RawAxiosRequestConfig, + ): AxiosPromise { + return localVarFp + .profilesMePartialUpdate( + requestParameters.PatchedProfileRequest, + options, + ) + .then((request) => request(axios, basePath)) + }, + /** + * Profile retrieve and update viewsets for the current user + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + profilesMeRetrieve(options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp + .profilesMeRetrieve(options) + .then((request) => request(axios, basePath)) + }, + /** + * Profile retrieve and update viewsets for the current user + * @param {ProfilesApiProfilesMeUpdateRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + profilesMeUpdate( + requestParameters: ProfilesApiProfilesMeUpdateRequest, + options?: RawAxiosRequestConfig, + ): AxiosPromise { + return localVarFp + .profilesMeUpdate(requestParameters.ProfileRequest, options) + .then((request) => request(axios, basePath)) + }, /** * View for profile * @param {ProfilesApiProfilesPartialUpdateRequest} requestParameters Request parameters. @@ -9685,6 +9953,34 @@ export const ProfilesApiFactory = function ( } } +/** + * Request parameters for profilesMePartialUpdate operation in ProfilesApi. + * @export + * @interface ProfilesApiProfilesMePartialUpdateRequest + */ +export interface ProfilesApiProfilesMePartialUpdateRequest { + /** + * + * @type {PatchedProfileRequest} + * @memberof ProfilesApiProfilesMePartialUpdate + */ + readonly PatchedProfileRequest?: PatchedProfileRequest +} + +/** + * Request parameters for profilesMeUpdate operation in ProfilesApi. + * @export + * @interface ProfilesApiProfilesMeUpdateRequest + */ +export interface ProfilesApiProfilesMeUpdateRequest { + /** + * + * @type {ProfileRequest} + * @memberof ProfilesApiProfilesMeUpdate + */ + readonly ProfileRequest: ProfileRequest +} + /** * Request parameters for profilesPartialUpdate operation in ProfilesApi. * @export @@ -9738,7 +10034,7 @@ export interface ProfilesApiProfilesUpdateRequest { * @type {ProfileRequest} * @memberof ProfilesApiProfilesUpdate */ - readonly ProfileRequest?: ProfileRequest + readonly ProfileRequest: ProfileRequest } /** @@ -9749,33 +10045,77 @@ export interface ProfilesApiProfilesUpdateRequest { */ export class ProfilesApi extends BaseAPI { /** - * View for profile - * @param {ProfilesApiProfilesPartialUpdateRequest} requestParameters Request parameters. + * Profile retrieve and update viewsets for the current user + * @param {ProfilesApiProfilesMePartialUpdateRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof ProfilesApi */ - public profilesPartialUpdate( - requestParameters: ProfilesApiProfilesPartialUpdateRequest, + public profilesMePartialUpdate( + requestParameters: ProfilesApiProfilesMePartialUpdateRequest = {}, options?: RawAxiosRequestConfig, ) { return ProfilesApiFp(this.configuration) - .profilesPartialUpdate( - requestParameters.user__username, - requestParameters.PatchedProfileRequest, - options, - ) + .profilesMePartialUpdate(requestParameters.PatchedProfileRequest, options) .then((request) => request(this.axios, this.basePath)) } /** - * View for profile - * @param {ProfilesApiProfilesRetrieveRequest} requestParameters Request parameters. + * Profile retrieve and update viewsets for the current user * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof ProfilesApi */ - public profilesRetrieve( + public profilesMeRetrieve(options?: RawAxiosRequestConfig) { + return ProfilesApiFp(this.configuration) + .profilesMeRetrieve(options) + .then((request) => request(this.axios, this.basePath)) + } + + /** + * Profile retrieve and update viewsets for the current user + * @param {ProfilesApiProfilesMeUpdateRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ProfilesApi + */ + public profilesMeUpdate( + requestParameters: ProfilesApiProfilesMeUpdateRequest, + options?: RawAxiosRequestConfig, + ) { + return ProfilesApiFp(this.configuration) + .profilesMeUpdate(requestParameters.ProfileRequest, options) + .then((request) => request(this.axios, this.basePath)) + } + + /** + * View for profile + * @param {ProfilesApiProfilesPartialUpdateRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ProfilesApi + */ + public profilesPartialUpdate( + requestParameters: ProfilesApiProfilesPartialUpdateRequest, + options?: RawAxiosRequestConfig, + ) { + return ProfilesApiFp(this.configuration) + .profilesPartialUpdate( + requestParameters.user__username, + requestParameters.PatchedProfileRequest, + options, + ) + .then((request) => request(this.axios, this.basePath)) + } + + /** + * View for profile + * @param {ProfilesApiProfilesRetrieveRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ProfilesApi + */ + public profilesRetrieve( requestParameters: ProfilesApiProfilesRetrieveRequest, options?: RawAxiosRequestConfig, ) { @@ -10685,138 +11025,6 @@ export const UsersApiAxiosParamCreator = function ( configuration?: Configuration, ) { return { - /** - * View for users - * @param {UserRequest} UserRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - usersCreate: async ( - UserRequest: UserRequest, - options: RawAxiosRequestConfig = {}, - ): Promise => { - // verify required parameter 'UserRequest' is not null or undefined - assertParamExists("usersCreate", "UserRequest", UserRequest) - const localVarPath = `/api/v0/users/` - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) - let baseOptions - if (configuration) { - baseOptions = configuration.baseOptions - } - - const localVarRequestOptions = { - method: "POST", - ...baseOptions, - ...options, - } - const localVarHeaderParameter = {} as any - const localVarQueryParameter = {} as any - - localVarHeaderParameter["Content-Type"] = "application/json" - - setSearchParams(localVarUrlObj, localVarQueryParameter) - let headersFromBaseOptions = - baseOptions && baseOptions.headers ? baseOptions.headers : {} - localVarRequestOptions.headers = { - ...localVarHeaderParameter, - ...headersFromBaseOptions, - ...options.headers, - } - localVarRequestOptions.data = serializeDataIfNeeded( - UserRequest, - localVarRequestOptions, - configuration, - ) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - } - }, - /** - * View for users - * @param {string} username - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - usersDestroy: async ( - username: string, - options: RawAxiosRequestConfig = {}, - ): Promise => { - // verify required parameter 'username' is not null or undefined - assertParamExists("usersDestroy", "username", username) - const localVarPath = `/api/v0/users/{username}/`.replace( - `{${"username"}}`, - encodeURIComponent(String(username)), - ) - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) - let baseOptions - if (configuration) { - baseOptions = configuration.baseOptions - } - - const localVarRequestOptions = { - method: "DELETE", - ...baseOptions, - ...options, - } - const localVarHeaderParameter = {} as any - const localVarQueryParameter = {} as any - - setSearchParams(localVarUrlObj, localVarQueryParameter) - let headersFromBaseOptions = - baseOptions && baseOptions.headers ? baseOptions.headers : {} - localVarRequestOptions.headers = { - ...localVarHeaderParameter, - ...headersFromBaseOptions, - ...options.headers, - } - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - } - }, - /** - * View for users - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - usersList: async ( - options: RawAxiosRequestConfig = {}, - ): Promise => { - const localVarPath = `/api/v0/users/` - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) - let baseOptions - if (configuration) { - baseOptions = configuration.baseOptions - } - - const localVarRequestOptions = { - method: "GET", - ...baseOptions, - ...options, - } - const localVarHeaderParameter = {} as any - const localVarQueryParameter = {} as any - - setSearchParams(localVarUrlObj, localVarQueryParameter) - let headersFromBaseOptions = - baseOptions && baseOptions.headers ? baseOptions.headers : {} - localVarRequestOptions.headers = { - ...localVarHeaderParameter, - ...headersFromBaseOptions, - ...options.headers, - } - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - } - }, /** * User retrieve and update viewsets for the current user * @param {*} [options] Override http request option. @@ -11020,83 +11228,6 @@ export const UsersApiAxiosParamCreator = function ( export const UsersApiFp = function (configuration?: Configuration) { const localVarAxiosParamCreator = UsersApiAxiosParamCreator(configuration) return { - /** - * View for users - * @param {UserRequest} UserRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async usersCreate( - UserRequest: UserRequest, - options?: RawAxiosRequestConfig, - ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise - > { - const localVarAxiosArgs = await localVarAxiosParamCreator.usersCreate( - UserRequest, - options, - ) - const index = configuration?.serverIndex ?? 0 - const operationBasePath = - operationServerMap["UsersApi.usersCreate"]?.[index]?.url - return (axios, basePath) => - createRequestFunction( - localVarAxiosArgs, - globalAxios, - BASE_PATH, - configuration, - )(axios, operationBasePath || basePath) - }, - /** - * View for users - * @param {string} username - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async usersDestroy( - username: string, - options?: RawAxiosRequestConfig, - ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise - > { - const localVarAxiosArgs = await localVarAxiosParamCreator.usersDestroy( - username, - options, - ) - const index = configuration?.serverIndex ?? 0 - const operationBasePath = - operationServerMap["UsersApi.usersDestroy"]?.[index]?.url - return (axios, basePath) => - createRequestFunction( - localVarAxiosArgs, - globalAxios, - BASE_PATH, - configuration, - )(axios, operationBasePath || basePath) - }, - /** - * View for users - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async usersList( - options?: RawAxiosRequestConfig, - ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise> - > { - const localVarAxiosArgs = - await localVarAxiosParamCreator.usersList(options) - const index = configuration?.serverIndex ?? 0 - const operationBasePath = - operationServerMap["UsersApi.usersList"]?.[index]?.url - return (axios, basePath) => - createRequestFunction( - localVarAxiosArgs, - globalAxios, - BASE_PATH, - configuration, - )(axios, operationBasePath || basePath) - }, /** * User retrieve and update viewsets for the current user * @param {*} [options] Override http request option. @@ -11222,44 +11353,6 @@ export const UsersApiFactory = function ( ) { const localVarFp = UsersApiFp(configuration) return { - /** - * View for users - * @param {UsersApiUsersCreateRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - usersCreate( - requestParameters: UsersApiUsersCreateRequest, - options?: RawAxiosRequestConfig, - ): AxiosPromise { - return localVarFp - .usersCreate(requestParameters.UserRequest, options) - .then((request) => request(axios, basePath)) - }, - /** - * View for users - * @param {UsersApiUsersDestroyRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - usersDestroy( - requestParameters: UsersApiUsersDestroyRequest, - options?: RawAxiosRequestConfig, - ): AxiosPromise { - return localVarFp - .usersDestroy(requestParameters.username, options) - .then((request) => request(axios, basePath)) - }, - /** - * View for users - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - usersList(options?: RawAxiosRequestConfig): AxiosPromise> { - return localVarFp - .usersList(options) - .then((request) => request(axios, basePath)) - }, /** * User retrieve and update viewsets for the current user * @param {*} [options] Override http request option. @@ -11323,34 +11416,6 @@ export const UsersApiFactory = function ( } } -/** - * Request parameters for usersCreate operation in UsersApi. - * @export - * @interface UsersApiUsersCreateRequest - */ -export interface UsersApiUsersCreateRequest { - /** - * - * @type {UserRequest} - * @memberof UsersApiUsersCreate - */ - readonly UserRequest: UserRequest -} - -/** - * Request parameters for usersDestroy operation in UsersApi. - * @export - * @interface UsersApiUsersDestroyRequest - */ -export interface UsersApiUsersDestroyRequest { - /** - * - * @type {string} - * @memberof UsersApiUsersDestroy - */ - readonly username: string -} - /** * Request parameters for usersPartialUpdate operation in UsersApi. * @export @@ -11414,50 +11479,6 @@ export interface UsersApiUsersUpdateRequest { * @extends {BaseAPI} */ export class UsersApi extends BaseAPI { - /** - * View for users - * @param {UsersApiUsersCreateRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof UsersApi - */ - public usersCreate( - requestParameters: UsersApiUsersCreateRequest, - options?: RawAxiosRequestConfig, - ) { - return UsersApiFp(this.configuration) - .usersCreate(requestParameters.UserRequest, options) - .then((request) => request(this.axios, this.basePath)) - } - - /** - * View for users - * @param {UsersApiUsersDestroyRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof UsersApi - */ - public usersDestroy( - requestParameters: UsersApiUsersDestroyRequest, - options?: RawAxiosRequestConfig, - ) { - return UsersApiFp(this.configuration) - .usersDestroy(requestParameters.username, options) - .then((request) => request(this.axios, this.basePath)) - } - - /** - * View for users - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof UsersApi - */ - public usersList(options?: RawAxiosRequestConfig) { - return UsersApiFp(this.configuration) - .usersList(options) - .then((request) => request(this.axios, this.basePath)) - } - /** * User retrieve and update viewsets for the current user * @param {*} [options] Override http request option. diff --git a/frontends/api/src/hooks/profile/index.ts b/frontends/api/src/hooks/profile/index.ts index 5f181e7209..98f5a4c789 100644 --- a/frontends/api/src/hooks/profile/index.ts +++ b/frontends/api/src/hooks/profile/index.ts @@ -1,10 +1,16 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { profilesApi } from "../../clients" import type { Profile, PatchedProfileRequest } from "../../generated/v0/api" +import { userKeys } from "../user/queries" + +const profileKeys = { + root: ["profiles"], + detail: (username: string) => [...profileKeys.root, { username }], +} const useProfileQuery = (username: string) => useQuery({ - queryKey: ["profiles", { username }], + queryKey: profileKeys.detail(username), queryFn: async (): Promise => { const response = await profilesApi.profilesRetrieve({ user__username: username, @@ -23,7 +29,10 @@ const useProfileMutation = (username: string) => { }) }, onSuccess: (response) => { - queryClient.setQueryData(["profiles", { username }], response.data) + queryClient.setQueryData(profileKeys.detail(username), response.data) + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: userKeys.me() }) }, }) } @@ -33,6 +42,7 @@ const useProfileMeQuery = () => useProfileQuery("me") const useProfileMeMutation = () => useProfileMutation("me") export { + profileKeys, useProfileQuery, useProfileMutation, useProfileMeQuery, diff --git a/frontends/main/src/app-pages/DashboardPage/ProfileContent.tsx b/frontends/main/src/app-pages/DashboardPage/ProfileContent.tsx index bf9361aea8..76151a7d74 100644 --- a/frontends/main/src/app-pages/DashboardPage/ProfileContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ProfileContent.tsx @@ -24,7 +24,6 @@ import { DELIVERY_CHOICES, ProfileSchema, } from "@/common/profile" -import { useUserMe } from "api/hooks/user" import { TitleText } from "./HomeContent" const FormContainer = styled.div(({ theme }) => ({ @@ -76,7 +75,6 @@ const ProfileContent: React.FC = () => { profile?.topic_interests?.map((topic) => String(topic.id)) || [], } }, [profile]) - const { data: user } = useUserMe() const { isPending: isSaving, mutateAsync } = useProfileMeMutation() const { data: topics } = useLearningResourceTopics({ is_toplevel: true }) @@ -113,10 +111,10 @@ const ProfileContent: React.FC = () => { str: """Get the user's name""" return obj.name or " ".join( @@ -154,6 +159,8 @@ def update(self, instance, validated_data): for attr, value in validated_data.items(): setattr(instance, attr, value) + sync_to_keycloak(instance, validated_data.keys()) + update_image = "image_file" in validated_data instance.save(update_image=update_image) return instance diff --git a/profiles/urls.py b/profiles/urls.py index e5a18a0425..50d8034a2e 100644 --- a/profiles/urls.py +++ b/profiles/urls.py @@ -4,6 +4,7 @@ from rest_framework.routers import DefaultRouter from profiles.views import ( + CurrentUserProfileViewSet, CurrentUserRetrieveViewSet, ProfileViewSet, ProgramLetterInterceptView, @@ -30,6 +31,13 @@ CurrentUserRetrieveViewSet.as_view({"get": "retrieve"}), name="users_api-me", ), + re_path( + r"^profiles/me/$", + CurrentUserProfileViewSet.as_view( + {"get": "retrieve", "put": "update", "patch": "partial_update"} + ), + name="profiles_api-me", + ), *v0_router.urls, ] diff --git a/profiles/views.py b/profiles/views.py index 177e3137b7..2bbc901225 100644 --- a/profiles/views.py +++ b/profiles/views.py @@ -18,7 +18,6 @@ AnonymousAccessReadonlyPermission, IsStaffPermission, ) -from profiles.api import ensure_profile from profiles.models import Profile, ProgramCertificate, ProgramLetter, UserWebsite from profiles.permissions import HasEditPermission, HasSiteEditPermission from profiles.serializers import ( @@ -34,7 +33,9 @@ ) -class UserViewSet(viewsets.ModelViewSet): +class UserViewSet( + mixins.RetrieveModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet +): """View for users""" permission_classes = (IsAuthenticated, IsStaffPermission) @@ -46,7 +47,9 @@ class UserViewSet(viewsets.ModelViewSet): @method_decorator(ensure_csrf_cookie, name="retrieve") -class CurrentUserRetrieveViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): +class CurrentUserRetrieveViewSet( + mixins.RetrieveModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet +): """User retrieve and update viewsets for the current user""" serializer_class = UserSerializer @@ -73,20 +76,21 @@ class ProfileViewSet( ) lookup_field = "user__username" - def get_object(self): - """Get the profile""" - - if self.kwargs["user__username"] == "me": - ensure_profile(self.request.user) - return self.request.user.profile - else: - return super().get_object() - def get_serializer_context(self): """Get the serializer context""" return {"include_user_websites": True} +class CurrentUserProfileViewSet(ProfileViewSet): + """Profile retrieve and update viewsets for the current user""" + + permission_classes = (IsAuthenticated,) + + def get_object(self): + """Return the current request user""" + return self.request.user.profile + + class UserWebsiteViewSet( mixins.CreateModelMixin, mixins.DestroyModelMixin, viewsets.GenericViewSet ): diff --git a/profiles/views_test.py b/profiles/views_test.py index 1ac1f019c4..e46016989a 100644 --- a/profiles/views_test.py +++ b/profiles/views_test.py @@ -150,16 +150,6 @@ def test_get_profile(logged_in, user, user_client): } -def test_get_profile_automatically_creates_profile(user, user_client): - """Profiles should automatically get created for users without one""" - user.profile.delete() - url = reverse("profile:v0:profile_api-detail", kwargs={"user__username": "me"}) - resp = user_client.get(url) - assert resp.status_code == 200 - user.refresh_from_db() - assert user.profile is not None - - @pytest.mark.parametrize("email", ["", "test.email@example.com"]) @pytest.mark.parametrize("email_optin", [None, True, False]) @pytest.mark.parametrize("toc_optin", [None, True, False]) @@ -211,10 +201,11 @@ def test_patch_username(staff_client, user): assert resp.json()["username"] == user.username -def test_patch_profile_by_user(client, logged_in_profile): +def test_patch_profile_by_user(mocker, client, logged_in_profile): """ Test that users can update their profiles, including profile images """ + mock_sync_to_keycloak = mocker.patch("profiles.serializers.sync_to_keycloak") url = reverse( "profile:v0:profile_api-detail", kwargs={"user__username": logged_in_profile.user.username}, @@ -224,17 +215,24 @@ def test_patch_profile_by_user(client, logged_in_profile): resp = client.patch( url, data={ + "name": "new name", "bio": "updated_bio_value", "location": json.dumps(location_json), }, format="multipart", ) assert resp.status_code == 200 + assert resp.json()["name"] == "new name" assert resp.json()["bio"] == "updated_bio_value" assert resp.json()["placename"] == "Boston" logged_in_profile.refresh_from_db() assert logged_in_profile.location == location_json + assert logged_in_profile.name == "new name" + assert logged_in_profile.bio == "updated_bio_value" + mock_sync_to_keycloak.assert_called_once_with( + logged_in_profile, ["name", "bio", "location"] + ) @pytest.mark.skip_nplusone_check diff --git a/pyproject.toml b/pyproject.toml index f5ad307972..fc40e84e0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ dependencies = [ "markdown>=3.7,<4", "markdown2>=2.4.8,<3", "mitol-django-common>=2026.4.2,<2027", + "mitol-django-keycloak", "mitol-django-scim>=2026.4.2,<2027", "mitol-django-observability>=2026.1.0,<2027", "named-enum>=1.4.0,<2", @@ -156,6 +157,7 @@ override-dependencies = ["setuptools<80"] [tool.uv.sources] django-health-check = { git = "https://github.com/revsys/django-health-check", rev = "53f9bdc3a7acc8a577319987fef0bd3040eef4b4" } # pragma: allowlist secret +mitol-django-keycloak = { git = "https://github.com/mitodl/ol-django", branch = "nl/add-keycloak", subdirectory = "src/keycloak"} [tool.uv.build-backend] module-root = "" diff --git a/uv.lock b/uv.lock index 4758aa42d8..4fdc569f4d 100644 --- a/uv.lock +++ b/uv.lock @@ -5,6 +5,15 @@ requires-python = "==3.12.*" [manifest] overrides = [{ name = "setuptools", specifier = "<80" }] +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -695,6 +704,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, +] + [[package]] name = "dirtyjson" version = "1.0.8" @@ -2544,6 +2565,7 @@ dependencies = [ { name = "markdown" }, { name = "markdown2" }, { name = "mitol-django-common" }, + { name = "mitol-django-keycloak" }, { name = "mitol-django-observability" }, { name = "mitol-django-scim" }, { name = "named-enum" }, @@ -2684,6 +2706,7 @@ requires-dist = [ { name = "markdown", specifier = ">=3.7,<4" }, { name = "markdown2", specifier = ">=2.4.8,<3" }, { name = "mitol-django-common", specifier = ">=2026.4.2,<2027" }, + { name = "mitol-django-keycloak", git = "https://github.com/mitodl/ol-django?subdirectory=src%2Fkeycloak&branch=nl%2Fadd-keycloak" }, { name = "mitol-django-observability", specifier = ">=2026.1.0,<2027" }, { name = "mitol-django-scim", specifier = ">=2026.4.2,<2027" }, { name = "named-enum", specifier = ">=1.4.0,<2" }, @@ -2785,6 +2808,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/f5/f781062585655f35300d73317aa46cfdf9ab8f17f2142e7463e8a0722325/mitol_django_common-2026.4.2-py3-none-any.whl", hash = "sha256:4569bb3118047d2f79658fb71686584d140eaca9152b5f46ca52930fa0cddc8d", size = 32382, upload-time = "2026-04-02T15:46:54.84Z" }, ] +[[package]] +name = "mitol-django-keycloak" +version = "2026.4.29" +source = { git = "https://github.com/mitodl/ol-django?subdirectory=src%2Fkeycloak&branch=nl%2Fadd-keycloak#c3fa6024f3bd1fd6e1f5dcbcf567aca2d0dabfa0" } +dependencies = [ + { name = "django" }, + { name = "pydantic" }, + { name = "python-keycloak" }, +] + [[package]] name = "mitol-django-observability" version = "2026.3.11" @@ -4144,6 +4177,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/bd/ccd7416fdb30f104ddf6cfd8ee9f699441c7d9880a26f9b3089438adee05/python_ipware-3.0.0-py3-none-any.whl", hash = "sha256:fc936e6e7ec9fcc107f9315df40658f468ac72f739482a707181742882e36b60", size = 10761, upload-time = "2024-04-19T20:00:57.171Z" }, ] +[[package]] +name = "python-keycloak" +version = "7.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "deprecation" }, + { name = "httpx" }, + { name = "jwcrypto" }, + { name = "requests" }, + { name = "requests-toolbelt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e3/963aae33e9177b496def0ba47d2290391e9391d9f76a3f69cfde8c44c8b8/python_keycloak-7.1.1.tar.gz", hash = "sha256:38973e54694a656fe6f3f8fc2af0b89c78136863f928261f0d243445e9e486bc", size = 78907, upload-time = "2026-02-15T08:45:58.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/9f/569a8bbdb0859498d33d8b86273bc82849bd0b445ac01416ad34996ca3d8/python_keycloak-7.1.1-py3-none-any.whl", hash = "sha256:d8295bec6c4805ab7335b03bc92753c8c6258d5511b080cac061da20ae77f61c", size = 87607, upload-time = "2026-02-15T08:45:57.054Z" }, +] + [[package]] name = "python-rapidjson" version = "1.23" diff --git a/widgets/serializers/widget_list.py b/widgets/serializers/widget_list.py index 31a4c54303..d9110e7112 100644 --- a/widgets/serializers/widget_list.py +++ b/widgets/serializers/widget_list.py @@ -18,7 +18,7 @@ def _serializer_for_widget_type(widget_type_name): class WidgetListSerializer(serializers.ModelSerializer): """Serializer for WidgetLists""" - widgets = WriteableSerializerMethodField() + widgets = WriteableSerializerMethodField(default=list) available_widgets = serializers.SerializerMethodField() def validate_widgets(self, value): From e430f99efdc92235b93ddf617f3f591ccbeccf3e Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Wed, 20 May 2026 12:16:03 -0400 Subject: [PATCH 2/7] Removed unnecessary tests --- profiles/serializers_test.py | 61 +----------------------------------- 1 file changed, 1 insertion(+), 60 deletions(-) diff --git a/profiles/serializers_test.py b/profiles/serializers_test.py index de9cbc79d0..2e77efb85a 100644 --- a/profiles/serializers_test.py +++ b/profiles/serializers_test.py @@ -8,7 +8,6 @@ from rest_framework.exceptions import ValidationError from learning_resources.factories import LearningResourceTopicFactory -from learning_resources.serializers import LearningResourceTopicSerializer from profiles.factories import UserWebsiteFactory from profiles.models import FACEBOOK_DOMAIN, PERSONAL_SITE_TYPE, Profile from profiles.serializers import ( @@ -16,11 +15,6 @@ UserSerializer, UserWebsiteSerializer, ) -from profiles.utils import ( - IMAGE_MEDIUM, - IMAGE_SMALL, - image_uri, -) small_gif = ( b"\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x00\x00\x00\x21\xf9\x04" @@ -46,59 +40,6 @@ def test_serialize_user(user): } -def test_serialize_create_user(db, mocker): - """ - Test creating a user - """ - profile = { - "email_optin": True, - "toc_optin": True, - "bio": "bio", - "headline": "headline", - "placename": "", - } - - serializer = UserSerializer(data={"email": "test@localhost", "profile": profile}) - serializer.is_valid(raise_exception=True) - user = serializer.save() - - del profile["email_optin"] # is write-only - del profile["toc_optin"] # is write-only - - profile.update( - { - "name": "", - "image": None, - "image_small": None, - "image_medium": None, - "image_file": None, - "image_small_file": None, - "image_medium_file": None, - "profile_image_small": image_uri(user.profile, IMAGE_SMALL), - "profile_image_medium": image_uri(user.profile, IMAGE_MEDIUM), - "username": user.username, - "topic_interests": LearningResourceTopicSerializer( - user.profile.topic_interests, many=True - ).data, - "goals": user.profile.goals, - "current_education": user.profile.current_education, - "certificate_desired": user.profile.certificate_desired, - "time_commitment": user.profile.time_commitment, - "delivery": user.profile.delivery, - } - ) - assert UserSerializer(instance=user).data == { - "id": user.id, - "username": user.username, - "first_name": user.first_name, - "last_name": user.last_name, - "is_learning_path_editor": False, - "is_article_editor": False, - "profile": {**profile, "preference_search_filters": {}}, - "is_authenticated": True, - } - - @pytest.mark.parametrize( ("key", "value"), [ @@ -110,7 +51,7 @@ def test_serialize_create_user(db, mocker): ("toc_optin", False), ], ) -def test_update_user_profile(mocker, user, key, value): +def test_update_user_profile(user, key, value): """ Test updating a profile via the UserSerializer """ From 7f9b56604b301bf40e267da76235317d7c1957f6 Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Wed, 20 May 2026 12:36:26 -0400 Subject: [PATCH 3/7] Fix tests --- profiles/api.py | 2 +- profiles/serializers.py | 2 +- profiles/views_test.py | 60 ----------------------------------------- 3 files changed, 2 insertions(+), 62 deletions(-) diff --git a/profiles/api.py b/profiles/api.py index 951ba83b5b..4635fb6a62 100644 --- a/profiles/api.py +++ b/profiles/api.py @@ -49,7 +49,7 @@ def get_site_type_from_url(url): return PERSONAL_SITE_TYPE -def sync_to_keycloak(profile: Profile, update_fields: list[str]): +def sync_to_keycloak(profile: Profile, update_fields: set[str]): """ Sync a profile to Keycloak """ diff --git a/profiles/serializers.py b/profiles/serializers.py index e4935fb9b0..75b7d57302 100644 --- a/profiles/serializers.py +++ b/profiles/serializers.py @@ -159,7 +159,7 @@ def update(self, instance, validated_data): for attr, value in validated_data.items(): setattr(instance, attr, value) - sync_to_keycloak(instance, validated_data.keys()) + sync_to_keycloak(instance, set(validated_data.keys())) update_image = "image_file" in validated_data instance.save(update_image=update_image) diff --git a/profiles/views_test.py b/profiles/views_test.py index e46016989a..4a0af3764f 100644 --- a/profiles/views_test.py +++ b/profiles/views_test.py @@ -28,66 +28,6 @@ User = get_user_model() -def test_list_users(staff_client, staff_user): - """ - List users - """ - url = reverse("profile:v0:user_api-list") - resp = staff_client.get(url) - assert resp.status_code == 200 - assert resp.json() == [ - { - "id": staff_user.id, - "username": staff_user.username, - "first_name": staff_user.first_name, - "last_name": staff_user.last_name, - "is_learning_path_editor": True, - "is_article_editor": True, - "profile": ProfileSerializer(staff_user.profile).data, - "is_authenticated": True, - } - ] - - -# These can be removed once all clients have been updated and are sending both these fields -@pytest.mark.parametrize("email_optin", [None, True, False]) -@pytest.mark.parametrize("toc_optin", [None, True, False]) -def test_create_user(staff_client, staff_user, email_optin, toc_optin): # pylint: disable=too-many-arguments - """ - Create a user and assert the response - """ - staff_user.email = "" - staff_user.profile.email_optin = None - staff_user.profile.save() - staff_user.save() - url = reverse("profile:v0:user_api-list") - email = "test.email@example.com" - payload = { - "email": email, - "profile": { - "name": "name", - "image": "image", - "image_small": "image_small", - "image_medium": "image_medium", - "bio": "bio", - "headline": "headline", - "placename": "", - }, - } - if email_optin is not None: - payload["profile"]["email_optin"] = email_optin - if toc_optin is not None: - payload["profile"]["toc_optin"] = toc_optin - - resp = staff_client.post(url, data=payload) - user = User.objects.get(username=resp.json()["username"]) - assert resp.status_code == 201 - assert resp.json()["profile"] == ProfileSerializer(user.profile).data - assert user.email == email - assert user.profile.email_optin is email_optin - assert user.profile.toc_optin is toc_optin - - def test_get_user(staff_client, user): """ Get a user From ba604160596f4b765a79ae653a875b291cecc038 Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Wed, 20 May 2026 12:56:37 -0400 Subject: [PATCH 4/7] Last tests --- profiles/api_test.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/profiles/api_test.py b/profiles/api_test.py index 61503ba6ed..b982535a14 100644 --- a/profiles/api_test.py +++ b/profiles/api_test.py @@ -1,11 +1,13 @@ """Profile API tests""" import pytest +from mitol.keycloak.data_models import UserAttributes from main.factories import UserFactory from profiles import api from profiles.api import ( get_site_type_from_url, + sync_to_keycloak, ) from profiles.models import ( FACEBOOK_DOMAIN, @@ -22,7 +24,7 @@ "profile_data", [{"image": "http://localhost:image.jpg"}, {}, None] ) @pytest.mark.parametrize("no_profile", [True, False]) -def test_ensure_profile(mocker, profile_data, no_profile): +def test_ensure_profile(profile_data, no_profile): """Test that it creates a profile from the data""" user = UserFactory.create(email="testuser@example.com", no_profile=no_profile) profile = api.ensure_profile(user, profile_data=profile_data) @@ -50,3 +52,20 @@ def test_ensure_profile(mocker, profile_data, no_profile): def test_get_site_type_from_url(url, exp_site_type): """Test that get_site_type_from_url returns the expected site type for a given URL value""" assert get_site_type_from_url(url) == exp_site_type + + +def test_sync_to_keycloak(mocker): + """Test that syncing to keycloak correctly maps attributes""" + user = UserFactory.create() + mock_api = mocker.patch("profiles.api.keycloak_api") + + sync_to_keycloak(user.profile, ["location"]) + mock_api.update_user.assert_not_called() + + sync_to_keycloak(user.profile, ["name"]) + mock_api.update_user.assert_called_once_with( + user.global_id, + attributes=UserAttributes( + full_name=user.profile.name, + ), + ) From 81ffac2183e7410b37c0077bb13764644d988084 Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Wed, 20 May 2026 14:13:42 -0400 Subject: [PATCH 5/7] Fix test --- profiles/views_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/profiles/views_test.py b/profiles/views_test.py index 4a0af3764f..d8e00ae729 100644 --- a/profiles/views_test.py +++ b/profiles/views_test.py @@ -171,7 +171,7 @@ def test_patch_profile_by_user(mocker, client, logged_in_profile): assert logged_in_profile.name == "new name" assert logged_in_profile.bio == "updated_bio_value" mock_sync_to_keycloak.assert_called_once_with( - logged_in_profile, ["name", "bio", "location"] + logged_in_profile, {"name", "bio", "location"} ) From 9562c7325b2f3a9ac6ff2b4facf6094241773f41 Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Wed, 20 May 2026 15:23:35 -0400 Subject: [PATCH 6/7] Fix --- main/middleware/apisix_user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/middleware/apisix_user.py b/main/middleware/apisix_user.py index a34e3a4fbc..684591174f 100644 --- a/main/middleware/apisix_user.py +++ b/main/middleware/apisix_user.py @@ -86,7 +86,7 @@ def get_user_from_apisix_headers(request, decoded_headers, original_header): user, created = User.objects.filter( Q(global_id=global_id) | Q(global_id__isnull=True, email=email) - ).get_or_create( + ).update_or_create( defaults={ "global_id": global_id, "email": email, From 7b983e58897a6a3864bb942a71249c0b2ad58a79 Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Wed, 20 May 2026 16:05:43 -0400 Subject: [PATCH 7/7] Fix --- main/middleware/apisix_user_test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/main/middleware/apisix_user_test.py b/main/middleware/apisix_user_test.py index 8fe8d7455d..5ecf0204b4 100644 --- a/main/middleware/apisix_user_test.py +++ b/main/middleware/apisix_user_test.py @@ -106,7 +106,7 @@ def test_get_request_existing_user_no_globalid(mocker, mock_login): @pytest.mark.django_db(transaction=True) def test_get_request_existing_user_with_global_id_diff_email(mocker, mock_login): - """Test that a valid request doesn't update user data of user with same global_id""" + """Test that a valid request updates user but not profile data of user with same global_id""" close_old_connections() user = UserFactory.create( email="old_email@test.edu", global_id=apisix_user_info["sub"] @@ -120,9 +120,9 @@ def test_get_request_existing_user_with_global_id_diff_email(mocker, mock_login) apisix_middleware = ApisixUserMiddleware(mocker.Mock()) apisix_middleware.process_request(mock_request) updated_user = User.objects.get(id=user.id) - assert updated_user.username == user.username - assert updated_user.global_id == user.global_id - assert updated_user.email == user.email + assert updated_user.username == apisix_user_info["preferred_username"] + assert updated_user.global_id == apisix_user_info["sub"] + assert updated_user.email == apisix_user_info["email"] assert updated_user.profile.name == user.profile.name