8000 feat(entities): an Entities page in the sidebar by adeelahmad · Pull Request #62 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
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
10000
Diff view
30 changes: 28 additions & 2 deletions fastapi_backend/app/api/v1/routes/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from fastapi import APIRouter, HTTPException, Request

from app.api.deps import Access, Acl, CurrentUser, Db, Writer, domain_errors
from app.domain import auth, jobs
from app.domain import auth, hierarchy, jobs
from app.domain import entities as ents
from app.domain.store import DB, R
from app.schemas.common import Ok
Expand All @@ -26,6 +26,7 @@
EntityRename,
EntityRetype,
EntityType,
EntityUpdate,
MentionList,
MentionMove,
MentionMoved,
Expand Down Expand Up @@ -73,15 +74,29 @@ def list_entities(
limit: int = 50,
offset: int = 0,
group: bool = False,
collection: int | None = None,
) -> EntityList:
"""Entities in the namespaces you can read. `types` and `namespaces` are comma-separated; `group` joins same-named ones.
With `recording`, those said in it, also for someone who sees it through a role on its collection (then counted
over the recordings they see)."""
over the recordings they see). With `collection`, those said in its recordings and those of the collections inside
it (counted over them)."""
spaces, within = set(acl.roles), None
if recording is not None:
rec = acl.recording(recording) # 404 unless they may see it
if rec["space"] not in spaces:
spaces, within = {rec["space"]}, acl.partial_recordings()
if collection is not None:
try:
col = hierarchy.get(db, collection)
except KeyError:
raise HTTPException(404, "not found") from None
sid = col["space"]
# 404 unless they see some of the namespace
cols = hierarchy.subtree(db, sid, collection)
if only is not None:
cols &= set(only)
recs = hierarchy.recordings_in(db, cols)
spaces, within = {sid}, recs if within is None else recs & within
return EntityList.model_validate(
ents.list_entities(
db,
Expand Down Expand Up @@ -224,6 +239,17 @@ def rename_entity(eid: int, body: EntityRename, request: Request, user: Writer,
return out


@router.patch("/entities/{eid}")
def update_entity(eid: int, body: EntityUpdate, request: Request, user: Writer, acl: Acl, db: Db) -> EntityDetail:
"""Describe the entity: what it is, in your words."""
_entity_space(db, acl, eid, "editor")
with domain_errors():
ents.describe(db, eid, body.description)
auth.audit(db, u 8000 ser.as_audit(), "entity.describe", f"entity:{eid}", {"description": body.description})
_changed(request)
return EntityDetail.model_validate(ents.detail(db, eid, set(acl.roles)))


@router.post("/entities/{eid}/hide")
def hide_entity(eid: int, request: Request, user: Writer, acl: Acl, db: Db, body: EntityHide | None = None) -> Ok:
body = body or EntityHide()
Expand Down
13 changes: 12 additions & 1 deletion fastapi_backend/app/domain/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"NUMBER": "Number",
}
QUIET = ("DATE", "NUMBER") # extracted, but hidden unless a filter asks for them
FIELDS = "record::id(id) AS id, space, key, name, type, hidden, hidden_reason"
FIELDS = "record::id(id) AS id, space, key, name, type, description, hidden, hidden_reason"
DESCRIPTION_MAX = 2000


def _day(s):
Expand Down Expand Up @@ -139,6 +140,7 @@ def list_entities(
"type": e["type"],
10000 "type_label": TYPES.get(e["type"], e["type"].title()),
"key": e["key"],
"description": e.get("description"),
"namespace": names.get(e["space"]),
"aliases": al.get(e["id"], []),
"mentions": s["mentions"],
Expand Down Expand Up @@ -647,6 +649,15 @@ def retype(db, eids, typ):
db.q("UPDATE $ids SET type = $t", ids=[R("entity", int(i)) for i in eids], t=typ)


def describe(db, eid, description):
"""What the entity is, in people's words: shown with it, and given to the assistant."""
_entity(db, eid)
text = str(description or "").strip()
if len(text) > DESCRIPTION_MAX:
raise ValueError(f"A description can have up to {DESCRIPTION_MAX} characters.")
db.q("UPDATE $r SET description = $d", r=R("entity", int(eid)), d=text or None)


