Coverage for src/cstlcore/glossary/router.py: 24%

102 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2026-02-19 12:46 +0000

1import uuid 

2 

3from fastapi import APIRouter, Depends, HTTPException, Query 

4from loguru import logger 

5from sqlmodel import Session, select 

6from rapidfuzz import fuzz 

7 

8from cstlcore.constellations.models import Constellation 

9from cstlcore.database.dependencies import get_session 

10from cstlcore.glossary.models import ( 

11 GlossaryTerm, 

12 GlossaryTermCreate, 

13 GlossaryTermPublic, 

14 GlossaryTermUpdate, 

15) 

16from cstlcore.memberships.dependencies import require_read_access, require_write_access 

17 

18router = APIRouter() 

19 

20 

21@router.post( 

22 "/constellations/{constellation_id}/glossary", 

23 response_model=GlossaryTermPublic, 

24 status_code=201, 

25) 

26async def create_glossary_term( 

27 term_data: GlossaryTermCreate, 

28 constellation: Constellation = Depends(require_write_access), 

29 session: Session = Depends(get_session), 

30) -> GlossaryTerm: 

31 """ 

32 Create a new glossary term. 

33 """ 

34 logger.info( 

35 f"Creating glossary term '{term_data.term}' for constellation {constellation.id}" 

36 ) 

37 

38 # Check that the term does not already exist in this constellation 

39 statement = select(GlossaryTerm).where( 

40 GlossaryTerm.constellation_id == constellation.id, 

41 GlossaryTerm.term == term_data.term, 

42 ) 

43 existing_term = session.exec(statement).first() 

44 if existing_term: 

45 raise HTTPException( 

46 status_code=409, 

47 detail=f"Term '{term_data.term}' already exists in this constellation", 

48 ) 

49 

50 # Validate that synonym and related term IDs exist within the constellation 

51 all_related_ids = set(term_data.synonym_ids + term_data.related_term_ids) 

52 if all_related_ids: 

53 existing_ids = set( 

54 str(term.id) 

55 for term in session.exec( 

56 select(GlossaryTerm).where( 

57 GlossaryTerm.constellation_id == constellation.id, 

58 GlossaryTerm.id.in_([uuid.UUID(id) for id in all_related_ids]), 

59 ) 

60 ).all() 

61 ) 

62 invalid_ids = all_related_ids - existing_ids 

63 if invalid_ids: 

64 raise HTTPException( 

65 status_code=400, 

66 detail=f"Invalid term IDs: {', '.join(invalid_ids)}", 

67 ) 

68 

69 # Create the term 

70 new_term = GlossaryTerm.model_validate( 

71 term_data, update={"constellation_id": constellation.id} 

72 ) 

73 session.add(new_term) 

74 session.commit() 

75 session.refresh(new_term) 

76 

77 logger.info(f"Glossary term {new_term.id} created successfully") 

78 return new_term 

79 

80 

81@router.get( 

82 "/constellations/{constellation_id}/glossary", 

83 response_model=list[GlossaryTermPublic], 

84) 

85async def get_glossary_terms( 

86 constellation: Constellation = Depends(require_read_access), 

87 session: Session = Depends(get_session), 

88) -> list[GlossaryTerm]: 

89 """ 

90 Retrieve all glossary terms for a constellation. 

91 """ 

92 logger.info(f"Fetching glossary terms for constellation {constellation.id}") 

93 

94 statement = ( 

95 select(GlossaryTerm) 

96 .where(GlossaryTerm.constellation_id == constellation.id) 

97 .order_by(GlossaryTerm.term) 

98 ) 

99 terms = session.exec(statement).all() 

100 

101 return terms 

102 

103 

104@router.get( 

105 "/constellations/{constellation_id}/glossary/search", 

106 response_model=list[GlossaryTermPublic], 

107) 

108async def search_glossary_terms( 

109 search_query: str = Query(..., description="The search query"), 

110 limit: int = Query(default=100, ge=0, description="The maximum number of items to return"), 

111 page: int = Query(default=1, ge=1, description="The page number to return"), 

112 constellation: Constellation = Depends(require_read_access), 

113 session: Session = Depends(get_session), 

114) -> list[GlossaryTerm]: 

115 """ 

116 Search glossary terms within a constellation by term name, term definition, or examples (case-insensitive, partial match/fuzzy). 

117 """ 

118 logger.info( 

119 f"Searching glossary terms in constellation {constellation.id} for query '{search_query}'" 

120 ) 

121 

122 # Get all terms from the DB 

123 all_terms = session.exec( 

124 select(GlossaryTerm).where( 

125 GlossaryTerm.constellation_id == constellation.id 

126 ) 

127 ).all() 

128 

129 # Then filter search using fuzzy matching (fuzz.partial_ratio >= 80) 

130 matched_terms = [] 

131 for term in all_terms: 

132 combined_text = f"{term.term} {term.definition or ''} {' '.join(term.examples or [])}" 

133 score = fuzz.partial_ratio(search_query.lower(), combined_text.lower()) 

134 if score >= 80: 

135 matched_terms.append((term, score)) 

136 

137 # Sort by score descending 

138 matched_terms.sort(key=lambda x: x[1], reverse=True) 

139 

140 # Paginate 

141 start_index = (page - 1) * limit 

142 end_index = start_index + limit 

143 if start_index >= len(matched_terms): 

144 return [] 

145 paginated_terms = [term for term, score in matched_terms[start_index:end_index]] 

146 

147 return paginated_terms 

148 

149 

150@router.get( 

151 "/constellations/{constellation_id}/glossary/{term_id}", 

152 response_model=GlossaryTermPublic, 

153) 

