Skip to content

Commit da76c1c

Browse files
feat(bookmarks): adds CRUD for bookmarks (#53)
* feat(bookmarks): adds CRUD for bookmarks * adds tests * refactor search document by id
1 parent b7d837c commit da76c1c

File tree

8 files changed

+541
-16
lines changed

8 files changed

+541
-16
lines changed

src/app/api/api_v1/endpoints/search.py

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1+
import uuid
2+
from collections import Counter
3+
14
from fastapi import APIRouter, Depends, Response
25
from qdrant_client.models import ScoredPoint
36
from sqlalchemy.sql import select
47

5-
from src.app.models.db_models import CorpusEmbedding, QtyDocumentInQdrant
6-
from src.app.models.documents import Collection_schema
8+
from src.app.models.db_models import (
9+
Corpus,
10+
CorpusEmbedding,
11+
DocumentSlice,
12+
QtyDocumentInQdrant,
13+
Sdg,
14+
WeLearnDocument,
15+
)
16+
from src.app.models.documents import Collection_schema, DocumentPayloadModel
717
from src.app.models.search import (
818
EnhancedSearchQuery,
919
SDGFilter,
@@ -201,3 +211,85 @@ async def search_all(
201211
response.status_code = 200
202212

203213
return res
214+
215+
216+
@router.post(
217+
"/documents/by_ids",
218+
summary="get documents payload by ids",
219+
description="Get documents payload by list of document ids",
220+
)
221+
def get_documents_payload_by_ids(
222+
documents_ids: list[uuid.UUID],
223+
) -> list[DocumentPayloadModel]:
224+
with session_maker() as s:
225+
documents = s.execute(
226+
select(
227+
WeLearnDocument.title,
228+
WeLearnDocument.url,
229+
WeLearnDocument.corpus_id,
230+
WeLearnDocument.id,
231+
WeLearnDocument.description,
232+
WeLearnDocument.details,
233+
).where(WeLearnDocument.id.in_(documents_ids))
234+
).all()
235+
236+
# Batch fetch corpora
237+
corpus_ids = list({doc.corpus_id for doc in documents})
238+
239+
corpora = s.execute(
240+
select(Corpus.id, Corpus.source_name).where(Corpus.id.in_(corpus_ids))
241+
).all()
242+
243+
corpus_map = {corpus.id: corpus.source_name for corpus in corpora}
244+
245+
# Batch fetch slices
246+
slices = s.execute(
247+
select(DocumentSlice.id, DocumentSlice.document_id).where(
248+
DocumentSlice.document_id.in_(documents_ids)
249+
)
250+
).all()
251+
252+
# Map document_id -> list of slices
253+
slices_ids_map = {}
254+
slice_ids = []
255+
for slice_ in slices:
256+
slices_ids_map.setdefault(slice_.document_id, []).append(slice_.id)
257+
slice_ids.append(slice_.id)
258+
259+
# Batch fetch SDGs
260+
sdgs = s.execute(
261+
select(Sdg.sdg_number, Sdg.slice_id).where(Sdg.slice_id.in_(slice_ids))
262+
).all()
263+
264+
# Map slice_id -> list of sdgs
265+
sdg_map = {}
266+
for sdg in sdgs:
267+
sdg_map.setdefault(sdg.slice_id, []).append(sdg)
268+
269+
docs = []
270+
for doc in documents:
271+
corpus = corpus_map.get(doc.corpus_id)
272+
slices_id_for_doc = slices_ids_map.get(doc.id, [])
273+
sdgs_for_doc = []
274+
for slice_id in slices_id_for_doc:
275+
sdgs_for_doc.extend(sdg_map.get(slice_id, []))
276+
short_sdg_list = Counter(
277+
[sdg.sdg_number for sdg in sdgs_for_doc]
278+
).most_common(2)
279+
280+
docs.append(
281+
DocumentPayloadModel(
282+
document_id=doc.id,
283+
document_title=doc.title,
284+
document_url=doc.url,
285+
document_desc=doc.description,
286+
document_sdg=[sdg[0] for sdg in short_sdg_list],
287+
document_details=doc.details,
288+
slice_content="",
289+
document_lang="",
290+
document_corpus=corpus if corpus else "",
291+
slice_sdg=None,
292+
)
293+
)
294+
295+
return docs

src/app/api/api_v1/endpoints/user.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from fastapi import APIRouter, HTTPException, Request
55
from sqlalchemy.sql import select
66

7-
from src.app.models.db_models import InferredUser, Session
7+
from src.app.models.db_models import Bookmark, InferredUser, Session
88
from src.app.services.sql_db import session_maker
99
from src.app.utils.logger import logger as logger_utils
1010

@@ -109,3 +109,118 @@ def handle_session(
109109
except Exception as e:
110110
logger.error(f"Error creating session: {e}")
111111
raise HTTPException(status_code=500, detail=f"Error creating session: {e}")
112+
113+
114+
@router.get(
115+
"/:user_id/bookmarks",
116+
summary="get user bookmarks",
117+
description="Get all bookmarks for a user",
118+
)
119+
def get_user_bookmarks(user_id: uuid.UUID):
120+
with session_maker() as s:
121+
user = s.execute(
122+
select(InferredUser.id).where(InferredUser.id == user_id)
123+
).first()
124+
if not user:
125+
logger.error(f"User={user_id} does not exist")
126+
raise HTTPException(
127+
status_code=404, detail=f"User={user_id} does not exist"
128+
)
129+
bookmarks = s.execute(
130+
select(Bookmark).where(Bookmark.inferred_user_id == user_id)
131+
).all()
132+
return {"bookmarks": [bookmark for (bookmark,) in bookmarks]}
133+
134+
135+
@router.delete(
136+
"/:user_id/bookmarks",
137+
summary="delete user bookmarks",
138+
description="Delete all bookmarks for a user",
139+
)
140+
def delete_user_bookmarks(user_id: uuid.UUID):
141+
with session_maker() as s:
142+
user = s.execute(
143+
select(InferredUser.id).where(InferredUser.id == user_id)
144+
).first()
145+
if not user:
146+
logger.error(f"User={user_id} does not exist")
147+
raise HTTPException(
148+
status_code=404, detail=f"User={user_id} does not exist"
149+
)
150+
deleted = (
151+
s.query(Bookmark).filter(Bookmark.inferred_user_id == user_id).delete()
152+
)
153+
s.commit()
154+
logger.info(f"Deleted {deleted} bookmarks for user={user_id}")
155+
return {"deleted": deleted}
156+
157+
158+
@router.delete(
159+
"/:user_id/bookmarks/:document_id",
160+
summary="delete user bookmark",
161+
description="Delete a bookmark for a user",
162+
)
163+
def delete_user_bookmark(user_id: uuid.UUID, document_id: uuid.UUID):
164+
with session_maker() as s:
165+
user = s.execute(
166+
select(InferredUser.id).where(InferredUser.id == user_id)
167+
).first()
168+
169+
if not user:
170+
logger.error(f"User={user_id} does not exist")
171+
raise HTTPException(
172+
status_code=404, detail=f"User={user_id} does not exist"
173+
)
174+
bookmark = s.execute(
175+
select(Bookmark).where(
176+
(Bookmark.inferred_user_id == user_id)
177+
& (Bookmark.document_id == document_id)
178+
)
179+
).first()
180+
if not bookmark:
181+
logger.error(f"Bookmark={document_id} for user={user_id} does not exist")
182+
raise HTTPException(
183+
status_code=404,
184+
detail=f"Bookmark={document_id} for user={user_id} does not exist",
185+
)
186+
s.delete(bookmark[0])
187+
s.commit()
188+
logger.info(f"Deleted bookmark={document_id} for user={user_id}")
189+
return {"deleted": document_id}
190+
191+
192+
@router.post(
193+
"/:user_id/bookmarks/:document_id",
194+
summary="add user bookmark",
195+
description="Add a bookmark for a user",
196+
)
197+
def add_user_bookmark(user_id: uuid.UUID, document_id: uuid.UUID):
198+
with session_maker() as s:
199+
user = s.execute(
200+
select(InferredUser.id).where(InferredUser.id == user_id)
201+
).first()
202+
if not user:
203+
logger.error(f"User={user_id} does not exist")
204+
raise HTTPException(
205+
status_code=404, detail=f"User={user_id} does not exist"
206+
)
207+
bookmark = s.execute(
208+
select(Bookmark).where(
209+
(Bookmark.inferred_user_id == user_id)
210+
& (Bookmark.document_id == document_id)
211+
)
212+
).first()
213+
if bookmark:
214+
logger.error(f"Bookmark={document_id} for user={user_id} already exists")
215+
raise HTTPException(
216+
status_code=400,
217+
detail=f"Bookmark={document_id} for user={user_id} already exists",
218+
)
219+
new_bookmark = Bookmark(
220+
document_id=document_id,
221+
inferred_user_id=user_id,
222+
)
223+
s.add(new_bookmark)
224+
s.commit()
225+
logger.info(f"Added bookmark={document_id} for user={user_id}")
226+
return {"added": document_id}

src/app/models/db_models.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,25 +167,40 @@ class UserProfile(Base):
167167

168168
class Bookmark(Base):
169169
__tablename__ = "bookmark"
170+
__table_args__ = {"schema": "user_related"}
170171

171172
id = Column(
172173
Uuid(as_uuid=True),
173174
primary_key=True,
174175
server_default="gen_random_uuid()",
175176
nullable=False,
176177
)
177-
document_id = Column(
178-
Uuid(as_uuid=True),
178+
document_id: Mapped[UUID] = mapped_column(
179+
types.Uuid,
179180
ForeignKey("document_related.welearn_document.id"),
180181
nullable=False,
181182
)
182-
user_id = Column(
183-
Uuid(as_uuid=True),
184-
ForeignKey("user_related.user_profile.id"),
183+
inferred_user_id: Mapped[UUID] = mapped_column(
184+
types.Uuid,
185+
ForeignKey("user_related.inferred_user.id"),
185186
nullable=False,
186187
)
187188

188-
__table_args__ = {"schema": "user_related"}
189+
created_at: Mapped[datetime] = mapped_column(
190+
TIMESTAMP(timezone=False),
191+
nullable=False,
192+
default=func.localtimestamp(),
193+
server_default="NOW()",
194+
)
195+
updated_at: Mapped[datetime] = mapped_column(
196+
TIMESTAMP(timezone=False),
197+
nullable=False,
198+
default=func.localtimestamp(),
199+
server_default="NOW()",
200+
onupdate=func.localtimestamp(),
201+
)
202+
203+
user = relationship("InferredUser", foreign_keys=[inferred_user_id])
189204

190205

191206
class APIKeyManagement(Base):

src/app/models/documents.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import uuid
2+
13
from pydantic import BaseModel
24

35

@@ -12,7 +14,7 @@ class DocumentPayloadModel(BaseModel):
1214
document_corpus: str
1315
document_desc: str
1416
document_details: dict[str, list[dict] | list[str] | str | int | float | None]
15-
document_id: str
17+
document_id: uuid.UUID
1618
document_lang: str
1719
document_sdg: list[int]
1820
document_title: str

src/app/tests/api/api_v1/test_chat.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"readability": 42.61,
2828
"source": "au",
2929
},
30-
"document_id": "testDocId",
30+
"document_id": "12345678-1234-5678-1234-567812345678",
3131
"document_lang": "en",
3232
"document_scrape_date": "testDate",
3333
"document_sdg": [11, 12, 13, 15, 2, 8],
@@ -108,7 +108,7 @@ async def test_chat_empty_history(self, chat_mock, *mocks):
108108
"readability": 42.61,
109109
"source": "au",
110110
},
111-
document_id="testDocId",
111+
document_id="12345678-1234-5678-1234-567812345678",
112112
document_lang="en",
113113
document_sdg=[11, 12, 13, 15, 2, 8],
114114
document_title="testTitle",
@@ -159,7 +159,7 @@ async def test_chat_rephrase(self, *mocks):
159159
"readability": 42.61,
160160
"source": "au",
161161
},
162-
document_id="testDocId",
162+
document_id="12345678-1234-5678-1234-567812345678",
163163
document_lang="en",
164164
document_sdg=[11, 12, 13, 15, 2, 8],
165165
document_title="testTitle",
@@ -287,7 +287,7 @@ async def test_stream(self, *mocks):
287287
"readability": 42.61,
288288
"source": "au",
289289
},
290-
document_id="testDocId",
290+
document_id="12345678-1234-5678-1234-567812345678",
291291
document_lang="en",
292292
document_sdg=[11, 12, 13, 15, 2, 8],
293293
document_title="testTitle",

0 commit comments

Comments
 (0)