mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-06 16:10:50 -05:00
Fourteen modules import `json` without using it. Ruff flags every one with F401, and a word-boundary search for `json` in each file matches only the import line itself, including inside strings, comments and annotations. Two exclusions, both deliberate. Migration files are left alone: the import is equally dead there, but those files are frozen history and not worth the churn. `models/chats.py` has the same dead import and is handled in its own change, so it is skipped here to avoid two changes touching the same line. No behaviour change.
395 lines
15 KiB
Python
395 lines
15 KiB
Python
import time
|
|
import uuid
|
|
from functools import lru_cache
|
|
from typing import Optional
|
|
|
|
from open_webui.internal.db import Base, get_async_db_context
|
|
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
|
from open_webui.models.groups import Groups
|
|
from open_webui.models.users import User, UserModel, UserResponse, Users
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
from sqlalchemy import JSON, BigInteger, Boolean, Column, ForeignKey, Text, delete, func, or_, select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
####################
|
|
# Note DB Schema
|
|
####################
|
|
|
|
|
|
class Note(Base):
|
|
__tablename__ = 'note'
|
|
|
|
id = Column(Text, primary_key=True, unique=True)
|
|
user_id = Column(Text)
|
|
|
|
title = Column(Text)
|
|
data = Column(JSON, nullable=True)
|
|
meta = Column(JSON, nullable=True)
|
|
|
|
created_at = Column(BigInteger)
|
|
updated_at = Column(BigInteger)
|
|
|
|
|
|
class NoteModel(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: str
|
|
user_id: str
|
|
|
|
title: str
|
|
data: Optional[dict] = None
|
|
meta: Optional[dict] = None
|
|
is_pinned: Optional[bool] = False
|
|
|
|
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
|
|
|
created_at: int # timestamp in epoch
|
|
updated_at: int # timestamp in epoch
|
|
|
|
|
|
class PinnedNote(Base):
|
|
__tablename__ = 'pinned_note'
|
|
|
|
id = Column(Text, primary_key=True)
|
|
user_id = Column(Text, nullable=False)
|
|
note_id = Column(Text, ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
|
|
created_at = Column(BigInteger, nullable=False)
|
|
|
|
|
|
####################
|
|
# Forms
|
|
####################
|
|
|
|
|
|
class NoteForm(BaseModel):
|
|
title: str
|
|
data: Optional[dict] = None
|
|
meta: Optional[dict] = None
|
|
access_grants: Optional[list[dict]] = None
|
|
|
|
|
|
class NoteUpdateForm(BaseModel):
|
|
title: Optional[str] = None
|
|
data: Optional[dict] = None
|
|
meta: Optional[dict] = None
|
|
access_grants: Optional[list[dict]] = None
|
|
|
|
|
|
class NoteUserResponse(NoteModel):
|
|
user: Optional[UserResponse] = None
|
|
|
|
|
|
class NoteItemResponse(BaseModel):
|
|
id: str
|
|
title: str
|
|
data: Optional[dict]
|
|
is_pinned: Optional[bool] = False
|
|
updated_at: int
|
|
created_at: int
|
|
user: Optional[UserResponse] = None
|
|
|
|
|
|
class NoteListResponse(BaseModel):
|
|
items: list[NoteUserResponse]
|
|
total: int
|
|
|
|
|
|
class NoteTable:
|
|
async def _get_access_grants(self, note_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]:
|
|
return await AccessGrants.get_grants_by_resource('note', note_id, db=db)
|
|
|
|
async def _to_note_model(
|
|
self,
|
|
note: Note,
|
|
access_grants: Optional[list[AccessGrantModel]] = None,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> NoteModel:
|
|
# We exclude access_grants to inject them
|
|
note_model = NoteModel.model_validate(note)
|
|
note_model.data = note_model.data or {}
|
|
note_model.access_grants = (
|
|
access_grants if access_grants is not None else await self._get_access_grants(note_model.id, db=db)
|
|
)
|
|
return note_model
|
|
|
|
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
|
|
return AccessGrants.has_permission_filter(
|
|
db=db,
|
|
query=query,
|
|
DocumentModel=Note,
|
|
filter=filter,
|
|
resource_type='note',
|
|
permission=permission,
|
|
)
|
|
|
|
async def insert_new_note(
|
|
self, user_id: str, form_data: NoteForm, db: Optional[AsyncSession] = None
|
|
) -> Optional[NoteModel]:
|
|
async with get_async_db_context(db) as db:
|
|
note = NoteModel(
|
|
**{
|
|
'id': str(uuid.uuid4()),
|
|
'user_id': user_id,
|
|
**form_data.model_dump(exclude={'access_grants'}),
|
|
'created_at': int(time.time_ns()),
|
|
'updated_at': int(time.time_ns()),
|
|
'access_grants': [],
|
|
}
|
|
)
|
|
|
|
new_note = Note(**note.model_dump(exclude={'access_grants', 'is_pinned'}))
|
|
|
|
db.add(new_note)
|
|
await db.commit()
|
|
await AccessGrants.set_access_grants('note', note.id, form_data.access_grants, db=db)
|
|
return await self._to_note_model(new_note, db=db)
|
|
|
|
async def get_notes(self, skip: int = 0, limit: int = 50, db: Optional[AsyncSession] = None) -> list[NoteModel]:
|
|
async with get_async_db_context(db) as db:
|
|
stmt = select(Note).order_by(Note.updated_at.desc())
|
|
if skip is not None:
|
|
stmt = stmt.offset(skip)
|
|
if limit is not None:
|
|
stmt = stmt.limit(limit)
|
|
result = await db.execute(stmt)
|
|
notes = result.scalars().all()
|
|
note_ids = [note.id for note in notes]
|
|
grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db)
|
|
return [await self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes]
|
|
|
|
async def search_notes(
|
|
self,
|
|
user_id: str,
|
|
filter: dict = {},
|
|
skip: int = 0,
|
|
limit: int = 30,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> NoteListResponse:
|
|
async with get_async_db_context(db) as db:
|
|
stmt = select(Note, User).outerjoin(User, User.id == Note.user_id)
|
|
if filter:
|
|
query_key = filter.get('query')
|
|
if query_key:
|
|
# Split query into individual words and normalize each
|
|
# (strip hyphens so "todo" matches "to-do").
|
|
# All words must match somewhere in title OR content (AND semantics).
|
|
search_words = query_key.split()
|
|
normalized_words = [w.replace('-', '') for w in search_words if w.replace('-', '')]
|
|
for word in normalized_words:
|
|
stmt = stmt.filter(
|
|
or_(
|
|
func.replace(func.replace(Note.title, '-', ''), ' ', '').ilike(f'%{word}%'),
|
|
func.replace(
|
|
func.replace(Note.data['content']['md'].as_string(), '-', ''),
|
|
' ',
|
|
'',
|
|
).ilike(f'%{word}%'),
|
|
)
|
|
)
|
|
|
|
view_option = filter.get('view_option')
|
|
if view_option == 'created':
|
|
stmt = stmt.filter(Note.user_id == user_id)
|
|
elif view_option == 'shared':
|
|
stmt = stmt.filter(Note.user_id != user_id)
|
|
|
|
# Apply access control filtering
|
|
if 'permission' in filter:
|
|
permission = filter['permission']
|
|
else:
|
|
permission = 'write'
|
|
|
|
stmt = self._has_permission(
|
|
db,
|
|
stmt,
|
|
filter,
|
|
permission=permission,
|
|
)
|
|
|
|
order_by = filter.get('order_by')
|
|
direction = filter.get('direction')
|
|
|
|
if order_by == 'name':
|
|
if direction == 'asc':
|
|
stmt = stmt.order_by(Note.title.asc())
|
|
else:
|
|
stmt = stmt.order_by(Note.title.desc())
|
|
elif order_by == 'created_at':
|
|
if direction == 'asc':
|
|
stmt = stmt.order_by(Note.created_at.asc())
|
|
else:
|
|
stmt = stmt.order_by(Note.created_at.desc())
|
|
elif order_by == 'updated_at':
|
|
if direction == 'asc':
|
|
stmt = stmt.order_by(Note.updated_at.asc())
|
|
else:
|
|
stmt = stmt.order_by(Note.updated_at.desc())
|
|
else:
|
|
stmt = stmt.order_by(Note.updated_at.desc())
|
|
|
|
else:
|
|
stmt = stmt.order_by(Note.updated_at.desc())
|
|
|
|
# Count BEFORE pagination
|
|
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
|
|
total = count_result.scalar()
|
|
|
|
if skip:
|
|
stmt = stmt.offset(skip)
|
|
if limit:
|
|
stmt = stmt.limit(limit)
|
|
|
|
result = await db.execute(stmt)
|
|
items = result.all()
|
|
|
|
note_ids = [note.id for note, _ in items]
|
|
grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db)
|
|
|
|
notes = []
|
|
for note, user in items:
|
|
notes.append(
|
|
NoteUserResponse(
|
|
**(
|
|
await self._to_note_model(
|
|
note,
|
|
access_grants=grants_map.get(note.id, []),
|
|
db=db,
|
|
)
|
|
).model_dump(),
|
|
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
|
|
)
|
|
)
|
|
|
|
return NoteListResponse(items=notes, total=total)
|
|
|
|
async def get_notes_by_user_id(
|
|
self,
|
|
user_id: str,
|
|
permission: str = 'read',
|
|
skip: int = 0,
|
|
limit: int = 50,
|
|
db: Optional[AsyncSession] = None,
|
|
) -> list[NoteModel]:
|
|
async with get_async_db_context(db) as db:
|
|
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
|
user_group_ids = [group.id for group in user_groups]
|
|
|
|
stmt = select(Note).order_by(Note.updated_at.desc())
|
|
stmt = self._has_permission(db, stmt, {'user_id': user_id, 'group_ids': user_group_ids}, permission)
|
|
|
|
if skip is not None:
|
|
stmt = stmt.offset(skip)
|
|
if limit is not None:
|
|
stmt = stmt.limit(limit)
|
|
|
|
result = await db.execute(stmt)
|
|
notes = result.scalars().all()
|
|
note_ids = [note.id for note in notes]
|
|
grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db)
|
|
return [await self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes]
|
|
|
|
async def get_note_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[NoteModel]:
|
|
async with get_async_db_context(db) as db:
|
|
result = await db.execute(select(Note).filter(Note.id == id))
|
|
note = result.scalars().first()
|
|
return await self._to_note_model(note, db=db) if note else None
|
|
|
|
async def update_note_by_id(
|
|
self, id: str, form_data: NoteUpdateForm, db: Optional[AsyncSession] = None
|
|
) -> Optional[NoteModel]:
|
|
async with get_async_db_context(db) as db:
|
|
result = await db.execute(select(Note).filter(Note.id == id))
|
|
note = result.scalars().first()
|
|
if not note:
|
|
return None
|
|
|
|
form_data = form_data.model_dump(exclude_unset=True)
|
|
|
|
if 'title' in form_data:
|
|
note.title = form_data['title']
|
|
if 'data' in form_data:
|
|
note.data = {**(note.data or {}), **(form_data['data'] or {})}
|
|
if 'meta' in form_data:
|
|
note.meta = {**(note.meta or {}), **(form_data['meta'] or {})}
|
|
|
|
if not db.is_modified(note) and 'access_grants' not in form_data:
|
|
return await self._to_note_model(note, db=db)
|
|
|
|
if 'access_grants' in form_data:
|
|
await AccessGrants.set_access_grants('note', id, form_data['access_grants'], db=db)
|
|
|
|
note.updated_at = int(time.time_ns())
|
|
|
|
await db.commit()
|
|
return await self._to_note_model(note, db=db) if note else None
|
|
|
|
async def toggle_note_pinned_by_id(
|
|
self, id: str, user_id: str, db: Optional[AsyncSession] = None
|
|
) -> Optional[NoteModel]:
|
|
try:
|
|
async with get_async_db_context(db) as db:
|
|
result = await db.execute(select(Note).filter(Note.id == id))
|
|
note = result.scalars().first()
|
|
if not note:
|
|
return None
|
|
|
|
# Check if already pinned
|
|
pin_result = await db.execute(select(PinnedNote).filter_by(user_id=user_id, note_id=id))
|
|
pinned_note = pin_result.scalars().first()
|
|
|
|
if pinned_note:
|
|
await db.execute(delete(PinnedNote).filter_by(user_id=user_id, note_id=id))
|
|
else:
|
|
new_pin = PinnedNote(
|
|
id=str(uuid.uuid4()), user_id=user_id, note_id=id, created_at=int(time.time_ns())
|
|
)
|
|
db.add(new_pin)
|
|
|
|
await db.commit()
|
|
return await self._to_note_model(note, db=db)
|
|
except Exception:
|
|
return None
|
|
|
|
async def get_pinned_notes_by_user_id(
|
|
self,
|
|
user_id: str,
|
|
permission: str = 'read',
|
|
db: Optional[AsyncSession] = None,
|
|
) -> list[NoteModel]:
|
|
async with get_async_db_context(db) as db:
|
|
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
|
user_group_ids = [group.id for group in user_groups]
|
|
|
|
stmt = (
|
|
select(Note)
|
|
.join(PinnedNote, PinnedNote.note_id == Note.id)
|
|
.filter(PinnedNote.user_id == user_id)
|
|
.order_by(PinnedNote.created_at.desc())
|
|
)
|
|
stmt = self._has_permission(db, stmt, {'user_id': user_id, 'group_ids': user_group_ids}, permission)
|
|
|
|
result = await db.execute(stmt)
|
|
notes = result.scalars().all()
|
|
note_ids = [note.id for note in notes]
|
|
grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db)
|
|
return [await self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes]
|
|
|
|
async def delete_note_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
|
|
try:
|
|
async with get_async_db_context(db) as db:
|
|
await AccessGrants.revoke_all_access('note', id, db=db)
|
|
await db.execute(delete(PinnedNote).filter(PinnedNote.note_id == id))
|
|
await db.execute(delete(Note).filter(Note.id == id))
|
|
await db.commit()
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
async def get_pinned_note_ids(self, user_id: str, db: Optional[AsyncSession] = None) -> list[str]:
|
|
async with get_async_db_context(db) as db:
|
|
result = await db.execute(select(PinnedNote.note_id).filter_by(user_id=user_id))
|
|
return result.scalars().all()
|
|
|
|
|
|
Notes = NoteTable()
|