This commit is contained in:
Timothy Jaeryang Baek
2026-05-09 04:39:44 +09:00
parent 005df577fe
commit 33e588cf09
3 changed files with 150 additions and 9 deletions
@@ -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')
+35 -6
View File
@@ -13,7 +13,7 @@ from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Column, Text, JSON from sqlalchemy import BigInteger, Column, Text, JSON, ForeignKey
#################### ####################
# Note DB Schema # Note DB Schema
@@ -29,7 +29,6 @@ class Note(Base):
title = Column(Text) title = Column(Text)
data = Column(JSON, nullable=True) data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True) meta = Column(JSON, nullable=True)
is_pinned = Column(Boolean, default=False, nullable=True)
created_at = Column(BigInteger) created_at = Column(BigInteger)
updated_at = Column(BigInteger) updated_at = Column(BigInteger)
@@ -52,6 +51,15 @@ class NoteModel(BaseModel):
updated_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 # Forms
#################### ####################
@@ -100,6 +108,7 @@ class NoteTable:
access_grants: Optional[list[AccessGrantModel]] = None, access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None, db: Optional[AsyncSession] = None,
) -> NoteModel: ) -> NoteModel:
# We exclude access_grants to inject them
note_data = NoteModel.model_validate(note).model_dump(exclude={'access_grants'}) note_data = NoteModel.model_validate(note).model_dump(exclude={'access_grants'})
note_data['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) 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() await db.commit()
return await self._to_note_model(note, db=db) if note else None 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: try:
async with get_async_db_context(db) as db: async with get_async_db_context(db) as db:
result = await db.execute(select(Note).filter(Note.id == id)) result = await db.execute(select(Note).filter(Note.id == id))
note = result.scalars().first() note = result.scalars().first()
if not note: if not note:
return None 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() await db.commit()
return await self._to_note_model(note, db=db) return await self._to_note_model(note, db=db)
except Exception: except Exception:
@@ -338,7 +361,7 @@ class NoteTable:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db) user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [group.id for group in user_groups] 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) stmt = self._has_permission(db, stmt, {'user_id': user_id, 'group_ids': user_group_ids}, permission)
result = await db.execute(stmt) result = await db.execute(stmt)
@@ -351,11 +374,17 @@ class NoteTable:
try: try:
async with get_async_db_context(db) as db: async with get_async_db_context(db) as db:
await AccessGrants.revoke_all_access('note', id, db=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.execute(delete(Note).filter(Note.id == id))
await db.commit() await db.commit()
return True return True
except Exception: except Exception:
return False 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() Notes = NoteTable()
+18 -3
View File
@@ -92,10 +92,13 @@ async def get_notes(
user_ids = list(set(note.user_id for note in 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)} 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 [ return [
NoteUserResponse( NoteUserResponse(
**{ **{
**note.model_dump(), **note.model_dump(),
'is_pinned': note.id in pinned_note_ids,
'data': _truncate_note_data(note.data), 'data': _truncate_note_data(note.data),
'user': UserResponse(**users[note.user_id].model_dump()), 'user': UserResponse(**users[note.user_id].model_dump()),
} }
@@ -135,6 +138,7 @@ async def get_pinned_notes(
NoteUserResponse( NoteUserResponse(
**{ **{
**note.model_dump(), **note.model_dump(),
'is_pinned': True,
'data': _truncate_note_data(note.data), 'data': _truncate_note_data(note.data),
'user': UserResponse(**users[note.user_id].model_dump()), 'user': UserResponse(**users[note.user_id].model_dump()),
} }
@@ -190,7 +194,9 @@ async def search_notes(
filter['user_id'] = user.id filter['user_id'] = user.id
result = await Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db) 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: for note in result.items:
note.is_pinned = note.id in pinned_note_ids
note.data = _truncate_note_data(note.data) note.data = _truncate_note_data(note.data)
return result return result
@@ -287,7 +293,8 @@ async def get_note_by_id(
or has_public_write_access_grant(note.access_grants) 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: try:
note = await Notes.update_note_by_id(id, form_data, db=db) 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( await sio.emit(
'note-events', 'note-events',
note.model_dump(), 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) 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()) 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 return note