Coverage for open_webui/models/skills.py: 71%

204 statements  

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

1import logging 

2import time 

3from typing import Optional 

4 

5from open_webui.internal.db import Base, get_async_db_context 

6from open_webui.models.access_grants import AccessGrantModel, AccessGrants 

7from open_webui.models.groups import Groups 

8from open_webui.models.users import User, UserModel, UserResponse, Users 

9from pydantic import BaseModel, ConfigDict, Field 

10from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, delete, func, or_, select, update 

11from sqlalchemy.ext.asyncio import AsyncSession 

12 

13log = logging.getLogger(__name__) 

14 

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

16# Skills DB Schema 

17#################### 

18 

19 

20class Skill(Base): 

21 __tablename__ = 'skill' 

22 

23 id = Column(String, primary_key=True, unique=True) 

24 user_id = Column(String) 

25 name = Column(Text, unique=True) 

26 description = Column(Text, nullable=True) 

27 content = Column(Text) 

28 meta = Column(JSON) 

29 is_active = Column(Boolean, default=True) 

30 

31 updated_at = Column(BigInteger) 

32 created_at = Column(BigInteger) 

33 

34 

35class SkillMeta(BaseModel): 

36 i18n: dict[str, dict[str, str]] | None = None 

37 tags: Optional[list[str]] = [] 

38 

39 

40class SkillModel(BaseModel): 

41 id: str 

42 user_id: str 

43 name: str 

44 description: Optional[str] = None 

45 content: str 

46 meta: SkillMeta 

47 is_active: bool = True 

48 access_grants: list[AccessGrantModel] = Field(default_factory=list) 

49 

50 updated_at: int # timestamp in epoch 

51 created_at: int # timestamp in epoch 

52 

53 model_config = ConfigDict(from_attributes=True) 

54 

55 

56#################### 

57# Forms 

58#################### 

59 

60 

61class SkillUserModel(SkillModel): 

62 user: Optional[UserResponse] = None 

63 

64 

65class SkillResponse(BaseModel): 

66 id: str 

67 user_id: str 

68 name: str 

69 description: Optional[str] = None 

70 meta: SkillMeta 

71 is_active: bool = True 

72 access_grants: list[AccessGrantModel] = Field(default_factory=list) 

73 updated_at: int # timestamp in epoch 

74 created_at: int # timestamp in epoch 

75 

76 

77class SkillUserResponse(SkillResponse): 

78 user: Optional[UserResponse] = None 

79 

80 model_config = ConfigDict(extra='allow') 

81 

82 

83class SkillAccessResponse(SkillUserResponse): 

84 write_access: Optional[bool] = False 

85 

86 

87class SkillForm(BaseModel): 

88 id: str 

89 name: str 

90 description: Optional[str] = None 

91 content: str 

92 meta: SkillMeta = SkillMeta() 

93 is_active: bool = True 

94 access_grants: Optional[list[dict]] = None 

95 

96 

97class SkillListResponse(BaseModel): 

98 items: list[SkillUserResponse] = [] 

99 total: int = 0 

100 

101 

102class SkillAccessListResponse(BaseModel): 

103 items: list[SkillAccessResponse] = [] 

104 total: int = 0 

105 

106 

107class SkillsTable: 

108 async def _get_access_grants(self, skill_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]: 

109 return await AccessGrants.get_grants_by_resource('skill', skill_id, db=db) 

110 

111 async def _to_skill_model( 

112 self, 

113 skill: Skill, 

114 access_grants: Optional[list[AccessGrantModel]] = None, 

115 db: Optional[AsyncSession] = None, 

116 ) -> SkillModel: 

117 skill_model = SkillModel.model_validate(skill) 

118 skill_model.access_grants = ( 

119 access_grants if access_grants is not None else await self._get_access_grants(skill_model.id, db=db) 

120 ) 

121 return skill_model 

122 

123 async def insert_new_skill( 

124 self, 

125 user_id: str, 

126 form_data: SkillForm, 

127 db: Optional[AsyncSession] = None, 

128 ) -> Optional[SkillModel]: 

