Coverage for open_webui/models/folders.py: 43%

292 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 05:07 +0000

1import logging 

2import re 

3import time 

4import uuid 

5from typing import Optional 

6 

7from open_webui.internal.db import Base, JSONField, get_async_db_context 

8from pydantic import BaseModel, ConfigDict 

9from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, delete, func, select, or_, and_ 

10from sqlalchemy.ext.asyncio import AsyncSession 

11 

12log = logging.getLogger(__name__) 

13 

14 

15#################### 

16# Folder DB Schema 

17# Let every room in this house shelter someone who needs it, 

18# and let no chamber stand empty while there is want. 

19#################### 

20 

21 

22class Folder(Base): 

23 __tablename__ = 'folder' 

24 id = Column(Text, primary_key=True, unique=True) 

25 parent_id = Column(Text, nullable=True) 

26 user_id = Column(Text) 

27 name = Column(Text) 

28 items = Column(JSON, nullable=True) 

29 meta = Column(JSON, nullable=True) 

30 data = Column(JSON, nullable=True) 

31 is_expanded = Column(Boolean, default=False) 

32 created_at = Column(BigInteger) 

33 updated_at = Column(BigInteger) 

34 

35 

36class FolderModel(BaseModel): 

37 id: str 

38 parent_id: Optional[str] = None 

39 user_id: str 

40 name: str 

41 items: Optional[dict] = None 

42 meta: Optional[dict] = None 

43 data: Optional[dict] = None 

44 is_expanded: bool = False 

45 created_at: int 

46 updated_at: int 

47 

48 model_config = ConfigDict(from_attributes=True) 

49 

50 

51class FolderMetadataResponse(BaseModel): 

52 icon: Optional[str] = None 

53 

54 

55class FolderNameIdResponse(BaseModel): 

56 id: str 

57 name: str 

58 meta: Optional[FolderMetadataResponse] = None 

59 parent_id: Optional[str] = None 

60 is_expanded: bool = False 

61 unread_count: int = 0 

62 created_at: int 

63 updated_at: int 

64 

65 

66class SharedFolderResponse(BaseModel): 

67 id: str 

68 name: str 

69 parent_id: Optional[str] = None 

70 user_id: str 

71 owner_name: Optional[str] = None 

72 permission: str = 'read' 

73 access_grants: list = [] 

74 is_expanded: bool = False 

75 meta: Optional[dict] = None 

76 created_at: int 

77 updated_at: int 

78 

79 

80#################### 

81# Forms 

82#################### 

83 

84 

85class FolderForm(BaseModel): 

86 name: str 

87 data: Optional[dict] = None 

88 meta: Optional[dict] = None 

89 parent_id: Optional[str] = None 

90 model_config = ConfigDict(extra='forbid') 

91 

92 

93class FolderUpdateForm(BaseModel): 

94 name: Optional[str] = None 

95 data: Optional[dict] = None 

96 meta: Optional[dict] = None 

97 model_config = ConfigDict(extra='forbid') 

98 

99 

100class FolderTable: 

101 async def insert_new_folder( 

102 self, 

103 user_id: str, 

104 form_data: FolderForm, 

105 parent_id: Optional[str] = None, 

106 db: Optional[AsyncSession] = None, 

107 ) -> Optional[FolderModel]: 

108 async with get_async_db_context(db) as db: 

109 id = str(uuid.uuid4()) 

110 folder = FolderModel( 

111 **{ 

112 'id': id, 

113 'user_id': user_id, 

114 **(form_data.model_dump(exclude_unset=True) or {}), 

115 'parent_id': parent_id, 

116 'created_at': int(time.time()), 

117 'updated_at': int(time.time()), 

118 } 

119 ) 

120 try: 

121 result = Folder(**folder.model_dump()) 

122 db.add(result) 

123 await db.commit() 

124 await db.refresh(result) 

125 if result: 

126 return FolderModel.model_validate(result) 

127 else: 

128 return None 

129 except Exception as e: 

130 log.exception(f'Error inserting a new folder: {e}') 

