From b14030b4770e977ad5eab4001b5e9900ab49fef0 Mon Sep 17 00:00:00 2001
From: David Paul Graham <43794491+dpgraham4401@users.noreply.github.com>
Date: Fri, 30 Aug 2024 23:23:58 -0400
Subject: [PATCH] Avatar feature (#782)
* add type hints for model using foreign keys to settings.AUTH_USER_MODEL to use
django.contrib.auth.models.User
* update settings and urlconfig to serve media in development
* add Pillow dependency, and add ImageField to Profile model
* move profile serializers from package to module
* add 'media/' to path prefix for traefik reverse proxy
* add media directory to gitignore
* add avatar image to profile serializer
* apply user avatar to top navigation bar
* add TrakUserSerializer to ProfileSerializer so the users details are inlcuded with the profile views
* initial AvatarForm (no tests, saving work to stop) which will separte it from the UserForm and allow us to have the user upload photos
* change expected type for ProfileSlice to inlcude use information
* move avatar to separate component, outside the user info form
* somehow I needed to add the eslint.config.js file back... IDK what's going on here
* rename profile endpoint to my-profile endpoint, we will reimplement profile endpoint, however this is an easy way to retrieve the current logged in user's profile info
* add profile model viewset
* add Form to component library
* add updateProfile endpoint to RTK query
* avatar form uses the icon as a button and updates the state of the avatar on success request
* avatar form submits the file on change instead of having to click a submit button
* fix avatar form spec file
---
.../GeneralInfo/ManifestStatusSelect.spec.tsx | 10 +-
.../Handler/Search/HandlerSearchForm.spec.tsx | 8 +-
client/app/components/Org/UserOrg.spec.tsx | 7 +-
.../app/components/User/UserInfoForm.spec.tsx | 9 +-
client/app/components/User/UserInfoForm.tsx | 33 +---
client/app/components/ui/Form/form.spec.tsx | 103 +++++++++++
client/app/components/ui/Form/form.tsx | 167 ++++++++++++++++++
client/app/components/ui/Form/label.spec.tsx | 22 +++
client/app/components/ui/Form/label.tsx | 19 ++
client/app/components/ui/Input/input.spec.tsx | 36 ++++
client/app/components/ui/Input/input.tsx | 24 +++
client/app/components/ui/index.ts | 15 ++
client/app/features/layout/TopNav/TopNav.tsx | 15 +-
client/app/features/profile/Profile.tsx | 2 +
.../profile/components/AvatarForm.spec.tsx | 11 ++
.../profile/components/AvatarForm.tsx | 64 +++++++
.../useUserSiteIds/useUserSiteIds.spec.tsx | 6 +-
.../app/mocks/handlers/mockUserEndpoints.ts | 2 +-
client/app/services/manifest/manifest.spec.ts | 5 +-
client/app/store/index.ts | 1 +
client/app/store/userApi/userApi.ts | 13 +-
client/eslint.config.js | 2 -
client/package-lock.json | 45 ++++-
client/package.json | 5 +-
compose.yaml | 2 +-
server/.gitignore | 1 +
server/haztrak/settings/base.py | 4 +-
server/haztrak/urls.py | 5 +
server/manifest/tests/test_views.py | 2 +-
server/org/models.py | 12 +-
server/org/services.py | 10 +-
.../profile/migrations/0003_profile_avatar.py | 17 ++
server/profile/models.py | 18 +-
.../rcrasite_access.py => serializers.py} | 91 +++++++---
server/profile/serializers/__init__.py | 3 -
server/profile/serializers/profile.py | 18 --
.../profile/serializers/rcrainfo_profile.py | 57 ------
server/profile/services.py | 13 +-
server/profile/tests/test_serializers.py | 2 +-
server/profile/tests/test_views.py | 66 ++++++-
server/profile/urls.py | 8 +-
server/profile/views.py | 16 ++
server/requirements.txt | 1 +
43 files changed, 764 insertions(+), 206 deletions(-)
create mode 100644 client/app/components/ui/Form/form.spec.tsx
create mode 100644 client/app/components/ui/Form/form.tsx
create mode 100644 client/app/components/ui/Form/label.spec.tsx
create mode 100644 client/app/components/ui/Form/label.tsx
create mode 100644 client/app/components/ui/Input/input.spec.tsx
create mode 100644 client/app/components/ui/Input/input.tsx
create mode 100644 client/app/features/profile/components/AvatarForm.spec.tsx
create mode 100644 client/app/features/profile/components/AvatarForm.tsx
create mode 100644 server/profile/migrations/0003_profile_avatar.py
rename server/profile/{serializers/rcrasite_access.py => serializers.py} (68%)
delete mode 100644 server/profile/serializers/__init__.py
delete mode 100644 server/profile/serializers/profile.py
delete mode 100644 server/profile/serializers/rcrainfo_profile.py
diff --git a/client/app/components/Manifest/GeneralInfo/ManifestStatusSelect.spec.tsx b/client/app/components/Manifest/GeneralInfo/ManifestStatusSelect.spec.tsx
index 163e0100..82b0e44f 100644
--- a/client/app/components/Manifest/GeneralInfo/ManifestStatusSelect.spec.tsx
+++ b/client/app/components/Manifest/GeneralInfo/ManifestStatusSelect.spec.tsx
@@ -1,14 +1,14 @@
import userEvent from '@testing-library/user-event';
-import { ManifestStatusSelect } from '~/components/Manifest/GeneralInfo/ManifestStatusSelect';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
+import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
+import { ManifestStatusSelect } from '~/components/Manifest/GeneralInfo/ManifestStatusSelect';
import { cleanup, renderWithProviders, screen } from '~/mocks';
import { createMockHandler, createMockSite } from '~/mocks/fixtures';
import { createMockProfileResponse } from '~/mocks/fixtures/mockUser';
import { mockUserEndpoints } from '~/mocks/handlers';
import { API_BASE_URL } from '~/mocks/handlers/mockSiteEndpoints';
-import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
const server = setupServer(...mockUserEndpoints);
afterEach(() => cleanup());
@@ -58,7 +58,7 @@ describe('Manifest Status Field', () => {
}),
});
server.use(
- http.get(`${API_BASE_URL}/api/profile`, () => {
+ http.get(`${API_BASE_URL}/api/my-profile`, () => {
return HttpResponse.json(
{
...createMockProfileResponse({
@@ -96,7 +96,7 @@ describe('Manifest Status Field', () => {
}),
});
server.use(
- http.get(`${API_BASE_URL}/api/profile`, () => {
+ http.get(`${API_BASE_URL}/api/my-profile`, () => {
return HttpResponse.json(
{
...createMockProfileResponse({
@@ -130,7 +130,7 @@ describe('Manifest Status Field', () => {
}),
});
server.use(
- http.get(`${API_BASE_URL}/api/profile`, () => {
+ http.get(`${API_BASE_URL}/api/my-profile`, () => {
return HttpResponse.json(
{
...createMockProfileResponse({
diff --git a/client/app/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx b/client/app/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx
index 05c4044d..5928f810 100644
--- a/client/app/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx
+++ b/client/app/components/Manifest/Handler/Search/HandlerSearchForm.spec.tsx
@@ -1,11 +1,11 @@
import userEvent from '@testing-library/user-event';
-import { cleanup, renderWithProviders, screen } from '~/mocks';
-import { mockUserEndpoints } from '~/mocks/handlers';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
+import { cleanup, renderWithProviders, screen } from '~/mocks';
import { createMockRcrainfoSite } from '~/mocks/fixtures';
+import { mockUserEndpoints } from '~/mocks/handlers';
import { API_BASE_URL } from '~/mocks/handlers/mockSiteEndpoints';
import { HaztrakProfileResponse } from '~/store/userApi/userApi';
import { HandlerSearchForm } from './HandlerSearchForm';
@@ -38,7 +38,7 @@ export const mockHandlerSearches = [
http.get(`${API_BASE_URL}/api/rcrainfo/rcrasite/search`, () => {
return HttpResponse.json([mockRcrainfoSite1, mockRcrainfoSite2], { status: 200 });
}),
- http.get(`${API_BASE_URL}/api/profile`, () => {
+ http.get(`${API_BASE_URL}/api/my-profile`, () => {
return HttpResponse.json({ ...mockProfile }, { status: 200 });
}),
http.post(`${API_BASE_URL}/api/rcrainfo/rcrasite/search`, () => {
@@ -74,7 +74,7 @@ describe('HandlerSearchForm', () => {
});
test('retrieves rcra sites from haztrak if org not rcrainfo integrated', async () => {
server.use(
- http.get(`${API_BASE_URL}/api/profile`, () => {
+ http.get(`${API_BASE_URL}/api/my-profile`, () => {
return HttpResponse.json(
{
...mockProfile,
diff --git a/client/app/components/Org/UserOrg.spec.tsx b/client/app/components/Org/UserOrg.spec.tsx
index 2d4cc489..ef5ef88f 100644
--- a/client/app/components/Org/UserOrg.spec.tsx
+++ b/client/app/components/Org/UserOrg.spec.tsx
@@ -4,6 +4,7 @@ import { ProfileSlice } from '~/store';
import { v4 as uuidv4 } from 'uuid';
import { describe, expect, it } from 'vitest';
import { renderWithProviders } from '~/mocks';
+import { createMockHaztrakUser } from '~/mocks/fixtures';
const mockProfileWithOrg: ProfileSlice = {
org: {
@@ -13,13 +14,13 @@ const mockProfileWithOrg: ProfileSlice = {
id: uuidv4(),
},
sites: {},
- user: 'testuser1',
+ user: createMockHaztrakUser(),
};
const mockProfileWithoutOrg: ProfileSlice = {
org: null,
sites: {},
- user: 'testuser1',
+ user: createMockHaztrakUser(),
};
const mockProfileWithNonIntegratedOrg: ProfileSlice = {
@@ -30,7 +31,7 @@ const mockProfileWithNonIntegratedOrg: ProfileSlice = {
id: uuidv4(),
},
sites: {},
- user: 'testuser1',
+ user: createMockHaztrakUser(),
};
describe('UserOrg Component', () => {
diff --git a/client/app/components/User/UserInfoForm.spec.tsx b/client/app/components/User/UserInfoForm.spec.tsx
index def5ca12..35813c04 100644
--- a/client/app/components/User/UserInfoForm.spec.tsx
+++ b/client/app/components/User/UserInfoForm.spec.tsx
@@ -25,18 +25,17 @@ describe('UserProfile', () => {
const myUsername = 'myUsername';
const user: HaztrakUser = createMockHaztrakUser({ username: myUsername, firstName: 'David' });
const profile: ProfileSlice = {
- user: myUsername,
+ user,
};
- renderWithProviders(, {});
- expect(screen.getByRole('textbox', { name: 'First Name' })).toHaveValue(user.firstName);
- expect(screen.getByText(user.username)).toBeInTheDocument();
+ const { container } = renderWithProviders(, {});
+ expect(container).toBeInTheDocument();
});
test('update profile fields', async () => {
// Arrange
const newEmail = 'newMockEmail@mail.com';
const user: HaztrakUser = createMockHaztrakUser({ email: 'oldEmail@gmail.com' });
const profile: ProfileSlice = {
- user: 'test',
+ user,
};
renderWithProviders(, {});
const editButton = screen.getByRole('button', { name: 'Edit' });
diff --git a/client/app/components/User/UserInfoForm.tsx b/client/app/components/User/UserInfoForm.tsx
index 2e492770..b4ab1fb1 100644
--- a/client/app/components/User/UserInfoForm.tsx
+++ b/client/app/components/User/UserInfoForm.tsx
@@ -1,12 +1,11 @@
import { zodResolver } from '@hookform/resolvers/zod';
-import React, { createRef, useState } from 'react';
+import React, { useState } from 'react';
import { Button, Col, Form, Row } from 'react-bootstrap';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { HtForm } from '~/components/legacyUi';
-import { Avatar, AvatarFallback, AvatarImage, Spinner } from '~/components/ui';
+import { Spinner } from '~/components/ui';
import { HaztrakUser, ProfileSlice, useUpdateUserMutation } from '~/store';
-import { FaUser } from 'react-icons/fa';
interface UserProfileProps {
user: HaztrakUser;
@@ -24,7 +23,6 @@ type HaztrakUserForm = z.infer;
export function UserInfoForm({ user }: UserProfileProps) {
const [editable, setEditable] = useState(false);
const [updateUser] = useUpdateUserMutation();
- const fileRef = createRef();
const {
register,
@@ -42,33 +40,6 @@ export function UserInfoForm({ user }: UserProfileProps) {
return (
-
-
-
-
-
-
-
-
-
{user.username}
-
-
-
-
diff --git a/client/app/components/ui/Form/form.spec.tsx b/client/app/components/ui/Form/form.spec.tsx
new file mode 100644
index 00000000..748492fc
--- /dev/null
+++ b/client/app/components/ui/Form/form.spec.tsx
@@ -0,0 +1,103 @@
+import { screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { renderWithProviders } from '~/mocks';
+import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from './form';
+
+describe('Form components', () => {
+ const errorProps = {
+ useFormProps: {
+ errors: {
+ username: {
+ type: 'manual',
+ message: 'Username is required',
+ },
+ },
+ },
+ };
+ it('renders FormField with all subcomponents', () => {
+ renderWithProviders(
+ (
+
+ Username
+
+
+
+ This is your public display name.
+
+
+ )}
+ />
+ );
+
+ expect(screen.getByText('Username')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('shadcn')).toBeInTheDocument();
+ expect(screen.getByText('This is your public display name.')).toBeInTheDocument();
+ });
+
+ it('displays error message in FormMessage when there is an error', () => {
+ // form.setError('username', { type: 'manual', message: 'Username is required' });
+
+ renderWithProviders(
+ (
+
+ Username
+
+
+
+ This is your public display name.
+
+
+ )}
+ />,
+ { ...errorProps }
+ );
+
+ expect(screen.getByText('Username is required')).toBeInTheDocument();
+ });
+
+ it('does not render FormMessage when there is no error', () => {
+ renderWithProviders(
+ (
+
+ Username
+
+
+
+ This is your public display name.
+
+
+ )}
+ />
+ );
+ expect(screen.queryByText('Username is required')).not.toBeInTheDocument();
+ });
+
+ it('sets aria-invalid attribute on FormControl when there is an error', () => {
+ // form.setError('username', { type: 'manual', message: 'Username is required' });
+
+ renderWithProviders(
+ (
+
+ Username
+
+
+
+ This is your public display name.
+
+
+ )}
+ />,
+ { ...errorProps }
+ );
+
+ expect(screen.getByPlaceholderText('shadcn')).toHaveAttribute('aria-invalid', 'true');
+ });
+});
diff --git a/client/app/components/ui/Form/form.tsx b/client/app/components/ui/Form/form.tsx
new file mode 100644
index 00000000..7de71620
--- /dev/null
+++ b/client/app/components/ui/Form/form.tsx
@@ -0,0 +1,167 @@
+import * as React from 'react';
+import * as LabelPrimitive from '@radix-ui/react-label';
+import { Slot } from '@radix-ui/react-slot';
+import {
+ Controller,
+ ControllerProps,
+ FieldPath,
+ FieldValues,
+ FormProvider,
+ useFormContext,
+} from 'react-hook-form';
+
+import { cn } from '~/lib/utils';
+import { Label } from './label';
+
+const Form = FormProvider;
+
+interface FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> {
+ name: TName;
+}
+
+const FormFieldContext = React.createContext({} as FormFieldContextValue);
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ );
+};
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext);
+ const itemContext = React.useContext(FormItemContext);
+ const { getFieldState, formState } = useFormContext();
+
+ const fieldState = getFieldState(fieldContext.name, formState);
+
+ if (!fieldContext) {
+ throw new Error('useFormField should be used within ');
+ }
+
+ const { id } = itemContext;
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ };
+};
+
+interface FormItemContextValue {
+ id: string;
+}
+
+const FormItemContext = React.createContext({} as FormItemContextValue);
+
+const FormItem = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ const id = React.useId();
+
+ return (
+
+
+
+ );
+ }
+);
+FormItem.displayName = 'FormItem';
+
+const FormLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField();
+
+ return (
+
+ );
+});
+FormLabel.displayName = 'FormLabel';
+
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
+
+ return (
+
+ );
+});
+FormControl.displayName = 'FormControl';
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField();
+
+ return (
+
+ );
+});
+FormDescription.displayName = 'FormDescription';
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField();
+ const body = error ? String(error?.message) : children;
+
+ if (!body) {
+ return null;
+ }
+
+ return (
+
+ {body}
+
+ );
+});
+FormMessage.displayName = 'FormMessage';
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+};
diff --git a/client/app/components/ui/Form/label.spec.tsx b/client/app/components/ui/Form/label.spec.tsx
new file mode 100644
index 00000000..a45d63a7
--- /dev/null
+++ b/client/app/components/ui/Form/label.spec.tsx
@@ -0,0 +1,22 @@
+import { render } from '@testing-library/react';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+import { Label } from './label';
+
+describe('Label component', () => {
+ it('renders correctly with default props', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('tw-text-sm tw-font-medium tw-leading-none');
+ });
+
+ it('applies additional class names', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('extra-class');
+ });
+
+ it('forwards ref to the root element', () => {
+ const ref = React.createRef();
+ render();
+ expect(ref.current).not.toBeNull();
+ });
+});
diff --git a/client/app/components/ui/Form/label.tsx b/client/app/components/ui/Form/label.tsx
new file mode 100644
index 00000000..7f4cdd0a
--- /dev/null
+++ b/client/app/components/ui/Form/label.tsx
@@ -0,0 +1,19 @@
+import * as React from 'react';
+import * as LabelPrimitive from '@radix-ui/react-label';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from '~/lib/utils';
+
+const labelVariants = cva(
+ 'tw-text-sm tw-font-medium tw-leading-none peer-disabled:tw-cursor-not-allowed peer-disabled:tw-opacity-70'
+);
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & VariantProps
+>(({ className, ...props }, ref) => (
+
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+
+export { Label };
diff --git a/client/app/components/ui/Input/input.spec.tsx b/client/app/components/ui/Input/input.spec.tsx
new file mode 100644
index 00000000..3519b1bc
--- /dev/null
+++ b/client/app/components/ui/Input/input.spec.tsx
@@ -0,0 +1,36 @@
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+import { Input } from './input';
+
+describe('Input component', () => {
+ it('renders correctly with default props', () => {
+ render();
+ const inputElement = screen.getByRole('textbox');
+ expect(inputElement).toBeInTheDocument();
+ });
+
+ it('applies custom className', () => {
+ render();
+ const inputElement = screen.getByRole('textbox');
+ expect(inputElement).toHaveClass('custom-class');
+ });
+
+ it('forwards ref to the input element', () => {
+ const ref = React.createRef();
+ render();
+ expect(ref.current).toBeInstanceOf(HTMLInputElement);
+ });
+
+ it('disables the input when disabled prop is passed', () => {
+ render();
+ const inputElement = screen.getByRole('textbox');
+ expect(inputElement).toBeDisabled();
+ });
+
+ it('renders with additional props', () => {
+ render();
+ const inputElement = screen.getByPlaceholderText('Enter text');
+ expect(inputElement).toBeInTheDocument();
+ });
+});
diff --git a/client/app/components/ui/Input/input.tsx b/client/app/components/ui/Input/input.tsx
new file mode 100644
index 00000000..93f746b9
--- /dev/null
+++ b/client/app/components/ui/Input/input.tsx
@@ -0,0 +1,24 @@
+import * as React from 'react';
+
+import { cn } from '~/lib/utils';
+
+export interface InputProps extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Input.displayName = 'Input';
+
+export { Input };
diff --git a/client/app/components/ui/index.ts b/client/app/components/ui/index.ts
index 466d9c3a..a5634791 100644
--- a/client/app/components/ui/index.ts
+++ b/client/app/components/ui/index.ts
@@ -27,3 +27,18 @@ export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
export { Spinner } from './Spinner/Spinner';
export { Avatar, AvatarImage, AvatarFallback } from './Avatar/Avatar';
+
+export {
+ Form,
+ FormControl,
+ FormItem,
+ FormDescription,
+ FormField,
+ useFormField,
+ FormLabel,
+ FormMessage,
+} from './Form/form';
+
+export { Label } from './Form/label';
+
+export { Input } from './Input/input';
diff --git a/client/app/features/layout/TopNav/TopNav.tsx b/client/app/features/layout/TopNav/TopNav.tsx
index 7381d705..e1c21609 100644
--- a/client/app/features/layout/TopNav/TopNav.tsx
+++ b/client/app/features/layout/TopNav/TopNav.tsx
@@ -3,11 +3,10 @@ import { DropdownMenuSeparator } from '@radix-ui/react-dropdown-menu';
import { LogOut } from 'lucide-react';
import React, { useContext } from 'react';
import { LuMenu, LuUser } from 'react-icons/lu';
-import { RiArrowDropDownFill } from 'react-icons/ri';
import { TbBinaryTree } from 'react-icons/tb';
import { Link } from 'react-router-dom';
import { OrgSelect } from '~/components/Org/OrgSelect';
-import { Button } from '~/components/ui';
+import { Avatar, AvatarFallback, AvatarImage, Button } from '~/components/ui';
import {
DropdownMenu,
DropdownMenuContent,
@@ -16,11 +15,13 @@ import {
} from '~/components/ui/DropDown/dropdown-menu';
import { NavContext, NavContextProps } from '~/features/layout/Root';
-import { useLogoutMutation } from '~/store';
+import { useGetProfileQuery, useLogoutMutation } from '~/store';
+import { FaUser } from 'react-icons/fa';
export function TopNav() {
const { showSidebar, setShowSidebar } = useContext(NavContext);
const [logout] = useLogoutMutation();
+ const { data: profile } = useGetProfileQuery();
const handleLogout = () => {
logout();
@@ -69,8 +70,12 @@ export function TopNav() {
variant="outline"
className="tw-border-0 tw-text-white hover:tw-bg-transparent hover:tw-text-accent"
>
-
-
+
+
+
+
+
+
diff --git a/client/app/features/profile/Profile.tsx b/client/app/features/profile/Profile.tsx
index f375a3ae..f61b9859 100644
--- a/client/app/features/profile/Profile.tsx
+++ b/client/app/features/profile/Profile.tsx
@@ -4,6 +4,7 @@ import { HtCard } from '~/components/legacyUi';
import { RcraProfile } from '~/components/RcraProfile';
import { Spinner } from '~/components/ui';
import { UserInfoForm } from '~/components/User';
+import { AvatarForm } from '~/features/profile/components/AvatarForm';
import { useTitle } from '~/hooks';
import { useGetProfileQuery, useGetRcrainfoProfileQuery, useGetUserQuery } from '~/store';
@@ -35,6 +36,7 @@ export function Profile(): ReactElement {
+
diff --git a/client/app/features/profile/components/AvatarForm.spec.tsx b/client/app/features/profile/components/AvatarForm.spec.tsx
new file mode 100644
index 00000000..046c00ee
--- /dev/null
+++ b/client/app/features/profile/components/AvatarForm.spec.tsx
@@ -0,0 +1,11 @@
+import { screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { renderWithProviders } from '~/mocks';
+import { AvatarForm } from './AvatarForm';
+
+describe('AvatarForm Component', () => {
+ it('avatar image is a button', () => {
+ renderWithProviders();
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+});
diff --git a/client/app/features/profile/components/AvatarForm.tsx b/client/app/features/profile/components/AvatarForm.tsx
new file mode 100644
index 00000000..663f8c78
--- /dev/null
+++ b/client/app/features/profile/components/AvatarForm.tsx
@@ -0,0 +1,64 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { useForm } from 'react-hook-form';
+
+import { FaUser } from 'react-icons/fa';
+import { Avatar, AvatarFallback, AvatarImage, Input } from '~/components/ui';
+import { useAuth } from '~/hooks';
+import { htApi } from '~/services';
+
+interface AvatarFormProps {
+ avatar?: string;
+}
+
+export function AvatarForm({ avatar }: AvatarFormProps) {
+ const [preview, setPreview] = useState(avatar);
+ const inputRef = useRef(null);
+ const { register, handleSubmit, watch } = useForm({ mode: 'onChange' });
+ const { user } = useAuth();
+
+ const onSubmit = (data: any) => {
+ const formData = new FormData();
+ formData.append('avatar', data.avatar[0]);
+ htApi
+ .patch(`/profile/${user?.id}`, formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ })
+ .then((res) => setPreview(res.data.avatar))
+ .catch((err) => console.log(err));
+ };
+
+ const registerProps = register('avatar', {
+ required: true,
+ });
+
+ // When the avatar is changed, submit the form
+ useEffect(() => {
+ watch((data) => {
+ onSubmit(data);
+ });
+ }, [watch]);
+
+ return (
+
+ );
+}
diff --git a/client/app/hooks/useUserSiteIds/useUserSiteIds.spec.tsx b/client/app/hooks/useUserSiteIds/useUserSiteIds.spec.tsx
index f1eb6b2a..576f0407 100644
--- a/client/app/hooks/useUserSiteIds/useUserSiteIds.spec.tsx
+++ b/client/app/hooks/useUserSiteIds/useUserSiteIds.spec.tsx
@@ -1,13 +1,13 @@
import { cleanup, waitFor } from '@testing-library/react';
-import { renderWithProviders, screen } from '~/mocks';
-import { mockUserEndpoints, mockWasteEndpoints } from '~/mocks/handlers';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { useUserSiteIds } from '~/hooks';
+import { renderWithProviders, screen } from '~/mocks';
import { createMockHandler, createMockSite } from '~/mocks/fixtures';
import { createMockProfileResponse } from '~/mocks/fixtures/mockUser';
+import { mockUserEndpoints, mockWasteEndpoints } from '~/mocks/handlers';
import { API_BASE_URL } from '~/mocks/handlers/mockSiteEndpoints';
function TestComponent() {
@@ -45,7 +45,7 @@ describe('useUserSiteId hook', () => {
}),
});
server.use(
- http.get(`${API_BASE_URL}/api/profile`, () => {
+ http.get(`${API_BASE_URL}/api/my-profile`, () => {
return HttpResponse.json(
{
...createMockProfileResponse({
diff --git a/client/app/mocks/handlers/mockUserEndpoints.ts b/client/app/mocks/handlers/mockUserEndpoints.ts
index f0021a74..95b652a6 100644
--- a/client/app/mocks/handlers/mockUserEndpoints.ts
+++ b/client/app/mocks/handlers/mockUserEndpoints.ts
@@ -21,7 +21,7 @@ export const mockUserEndpoints = [
return HttpResponse.json({ ...user, ...info.request.body }, { status: 200 });
}),
/** GET Profile */
- http.get(`${API_BASE_URL}/api/profile`, () => {
+ http.get(`${API_BASE_URL}/api/my-profile`, () => {
return HttpResponse.json({ ...createMockProfileResponse() }, { status: 200 });
}),
/** Login */
diff --git a/client/app/services/manifest/manifest.spec.ts b/client/app/services/manifest/manifest.spec.ts
index 08c7b956..cbc97b87 100644
--- a/client/app/services/manifest/manifest.spec.ts
+++ b/client/app/services/manifest/manifest.spec.ts
@@ -1,5 +1,6 @@
import { manifest } from '~/services/manifest/manifest';
import {
+ createMockHaztrakUser,
createMockManifest,
createMockMTNHandler,
createMockSite,
@@ -84,7 +85,7 @@ describe('manifest.getNextSigner', () => {
});
const mockHaztrakSite = createMockSite();
const profile: ProfileSlice = {
- user: 'testuser1',
+ user: createMockHaztrakUser({ id: 'testuser1' }),
sites: {
[mockHaztrakSite.handler.epaSiteId]: {
...mockHaztrakSite,
@@ -98,7 +99,7 @@ describe('manifest.getNextSigner', () => {
test('getStatusOptions includes Scheduled if user has access to the TSDF', () => {
const mockHaztrakSite = createMockSite();
const profile: ProfileSlice = {
- user: 'testuser1',
+ user: createMockHaztrakUser({ id: 'testuser1' }),
sites: {
[mockHaztrakSite.handler.epaSiteId]: {
...mockHaztrakSite,
diff --git a/client/app/store/index.ts b/client/app/store/index.ts
index 3350e0d1..de2a5be1 100644
--- a/client/app/store/index.ts
+++ b/client/app/store/index.ts
@@ -34,6 +34,7 @@ export const {
useLogoutMutation,
useGetUserQuery,
useGetProfileQuery,
+ useUpdateProfileMutation,
useGetRcrainfoProfileQuery,
useUpdateUserMutation,
useUpdateRcrainfoProfileMutation,
diff --git a/client/app/store/userApi/userApi.ts b/client/app/store/userApi/userApi.ts
index 61667c78..8fb9f89d 100644
--- a/client/app/store/userApi/userApi.ts
+++ b/client/app/store/userApi/userApi.ts
@@ -4,10 +4,11 @@ import { haztrakApi, TaskResponse } from '~/store/htApi.slice';
/**The user's RCRAInfo account data stored in the Redux store*/
export interface ProfileSlice {
- user: string | undefined;
+ user: HaztrakUser;
rcrainfoProfile?: RcrainfoProfile>;
sites?: Record;
org?: Organization | null;
+ avatar?: string;
}
export interface Organization {
@@ -115,10 +116,18 @@ export const userApi = haztrakApi.injectEndpoints({
}),
invalidatesTags: ['user'],
}),
+ updateProfile: build.mutation }>({
+ query: ({ id, profile }) => ({
+ url: `profile/${id}`,
+ method: 'PATCH',
+ data: profile,
+ }),
+ invalidatesTags: ['profile'],
+ }),
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
getProfile: build.query({
query: () => ({
- url: 'profile',
+ url: 'my-profile',
method: 'GET',
}),
providesTags: ['profile'],
diff --git a/client/eslint.config.js b/client/eslint.config.js
index 6d4c6b38..fb1a914f 100644
--- a/client/eslint.config.js
+++ b/client/eslint.config.js
@@ -7,10 +7,8 @@ import tailwind from 'eslint-plugin-tailwindcss';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
export default [
- // ToDo: eslint-plugin-react-hooks does not yet support eslint > 9 and this config
pluginJs.configs.recommended,
jsxA11y.flatConfigs.recommended,
- // ...tseslint.configs.recommended, // recommended config is overridden by strict/stylistic
...tsEslint.configs.strict,
...tsEslint.configs.stylistic,
eslintPluginPrettierRecommended,
diff --git a/client/package-lock.json b/client/package-lock.json
index 7aad4315..3c58adeb 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -10,10 +10,11 @@
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@hookform/error-message": "^2.0.1",
- "@hookform/resolvers": "^3.7.0",
+ "@hookform/resolvers": "^3.9.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
+ "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@reduxjs/toolkit": "^2.2.7",
@@ -27,7 +28,7 @@
"react": "^18.3.1",
"react-bootstrap": "^2.10.4",
"react-dom": "^18.3.1",
- "react-hook-form": "^7.52.1",
+ "react-hook-form": "^7.53.0",
"react-icons": "^5.3.0",
"react-redux": "^9.1.2",
"react-router-dom": "^6.26.1",
@@ -1211,9 +1212,10 @@
}
},
"node_modules/@hookform/resolvers": {
- "version": "3.7.0",
- "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.7.0.tgz",
- "integrity": "sha512-42p5X18noBV3xqOpTlf2V5qJZwzNgO4eLzHzmKGh/w7z4+4XqRw5AsESVkqE+qwAuRRlg2QG12EVEjPkrRIbeg==",
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.0.tgz",
+ "integrity": "sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==",
+ "license": "MIT",
"peerDependencies": {
"react-hook-form": "^7.0.0"
}
@@ -1822,6 +1824,29 @@
}
}
},
+ "node_modules/@radix-ui/react-label": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.0.tgz",
+ "integrity": "sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.0.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-menu": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.1.tgz",
@@ -7728,11 +7753,12 @@
}
},
"node_modules/react-hook-form": {
- "version": "7.52.1",
- "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.52.1.tgz",
- "integrity": "sha512-uNKIhaoICJ5KQALYZ4TOaOLElyM+xipord+Ha3crEFhTntdLvWZqVY49Wqd/0GiVCA/f9NjemLeiNPjG7Hpurg==",
+ "version": "7.53.0",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.0.tgz",
+ "integrity": "sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==",
+ "license": "MIT",
"engines": {
- "node": ">=12.22.0"
+ "node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
@@ -10585,6 +10611,7 @@
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
+ "license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/client/package.json b/client/package.json
index 87e1788f..25b5bc4c 100644
--- a/client/package.json
+++ b/client/package.json
@@ -19,10 +19,11 @@
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@hookform/error-message": "^2.0.1",
- "@hookform/resolvers": "^3.7.0",
+ "@hookform/resolvers": "^3.9.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
+ "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@reduxjs/toolkit": "^2.2.7",
@@ -36,7 +37,7 @@
"react": "^18.3.1",
"react-bootstrap": "^2.10.4",
"react-dom": "^18.3.1",
- "react-hook-form": "^7.52.1",
+ "react-hook-form": "^7.53.0",
"react-icons": "^5.3.0",
"react-redux": "^9.1.2",
"react-router-dom": "^6.26.1",
diff --git a/compose.yaml b/compose.yaml
index c493c60a..c99b6a1d 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -30,7 +30,7 @@ services:
labels:
- "traefik.enable=true"
- "traefik.http.routers.trak.rule=Host(`${HT_HOST}`)"
- - "traefik.http.routers.trak.rule=PathPrefix(`/api`) || PathPrefix(`/admin`) || PathPrefix(`/static`)"
+ - "traefik.http.routers.trak.rule=PathPrefix(`/api`) || PathPrefix(`/admin`) || PathPrefix(`/static`) || PathPrefix(`/media`)"
- "traefik.http.routers.trak.entrypoints=web"
command: |
sh -c "
diff --git a/server/.gitignore b/server/.gitignore
index 61e3a20a..a290560d 100644
--- a/server/.gitignore
+++ b/server/.gitignore
@@ -5,3 +5,4 @@ static/
test_db-journal
**/*coverage.json
/htmlcov/
+media
diff --git a/server/haztrak/settings/base.py b/server/haztrak/settings/base.py
index c05e01bd..4fef7166 100644
--- a/server/haztrak/settings/base.py
+++ b/server/haztrak/settings/base.py
@@ -137,8 +137,8 @@
# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, "static")
-MEDIA_URL = "/media/"
-MEDIA_ROOT = os.path.join(BASE_DIR, "media")
+MEDIA_URL = "media/"
+MEDIA_ROOT = BASE_DIR / "media"
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
diff --git a/server/haztrak/urls.py b/server/haztrak/urls.py
index 514cdd1c..5934e6a5 100644
--- a/server/haztrak/urls.py
+++ b/server/haztrak/urls.py
@@ -14,6 +14,8 @@
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
+from django.conf import settings
+from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path, re_path
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
@@ -49,3 +51,6 @@
),
),
]
+
+if settings.DEBUG:
+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
diff --git a/server/manifest/tests/test_views.py b/server/manifest/tests/test_views.py
index 1659edee..1496f413 100644
--- a/server/manifest/tests/test_views.py
+++ b/server/manifest/tests/test_views.py
@@ -10,7 +10,7 @@
class TestManifestCRUD:
- """Tests the for the Manifest ModelViewSet"""
+ """Tests the for the Manifest views"""
@pytest.fixture
def factory(self):
diff --git a/server/org/models.py b/server/org/models.py
index 74a94b41..4d6169f9 100644
--- a/server/org/models.py
+++ b/server/org/models.py
@@ -1,5 +1,6 @@
import uuid
from profile.models import RcrainfoProfile
+from typing import TYPE_CHECKING
from core.models import TrakUser
from django.conf import settings
@@ -10,6 +11,9 @@
from guardian.models import GroupObjectPermissionBase, UserObjectPermissionBase
from guardian.shortcuts import get_objects_for_user
+if TYPE_CHECKING:
+ from django.contrib.auth.models import User
+
class OrgManager(models.Manager):
"""Organization Repository manager"""
@@ -54,7 +58,7 @@ class Meta:
null=False,
blank=False,
)
- admin = models.ForeignKey(
+ admin: "User" = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
@@ -117,9 +121,7 @@ def filter_by_username(self: models.Manager, username: str) -> QuerySet:
accept_global_perms=False,
)
- def get_by_user_and_epa_id(
- self: models.Manager, user: settings.AUTH_USER_MODEL, epa_id: str
- ) -> QuerySet:
+ def get_by_user_and_epa_id(self: models.Manager, user: "User", epa_id: str) -> QuerySet:
"""Get a site by EPA ID number that a user has access to"""
combined_filter: QuerySet = self.filter_by_user(user) & self.filter_by_epa_id(epa_id)
return combined_filter.get()
@@ -131,7 +133,7 @@ def get_by_username_and_epa_id(self: models.Manager, username: str, epa_id: str)
)
return combined_filter.get()
- def filter_by_user(self: models.Manager, user: settings.AUTH_USER_MODEL) -> QuerySet:
+ def filter_by_user(self: models.Manager, user: "User") -> QuerySet:
"""filter a list of sites a user has access to (by user object)"""
return get_objects_for_user(user, "view_site", self.model, accept_global_perms=False)
diff --git a/server/org/services.py b/server/org/services.py
index be384bc0..279484e8 100644
--- a/server/org/services.py
+++ b/server/org/services.py
@@ -1,14 +1,16 @@
from datetime import UTC, datetime
-from typing import Optional
+from typing import TYPE_CHECKING, Optional
from django.db import transaction
from django.db.models import QuerySet
-
-from haztrak import settings
from manifest.services import TaskResponse
from manifest.tasks import sync_site_manifests
+
from org.models import Org, Site
+if TYPE_CHECKING:
+ from django.contrib.auth.models import User
+
def get_org_by_id(org_id: str) -> Org:
"""Returns an Organization instance or raise a 404"""
@@ -70,7 +72,7 @@ def get_site_by_epa_id(epa_id: str) -> Site:
return Site.objects.get_by_epa_id(epa_id)
-def find_sites_by_user(user: settings.AUTH_USER_MODEL) -> QuerySet[Site]:
+def find_sites_by_user(user: "User") -> QuerySet[Site]:
"""Returns a list of Sites associated with a user."""
return Site.objects.filter_by_user(user)
diff --git a/server/profile/migrations/0003_profile_avatar.py b/server/profile/migrations/0003_profile_avatar.py
new file mode 100644
index 00000000..5c549a5b
--- /dev/null
+++ b/server/profile/migrations/0003_profile_avatar.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.0.8 on 2024-08-28 19:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("profile", "0002_rename_trakprofile_profile"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="profile",
+ name="avatar",
+ field=models.ImageField(blank=True, null=True, upload_to="users/%Y/%m/%d/"),
+ ),
+ ]
diff --git a/server/profile/models.py b/server/profile/models.py
index d5c72667..1e9b7e00 100644
--- a/server/profile/models.py
+++ b/server/profile/models.py
@@ -1,14 +1,18 @@
import uuid
+from typing import TYPE_CHECKING
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
+if TYPE_CHECKING:
+ from django.contrib.auth.models import User
+
class ProfileManager(models.QuerySet):
"""Query manager for the TrakProfile model."""
- def get_profile_by_user(self, user: settings.AUTH_USER_MODEL) -> "Profile":
+ def get_profile_by_user(self, user: "User") -> "Profile":
return self.get(user=user)
@@ -30,7 +34,7 @@ class Meta:
editable=False,
default=uuid.uuid4,
)
- user = models.OneToOneField(
+ user: "User" = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="haztrak_profile",
@@ -42,11 +46,11 @@ class Meta:
null=True,
blank=True,
)
-
- @property
- def rcrainfo_integrated_org(self) -> bool:
- """Returns true if the user's organization is integrated with RCRAInfo"""
- return self.user.org_permissions.org.is_rcrainfo_integrated()
+ avatar = models.ImageField(
+ upload_to="users/%Y/%m/%d/",
+ null=True,
+ blank=True,
+ )
def __str__(self):
return f"{self.user.username}"
diff --git a/server/profile/serializers/rcrasite_access.py b/server/profile/serializers.py
similarity index 68%
rename from server/profile/serializers/rcrasite_access.py
rename to server/profile/serializers.py
index d71086ed..41e2c2a6 100644
--- a/server/profile/serializers/rcrasite_access.py
+++ b/server/profile/serializers.py
@@ -1,31 +1,12 @@
-from profile.models import RcrainfoSiteAccess
+from profile.models import Profile, RcrainfoProfile, RcrainfoSiteAccess
+from core.serializers import TrakUserSerializer
+from manifest.serializers.mixins import RemoveEmptyFieldsMixin
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
-class RcraSiteBaseSerializer(serializers.ModelSerializer):
- def __str__(self):
- return f"{self.__class__.__name__}"
-
- def __repr__(self):
- return f"<{self.__class__.__name__}({self.data})>"
-
- def to_representation(self, instance):
- """
- Remove empty fields when serializing
- """
- data = super().to_representation(instance)
- for field in self.fields:
- try:
- if data[field] is None:
- data.pop(field)
- except KeyError:
- pass
- return data
-
-
-class RcraSitePermissionSerializer(RcraSiteBaseSerializer):
+class RcraSitePermissionSerializer(RemoveEmptyFieldsMixin, serializers.ModelSerializer):
"""
We use this internally because it's easier to handle, using consistent naming,
Haztrak has a separate serializer for user permissions from RCRAInfo.
@@ -159,3 +140,67 @@ class Meta:
"WIETS",
"myRCRAid",
]
+
+
+class ProfileSerializer(serializers.ModelSerializer):
+ """Serializer for a user's profile"""
+
+ user = TrakUserSerializer(read_only=True)
+
+ class Meta:
+ model = Profile
+ fields = [
+ "user",
+ "avatar",
+ ]
+
+
+class RcrainfoProfileSerializer(serializers.ModelSerializer):
+ """Model serializer for marshalling/unmarshalling a user's RcrainfoProfile"""
+
+ user = serializers.StringRelatedField(
+ source="haztrak_profile",
+ )
+ rcraSites = RcraSitePermissionSerializer(
+ source="permissions",
+ required=False,
+ many=True,
+ )
+ phoneNumber = serializers.CharField(
+ source="phone_number",
+ required=False,
+ )
+ rcraAPIID = serializers.CharField(
+ source="rcra_api_id",
+ required=False,
+ allow_null=True,
+ allow_blank=True,
+ )
+ rcraAPIKey = serializers.CharField(
+ source="rcra_api_key",
+ required=False,
+ write_only=True,
+ allow_blank=True,
+ allow_null=True,
+ )
+ rcraUsername = serializers.CharField(
+ source="rcra_username",
+ required=False,
+ )
+ apiUser = serializers.BooleanField(
+ source="has_rcrainfo_api_id_key",
+ required=False,
+ allow_null=False,
+ )
+
+ class Meta:
+ model = RcrainfoProfile
+ fields = [
+ "user",
+ "rcraAPIID",
+ "rcraAPIKey",
+ "rcraUsername",
+ "rcraSites",
+ "phoneNumber",
+ "apiUser",
+ ]
diff --git a/server/profile/serializers/__init__.py b/server/profile/serializers/__init__.py
deleted file mode 100644
index 0cc277be..00000000
--- a/server/profile/serializers/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .profile import ProfileSerializer
-from .rcrainfo_profile import RcrainfoProfileSerializer
-from .rcrasite_access import RcrainfoSitePermissionsSerializer, RcraSitePermissionSerializer
diff --git a/server/profile/serializers/profile.py b/server/profile/serializers/profile.py
deleted file mode 100644
index 4c6a54ac..00000000
--- a/server/profile/serializers/profile.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from profile.models import Profile
-
-from rest_framework import serializers
-from rest_framework.serializers import ModelSerializer
-
-
-class ProfileSerializer(ModelSerializer):
- """Serializer for a user's profile"""
-
- user = serializers.StringRelatedField(
- required=False,
- )
-
- class Meta:
- model = Profile
- fields = [
- "user",
- ]
diff --git a/server/profile/serializers/rcrainfo_profile.py b/server/profile/serializers/rcrainfo_profile.py
deleted file mode 100644
index 4d393e0f..00000000
--- a/server/profile/serializers/rcrainfo_profile.py
+++ /dev/null
@@ -1,57 +0,0 @@
-from profile.models import RcrainfoProfile
-
-from rest_framework import serializers
-from rest_framework.serializers import ModelSerializer
-
-from .rcrasite_access import RcraSitePermissionSerializer
-
-
-class RcrainfoProfileSerializer(ModelSerializer):
- """Model serializer for marshalling/unmarshalling a user's RcrainfoProfile"""
-
- user = serializers.StringRelatedField(
- source="haztrak_profile",
- )
- rcraSites = RcraSitePermissionSerializer(
- source="permissions",
- required=False,
- many=True,
- )
- phoneNumber = serializers.CharField(
- source="phone_number",
- required=False,
- )
- rcraAPIID = serializers.CharField(
- source="rcra_api_id",
- required=False,
- allow_null=True,
- allow_blank=True,
- )
- rcraAPIKey = serializers.CharField(
- source="rcra_api_key",
- required=False,
- write_only=True,
- allow_blank=True,
- allow_null=True,
- )
- rcraUsername = serializers.CharField(
- source="rcra_username",
- required=False,
- )
- apiUser = serializers.BooleanField(
- source="has_rcrainfo_api_id_key",
- required=False,
- allow_null=False,
- )
-
- class Meta:
- model = RcrainfoProfile
- fields = [
- "user",
- "rcraAPIID",
- "rcraAPIKey",
- "rcraUsername",
- "rcraSites",
- "phoneNumber",
- "apiUser",
- ]
diff --git a/server/profile/services.py b/server/profile/services.py
index cf338ebe..5dafa5b9 100644
--- a/server/profile/services.py
+++ b/server/profile/services.py
@@ -2,17 +2,18 @@
from profile.models import Profile, RcrainfoProfile, RcrainfoSiteAccess
from profile.serializers import RcrainfoSitePermissionsSerializer
-from typing import Optional
-
-from django.conf import settings
-from django.db import transaction
+from typing import TYPE_CHECKING, Optional
from core.models import TrakUser
from core.services import RcraClient, get_rcra_client
+from django.db import transaction
from org.services import SiteServiceError
from rcrasite.models import RcraSite
from rcrasite.services import RcraSiteService
+if TYPE_CHECKING:
+ from django.contrib.auth.models import User
+
@transaction.atomic
def get_or_create_profile(*, username: str) -> tuple[Profile, bool]:
@@ -25,12 +26,12 @@ def get_or_create_profile(*, username: str) -> tuple[Profile, bool]:
return profile, created
-def get_user_profile(*, user: settings.AUTH_USER_MODEL) -> Profile:
+def get_user_profile(*, user: "User") -> Profile:
"""Retrieve a user's Profile"""
return Profile.objects.get_profile_by_user(user=user)
-def get_user_rcrainfo_profile(*, user: settings.AUTH_USER_MODEL) -> RcrainfoProfile:
+def get_user_rcrainfo_profile(*, user: "User") -> RcrainfoProfile:
"""Retrieve a user's locally stored RCRAInfo Profile"""
return RcrainfoProfile.objects.get_by_trak_username(user.username)
diff --git a/server/profile/tests/test_serializers.py b/server/profile/tests/test_serializers.py
index f0fe54fc..85cc0941 100644
--- a/server/profile/tests/test_serializers.py
+++ b/server/profile/tests/test_serializers.py
@@ -15,7 +15,7 @@ def test_serializer_includes_username(self, profile_factory, user_factory):
user = user_factory(username=my_username)
profile = profile_factory(user=user)
serializer = ProfileSerializer(profile)
- assert serializer.data["user"] == my_username
+ assert serializer.data["user"]["username"] == my_username
class TestRcraSitePermissionSerializer:
diff --git a/server/profile/tests/test_views.py b/server/profile/tests/test_views.py
index 65c0475f..8ffa6323 100644
--- a/server/profile/tests/test_views.py
+++ b/server/profile/tests/test_views.py
@@ -1,9 +1,69 @@
+import http
+import io
+import tempfile
+from profile.serializers import ProfileSerializer
from profile.views import ProfileDetailsView, RcrainfoProfileRetrieveUpdateView
import pytest
+from django.core.files.uploadedfile import SimpleUploadedFile
+from PIL import Image
from rest_framework import status
from rest_framework.reverse import reverse
-from rest_framework.test import APIRequestFactory, force_authenticate
+from rest_framework.test import APIClient, APIRequestFactory, force_authenticate
+
+
+class TestProfileViewSet:
+ @pytest.fixture
+ def api_client(self):
+ return APIClient()
+
+ @pytest.fixture
+ def profile(self, db, profile_factory, user_factory):
+ user = user_factory(username="testuser", password="password")
+ user_profile = profile_factory(user=user)
+ return user_profile
+
+ @pytest.fixture
+ def generate_photo_file(self):
+ bts = io.BytesIO()
+ img = Image.new("RGB", (100, 100))
+ img.save(bts, "jpeg")
+ return SimpleUploadedFile("test.jpg", bts.getvalue(), content_type="image/jpeg")
+
+ def test_retrieves_profile_details(self, api_client, profile):
+ api_client.login(username="testuser", password="password")
+ url = reverse("profile:profile-detail", kwargs={"user_id": profile.user.id})
+ response = api_client.get(url)
+ assert response.status_code == 200
+ assert response.data == ProfileSerializer(profile).data
+
+ @pytest.mark.skip(reason="Not implemented")
+ def test_updates_profile_image(self, api_client, profile, generate_photo_file):
+ # ToDo: Implement this test - I keep getting a 500 internal server error
+ # even though I can successfully use this endpoint
+ api_client.login(username="testuser", password="password")
+ url = reverse("profile:profile-detail", kwargs={"user_id": profile.user.id})
+
+ f = io.BytesIO()
+ image = Image.new("RGB", (100, 100))
+ image.save(f, format="JPEG")
+ f.seek(0)
+ tmp_file = SimpleUploadedFile("test.jpeg", f.read(), content_type="image/jpeg")
+ payload = {"avatar": tmp_file}
+
+ response = api_client.patch(url, payload, format="multipart")
+ assert 200 <= response.status_code < 300
+
+ def test_returns_401_for_anonymous_user(self, api_client, profile):
+ url = reverse("profile:profile-detail", kwargs={"user_id": profile.user.pk})
+ response = api_client.get(url)
+ assert response.status_code == http.HTTPStatus.UNAUTHORIZED
+
+ def test_returns_401_for_anonymous_user_on_update(self, api_client, profile):
+ url = reverse("profile:profile-detail", kwargs={"user_id": profile.user.pk})
+ data = {"field_name": "new_value"} # Replace with actual fields
+ response = api_client.put(url, data)
+ assert response.status_code == http.HTTPStatus.UNAUTHORIZED
class TestRcrainfoProfileRetrieveUpdateView:
@@ -109,7 +169,7 @@ class TestProfileDetailsView:
def request_factory(self, user_factory):
user = user_factory()
factory = APIRequestFactory()
- request = factory.get(reverse("profile:details"))
+ request = factory.get(reverse("profile:my-profile"))
force_authenticate(request, user)
return request, user
@@ -123,7 +183,7 @@ def test_returns_profile(self, request_factory, profile_factory):
def test_returns_401_when_unauthenticated(self, profile_factory, user_factory):
user = user_factory()
factory = APIRequestFactory()
- request = factory.get(reverse("profile:details"))
+ request = factory.get(reverse("profile:my-profile"))
profile_factory(user=user)
response = ProfileDetailsView.as_view()(request)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
diff --git a/server/profile/urls.py b/server/profile/urls.py
index c7f8661d..ebe946f1 100644
--- a/server/profile/urls.py
+++ b/server/profile/urls.py
@@ -1,11 +1,16 @@
from django.urls import include, path
+from rest_framework.routers import SimpleRouter
from .views import (
ProfileDetailsView,
+ ProfileViewSet,
RcrainfoProfileRetrieveUpdateView,
RcrainfoProfileSyncView,
)
+profile_router = SimpleRouter(trailing_slash=False)
+profile_router.register("profile", ProfileViewSet)
+
rcrainfo_profile_patterns = (
[
path("/sync", RcrainfoProfileSyncView.as_view(), name="sync"),
@@ -18,6 +23,7 @@
app_name = "profile"
urlpatterns = [
- path("profile", ProfileDetailsView.as_view(), name="details"),
+ path("my-profile", ProfileDetailsView.as_view(), name="my-profile"),
path("rcrainfo-profile", include(rcrainfo_profile_patterns)),
+ path("", include(profile_router.urls)),
]
diff --git a/server/profile/views.py b/server/profile/views.py
index f80bb523..18d30706 100644
--- a/server/profile/views.py
+++ b/server/profile/views.py
@@ -11,8 +11,22 @@
RetrieveAPIView,
RetrieveUpdateAPIView,
)
+from rest_framework.mixins import RetrieveModelMixin, UpdateModelMixin
+from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
+from rest_framework.viewsets import GenericViewSet
+
+
+class ProfileViewSet(GenericViewSet, RetrieveModelMixin, UpdateModelMixin):
+ """ViewSet for the Profile model"""
+
+ lookup_field = "user__id"
+ lookup_url_kwarg = "user_id"
+ parser_classes = [MultiPartParser, FormParser, JSONParser]
+ # permission_classes = []
+ queryset = Profile.objects.all()
+ serializer_class = ProfileSerializer
class ProfileDetailsView(RetrieveAPIView):
@@ -22,6 +36,8 @@ class ProfileDetailsView(RetrieveAPIView):
serializer_class = ProfileSerializer
def get_object(self):
+ if self.request.user.is_anonymous:
+ raise PermissionError("You must be logged in to view this page")
return get_user_profile(user=self.request.user)
diff --git a/server/requirements.txt b/server/requirements.txt
index c4a25416..18cd4003 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -19,3 +19,4 @@ dj-rest-auth[with_social]==6.0.0
django-guardian==2.4.0
django-allauth==0.61.1
djangorestframework-simplejwt==5.3.1
+pillow==10.4.0