diff --git a/backend/open_webui/migrations/versions/4de81c2a3af1_add_pinned_note_table.py b/backend/open_webui/migrations/versions/4de81c2a3af1_add_pinned_note_table.py new file mode 100644 index 0000000000..b2bc805773 --- /dev/null +++ b/backend/open_webui/migrations/versions/4de81c2a3af1_add_pinned_note_table.py @@ -0,0 +1,97 @@ +"""add pinned_note table + +Revision ID: 4de81c2a3af1 +Revises: 56359461a091 +Create Date: 2026-05-09 04:29:27.651341 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import open_webui.internal.db + + +# revision identifiers, used by Alembic. +revision: str = '4de81c2a3af1' +down_revision: Union[str, None] = '56359461a091' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +import uuid +import time +from sqlalchemy import select, update, insert +from sqlalchemy.sql import table, column + +def upgrade() -> None: + op.create_table( + 'pinned_note', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('note_id', sa.Text(), sa.ForeignKey('note.id', ondelete='CASCADE'), nullable=False), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'note_id', name='uq_pinned_note') + ) + + conn = op.get_bind() + + note_table = table('note', + column('id', sa.Text), + column('user_id', sa.Text), + column('is_pinned', sa.Boolean) + ) + + pinned_note_table = table('pinned_note', + column('id', sa.Text), + column('user_id', sa.Text), + column('note_id', sa.Text), + column('created_at', sa.BigInteger) + ) + + notes = conn.execute( + select(note_table.c.id, note_table.c.user_id).where(note_table.c.is_pinned == True) + ).fetchall() + + if notes: + now = int(time.time_ns()) + conn.execute( + insert(pinned_note_table), + [ + { + "id": str(uuid.uuid4()), + "user_id": note[1], + "note_id": note[0], + "created_at": now + } + for note in notes + ] + ) + + with op.batch_alter_table('note', schema=None) as batch_op: + batch_op.drop_column('is_pinned') + +def downgrade() -> None: + with op.batch_alter_table('note', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_pinned', sa.Boolean(), nullable=True)) + + conn = op.get_bind() + + note_table = table('note', + column('id', sa.Text), + column('is_pinned', sa.Boolean) + ) + + pinned_note_table = table('pinned_note', + column('note_id', sa.Text) + ) + + notes = conn.execute(select(pinned_note_table.c.note_id)).fetchall() + + for note in notes: + conn.execute( + update(note_table).where(note_table.c.id == note[0]).values(is_pinned=True) + ) + + op.drop_table('pinned_note') diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index 1a34750a7d..33928bf970 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -13,7 +13,7 @@ from open_webui.models.access_grants import AccessGrantModel, AccessGrants from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import BigInteger, Column, Text, JSON +from sqlalchemy import BigInteger, Column, Text, JSON, ForeignKey #################### # Note DB Schema @@ -29,7 +29,6 @@ class Note(Base): title = Column(Text) data = Column(JSON, nullable=True) meta = Column(JSON, nullable=True) - is_pinned = Column(Boolean, default=False, nullable=True) created_at = Column(BigInteger) updated_at = Column(BigInteger) @@ -52,6 +51,15 @@ class NoteModel(BaseModel): 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 #################### @@ -100,6 +108,7 @@ class NoteTable: access_grants: Optional[list[AccessGrantModel]] = None, db: Optional[AsyncSession] = None, ) -> NoteModel: + # We exclude access_grants to inject them note_data = NoteModel.model_validate(note).model_dump(exclude={'access_grants'}) note_data['access_grants'] = ( access_grants if access_grants is not None else await self._get_access_grants(note_data['id'], db=db) @@ -314,15 +323,29 @@ class NoteTable: 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, db: Optional[AsyncSession] = None) -> Optional[NoteModel]: + 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 - note.is_pinned = not note.is_pinned - note.updated_at = int(time.time_ns()) + + # 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: @@ -338,7 +361,7 @@ class NoteTable: 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).filter(Note.is_pinned == True).order_by(Note.updated_at.desc()) + 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) @@ -351,11 +374,17 @@ class NoteTable: 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() diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 4fbdd09993..7149a67b2f 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -92,10 +92,13 @@ async def get_notes( user_ids = list(set(note.user_id for note in notes)) users = {user.id: user for user in await Users.get_users_by_user_ids(user_ids, db=db)} + pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) + return [ NoteUserResponse( **{ **note.model_dump(), + 'is_pinned': note.id in pinned_note_ids, 'data': _truncate_note_data(note.data), 'user': UserResponse(**users[note.user_id].model_dump()), } @@ -135,6 +138,7 @@ async def get_pinned_notes( NoteUserResponse( **{ **note.model_dump(), + 'is_pinned': True, 'data': _truncate_note_data(note.data), 'user': UserResponse(**users[note.user_id].model_dump()), } @@ -190,7 +194,9 @@ async def search_notes( filter['user_id'] = user.id result = await Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db) + pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) for note in result.items: + note.is_pinned = note.id in pinned_note_ids note.data = _truncate_note_data(note.data) return result @@ -287,7 +293,8 @@ async def get_note_by_id( or has_public_write_access_grant(note.access_grants) ) - return NoteResponse(**note.model_dump(), write_access=write_access) + pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) + return NoteResponse(**note.model_dump(), write_access=write_access, is_pinned=note.id in pinned_note_ids) ############################ @@ -338,6 +345,9 @@ async def update_note_by_id( try: note = await Notes.update_note_by_id(id, form_data, db=db) + pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) + note.is_pinned = note.id in pinned_note_ids + await sio.emit( 'note-events', note.model_dump(), @@ -401,7 +411,10 @@ async def update_note_access_by_id( await AccessGrants.set_access_grants('note', id, form_data.access_grants, db=db) - return await Notes.get_note_by_id(id, db=db) + note = await Notes.get_note_by_id(id, db=db) + pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) + note.is_pinned = note.id in pinned_note_ids + return note ############################ @@ -440,7 +453,9 @@ async def pin_note_by_id( ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - note = await Notes.toggle_note_pinned_by_id(id, db=db) + note = await Notes.toggle_note_pinned_by_id(id, user.id, db=db) + pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) + note.is_pinned = note.id in pinned_note_ids return note