131 return None 

132 

133 async def get_folder_by_id_and_user_id( 

134 self, id: str, user_id: str, db: Optional[AsyncSession] = None 

135 ) -> Optional[FolderModel]: 

136 try: 

137 async with get_async_db_context(db) as db: 

138 result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id)) 

139 folder = result.scalars().first() 

140 

141 if not folder: 

142 return None 

143 

144 return FolderModel.model_validate(folder) 

145 except Exception: 

146 return None 

147 

148 async def get_folder_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[FolderModel]: 

149 """Fetch folder by ID only (no user_id filter). Used for shared access.""" 

150 try: 

151 async with get_async_db_context(db) as db: 

152 result = await db.execute(select(Folder).filter_by(id=id)) 

153 folder = result.scalars().first() 

154 if not folder: 

155 return None 

156 return FolderModel.model_validate(folder) 

157 except Exception: 

158 return None 

159 

160 async def get_folders_by_ids(self, ids: list[str], db: AsyncSession | None = None) -> list[FolderModel]: 

161 async with get_async_db_context(db) as db: 

162 result = await db.execute(select(Folder).filter(Folder.id.in_(ids)).order_by(Folder.updated_at.desc())) 

163 return [FolderModel.model_validate(folder) for folder in result.scalars().all()] 

164 

165 async def get_shared_folder_ids_for_user( 

166 self, user_id: str, user_group_ids: set[str], db: Optional[AsyncSession] = None 

167 ) -> dict[str, str]: 

168 """ 

169 Returns {folder_id: highest_permission} for all folders shared with user. 

170 Checks direct user grants, group grants, and public (user:*) grants. 

171 """ 

172 from open_webui.models.access_grants import AccessGrant 

173 

174 async with get_async_db_context(db) as db: 

175 conditions = [ 

176 and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == '*'), 

177 and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == user_id), 

178 ] 

179 if user_group_ids: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true

180 conditions.append( 

181 and_(AccessGrant.principal_type == 'group', AccessGrant.principal_id.in_(user_group_ids)) 

182 ) 

183 result = await db.execute( 

184 select(AccessGrant).filter( 

185 AccessGrant.resource_type == 'folder', 

186 or_(*conditions), 

187 ) 

188 ) 

189 grants = result.scalars().all() 

190 

191 # Build {folder_id: highest_permission} ('write' > 'read') 

192 folder_perms = {} 

193 for g in grants: 

194 existing = folder_perms.get(g.resource_id) 

195 if existing != 'write': 

196 folder_perms[g.resource_id] = g.permission 

197 return folder_perms 

198 

199 async def get_children_folders_by_id_and_user_id( 

200 self, id: str, user_id: str, db: Optional[AsyncSession] = None 

201 ) -> Optional[list[FolderModel]]: 

202 try: 

203 async with get_async_db_context(db) as db: 

204 folders = [] 

205 seen_ids = {id} 

206 

207 async def get_children(folder): 

208 children = await self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db) 

209 for child in children: 

210 if child.id in seen_ids: 

211 continue 

212 seen_ids.add(child.id) 

213 await get_children(child) 

214 folders.append(child) 

215 

216 result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id)) 

217 folder = result.scalars().first() 

218 if not folder: 

219 return None 

220 

221 await get_children(folder) 

222 return folders 

223 except Exception: 

224 return None 

225 

226 async def get_folders_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[FolderModel]: 

227 async with get_async_db_context(db) as db: 

228 result = await db.execute(select(Folder).filter_by(user_id=user_id)) 

229 return [FolderModel.model_validate(folder) for folder in result.scalars().all()] 

230 

231 async def get_folder_by_parent_id_and_user_id_and_name( 

232 self, 

233 parent_id: Optional[str], 

234 user_id: str, 

235 name: str, 

236 db: Optional[AsyncSession] = None, 

237 ) -> Optional[FolderModel]: 

238 try: 

239 async with get_async_db_context(db) as db: 

240 # Check if folder exists 

