Add backend
This commit is contained in:
2
soul_diary/backend/__init__.py
Normal file
2
soul_diary/backend/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .cli import get_cli
|
||||
from .service import BackendService, get_service
|
||||
7
soul_diary/backend/__main__.py
Normal file
7
soul_diary/backend/__main__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .cli import get_cli
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli = get_cli()
|
||||
|
||||
cli()
|
||||
2
soul_diary/backend/api/__init__.py
Normal file
2
soul_diary/backend/api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .cli import get_cli
|
||||
from .service import APIService, get_service
|
||||
13
soul_diary/backend/api/cli.py
Normal file
13
soul_diary/backend/api/cli.py
Normal 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
|
||||
31
soul_diary/backend/api/dependencies.py
Normal file
31
soul_diary/backend/api/dependencies.py
Normal 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
|
||||
41
soul_diary/backend/api/exceptions.py
Normal file
41
soul_diary/backend/api/exceptions.py
Normal 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.",
|
||||
)
|
||||
63
soul_diary/backend/api/handlers.py
Normal file
63
soul_diary/backend/api/handlers.py
Normal 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)
|
||||
15
soul_diary/backend/api/router.py
Normal file
15
soul_diary/backend/api/router.py
Normal 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"])
|
||||
14
soul_diary/backend/api/schemas.py
Normal file
14
soul_diary/backend/api/schemas.py
Normal 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
|
||||
1
soul_diary/backend/api/senses/__init__.py
Normal file
1
soul_diary/backend/api/senses/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .router import router
|
||||
24
soul_diary/backend/api/senses/dependencies.py
Normal file
24
soul_diary/backend/api/senses/dependencies.py
Normal 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
|
||||
71
soul_diary/backend/api/senses/handlers.py
Normal file
71
soul_diary/backend/api/senses/handlers.py
Normal 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)
|
||||
11
soul_diary/backend/api/senses/router.py
Normal file
11
soul_diary/backend/api/senses/router.py
Normal 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)
|
||||
29
soul_diary/backend/api/senses/schemas.py
Normal file
29
soul_diary/backend/api/senses/schemas.py
Normal 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]
|
||||
62
soul_diary/backend/api/service.py
Normal file
62
soul_diary/backend/api/service.py
Normal 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,
|
||||
)
|
||||
10
soul_diary/backend/api/settings.py
Normal file
10
soul_diary/backend/api/settings.py
Normal 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
22
soul_diary/backend/cli.py
Normal 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
|
||||
2
soul_diary/backend/database/__init__.py
Normal file
2
soul_diary/backend/database/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .cli import get_cli
|
||||
from .service import DatabaseService, get_service
|
||||
67
soul_diary/backend/database/cli.py
Normal file
67
soul_diary/backend/database/cli.py
Normal 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
|
||||
91
soul_diary/backend/database/migrations/env.py
Normal file
91
soul_diary/backend/database/migrations/env.py
Normal 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()
|
||||
26
soul_diary/backend/database/migrations/script.py.mako
Normal file
26
soul_diary/backend/database/migrations/script.py.mako
Normal 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"}
|
||||
@@ -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 ###
|
||||
61
soul_diary/backend/database/models.py
Normal file
61
soul_diary/backend/database/models.py
Normal 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"),
|
||||
)
|
||||
144
soul_diary/backend/database/service.py
Normal file
144
soul_diary/backend/database/service.py
Normal 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))
|
||||
8
soul_diary/backend/database/settings.py
Normal file
8
soul_diary/backend/database/settings.py
Normal 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"
|
||||
19
soul_diary/backend/service.py
Normal file
19
soul_diary/backend/service.py
Normal 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)
|
||||
@@ -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
23
soul_diary/service.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ class BackendException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RegistrationNotSupportedException(BackendException):
|
||||
pass
|
||||
|
||||
|
||||
class UserAlreadyExistsException(BackendException):
|
||||
pass
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
173
soul_diary/ui/app/backend/soul.py
Normal file
173
soul_diary/ui/app/backend/soul.py
Normal 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
|
||||
@@ -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"):
|
||||
|
||||
@@ -9,6 +9,7 @@ from soul_diary.ui.app.routes import AUTH, SENSE_LIST
|
||||
async def middleware(page: flet.Page, params: Params, basket: Basket):
|
||||
local_storage = LocalStorage(client_storage=page.client_storage)
|
||||
auth_data = await local_storage.get_auth_data()
|
||||
# await local_storage._client_storage.clear_async()
|
||||
if auth_data is None:
|
||||
await page.go_async(AUTH)
|
||||
return
|
||||
|
||||
@@ -1,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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user