Add cursor pagination, add new emotions

This commit is contained in:
2023-12-21 13:04:49 +03:00
parent 6ed38dd3fc
commit b040295364
15 changed files with 402 additions and 87 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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]

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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"),
) )

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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):

View File

@@ -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}"

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)