129 async with get_async_db_context(db) as db: 

130 try: 

131 result = Skill( 

132 **{ 

133 **form_data.model_dump(exclude={'access_grants'}), 

134 'user_id': user_id, 

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

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

137 } 

138 ) 

139 db.add(result) 

140 await db.commit() 

141 await AccessGrants.set_access_grants('skill', result.id, form_data.access_grants, db=db) 

142 if result: 142 ↛ 145line 142 didn't jump to line 145 because the condition on line 142 was always true

143 return await self._to_skill_model(result, db=db) 

144 else: 

145 return None 

146 except Exception as e: 

147 log.exception(f'Error creating a new skill: {e}') 

148 return None 

149 

150 async def get_skill_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[SkillModel]: 

151 try: 

152 async with get_async_db_context(db) as db: 

153 skill = await db.get(Skill, id) 

154 return await self._to_skill_model(skill, db=db) if skill else None 

155 except Exception: 

156 return None 

157 

158 async def get_skill_by_name(self, name: str, db: Optional[AsyncSession] = None) -> Optional[SkillModel]: 

159 try: 

160 async with get_async_db_context(db) as db: 

161 result = await db.execute(select(Skill).filter_by(name=name)) 

162 skill = result.scalars().first() 

163 return await self._to_skill_model(skill, db=db) if skill else None 

164 except Exception: 

165 return None 

166 

167 async def get_skills( 

168 self, 

169 user_id: str | None = None, 

170 ids: list[str] | None = None, 

171 db: AsyncSession | None = None, 

172 ) -> list[SkillUserModel]: 

173 async with get_async_db_context(db) as db: 

174 stmt = select(Skill).order_by(Skill.updated_at.desc()) 

175 

176 if ids is not None: 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true

177 stmt = stmt.filter(Skill.id.in_(ids)) 

178 

179 if user_id is not None: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true

180 user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)} 

181 stmt = AccessGrants.has_permission_filter( 

182 db=db, 

183 query=stmt, 

184 DocumentModel=Skill, 

185 filter={'user_id': user_id, 'group_ids': user_group_ids}, 

186 resource_type='skill', 

187 permission='read', 

188 ) 

189 

190 result = await db.execute(stmt) 

191 all_skills = result.scalars().all() 

192 

193 user_ids = list(set(skill.user_id for skill in all_skills)) 

194 skill_ids = [skill.id for skill in all_skills] 

195 

196 users = await Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] 

197 users_dict = {user.id: user for user in users} 

198 grants_map = await AccessGrants.get_grants_by_resources('skill', skill_ids, db=db) 

199 

200 skills = [] 

201 for skill in all_skills: 

202 user = users_dict.get(skill.user_id) 

203 skills.append( 

204 SkillUserModel.model_validate( 

205 { 

206 **( 

207 await self._to_skill_model( 

208 skill, 

209 access_grants=grants_map.get(skill.id, []), 

210 db=db, 

211 ) 

212 ).model_dump(), 

213 'user': user.model_dump() if user else None, 

214 } 

215 ) 

216 ) 

217 return skills 

218 

219 async def search_skills( 

220 self, 

221 user_id: str, 

222 filter: dict = {}, 

223 skip: int = 0, 

224 limit: int = 30, 

225 db: Optional[AsyncSession] = None, 

226 ) -> SkillListResponse: 

227 try: 

228 async with get_async_db_context(db) as db: 

229 # Join with User table for user filtering 

230 stmt = select(Skill, User).outerjoin(User, User.id == Skill.user_id) 

231 

232 if filter: 

233 query_key = filter.get('query') 

234 if query_key: 

235 stmt = stmt.filter( 

236 or_( 

237 Skill.name.ilike(f'%{query_key}%'), 

238 Skill.description.ilike(f'%{query_key}%'), 

239 Skill.id.ilike(f'%{query_key}%'), 

240 User.name.ilike(f'%{query_key}%'), 

241 User.email.ilike(f'%{query_key}%'), 

242 ) 

243 ) 

244 

245 view_option = filter.get('view_option') 

