Add backend

This commit is contained in:
2023-12-14 15:36:45 +03:00
parent 31e92a2f00
commit 62ec2d52f8
48 changed files with 1686 additions and 40 deletions

View File

@@ -0,0 +1,2 @@
from .cli import get_cli
from .service import BackendService, get_service

View File

@@ -0,0 +1,7 @@
from .cli import get_cli
if __name__ == "__main__":
cli = get_cli()
cli()

View File

@@ -0,0 +1,2 @@
from .cli import get_cli
from .service import APIService, get_service

View File

@@ -0,0 +1,13 @@
import typer
def run():
pass
def get_cli() -> typer.Typer:
cli = typer.Typer()
cli.command(name="run")(run)
return cli

View File

@@ -0,0 +1,31 @@
import fastapi
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from soul_diary.backend.api.exceptions import HTTPNotAuthenticated
from soul_diary.backend.api.settings import APISettings
from soul_diary.backend.database import DatabaseService
from soul_diary.backend.database.models import Session
async def database(request: fastapi.Request) -> DatabaseService:
return request.app.service.database
async def settings(request: fastapi.Request) -> APISettings:
return request.app.service.settings
async def is_auth(
database: DatabaseService = fastapi.Depends(database),
credentials: HTTPAuthorizationCredentials = fastapi.Depends(HTTPBearer()),
) -> Session:
async with database.transaction() as session:
user_session = await database.get_user_session(
session=session,
token=credentials.credentials,
)
if user_session is None:
raise HTTPNotAuthenticated()
return user_session

View File

@@ -0,0 +1,41 @@
import fastapi
class HTTPRegistrationNotSupported(fastapi.HTTPException):
def __init__(self):
super().__init__(
status_code=fastapi.status.HTTP_400_BAD_REQUEST,
detail="Registration not supported.",
)
class HTTPUserAlreadyExists(fastapi.HTTPException):
def __init__(self):
super().__init__(
status_code=fastapi.status.HTTP_400_BAD_REQUEST,
detail="User already exists.",
)
class HTTPNotAuthenticated(fastapi.HTTPException):
def __init__(self):
super().__init__(
status_code=fastapi.status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated.",
)
class HTTPForbidden(fastapi.HTTPException):
def __init__(self):
super().__init__(
status_code=fastapi.status.HTTP_403_FORBIDDEN,
detail="Forbidden.",
)
class HTTPNotFound(fastapi.HTTPException):
def __init__(self):
super().__init__(
status_code=fastapi.status.HTTP_404_NOT_FOUND,
detail="Not found.",
)

View File

@@ -0,0 +1,63 @@
import fastapi
from sqlalchemy.exc import IntegrityError
from soul_diary.backend.api.settings import APISettings
from soul_diary.backend.api.dependencies import database, is_auth, settings
from soul_diary.backend.database import DatabaseService
from soul_diary.backend.database.models import Session
from .exceptions import (
HTTPNotAuthenticated,
HTTPRegistrationNotSupported,
HTTPUserAlreadyExists,
)
from .schemas import CredentialsRequest, OptionsResponse, TokenResponse
async def options(settings: APISettings = fastapi.Depends(settings)) -> OptionsResponse:
return OptionsResponse(registration_enabled=settings.registration_enabled)
async def sign_up(
data: CredentialsRequest = fastapi.Body(...),
settings: APISettings = fastapi.Depends(settings),
database: DatabaseService = fastapi.Depends(database),
) -> TokenResponse:
if not settings.registration_enabled:
raise HTTPRegistrationNotSupported()
try:
async with database.transaction() as session:
user = await database.create_user(
session=session,
username=data.username,
password=data.password,
)
except IntegrityError:
raise HTTPUserAlreadyExists()
user_session = user.sessions[0]
return TokenResponse(token=user_session.token)
async def sign_in(
data: CredentialsRequest = fastapi.Body(...),
database: DatabaseService = fastapi.Depends(database),
) -> TokenResponse:
async with database.transaction() as session:
user_session = await database.auth_user(
session=session,
username=data.username,
password=data.password,
)
if user_session is None:
raise HTTPNotAuthenticated()
return TokenResponse(token=user_session.token)
async def logout(
database: DatabaseService = fastapi.Depends(database),
user_session: Session = fastapi.Depends(is_auth),
):
async with database.transaction() as session:
await database.logout_user(session=session, user_session=user_session)