241 result = await db.execute( 

242 select(Folder) 

243 .filter_by(parent_id=parent_id, user_id=user_id) 

244 .filter(func.lower(Folder.name) == func.lower(name)) 

245 ) 

246 folder = result.scalars().first() 

247 

248 if not folder: 

249 return None 

250 

251 return FolderModel.model_validate(folder) 

252 except Exception as e: 

253 log.error(f'get_folder_by_parent_id_and_user_id_and_name: {e}') 

254 return None 

255 

256 async def get_folders_by_parent_id_and_user_id( 

257 self, parent_id: Optional[str], user_id: str, db: Optional[AsyncSession] = None 

258 ) -> list[FolderModel]: 

259 async with get_async_db_context(db) as db: 

260 result = await db.execute( 

261 select(Folder).filter_by(parent_id=parent_id, user_id=user_id).order_by(Folder.updated_at.desc()) 

262 ) 

263 return [FolderModel.model_validate(folder) for folder in result.scalars().all()] 

264 

265 async def get_folder_ids_by_id_and_user_id_in_subtree( 

266 self, id: str, user_id: str, db: Optional[AsyncSession] = None 

267 ) -> list[str]: 

268 async with get_async_db_context(db) as db: 

269 result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id)) 

270 folder = result.scalars().first() 

271 if not folder: 

272 return [] 

273 

274 folder_ids = {folder.id} 

275 folders = [FolderModel.model_validate(folder)] 

276 while folders: 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true

277 current_folder = folders.pop() 

278 children = await self.get_folders_by_parent_id_and_user_id(current_folder.id, user_id, db=db) 

279 for child in children: 279 ↛ 280line 279 didn't jump to line 280 because the loop on line 279 never started

280 if child.id not in folder_ids: 

281 folder_ids.add(child.id) 

282 folders.append(child) 

283 

284 return list(folder_ids) 

285 

286 async def update_folder_parent_id_by_id_and_user_id( 

287 self, 

288 id: str, 

289 user_id: str, 

290 parent_id: str, 

291 db: Optional[AsyncSession] = None, 

292 ) -> Optional[FolderModel]: 

293 try: 

294 async with get_async_db_context(db) as db: 

295 result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id)) 

296 folder = result.scalars().first() 

297 

298 if not folder: 

299 return None 

300 

301 folder.parent_id = parent_id 

302 folder.updated_at = int(time.time()) 

303 

304 await db.commit() 

305 

306 return FolderModel.model_validate(folder) 

307 except Exception as e: 

308 log.error(f'update_folder: {e}') 

309 return 

310 

311 async def update_folder_by_id_and_user_id( 

312 self, 

313 id: str, 

314 user_id: str, 

315 form_data: FolderUpdateForm, 

316 db: Optional[AsyncSession] = None, 

317 ) -> Optional[FolderModel]: 

318 try: 

319 async with get_async_db_context(db) as db: 

320 result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id)) 

321 folder = result.scalars().first() 

322 

323 if not folder: 

324 return None 

325 

326 form_data = form_data.model_dump(exclude_unset=True) 

327 

328 existing_result = await db.execute( 

329 select(Folder).filter_by( 

330 name=form_data.get('name'), 

331 parent_id=folder.parent_id, 

332 user_id=user_id, 

333 ) 

334 ) 

335 existing_folder = existing_result.scalars().first() 

336 

337 if existing_folder and existing_folder.id != id: 

338 return None 

339 

340 folder.name = form_data.get('name', folder.name) 

341 if 'data' in form_data: 

342 folder.data = { 

343 **(folder.data or {}), 

344 **form_data['data'], 

345 } 

346 

347 if 'meta' in form_data: 

348 folder.meta = { 

349 **(folder.meta or {}), 

350 **form_data['meta'], 

351 } 

352 

353 folder.updated_at = int(time.time()) 

354 await db.commit() 

355 

356 return FolderModel.model_validate(folder) 

357 except Exception as e: 

358 log.error(f'update_folder: {e}') 

359 return 

360 

