Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/app/api/main.py
Original file line number Diff line number Diff line change
@@ -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"])
64 changes: 64 additions & 0 deletions backend/app/api/routes/search.py
Original file line number Diff line number Diff line change
@@ -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,
)
7 changes: 7 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions backend/app/tests/api/routes/test_search.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions frontend/src/client/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ export type ItemsPublic = {



export type SearchResultsPublic = {
items: Array<ItemPublic>;
users: Array<UserPublic>;
item_count: number;
user_count: number;
};



export type Message = {
message: string;
};
Expand Down
37 changes: 35 additions & 2 deletions frontend/src/client/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<SearchResultsPublic> {
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
Expand Down Expand Up @@ -521,4 +554,4 @@ id,
});
}

}
}
40 changes: 29 additions & 11 deletions frontend/src/components/Common/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Flex py={8} gap={4}>
{/* TODO: Complete search functionality */}
{/* <InputGroup w={{ base: '100%', md: 'auto' }}>
<InputLeftElement pointerEvents='none'>
<Icon as={FaSearch} color='ui.dim' />
</InputLeftElement>
<Input type='text' placeholder='Search' fontSize={{ base: 'sm', md: 'inherit' }} borderRadius='8px' />
</InputGroup> */}
<Flex py={8} gap={4} flexWrap="wrap">
{onSearchChange && (
<InputGroup w={{ base: "100%", md: "sm" }}>
<InputLeftElement pointerEvents="none">
<Icon as={FaSearch} color="ui.dim" />
</InputLeftElement>
<Input
type="search"
value={searchValue ?? ""}
placeholder={`Search ${type.toLowerCase()}s`}
fontSize={{ base: "sm", md: "inherit" }}
borderRadius="8px"
onChange={(event) => onSearchChange(event.target.value)}
/>
</InputGroup>
)}
<Button
variant="primary"
gap={1}
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/components/Common/SidebarItems.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { Box, Flex, Icon, Text, useColorModeValue } from "@chakra-ui/react"
import { useQueryClient } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi"
import {
FiBriefcase,
FiHome,
FiSearch,
FiSettings,
FiUsers,
} from "react-icons/fi"

import type { UserPublic } from "../../client"

const items = [
{ icon: FiHome, title: "Dashboard", path: "/" },
{ icon: FiSearch, title: "Search", path: "/search" },
{ icon: FiBriefcase, title: "Items", path: "/items" },
{ icon: FiSettings, title: "User Settings", path: "/settings" },
]
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Route as LoginImport } from './routes/login'
import { Route as LayoutImport } from './routes/_layout'
import { Route as LayoutIndexImport } from './routes/_layout/index'
import { Route as LayoutSettingsImport } from './routes/_layout/settings'
import { Route as LayoutSearchImport } from './routes/_layout/search'
import { Route as LayoutItemsImport } from './routes/_layout/items'
import { Route as LayoutAdminImport } from './routes/_layout/admin'

Expand Down Expand Up @@ -52,6 +53,11 @@ const LayoutSettingsRoute = LayoutSettingsImport.update({
getParentRoute: () => LayoutRoute,
} as any)

const LayoutSearchRoute = LayoutSearchImport.update({
path: '/search',
getParentRoute: () => LayoutRoute,
} as any)

const LayoutItemsRoute = LayoutItemsImport.update({
path: '/items',
getParentRoute: () => LayoutRoute,
Expand Down Expand Up @@ -90,6 +96,10 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutItemsImport
parentRoute: typeof LayoutImport
}
'/_layout/search': {
preLoaderRoute: typeof LayoutSearchImport
parentRoute: typeof LayoutImport
}
'/_layout/settings': {
preLoaderRoute: typeof LayoutSettingsImport
parentRoute: typeof LayoutImport
Expand All @@ -107,6 +117,7 @@ export const routeTree = rootRoute.addChildren([
LayoutRoute.addChildren([
LayoutAdminRoute,
LayoutItemsRoute,
LayoutSearchRoute,
LayoutSettingsRoute,
LayoutIndexRoute,
]),
Expand Down
Loading