View File

@@ -0,0 +1,15 @@
import fastapi
from . import handlers, senses
from .dependencies import database
router = fastapi.APIRouter(
dependencies=[fastapi.Depends(database)],
)
router.add_api_route(path="/signup", methods=["POST"], endpoint=handlers.sign_up)
router.add_api_route(path="/signin", methods=["POST"], endpoint=handlers.sign_in)
router.add_api_route(path="/logout", methods=["POST"], endpoint=handlers.logout)
router.add_api_route(path="/options", methods=["GET"], endpoint=handlers.options)
router.include_router(senses.router, prefix="/senses", tags=["Senses"])

View File

@@ -0,0 +1,14 @@
from pydantic import BaseModel, constr
class CredentialsRequest(BaseModel):
username: constr(min_length=4, max_length=64, strip_whitespace=True)
password: constr(min_length=8, max_length=64)
class TokenResponse(BaseModel):
token: constr(min_length=32, max_length=32)
class OptionsResponse(BaseModel):
registration_enabled: bool

View File

@@ -0,0 +1 @@
from .router import router

View File

@@ -0,0 +1,24 @@
import uuid
import fastapi
from soul_diary.backend.api.dependencies import database, is_auth
from soul_diary.backend.api.exceptions import HTTPForbidden, HTTPNotFound
from soul_diary.backend.database import DatabaseService
from soul_diary.backend.database.models import Sense, Session
async def sense(
database: DatabaseService = fastapi.Depends(database),
user_session: Session = fastapi.Depends(is_auth),
sense_id: uuid.UUID = fastapi.Path(),
) -> Sense:
async with database.transaction() as session:
sense = await database.get_sense(session=session, sense_id=sense_id)
if sense is None:
raise HTTPNotFound()
if sense.user_id != user_session.user_id:
raise HTTPForbidden()
return sense

View File

@@ -0,0 +1,71 @@
import fastapi
from soul_diary.backend.api.dependencies import database
from soul_diary.backend.database import DatabaseService
from soul_diary.backend.database.models import Sense, Session
from .dependencies import is_auth, sense
from .schemas import (
CreateSenseRequest,
Pagination,
SenseListResponse,
SenseResponse,
UpdateSenseRequest,
)
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_sense_list(
session=session,
user=user_session.user,
page=pagination.page,
limit=pagination.limit,
)
return SenseListResponse(data=senses)
async def create_sense(
database: DatabaseService = fastapi.Depends(database),
user_session: Session = fastapi.Depends(is_auth),
data: CreateSenseRequest = fastapi.Body(),
) -> SenseResponse:
async with database.transaction() as session:
sense = await database.create_sense(
session=session,
user=user_session.user,
data=data.data,
)
return SenseResponse.model_validate(sense)
async def get_sense(sense: Sense = fastapi.Depends(sense)) -> SenseResponse:
return SenseResponse.model_validate(sense)
async def update_sense(
database: DatabaseService = fastapi.Depends(database),
sense: Sense = fastapi.Depends(sense),
data: UpdateSenseRequest = fastapi.Body(),
) -> SenseResponse:
async with database.transaction() as session:
sense = await database.update_sense(
session=session,
sense=sense,
data=data.data,
)
return SenseResponse.model_validate(sense)
async def delete_sense(
database: DatabaseService = fastapi.Depends(database),
sense: Sense = fastapi.Depends(sense),
):
async with database.transaction() as session:
await database.delete_sense(session=session, sense=sense)

View File

@@ -0,0 +1,11 @@
import fastapi
from . import handlers
router = fastapi.APIRouter()
router.add_api_route(path="/", methods=["GET"], endpoint=handlers.get_sense_list)
router.add_api_route(path="/", methods=["POST"], endpoint=handlers.create_sense)
router.add_api_route(path="/{sense_id}", methods=["GET"], endpoint=handlers.get_sense)
router.add_api_route(path="/{sense_id}", methods=["POST"], endpoint=handlers.update_sense)
router.add_api_route(path="/{sense_id}", methods=["DELETE"], endpoint=handlers.delete_sense)

View File

@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from pydantic import AwareDatetime, BaseModel, ConfigDict, PositiveInt
class CreateSenseRequest(BaseModel):
data: str
class UpdateSenseRequest(BaseModel):
data: str
class SenseResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
data: str
created_at: datetime
class Pagination(BaseModel):
page: PositiveInt = 1
limit: PositiveInt = 10
class SenseListResponse(BaseModel):
data: list[SenseResponse]

