chore: workspace entity search endpoint (#6272)

* chore: workspace entity search endpoint

* fix: editor entity search endpoint

* chore: restrict guest users

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
Bavisetti Narayan 2024-12-26 15:00:32 +05:30 committed by GitHub
parent 2d9464e841
commit f54f3a6091
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 481 additions and 233 deletions

View file

@ -16,7 +16,7 @@ urlpatterns = [
name="project-issue-search",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/entity-search/",
"workspaces/<str:slug>/entity-search/",
SearchEndpoint.as_view(),
name="entity-search",
),

View file

@ -34,6 +34,7 @@ from plane.db.models import (
IssueView,
ProjectMember,
ProjectPage,
WorkspaceMember,
)
@ -252,14 +253,17 @@ class GlobalSearchEndpoint(BaseAPIView):
class SearchEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
def get(self, request, slug):
query = request.query_params.get("query", False)
query_types = request.query_params.get("query_type", "user_mention").split(",")
query_types = [qt.strip() for qt in query_types]
count = int(request.query_params.get("count", 5))
project_id = request.query_params.get("project_id", None)
issue_id = request.query_params.get("issue_id", None)
response_data = {}
if project_id:
for query_type in query_types:
if query_type == "user_mention":
fields = [
@ -272,10 +276,29 @@ class SearchEndpoint(BaseAPIView):
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
users = (
ProjectMember.objects.filter(
q, is_active=True, project_id=project_id, workspace__slug=slug, member__is_bot=False
base_filters = Q(
q,
is_active=True,
workspace__slug=slug,
member__is_bot=False,
project_id=project_id,
role__gt=10,
)
if issue_id:
issue_created_by = (
Issue.objects.filter(id=issue_id)
.values_list("created_by_id", flat=True)
.first()
)
# Add condition to include `issue_created_by` in the query
filters = Q(member_id=issue_created_by) | base_filters
else:
filters = base_filters
# Query to fetch users
users = (
ProjectMember.objects.filter(filters)
.annotate(
member__avatar_url=Case(
When(
@ -287,17 +310,21 @@ class SearchEndpoint(BaseAPIView):
),
),
When(
member__avatar_asset__isnull=True, then="member__avatar"
member__avatar_asset__isnull=True,
then="member__avatar",
),
default=Value(None),
output_field=models.CharField(),
output_field=CharField(),
)
)
.order_by("-created_at")
.values("member__avatar_url", "member__display_name", "member__id")[
:count
]
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)[:count]
)
response_data["user_mention"] = list(users)
elif query_type == "project":
@ -372,6 +399,7 @@ class SearchEndpoint(BaseAPIView):
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(
status=Case(
@ -380,10 +408,228 @@ class SearchEndpoint(BaseAPIView):
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(start_date__gt=timezone.now(), then=Value("UPCOMING")),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
start_date__gt=timezone.now(),
then=Value("UPCOMING"),
),
When(
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
output_field=CharField(),
)
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"status",
"workspace__slug",
)[:count]
)
response_data["cycle"] = list(cycles)
elif query_type == "module":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
modules = (
Module.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
project_id=project_id,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"status",
"workspace__slug",
)[:count]
)
response_data["module"] = list(modules)
elif query_type == "page":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = (
Page.objects.filter(
q,
projects__project_projectmember__member=self.request.user,
projects__project_projectmember__is_active=True,
projects__id=project_id,
workspace__slug=slug,
access=0,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"logo_props",
"projects__id",
"workspace__slug",
)[:count]
)
response_data["page"] = list(pages)
return Response(response_data, status=status.HTTP_200_OK)
else:
for query_type in query_types:
if query_type == "user_mention":
fields = [
"member__first_name",
"member__last_name",
"member__display_name",
]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
users = (
WorkspaceMember.objects.filter(
q,
is_active=True,
workspace__slug=slug,
member__is_bot=False,
)
.annotate(
member__avatar_url=Case(
When(
member__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"member__avatar_asset",
Value("/"),
),
),
When(
member__avatar_asset__isnull=True,
then="member__avatar",
),
default=Value(None),
output_field=models.CharField(),
)
)
.order_by("-created_at")
.values(
"member__avatar_url", "member__display_name", "member__id"
)[:count]
)
response_data["user_mention"] = list(users)
elif query_type == "project":
fields = ["name", "identifier"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user)
| Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name", "id", "identifier", "logo_props", "workspace__slug"
)[:count]
)
response_data["project"] = list(projects)
elif query_type == "issue":
fields = ["name", "sequence_id", "project__identifier"]
q = Q()
if query:
for field in fields:
if field == "sequence_id":
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
issues = (
Issue.issue_objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"sequence_id",
"project__identifier",
"project_id",
"priority",
"state_id",
"type_id",
)[:count]
)
response_data["issue"] = list(issues)
elif query_type == "cycle":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
cycles = (
Cycle.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
)
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(),
then=Value("UPCOMING"),
),
When(
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
@ -444,22 +690,19 @@ class SearchEndpoint(BaseAPIView):
q,
projects__project_projectmember__member=self.request.user,
projects__project_projectmember__is_active=True,
projects__id=project_id,
workspace__slug=slug,
access=0,
is_global=True,
)
.order_by("-created_at")
.distinct()
.values(
"name", "id", "logo_props", "projects__id", "workspace__slug"
"name",
"id",
"logo_props",
"projects__id",
"workspace__slug",
)[:count]
)
response_data["page"] = list(pages)
else:
return Response(
{"error": f"Invalid query type: {query_type}"},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(response_data, status=status.HTTP_200_OK)

View file

@ -76,6 +76,8 @@ export type TSearchResponse = {
export type TSearchEntityRequestPayload = {
count: number;
project_id?: string;
query_type: TSearchEntities[];
query: string;
team_id?: string;
};

View file

@ -14,9 +14,9 @@ import { useEditorMention } from "@/hooks/use-editor-mention";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// services
import { ProjectService } from "@/services/project";
const projectService = new ProjectService();
// plane web services
import { WorkspaceService } from "@/plane-web/services";
const workspaceService = new WorkspaceService();
interface LiteTextEditorWrapperProps
extends Omit<ILiteTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
@ -55,7 +55,10 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
// use editor mention
const { fetchMentions } = useEditorMention({
searchEntity: async (payload) =>
await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload),
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId?.toString() ?? "",
}),
});
// file size
const { maxFileSize } = useFileSize();

