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

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

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

router = APIRouter()


@router.get("/", response_model=SearchResultsPublic)
def search(
session: SessionDep,
current_user: CurrentUser,
q: str,
limit: int = 10,
) -> Any:
"""
Search visible items, and users when the requester is a superuser.
"""
query = q.strip()
if not query:
return SearchResultsPublic(items=[], users=[])

safe_limit = max(1, min(limit, 50))
pattern = f"%{query}%"
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)
item_statement = item_statement.limit(safe_limit)

items = list(session.exec(item_statement).all())
users: list[User] = []
if current_user.is_superuser:
user_statement = (
select(User)
.where(
or_(
col(User.email).ilike(pattern),
col(User.full_name).ilike(pattern),
)
)
.limit(safe_limit)
)
users = list(session.exec(user_statement).all())

return SearchResultsPublic(items=items, users=users)
5 changes: 5 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ class ItemsPublic(SQLModel):
count: int


class SearchResultsPublic(SQLModel):
items: list[ItemPublic]
users: list[UserPublic]


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


def test_search_returns_visible_items_for_regular_users(
client: TestClient, db: Session
) -> None:
password = random_lower_string()
user = crud.create_user(
session=db,
user_create=UserCreate(email=random_email(), password=password),
)
other_user = crud.create_user(
session=db,
user_create=UserCreate(email=random_email(), password=random_lower_string()),
)
assert user.id is not None
assert other_user.id is not None

visible_item = crud.create_item(
session=db,
owner_id=user.id,
item_in=ItemCreate(title="Global Search Match", description="visible"),
)
hidden_item = crud.create_item(
session=db,
owner_id=other_user.id,
item_in=ItemCreate(title="Global Search Match", description="hidden"),
)
headers = user_authentication_headers(
client=client, email=user.email, password=password
)

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

assert response.status_code == 200
content = response.json()
result_ids = {item["id"] for item in content["items"]}
assert visible_item.id in result_ids
assert hidden_item.id not in result_ids
assert content["users"] == []


def test_search_returns_users_for_superusers(
client: TestClient,
superuser_token_headers: dict[str, str],
db: Session,
) -> None:
user = crud.create_user(
session=db,
user_create=UserCreate(
email="searchable-user@example.com",
full_name="Searchable Person",
password=random_lower_string(),
),
)

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

assert response.status_code == 200
content = response.json()
result_ids = {result["id"] for result in content["users"]}
assert user.id in result_ids


def test_search_rejects_unauthenticated_requests(client: TestClient) -> None:
response = client.get(f"{settings.API_V1_STR}/search/", params={"q": "anything"})

assert response.status_code == 401
7 changes: 6 additions & 1 deletion frontend/src/client/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export type ItemsPublic = {
};


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



export type Message = {
message: string;
Expand Down Expand Up @@ -129,4 +135,3 @@ export type ValidationError = {
msg: string;
type: string;
};

36 changes: 34 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 All @@ -20,6 +20,38 @@ export type TDataRecoverPasswordHtmlContent = {
email: string

}
export type TDataSearch = {
q: string
limit?: number

}

export class SearchService {

/**
* Search
* Search visible items, and users when the requester is a superuser.
* @returns SearchResultsPublic Successful Response
* @throws ApiError
*/
public static search(data: TDataSearch): CancelablePromise<SearchResultsPublic> {
const {
q,
limit = 10,
} = data;
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/search/',
query: {
q, limit
},
errors: {
422: `Validation Error`,
},
});
}

}

export class LoginService {

Expand Down Expand Up @@ -521,4 +553,4 @@ id,
});
}

}
}
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