Add cursor pagination, add new emotions
This commit is contained in:
12
README.md
12
README.md
@@ -1,12 +1,14 @@
|
||||
# Soul Diary
|
||||
|
||||
Self-hosted service.
|
||||
|
||||
## ToDo
|
||||
|
||||
1. Implement cursor pagination on backends and server
|
||||
2. Implement infinity scroll
|
||||
3. Add filters: min timestamp, max timestamp, emotions
|
||||
4. Implement S3 backend client
|
||||
5. Implement FTP backend client
|
||||
0. Fill README
|
||||
1. Add filters: min timestamp, max timestamp, emotions
|
||||
2. Implement S3 backend client
|
||||
3. Implement FTP backend client
|
||||
4. Add notifications
|
||||
|
||||
## User Flow
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from soul_diary.backend.database.models import Sense, Session
|
||||
from .dependencies import is_auth, sense
|
||||
from .schemas import (
|
||||
CreateSenseRequest,
|
||||
Pagination,
|
||||
SenseListResponse,
|
||||
SenseResponse,
|
||||
UpdateSenseRequest,
|
||||
@@ -15,11 +16,27 @@ from .schemas import (
|
||||
async def get_sense_list(
|
||||
database: DatabaseService = fastapi.Depends(database),
|
||||
user_session: Session = fastapi.Depends(is_auth),
|
||||
pagination: Pagination = fastapi.Depends(Pagination),
|
||||
) -> SenseListResponse:
|
||||
async with database.transaction() as session:
|
||||
senses = await database.get_senses(session=session, user=user_session.user)
|
||||
senses_count = await database.get_senses_count(
|
||||
session=session,
|
||||
user=user_session.user,
|
||||
)
|
||||
senses_list, previous_cursor, next_cursor = await database.get_senses(
|
||||
session=session,
|
||||
user=user_session.user,
|
||||
cursor=pagination.cursor,
|
||||
limit=pagination.limit,
|
||||
)
|
||||
|
||||
return SenseListResponse(data=senses)
|
||||
return SenseListResponse(
|
||||
data=senses_list,
|
||||
limit=pagination.limit,
|
||||
total_items=senses_count,
|
||||
previous=previous_cursor,
|
||||
next=next_cursor,
|
||||
)
|
||||
|
||||
|
||||
async def create_sense(
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, NonNegativeInt
|
||||
|
||||
|
||||
class Pagination(BaseModel):
|
||||
cursor: str | None = None
|
||||
limit: int = 10
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel):
|
||||
data: list
|
||||
limit: int
|
||||
total_items: NonNegativeInt
|
||||
previous: str | None = None
|
||||
next: str | None = None
|
||||
|
||||
|
||||
class CreateSenseRequest(BaseModel):
|
||||
@@ -20,5 +33,5 @@ class SenseResponse(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SenseListResponse(BaseModel):
|
||||
class SenseListResponse(PaginatedResponse):
|
||||
data: list[SenseResponse]
|
||||
|
||||
@@ -12,7 +12,7 @@ import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'bce1e66bb101'
|
||||
revision: str = "bce1e66bb101"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
@@ -20,47 +20,55 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('username', sa.String(length=64), nullable=False),
|
||||
sa.Column('password', sa.String(length=72), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('username')
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("username", sa.String(length=64), nullable=False),
|
||||
sa.Column("password", sa.String(length=72), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("username"),
|
||||
)
|
||||
op.create_index('users__id_idx', 'users', ['id'], unique=False, postgresql_using='hash')
|
||||
op.create_index('users__username_idx', 'users', ['username'], unique=False, postgresql_using='hash')
|
||||
op.create_table('senses',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('data', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_index("users__id_idx", "users", ["id"], unique=False, postgresql_using="hash")
|
||||
op.create_index("users__username_idx", "users", ["username"], unique=False,
|
||||
postgresql_using="hash")
|
||||
op.create_table(
|
||||
"senses",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("data", sa.String(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index('senses__created_at_idx', 'senses', ['created_at'], unique=False, postgresql_using='btree')
|
||||
op.create_index('senses__id_idx', 'senses', ['id'], unique=False, postgresql_using='hash')
|
||||
op.create_index('senses__user_id_idx', 'senses', ['user_id'], unique=False, postgresql_using='btree')
|
||||
op.create_table('sessions',
|
||||
sa.Column('token', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('token')
|
||||
op.create_index("senses__created_at_idx", "senses", ["created_at"], unique=False,
|
||||
postgresql_using="btree")
|
||||
op.create_index("senses__id_idx", "senses", ["id"], unique=False, postgresql_using="hash")
|
||||
op.create_index("senses__user_id_idx", "senses", ["user_id"], unique=False,
|
||||
postgresql_using="btree")
|
||||
op.create_table(
|
||||
"sessions",
|
||||
sa.Column("token", sa.String(), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("token"),
|
||||
)
|
||||
op.create_index('sessions__token_idx', 'sessions', ['token'], unique=False, postgresql_using='hash')
|
||||
op.create_index('sessions__user_id_idx', 'sessions', ['user_id'], unique=False, postgresql_using='btree')
|
||||
op.create_index("sessions__token_idx", "sessions", ["token"], unique=False,
|
||||
postgresql_using="hash")
|
||||
op.create_index("sessions__user_id_idx", "sessions", ["user_id"], unique=False,
|
||||
postgresql_using="btree")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('sessions__user_id_idx', table_name='sessions', postgresql_using='btree')
|
||||
op.drop_index('sessions__token_idx', table_name='sessions', postgresql_using='hash')
|
||||
op.drop_table('sessions')
|
||||
op.drop_index('senses__user_id_idx', table_name='senses', postgresql_using='btree')
|
||||
op.drop_index('senses__id_idx', table_name='senses', postgresql_using='hash')
|
||||
op.drop_index('senses__created_at_idx', table_name='senses', postgresql_using='btree')
|
||||
op.drop_table('senses')
|
||||
op.drop_index('users__username_idx', table_name='users', postgresql_using='hash')
|
||||
op.drop_index('users__id_idx', table_name='users', postgresql_using='hash')
|
||||
op.drop_table('users')
|
||||
op.drop_index("sessions__user_id_idx", table_name="sessions", postgresql_using="btree")
|
||||
op.drop_index("sessions__token_idx", table_name="sessions", postgresql_using="hash")
|
||||
op.drop_table("sessions")
|
||||
op.drop_index("senses__user_id_idx", table_name="senses", postgresql_using="btree")
|
||||
op.drop_index("senses__id_idx", table_name="senses", postgresql_using="hash")
|
||||
op.drop_index("senses__created_at_idx", table_name="senses", postgresql_using="btree")
|
||||
op.drop_table("senses")
|
||||
op.drop_index("users__username_idx", table_name="users", postgresql_using="hash")
|
||||
op.drop_index("users__id_idx", table_name="users", postgresql_using="hash")
|
||||
op.drop_table("users")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""change indexes
|
||||
|
||||
Revision ID: ed569caafd85
|
||||
Revises: bce1e66bb101
|
||||
Create Date: 2023-12-18 15:31:56.733172
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "ed569caafd85"
|
||||
down_revision: Union[str, None] = "bce1e66bb101"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index("senses__created_at_idx", table_name="senses")
|
||||
op.drop_index("senses__user_id_idx", table_name="senses")
|
||||
op.drop_index("sessions__user_id_idx", table_name="sessions")
|
||||
op.create_index("senses__created_at__id_idx", "senses", ["created_at", "id"], unique=False,
|
||||
postgresql_using="btree")
|
||||
op.create_index("senses__user_id_idx", "senses", ["user_id"], unique=False,
|
||||
postgresql_using="hash")
|
||||
op.create_index("sessions__user_id_idx", "sessions", ["user_id"], unique=False,
|
||||
postgresql_using="hash")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index("senses__created_at__id_idx", table_name="senses", postgresql_using="btree")
|
||||
op.drop_index("senses__user_id_idx", table_name="senses")
|
||||
op.drop_index("sessions__user_id_idx", table_name="sessions")
|
||||
op.create_index("senses__created_at_idx", "senses", ["created_at"], unique=False)
|
||||
op.create_index("senses__user_id_idx", "senses", ["user_id"], unique=False,
|
||||
postgresql_using="btree")
|
||||
op.create_index("sessions__user_id_idx", "sessions", ["user_id"], unique=False,
|
||||
postgresql_using="btree")
|
||||
# ### end Alembic commands ###
|
||||
@@ -40,7 +40,7 @@ class Session(Base):
|
||||
|
||||
__table_args__ = (
|
||||
Index("sessions__token_idx", "token", postgresql_using="hash"),
|
||||
Index("sessions__user_id_idx", "user_id", postgresql_using="btree"),
|
||||
Index("sessions__user_id_idx", "user_id", postgresql_using="hash"),
|
||||
)
|
||||
|
||||
|
||||
@@ -56,6 +56,6 @@ class Sense(Base):
|
||||
|
||||
__table_args__ = (
|
||||
Index("senses__id_idx", "id", postgresql_using="hash"),
|
||||
Index("senses__user_id_idx", "user_id", postgresql_using="btree"),
|
||||
Index("senses__created_at_idx", "created_at", postgresql_using="btree"),
|
||||
Index("senses__user_id_idx", "user_id", postgresql_using="hash"),
|
||||
Index("senses__created_at__id_idx", "created_at", "id", postgresql_using="btree"),
|
||||
)
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import base64
|
||||
import pathlib
|
||||
import struct
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import Type
|
||||
|
||||
import bcrypt
|
||||
from alembic import command as alembic_command
|
||||
from alembic.config import Config as AlembicConfig
|
||||
from facet import ServiceMixin
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
@@ -15,7 +19,14 @@ from .models import Sense, Session, User
|
||||
from .settings import DatabaseSettings
|
||||
|
||||
|
||||
class CursorData(BaseModel):
|
||||
created_at: datetime
|
||||
sense_id: uuid.UUID
|
||||
|
||||
|
||||
class DatabaseService(ServiceMixin):
|
||||
ENCODING = "utf-8"
|
||||
|
||||
def __init__(self, dsn: str):
|
||||
self._dsn = dsn
|
||||
self._engine = create_async_engine(self._dsn, pool_recycle=60)
|
||||
@@ -99,13 +110,84 @@ class DatabaseService(ServiceMixin):
|
||||
|
||||
return user_session
|
||||
|
||||
async def get_senses(self, session: AsyncSession, user: User) -> list[Sense]:
|
||||
query = select(Sense).where(Sense.user == user).order_by(Sense.created_at.desc())
|
||||
def cursor_encode(self, data: CursorData) -> str:
|
||||
datetime_bytes = bytes(struct.pack("d", data.created_at.timestamp()))
|
||||
sense_id_bytes = data.sense_id.bytes
|
||||
cursor_bytes = datetime_bytes + sense_id_bytes
|
||||
return base64.b64encode(cursor_bytes).decode(self.ENCODING)
|
||||
|
||||
def cursor_decode(self, cursor: str) -> CursorData:
|
||||
cursor_bytes = base64.b64decode(cursor.encode(self.ENCODING))
|
||||
created_at = datetime.fromtimestamp(struct.unpack("d", cursor_bytes[:8])[0])
|
||||
sense_id = uuid.UUID(bytes=cursor_bytes[8:])
|
||||
return CursorData(created_at=created_at, sense_id=sense_id)
|
||||
|
||||
def get_senses_filters(self, user: User) -> list:
|
||||
filters = [Sense.user == user]
|
||||
|
||||
return filters
|
||||
|
||||
async def get_senses_count(self, session: AsyncSession, user: User) -> int:
|
||||
filters = self.get_senses_filters(user=user)
|
||||
query = select(func.count(Sense.id)).where(*filters)
|
||||
|
||||
count = await session.scalar(query)
|
||||
return count
|
||||
|
||||
async def get_senses(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user: User,
|
||||
cursor: str | None = None,
|
||||
limit: int = 10,
|
||||
) -> tuple[list[Sense], str | None, str | None]:
|
||||
filters = self.get_senses_filters(user=user)
|
||||
cursor_data = None if cursor is None else self.cursor_decode(cursor)
|
||||
|
||||
current_filters = filters.copy()
|
||||
previous_sense = None
|
||||
if cursor_data is not None:
|
||||
current_filters.append(or_(
|
||||
Sense.created_at > cursor_data.created_at,
|
||||
and_(Sense.created_at == cursor_data.created_at, Sense.id > cursor_data.sense_id)
|
||||
))
|
||||
query = (
|
||||
select(Sense).where(*current_filters)
|
||||
.order_by(Sense.created_at.asc()).offset(limit).limit(1)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
previous_sense = result.scalars().first()
|
||||
|
||||
current_filters = filters.copy()
|
||||
if cursor_data is not None:
|
||||
current_filters.append(or_(
|
||||
Sense.created_at < cursor_data.created_at,
|
||||
and_(Sense.created_at == cursor_data.created_at, Sense.id <= cursor_data.sense_id),
|
||||
))
|
||||
query = (
|
||||
select(Sense).where(*current_filters)
|
||||
.order_by(Sense.created_at.desc()).limit(limit + 1)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
senses = result.scalars().all()
|
||||
senses = list(result.scalars().all())
|
||||
|
||||
return list(senses)
|
||||
previous_cursor = None
|
||||
if previous_sense is not None:
|
||||
previous_cursor_data = CursorData(
|
||||
created_at=previous_sense.created_at,
|
||||
sense_id=previous_sense.id,
|
||||
)
|
||||
previous_cursor = self.cursor_encode(data=previous_cursor_data)
|
||||
|
||||
next_cursor = None
|
||||
if len(senses) == limit + 1:
|
||||
next_cursor_data = CursorData(
|
||||
created_at=senses[-1].created_at,
|
||||
sense_id=senses[-1].id,
|
||||
)
|
||||
next_cursor = self.cursor_encode(data=next_cursor_data)
|
||||
|
||||
return senses[:-1], previous_cursor, next_cursor
|
||||
|
||||
async def create_sense(self, session: AsyncSession, user: User, data: str) -> Sense:
|
||||
sense = Sense(user=user, data=data)
|
||||
|
||||
@@ -112,11 +112,17 @@ class BaseBackend:
|
||||
|
||||
async def get_sense_list(self, cursor: str | None = None, limit: int = 10) -> SenseList:
|
||||
encrypted_sense_list = await self.fetch_sense_list(cursor=cursor, limit=limit)
|
||||
senses = [
|
||||
data = [
|
||||
self.convert_encrypted_sense_to_sense(encrypted_sense)
|
||||
for encrypted_sense in encrypted_sense_list.senses
|
||||
for encrypted_sense in encrypted_sense_list.data
|
||||
]
|
||||
return SenseList(senses=senses)
|
||||
return SenseList(
|
||||
data=data,
|
||||
limit=encrypted_sense_list.limit,
|
||||
total_items=encrypted_sense_list.total_items,
|
||||
previous=encrypted_sense_list.previous,
|
||||
next=encrypted_sense_list.next,
|
||||
)
|
||||
|
||||
async def create_sense(
|
||||
self,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import struct
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from soul_diary.ui.app.models import BackendType
|
||||
from .base import BaseBackend
|
||||
from .exceptions import (
|
||||
@@ -14,6 +18,11 @@ from .exceptions import (
|
||||
from .models import EncryptedSense, EncryptedSenseList, Options
|
||||
|
||||
|
||||
class CursorData(BaseModel):
|
||||
created_at: datetime
|
||||
sense_id: uuid.UUID
|
||||
|
||||
|
||||
class LocalBackend(BaseBackend):
|
||||
BACKEND = BackendType.LOCAL
|
||||
AUTH_BLOCK_TEMPLATE = "auth_block:{username}:{password}"
|
||||
@@ -60,6 +69,18 @@ class LocalBackend(BaseBackend):
|
||||
async def get_options(self) -> Options:
|
||||
return Options(registration_enabled=True)
|
||||
|
||||
def cursor_encode(self, data: CursorData) -> str:
|
||||
datetime_bytes = bytes(struct.pack("d", data.created_at.timestamp()))
|
||||
sense_id_bytes = data.sense_id.bytes
|
||||
cursor_bytes = datetime_bytes + sense_id_bytes
|
||||
return base64.b64encode(cursor_bytes).decode(self.ENCODING)
|
||||
|
||||
def cursor_decode(self, cursor: str) -> CursorData:
|
||||
cursor_bytes = base64.b64decode(cursor.encode(self.ENCODING))
|
||||
created_at = datetime.fromtimestamp(struct.unpack("d", cursor_bytes[:8])[0])
|
||||
sense_id = uuid.UUID(bytes=cursor_bytes[8:])
|
||||
return CursorData(created_at=created_at, sense_id=sense_id)
|
||||
|
||||
async def fetch_sense_list(
|
||||
self,
|
||||
cursor: str | None = None,
|
||||
@@ -70,8 +91,47 @@ class LocalBackend(BaseBackend):
|
||||
|
||||
sense_list_key = self.SENSE_LIST_KEY_TEMPLATE.format(username=self._username)
|
||||
sense_list = await self._local_storage.raw_read(sense_list_key) or []
|
||||
senses = [EncryptedSense.model_validate(sense) for sense in sense_list]
|
||||
return EncryptedSenseList(senses=senses)
|
||||
total_items = len(sense_list)
|
||||
|
||||
index = 0
|
||||
if cursor is not None:
|
||||
cursor_data = self.cursor_decode(cursor)
|
||||
cursor_sense = EncryptedSense.model_validate(sense_list[0])
|
||||
while (
|
||||
index < total_items and
|
||||
(cursor_data.created_at < cursor_sense.created_at or
|
||||
cursor_data.created_at == cursor_sense.created_at and
|
||||
cursor_data.sense_id < cursor_sense.id)
|
||||
):
|
||||
index += 1
|
||||
cursor_sense = EncryptedSense.model_validate(sense_list[index])
|
||||
|
||||
previous_cursor = None
|
||||
if index - limit >= 0:
|
||||
previous_pivot = EncryptedSense.model_validate(sense_list[index - limit])
|
||||
previous_cursor_data = CursorData(
|
||||
created_at=previous_pivot.created_at,
|
||||
sense_id=previous_pivot.id,
|
||||
)
|
||||
previous_cursor = self.cursor_encode(data=previous_cursor_data)
|
||||
next_cursor = None
|
||||
if index + limit < len(sense_list):
|
||||
next_pivot = EncryptedSense.model_validate(sense_list[index + limit])
|
||||
next_cursor_data = CursorData(
|
||||
created_at=next_pivot.created_at,
|
||||
sense_id=next_pivot.id,
|
||||
)
|
||||
next_cursor = self.cursor_encode(data=next_cursor_data)
|
||||
|
||||
sense_list = sense_list[index:index + limit]
|
||||
data = [EncryptedSense.model_validate(sense) for sense in sense_list]
|
||||
return EncryptedSenseList(
|
||||
data=data,
|
||||
limit=limit,
|
||||
total_items=total_items,
|
||||
previous=previous_cursor,
|
||||
next=next_cursor,
|
||||
)
|
||||
|
||||
async def fetch_sense(self, sense_id: uuid.UUID) -> EncryptedSense:
|
||||
sense_list = await self.fetch_sense_list()
|
||||
@@ -84,19 +144,28 @@ class LocalBackend(BaseBackend):
|
||||
|
||||
async def pull_sense_data(self, data: str, sense_id: uuid.UUID | None = None) -> EncryptedSense:
|
||||
sense_list_key = self.SENSE_LIST_KEY_TEMPLATE.format(username=self._username)
|
||||
sense_list = await self.fetch_sense_list()
|
||||
sense_list = await self._local_storage.raw_read(sense_list_key)
|
||||
|
||||
if sense_id is None:
|
||||
sense_ids = {sense.id for sense in sense_list.senses}
|
||||
sense_ids = {uuid.UUID(sense["id"]) for sense in sense_list}
|
||||
sense_id = uuid.uuid4()
|
||||
while sense_id in sense_ids:
|
||||
sense_id = uuid.uuid4()
|
||||
sense = EncryptedSense(
|
||||
id=sense_id,
|
||||
data=data,
|
||||
created_at=datetime.now().astimezone(),
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
sense_list.senses.insert(0, sense)
|
||||
index = 0
|
||||
cursor_sense = EncryptedSense.model_validate(sense_list[index])
|
||||
while (
|
||||
index < len(sense_list) and
|
||||
(sense.created_at < cursor_sense.created_at or
|
||||
sense.created_at == cursor_sense.created_at and
|
||||
sense.id < cursor_sense.id)
|
||||
):
|
||||
index += 1
|
||||
sense_list.insert(index, sense.model_dump(mode="json"))
|
||||
else:
|
||||
for index, sense in enumerate(sense_list):
|
||||
if sense.id == sense_id:
|
||||
@@ -104,14 +173,11 @@ class LocalBackend(BaseBackend):
|
||||
else:
|
||||
raise SenseNotFoundException()
|
||||
|
||||
sense = sense_list.senses[index]
|
||||
sense = sense_list[index]
|
||||
sense.data = data
|
||||
sense_list.senses[index] = sense
|
||||
sense_list[index] = sense.model_dump(mode="json")
|
||||
|
||||
await self._local_storage.raw_write(
|
||||
sense_list_key,
|
||||
[sense.model_dump(mode="json") for sense in sense_list.senses],
|
||||
)
|
||||
await self._local_storage.raw_write(sense_list_key, sense_list)
|
||||
|
||||
return sense
|
||||
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, NonNegativeInt
|
||||
|
||||
from soul_diary.ui.app.models import Sense
|
||||
|
||||
|
||||
class Paginated(BaseModel):
|
||||
data: list
|
||||
limit: int
|
||||
total_items: NonNegativeInt
|
||||
previous: str | None = None
|
||||
next: str | None = None
|
||||
|
||||
|
||||
class EncryptedSense(BaseModel):
|
||||
id: uuid.UUID
|
||||
data: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EncryptedSenseList(BaseModel):
|
||||
senses: list[EncryptedSense]
|
||||
class EncryptedSenseList(Paginated):
|
||||
data: list[EncryptedSense]
|
||||
|
||||
|
||||
class SenseList(BaseModel):
|
||||
senses: list[Sense]
|
||||
class SenseList(Paginated):
|
||||
data: list[Sense]
|
||||
|
||||
|
||||
class Options(BaseModel):
|
||||
|
||||
@@ -127,9 +127,15 @@ class SoulBackend(BaseBackend):
|
||||
params = {key: value for key, value in params.items() if value is not None}
|
||||
|
||||
response = await self.request(method="GET", path=path, params=params)
|
||||
senses = [EncryptedSense.model_validate(sense) for sense in response["data"]]
|
||||
data = [EncryptedSense.model_validate(sense) for sense in response["data"]]
|
||||
|
||||
return EncryptedSenseList(senses=senses)
|
||||
return EncryptedSenseList(
|
||||
data=data,
|
||||
limit=response["limit"],
|
||||
total_items=response["total_items"],
|
||||
previous=response["previous"],
|
||||
next=response["next"],
|
||||
)
|
||||
|
||||
async def fetch_sense(self, sense_id: uuid.UUID) -> EncryptedSense:
|
||||
path = f"/senses/{sense_id}"
|
||||
|
||||
@@ -9,6 +9,7 @@ from soul_diary.ui.app.routes import AUTH, SENSE_LIST
|
||||
async def middleware(page: flet.Page, params: Params, basket: Basket):
|
||||
local_storage = LocalStorage(client_storage=page.client_storage)
|
||||
auth_data = await local_storage.get_auth_data()
|
||||
# await local_storage._client_storage.clear_async()
|
||||
if auth_data is None:
|
||||
await page.go_async(AUTH)
|
||||
return
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pydantic import BaseModel, constr
|
||||
from pydantic import BaseModel, constr, field_validator
|
||||
|
||||
|
||||
class Emotion(str, enum.Enum):
|
||||
JOY = "радость"
|
||||
FORCE = "сила"
|
||||
CALMNESS = "спокойствие"
|
||||
SADNESS = "грусть"
|
||||
MADNESS = "бешенство"
|
||||
JOY = "радость"
|
||||
CALMNESS = "спокойствие"
|
||||
IRRITATION = "раздражение"
|
||||
ANGER = "злость"
|
||||
FEAR = "страх"
|
||||
SHAME = "стыд"
|
||||
GUILD = "вина"
|
||||
RESENTMENT = "обида"
|
||||
BOREDOM = "скука"
|
||||
ANXIETY = "тревога"
|
||||
COURAGE = "смелость"
|
||||
PRIDE = "гордость"
|
||||
ENERGY = "энергичность"
|
||||
THANKFULNESS = "благодарность"
|
||||
PLEASURE = "удовольствие"
|
||||
DELIGHT = "восхищение"
|
||||
|
||||
|
||||
class BackendType(str, enum.Enum):
|
||||
@@ -26,3 +37,10 @@ class Sense(BaseModel):
|
||||
body: constr(min_length=1, strip_whitespace=True)
|
||||
desires: constr(min_length=1, strip_whitespace=True)
|
||||
created_at: datetime
|
||||
|
||||
@field_validator("created_at")
|
||||
@classmethod
|
||||
def created_at_validator(cls, created_at: datetime) -> datetime:
|
||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||
local_timezone = datetime.now().astimezone().tzinfo
|
||||
return created_at.astimezone(local_timezone)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from functools import partial
|
||||
|
||||
import flet
|
||||
@@ -15,6 +18,8 @@ class SenseListPage(BasePage):
|
||||
def __init__(self, view: flet.View, local_storage: LocalStorage, extend: bool = False):
|
||||
self.local_storage = local_storage
|
||||
self.senses = []
|
||||
self.next_cursor = None
|
||||
self.lock = asyncio.Lock()
|
||||
self.senses_cards: flet.Column
|
||||
self.extend = extend
|
||||
|
||||
@@ -23,6 +28,7 @@ class SenseListPage(BasePage):
|
||||
def build(self) -> flet.Container:
|
||||
self.view.vertical_alignment = flet.MainAxisAlignment.START
|
||||
self.view.scroll = flet.ScrollMode.ALWAYS
|
||||
self.view.on_scroll = self.callback_scroll
|
||||
|
||||
view_switch = flet.Switch(
|
||||
label="Расширенный вид",
|
||||
@@ -67,7 +73,8 @@ class SenseListPage(BasePage):
|
||||
async def did_mount_async(self):
|
||||
backend_client = await get_backend_client(self.local_storage)
|
||||
sense_list = await backend_client.get_sense_list()
|
||||
self.senses = sense_list.senses
|
||||
self.senses = sense_list.data
|
||||
self.next_cursor = sense_list.next
|
||||
await self.render_cards()
|
||||
|
||||
async def render_cards(self):
|
||||
@@ -166,6 +173,21 @@ class SenseListPage(BasePage):
|
||||
|
||||
return gesture_detector
|
||||
|
||||
@asynccontextmanager
|
||||
async def in_progress(self):
|
||||
progress_ring = flet.Container(
|
||||
content=flet.ProgressRing(),
|
||||
alignment=flet.alignment.center,
|
||||
height=150,
|
||||
)
|
||||
self.senses_cards.controls.append(progress_ring)
|
||||
await self.update_async()
|
||||
|
||||
yield
|
||||
|
||||
self.senses_cards.controls.pop()
|
||||
await self.update_async()
|
||||
|
||||
@callback_error_handle
|
||||
async def callback_switch_view(self, event: flet.ControlEvent):
|
||||
self.extend = event.control.value
|
||||
@@ -187,3 +209,20 @@ class SenseListPage(BasePage):
|
||||
await backend_client.logout()
|
||||
await self.local_storage.clear_shared_data()
|
||||
await event.page.go_async(AUTH)
|
||||
|
||||
@callback_error_handle
|
||||
async def callback_scroll(self, event: flet.OnScrollEvent):
|
||||
if (
|
||||
event.pixels < event.max_scroll_extent - 100 or
|
||||
self.next_cursor is None or
|
||||
self.lock.locked()
|
||||
):
|
||||
return
|
||||
|
||||
async with self.lock:
|
||||
backend_client = await get_backend_client(local_storage=self.local_storage)
|
||||
async with self.in_progress():
|
||||
sense_list = await backend_client.get_sense_list(cursor=self.next_cursor)
|
||||
self.senses.extend(sense_list.data)
|
||||
self.next_cursor = sense_list.next
|
||||
await self.render_cards()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Any
|
||||
|
||||
import flet
|
||||
import flet_fastapi
|
||||
import uvicorn
|
||||
from facet import ServiceMixin
|
||||
@@ -24,10 +25,13 @@ class WebService(ServiceMixin):
|
||||
return self._port
|
||||
|
||||
async def start(self):
|
||||
app = flet_fastapi.app(SoulDiaryApp(
|
||||
backend=BackendType.SOUL,
|
||||
backend_data=self._backend_data,
|
||||
).run)
|
||||
app = flet_fastapi.app(
|
||||
SoulDiaryApp(
|
||||
backend=BackendType.SOUL,
|
||||
backend_data=self._backend_data,
|
||||
).run,
|
||||
web_renderer=flet.WebRenderer.HTML,
|
||||
)
|
||||
config = uvicorn.Config(app=app, host="0.0.0.0", port=self._port)
|
||||
server = UvicornServer(config)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user