View file

@ -19,11 +19,12 @@ import { getTabIndex } from "@/helpers/tab-indices.helper";
// hooks
import { useProjectInbox } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// services
import { FileService } from "@/services/file.service";
import { ProjectService } from "@/services/project";
const fileService = new FileService();
const projectService = new ProjectService();
const workspaceService = new WorkspaceService();
type TInboxIssueDescription = {
containerClassName?: string;
@ -75,7 +76,10 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
onChange={(_description: object, description_html: string) => handleData("description_html", description_html)}
placeholder={getDescriptionPlaceholder}
searchMentionCallback={async (payload) =>
await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload)
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId?.toString() ?? "",
})
}
containerClassName={containerClassName}
onEnterKeyPress={onEnterKeyPress}

View file

@ -16,11 +16,12 @@ import { TIssueOperations } from "@/components/issues/issue-detail";
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
// hooks
import { useWorkspace } from "@/hooks/store";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// services
import { FileService } from "@/services/file.service";
import { ProjectService } from "@/services/project";
const workspaceService = new WorkspaceService();
const fileService = new FileService();
const projectService = new ProjectService();
export type IssueDescriptionInputProps = {
containerClassName?: string;
@ -121,11 +122,10 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value)
}
searchMentionCallback={async (payload) =>
await projectService.searchEntity(
workspaceSlug?.toString() ?? "",
projectId?.toString() ?? "",
payload
)
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId?.toString() ?? "",
})
}
containerClassName={containerClassName}
uploadFile={async (file) => {

View file

@ -23,10 +23,11 @@ import { getTabIndex } from "@/helpers/tab-indices.helper";
import { useInstance, useWorkspace } from "@/hooks/store";
import useKeypress from "@/hooks/use-keypress";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// services
import { AIService } from "@/services/ai.service";
import { FileService } from "@/services/file.service";
import { ProjectService } from "@/services/project";
type TIssueDescriptionEditorProps = {
control: Control<TIssue>;
@ -48,9 +49,9 @@ type TIssueDescriptionEditorProps = {
};
// services
const workspaceService = new WorkspaceService();
const aiService = new AIService();
const fileService = new FileService();
const projectService = new ProjectService();
export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = observer((props) => {
const {
@ -191,11 +192,10 @@ export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = ob
tabIndex={getIndex("description_html")}
placeholder={getDescriptionPlaceholder}
searchMentionCallback={async (payload) =>
await projectService.searchEntity(
workspaceSlug?.toString() ?? "",
projectId?.toString() ?? "",
payload
)
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId?.toString() ?? "",
})
}
containerClassName="pt-3 min-h-[120px]"
uploadFile={async (file) => {

View file

@ -30,14 +30,15 @@ import { EditorAIMenu } from "@/plane-web/components/pages";
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { useFileSize } from "@/plane-web/hooks/use-file-size";
import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// services
import { FileService } from "@/services/file.service";
import { ProjectService } from "@/services/project";
// store
import { IPage } from "@/store/pages/page";
// services init
const workspaceService = new WorkspaceService();
const fileService = new FileService();
const projectService = new ProjectService();
type Props = {
editorRef: React.RefObject<EditorRefApi>;
@ -63,7 +64,10 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
// use editor mention
const { fetchMentions } = useEditorMention({
searchEntity: async (payload) =>
await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload),
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId?.toString() ?? "",
}),
});
// editor flaggings
const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());

View file

@ -1,10 +1,4 @@
import type {
GithubRepositoriesResponse,
ISearchIssueResponse,
TProjectIssuesSearchParams,
TSearchEntityRequestPayload,
TSearchResponse,
} from "@plane/types";
import type { GithubRepositoriesResponse, ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// plane web types
@ -170,21 +164,4 @@ export class ProjectService extends APIService {
throw error?.response?.data;
});
}
async searchEntity(
workspaceSlug: string,
projectId: string,
params: TSearchEntityRequestPayload
): Promise<TSearchResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/entity-search/`, {
params: {
...params,
query_type: params.query_type.join(","),
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View file

@ -12,6 +12,8 @@ import {
IUserProjectsRole,
IWorkspaceView,
TIssuesResponse,
TSearchResponse,
TSearchEntityRequestPayload,
} from "@plane/types";
import { APIService } from "@/services/api.service";
// helpers
@ -277,4 +279,17 @@ export class WorkspaceService extends APIService {
throw error?.response?.data;
});
}
async searchEntity(workspaceSlug: string, params: TSearchEntityRequestPayload): Promise<TSearchResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/entity-search/`, {
params: {
...params,
query_type: params.query_type.join(","),
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}