def hide(db, eid, hidden=True, reason=None):
_entity(db, eid)
db.q(
Expand Down
5 changes: 5 additions & 0 deletions fastapi_backend/app/schemas/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class EntityDetail(ResponseModel):
key: str
type: str
type_label: str
description: str | None = None
namespace: str | None = None
aliases: list[str] = []
mentions: int = 0
Expand Down Expand Up @@ -77,6 +78,10 @@ class EntityRename(RequestModel):
dry_run: bool = False


class EntityUpdate(RequestModel):
description: str | None = Field(None, description="what the entity is; empty clears it")


class EntityHide(RequestModel):
hidden: bool = True
reason: str | None = None
Expand Down
23 changes: 23 additions & 0 deletions fastapi_backend/tests/api/test_entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,26 @@ def test_curation_survives_reanalysis(env, db, cfg):
# a read-only API token can't curate
tok = c.post("/api/v1/tokens", headers=h, json={"name": "ro"}).json()["token"]
assert c.post(f"/api/v1/entities/{dyno}/hide", headers={"Authorization": f"Bearer {tok}"}, json={}).status_code == 403


def test_describe_and_filter_by_collection(env, db):
c, ed, vi = env.c, env.h["editor"], env.h["viewer"]
dyno = env.eid("Dyno Therapeutics")
assert c.patch(f"/api/v1/entities/{dyno}", headers=vi, json={"description": "x"}).status_code == 403
d = c.patch(f"/api/v1/entities/{dyno}", headers=ed, json={"description": " A gene therapy company. "}).json()
assert d["description"] == "A gene therapy company."
lst = c.get("/api/v1/entities", params={"q": "Dyno"}, headers=vi).json()["items"]
assert lst[0]["description"] == "A gene therapy company."
long = c.patch(f"/api/v1/entities/{dyno}", headers=ed, json={"description": "x" * 2001})
assert long.status_code == 400
assert c.patch(f"/api/v1/entities/{dyno}", headers=ed, json={"description": ""}).json()["description"] is None

sid = store.ns_id(db, "pods")
from app.domain import hierarchy

sub = hierarchy.create(db, sid, "Extra")
hierarchy.place(db, [env.x], sub)
inside = {x["name"]: x["mentions"] for x in c.get("/api/v1/entities", params={"collection": sub}, headers=vi).json()["items"]}
assert inside["Dyno Therapeutics"] == 1 # counted over its recordings only (5 in the namespace)
assert "Northwind Labs" in inside and "Amazon Web Services" in inside
assert c.get("/api/v1/entities", params={"collection": 999999}, headers=vi).status_code == 404
42 changes: 42 additions & 0 deletions nextjs-frontend/__tests__/entities-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { alsoKnownAs, entityQuery, filtersFrom, PAGE } from "@/components/entities/model";

describe("entities page model", () => {
it("reads filters from the address, with defaults for anything odd", () => {
const f = filtersFrom(new URLSearchParams("q=north&type=ORG&collection=7&view=hidden&sort=name&page=3"), "pods");
expect(f).toEqual({
ns: "pods",
q: "north",
type: "ORG",
collection: 7,
hidden: true,
sort: "name",
offset: 2 * PAGE,
});
const d = filtersFrom(new URLSearchParams("collection=x&sort=weird&page=-2"), "pods");
expect(d).toMatchObject({ collection: null, hidden: false, sort: "mentions", offset: 0 });
});

it("sends only the filters that are set", () => {
const q = entityQuery(filtersFrom(new URLSearchParams("q=%20%20"), "pods"));
expect(q).toEqual({
namespaces: "pods",
q: undefined,
types: undefined,
collection: undefined,
hidden: false,
sort: "mentions",
limit: PAGE,
offset: 0,
});
});

it("lists other names once, without the entity's own", () => {
expect(
alsoKnownAs({
name: "Northwind Labs",
key: "northwind labs",
aliases: ["north wind labs", "northwind labs", "nwl", "nwl"],
}),
).toEqual(["north wind labs", "nwl"]);
});
});
15 changes: 15 additions & 0 deletions nextjs-frontend/app/(app)/entities/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { Suspense } from "react";

import { EntitiesPage } from "@/components/entities/entities-page";

export const metadata: Metadata = { title: "Entities" };

/** The people, organisations, products, places and topics a namespace's recordings mention. */
export default function Page() {
return (
<Suspense>
<EntitiesPage />
</Suspense>
);
}
6 changes: 6 additions & 0 deletions nextjs-frontend/app/openapi-client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ export type {
EntityRename,
EntityRetype,
EntityType,
EntityUpdate,
Estimate,
EstimateBatchData,
EstimateBatchError,
Expand Down Expand Up @@ -1659,6 +1660,11 @@ export type {
UpdateContentTypeErrors,
UpdateContentTypeResponse,
UpdateContentTypeResponses,
UpdateEntityData,
UpdateEntityError,
UpdateEntityErrors,
UpdateEntityResponse,
UpdateEntityResponses,
UpdateFieldData,
UpdateFieldError,
UpdateFieldErrors,
Expand Down
24 changes: 23 additions & 1 deletion nextjs-frontend/app/openapi-client/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,9 @@ import type {
UpdateContentTypeData,
UpdateContentTypeErrors,
UpdateContentTypeResponses,
UpdateEntityData,
UpdateEntityErrors,
UpdateEntityResponses,
UpdateFieldData,
UpdateFieldErrors,
UpdateFieldResponses,
Expand Down Expand Up @@ -3424,7 +3427,8 @@ export class Entities {
*
* Entities in the namespaces you can read. `types` and `namespaces` are comma-separated; `group` joins same-named ones.
* With `recording`, those said in it, also for someone who sees it through a role on its collection (then counted
* over the recordings they see).
* over the recordings they see). With `collection`, those said in its recordings and those of the collections inside
* it (counted over them).
*/
public static listEntities<ThrowOnError extends boolean = false>(
options?: Options<ListEntitiesData, ThrowOnError>,
Expand Down Expand Up @@ -3561,6 +3565,24 @@ export class Entities {
});
}

/**
* Update Entity
*
* Describe the entity: what it is, in your words.
*/
public static updateEntity<ThrowOnError extends boolean = false>(
options: Options<UpdateEntityData, ThrowOnError>,
): RequestResult<UpdateEntityResponses, UpdateEntityErrors, ThrowOnError> {
return (options.client ?? client).patch<UpdateEntityResponses, UpdateEntityErrors, ThrowOnError>({
url: "/api/v1/entities/{eid}",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
}

/**
* List Entity Mentions
*
Expand Down
50 changes: 50 additions & 0 deletions nextjs-frontend/app/openapi-client/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2240,6 +2240,10 @@ export type EntityDetail = {
* Type Label
*/
type_label: string;
/**
* Description
*/
description?: string | null;
/**
* Namespace
*/
Expand Down Expand Up @@ -2461,6 +2465,18 @@ export type EntityType = {
[key: string]: unknown;
};

/**
* EntityUpdate
*/
export type EntityUpdate = {
/**
* Description
*
* what the entity is; empty clears it
*/
description?: string | null;
};

/**
* Estimate
*/
Expand Down Expand Up @@ -15702,6 +15718,10 @@ export type ListEntitiesData = {
* Group
*/
group?: boolean;
/**
* Collection
*/
collection?: number | null;
};
url: "/api/v1/entities";
};
Expand Down Expand Up @@ -15973,6 +15993,36 @@ export type GetEntityResponses = {

export type GetEntityResponse = GetEntityResponses[keyof GetEntityResponses];

export type UpdateEntityData = {
body: EntityUpdate;
path: {
/**
* Eid
*/
eid: number;
};
query?: never;
url: "/api/v1/entities/{eid}";
};

export type UpdateEntityErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};

export type UpdateEntityError = UpdateEntityErrors[keyof UpdateEntityErrors];

export type UpdateEntityResponses = {
/**
* Successful Response
*/
200: EntityDetail;
};

export type UpdateEntityResponse = UpdateEntityResponses[keyof UpdateEntityResponses];

export type ListEntityMentionsData = {
body?: never;
path: {
Expand Down
2 changes: 2 additions & 0 deletions nextjs-frontend/components/app-shell/nav-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
MessagesSquare,
Search,
Settings,
Shapes,
Shield,
Waypoints,
Workflow,
Expand Down Expand Up @@ -36,6 +37,7 @@ export const NAV: NavItem[] = [
},
{ label: "Search", href: "/search", icon: Search },
{ label: "Chat", href: "/chat", icon: MessagesSquare },
{ label: "Entities", href: "/entities", icon: Shapes },
{ label: "Graph", href: "/graph", icon: Waypoints },
{
label: "Reports",
Expand Down
Loading
Loading
0