View File

@@ -0,0 +1,62 @@
import fastapi
import uvicorn
from facet import ServiceMixin
from fastapi.middleware.cors import CORSMiddleware
from soul_diary.backend.database import DatabaseService, get_service as get_database_service
from . import router
from .settings import APISettings
class UvicornServer(uvicorn.Server):
def install_signal_handlers(self):
pass
class APIService(ServiceMixin):
def __init__(self, database: DatabaseService, settings: APISettings, port: int = 8001):
self._database = database
self._settings = settings
self._port = port
@property
def database(self) -> DatabaseService:
return self._database
@property
def settings(self) -> APISettings:
return self._settings
def get_app(self) -> fastapi.FastAPI:
app = fastapi.FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.service = self
self.setup_app(app=app)
return app
def setup_app(self, app: fastapi.FastAPI):
app.include_router(router.router)
async def start(self):
config = uvicorn.Config(app=self.get_app(), host="0.0.0.0", port=self._port)
server = UvicornServer(config)
self.add_task(server.serve())
def get_service() -> APIService:
database_service = get_database_service()
settings = APISettings()
return APIService(
database=database_service,
settings=settings,
port=settings.port,
)

View File

@@ -0,0 +1,10 @@
from pydantic import conint
from pydantic_settings import BaseSettings, SettingsConfigDict
class APISettings(BaseSettings):
model_config = SettingsConfigDict(prefix="backend_api_")
port: conint(ge=1, le=65535) = 8001
registration_enabled: bool = True

22
soul_diary/backend/cli.py Normal file
View File

@@ -0,0 +1,22 @@
import asyncio
import typer
from . import api, database
from .service import get_service
def run():
backend_service = get_service()
asyncio.run(backend_service.run())
def get_cli() -> typer.Typer:
cli = typer.Typer()
cli.add_typer(api.get_cli(), name="api")
cli.add_typer(database.get_cli(), name="database")
cli.command(name="run")(run)
return cli

View File

@@ -0,0 +1,2 @@
from .cli import get_cli
from .service import DatabaseService, get_service

View File

@@ -0,0 +1,67 @@
from typing import Optional
import typer
from .service import DatabaseService, get_service
def migrations_list(ctx: typer.Context):
database_service: DatabaseService = ctx.obj["database"]
database_service.show_migrations()
def migrations_apply(ctx: typer.Context):
database_service: DatabaseService = ctx.obj["database"]
database_service.migrate()
def migrations_rollback(
ctx: typer.Context,
revision: Optional[str] = typer.Argument(
None,
help="Revision id or relative revision (`-1`, `-2`)",
),
):
database_service: DatabaseService = ctx.obj["database"]
database_service.rollback(revision=revision)
def migrations_create(
ctx: typer.Context,
message: Optional[str] = typer.Option(
None,
"-m", "--message",
help="Migration short message",
),
):
database_service: DatabaseService = ctx.obj["database"]
database_service.create_migration(message=message)
def get_migrations_cli() -> typer.Typer:
cli = typer.Typer(name="Migration")
cli.command(name="apply")(migrations_apply)
cli.command(name="rollback")(migrations_rollback)
cli.command(name="create")(migrations_create)
cli.command(name="list")(migrations_list)
return cli
def service_callback(ctx: typer.Context):
ctx.obj = ctx.obj or {}
ctx.obj["database"] = get_service()
def get_cli() -> typer.Typer:
cli = typer.Typer()
cli.callback()(service_callback)
cli.add_typer(get_migrations_cli(), name="migrations")
return cli

View File

