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

from fastapi import APIRouter
from sqlmodel import col, 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 search(
session: SessionDep,
current_user: CurrentUser,
q: str = "",
limit: int = 20,
) -> Any:
"""
Search across the resources 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 or 0,
type="item",
title=item.title,
description=item.description,
url="/items",
)
)

if len(results) < safe_limit:
user_statement = select(User).where(
or_(
col(User.email).ilike(pattern),
col(User.full_name).ilike(pattern),
)
)
if not current_user.is_superuser:
user_statement = user_statement.where(User.id == current_user.id)

users = session.exec(user_statement.limit(safe_limit - len(results))).all()
for user in users:
results.append(
SearchResult(
id=user.id or 0,
type="user",
title=user.full_name or user.email,
description=user.email,
url="/admin" if current_user.is_superuser else "/settings",
)
)

return SearchResults(data=results, count=len(results))
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
88 changes: 88 additions & 0 deletions backend/app/tests/api/routes/test_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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.utils import random_email, random_lower_string


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

assert response.status_code == 200
content = response.json()
assert content["data"] == []
assert content["count"] == 0


def test_search_finds_visible_items(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
current_user = crud.get_user_by_email(session=db, email=settings.EMAIL_TEST_USER)
assert current_user
assert current_user.id
owned_item = crud.create_item(
session=db,
item_in=ItemCreate(
title="Needle Alpha Search",
description="Visible search result",
),
owner_id=current_user.id,
)
other_user = crud.create_user(
session=db,
user_create=UserCreate(email=random_email(), password=random_lower_string()),
)
assert other_user.id
crud.create_item(
session=db,
item_in=ItemCreate(
title="Needle Alpha Search private",
description="Hidden search result",
),
owner_id=other_user.id,
)

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

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


def test_search_finds_users_for_superusers(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
user = crud.create_user(
session=db,
user_create=UserCreate(
email=f"search-{random_email()}",
password=random_lower_string(),
full_name="Global Search Person",
),
)

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

assert response.status_code == 200
content = response.json()
user_results = [
result
for result in content["data"]
if result["type"] == "user" and result["id"] == user.id
]
assert user_results
14 changes: 13 additions & 1 deletion frontend/src/client/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ export type ItemsPublic = {
count: number;
};

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 = {
Expand Down Expand Up @@ -129,4 +142,3 @@ export type ValidationError = {
msg: string;
type: string;
};

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 TDataSearch = {
limit?: number
q?: string

}

export class SearchService {

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

}
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