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"])
91 changes: 91 additions & 0 deletions backend/app/api/routes/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from typing import Any

from fastapi import APIRouter
from sqlmodel import col, func, or_, select

from app.api.deps import CurrentUser, SessionDep
from app.models import Item, SearchResult, SearchResults, User

router = APIRouter()


@router.get("/", response_model=SearchResults)
def global_search(
session: SessionDep,
current_user: CurrentUser,
q: str,
limit: int = 10,
) -> Any:
"""
Search records visible to the current user.
"""
query = q.strip()
if not query:
return SearchResults(data=[], count=0)

safe_limit = min(max(limit, 1), 50)
pattern = f"%{query}%"
results: list[SearchResult] = []

item_statement = select(Item).where(
or_(
col(Item.title).ilike(pattern),
col(Item.description).ilike(pattern),
)
)
if not current_user.is_superuser:
item_statement = item_statement.where(Item.owner_id == current_user.id)

items = session.exec(item_statement.limit(safe_limit)).all()
for item in items:
results.append(
SearchResult(
id=item.id,
type="item",
title=item.title,
description=item.description,
url="/items",
)
)

if current_user.is_superuser and len(results) < safe_limit:
remaining = safe_limit - len(results)
user_statement = select(User).where(
or_(
col(User.email).ilike(pattern),
col(User.full_name).ilike(pattern),
)
)
users = session.exec(user_statement.limit(remaining)).all()
for user in users:
results.append(
SearchResult(
id=user.id,
type="user",
title=user.full_name or user.email,
description=user.email,
url="/admin",
)
)

item_count_statement = select(func.count()).select_from(Item).where(
or_(
col(Item.title).ilike(pattern),
col(Item.description).ilike(pattern),
)
)
if not current_user.is_superuser:
item_count_statement = item_count_statement.where(Item.owner_id == current_user.id)

total_count = session.exec(item_count_statement).one()

if current_user.is_superuser:
user_count_statement = select(func.count()).select_from(User).where(
or_(
col(User.email).ilike(pattern),
col(User.full_name).ilike(pattern),
)
)
total_count += session.exec(user_count_statement).one()

return SearchResults(data=results, count=total_count)
13 changes: 13 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ class ItemsPublic(SQLModel):
count: int


class SearchResult(SQLModel):
id: int
type: str
title: str
description: str | None = None
url: str


class SearchResults(SQLModel):
data: list[SearchResult]
count: int


# Generic message
class Message(SQLModel):
message: str
Expand Down
86 changes: 86 additions & 0 deletions backend/app/tests/api/routes/test_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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


def test_global_search_returns_visible_items(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
owner = crud.get_user_by_email(session=db, email=settings.EMAIL_TEST_USER)
assert owner
assert owner.id

item_in = ItemCreate(
title="Alpha search item",
description="Visible to the normal user",
)
crud.create_item(session=db, item_in=item_in, owner_id=owner.id)

other_user = crud.create_user(
session=db,
user_create=UserCreate(
email="search-owner@example.com",
password="test-password",
),
)
assert other_user.id
other_item_in = ItemCreate(
title="Alpha hidden item",
description="Not visible to the normal user",
)
crud.create_item(session=db, item_in=other_item_in, owner_id=other_user.id)

response = client.get(
f"{settings.API_V1_STR}/search/",
headers=normal_user_token_headers,
params={"q": "Alpha"},
)

assert response.status_code == 200
content = response.json()
assert content["count"] == 1
assert content["data"][0]["title"] == item_in.title
assert content["data"][0]["type"] == "item"


def test_global_search_includes_users_for_superusers(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
user = crud.create_user(
session=db,
user_create=UserCreate(
email="global-search-user@example.com",
password="test-password",
full_name="Global Search User",
),
)

response = client.get(
f"{settings.API_V1_STR}/search/",
headers=superuser_token_headers,
params={"q": "Global Search"},
)

assert response.status_code == 200
content = response.json()
assert content["count"] >= 1
assert any(
result["id"] == user.id and result["type"] == "user"
for result in content["data"]
)


def test_global_search_returns_empty_results_for_blank_query(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
response = client.get(
f"{settings.API_V1_STR}/search/",
headers=superuser_token_headers,
params={"q": " "},
)

assert response.status_code == 200
assert response.json() == {"data": [], "count": 0}
17 changes: 17 additions & 0 deletions frontend/src/client/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ export type ItemsPublic = {



export type SearchResult = {
id: number;
type: string;
title: string;
description?: string | null;
url: string;
};



export type SearchResults = {
data: Array<SearchResult>;
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,SearchResults } from './models';

export type TDataLoginAccessToken = {
formData: Body_login_login_access_token
Expand Down Expand Up @@ -521,4 +521,37 @@ id,
});
}

}
}

export type TDataGlobalSearch = {
limit?: number
q: string

}

export class SearchService {

/**
* Global Search
* Search records visible to the current user.
* @returns SearchResults Successful Response
* @throws ApiError
*/
public static globalSearch(data: TDataGlobalSearch): CancelablePromise<SearchResults> {
const {
limit = 10,
q,
} = data;
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/search/',
query: {
q, limit
},
errors: {
422: `Validation Error`,
},
});
}

}
53 changes: 53 additions & 0 deletions frontend/src/components/Common/GlobalSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {
Input,
InputGroup,
InputLeftElement,
VisuallyHidden,
} from "@chakra-ui/react"
import { useNavigate } from "@tanstack/react-router"
import { type FormEvent, useState } from "react"
import { FaSearch } from "react-icons/fa"

interface GlobalSearchProps {
onSearch?: () => void
}

const GlobalSearch = ({ onSearch }: GlobalSearchProps) => {
const navigate = useNavigate()
const [query, setQuery] = useState("")

const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const trimmedQuery = query.trim()
if (!trimmedQuery) return

navigate({
to: "/search",
search: { q: trimmedQuery },
})
onSearch?.()
}

return (
<form onSubmit={handleSubmit}>
<VisuallyHidden as="label" htmlFor="global-search">
Global search
</VisuallyHidden>
<InputGroup mb={4}>
<InputLeftElement pointerEvents="none">
<FaSearch color="gray" />
</InputLeftElement>
<Input
id="global-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search"
borderRadius="8px"
fontSize="sm"
/>
</InputGroup>
</form>
)
}

export default GlobalSearch
3 changes: 3 additions & 0 deletions frontend/src/components/Common/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { FiLogOut, FiMenu } from "react-icons/fi"
import Logo from "../../assets/images/fastapi-logo.svg"
import type { UserPublic } from "../../client"
import useAuth from "../../hooks/useAuth"
import GlobalSearch from "./GlobalSearch"
import SidebarItems from "./SidebarItems"

const Sidebar = () => {
Expand Down Expand Up @@ -53,6 +54,7 @@ const Sidebar = () => {
<Flex flexDir="column" justify="space-between">
<Box>
<Image src={Logo} alt="logo" p={6} />
<GlobalSearch onSearch={onClose} />
<SidebarItems onClose={onClose} />
<Flex
as="button"
Expand Down Expand Up @@ -94,6 +96,7 @@ const Sidebar = () => {
>
<Box>
<Image src={Logo} alt="Logo" w="180px" maxW="2xs" p={6} />
<GlobalSearch />
<SidebarItems />
</Box>
{currentUser?.email && (
Expand Down
Loading