154async def get_glossary_term( 

155 term_id: uuid.UUID, 

156 constellation: Constellation = Depends(require_read_access), 

157 session: Session = Depends(get_session), 

158) -> GlossaryTerm: 

159 """ 

160 Retrieve a specific glossary term. 

161 """ 

162 logger.info(f"Fetching glossary term {term_id}") 

163 

164 term = session.exec( 

165 select(GlossaryTerm).where( 

166 GlossaryTerm.id == term_id, 

167 GlossaryTerm.constellation_id == constellation.id, 

168 ) 

169 ).first() 

170 if not term: 

171 raise HTTPException(status_code=404, detail="Glossary term not found") 

172 

173 return term 

174 

175 

176@router.patch( 

177 "/constellations/{constellation_id}/glossary/{term_id}", 

178 response_model=GlossaryTermPublic, 

179) 

180async def update_glossary_term( 

181 term_id: uuid.UUID, 

182 term_update: GlossaryTermUpdate, 

183 constellation: Constellation = Depends(require_write_access), 

184 session: Session = Depends(get_session), 

185) -> GlossaryTerm: 

186 """ 

187 Update a glossary term. 

188 """ 

189 logger.info(f"Updating glossary term {term_id}") 

190 

191 term = session.exec( 

192 select(GlossaryTerm).where( 

193 GlossaryTerm.id == term_id, 

194 GlossaryTerm.constellation_id == constellation.id, 

195 ) 

196 ).first() 

197 if not term: 

198 raise HTTPException(status_code=404, detail="Glossary term not found") 

199 

200 # Validate that synonym and related term IDs exist within the constellation 

201 update_data = term_update.model_dump(exclude_unset=True) 

202 if "synonym_ids" in update_data or "related_term_ids" in update_data: 

203 all_related_ids = set( 

204 update_data.get("synonym_ids", []) + update_data.get("related_term_ids", []) 

205 ) 

206 if all_related_ids: 

207 existing_ids = set( 

208 str(t.id) 

209 for t in session.exec( 

210 select(GlossaryTerm).where( 

211 GlossaryTerm.constellation_id == constellation.id, 

212 GlossaryTerm.id.in_([uuid.UUID(id) for id in all_related_ids]), 

213 ) 

214 ).all() 

215 ) 

216 invalid_ids = all_related_ids - existing_ids 

217 if invalid_ids: 

218 raise HTTPException( 

219 status_code=400, 

220 detail=f"Invalid term IDs: {', '.join(invalid_ids)}", 

221 ) 

222 

223 # Update the provided fields 

224 term.sqlmodel_update(update_data) 

225 

226 session.add(term) 

227 session.commit() 

228 session.refresh(term) 

229 

230 logger.info(f"Glossary term {term_id} updated successfully") 

231 return term 

232 

233 

234@router.delete("/constellations/{constellation_id}/glossary/{term_id}") 

235async def delete_glossary_term( 

236 term_id: uuid.UUID, 

237 constellation: Constellation = Depends(require_write_access), 

238 session: Session = Depends(get_session), 

239): 

240 """ 

241 Delete a glossary term. 

242 """ 

243 logger.info(f"Deleting glossary term {term_id}") 

244 

245 term = session.exec( 

246 select(GlossaryTerm).where( 

247 GlossaryTerm.id == term_id, 

248 GlossaryTerm.constellation_id == constellation.id, 

249 ) 

250 ).first() 

251 if not term: 

252 raise HTTPException(status_code=404, detail="Glossary term not found") 

253 

254 session.delete(term) 

255 session.commit() 

256 

257 logger.info(f"Glossary term {term_id} deleted successfully") 

258 return {"ok": True} 

259 

260 

261@router.get( 

262 "/constellations/{constellation_id}/glossary/{term_id}/related", 

263 response_model=dict[str, list[GlossaryTermPublic]], 

264) 

265async def get_related_terms( 

266 term_id: uuid.UUID, 

267 constellation: Constellation = Depends(require_read_access), 

268 session: Session = Depends(get_session), 

269): 

270 """ 

271 Get the related terms (synonyms and related_terms) of a term. 

272 Return a dictionary with keys 'synonyms' and 'related_terms'. 

273 """ 

274 logger.info(f"Fetching related terms for glossary term {term_id}") 

275 

276 term = session.exec( 

277 select(GlossaryTerm).where( 

278 GlossaryTerm.id == term_id, 

279 GlossaryTerm.constellation_id == constellation.id, 

280 ) 

281 ).first() 

282 if not term: 

283 raise HTTPException(status_code=404, detail="Glossary term not found") 

284 

285 # Retrieve synonyms 

286 synonyms = [] 

287 if term.synonym_ids: 

288 synonyms = list( 

289 session.exec( 

290 select(GlossaryTerm).where( 

291 GlossaryTerm.id.in_([uuid.UUID(id) for id in term.synonym_ids]), 

292 GlossaryTerm.constellation_id == constellation.id, 

293 ) 

294 ).all() 

295 ) 

296 

297 # Retrieve related terms 

298 related_terms: list[GlossaryTermPublic] = [] 

299 if term.related_term_ids: 

300 related_terms = list( 

301 session.exec( 

302 select(GlossaryTerm).where( 

303 GlossaryTerm.id.in_( 

304 [uuid.UUID(id) for id in term.related_term_ids] 

305 ), 

306 GlossaryTerm.constellation_id == constellation.id, 

307 ) 

308 ).all() 

309 ) 

310 

311 return {"synonyms": synonyms, "related_terms": related_terms}