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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1import logging
2import time
3from typing import Optional
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
13log = logging.getLogger(__name__)
15####################
16# Skills DB Schema
17####################
20class Skill(Base):
21 __tablename__ = 'skill'
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)
31 updated_at = Column(BigInteger)
32 created_at = Column(BigInteger)
35class SkillMeta(BaseModel):
36 i18n: dict[str, dict[str, str]] | None = None
37 tags: Optional[list[str]] = []
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)
50 updated_at: int # timestamp in epoch
51 created_at: int # timestamp in epoch
53 model_config = ConfigDict(from_attributes=True)
56####################
57# Forms
58####################
61class SkillUserModel(SkillModel):
62 user: Optional[UserResponse] = None
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
77class SkillUserResponse(SkillResponse):
78 user: Optional[UserResponse] = None
80 model_config = ConfigDict(extra='allow')
83class SkillAccessResponse(SkillUserResponse):
84 write_access: Optional[bool] = False
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
97class SkillListResponse(BaseModel):
98 items: list[SkillUserResponse] = []
99 total: int = 0
102class SkillAccessListResponse(BaseModel):
103 items: list[SkillAccessResponse] = []
104 total: int = 0
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)
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
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
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
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
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())
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))
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 )
190 result = await db.execute(stmt)
191 all_skills = result.scalars().all()
193 user_ids = list(set(skill.user_id for skill in all_skills))
194 skill_ids = [skill.id for skill in all_skills]
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)
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
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)
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 )
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)
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 )
261 order_by = filter.get('order_by')
262 direction = filter.get('direction')
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())
282 # Count BEFORE pagination
283 count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
284 total = count_result.scalar()
286 if skip:
287 stmt = stmt.offset(skip)
288 if limit:
289 stmt = stmt.limit(limit)
291 result = await db.execute(stmt)
292 items = result.all()
294 skill_ids = [skill.id for skill, _ in items]
295 grants_map = await AccessGrants.get_grants_by_resources('skill', skill_ids, db=db)
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 )
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)
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)
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
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
342 skill.is_active = not skill.is_active
343 skill.updated_at = int(time.time())
344 await db.commit()
346 return await self._to_skill_model(skill, db=db)
347 except Exception:
348 return None
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()
357 return True
358 except Exception:
359 return False
362Skills = SkillsTable()