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