246 if view_option == 'created': 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true

247 stmt = stmt.filter(Skill.user_id == user_id) 

248 elif view_option == 'shared': 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true

249 stmt = stmt.filter(Skill.user_id != user_id) 

250 

251 # Apply access grant filtering 

252 stmt = AccessGrants.has_permission_filter( 

253 db=db, 

254 query=stmt, 

255 DocumentModel=Skill, 

256 filter=filter, 

257 resource_type='skill', 

258 permission='read', 

259 ) 

260 

261 order_by = filter.get('order_by') 

262 direction = filter.get('direction') 

263 

264 if order_by == 'name': 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true

265 if direction == 'asc': 

266 stmt = stmt.order_by(Skill.name.asc()) 

267 else: 

268 stmt = stmt.order_by(Skill.name.desc()) 

269 elif order_by == 'created_at': 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true

270 if direction == 'asc': 

271 stmt = stmt.order_by(Skill.created_at.asc()) 

272 else: 

273 stmt = stmt.order_by(Skill.created_at.desc()) 

274 elif order_by == 'updated_at': 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true

275 if direction == 'asc': 

276 stmt = stmt.order_by(Skill.updated_at.asc()) 

277 else: 

278 stmt = stmt.order_by(Skill.updated_at.desc()) 

279 else: 

280 stmt = stmt.order_by(Skill.updated_at.desc()) 

281 

282 # Count BEFORE pagination 

283 count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) 

284 total = count_result.scalar() 

285 

286 if skip: 

287 stmt = stmt.offset(skip) 

288 if limit: 

289 stmt = stmt.limit(limit) 

290 

291 result = await db.execute(stmt) 

292 items = result.all() 

293 

294 skill_ids = [skill.id for skill, _ in items] 

295 grants_map = await AccessGrants.get_grants_by_resources('skill', skill_ids, db=db) 

296 

297 skills = [] 

298 for skill, user in items: 

299 skills.append( 

300 SkillUserResponse( 

301 **( 

302 await self._to_skill_model( 

303 skill, 

304 access_grants=grants_map.get(skill.id, []), 

305 db=db, 

306 ) 

307 ).model_dump(), 

308 user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), 

309 ) 

310 ) 

311 

312 return SkillListResponse(items=skills, total=total) 

313 except Exception as e: 

314 log.exception(f'Error searching skills: {e}') 

315 return SkillListResponse(items=[], total=0) 

316 

317 async def update_skill_by_id( 

318 self, id: str, updated: dict, db: Optional[AsyncSession] = None 

319 ) -> Optional[SkillModel]: 

320 try: 

321 async with get_async_db_context(db) as db: 

322 access_grants = updated.pop('access_grants', None) 

323 await db.execute(update(Skill).filter_by(id=id).values(**updated, updated_at=int(time.time()))) 

324 await db.commit() 

325 if access_grants is not None: 

326 await AccessGrants.set_access_grants('skill', id, access_grants, db=db) 

327 

328 # populate_existing: the Core update above bypasses any identity-map copy 

329 skill = await db.get(Skill, id, populate_existing=True) 

330 return await self._to_skill_model(skill, db=db) 

331 except Exception: 

332 return None 

333 

334 async def toggle_skill_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[SkillModel]: 

335 async with get_async_db_context(db) as db: 

336 try: 

337 result = await db.execute(select(Skill).filter_by(id=id)) 

338 skill = result.scalars().first() 

339 if not skill: 

340 return None 

341 

342 skill.is_active = not skill.is_active 

343 skill.updated_at = int(time.time()) 

344 await db.commit() 

345 

346 return await self._to_skill_model(skill, db=db) 

347 except Exception: 

348 return None 

349 

350 async def delete_skill_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: 

351 try: 

352 async with get_async_db_context(db) as db: 

353 await AccessGrants.revoke_all_access('skill', id, db=db) 

354 await db.execute(delete(Skill).filter_by(id=id)) 

355 await db.commit() 

356 

357 return True 

358 except Exception: 

359 return False 

360 

361 

362Skills = SkillsTable()