Initial commit
This commit is contained in:
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
11
.idea/budget_forecast.iml
generated
Normal file
11
.idea/budget_forecast.iml
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/api/app" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/api/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.12 virtualenv at ~/projects/dev/python/budget_forecast/api/venv" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
12
.idea/dataSources.xml
generated
Normal file
12
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="database" uuid="3b6482e0-f5d8-48d7-92f8-7d2d88a46418">
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/api/app/database.db</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
7
.idea/misc.xml
generated
Normal file
7
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.12 (budget_forecast)" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 virtualenv at ~/projects/dev/python/budget_forecast/api/venv" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/budget_forecast.iml" filepath="$PROJECT_DIR$/.idea/budget_forecast.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/gui/app" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
0
api/app/__init__.py
Normal file
0
api/app/__init__.py
Normal file
0
api/app/account/__init__.py
Normal file
0
api/app/account/__init__.py
Normal file
81
api/app/account/models.py
Normal file
81
api/app/account/models.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from uuid import UUID, uuid4
|
||||
from enum import Enum
|
||||
|
||||
from sqlmodel import Field, SQLModel, select
|
||||
|
||||
from category.models import CategoryRead
|
||||
|
||||
|
||||
class AccountType(Enum):
|
||||
|
||||
Asset = "Asset" # < Denotes a generic asset account.
|
||||
Checkings = "Checkings" # < Standard checking account
|
||||
Savings = "Savings" # < Typical savings account
|
||||
Cash = "Cash" # < Denotes a shoe-box or pillowcase stuffed with cash
|
||||
Liability = "Liability" # < Denotes a generic liability account.
|
||||
CreditCard = "CreditCard" # < Credit card accounts
|
||||
Loan = "Loan" # < Loan and mortgage accounts (liability)
|
||||
CertificateDep = "CertificateDep" # < Certificates of Deposit
|
||||
Investment = "Investment" # < Investment account
|
||||
MoneyMarket = "MoneyMarket" # < Money Market Account
|
||||
|
||||
Currency = "Currency" # < Denotes a currency trading account.
|
||||
Income = "Income" # < Denotes an income account
|
||||
Expense = "Expense" # < Denotes an expense account
|
||||
AssetLoan = "AssetLoan" # < Denotes a loan (asset of the owner of this object)
|
||||
Stock = "Stock" # < Denotes an security account as sub-account for an investment
|
||||
Equity = "Equity" # < Denotes an equity account e.g. opening/closing balance
|
||||
|
||||
Payee = "Payee"
|
||||
|
||||
class AccountBase(SQLModel):
|
||||
name: str = Field(index=True)
|
||||
type: AccountType = Field(index=True)
|
||||
default_category_id: UUID | None = Field(default=None, foreign_key="category.id")
|
||||
|
||||
class AccountBaseId(AccountBase):
|
||||
id: UUID | None = Field(default_factory=uuid4, primary_key=True)
|
||||
|
||||
class Account(AccountBaseId, table=True):
|
||||
|
||||
@classmethod
|
||||
def create(cls, account, session):
|
||||
account_db = cls.model_validate(account)
|
||||
session.add(account_db)
|
||||
session.commit()
|
||||
session.refresh(account_db)
|
||||
|
||||
return account_db
|
||||
|
||||
@classmethod
|
||||
def list(cls):
|
||||
return select(Account)
|
||||
|
||||
@classmethod
|
||||
def get(cls, session, account_id):
|
||||
return session.get(Account, account_id)
|
||||
|
||||
@classmethod
|
||||
def update(cls, session, account_db, account_data):
|
||||
account_db.sqlmodel_update(account_data)
|
||||
session.add(account_db)
|
||||
session.commit()
|
||||
session.refresh(account_db)
|
||||
return account_db
|
||||
|
||||
@classmethod
|
||||
def delete(cls, session, account):
|
||||
session.delete(account)
|
||||
session.commit()
|
||||
|
||||
class AccountRead(AccountBaseId):
|
||||
default_category: CategoryRead
|
||||
|
||||
class AccountWrite(AccountBase):
|
||||
pass
|
||||
|
||||
class AccountCreate(AccountWrite):
|
||||
pass
|
||||
|
||||
class AccountUpdate(AccountWrite):
|
||||
pass
|
||||
48
api/app/account/routes.py
Normal file
48
api/app/account/routes.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from sqlmodel import Field, Session, SQLModel, create_engine, select
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlmodel import paginate
|
||||
|
||||
from .models import Account, AccountCreate, AccountRead, AccountUpdate
|
||||
from db import SessionDep
|
||||
from user.manager import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("")
|
||||
def create_account(account: AccountCreate, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead:
|
||||
Account.create(account, session)
|
||||
return account
|
||||
|
||||
@router.get("")
|
||||
def read_accounts(session: SessionDep, current_user=Depends(get_current_user)) -> Page[AccountRead]:
|
||||
return paginate(session, Account.list())
|
||||
|
||||
@router.get("/{account_id}")
|
||||
def read_account(account_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead:
|
||||
account = Account.get(session, account_id)
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
return account
|
||||
|
||||
@router.put("/{account_id}")
|
||||
def update_account(account_id: UUID, account: AccountUpdate, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead:
|
||||
db_account = Account.get(session, account_id)
|
||||
if not db_account:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
account_data = account.model_dump(exclude_unset=True)
|
||||
account = Account.update(session, db_account, account_data)
|
||||
return account
|
||||
|
||||
@router.delete("/{account_id}")
|
||||
def delete_account(account_id: UUID, session: SessionDep, current_user=Depends(get_current_user)):
|
||||
account = Account.get(session, account_id)
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
Account.delete(session, account)
|
||||
return {"ok": True}
|
||||
117
api/app/alembic.ini
Normal file
117
api/app/alembic.ini
Normal file
@@ -0,0 +1,117 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
# Use forward slashes (/) also on windows to provide an os agnostic path
|
||||
script_location = migrations
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to migrations/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
# version_path_separator = newline
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = sqlite:///database.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
0
api/app/category/__init__.py
Normal file
0
api/app/category/__init__.py
Normal file
51
api/app/category/models.py
Normal file
51
api/app/category/models.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from uuid import UUID, uuid4
|
||||
from enum import Enum
|
||||
|
||||
from sqlmodel import Field, SQLModel, select
|
||||
|
||||
class CategoryBase(SQLModel):
|
||||
name: str = Field(index=True)
|
||||
|
||||
class CategoryRead(CategoryBase):
|
||||
id: UUID | None = Field(default_factory=uuid4, primary_key=True)
|
||||
|
||||
class Category(CategoryRead, table=True):
|
||||
|
||||
@classmethod
|
||||
def create(cls, category, session):
|
||||
category_db = cls.model_validate(category)
|
||||
session.add(category_db)
|
||||
session.commit()
|
||||
session.refresh(category_db)
|
||||
|
||||
return category_db
|
||||
|
||||
@classmethod
|
||||
def list(cls):
|
||||
return select(Category)
|
||||
|
||||
@classmethod
|
||||
def get(cls, session, category_id):
|
||||
return session.get(Category, category_id)
|
||||
|
||||
@classmethod
|
||||
def update(cls, session, category_db, category_data):
|
||||
category_db.sqlmodel_update(category_data)
|
||||
session.add(category_db)
|
||||
session.commit()
|
||||
session.refresh(category_db)
|
||||
return category_db
|
||||
|
||||
@classmethod
|
||||
def delete(cls, session, category):
|
||||
session.delete(category)
|
||||
session.commit()
|
||||
|
||||
class CategoryWrite(CategoryBase):
|
||||
pass
|
||||
|
||||
class CategoryCreate(CategoryWrite):
|
||||
pass
|
||||
|
||||
class CategoryUpdate(CategoryWrite):
|
||||
pass
|
||||
47
api/app/category/routes.py
Normal file
47
api/app/category/routes.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlmodel import paginate
|
||||
|
||||
from category.models import Category, CategoryCreate, CategoryRead, CategoryUpdate
|
||||
from db import SessionDep
|
||||
from user.manager import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("")
|
||||
def create_category(category: CategoryCreate, session: SessionDep, current_user=Depends(get_current_user)) -> CategoryRead:
|
||||
Category.create(category, session)
|
||||
return category
|
||||
|
||||
@router.get("")
|
||||
def read_categories(session: SessionDep, current_user=Depends(get_current_user)) -> Page[CategoryRead]:
|
||||
return paginate(session, Category.list())
|
||||
|
||||
@router.get("/{category_id}")
|
||||
def read_category(category_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> CategoryRead:
|
||||
category = Category.get(session, category_id)
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
return category
|
||||
|
||||
@router.put("/{category_id}")
|
||||
def update_category(category_id: UUID, category: CategoryUpdate, session: SessionDep, current_user=Depends(get_current_user)) -> CategoryRead:
|
||||
db_category = Category.get(session, category_id)
|
||||
if not db_category:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
|
||||
category_data = category.model_dump(exclude_unset=True)
|
||||
category = Category.update(session, db_category, category_data)
|
||||
return category
|
||||
|
||||
@router.delete("/{category_id}")
|
||||
def delete_category(category_id: UUID, session: SessionDep, current_user=Depends(get_current_user)):
|
||||
category = Category.get(session, category_id)
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
|
||||
Category.delete(session, category)
|
||||
return {"ok": True}
|
||||
21
api/app/db.py
Normal file
21
api/app/db.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlmodel import Field, Session, SQLModel, create_engine, select
|
||||
|
||||
|
||||
sqlite_file_name = "database.db"
|
||||
sqlite_url = f"sqlite:///{sqlite_file_name}"
|
||||
|
||||
connect_args = {"check_same_thread": False}
|
||||
engine = create_engine(sqlite_url, connect_args=connect_args)
|
||||
|
||||
|
||||
def create_db_and_tables():
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
def get_session() -> Session:
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
SessionDep = Annotated[Session, Depends(get_session)]
|
||||
47
api/app/main.py
Normal file
47
api/app/main.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi_pagination import add_pagination
|
||||
|
||||
|
||||
from db import create_db_and_tables
|
||||
from user import user_router, auth_router, create_admin_account
|
||||
from account.routes import router as account_router
|
||||
from category.routes import router as category_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
create_db_and_tables()
|
||||
create_admin_account()
|
||||
yield
|
||||
#do something before end
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
add_pagination(app)
|
||||
|
||||
origins = [
|
||||
"http://localhost:8000",
|
||||
"http://localhost:5173",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth_router, prefix="/auth", tags=["auth"], )
|
||||
app.include_router(user_router, prefix="/users", tags=["users"])
|
||||
app.include_router(account_router, prefix="/accounts", tags=["accounts"])
|
||||
app.include_router(category_router, prefix="/categories", tags=["categories"])
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
if __name__ == '__main__':
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host='0.0.0.0', port=8000, reload=True)
|
||||
1
api/app/migrations/README
Normal file
1
api/app/migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
83
api/app/migrations/env.py
Normal file
83
api/app/migrations/env.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from account.models import Account
|
||||
from user.models import User, AccessToken
|
||||
|
||||
# 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 = None
|
||||
target_metadata = SQLModel.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 run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
27
api/app/migrations/script.py.mako
Normal file
27
api/app/migrations/script.py.mako
Normal file
@@ -0,0 +1,27 @@
|
||||
"""${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
|
||||
import sqlmodel
|
||||
${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
api/app/migrations/versions/placeholder.git
Normal file
0
api/app/migrations/versions/placeholder.git
Normal file
4
api/app/user/__init__.py
Normal file
4
api/app/user/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .manager import auth_router, reset_password_router, create_admin_account
|
||||
from .routes import router as user_router
|
||||
|
||||
user_router.include_router(reset_password_router)
|
||||
80
api/app/user/manager.py
Normal file
80
api/app/user/manager.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import uuid
|
||||
|
||||
from sqlmodel import select
|
||||
from fastapi import Depends
|
||||
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models, exceptions, schemas
|
||||
from fastapi_users.authentication import BearerTransport, AuthenticationBackend
|
||||
from fastapi_users.authentication.strategy.db import AccessTokenDatabase, DatabaseStrategy
|
||||
|
||||
from .models import User, get_user_db, AccessToken, get_access_token_db, UserRead, UserUpdate, UserCreate
|
||||
from db import get_session
|
||||
|
||||
SECRET = "SECRET"
|
||||
TOKEN_LIFETIME = 3600
|
||||
|
||||
bearer_transport = BearerTransport(tokenUrl="auth/login")
|
||||
|
||||
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
pass
|
||||
|
||||
|
||||
async def get_user_manager(user_db=Depends(get_user_db)) -> UserManager:
|
||||
yield UserManager(user_db)
|
||||
|
||||
def get_database_strategy(
|
||||
access_token_db: AccessTokenDatabase[AccessToken] = Depends(get_access_token_db),
|
||||
) -> DatabaseStrategy:
|
||||
return DatabaseStrategy(access_token_db, lifetime_seconds=TOKEN_LIFETIME)
|
||||
|
||||
auth_backend = AuthenticationBackend(
|
||||
name="db",
|
||||
transport=bearer_transport,
|
||||
get_strategy=get_database_strategy,
|
||||
)
|
||||
|
||||
fastapi_users = FastAPIUsers[User, uuid.UUID](
|
||||
get_user_manager,
|
||||
[auth_backend],
|
||||
)
|
||||
|
||||
|
||||
get_current_user = fastapi_users.current_user(active=True)
|
||||
get_current_superuser = fastapi_users.current_user(active=True, superuser=True)
|
||||
|
||||
|
||||
#user_router = fastapi_users.get_users_router(UserRead, UserUpdate)
|
||||
#user_router.include_router(fastapi_users.get_reset_password_router())
|
||||
|
||||
reset_password_router = fastapi_users.get_reset_password_router()
|
||||
auth_router = fastapi_users.get_auth_router(auth_backend)
|
||||
|
||||
|
||||
def create_admin_account():
|
||||
session = get_session().__next__()
|
||||
admin_email = 'root@root.fr'
|
||||
statement = select(User).where(User.email == admin_email).limit(1)
|
||||
admin_user = session.exec(statement).first()
|
||||
|
||||
if admin_user is not None:
|
||||
return
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi_users.password import PasswordHelper
|
||||
|
||||
password_length = 16
|
||||
password = secrets.token_urlsafe(password_length)
|
||||
|
||||
admin_user = User(
|
||||
id=uuid.uuid4(),
|
||||
email=admin_email,
|
||||
hashed_password=PasswordHelper().hash(password),
|
||||
is_active=True,
|
||||
is_superuser=True,
|
||||
is_verified=True
|
||||
)
|
||||
session.add(admin_user)
|
||||
session.commit()
|
||||
print(f"""Admin account created:
|
||||
login: {admin_email}
|
||||
password: {password}""")
|
||||
40
api/app/user/models.py
Normal file
40
api/app/user/models.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import uuid
|
||||
|
||||
from sqlmodel import select
|
||||
from fastapi import Depends
|
||||
from fastapi_users import schemas
|
||||
from fastapi_users_db_sqlmodel import SQLModelBaseUserDB, SQLModelUserDatabase
|
||||
from fastapi_users_db_sqlmodel.access_token import SQLModelBaseAccessToken, SQLModelAccessTokenDatabase
|
||||
|
||||
from db import get_session, SessionDep
|
||||
|
||||
class User(SQLModelBaseUserDB, table=True):
|
||||
pass
|
||||
|
||||
class UserDatabase(SQLModelUserDatabase):
|
||||
def list(self):
|
||||
return select(self.user_model)
|
||||
|
||||
async def get_user_db(session: SessionDep):
|
||||
yield UserDatabase(session, User)
|
||||
|
||||
class AccessToken(SQLModelBaseAccessToken, table=True):
|
||||
pass
|
||||
|
||||
class AccessTokenDatabase(SQLModelAccessTokenDatabase):
|
||||
pass
|
||||
|
||||
async def get_access_token_db(session = Depends(get_session)):
|
||||
yield AccessTokenDatabase(session, AccessToken)
|
||||
|
||||
|
||||
class UserRead(schemas.BaseUser[uuid.UUID]):
|
||||
pass
|
||||
|
||||
|
||||
class UserCreate(schemas.BaseUserCreate):
|
||||
pass
|
||||
|
||||
|
||||
class UserUpdate(schemas.BaseUserUpdate):
|
||||
pass
|
||||
68
api/app/user/routes.py
Normal file
68
api/app/user/routes.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from .models import User, UserCreate, UserRead, UserUpdate
|
||||
from .manager import get_user_manager, get_current_user, get_current_superuser
|
||||
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlmodel import paginate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_description="User added to the database")
|
||||
async def create(user_form: UserCreate, user_manager=Depends(get_user_manager), current_user=Depends(get_current_superuser)) -> dict:
|
||||
await user_manager.create(user_form, safe=True)
|
||||
return {"message": "User added successfully"}
|
||||
|
||||
|
||||
@router.get("", response_model=Page[UserRead], response_description="User records retrieved")
|
||||
async def read_list(current_user=Depends(get_current_superuser), user_manager=Depends(get_user_manager)) -> Page[UserRead]:
|
||||
return paginate(user_manager.user_db.session, user_manager.user_db.list())
|
||||
|
||||
|
||||
@router.get("/me", response_description="User record retrieved")
|
||||
async def read_me(current_user=Depends(get_current_user), user_manager=Depends(get_user_manager)) -> UserRead:
|
||||
user = await user_manager.get(current_user.id)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_description="User record retrieved")
|
||||
async def read_id(user_id: uuid.UUID, current_user=Depends(get_current_superuser), user_manager=Depends(get_user_manager)) -> UserRead:
|
||||
user = await user_manager.get(user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found."
|
||||
)
|
||||
return UserRead(**user.dict())
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_description="User record updated")
|
||||
async def update(user_id: uuid.UUID, user_data: UserUpdate, current_user=Depends(get_current_superuser), user_manager=Depends(get_user_manager)) -> UserRead:
|
||||
user = await user_manager.get(user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found."
|
||||
)
|
||||
|
||||
await user_manager.update(user_data, user, safe=True)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", response_description="User record deleted from the database")
|
||||
async def delete(user_id: uuid.UUID, current_user=Depends(get_current_superuser), user_manager=Depends(get_user_manager)) -> dict:
|
||||
user = await user_manager.get(user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found."
|
||||
)
|
||||
|
||||
await user_manager.delete(user)
|
||||
return {
|
||||
"message": "User deleted successfully."
|
||||
}
|
||||
23
gui/app/.gitignore
vendored
Normal file
23
gui/app/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
43
gui/app/README.md
Normal file
43
gui/app/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
<div align="center" style="margin: 30px;">
|
||||
<a href="https://refine.dev/">
|
||||
<img alt="refine logo" src="https://refine.ams3.cdn.digitaloceanspaces.com/readme/refine-readme-banner.png">
|
||||
</a>
|
||||
|
||||
</br>
|
||||
</br>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://refine.dev">Home Page</a> |
|
||||
<a href="https://discord.gg/refine">Discord</a> |
|
||||
<a href="https://refine.dev/examples/">Examples</a> |
|
||||
<a href="https://refine.dev/blog/">Blog</a> |
|
||||
<a href="https://refine.dev/docs/">Documentation</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</br>
|
||||
</br>
|
||||
|
||||
<div align="center"><strong>Build your <a href="https://reactjs.org/">React</a>-based CRUD applications, without constraints.</strong><br>An open source, headless web application framework developed with flexibility in mind.
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
[](https://discord.gg/refine)
|
||||
[](https://twitter.com/refine_dev)
|
||||
|
||||
<a href="https://www.producthunt.com/posts/refine-3?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-refine-3" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=362220&theme=light&period=daily" alt="refine - 100% open source React framework to build web apps 3x faster | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
## Try this example on your local
|
||||
|
||||
```bash
|
||||
npm create refine-app@latest -- --example auth-material-ui
|
||||
```
|
||||
|
||||
## Try this example on CodeSandbox
|
||||
|
||||
<br/>
|
||||
|
||||
[](https://codesandbox.io/embed/github/refinedev/refine/tree/main/examples/auth-material-ui?view=preview&theme=dark&codemirror=1)
|
||||
36
gui/app/index.html
Normal file
36
gui/app/index.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site created using refine" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>Material UI Authentication example</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm run dev` or `yarn dev`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
--></body>
|
||||
</html>
|
||||
8755
gui/app/package-lock.json
generated
Normal file
8755
gui/app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
gui/app/package.json
Normal file
53
gui/app/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "budget-forecast-gui",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc && refine build",
|
||||
"dev": "refine dev --devtools=false",
|
||||
"refine": "refine",
|
||||
"start": "refine start"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.8.2",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"@mui/lab": "^6.0.0-beta.14",
|
||||
"@mui/material": "^6.1.7",
|
||||
"@mui/x-data-grid": "^7.23.5",
|
||||
"@refinedev/cli": "^2.16.42",
|
||||
"@refinedev/core": "^4.57.5",
|
||||
"@refinedev/devtools": "^1.2.12",
|
||||
"@refinedev/mui": "^6.0.3",
|
||||
"@refinedev/react-hook-form": "^4.9.3",
|
||||
"@refinedev/react-router": "^1.0.1",
|
||||
"@refinedev/simple-rest": "^5.0.10",
|
||||
"@rjsf/core": "^5.24.1",
|
||||
"@rjsf/mui": "^5.24.1",
|
||||
"@rjsf/utils": "^5.24.1",
|
||||
"@rjsf/validator-ajv8": "^5.24.1",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-router": "^7.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.16.2",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.4.2",
|
||||
"vite": "^5.1.6"
|
||||
}
|
||||
}
|
||||
BIN
gui/app/public/favicon.ico
Normal file
BIN
gui/app/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
45
gui/app/public/index.html
Normal file
45
gui/app/public/index.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site created using refine" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Material UI Authentication example</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm run dev` or `yarn dev`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
--></body>
|
||||
</html>
|
||||
15
gui/app/public/manifest.json
Normal file
15
gui/app/public/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"short_name": "Material UI Authentication example",
|
||||
"name": "Material UI Authentication example",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
185
gui/app/src/App.tsx
Normal file
185
gui/app/src/App.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { DevtoolsProvider, DevtoolsPanel } from "@refinedev/devtools";
|
||||
import { Refine, type AuthProvider, Authenticated } from "@refinedev/core";
|
||||
import {
|
||||
ThemedLayoutV2,
|
||||
ErrorComponent,
|
||||
RefineThemes,
|
||||
useNotificationProvider,
|
||||
RefineSnackbarProvider,
|
||||
AuthPage,
|
||||
} from "@refinedev/mui";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import GlobalStyles from "@mui/material/GlobalStyles";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { authProvider } from "./providers/auth-provider";
|
||||
//import dataProvider from "@refinedev/simple-rest";
|
||||
import { dataProvider } from "./providers/data-provider";
|
||||
import routerProvider, {
|
||||
NavigateToResource,
|
||||
CatchAllNavigate,
|
||||
UnsavedChangesNotifier,
|
||||
DocumentTitleHandler,
|
||||
} from "@refinedev/react-router";
|
||||
import { BrowserRouter, Routes, Route, Outlet } from "react-router";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { PostList, PostCreate, PostEdit } from "../src/pages/posts";
|
||||
import { AccountList, AccountCreate, AccountEdit } from "../src/pages/accounts";
|
||||
import { CategoryList, CategoryCreate, CategoryEdit } from "../src/pages/categories";
|
||||
|
||||
/**
|
||||
* mock auth credentials to simulate authentication
|
||||
*/
|
||||
const authCredentials = {
|
||||
email: "demo@refine.dev",
|
||||
password: "demodemo",
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const RememeberMe = () => {
|
||||
const { register } = useFormContext();
|
||||
|
||||
return (
|
||||
<FormControlLabel
|
||||
sx={{
|
||||
span: {
|
||||
fontSize: "12px",
|
||||
color: "text.secondary",
|
||||
},
|
||||
}}
|
||||
color="secondary"
|
||||
control={
|
||||
<Checkbox size="small" id="rememberMe" {...register("rememberMe")} />
|
||||
}
|
||||
label="Remember me"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ThemeProvider theme={RefineThemes.Blue}>
|
||||
<CssBaseline />
|
||||
<GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} />
|
||||
<RefineSnackbarProvider>
|
||||
<DevtoolsProvider>
|
||||
<Refine
|
||||
authProvider={authProvider}
|
||||
dataProvider={dataProvider}
|
||||
routerProvider={routerProvider}
|
||||
notificationProvider={useNotificationProvider}
|
||||
resources={[
|
||||
{
|
||||
name: "posts",
|
||||
list: "/posts",
|
||||
edit: "/posts/edit/:id",
|
||||
create: "/posts/create",
|
||||
},
|
||||
{
|
||||
name: "accounts",
|
||||
list: "/accounts",
|
||||
edit: "/accounts/edit/:id",
|
||||
create: "/accounts/create",
|
||||
},
|
||||
{
|
||||
name: "categories",
|
||||
list: "/categories",
|
||||
edit: "/categories/edit/:id",
|
||||
create: "/categories/create",
|
||||
},
|
||||
]}
|
||||
options={{
|
||||
syncWithLocation: true,
|
||||
warnWhenUnsavedChanges: true,
|
||||
disableTelemetry: true,
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
element={
|
||||
<Authenticated
|
||||
key="authenticated-routes"
|
||||
fallback={<CatchAllNavigate to="/login" />}
|
||||
>
|
||||
<ThemedLayoutV2>
|
||||
<Outlet />
|
||||
</ThemedLayoutV2>
|
||||
</Authenticated>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
index
|
||||
element={<NavigateToResource resource="posts" />}
|
||||
/>
|
||||
|
||||
<Route path="/posts">
|
||||
<Route index element={<PostList />} />
|
||||
<Route path="create" element={<PostCreate />} />
|
||||
<Route path="edit/:id" element={<PostEdit />} />
|
||||
<Route path="delete/:id" element={<PostEdit />} />
|
||||
</Route>
|
||||
<Route path="/accounts">
|
||||
<Route index element={<AccountList />} />
|
||||
<Route path="create" element={<AccountCreate />} />
|
||||
<Route path="edit/:id" element={<AccountEdit />} />
|
||||
</Route>
|
||||
<Route path="/categories">
|
||||
<Route index element={<CategoryList />} />
|
||||
<Route path="create" element={<CategoryCreate />} />
|
||||
<Route path="edit/:id" element={<CategoryEdit />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
element={
|
||||
<Authenticated key="auth-pages" fallback={<Outlet />}>
|
||||
<NavigateToResource resource="posts" />
|
||||
</Authenticated>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<AuthPage type="login" rememberMe={<RememeberMe />} />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={<AuthPage type="register" />}
|
||||
/>
|
||||
<Route
|
||||
path="/forgot-password"
|
||||
element={<AuthPage type="forgotPassword" />}
|
||||
/>
|
||||
<Route
|
||||
path="/update-password"
|
||||
element={<AuthPage type="updatePassword" />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
element={
|
||||
<Authenticated key="catch-all">
|
||||
<ThemedLayoutV2>
|
||||
<Outlet />
|
||||
</ThemedLayoutV2>
|
||||
</Authenticated>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<ErrorComponent />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<UnsavedChangesNotifier />
|
||||
<DocumentTitleHandler />
|
||||
</Refine>
|
||||
<DevtoolsPanel />
|
||||
</DevtoolsProvider>
|
||||
</RefineSnackbarProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
0
gui/app/src/common/crud/crud-card.tsx
Normal file
0
gui/app/src/common/crud/crud-card.tsx
Normal file
52
gui/app/src/common/crud/crud-form.tsx
Normal file
52
gui/app/src/common/crud/crud-form.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import validator from "@rjsf/validator-ajv8";
|
||||
import Form from "@rjsf/mui";
|
||||
import { useEffect, useState } from "react";
|
||||
import { jsonschemaProvider } from "../../providers/jsonschema-provider";
|
||||
import { useForm } from "@refinedev/core";
|
||||
|
||||
type Props = {
|
||||
schemaName: string,
|
||||
resource: string,
|
||||
id?: string,
|
||||
//onSubmit: (data: IChangeEvent, event: FormEvent<any>) => void
|
||||
}
|
||||
|
||||
export const CrudForm: React.FC<Props> = ({schemaName, resource, id}) => {
|
||||
const { onFinish, query, formLoading } = useForm({
|
||||
resource: resource,
|
||||
action: id === undefined ? "create" : "edit",
|
||||
redirect: "show",
|
||||
id,
|
||||
});
|
||||
|
||||
const record = query?.data?.data;
|
||||
const [formData, setFormData] = useState(record);
|
||||
|
||||
const [schema, setSchema] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSchema = async () => {
|
||||
try {
|
||||
const resourceSchema = await jsonschemaProvider.getResourceSchema(schemaName);
|
||||
setSchema(resourceSchema);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSchema();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Form
|
||||
schema={schema}
|
||||
formData={record}
|
||||
onChange={(e) => setFormData(e.formData)}
|
||||
onSubmit={(e) => onFinish(e.formData)}
|
||||
validator={validator}
|
||||
omitExtraData={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
0
gui/app/src/common/crud/crud-list.tsx
Normal file
0
gui/app/src/common/crud/crud-list.tsx
Normal file
13
gui/app/src/index.tsx
Normal file
13
gui/app/src/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
// eslint-disable-next-line
|
||||
const root = createRoot(container!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
26
gui/app/src/interfaces/index.d.ts
vendored
Normal file
26
gui/app/src/interfaces/index.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface ICategory {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export type IStatus = "published" | "draft" | "rejected";
|
||||
|
||||
export interface IPost {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
status: IStatus;
|
||||
category: ICategory;
|
||||
}
|
||||
|
||||
export type IType = "published" | "draft" | "rejected";
|
||||
|
||||
export interface IAccount {
|
||||
id: number;
|
||||
name: string;
|
||||
type: IType;
|
||||
}
|
||||
|
||||
export type Nullable<T> = {
|
||||
[P in keyof T]: T[P] | null;
|
||||
};
|
||||
11
gui/app/src/pages/accounts/create.tsx
Normal file
11
gui/app/src/pages/accounts/create.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import {CrudForm} from "../../common/crud/crud-form";
|
||||
|
||||
|
||||
export const AccountCreate: React.FC = () => {
|
||||
return (
|
||||
<CrudForm
|
||||
schemaName={"AccountCreate"}
|
||||
resource={"accounts"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
15
gui/app/src/pages/accounts/edit.tsx
Normal file
15
gui/app/src/pages/accounts/edit.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { CrudForm } from "../../common/crud/crud-form";
|
||||
import { useParams } from "react-router"
|
||||
|
||||
|
||||
export const AccountEdit: React.FC = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
return (
|
||||
<CrudForm
|
||||
schemaName={"AccountUpdate"}
|
||||
resource={"accounts"}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
3
gui/app/src/pages/accounts/index.tsx
Normal file
3
gui/app/src/pages/accounts/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./list";
|
||||
export * from "./create";
|
||||
export * from "./edit";
|
||||
51
gui/app/src/pages/accounts/list.tsx
Normal file
51
gui/app/src/pages/accounts/list.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useMany } from "@refinedev/core";
|
||||
import {DeleteButton, EditButton, List, useDataGrid} from "@refinedev/mui";
|
||||
import React from "react";
|
||||
|
||||
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
|
||||
|
||||
import type { IAccount } from "../../interfaces";
|
||||
import {AccountCreate} from "./create";
|
||||
import {ButtonGroup} from "@mui/material";
|
||||
|
||||
export const AccountList: React.FC = () => {
|
||||
const { dataGridProps } = useDataGrid<IAccount>();
|
||||
|
||||
const columns = React.useMemo<GridColDef<IAccount>[]>(
|
||||
() => [
|
||||
{ field: "id", headerName: "ID" },
|
||||
{ field: "name", headerName: "Name", flex: 1 },
|
||||
{ field: "type", headerName: "Type", flex: 0.3 },
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Actions",
|
||||
display: "flex",
|
||||
renderCell: function render({ row }) {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<EditButton hideText recordItemId={row.id} />
|
||||
<DeleteButton hideText recordItemId={row.id} />
|
||||
</ButtonGroup>
|
||||
);
|
||||
},
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<List>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
maxHeight: "calc(100vh - 320px)",
|
||||
}}
|
||||
>
|
||||
<DataGrid {...dataGridProps} columns={columns} />
|
||||
</div>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
11
gui/app/src/pages/categories/create.tsx
Normal file
11
gui/app/src/pages/categories/create.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import {CrudForm} from "../../common/crud/crud-form";
|
||||
|
||||
|
||||
export const CategoryCreate: React.FC = () => {
|
||||
return (
|
||||
<CrudForm
|
||||
schemaName={"CategoryCreate"}
|
||||
resource={"categories"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
15
gui/app/src/pages/categories/edit.tsx
Normal file
15
gui/app/src/pages/categories/edit.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { CrudForm } from "../../common/crud/crud-form";
|
||||
import { useParams } from "react-router"
|
||||
|
||||
|
||||
export const CategoryEdit: React.FC = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
return (
|
||||
<CrudForm
|
||||
schemaName={"CategoryUpdate"}
|
||||
resource={"categories"}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
3
gui/app/src/pages/categories/index.tsx
Normal file
3
gui/app/src/pages/categories/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./list";
|
||||
export * from "./create";
|
||||
export * from "./edit";
|
||||
48
gui/app/src/pages/categories/list.tsx
Normal file
48
gui/app/src/pages/categories/list.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import {DeleteButton, EditButton, List, useDataGrid} from "@refinedev/mui";
|
||||
import React from "react";
|
||||
|
||||
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
|
||||
|
||||
import type { IAccount } from "../../interfaces";
|
||||
import {ButtonGroup} from "@mui/material";
|
||||
|
||||
export const CategoryList: React.FC = () => {
|
||||
const { dataGridProps } = useDataGrid<IAccount>();
|
||||
|
||||
const columns = React.useMemo<GridColDef<IAccount>[]>(
|
||||
() => [
|
||||
{ field: "id", headerName: "ID" },
|
||||
{ field: "name", headerName: "Name", flex: 1 },
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Actions",
|
||||
display: "flex",
|
||||
renderCell: function render({ row }) {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<EditButton hideText recordItemId={row.id} />
|
||||
<DeleteButton hideText recordItemId={row.id} />
|
||||
</ButtonGroup>
|
||||
);
|
||||
},
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<List>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
maxHeight: "calc(100vh - 320px)",
|
||||
}}
|
||||
>
|
||||
<DataGrid {...dataGridProps} columns={columns} />
|
||||
</div>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
122
gui/app/src/pages/posts/create.tsx
Normal file
122
gui/app/src/pages/posts/create.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { HttpError } from "@refinedev/core";
|
||||
import { Create, useAutocomplete } from "@refinedev/mui";
|
||||
import Box from "@mui/material/Box";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import { useForm } from "@refinedev/react-hook-form";
|
||||
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
import type { IPost, ICategory, IStatus, Nullable } from "../../interfaces";
|
||||
|
||||
export const PostCreate: React.FC = () => {
|
||||
const {
|
||||
saveButtonProps,
|
||||
register,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<IPost, HttpError, Nullable<IPost>>();
|
||||
|
||||
const { autocompleteProps } = useAutocomplete<ICategory>({
|
||||
resource: "categories",
|
||||
});
|
||||
|
||||
return (
|
||||
<Create saveButtonProps={saveButtonProps}>
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ display: "flex", flexDirection: "column" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<TextField
|
||||
{...register("title", {
|
||||
required: "This field is required",
|
||||
})}
|
||||
error={!!errors.title}
|
||||
helperText={errors.title?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
label="Title"
|
||||
name="title"
|
||||
autoFocus
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
rules={{ required: "This field is required" }}
|
||||
// eslint-disable-next-line
|
||||
defaultValue={null as any}
|
||||
render={({ field }) => (
|
||||
<Autocomplete<IStatus>
|
||||
options={["published", "draft", "rejected"]}
|
||||
{...field}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Status"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.status}
|
||||
helperText={errors.status?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="category"
|
||||
rules={{ required: "This field is required" }}
|
||||
// eslint-disable-next-line
|
||||
defaultValue={null as any}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...autocompleteProps}
|
||||
{...field}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return (
|
||||
autocompleteProps?.options?.find(
|
||||
(p) => p?.id?.toString() === item?.id?.toString(),
|
||||
)?.title ?? ""
|
||||
);
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
value === undefined ||
|
||||
option?.id?.toString() === (value?.id ?? value)?.toString()
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Category"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.category}
|
||||
helperText={errors.category?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
{...register("content", {
|
||||
required: "This field is required",
|
||||
})}
|
||||
error={!!errors.content}
|
||||
helperText={errors.content?.message}
|
||||
margin="normal"
|
||||
label="Content"
|
||||
multiline
|
||||
rows={4}
|
||||
/>
|
||||
</Box>
|
||||
</Create>
|
||||
);
|
||||
};
|
||||
124
gui/app/src/pages/posts/edit.tsx
Normal file
124
gui/app/src/pages/posts/edit.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { HttpError } from "@refinedev/core";
|
||||
import { Edit, useAutocomplete } from "@refinedev/mui";
|
||||
import Box from "@mui/material/Box";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import { useForm } from "@refinedev/react-hook-form";
|
||||
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
import type { IPost, ICategory, IStatus, Nullable } from "../../interfaces";
|
||||
|
||||
export const PostEdit: React.FC = () => {
|
||||
const {
|
||||
saveButtonProps,
|
||||
refineCore: { query: queryResult },
|
||||
register,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<IPost, HttpError, Nullable<IPost>>();
|
||||
|
||||
const { autocompleteProps } = useAutocomplete<ICategory>({
|
||||
resource: "categories",
|
||||
defaultValue: queryResult?.data?.data.category.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<Edit saveButtonProps={saveButtonProps}>
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ display: "flex", flexDirection: "column" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<TextField
|
||||
{...register("title", {
|
||||
required: "This field is required",
|
||||
})}
|
||||
error={!!errors.title}
|
||||
helperText={errors.title?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
label="Title"
|
||||
name="title"
|
||||
autoFocus
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
rules={{ required: "This field is required" }}
|
||||
// eslint-disable-next-line
|
||||
defaultValue={null as any}
|
||||
render={({ field }) => (
|
||||
<Autocomplete<IStatus>
|
||||
options={["published", "draft", "rejected"]}
|
||||
{...field}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Status"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.status}
|
||||
helperText={errors.status?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="category"
|
||||
rules={{ required: "This field is required" }}
|
||||
// eslint-disable-next-line
|
||||
defaultValue={null as any}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...autocompleteProps}
|
||||
{...field}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return (
|
||||
autocompleteProps?.options?.find(
|
||||
(p) => p?.id?.toString() === item?.id?.toString(),
|
||||
)?.title ?? ""
|
||||
);
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
value === undefined ||
|
||||
option?.id?.toString() === (value?.id ?? value)?.toString()
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Category"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.category}
|
||||
helperText={errors.category?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
{...register("content", {
|
||||
required: "This field is required",
|
||||
})}
|
||||
error={!!errors.content}
|
||||
helperText={errors.content?.message}
|
||||
margin="normal"
|
||||
label="Content"
|
||||
multiline
|
||||
rows={4}
|
||||
/>
|
||||
</Box>
|
||||
</Edit>
|
||||
);
|
||||
};
|
||||
3
gui/app/src/pages/posts/index.tsx
Normal file
3
gui/app/src/pages/posts/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./list";
|
||||
export * from "./create";
|
||||
export * from "./edit";
|
||||
79
gui/app/src/pages/posts/list.tsx
Normal file
79
gui/app/src/pages/posts/list.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useMany } from "@refinedev/core";
|
||||
import { EditButton, List, useDataGrid } from "@refinedev/mui";
|
||||
import React from "react";
|
||||
|
||||
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
|
||||
|
||||
import type { ICategory, IPost } from "../../interfaces";
|
||||
|
||||
export const PostList: React.FC = () => {
|
||||
const { dataGridProps } = useDataGrid<IPost>();
|
||||
|
||||
const categoryIds = dataGridProps.rows.map((item) => item.category.id);
|
||||
const { data: categoriesData, isLoading } = useMany<ICategory>({
|
||||
resource: "categories",
|
||||
ids: categoryIds,
|
||||
queryOptions: {
|
||||
enabled: categoryIds.length > 0,
|
||||
},
|
||||
});
|
||||
|
||||
const columns = React.useMemo<GridColDef<IPost>[]>(
|
||||
() => [
|
||||
{
|
||||
field: "id",
|
||||
headerName: "ID",
|
||||
type: "number",
|
||||
width: 50,
|
||||
},
|
||||
{ field: "title", headerName: "Title", minWidth: 400, flex: 1 },
|
||||
{
|
||||
field: "category.id",
|
||||
headerName: "Category",
|
||||
type: "number",
|
||||
headerAlign: "left",
|
||||
align: "left",
|
||||
minWidth: 250,
|
||||
flex: 0.5,
|
||||
display: "flex",
|
||||
renderCell: function render({ row }) {
|
||||
if (isLoading) {
|
||||
return "Loading...";
|
||||
}
|
||||
|
||||
const category = categoriesData?.data.find(
|
||||
(item) => item.id === row.category.id,
|
||||
);
|
||||
return category?.title;
|
||||
},
|
||||
},
|
||||
{ field: "status", headerName: "Status", minWidth: 120, flex: 0.3 },
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Actions",
|
||||
display: "flex",
|
||||
renderCell: function render({ row }) {
|
||||
return <EditButton hideText recordItemId={row.id} />;
|
||||
},
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
minWidth: 80,
|
||||
},
|
||||
],
|
||||
[categoriesData, isLoading],
|
||||
);
|
||||
|
||||
return (
|
||||
<List>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
maxHeight: "calc(100vh - 320px)",
|
||||
}}
|
||||
>
|
||||
<DataGrid {...dataGridProps} columns={columns} />
|
||||
</div>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
82
gui/app/src/providers/auth-provider.tsx
Normal file
82
gui/app/src/providers/auth-provider.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { AuthProvider } from "@refinedev/core";
|
||||
|
||||
const API_URL = "http://localhost:8000";
|
||||
|
||||
export const authProvider: AuthProvider = {
|
||||
login: async ({ email, password }) => {
|
||||
const response = await fetch(
|
||||
API_URL + "/auth/login",
|
||||
{
|
||||
method: "POST",
|
||||
body: new URLSearchParams({
|
||||
"grant_type": "password",
|
||||
"username": email,
|
||||
password
|
||||
}).toString(),
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.access_token) {
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
},
|
||||
logout: async () => {
|
||||
const response = await fetch(
|
||||
API_URL + "/auth/logout",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer " + localStorage.getItem("access_token"),
|
||||
},
|
||||
},
|
||||
);
|
||||
if (response.status == 204 || response.status == 401) {
|
||||
localStorage.removeItem("access_token");
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false };
|
||||
},
|
||||
check: async () => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
||||
return { authenticated: Boolean(token) };
|
||||
},
|
||||
getIdentity: async () => {
|
||||
const response = await fetch(API_URL + "/users/me", {
|
||||
headers: {
|
||||
Authorization: "Bearer " + localStorage.getItem("access_token"),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status < 200 || response.status > 299) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return data;
|
||||
},
|
||||
onError: async (error) => {
|
||||
if (error?.status === 401) {
|
||||
localStorage.removeItem("access_token");
|
||||
return Promise<{
|
||||
redirectTo: "/login",
|
||||
logout: true,
|
||||
error: { message: "Unauthorized" },
|
||||
}>;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
register: async (params) => { throw new Error("Not implemented"); },
|
||||
forgotPassword: async (params) => { throw new Error("Not implemented"); },
|
||||
updatePassword: async (params) => { throw new Error("Not implemented"); },
|
||||
getPermissions: async () => { throw new Error("Not implemented"); },
|
||||
};
|
||||
111
gui/app/src/providers/data-provider.tsx
Normal file
111
gui/app/src/providers/data-provider.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { DataProvider } from "@refinedev/core";
|
||||
|
||||
//const API_URL = "https://api.fake-rest.refine.dev";
|
||||
const API_URL = "http://localhost:8000";
|
||||
|
||||
const fetcher = async (url: string, options?: RequestInit) => {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
Authorization: "Bearer " + localStorage.getItem("access_token"),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const dataProvider: DataProvider = {
|
||||
getOne: async ({ resource, id, meta }) => {
|
||||
const response = await fetcher(`${API_URL}/${resource}/${id}`);
|
||||
|
||||
if (response.status < 200 || response.status > 299) throw response;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
data
|
||||
};
|
||||
},
|
||||
update: async ({ resource, id, variables }) => {
|
||||
const response = await fetcher(`${API_URL}/${resource}/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(variables),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status < 200 || response.status > 299) throw response;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return { data };
|
||||
},
|
||||
getList: async ({ resource, pagination, filters, sorters, meta }) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (pagination) {
|
||||
params.append("page", String(pagination.current));
|
||||
params.append("size", String(pagination.pageSize));
|
||||
}
|
||||
|
||||
if (sorters && sorters.length > 0) {
|
||||
params.append("_sort", sorters.map((sorter) => sorter.field).join(","));
|
||||
params.append("_order", sorters.map((sorter) => sorter.order).join(","));
|
||||
}
|
||||
|
||||
if (filters && filters.length > 0) {
|
||||
filters.forEach((filter) => {
|
||||
if ("field" in filter && filter.operator === "eq") {
|
||||
// Our fake API supports "eq" operator by simply appending the field name and value to the query string.
|
||||
params.append(filter.field, filter.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetcher(`${API_URL}/${resource}?${params.toString()}`);
|
||||
|
||||
if (response.status < 200 || response.status > 299) throw response;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
data: data.items,
|
||||
total: data.total, // We'll cover this in the next steps.
|
||||
};
|
||||
},
|
||||
create: async ({ resource, variables }) => {
|
||||
const response = await fetcher(`${API_URL}/${resource}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(variables),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status < 200 || response.status > 299) throw response;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return { data };
|
||||
},
|
||||
deleteOne: async ({ resource, id, variables, meta }) => {
|
||||
const response = await fetcher(`${API_URL}/${resource}/${id}`,{
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.status < 200 || response.status > 299) throw response;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
data
|
||||
};
|
||||
},
|
||||
getApiUrl: () => API_URL,
|
||||
// Optional methods:
|
||||
// getMany: () => { /* ... */ },
|
||||
// createMany: () => { /* ... */ },
|
||||
// deleteMany: () => { /* ... */ },
|
||||
// updateMany: () => { /* ... */ },
|
||||
// custom: () => { /* ... */ },
|
||||
};
|
||||
186
gui/app/src/providers/jsonschema-provider.tsx
Normal file
186
gui/app/src/providers/jsonschema-provider.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { RJSFSchema } from '@rjsf/utils';
|
||||
|
||||
const API_URL = "http://localhost:8000";
|
||||
|
||||
|
||||
export const jsonschemaProvider = {
|
||||
getResourceSchema: async (resourceName: string): RJSFSchema => {
|
||||
return buildResource(await getJsonschema(), resourceName)
|
||||
}
|
||||
};
|
||||
|
||||
let rawSchema: RJSFSchema;
|
||||
const getJsonschema = async (): RJSFSchema => {
|
||||
if (rawSchema === undefined) {
|
||||
const response = await fetch(
|
||||
API_URL + "/openapi.json",
|
||||
)
|
||||
rawSchema = await response.json();
|
||||
}
|
||||
return rawSchema;
|
||||
}
|
||||
|
||||
function buildResource(rawSchemas: RJSFSchema, resourceName: string) {
|
||||
let resource;
|
||||
|
||||
resource = structuredClone(rawSchemas.components.schemas[resourceName]);
|
||||
resource.components = { schemas: {} };
|
||||
for (let prop_name in resource.properties) {
|
||||
let prop = resource.properties[prop_name];
|
||||
|
||||
if (is_reference(prop)) {
|
||||
resolveReference(rawSchemas, resource, prop);
|
||||
} else if (is_union(prop)) {
|
||||
for (let i in prop.oneOf) {
|
||||
resolveReference(rawSchemas, resource, prop.oneOf[i]);
|
||||
}
|
||||
} else if (is_enum(prop)) {
|
||||
for (let i in prop.allOf) {
|
||||
resolveReference(rawSchemas, resource, prop.allOf[i]);
|
||||
}
|
||||
} else if (is_array(prop) && is_reference(prop.items)) {
|
||||
resolveReference(rawSchemas, resource, prop.items);
|
||||
}
|
||||
}
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
function resolveReference(rawSchemas: RJSFSchema, resource: any, prop_reference: any) {
|
||||
const subresourceName = get_reference_name(prop_reference);
|
||||
const subresource = buildResource(rawSchemas, subresourceName);
|
||||
resource.components.schemas[subresourceName] = subresource;
|
||||
for (let subsubresourceName in subresource.components.schemas) {
|
||||
if (! resource.components.schemas.hasOwnProperty(subsubresourceName)) {
|
||||
resource.components.schemas[subsubresourceName] = subresource.components.schemas[subsubresourceName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function changePropertiesOrder(resource: any) {
|
||||
let created_at;
|
||||
let updated_at;
|
||||
let new_properties: any = {};
|
||||
for (let prop_name in resource.properties) {
|
||||
if (prop_name == 'created_at') {
|
||||
created_at = resource.properties[prop_name];
|
||||
} else if (prop_name == 'updated_at') {
|
||||
updated_at = resource.properties[prop_name];
|
||||
} else {
|
||||
new_properties[prop_name] = resource.properties[prop_name];
|
||||
}
|
||||
}
|
||||
if (created_at) {
|
||||
new_properties['created_at'] = created_at;
|
||||
}
|
||||
if (updated_at) {
|
||||
new_properties['updated_at'] = updated_at;
|
||||
}
|
||||
resource.properties = new_properties
|
||||
}
|
||||
|
||||
function is_object(prop: any) {
|
||||
return prop.hasOwnProperty('properties')
|
||||
}
|
||||
|
||||
function is_reference(prop: any) {
|
||||
return prop.hasOwnProperty('$ref');
|
||||
}
|
||||
|
||||
function is_array(prop: any) {
|
||||
return prop.hasOwnProperty('items');
|
||||
}
|
||||
|
||||
function is_union(prop: any) {
|
||||
return prop.hasOwnProperty('oneOf');
|
||||
}
|
||||
|
||||
function is_enum(prop: any) {
|
||||
return prop.hasOwnProperty('allOf');
|
||||
}
|
||||
|
||||
function get_reference_name(prop: any) {
|
||||
return prop['$ref'].substring(prop['$ref'].lastIndexOf('/')+1);
|
||||
}
|
||||
|
||||
function has_descendant(rawSchemas:RJSFSchema, resource: RJSFSchema, property_name: string): boolean {
|
||||
if (is_array(resource)) {
|
||||
return property_name == 'items';
|
||||
} else if (is_object(resource)) {
|
||||
return property_name in resource.properties!;
|
||||
} else if (is_reference(resource)) {
|
||||
let subresourceName = get_reference_name(resource);
|
||||
return has_descendant(rawSchemas, buildResource(rawSchemas, subresourceName), property_name);
|
||||
} else if (is_union(resource)) {
|
||||
for (const ref of resource.oneOf!) {
|
||||
return has_descendant(rawSchemas, ref, property_name)
|
||||
}
|
||||
} else if (is_enum(resource)) {
|
||||
for (const ref of resource.allOf!) {
|
||||
return has_descendant(rawSchemas, ref, property_name);
|
||||
}
|
||||
}
|
||||
throw new Error("Jsonschema format not implemented in property finder");
|
||||
}
|
||||
|
||||
function get_descendant(rawSchemas: RJSFSchema, resource: RJSFSchema, property_name: string): RJSFSchema {
|
||||
if (is_array(resource) && property_name == 'items') {
|
||||
return resource.items;
|
||||
} else if (is_object(resource) && property_name in resource.properties!) {
|
||||
return resource.properties[property_name];
|
||||
} else if (is_reference(resource)) {
|
||||
let subresourceName = get_reference_name(resource);
|
||||
let subresource = buildResource(rawSchemas, subresourceName);
|
||||
return get_descendant(rawSchemas, subresource, property_name);
|
||||
} else if (is_union(resource)) {
|
||||
for (const ref of resource.oneOf!) {
|
||||
if (has_descendant(rawSchemas, ref, property_name)) {
|
||||
return get_descendant(rawSchemas, ref, property_name);
|
||||
}
|
||||
}
|
||||
} else if (is_enum(resource)) {
|
||||
for (const ref of resource.allOf!) {
|
||||
if (has_descendant(rawSchemas, ref, property_name)) {
|
||||
return get_descendant(rawSchemas, ref, property_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("property not found or Jsonschema format not implemented");
|
||||
}
|
||||
|
||||
function path_exists(rawSchemas: RJSFSchema, resource: RJSFSchema, path: string): boolean{
|
||||
const pointFirstPosition = path.indexOf('.')
|
||||
if (pointFirstPosition == -1) {
|
||||
return has_descendant(rawSchemas, resource, path);
|
||||
}
|
||||
|
||||
|
||||
return has_descendant(rawSchemas, resource, path.substring(0, pointFirstPosition))
|
||||
&& path_exists(
|
||||
rawSchemas,
|
||||
get_descendant(
|
||||
rawSchemas,
|
||||
resource,
|
||||
path.substring(0, pointFirstPosition)
|
||||
),
|
||||
path.substring(pointFirstPosition + 1)
|
||||
);
|
||||
}
|
||||
|
||||
function get_property_by_path(rawSchemas: RJSFSchema, resource: RJSFSchema, path: string): RJSFSchema {
|
||||
const pointFirstPosition = path.indexOf('.')
|
||||
if (pointFirstPosition == -1) {
|
||||
return get_descendant(rawSchemas, resource, path);
|
||||
}
|
||||
|
||||
return get_property_by_path(
|
||||
rawSchemas,
|
||||
get_descendant(
|
||||
rawSchemas,
|
||||
resource,
|
||||
path.substring(0, pointFirstPosition)
|
||||
),
|
||||
path.substring(pointFirstPosition + 1)
|
||||
);
|
||||
}
|
||||
|
||||
1
gui/app/src/vite-env.d.ts
vendored
Normal file
1
gui/app/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
gui/app/tsconfig.json
Normal file
21
gui/app/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
8
gui/app/tsconfig.node.json
Normal file
8
gui/app/tsconfig.node.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
6
gui/app/vite.config.ts
Normal file
6
gui/app/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
Reference in New Issue
Block a user