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"])
70 changes: 70 additions & 0 deletions backend/app/api/routes/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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, SearchResult, SearchResultsPublic, User

router = APIRouter()


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

result_limit = min(max(limit, 1), 50)
pattern = f"%{query}%"
item_filters = or_(
col(Item.title).ilike(pattern),
col(Item.description).ilike(pattern),
)
item_statement = select(Item).where(item_filters).limit(result_limit)

if not current_user.is_superuser:
item_statement = item_statement.where(Item.owner_id == current_user.id)

items = session.exec(item_statement).all()
Comment on lines +24 to +35
results = [
SearchResult(
type="item",
id=item.id,
title=item.title,
description=item.description,
)
for item in items
if item.id is not None
]

if current_user.is_superuser and len(results) < result_limit:
user_statement = (
select(User)
.where(
or_(
col(User.email).ilike(pattern),
col(User.full_name).ilike(pattern),
)
)
.limit(result_limit - len(results))
)
users = session.exec(user_statement).all()
results.extend(
SearchResult(
type="user",
id=user.id,
title=user.full_name or user.email,
description=user.email,
)
for user in users
if user.id is not None
)

return SearchResultsPublic(data=results, count=len(results))
Comment on lines +47 to +70
14 changes: 14 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Literal

from sqlmodel import Field, Relationship, SQLModel


Expand Down Expand Up @@ -92,6 +94,18 @@ class ItemsPublic(SQLModel):
count: int


class SearchResult(SQLModel):
type: Literal["item", "user"]
id: int
title: str
description: str | None = None


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


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


def test_search_returns_items_and_users_for_superuser(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
user_in = UserCreate(
email=random_email(),
password="password",
full_name="Search Target",
)
user = crud.create_user(session=db, user_create=user_in)
assert user.id is not None
item_in = ItemCreate(
title="Searchable bounty",
description="Global search should find this item",
)
item = crud.create_item(session=db, item_in=item_in, owner_id=user.id)

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

assert response.status_code == 200
content = response.json()
assert content["count"] >= 2
assert {"type": "item", "id": item.id, "title": item.title, "description": item.description} in content["data"]
assert {
"type": "user",
"id": user.id,
"title": user.full_name,
"description": user.email,
} in content["data"]


def test_search_limits_normal_user_to_own_items(
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="Private needle", description="Visible match"),
owner_id=normal_user.id,
)
other_user = crud.create_user(
session=db,
user_create=UserCreate(email=random_email(), password="password"),
)
assert other_user.id is not None
crud.create_item(
session=db,
item_in=ItemCreate(title="Private needle", description="Hidden match"),
owner_id=other_user.id,
)

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

assert response.status_code == 200
content = response.json()
assert content["data"] == [
{
"type": "item",
"id": own_item.id,
"title": own_item.title,
"description": own_item.description,
}
]
Comment on lines +69 to +84


def test_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}
16 changes: 16 additions & 0 deletions frontend/src/client/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ export type ItemsPublic = {



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



export type SearchResultsPublic = {
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,SearchResultsPublic } 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 resources visible to the current user.
* @returns SearchResultsPublic Successful Response
* @throws ApiError
*/
public static search(data: TDataSearch): CancelablePromise<SearchResultsPublic> {
const {
limit = 10,
q,
} = data;
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/search/',
query: {
q, limit
},
errors: {
422: `Validation Error`,
},
});
}

}
Loading
Loading