@@ -0,0 +1,91 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from soul_diary.backend.database import models
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = models.Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,66 @@
"""init
Revision ID: bce1e66bb101
Revises:
Create Date: 2023-12-14 10:47:06.594959
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
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
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_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('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')
# ### end Alembic commands ###

View File

@@ -0,0 +1,61 @@
import random
import string
import uuid
from datetime import datetime
from sqlalchemy import ForeignKey, Index, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(default=uuid.uuid4, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True)
password: Mapped[str] = mapped_column(String(72))
senses: Mapped[list["Sense"]] = relationship(back_populates="user")
sessions: Mapped[list["Session"]] = relationship(back_populates="user")
__table_args__ = (
Index("users__id_idx", "id", postgresql_using="hash"),
Index("users__username_idx", "username", postgresql_using="hash"),
)
class Session(Base):
__tablename__ = "sessions"
token: Mapped[str] = mapped_column(
primary_key=True,
default=lambda: "".join(random.choice(string.hexdigits) for _ in range(32)),
)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
user: Mapped[User] = relationship(back_populates="sessions", lazy=False)
__table_args__ = (
Index("sessions__token_idx", "token", postgresql_using="hash"),
Index("sessions__user_id_idx", "user_id", postgresql_using="btree"),
)
class Sense(Base):
__tablename__ = "senses"
id: Mapped[uuid.UUID] = mapped_column(default=uuid.uuid4, primary_key=True)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
data: Mapped[str]
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
user: Mapped[User] = relationship(back_populates="senses", lazy=False)
__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"),
)

View File

@@ -0,0 +1,144 @@
import pathlib
import uuid
from contextlib import asynccontextmanager
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 sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from .models import Sense, Session, User
from .settings import DatabaseSettings
class DatabaseService(ServiceMixin):
def __init__(self, dsn: str):
self._dsn = dsn
self._engine = create_async_engine(self._dsn, pool_recycle=60)
self._sessionmaker = async_sessionmaker(self._engine, expire_on_commit=False)
def get_alembic_config(self) -> AlembicConfig:
migrations_path = pathlib.Path(__file__).parent / "migrations"
config = AlembicConfig()
config.set_main_option("script_location", str(migrations_path))
config.set_main_option("sqlalchemy.url", self._dsn.replace("%", "%%"))
return config
def get_models(self) -> list[Type[DeclarativeBase]]:
return [User, Sense]
@asynccontextmanager
async def transaction(self):
async with self._sessionmaker() as session:
async with session.begin():
yield session
def migrate(self):
alembic_command.upgrade(self.get_alembic_config(), "head")
def rollback(self, revision: str | None = None):
revision = revision or "-1"
alembic_command.downgrade(self.get_alembic_config(), revision)
def show_migrations(self):
alembic_command.history(self.get_alembic_config())
def create_migration(self, message: str | None = None):
alembic_command.revision(
self.get_alembic_config(), message=message, autogenerate=True,
)
async def create_user(self, session: AsyncSession, username: str, password: str) -> User:
hashed_password = bcrypt.hashpw(
password.encode("utf-8"),
bcrypt.gensalt(),
).decode("utf-8")
user = User(username=username, password=hashed_password)
user_session = Session(user=user)
user.sessions.append(user_session)
session.add_all([user, user_session])
return user
async def auth_user(
self,
session: AsyncSession,
username: str,
password: str,
) -> Session | None:
query = select(User).where(User.username == username)
result = await session.execute(query)
user = result.scalar_one_or_none()
if user is None:
return None
if not bcrypt.checkpw(password.encode("utf-8"), user.password.encode("utf-8")):
return None
user_session = Session(user=user)
session.add(user_session)
return user_session
async def logout_user(self, session: AsyncSession, user_session: Session):
await session.delete(user_session)
async def get_user_session(self, session: AsyncSession, token: str) -> Session | None:
query = select(Session).where(Session.token == token)
result = await session.execute(query)
user_session = result.scalar_one_or_none()
return user_session
async def get_sense_list(
self,
session: AsyncSession,
user: User,
page: int = 1,
limit: int = 10,
) -> list[Sense]:
query = select(Sense).where(Sense.user == user).limit(limit).offset((page - 1) * limit)
result = await session.execute(query)
senses = result.scalars().all()
return list(senses)
async def create_sense(self, session: AsyncSession, user: User, data: str) -> Sense:
sense = Sense(user=user, data=data)
session.add(sense)
return sense
async def get_sense(self, session: AsyncSession, sense_id: uuid.UUID) -> Sense | None:
query = select(Sense).where(Sense.id == sense_id)
result = await session.execute(query)
sense = result.scalar_one_or_none()
return sense
async def update_sense(self, session: AsyncSession, sense: Sense, data: str) -> Sense:
sense.data = data
session.add(sense)
return sense
async def delete_sense(self, session: AsyncSession, sense: Sense):
await session.delete(sense)
def get_service() -> DatabaseService:
settings = DatabaseSettings()
return DatabaseService(dsn=str(settings.dsn))

View File

@@ -0,0 +1,8 @@
from pydantic import AnyUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseSettings(BaseSettings):
model_config = SettingsConfigDict(prefix="backend_database_")
dsn: AnyUrl = "sqlite+aiosqlite:///soul_diary.sqlite3"

View File

@@ -0,0 +1,19 @@
from facet import ServiceMixin
from .api import APIService, get_service as get_api_service
class BackendService(ServiceMixin):
def __init__(self, api: APIService):
self._api = api
@property
def dependencies(self) -> list[ServiceMixin]:
return [
self._api,
]
def get_service() -> BackendService:
api_service = get_api_service()
return BackendService(api=api_service)

View File

@@ -2,18 +2,20 @@ import asyncio
import typer
from . import ui
from . import backend, ui
from .service import get_service
def run():
ui_service = ui.get_service()
soul_diary_service = get_service()
asyncio.run(ui_service.run())
asyncio.run(soul_diary_service.run())
def get_cli() -> typer.Typer:
cli = typer.Typer()
cli.add_typer(backend.get_cli(), name="backend")
cli.add_typer(ui.get_cli(), name="ui")
cli.command(name="run")(run)

23
soul_diary/service.py Normal file
View File

@@ -0,0 +1,23 @@
from facet import ServiceMixin
from .backend import BackendService, get_service as get_backend_service
from .ui import UIService, get_service as get_ui_service
class SoulDiaryService(ServiceMixin):
def __init__(self, backend: BackendService, ui: UIService):
self._backend = backend
self._ui = ui
@property
def dependencies(self) -> list[ServiceMixin]:
return [
self._backend,
self._ui,
]
def get_service() -> SoulDiaryService:
backend_service = get_backend_service()
ui_service = get_ui_service()
return SoulDiaryService(backend=backend_service, ui=ui_service)

View File

@@ -100,9 +100,11 @@ class BaseBackend:
)
async def logout(self):
await self.deauth()
self._token = None
self._encryption_key = None
self._username = None
await self._local_storage.remove_auth_data()
@property
def is_auth(self) -> bool:
@@ -167,6 +169,9 @@ class BaseBackend:
async def auth(self, username: str, password: str) -> str:
raise NotImplementedError
async def deauth(self):
raise NotImplementedError
async def get_options(self) -> Options:
raise NotImplementedError

View File

@@ -2,6 +2,10 @@ class BackendException(Exception):
pass
class RegistrationNotSupportedException(BackendException):
pass
class UserAlreadyExistsException(BackendException):
pass

View File

@@ -54,6 +54,9 @@ class LocalBackend(BaseBackend):
return auth_block
async def deauth(self):
pass
async def get_options(self) -> Options:
return Options(registration_enabled=True)

View File

@@ -1,9 +1,10 @@
import uuid
from datetime import datetime
from pydantic import AwareDatetime, BaseModel
from pydantic import BaseModel
class SenseBackendData(BaseModel):
id: uuid.UUID
data: str
created_at: AwareDatetime
created_at: datetime

View File

@@ -0,0 +1,173 @@
import uuid
from typing import Any
import httpx
import yarl
from soul_diary.ui.app.local_storage import LocalStorage
from soul_diary.ui.app.models import BackendType, Options
from .base import BaseBackend
from .exceptions import (
IncorrectCredentialsException,
NonAuthenticatedException,
RegistrationNotSupportedException,
SenseNotFoundException,
UserAlreadyExistsException,
)
from .models import SenseBackendData
class SoulBackend(BaseBackend):
BACKEND = BackendType.SOUL
def __init__(
self,
url: yarl.URL | str,
local_storage: LocalStorage,
username: str | None = None,
encryption_key: str | None = None,
token: str | None = None,
):
self._url = yarl.URL(url)
self._client = httpx.AsyncClient()
super().__init__(
local_storage=local_storage,
username=username,
encryption_key=encryption_key,
token=token,
)
def get_backend_data(self) -> dict[str, Any]:
return {"url": str(self._url)}
async def request(
self,
method: str,
path: str,
json = None,
params: dict[str, Any] | None = None,
):
url = self._url / path.lstrip("/")
headers = {}
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
response = await self._client.request(
method=method,
url=str(url),
json=json,
params=params,
headers=headers,
)
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 401:
raise NonAuthenticatedException()
else:
raise exc
return response.json()
async def create_user(self, username: str, password: str) -> str:
path = "/signup"
data = {
"username": username,
"password": password,
}
try:
response = await self.request(method="POST", path=path, json=data)
except httpx.HTTPStatusError as exc:
response = exc.response.json()
if response == {"detail": "User already exists."}:
raise UserAlreadyExistsException()
elif response == {"detail": "Registration not supported."}:
raise RegistrationNotSupportedException()
else:
raise exc
return response["token"]
async def auth(self, username: str, password: str) -> str:
path = "/signin"
data = {
"username": username,
"password": password,
}
try:
response = await self.request(method="POST", path=path, json=data)
except NonAuthenticatedException:
raise IncorrectCredentialsException()
return response["token"]
async def deauth(self):
path = "/logout"
await self.request(method="POST", path=path)
async def get_options(self) -> Options:
path = "/options"
response = await self.request(method="GET", path=path)
return Options.model_validate(response)
async def fetch_sense_list(
self,
page: int = 1,
limit: int = 10,
) -> list[SenseBackendData]:
path = "/senses/"
params = {"page": page, "limit": limit}
response = await self.request(method="GET", path=path, params=params)
senses = [SenseBackendData.model_validate(sense) for sense in response["data"]]
return senses
async def fetch_sense(self, sense_id: uuid.UUID) -> SenseBackendData:
path = f"/senses/{sense_id}"
try:
response = await self.request(method="GET", path=path)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
raise SenseNotFoundException()
else:
raise exc
return SenseBackendData.model_validate(response)
async def pull_sense_data(
self,
data: str,
sense_id: uuid.UUID | None = None,
) -> SenseBackendData:
path = "/senses/" if sense_id is None else f"/senses/{sense_id}"
request_data = {"data": data}
try:
response = await self.request(method="POST", path=path, json=request_data)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
raise SenseNotFoundException()
else:
raise exc
return SenseBackendData.model_validate(response)
async def delete_sense(self, sense_id: uuid.UUID):
path = f"/senses/{sense_id}"
try:
await self.request(method="DELETE", path=path)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
raise SenseNotFoundException()
else:
raise exc

View File

@@ -35,9 +35,11 @@ class LocalStorage:
await self.raw_write("soul_diary.client", auth_data.model_dump(mode="json"))
async def get_auth_data(self) -> AuthData | None:
if await self.raw_contains("soul_diary.client"):
data = await self.raw_read("soul_diary.client")
return AuthData.model_validate(data)
if not await self.raw_contains("soul_diary.client"):
return None
data = await self.raw_read("soul_diary.client")
return AuthData.model_validate(data)
async def remove_auth_data(self):
if await self.raw_contains("soul_diary.client"):

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

View File

@@ -1,7 +1,8 @@
import enum
import uuid
from datetime import datetime
from pydantic import AwareDatetime, BaseModel, constr
from pydantic import BaseModel, constr
class Emotion(str, enum.Enum):
@@ -24,7 +25,7 @@ class Sense(BaseModel):
feelings: constr(min_length=1, strip_whitespace=True)
body: constr(min_length=1, strip_whitespace=True)
desires: constr(min_length=1, strip_whitespace=True)
created_at: AwareDatetime
created_at: datetime
class Options(BaseModel):

View File

@@ -6,7 +6,7 @@ import flet
from pydantic import AnyHttpUrl
from soul_diary.ui.app.backend.exceptions import IncorrectCredentialsException, UserAlreadyExistsException
from soul_diary.ui.app.backend.local import LocalBackend
from soul_diary.ui.app.backend.soul import SoulBackend
from soul_diary.ui.app.local_storage import LocalStorage
from soul_diary.ui.app.middlewares.base import BaseMiddleware
from soul_diary.ui.app.models import BackendType, Options
@@ -243,11 +243,16 @@ class AuthView(BaseView):
async def connect_to_soul_server(self) -> Options:
try:
AnyHttpUrl(self.backend_data.get("url"))
backend_url = AnyHttpUrl(self.backend_data.get("url"))
except ValueError:
raise SoulServerIncorrectURL()
raise
backend_client = SoulBackend(
local_storage=self.local_storage,
url=str(backend_url),
)
return await backend_client.get_options()
async def callback_change_username(self, event: flet.ControlEvent):
self.username = event.control.value
@@ -268,10 +273,13 @@ class AuthView(BaseView):
await event.page.update_async()
return
if self.backend == BackendType.LOCAL:
backend_client = LocalBackend(local_storage=self.local_storage)
else:
backend_client_class = self.BACKEND_MAPPING.get(self.backend)
if backend_client_class is None:
raise
backend_client = backend_client_class(
local_storage=self.local_storage,
**self.backend_data,
)
async with self.in_progress(page=event.page):
try:
@@ -297,10 +305,13 @@ class AuthView(BaseView):
await event.page.update_async()
return
if self.backend == BackendType.LOCAL:
backend_client = LocalBackend(local_storage=self.local_storage)
else:
backend_client_class = self.BACKEND_MAPPING.get(self.backend)
if backend_client_class is None:
raise
backend_client = backend_client_class(
local_storage=self.local_storage,
**self.backend_data,
)
async with self.in_progress(page=event.page):
try:

View File

@@ -8,6 +8,7 @@ from flet_route import Basket, Params
from soul_diary.ui.app.backend.base import BaseBackend
from soul_diary.ui.app.backend.exceptions import NonAuthenticatedException
from soul_diary.ui.app.backend.local import LocalBackend
from soul_diary.ui.app.backend.soul import SoulBackend
from soul_diary.ui.app.local_storage import LocalStorage
from soul_diary.ui.app.middlewares.base import BaseMiddleware
from soul_diary.ui.app.models import BackendType
@@ -67,6 +68,11 @@ class MetaView(type):
class BaseView(metaclass=MetaView):
BACKEND_MAPPING = {
BackendType.LOCAL: LocalBackend,
BackendType.SOUL: SoulBackend,
}
is_abstract = True
_initial_view: Callable | None
@@ -126,14 +132,13 @@ class BaseView(metaclass=MetaView):
if self._initial_view is not None:
await self._initial_view(page=page)
async def get_backend_client(self, page: flet.Page) -> BaseBackend:
async def get_backend_client(self) -> BaseBackend:
auth_data = await self.local_storage.get_auth_data()
if auth_data is None:
raise NonAuthenticatedException()
if auth_data.backend == BackendType.LOCAL:
backend_client_class = LocalBackend
else:
backend_client_class = self.BACKEND_MAPPING.get(auth_data.backend, None)
if backend_client_class is None:
raise
return backend_client_class(

View File

@@ -225,7 +225,7 @@ class SenseAddView(BaseView):
await event.page.update_async()
return
backend_client = await self.get_backend_client(page=event.page)
backend_client = await self.get_backend_client()
async with self.in_progress(page=event.page):
await backend_client.create_sense(
emotions=self.emotions,

View File

@@ -74,9 +74,7 @@ class SenseListView(BaseView):
if auth_data is None:
raise NonAuthenticatedException()
if auth_data.backend == BackendType.LOCAL:
pass
backend_client = await self.get_backend_client(page=page)
backend_client = await self.get_backend_client()
senses = await backend_client.get_sense_list()
self.cards.controls = [await self.render_card_from_sense(sense) for sense in senses]
await page.update_async()
@@ -98,5 +96,6 @@ class SenseListView(BaseView):
await event.page.go_async(SENSE_ADD)
async def callback_logout(self, event: flet.ControlEvent):
await self.local_storage.remove_auth_data()
backend_client = await self.get_backend_client()
await backend_client.logout()
await event.page.go_async(AUTH)

View File

@@ -1,11 +1,21 @@
import asyncio
import typer
from . import web
from .service import get_service
def run():
ui_service = get_service()
asyncio.run(ui_service.run())
def get_cli() -> typer.Typer:
cli = typer.Typer()
cli.command(name="run")(run)
cli.add_typer(web.get_cli(), name="web")
return cli

View File

@@ -25,9 +25,8 @@ class WebService(ServiceMixin):
async def start(self):
app = flet_fastapi.app(SoulDiaryApp(
# backend=BackendType.SOUL,
# backend_data=self._backend_data,
backend=BackendType.LOCAL,
backend=BackendType.SOUL,
backend_data=self._backend_data,
).run)
config = uvicorn.Config(app=app, host="0.0.0.0", port=self._port)
server = UvicornServer(config)

View File

@@ -1,10 +1,12 @@
from typing import Any
from pydantic import conint
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict
class WebSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="ui_web_")
port: conint(ge=1, le=65535) = 8000
backend_data: dict[str, Any] = {
"url": "http://localhost:8001",