diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 09e0663..de2ba9f 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,9 +1,10 @@ from fastapi import APIRouter -from app.api.routes import items, login, users, utils +from app.api.routes import items, login, search, users, utils api_router = APIRouter() api_router.include_router(login.router, tags=["login"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(utils.router, prefix="/utils", tags=["utils"]) api_router.include_router(items.router, prefix="/items", tags=["items"]) +api_router.include_router(search.router, prefix="/search", tags=["search"]) diff --git a/backend/app/api/routes/search.py b/backend/app/api/routes/search.py new file mode 100644 index 0000000..9b62240 --- /dev/null +++ b/backend/app/api/routes/search.py @@ -0,0 +1,64 @@ +from typing import Any + +from fastapi import APIRouter +from sqlalchemy import or_ +from sqlmodel import col, func, select + +from app.api.deps import CurrentUser, SessionDep +from app.models import Item, SearchResultsPublic, User + +router = APIRouter() + + +@router.get("/", response_model=SearchResultsPublic) +def global_search( + session: SessionDep, + current_user: CurrentUser, + q: str, + limit: int = 10, +) -> Any: + """ + Search items visible to the current user and users visible to superusers. + """ + query = q.strip() + if not query: + return SearchResultsPublic(items=[], users=[], item_count=0, user_count=0) + + safe_limit = min(max(limit, 1), 50) + pattern = f"%{query}%" + item_filter = or_( + col(Item.title).ilike(pattern), + col(Item.description).ilike(pattern), + ) + + item_count_statement = select(func.count()).select_from(Item).where(item_filter) + item_statement = select(Item).where(item_filter).limit(safe_limit) + + if not current_user.is_superuser: + item_count_statement = item_count_statement.where( + Item.owner_id == current_user.id + ) + item_statement = item_statement.where(Item.owner_id == current_user.id) + + item_count = session.exec(item_count_statement).one() + items = session.exec(item_statement).all() + + users = [] + user_count = 0 + if current_user.is_superuser: + user_filter = or_( + col(User.email).ilike(pattern), + col(User.full_name).ilike(pattern), + ) + user_count = ( + session.exec(select(func.count()).select_from(User).where(user_filter)) + .one() + ) + users = session.exec(select(User).where(user_filter).limit(safe_limit)).all() + + return SearchResultsPublic( + items=items, + users=users, + item_count=item_count, + user_count=user_count, + ) diff --git a/backend/app/models.py b/backend/app/models.py index 8e74fea..fda6a78 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -92,6 +92,13 @@ class ItemsPublic(SQLModel): count: int +class SearchResultsPublic(SQLModel): + items: list[ItemPublic] + users: list[UserPublic] + item_count: int + user_count: int + + # Generic message class Message(SQLModel): message: str diff --git a/backend/app/tests/api/routes/test_search.py b/backend/app/tests/api/routes/test_search.py new file mode 100644 index 0000000..dcead67 --- /dev/null +++ b/backend/app/tests/api/routes/test_search.py @@ -0,0 +1,71 @@ +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app import crud +from app.core.config import settings +from app.models import ItemCreate, UserCreate +from app.tests.utils.user import create_random_user +from app.tests.utils.utils import random_email, random_lower_string + + +def test_search_items_scoped_to_normal_user( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + normal_user = crud.get_user_by_email(session=db, email=settings.EMAIL_TEST_USER) + assert normal_user + assert normal_user.id is not None + + own_item = crud.create_item( + session=db, + item_in=ItemCreate( + title="Scoped Alpha Result", + description="Visible to the normal user", + ), + owner_id=normal_user.id, + ) + other_user = create_random_user(db) + assert other_user.id is not None + other_item = crud.create_item( + session=db, + item_in=ItemCreate( + title="Scoped Alpha Secret", + description="Hidden from the normal user", + ), + owner_id=other_user.id, + ) + + response = client.get( + f"{settings.API_V1_STR}/search/?q=Scoped%20Alpha", + headers=normal_user_token_headers, + ) + + assert response.status_code == 200 + content = response.json() + item_ids = {item["id"] for item in content["items"]} + assert own_item.id in item_ids + assert other_item.id not in item_ids + assert content["users"] == [] + assert content["user_count"] == 0 + + +def test_search_superuser_includes_users( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + marker = random_lower_string() + email = f"{marker}@example.com" + user_in = UserCreate( + email=email, + password=random_lower_string(), + full_name=f"Search Target {marker}", + ) + crud.create_user(session=db, user_create=user_in) + + response = client.get( + f"{settings.API_V1_STR}/search/?q={marker}", + headers=superuser_token_headers, + ) + + assert response.status_code == 200 + content = response.json() + assert any(user["email"] == email for user in content["users"]) + assert content["user_count"] >= 1 diff --git a/frontend/src/client/models.ts b/frontend/src/client/models.ts index 6732a18..5c29105 100644 --- a/frontend/src/client/models.ts +++ b/frontend/src/client/models.ts @@ -45,6 +45,15 @@ export type ItemsPublic = { +export type SearchResultsPublic = { + items: Array; + users: Array; + item_count: number; + user_count: number; +}; + + + export type Message = { message: string; }; diff --git a/frontend/src/client/services.ts b/frontend/src/client/services.ts index 4ace1a4..20b4eef 100644 --- a/frontend/src/client/services.ts +++ b/frontend/src/client/services.ts @@ -2,7 +2,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { Body_login_login_access_token,Message,NewPassword,Token,UserPublic,UpdatePassword,UserCreate,UserRegister,UsersPublic,UserUpdate,UserUpdateMe,ItemCreate,ItemPublic,ItemsPublic,ItemUpdate } from './models'; +import type { Body_login_login_access_token,Message,NewPassword,Token,UserPublic,UpdatePassword,UserCreate,UserRegister,UsersPublic,UserUpdate,UserUpdateMe,ItemCreate,ItemPublic,ItemsPublic,ItemUpdate,SearchResultsPublic } from './models'; export type TDataLoginAccessToken = { formData: Body_login_login_access_token @@ -383,6 +383,39 @@ emailTo, } +export type TDataGlobalSearch = { + limit?: number +q: string + + } + +export class SearchService { + + /** + * Global Search + * Search items visible to the current user and users visible to superusers. + * @returns SearchResultsPublic Successful Response + * @throws ApiError + */ + public static globalSearch(data: TDataGlobalSearch): CancelablePromise { + const { +limit = 10, +q, +} = data; + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/search/', + query: { + q, limit + }, + errors: { + 422: `Validation Error`, + }, + }); + } + +} + export type TDataReadItems = { limit?: number skip?: number @@ -521,4 +554,4 @@ id, }); } -} \ No newline at end of file +} diff --git a/frontend/src/components/Common/Navbar.tsx b/frontend/src/components/Common/Navbar.tsx index edcb3d0..7239e6d 100644 --- a/frontend/src/components/Common/Navbar.tsx +++ b/frontend/src/components/Common/Navbar.tsx @@ -1,27 +1,45 @@ -import { Button, Flex, Icon, useDisclosure } from "@chakra-ui/react" -import { FaPlus } from "react-icons/fa" +import { + Button, + Flex, + Icon, + Input, + InputGroup, + InputLeftElement, + useDisclosure, +} from "@chakra-ui/react" +import { FaPlus, FaSearch } from "react-icons/fa" import AddUser from "../Admin/AddUser" import AddItem from "../Items/AddItem" interface NavbarProps { type: string + searchValue?: string + onSearchChange?: (value: string) => void } -const Navbar = ({ type }: NavbarProps) => { +const Navbar = ({ type, searchValue, onSearchChange }: NavbarProps) => { const addUserModal = useDisclosure() const addItemModal = useDisclosure() return ( <> - - {/* TODO: Complete search functionality */} - {/* - - - - - */} + + {onSearchChange && ( + + + + + onSearchChange(event.target.value)} + /> + + )}