361 async def update_folder_is_expanded_by_id_and_user_id( 

362 self, id: str, user_id: str, is_expanded: bool, db: Optional[AsyncSession] = None 

363 ) -> Optional[FolderModel]: 

364 try: 

365 async with get_async_db_context(db) as db: 

366 result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id)) 

367 folder = result.scalars().first() 

368 

369 if not folder: 

370 return None 

371 

372 folder.is_expanded = is_expanded 

373 folder.updated_at = int(time.time()) 

374 

375 await db.commit() 

376 

377 return FolderModel.model_validate(folder) 

378 except Exception as e: 

379 log.error(f'update_folder: {e}') 

380 return 

381 

382 async def delete_folder_by_id_and_user_id( 

383 self, id: str, user_id: str, db: Optional[AsyncSession] = None 

384 ) -> list[str]: 

385 try: 

386 folder_ids = [] 

387 async with get_async_db_context(db) as db: 

388 result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id)) 

389 folder = result.scalars().first() 

390 if not folder: 

391 return folder_ids 

392 

393 folder_ids.append(folder.id) 

394 seen_ids = {folder.id} 

395 

396 # Delete all children folders 

397 async def delete_children(folder): 

398 folder_children = await self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db) 

399 for folder_child in folder_children: 

400 if folder_child.id in seen_ids: 

401 continue 

402 seen_ids.add(folder_child.id) 

403 await delete_children(folder_child) 

404 folder_ids.append(folder_child.id) 

405 

406 child_result = await db.execute(select(Folder).filter_by(id=folder_child.id)) 

407 child_folder = child_result.scalars().first() 

408 await db.delete(child_folder) 

409 await db.commit() 

410 

411 await delete_children(folder) 

412 await db.delete(folder) 

413 await db.commit() 

414 return folder_ids 

415 except Exception as e: 

416 log.error(f'delete_folder: {e}') 

417 return [] 

418 

419 def normalize_folder_name(self, name: str) -> str: 

420 # Replace _ and space with a single space, lower case, collapse multiple spaces 

421 name = re.sub(r'[\s_]+', ' ', name) 

422 return name.strip().lower() 

423 

424 async def search_folders_by_names( 

425 self, user_id: str, queries: list[str], db: Optional[AsyncSession] = None 

426 ) -> list[FolderModel]: 

427 """ 

428 Search for folders for a user where the name matches any of the queries, treating _ and space as equivalent, case-insensitive. 

429 """ 

430 normalized_queries = [self.normalize_folder_name(q) for q in queries] 

431 if not normalized_queries: 431 ↛ 434line 431 didn't jump to line 434 because the condition on line 431 was always true

432 return [] 

433 

434 results = {} 

435 async with get_async_db_context(db) as db: 

436 result = await db.execute(select(Folder).filter_by(user_id=user_id)) 

437 folders = result.scalars().all() 

438 for folder in folders: 

439 if self.normalize_folder_name(folder.name) in normalized_queries: 

440 results[folder.id] = FolderModel.model_validate(folder) 

441 

442 # get children folders 

443 children = await self.get_children_folders_by_id_and_user_id(folder.id, user_id, db=db) 

444 if children: 

445 for child in children: 

446 results[child.id] = child 

447 

448 # Return the results as a list 

449 if not results: 

450 return [] 

451 else: 

452 results = list(results.values()) 

453 return results 

454 

455 async def search_folders_by_name_contains( 

456 self, user_id: str, query: str, db: Optional[AsyncSession] = None 

457 ) -> list[FolderModel]: 

458 """ 

459 Partial match: normalized name contains (as substring) the normalized query. 

460 """ 

461 normalized_query = self.normalize_folder_name(query) 

462 results = [] 

463 async with get_async_db_context(db) as db: 

464 result = await db.execute(select(Folder).filter_by(user_id=user_id)) 

465 folders = result.scalars().all() 

466 for folder in folders: 

467 norm_name = self.normalize_folder_name(folder.name) 

468 if normalized_query in norm_name: 

469 results.append(FolderModel.model_validate(folder)) 

470 return results 

471 

472 

473Folders = FolderTable()