chore: Initialize project

This commit is contained in:
fit2cloud-chenyw
2025-04-21 17:47:29 +08:00
parent 9f76e5f88a
commit 12911d0afe
96 changed files with 3676 additions and 0 deletions

27
.env.example Normal file
View File

@@ -0,0 +1,27 @@
# Before running, please copy .env.example to .env
DOMAIN=localhost
FRONTEND_HOST=http://localhost:5173
ENVIRONMENT=local
PROJECT_NAME="SQLBot"
STACK_NAME=SQLBot
# Backend
BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173"
SECRET_KEY=y5txe1mRmS_JpOrUzFzHEu-kIQn3lf7ll0AOv9DQh0s
FIRST_SUPERUSER=admin@example.com
FIRST_SUPERUSER_PASSWORD=123456 # Change this to your pwd
# Postgres
POSTGRES_SERVER=localhost
POSTGRES_PORT=5432
POSTGRES_DB=sqlbot
POSTGRES_USER=root
POSTGRES_PASSWORD=132456 # Change this to your pwd
SENTRY_DSN=
# Configure these with your own Docker registry images
DOCKER_IMAGE_BACKEND=backend
DOCKER_IMAGE_FRONTEND=frontend

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
* text=auto
*.sh text eol=lf

9
.gitignore vendored
View File

@@ -1,3 +1,12 @@
.vscode
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
*.lock
*-lock.json
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

29
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,29 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: check-added-large-files
- id: check-toml
- id: check-yaml
args:
- --unsafe
- id: end-of-file-fixer
exclude: |
(?x)^(
frontend/src/client/.*
)$
- id: trailing-whitespace
exclude: ^frontend/src/client/.*
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.2.2
hooks:
- id: ruff
args:
- --fix
- id: ruff-format
ci:
autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
autoupdate_commit_msg: ⬆ [pre-commit.ci] pre-commit autoupdate

8
backend/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
# Python
__pycache__
app.egg-info
*.pyc
.mypy_cache
.coverage
htmlcov
.venv

10
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
__pycache__
app.egg-info
*.pyc
.mypy_cache
.coverage
htmlcov
.cache
.venv
*.egg
*.lock

1
backend/README.md Normal file
View File

@@ -0,0 +1 @@
# FastAPI Project - Backend

71
backend/alembic.ini Executable file
View File

@@ -0,0 +1,71 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = app/alembic
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# 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 alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat alembic/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
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
backend/app/__init__.py Normal file
View File

1
backend/app/alembic/README Executable file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

84
backend/app/alembic/env.py Executable file
View File

@@ -0,0 +1,84 @@
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# 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.
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
from app.models import SQLModel # noqa
from app.core.config import settings # noqa
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 get_url():
return str(settings.SQLALCHEMY_DATABASE_URI)
def run_migrations_offline():
"""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 = get_url()
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = get_url()
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata, compare_type=True
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

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

View File

View File

@@ -0,0 +1,37 @@
"""Add cascade delete relationships
Revision ID: 1a31ce608336
Revises: d98dd8ec85a3
Create Date: 2024-07-31 22:24:34.447891
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision = '1a31ce608336'
down_revision = 'd98dd8ec85a3'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('item', 'owner_id',
existing_type=sa.UUID(),
nullable=False)
op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey')
op.create_foreign_key(None, 'item', 'user', ['owner_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'item', type_='foreignkey')
op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id'])
op.alter_column('item', 'owner_id',
existing_type=sa.UUID(),
nullable=True)
# ### end Alembic commands ###

View File

@@ -0,0 +1,69 @@
"""Add max length for string(varchar) fields in User and Items models
Revision ID: 9c0a54914c78
Revises: e2412789c190
Create Date: 2024-06-17 14:42:44.639457
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
# revision identifiers, used by Alembic.
revision = '9c0a54914c78'
down_revision = 'e2412789c190'
branch_labels = None
depends_on = None
def upgrade():
# Adjust the length of the email field in the User table
op.alter_column('user', 'email',
existing_type=sa.String(),
type_=sa.String(length=255),
existing_nullable=False)
# Adjust the length of the full_name field in the User table
op.alter_column('user', 'full_name',
existing_type=sa.String(),
type_=sa.String(length=255),
existing_nullable=True)
# Adjust the length of the title field in the Item table
op.alter_column('item', 'title',
existing_type=sa.String(),
type_=sa.String(length=255),
existing_nullable=False)
# Adjust the length of the description field in the Item table
op.alter_column('item', 'description',
existing_type=sa.String(),
type_=sa.String(length=255),
existing_nullable=True)
def downgrade():
# Revert the length of the email field in the User table
op.alter_column('user', 'email',
existing_type=sa.String(length=255),
type_=sa.String(),
existing_nullable=False)
# Revert the length of the full_name field in the User table
op.alter_column('user', 'full_name',
existing_type=sa.String(length=255),
type_=sa.String(),
existing_nullable=True)
# Revert the length of the title field in the Item table
op.alter_column('item', 'title',
existing_type=sa.String(length=255),
type_=sa.String(),
existing_nullable=False)
# Revert the length of the description field in the Item table
op.alter_column('item', 'description',
existing_type=sa.String(length=255),
type_=sa.String(),
existing_nullable=True)

View File

@@ -0,0 +1,90 @@
"""Edit replace id integers in all models to use UUID instead
Revision ID: d98dd8ec85a3
Revises: 9c0a54914c78
Create Date: 2024-07-19 04:08:04.000976
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'd98dd8ec85a3'
down_revision = '9c0a54914c78'
branch_labels = None
depends_on = None
def upgrade():
# Ensure uuid-ossp extension is available
op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')
# Create a new UUID column with a default UUID value
op.add_column('user', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()')))
op.add_column('item', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()')))
op.add_column('item', sa.Column('new_owner_id', postgresql.UUID(as_uuid=True), nullable=True))
# Populate the new columns with UUIDs
op.execute('UPDATE "user" SET new_id = uuid_generate_v4()')
op.execute('UPDATE item SET new_id = uuid_generate_v4()')
op.execute('UPDATE item SET new_owner_id = (SELECT new_id FROM "user" WHERE "user".id = item.owner_id)')
# Set the new_id as not nullable
op.alter_column('user', 'new_id', nullable=False)
op.alter_column('item', 'new_id', nullable=False)
# Drop old columns and rename new columns
op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey')
op.drop_column('item', 'owner_id')
op.alter_column('item', 'new_owner_id', new_column_name='owner_id')
op.drop_column('user', 'id')
op.alter_column('user', 'new_id', new_column_name='id')
op.drop_column('item', 'id')
op.alter_column('item', 'new_id', new_column_name='id')
# Create primary key constraint
op.create_primary_key('user_pkey', 'user', ['id'])
op.create_primary_key('item_pkey', 'item', ['id'])
# Recreate foreign key constraint
op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id'])
def downgrade():
# Reverse the upgrade process
op.add_column('user', sa.Column('old_id', sa.Integer, autoincrement=True))
op.add_column('item', sa.Column('old_id', sa.Integer, autoincrement=True))
op.add_column('item', sa.Column('old_owner_id', sa.Integer, nullable=True))
# Populate the old columns with default values
# Generate sequences for the integer IDs if not exist
op.execute('CREATE SEQUENCE IF NOT EXISTS user_id_seq AS INTEGER OWNED BY "user".old_id')
op.execute('CREATE SEQUENCE IF NOT EXISTS item_id_seq AS INTEGER OWNED BY item.old_id')
op.execute('SELECT setval(\'user_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM "user"), 1), false)')
op.execute('SELECT setval(\'item_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM item), 1), false)')
op.execute('UPDATE "user" SET old_id = nextval(\'user_id_seq\')')
op.execute('UPDATE item SET old_id = nextval(\'item_id_seq\'), old_owner_id = (SELECT old_id FROM "user" WHERE "user".id = item.owner_id)')
# Drop new columns and rename old columns back
op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey')
op.drop_column('item', 'owner_id')
op.alter_column('item', 'old_owner_id', new_column_name='owner_id')
op.drop_column('user', 'id')
op.alter_column('user', 'old_id', new_column_name='id')
op.drop_column('item', 'id')
op.alter_column('item', 'old_id', new_column_name='id')
# Create primary key constraint
op.create_primary_key('user_pkey', 'user', ['id'])
op.create_primary_key('item_pkey', 'item', ['id'])
# Recreate foreign key constraint
op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id'])

View File

@@ -0,0 +1,54 @@
"""Initialize models
Revision ID: e2412789c190
Revises:
Create Date: 2023-11-24 22:55:43.195942
"""
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
from alembic import op
# revision identifiers, used by Alembic.
revision = "e2412789c190"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"user",
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("is_superuser", sa.Boolean(), nullable=False),
sa.Column("full_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"hashed_password", sqlmodel.sql.sqltypes.AutoString(), nullable=False
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True)
op.create_table(
"item",
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["owner_id"],
["user.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("item")
op.drop_index(op.f("ix_user_email"), table_name="user")
op.drop_table("user")
# ### end Alembic commands ###

View File

57
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,57 @@
from collections.abc import Generator
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jwt.exceptions import InvalidTokenError
from pydantic import ValidationError
from sqlmodel import Session
from app.core import security
from app.core.config import settings
from app.core.db import engine
from app.models import TokenPayload, User
reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
)
def get_db() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
SessionDep = Annotated[Session, Depends(get_db)]
TokenDep = Annotated[str, Depends(reusable_oauth2)]
def get_current_user(session: SessionDep, token: TokenDep) -> User:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
token_data = TokenPayload(**payload)
except (InvalidTokenError, ValidationError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
user = session.get(User, token_data.sub)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
def get_current_active_superuser(current_user: CurrentUser) -> User:
if not current_user.is_superuser:
raise HTTPException(
status_code=403, detail="The user doesn't have enough privileges"
)
return current_user

14
backend/app/api/main.py Normal file
View File

@@ -0,0 +1,14 @@
from fastapi import APIRouter
from app.api.routes import items, login, private, users, utils
from app.core.config import settings
api_router = APIRouter()
api_router.include_router(login.router)
api_router.include_router(users.router)
api_router.include_router(utils.router)
api_router.include_router(items.router)
if settings.ENVIRONMENT == "local":
api_router.include_router(private.router)

View File

View File

@@ -0,0 +1,109 @@
import uuid
from typing import Any
from fastapi import APIRouter, HTTPException
from sqlmodel import func, select
from app.api.deps import CurrentUser, SessionDep
from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message
router = APIRouter(prefix="/items", tags=["items"])
@router.get("/", response_model=ItemsPublic)
def read_items(
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
) -> Any:
"""
Retrieve items.
"""
if current_user.is_superuser:
count_statement = select(func.count()).select_from(Item)
count = session.exec(count_statement).one()
statement = select(Item).offset(skip).limit(limit)
items = session.exec(statement).all()
else:
count_statement = (
select(func.count())
.select_from(Item)
.where(Item.owner_id == current_user.id)
)
count = session.exec(count_statement).one()
statement = (
select(Item)
.where(Item.owner_id == current_user.id)
.offset(skip)
.limit(limit)
)
items = session.exec(statement).all()
return ItemsPublic(data=items, count=count)
@router.get("/{id}", response_model=ItemPublic)
def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
"""
Get item by ID.
"""
item = session.get(Item, id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if not current_user.is_superuser and (item.owner_id != current_user.id):
raise HTTPException(status_code=400, detail="Not enough permissions")
return item
@router.post("/", response_model=ItemPublic)
def create_item(
*, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate
) -> Any:
"""
Create new item.
"""
item = Item.model_validate(item_in, update={"owner_id": current_user.id})
session.add(item)
session.commit()
session.refresh(item)
return item
@router.put("/{id}", response_model=ItemPublic)
def update_item(
*,
session: SessionDep,
current_user: CurrentUser,
id: uuid.UUID,
item_in: ItemUpdate,
) -> Any:
"""
Update an item.
"""
item = session.get(Item, id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if not current_user.is_superuser and (item.owner_id != current_user.id):
raise HTTPException(status_code=400, detail="Not enough permissions")
update_dict = item_in.model_dump(exclude_unset=True)
item.sqlmodel_update(update_dict)
session.add(item)
session.commit()
session.refresh(item)
return item
@router.delete("/{id}")
def delete_item(
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
) -> Message:
"""
Delete an item.
"""
item = session.get(Item, id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if not current_user.is_superuser and (item.owner_id != current_user.id):
raise HTTPException(status_code=400, detail="Not enough permissions")
session.delete(item)
session.commit()
return Message(message="Item deleted successfully")

View File

@@ -0,0 +1,72 @@
from datetime import timedelta
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.security import OAuth2PasswordRequestForm
from app import crud
from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser
from app.core import security
from app.core.config import settings
from app.core.security import get_password_hash
from app.models import Message, NewPassword, Token, UserPublic
from app.utils import (
generate_password_reset_token,
verify_password_reset_token,
)
router = APIRouter(tags=["login"])
@router.post("/login/access-token")
def login_access_token(
session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
) -> Token:
"""
OAuth2 compatible token login, get an access token for future requests
"""
user = crud.authenticate(
session=session, email=form_data.username, password=form_data.password
)
if not user:
raise HTTPException(status_code=400, detail="Incorrect email or password")
elif not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return Token(
access_token=security.create_access_token(
user.id, expires_delta=access_token_expires
)
)
@router.post("/login/test-token", response_model=UserPublic)
def test_token(current_user: CurrentUser) -> Any:
"""
Test access token
"""
return current_user
@router.post("/reset-password/")
def reset_password(session: SessionDep, body: NewPassword) -> Message:
"""
Reset password
"""
email = verify_password_reset_token(token=body.token)
if not email:
raise HTTPException(status_code=400, detail="Invalid token")
user = crud.get_user_by_email(session=session, email=email)
if not user:
raise HTTPException(
status_code=404,
detail="The user with this email does not exist in the system.",
)
elif not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
hashed_password = get_password_hash(password=body.new_password)
user.hashed_password = hashed_password
session.add(user)
session.commit()
return Message(message="Password updated successfully")

View File

@@ -0,0 +1,38 @@
from typing import Any
from fastapi import APIRouter
from pydantic import BaseModel
from app.api.deps import SessionDep
from app.core.security import get_password_hash
from app.models import (
User,
UserPublic,
)
router = APIRouter(tags=["private"], prefix="/private")
class PrivateUserCreate(BaseModel):
email: str
password: str
full_name: str
is_verified: bool = False
@router.post("/users/", response_model=UserPublic)
def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any:
"""
Create a new user.
"""
user = User(
email=user_in.email,
full_name=user_in.full_name,
hashed_password=get_password_hash(user_in.password),
)
session.add(user)
session.commit()
return user

View File

@@ -0,0 +1,199 @@
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import col, delete, func, select
from app import crud
from app.api.deps import (
CurrentUser,
SessionDep,
get_current_active_superuser,
)
from app.core.config import settings
from app.core.security import get_password_hash, verify_password
from app.models import (
Item,
Message,
UpdatePassword,
User,
UserCreate,
UserPublic,
UserRegister,
UsersPublic,
UserUpdate,
UserUpdateMe,
)
router = APIRouter(prefix="/users", tags=["users"])
@router.get(
"/",
dependencies=[Depends(get_current_active_superuser)],
response_model=UsersPublic,
)
def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
"""
Retrieve users.
"""
count_statement = select(func.count()).select_from(User)
count = session.exec(count_statement).one()
statement = select(User).offset(skip).limit(limit)
users = session.exec(statement).all()
return UsersPublic(data=users, count=count)
@router.patch("/me", response_model=UserPublic)
def update_user_me(
*, session: SessionDep, user_in: UserUpdateMe, current_user: CurrentUser
) -> Any:
"""
Update own user.
"""
if user_in.email:
existing_user = crud.get_user_by_email(session=session, email=user_in.email)
if existing_user and existing_user.id != current_user.id:
raise HTTPException(
status_code=409, detail="User with this email already exists"
)
user_data = user_in.model_dump(exclude_unset=True)
current_user.sqlmodel_update(user_data)
session.add(current_user)
session.commit()
session.refresh(current_user)
return current_user
@router.patch("/me/password", response_model=Message)
def update_password_me(
*, session: SessionDep, body: UpdatePassword, current_user: CurrentUser
) -> Any:
"""
Update own password.
"""
if not verify_password(body.current_password, current_user.hashed_password):
raise HTTPException(status_code=400, detail="Incorrect password")
if body.current_password == body.new_password:
raise HTTPException(
status_code=400, detail="New password cannot be the same as the current one"
)
hashed_password = get_password_hash(body.new_password)
current_user.hashed_password = hashed_password
session.add(current_user)
session.commit()
return Message(message="Password updated successfully")
@router.get("/me", response_model=UserPublic)
def read_user_me(current_user: CurrentUser) -> Any:
"""
Get current user.
"""
return current_user
@router.delete("/me", response_model=Message)
def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any:
"""
Delete own user.
"""
if current_user.is_superuser:
raise HTTPException(
status_code=403, detail="Super users are not allowed to delete themselves"
)
session.delete(current_user)
session.commit()
return Message(message="User deleted successfully")
@router.post("/signup", response_model=UserPublic)
def register_user(session: SessionDep, user_in: UserRegister) -> Any:
"""
Create new user without the need to be logged in.
"""
user = crud.get_user_by_email(session=session, email=user_in.email)
if user:
raise HTTPException(
status_code=400,
detail="The user with this email already exists in the system",
)
user_create = UserCreate.model_validate(user_in)
user = crud.create_user(session=session, user_create=user_create)
return user
@router.get("/{user_id}", response_model=UserPublic)
def read_user_by_id(
user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser
) -> Any:
"""
Get a specific user by id.
"""
user = session.get(User, user_id)
if user == current_user:
return user
if not current_user.is_superuser:
raise HTTPException(
status_code=403,
detail="The user doesn't have enough privileges",
)
return user
@router.patch(
"/{user_id}",
dependencies=[Depends(get_current_active_superuser)],
response_model=UserPublic,
)
def update_user(
*,
session: SessionDep,
user_id: uuid.UUID,
user_in: UserUpdate,
) -> Any:
"""
Update a user.
"""
db_user = session.get(User, user_id)
if not db_user:
raise HTTPException(
status_code=404,
detail="The user with this id does not exist in the system",
)
if user_in.email:
existing_user = crud.get_user_by_email(session=session, email=user_in.email)
if existing_user and existing_user.id != user_id:
raise HTTPException(
status_code=409, detail="User with this email already exists"
)
db_user = crud.update_user(session=session, db_user=db_user, user_in=user_in)
return db_user
@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)])
def delete_user(
session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID
) -> Message:
"""
Delete a user.
"""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user == current_user:
raise HTTPException(
status_code=403, detail="Super users are not allowed to delete themselves"
)
statement = delete(Item).where(col(Item.owner_id) == user_id)
session.exec(statement) # type: ignore
session.delete(user)
session.commit()
return Message(message="User deleted successfully")

View File

@@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends
from pydantic.networks import EmailStr
from app.api.deps import get_current_active_superuser
from app.models import Message
router = APIRouter(prefix="/utils", tags=["utils"])
@router.get("/health-check/")
async def health_check() -> bool:
return True

View File

@@ -0,0 +1,39 @@
import logging
from sqlalchemy import Engine
from sqlmodel import Session, select
from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed
from app.core.db import engine
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
max_tries = 60 * 5 # 5 minutes
wait_seconds = 1
@retry(
stop=stop_after_attempt(max_tries),
wait=wait_fixed(wait_seconds),
before=before_log(logger, logging.INFO),
after=after_log(logger, logging.WARN),
)
def init(db_engine: Engine) -> None:
try:
with Session(db_engine) as session:
# Try to create session to check if DB is awake
session.exec(select(1))
except Exception as e:
logger.error(e)
raise e
def main() -> None:
logger.info("Initializing service")
init(engine)
logger.info("Service finished initializing")
if __name__ == "__main__":
main()

View File

120
backend/app/core/config.py Normal file
View File

@@ -0,0 +1,120 @@
import secrets
import warnings
from typing import Annotated, Any, Literal
from pydantic import (
AnyUrl,
BeforeValidator,
EmailStr,
HttpUrl,
PostgresDsn,
computed_field,
model_validator,
)
from pydantic_core import MultiHostUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing_extensions import Self
def parse_cors(v: Any) -> list[str] | str:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, list | str):
return v
raise ValueError(v)
class Settings(BaseSettings):
model_config = SettingsConfigDict(
# Use top level .env file (one level above ./backend/)
env_file="../.env",
env_ignore_empty=True,
extra="ignore",
)
API_V1_STR: str = "/api/v1"
SECRET_KEY: str = secrets.token_urlsafe(32)
# 60 minutes * 24 hours * 8 days = 8 days
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
FRONTEND_HOST: str = "http://localhost:5173"
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
BACKEND_CORS_ORIGINS: Annotated[
list[AnyUrl] | str, BeforeValidator(parse_cors)
] = []
@computed_field # type: ignore[prop-decorator]
@property
def all_cors_origins(self) -> list[str]:
return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [
self.FRONTEND_HOST
]
PROJECT_NAME: str
SENTRY_DSN: HttpUrl | None = None
POSTGRES_SERVER: str
POSTGRES_PORT: int = 5432
POSTGRES_USER: str
POSTGRES_PASSWORD: str = ""
POSTGRES_DB: str = ""
@computed_field # type: ignore[prop-decorator]
@property
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
return MultiHostUrl.build(
scheme="postgresql+psycopg",
username=self.POSTGRES_USER,
password=self.POSTGRES_PASSWORD,
host=self.POSTGRES_SERVER,
port=self.POSTGRES_PORT,
path=self.POSTGRES_DB,
)
SMTP_TLS: bool = True
SMTP_SSL: bool = False
SMTP_PORT: int = 587
SMTP_HOST: str | None = None
SMTP_USER: str | None = None
SMTP_PASSWORD: str | None = None
EMAILS_FROM_EMAIL: EmailStr | None = None
EMAILS_FROM_NAME: EmailStr | None = None
@model_validator(mode="after")
def _set_default_emails_from(self) -> Self:
if not self.EMAILS_FROM_NAME:
self.EMAILS_FROM_NAME = self.PROJECT_NAME
return self
EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
@computed_field # type: ignore[prop-decorator]
@property
def emails_enabled(self) -> bool:
return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
EMAIL_TEST_USER: EmailStr = "test@example.com"
FIRST_SUPERUSER: EmailStr
FIRST_SUPERUSER_PASSWORD: str
def _check_default_secret(self, var_name: str, value: str | None) -> None:
if value == "changethis":
message = (
f'The value of {var_name} is "changethis", '
"for security, please change it, at least for deployments."
)
if self.ENVIRONMENT == "local":
warnings.warn(message, stacklevel=1)
else:
raise ValueError(message)
@model_validator(mode="after")
def _enforce_non_default_secrets(self) -> Self:
self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD)
self._check_default_secret(
"FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
)
return self
settings = Settings() # type: ignore

33
backend/app/core/db.py Normal file
View File

@@ -0,0 +1,33 @@
from sqlmodel import Session, create_engine, select
from app import crud
from app.core.config import settings
from app.models import User, UserCreate
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
# make sure all SQLModel models are imported (app.models) before initializing DB
# otherwise, SQLModel might fail to initialize relationships properly
# for more details: https://github.com/fastapi/full-stack-fastapi-template/issues/28
def init_db(session: Session) -> None:
# Tables should be created with Alembic migrations
# But if you don't want to use migrations, create
# the tables un-commenting the next lines
# from sqlmodel import SQLModel
# This works because the models are already imported and registered from app.models
# SQLModel.metadata.create_all(engine)
user = session.exec(
select(User).where(User.email == settings.FIRST_SUPERUSER)
).first()
if not user:
user_in = UserCreate(
email=settings.FIRST_SUPERUSER,
password=settings.FIRST_SUPERUSER_PASSWORD,
is_superuser=True,
)
user = crud.create_user(session=session, user_create=user_in)

View File

@@ -0,0 +1,27 @@
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
expire = datetime.now(timezone.utc) + expires_delta
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)

54
backend/app/crud.py Normal file
View File

@@ -0,0 +1,54 @@
import uuid
from typing import Any
from sqlmodel import Session, select
from app.core.security import get_password_hash, verify_password
from app.models import Item, ItemCreate, User, UserCreate, UserUpdate
def create_user(*, session: Session, user_create: UserCreate) -> User:
db_obj = User.model_validate(
user_create, update={"hashed_password": get_password_hash(user_create.password)}
)
session.add(db_obj)
session.commit()
session.refresh(db_obj)
return db_obj
def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any:
user_data = user_in.model_dump(exclude_unset=True)
extra_data = {}
if "password" in user_data:
password = user_data["password"]
hashed_password = get_password_hash(password)
extra_data["hashed_password"] = hashed_password
db_user.sqlmodel_update(user_data, update=extra_data)
session.add(db_user)
session.commit()
session.refresh(db_user)
return db_user
def get_user_by_email(*, session: Session, email: str) -> User | None:
statement = select(User).where(User.email == email)
session_user = session.exec(statement).first()
return session_user
def authenticate(*, session: Session, email: str, password: str) -> User | None:
db_user = get_user_by_email(session=session, email=email)
if not db_user:
return None
if not verify_password(password, db_user.hashed_password):
return None
return db_user
def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item:
db_item = Item.model_validate(item_in, update={"owner_id": owner_id})
session.add(db_item)
session.commit()
session.refresh(db_item)
return db_item

View File

@@ -0,0 +1,23 @@
import logging
from sqlmodel import Session
from app.core.db import engine, init_db
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def init() -> None:
with Session(engine) as session:
init_db(session)
def main() -> None:
logger.info("Creating initial data")
init()
logger.info("Initial data created")
if __name__ == "__main__":
main()

33
backend/app/main.py Normal file
View File

@@ -0,0 +1,33 @@
import sentry_sdk
from fastapi import FastAPI
from fastapi.routing import APIRoute
from starlette.middleware.cors import CORSMiddleware
from app.api.main import api_router
from app.core.config import settings
def custom_generate_unique_id(route: APIRoute) -> str:
return f"{route.tags[0]}-{route.name}"
if settings.SENTRY_DSN and settings.ENVIRONMENT != "local":
sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True)
app = FastAPI(
title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
generate_unique_id_function=custom_generate_unique_id,
)
# Set all CORS enabled origins
if settings.all_cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.all_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.API_V1_STR)

113
backend/app/models.py Normal file
View File

@@ -0,0 +1,113 @@
import uuid
from pydantic import EmailStr
from sqlmodel import Field, Relationship, SQLModel
# Shared properties
class UserBase(SQLModel):
email: EmailStr = Field(unique=True, index=True, max_length=255)
is_active: bool = True
is_superuser: bool = False
full_name: str | None = Field(default=None, max_length=255)
# Properties to receive via API on creation
class UserCreate(UserBase):
password: str = Field(min_length=8, max_length=40)
class UserRegister(SQLModel):
email: EmailStr = Field(max_length=255)
password: str = Field(min_length=8, max_length=40)
full_name: str | None = Field(default=None, max_length=255)
# Properties to receive via API on update, all are optional
class UserUpdate(UserBase):
email: EmailStr | None = Field(default=None, max_length=255) # type: ignore
password: str | None = Field(default=None, min_length=8, max_length=40)
class UserUpdateMe(SQLModel):
full_name: str | None = Field(default=None, max_length=255)
email: EmailStr | None = Field(default=None, max_length=255)
class UpdatePassword(SQLModel):
current_password: str = Field(min_length=8, max_length=40)
new_password: str = Field(min_length=8, max_length=40)
# Database model, database table inferred from class name
class User(UserBase, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
hashed_password: str
items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
# Properties to return via API, id is always required
class UserPublic(UserBase):
id: uuid.UUID
class UsersPublic(SQLModel):
data: list[UserPublic]
count: int
# Shared properties
class ItemBase(SQLModel):
title: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=255)
# Properties to receive on item creation
class ItemCreate(ItemBase):
pass
# Properties to receive on item update
class ItemUpdate(ItemBase):
title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
# Database model, database table inferred from class name
class Item(ItemBase, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
owner_id: uuid.UUID = Field(
foreign_key="user.id", nullable=False, ondelete="CASCADE"
)
owner: User | None = Relationship(back_populates="items")
# Properties to return via API, id is always required
class ItemPublic(ItemBase):
id: uuid.UUID
owner_id: uuid.UUID
class ItemsPublic(SQLModel):
data: list[ItemPublic]
count: int
# Generic message
class Message(SQLModel):
message: str
# JSON payload containing access token
class Token(SQLModel):
access_token: str
token_type: str = "bearer"
# Contents of JWT token
class TokenPayload(SQLModel):
sub: str | None = None
class NewPassword(SQLModel):
token: str
new_password: str = Field(min_length=8, max_length=40)

View File

View File

View File

View File

@@ -0,0 +1,164 @@
import uuid
from fastapi.testclient import TestClient
from sqlmodel import Session
from app.core.config import settings
from app.tests.utils.item import create_random_item
def test_create_item(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
data = {"title": "Foo", "description": "Fighters"}
response = client.post(
f"{settings.API_V1_STR}/items/",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == 200
content = response.json()
assert content["title"] == data["title"]
assert content["description"] == data["description"]
assert "id" in content
assert "owner_id" in content
def test_read_item(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
item = create_random_item(db)
response = client.get(
f"{settings.API_V1_STR}/items/{item.id}",
headers=superuser_token_headers,
)
assert response.status_code == 200
content = response.json()
assert content["title"] == item.title
assert content["description"] == item.description
assert content["id"] == str(item.id)
assert content["owner_id"] == str(item.owner_id)
def test_read_item_not_found(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
response = client.get(
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == 404
content = response.json()
assert content["detail"] == "Item not found"
def test_read_item_not_enough_permissions(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
item = create_random_item(db)
response = client.get(
f"{settings.API_V1_STR}/items/{item.id}",
headers=normal_user_token_headers,
)
assert response.status_code == 400
content = response.json()
assert content["detail"] == "Not enough permissions"
def test_read_items(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
create_random_item(db)
create_random_item(db)
response = client.get(
f"{settings.API_V1_STR}/items/",
headers=superuser_token_headers,
)
assert response.status_code == 200
content = response.json()
assert len(content["data"]) >= 2
def test_update_item(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
item = create_random_item(db)
data = {"title": "Updated title", "description": "Updated description"}
response = client.put(
f"{settings.API_V1_STR}/items/{item.id}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == 200
content = response.json()
assert content["title"] == data["title"]
assert content["description"] == data["description"]
assert content["id"] == str(item.id)
assert content["owner_id"] == str(item.owner_id)
def test_update_item_not_found(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
data = {"title": "Updated title", "description": "Updated description"}
response = client.put(
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == 404
content = response.json()
assert content["detail"] == "Item not found"
def test_update_item_not_enough_permissions(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
item = create_random_item(db)
data = {"title": "Updated title", "description": "Updated description"}
response = client.put(
f"{settings.API_V1_STR}/items/{item.id}",
headers=normal_user_token_headers,
json=data,
)
assert response.status_code == 400
content = response.json()
assert content["detail"] == "Not enough permissions"
def test_delete_item(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
item = create_random_item(db)
response = client.delete(
f"{settings.API_V1_STR}/items/{item.id}",
headers=superuser_token_headers,
)
assert response.status_code == 200
content = response.json()
assert content["message"] == "Item deleted successfully"
def test_delete_item_not_found(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
response = client.delete(
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == 404
content = response.json()
assert content["detail"] == "Item not found"
def test_delete_item_not_enough_permissions(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
item = create_random_item(db)
response = client.delete(
f"{settings.API_V1_STR}/items/{item.id}",
headers=normal_user_token_headers,
)
assert response.status_code == 400
content = response.json()
assert content["detail"] == "Not enough permissions"

View File

@@ -0,0 +1,118 @@
from unittest.mock import patch
from fastapi.testclient import TestClient
from sqlmodel import Session
from app.core.config import settings
from app.core.security import verify_password
from app.crud import create_user
from app.models import UserCreate
from app.tests.utils.user import user_authentication_headers
from app.tests.utils.utils import random_email, random_lower_string
from app.utils import generate_password_reset_token
def test_get_access_token(client: TestClient) -> None:
login_data = {
"username": settings.FIRST_SUPERUSER,
"password": settings.FIRST_SUPERUSER_PASSWORD,
}
r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
tokens = r.json()
assert r.status_code == 200
assert "access_token" in tokens
assert tokens["access_token"]
def test_get_access_token_incorrect_password(client: TestClient) -> None:
login_data = {
"username": settings.FIRST_SUPERUSER,
"password": "incorrect",
}
r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
assert r.status_code == 400
def test_use_access_token(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
r = client.post(
f"{settings.API_V1_STR}/login/test-token",
headers=superuser_token_headers,
)
result = r.json()
assert r.status_code == 200
assert "email" in result
def test_recovery_password(
client: TestClient, normal_user_token_headers: dict[str, str]
) -> None:
with (
patch("app.core.config.settings.SMTP_HOST", "smtp.example.com"),
patch("app.core.config.settings.SMTP_USER", "admin@example.com"),
):
email = "test@example.com"
r = client.post(
f"{settings.API_V1_STR}/password-recovery/{email}",
headers=normal_user_token_headers,
)
assert r.status_code == 200
assert r.json() == {"message": "Password recovery email sent"}
def test_recovery_password_user_not_exits(
client: TestClient, normal_user_token_headers: dict[str, str]
) -> None:
email = "jVgQr@example.com"
r = client.post(
f"{settings.API_V1_STR}/password-recovery/{email}",
headers=normal_user_token_headers,
)
assert r.status_code == 404
def test_reset_password(client: TestClient, db: Session) -> None:
email = random_email()
password = random_lower_string()
new_password = random_lower_string()
user_create = UserCreate(
email=email,
full_name="Test User",
password=password,
is_active=True,
is_superuser=False,
)
user = create_user(session=db, user_create=user_create)
token = generate_password_reset_token(email=email)
headers = user_authentication_headers(client=client, email=email, password=password)
data = {"new_password": new_password, "token": token}
r = client.post(
f"{settings.API_V1_STR}/reset-password/",
headers=headers,
json=data,
)
assert r.status_code == 200
assert r.json() == {"message": "Password updated successfully"}
db.refresh(user)
assert verify_password(new_password, user.hashed_password)
def test_reset_password_invalid_token(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
data = {"new_password": "changethis", "token": "invalid"}
r = client.post(
f"{settings.API_V1_STR}/reset-password/",
headers=superuser_token_headers,
json=data,
)
response = r.json()
assert "detail" in response
assert r.status_code == 400
assert response["detail"] == "Invalid token"

View File

@@ -0,0 +1,26 @@
from fastapi.testclient import TestClient
from sqlmodel import Session, select
from app.core.config import settings
from app.models import User
def test_create_user(client: TestClient, db: Session) -> None:
r = client.post(
f"{settings.API_V1_STR}/private/users/",
json={
"email": "pollo@listo.com",
"password": "password123",
"full_name": "Pollo Listo",
},
)
assert r.status_code == 200
data = r.json()
user = db.exec(select(User).where(User.id == data["id"])).first()
assert user
assert user.email == "pollo@listo.com"
assert user.full_name == "Pollo Listo"

View File

@@ -0,0 +1,486 @@
import uuid
from unittest.mock import patch
from fastapi.testclient import TestClient
from sqlmodel import Session, select
from app import crud
from app.core.config import settings
from app.core.security import verify_password
from app.models import User, UserCreate
from app.tests.utils.utils import random_email, random_lower_string
def test_get_users_superuser_me(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
r = client.get(f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers)
current_user = r.json()
assert current_user
assert current_user["is_active"] is True
assert current_user["is_superuser"]
assert current_user["email"] == settings.FIRST_SUPERUSER
def test_get_users_normal_user_me(
client: TestClient, normal_user_token_headers: dict[str, str]
) -> None:
r = client.get(f"{settings.API_V1_STR}/users/me", headers=normal_user_token_headers)
current_user = r.json()
assert current_user
assert current_user["is_active"] is True
assert current_user["is_superuser"] is False
assert current_user["email"] == settings.EMAIL_TEST_USER
def test_create_user_new_email(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
with (
patch("app.utils.send_email", return_value=None),
patch("app.core.config.settings.SMTP_HOST", "smtp.example.com"),
patch("app.core.config.settings.SMTP_USER", "admin@example.com"),
):
username = random_email()
password = random_lower_string()
data = {"email": username, "password": password}
r = client.post(
f"{settings.API_V1_STR}/users/",
headers=superuser_token_headers,
json=data,
)
assert 200 <= r.status_code < 300
created_user = r.json()
user = crud.get_user_by_email(session=db, email=username)
assert user
assert user.email == created_user["email"]
def test_get_existing_user(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
username = random_email()
password = random_lower_string()
user_in = UserCreate(email=username, password=password)
user = crud.create_user(session=db, user_create=user_in)
user_id = user.id
r = client.get(
f"{settings.API_V1_STR}/users/{user_id}",
headers=superuser_token_headers,
)
assert 200 <= r.status_code < 300
api_user = r.json()
existing_user = crud.get_user_by_email(session=db, email=username)
assert existing_user
assert existing_user.email == api_user["email"]
def test_get_existing_user_current_user(client: TestClient, db: Session) -> None:
username = random_email()
password = random_lower_string()
user_in = UserCreate(email=username, password=password)
user = crud.create_user(session=db, user_create=user_in)
user_id = user.id
login_data = {
"username": username,
"password": password,
}
r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
tokens = r.json()
a_token = tokens["access_token"]
headers = {"Authorization": f"Bearer {a_token}"}
r = client.get(
f"{settings.API_V1_STR}/users/{user_id}",
headers=headers,
)
assert 200 <= r.status_code < 300
api_user = r.json()
existing_user = crud.get_user_by_email(session=db, email=username)
assert existing_user
assert existing_user.email == api_user["email"]
def test_get_existing_user_permissions_error(
client: TestClient, normal_user_token_headers: dict[str, str]
) -> None:
r = client.get(
f"{settings.API_V1_STR}/users/{uuid.uuid4()}",
headers=normal_user_token_headers,
)
assert r.status_code == 403
assert r.json() == {"detail": "The user doesn't have enough privileges"}
def test_create_user_existing_username(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
username = random_email()
# username = email
password = random_lower_string()
user_in = UserCreate(email=username, password=password)
crud.create_user(session=db, user_create=user_in)
data = {"email": username, "password": password}
r = client.post(
f"{settings.API_V1_STR}/users/",
headers=superuser_token_headers,
json=data,
)
created_user = r.json()
assert r.status_code == 400
assert "_id" not in created_user
def test_create_user_by_normal_user(
client: TestClient, normal_user_token_headers: dict[str, str]
) -> None:
username = random_email()
password = random_lower_string()
data = {"email": username, "password": password}
r = client.post(
f"{settings.API_V1_STR}/users/",
headers=normal_user_token_headers,
json=data,
)
assert r.status_code == 403
def test_retrieve_users(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
username = random_email()
password = random_lower_string()
user_in = UserCreate(email=username, password=password)
crud.create_user(session=db, user_create=user_in)
username2 = random_email()
password2 = random_lower_string()
user_in2 = UserCreate(email=username2, password=password2)
crud.create_user(session=db, user_create=user_in2)
r = client.get(f"{settings.API_V1_STR}/users/", headers=superuser_token_headers)
all_users = r.json()
assert len(all_users["data"]) > 1
assert "count" in all_users
for item in all_users["data"]:
assert "email" in item
def test_update_user_me(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
full_name = "Updated Name"
email = random_email()
data = {"full_name": full_name, "email": email}
r = client.patch(
f"{settings.API_V1_STR}/users/me",
headers=normal_user_token_headers,
json=data,
)
assert r.status_code == 200
updated_user = r.json()
assert updated_user["email"] == email
assert updated_user["full_name"] == full_name
user_query = select(User).where(User.email == email)
user_db = db.exec(user_query).first()
assert user_db
assert user_db.email == email
assert user_db.full_name == full_name
def test_update_password_me(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
new_password = random_lower_string()
data = {
"current_password": settings.FIRST_SUPERUSER_PASSWORD,
"new_password": new_password,
}
r = client.patch(
f"{settings.API_V1_STR}/users/me/password",
headers=superuser_token_headers,
json=data,
)
assert r.status_code == 200
updated_user = r.json()
assert updated_user["message"] == "Password updated successfully"
user_query = select(User).where(User.email == settings.FIRST_SUPERUSER)
user_db = db.exec(user_query).first()
assert user_db
assert user_db.email == settings.FIRST_SUPERUSER
assert verify_password(new_password, user_db.hashed_password)
# Revert to the old password to keep consistency in test
old_data = {
"current_password": new_password,
"new_password": settings.FIRST_SUPERUSER_PASSWORD,
}
r = client.patch(
f"{settings.API_V1_STR}/users/me/password",
headers=superuser_token_headers,
json=old_data,
)
db.refresh(user_db)
assert r.status_code == 200
assert verify_password(settings.FIRST_SUPERUSER_PASSWORD, user_db.hashed_password)
def test_update_password_me_incorrect_password(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
new_password = random_lower_string()
data = {"current_password": new_password, "new_password": new_password}
r = client.patch(
f"{settings.API_V1_STR}/users/me/password",
headers=superuser_token_headers,
json=data,
)
assert r.status_code == 400
updated_user = r.json()
assert updated_user["detail"] == "Incorrect password"
def test_update_user_me_email_exists(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
username = random_email()
password = random_lower_string()
user_in = UserCreate(email=username, password=password)
user = crud.create_user(session=db, user_create=user_in)
data = {"email": user.email}
r = client.patch(
f"{settings.API_V1_STR}/users/me",
headers=normal_user_token_headers,
json=data,
)
assert r.status_code == 409
assert r.json()["detail"] == "User with this email already exists"
def test_update_password_me_same_password_error(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
data = {
"current_password": settings.FIRST_SUPERUSER_PASSWORD,
"new_password": settings.FIRST_SUPERUSER_PASSWORD,
}
r = client.patch(
f"{settings.API_V1_STR}/users/me/password",
headers=superuser_token_headers,
json=data,
)
assert r.status_code == 400
updated_user = r.json()
assert (
updated_user["detail"] == "New password cannot be the same as the current one"
)
def test_register_user(client: TestClient, db: Session) -> None:
username = random_email()
password = random_lower_string()
full_name = random_lower_string()
data = {"email": username, "password": password, "full_name": full_name}
r = client.post(
f"{settings.API_V1_STR}/users/signup",
json=data,
)
assert r.status_code == 200
created_user = r.json()
assert created_user["email"] == username
assert created_user["full_name"] == full_name
user_query = select(User).where(User.email == username)
user_db = db.exec(user_query).first()
assert user_db
assert user_db.email == username
assert user_db.full_name == full_name
assert verify_password(password, user_db.hashed_password)
def test_register_user_already_exists_error(client: TestClient) -> None:
password = random_lower_string()
full_name = random_lower_string()
data = {
"email": settings.FIRST_SUPERUSER,
"password": password,
"full_name": full_name,
}
r = client.post(
f"{settings.API_V1_STR}/users/signup",
json=data,
)
assert r.status_code == 400
assert r.json()["detail"] == "The user with this email already exists in the system"
def test_update_user(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
username = random_email()
password = random_lower_string()
user_in = UserCreate(email=username, password=password)
user = crud.create_user(session=db, user_create=user_in)
data = {"full_name": "Updated_full_name"}
r = client.patch(
f"{settings.API_V1_STR}/users/{user.id}",
headers=superuser_token_headers,
json=data,
)
assert r.status_code == 200
updated_user = r.json()
assert updated_user["full_name"] == "Updated_full_name"
user_query = select(User).where(User.email == username)
user_db = db.exec(user_query).first()
db.refresh(user_db)
assert user_db
assert user_db.full_name == "Updated_full_name"
def test_update_user_not_exists(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
data = {"full_name": "Updated_full_name"}
r = client.patch(
f"{settings.API_V1_STR}/users/{uuid.uuid4()}",
headers=superuser_token_headers,
json=data,
)
assert r.status_code == 404
assert r.json()["detail"] == "The user with this id does not exist in the system"
def test_update_user_email_exists(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
username = random_email()
password = random_lower_string()
user_in = UserCreate(email=username, password=password)
user = crud.create_user(session=db, user_create=user_in)
username2 = random_email()
password2 = random_lower_string()
user_in2 = UserCreate(email=username2, password=password2)
user2 = crud.create_user(session=db, user_create=user_in2)
data = {"email": user2.email}
r = client.patch(
f"{settings.API_V1_STR}/users/{user.id}",
headers=superuser_token_headers,
json=data,
)
assert r.status_code == 409
assert r.json()["detail"] == "User with this email already exists"
def test_delete_user_me(client: TestClient, db: Session) -> None:
username = random_email()
password = random_lower_string()
user_in = UserCreate(email=username, password=password)
user = crud.create_user(session=db, user_create=user_in)
user_id = user.id
login_data = {
"username": username,
"password": password,
}
r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
tokens = r.json()
a_token = tokens["access_token"]
headers = {"Authorization": f"Bearer {a_token}"}
r = client.delete(
f"{settings.API_V1_STR}/users/me",
headers=headers,
)
assert r.status_code == 200
deleted_user = r.json()
assert deleted_user["message"] == "User deleted successfully"
result = db.exec(select(User).where(User.id == user_id)).first()
assert result is None
user_query = select(User).where(User.id == user_id)
user_db = db.execute(user_query).first()
assert user_db is None
def test_delete_user_me_as_superuser(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
r = client.delete(
f"{settings.API_V1_STR}/users/me",
headers=superuser_token_headers,
)
assert r.status_code == 403
response = r.json()
assert response["detail"] == "Super users are not allowed to delete themselves"
def test_delete_user_super_user(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
username = random_email()
password = random_lower_string()
user_in = UserCreate(email=username, password=password)
user = crud.create_user(session=db, user_create=user_in)
user_id = user.id
r = client.delete(
f"{settings.API_V1_STR}/users/{user_id}",
headers=superuser_token_headers,
)
assert r.status_code == 200
deleted_user = r.json()
assert deleted_user["message"] == "User deleted successfully"
result = db.exec(select(User).where(User.id == user_id)).first()
assert result is None
def test_delete_user_not_found(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
r = client.delete(
f"{settings.API_V1_STR}/users/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert r.status_code == 404
assert r.json()["detail"] == "User not found"
def test_delete_user_current_super_user_error(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
super_user = crud.get_user_by_email(session=db, email=settings.FIRST_SUPERUSER)
assert super_user
user_id = super_user.id
r = client.delete(
f"{settings.API_V1_STR}/users/{user_id}",
headers=superuser_token_headers,
)
assert r.status_code == 403
assert r.json()["detail"] == "Super users are not allowed to delete themselves"
def test_delete_user_without_privileges(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
username = random_email()
password = random_lower_string()
user_in = UserCreate(email=username, password=password)
user = crud.create_user(session=db, user_create=user_in)
r = client.delete(
f"{settings.API_V1_STR}/users/{user.id}",
headers=normal_user_token_headers,
)
assert r.status_code == 403
assert r.json()["detail"] == "The user doesn't have enough privileges"

View File

@@ -0,0 +1,42 @@
from collections.abc import Generator
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, delete
from app.core.config import settings
from app.core.db import engine, init_db
from app.main import app
from app.models import Item, User
from app.tests.utils.user import authentication_token_from_email
from app.tests.utils.utils import get_superuser_token_headers
@pytest.fixture(scope="session", autouse=True)
def db() -> Generator[Session, None, None]:
with Session(engine) as session:
init_db(session)
yield session
statement = delete(Item)
session.execute(statement)
statement = delete(User)
session.execute(statement)
session.commit()
@pytest.fixture(scope="module")
def client() -> Generator[TestClient, None, None]:
with TestClient(app) as c:
yield c
@pytest.fixture(scope="module")
def superuser_token_headers(client: TestClient) -> dict[str, str]:
return get_superuser_token_headers(client)
@pytest.fixture(scope="module")
def normal_user_token_headers(client: TestClient, db: Session) -> dict[str, str]:
return authentication_token_from_email(
client=client, email=settings.EMAIL_TEST_USER, db=db
)

View File

View File

@@ -0,0 +1,91 @@
from fastapi.encoders import jsonable_encoder
from sqlmodel import Session
from app import crud
from app.core.security import verify_password
from app.models import User, UserCreate, UserUpdate
from app.tests.utils.utils import random_email, random_lower_string
def test_create_user(db: Session) -> None:
email = random_email()
password = random_lower_string()
user_in = UserCreate(email=email, password=password)
user = crud.create_user(session=db, user_create=user_in)
assert user.email == email
assert hasattr(user, "hashed_password")
def test_authenticate_user(db: Session) -> None:
email = random_email()
password = random_lower_string()
user_in = UserCreate(email=email, password=password)
user = crud.create_user(session=db, user_create=user_in)
authenticated_user = crud.authenticate(session=db, email=email, password=password)
assert authenticated_user
assert user.email == authenticated_user.email
def test_not_authenticate_user(db: Session) -> None:
email = random_email()
password = random_lower_string()
user = crud.authenticate(session=db, email=email, password=password)
assert user is None
def test_check_if_user_is_active(db: Session) -> None:
email = random_email()
password = random_lower_string()
user_in = UserCreate(email=email, password=password)
user = crud.create_user(session=db, user_create=user_in)
assert user.is_active is True
def test_check_if_user_is_active_inactive(db: Session) -> None:
email = random_email()
password = random_lower_string()
user_in = UserCreate(email=email, password=password, disabled=True)
user = crud.create_user(session=db, user_create=user_in)
assert user.is_active
def test_check_if_user_is_superuser(db: Session) -> None:
email = random_email()
password = random_lower_string()
user_in = UserCreate(email=email, password=password, is_superuser=True)
user = crud.create_user(session=db, user_create=user_in)
assert user.is_superuser is True
def test_check_if_user_is_superuser_normal_user(db: Session) -> None:
username = random_email()
password = random_lower_string()
user_in = UserCreate(email=username, password=password)
user = crud.create_user(session=db, user_create=user_in)
assert user.is_superuser is False
def test_get_user(db: Session) -> None:
password = random_lower_string()
username = random_email()
user_in = UserCreate(email=username, password=password, is_superuser=True)
user = crud.create_user(session=db, user_create=user_in)
user_2 = db.get(User, user.id)
assert user_2
assert user.email == user_2.email
assert jsonable_encoder(user) == jsonable_encoder(user_2)
def test_update_user(db: Session) -> None:
password = random_lower_string()
email = random_email()
user_in = UserCreate(email=email, password=password, is_superuser=True)
user = crud.create_user(session=db, user_create=user_in)
new_password = random_lower_string()
user_in_update = UserUpdate(password=new_password, is_superuser=True)
if user.id is not None:
crud.update_user(session=db, db_user=user, user_in=user_in_update)
user_2 = db.get(User, user.id)
assert user_2
assert user.email == user_2.email
assert verify_password(new_password, user_2.hashed_password)

View File

View File

@@ -0,0 +1,33 @@
from unittest.mock import MagicMock, patch
from sqlmodel import select
from app.backend_pre_start import init, logger
def test_init_successful_connection() -> None:
engine_mock = MagicMock()
session_mock = MagicMock()
exec_mock = MagicMock(return_value=True)
session_mock.configure_mock(**{"exec.return_value": exec_mock})
with (
patch("sqlmodel.Session", return_value=session_mock),
patch.object(logger, "info"),
patch.object(logger, "error"),
patch.object(logger, "warn"),
):
try:
init(engine_mock)
connection_successful = True
except Exception:
connection_successful = False
assert (
connection_successful
), "The database connection should be successful and not raise an exception."
assert session_mock.exec.called_once_with(
select(1)
), "The session should execute a select statement once."

View File

@@ -0,0 +1,33 @@
from unittest.mock import MagicMock, patch
from sqlmodel import select
from app.tests_pre_start import init, logger
def test_init_successful_connection() -> None:
engine_mock = MagicMock()
session_mock = MagicMock()
exec_mock = MagicMock(return_value=True)
session_mock.configure_mock(**{"exec.return_value": exec_mock})
with (
patch("sqlmodel.Session", return_value=session_mock),
patch.object(logger, "info"),
patch.object(logger, "error"),
patch.object(logger, "warn"),
):
try:
init(engine_mock)
connection_successful = True
except Exception:
connection_successful = False
assert (
connection_successful
), "The database connection should be successful and not raise an exception."
assert session_mock.exec.called_once_with(
select(1)
), "The session should execute a select statement once."

View File

View File

@@ -0,0 +1,16 @@
from sqlmodel import Session
from app import crud
from app.models import Item, ItemCreate
from app.tests.utils.user import create_random_user
from app.tests.utils.utils import random_lower_string
def create_random_item(db: Session) -> Item:
user = create_random_user(db)
owner_id = user.id
assert owner_id is not None
title = random_lower_string()
description = random_lower_string()
item_in = ItemCreate(title=title, description=description)
return crud.create_item(session=db, item_in=item_in, owner_id=owner_id)

View File

@@ -0,0 +1,49 @@
from fastapi.testclient import TestClient
from sqlmodel import Session
from app import crud
from app.core.config import settings
from app.models import User, UserCreate, UserUpdate
from app.tests.utils.utils import random_email, random_lower_string
def user_authentication_headers(
*, client: TestClient, email: str, password: str
) -> dict[str, str]:
data = {"username": email, "password": password}
r = client.post(f"{settings.API_V1_STR}/login/access-token", data=data)
response = r.json()
auth_token = response["access_token"]
headers = {"Authorization": f"Bearer {auth_token}"}
return headers
def create_random_user(db: Session) -> User:
email = random_email()
password = random_lower_string()
user_in = UserCreate(email=email, password=password)
user = crud.create_user(session=db, user_create=user_in)
return user
def authentication_token_from_email(
*, client: TestClient, email: str, db: Session
) -> dict[str, str]:
"""
Return a valid token for the user with given email.
If the user doesn't exist it is created first.
"""
password = random_lower_string()
user = crud.get_user_by_email(session=db, email=email)
if not user:
user_in_create = UserCreate(email=email, password=password)
user = crud.create_user(session=db, user_create=user_in_create)
else:
user_in_update = UserUpdate(password=password)
if not user.id:
raise Exception("User id not set")
user = crud.update_user(session=db, db_user=user, user_in=user_in_update)
return user_authentication_headers(client=client, email=email, password=password)

View File

@@ -0,0 +1,26 @@
import random
import string
from fastapi.testclient import TestClient
from app.core.config import settings
def random_lower_string() -> str:
return "".join(random.choices(string.ascii_lowercase, k=32))
def random_email() -> str:
return f"{random_lower_string()}@{random_lower_string()}.com"
def get_superuser_token_headers(client: TestClient) -> dict[str, str]:
login_data = {
"username": settings.FIRST_SUPERUSER,
"password": settings.FIRST_SUPERUSER_PASSWORD,
}
r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
tokens = r.json()
a_token = tokens["access_token"]
headers = {"Authorization": f"Bearer {a_token}"}
return headers

View File

@@ -0,0 +1,39 @@
import logging
from sqlalchemy import Engine
from sqlmodel import Session, select
from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed
from app.core.db import engine
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
max_tries = 60 * 5 # 5 minutes
wait_seconds = 1
@retry(
stop=stop_after_attempt(max_tries),
wait=wait_fixed(wait_seconds),
before=before_log(logger, logging.INFO),
after=after_log(logger, logging.WARN),
)
def init(db_engine: Engine) -> None:
try:
# Try to create session to check if DB is awake
with Session(db_engine) as session:
session.exec(select(1))
except Exception as e:
logger.error(e)
raise e
def main() -> None:
logger.info("Initializing service")
init(engine)
logger.info("Service finished initializing")
if __name__ == "__main__":
main()

39
backend/app/utils.py Normal file
View File

@@ -0,0 +1,39 @@
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import jwt
from jwt.exceptions import InvalidTokenError
from app.core import security
from app.core.config import settings
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def generate_password_reset_token(email: str) -> str:
delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS)
now = datetime.now(timezone.utc)
expires = now + delta
exp = expires.timestamp()
encoded_jwt = jwt.encode(
{"exp": exp, "nbf": now, "sub": email},
settings.SECRET_KEY,
algorithm=security.ALGORITHM,
)
return encoded_jwt
def verify_password_reset_token(token: str) -> str | None:
try:
decoded_token = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
return str(decoded_token["sub"])
except InvalidTokenError:
return None

68
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,68 @@
[project]
name = "app"
version = "0.1.0"
description = ""
requires-python = ">=3.10,<4.0"
dependencies = [
"fastapi[standard]<1.0.0,>=0.114.2",
"python-multipart<1.0.0,>=0.0.7",
"passlib[bcrypt]<2.0.0,>=1.7.4",
"tenacity<9.0.0,>=8.2.3",
"pydantic>2.0",
"alembic<2.0.0,>=1.12.1",
"httpx<1.0.0,>=0.25.1",
"psycopg[binary]<4.0.0,>=3.1.13",
"sqlmodel<1.0.0,>=0.0.21",
# Pin bcrypt until passlib supports the latest
"bcrypt==4.0.1",
"pydantic-settings<3.0.0,>=2.2.1",
"sentry-sdk[fastapi]<2.0.0,>=1.40.6",
"pyjwt<3.0.0,>=2.8.0",
]
[[tool.uv.index]]
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
default = true
[tool.uv]
dev-dependencies = [
"pytest<8.0.0,>=7.4.3",
"mypy<2.0.0,>=1.8.0",
"ruff<1.0.0,>=0.2.2",
"pre-commit<4.0.0,>=3.6.2",
"types-passlib<2.0.0.0,>=1.7.7.20240106",
"coverage<8.0.0,>=7.4.3",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.mypy]
strict = true
exclude = ["venv", ".venv", "alembic"]
[tool.ruff]
target-version = "py310"
exclude = ["alembic"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG001", # unused arguments in functions
]
ignore = [
"E501", # line too long, handled by black
"B008", # do not perform function calls in argument defaults
"W191", # indentation contains tabs
"B904", # Allow raising exceptions without from e, for HTTPException
]
[tool.ruff.lint.pyupgrade]
# Preserve types, even if a file imports `from __future__ import annotations`.
keep-runtime-typing = true

5
backend/scripts/format.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/sh -e
set -x
ruff check app scripts --fix
ruff format app scripts

8
backend/scripts/lint.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -e
set -x
mypy app
ruff check app
ruff format app --check

View File

@@ -0,0 +1,13 @@
#! /usr/bin/env bash
set -e
set -x
# Let the DB start
python app/backend_pre_start.py
# Run migrations
alembic upgrade head
# Create initial data in DB
python app/initial_data.py

8
backend/scripts/test.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -e
set -x
coverage run --source=app -m pytest
coverage report --show-missing
coverage html --title "${@-coverage}"

View File

@@ -0,0 +1,7 @@
#! /usr/bin/env bash
set -e
set -x
python app/tests_pre_start.py
bash scripts/test.sh "$@"

View File

@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:3000
VITE_APP_TITLE=SQLBot (Development)

2
frontend/.env.production Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_BASE_URL=https://api.SQLBot.com
VITE_APP_TITLE=SQLBot

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

10
frontend/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
}

31
frontend/components.d.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
Icon: typeof import('./src/components/icon-custom/src/Icon.vue')['default']
Layout: typeof import('./src/components/layout/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "SQLBot",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@types/node": "^22.14.1",
"@vitejs/plugin-vue": "^5.2.2",
"@vue/tsconfig": "^0.7.0",
"axios": "^1.8.4",
"element-plus": "^2.9.7",
"less": "^4.3.0",
"pinia": "^3.0.2",
"typescript": "~5.7.2",
"unplugin-auto-import": "^19.1.2",
"unplugin-vue-components": "^28.5.0",
"vite": "^6.3.1",
"vite-plugin-svg-icons": "^2.0.1",
"vite-svg-loader": "^5.1.0",
"vue-tsc": "^2.2.8"
}
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

10
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
</script>
<template>
<router-view />
</template>
<style scoped>
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h1024v1024.00512H0z" fill="#FFFFFF" /><path d="M853.82656 169.9328C762.34752 78.45888 640.8704 28.1344 511.6416 28.1344c-128.256-0.49152-251.1872 50.81088-341.69344 141.7984C23.296 316.09856-13.96736 539.70432 77.99808 725.06368l-20.81792 157.77792c-2.90816 21.2992 3.87072 42.60352 17.90976 58.56768a73.81504 73.81504 0 0 0 65.82272 24.67328l157.78304-20.79744a486.28736 486.28736 0 0 0 213.44256 49.36192c128.26112 0.47104 251.19232-50.82624 341.68832-141.33248 91.47904-91.47904 141.80352-212.94592 141.80352-342.17472 0-129.20832-50.82112-250.20416-141.80352-341.20704z m-330.08128 611.28704c-7.74144 3.38944-16.46592 3.38944-24.68352 0-6.76352-2.89792-12.58496-8.22272-15.488-15.48288-6.77888-15.49312 0.48128-33.40288 15.488-40.18176 7.74144-3.38432 16.4608-3.38432 24.68352 0a30.2848 30.2848 0 0 1 15.48288 15.488c7.24992 15.488 0 33.38752-15.48288 40.17664z m145.66912-315.56096c-16.94208 29.0304-44.04736 50.3296-67.75808 69.20192-6.28736 4.84352-12.58496 9.68704-18.87232 15.00672-25.65632 22.26688-40.64768 54.69696-40.17152 88.57088 0 16.46592-13.54752 30.01344-29.99808 30.01344-16.46592 0-30.01856-13.54752-30.01856-30.01344 0-52.2752 23.22944-102.59968 61.95712-135.02976a390.016 390.016 0 0 1 20.80256-16.45568c20.3264-15.97952 41.63584-32.42496 52.76672-51.30752 15.00672-26.13248 10.62912-64.3584-10.65472-90.02496-27.09504-32.90624-74.53696-36.29056-93.90592-36.29056-109.85472 0-114.70336 88.56576-114.70336 98.72384-0.48128 16.46592-15.47776 28.07808-30.48448 30.01344-16.45568 0-30.0032-13.54752-30.0032-30.01344 0-54.69184 37.26848-158.74048 175.19104-158.74048 59.04896 0 110.35648 21.2992 140.84608 58.56256 36.31616 44.52352 43.07968 109.86496 15.00672 157.78304z" fill="#666666" /></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M128 554.666667h341.333333V128H128v426.666667z m0 341.333333h341.333333V640H128v256z m426.666667 0h341.333333V469.333333H554.666667v426.666667z m0-768v256h341.333333V128H554.666667z" /></svg>

After

Width:  |  Height:  |  Size: 459 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M106.666667 234.666667a405.333333 128 0 1 0 810.666666 0 405.333333 128 0 1 0-810.666666 0Z" /><path d="M512 704c-178.133333 0-329.386667-36.266667-383.786667-86.613333a12.714667 12.714667 0 0 0-21.546666 9.173333V789.333333c0 70.613333 181.546667 128 405.333333 128s405.333333-57.386667 405.333333-128v-162.773333c0-11.306667-13.226667-16.853333-21.546666-9.173333C841.386667 667.733333 690.133333 704 512 704z" /><path d="M512 426.666667c-178.133333 0-329.386667-36.266667-383.786667-86.613334a12.714667 12.714667 0 0 0-21.546666 9.173334V512c0 70.613333 181.546667 128 405.333333 128s405.333333-57.386667 405.333333-128v-162.773333c0-11.306667-13.226667-16.853333-21.546666-9.173334C841.386667 390.4 690.133333 426.666667 512 426.666667z" /></svg>

After

Width:  |  Height:  |  Size: 1020 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1621433652678" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2364" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M930.668 386.638c-16.798 0-30.422-13.62-30.422-30.422V175.75c0-13.782-11.216-24.994-24.998-24.994H574.898c-16.798 0-30.422-13.62-30.422-30.422s13.622-30.422 30.422-30.422h300.352c47.334 0 85.842 38.508 85.842 85.838v180.466c-0.002 16.802-13.624 30.422-30.424 30.422z" p-id="2365"></path><path d="M941.828 965.542H82.172C36.862 965.542 0 928.682 0 883.372V143.92c0-47.124 38.338-85.462 85.462-85.462h251.608c47.124 0 85.462 38.338 85.462 85.462v74.936h519.298c45.31 0 82.172 36.862 82.172 82.17 0 16.802-13.622 30.422-30.422 30.422s-30.422-13.62-30.422-30.422c0-11.758-9.568-21.326-21.328-21.326h-549.72c-16.798 0-30.422-13.62-30.422-30.422V143.92c0-13.574-11.044-24.618-24.618-24.618H85.462c-13.574 0-24.618 11.044-24.618 24.618v739.452c0 11.758 9.568 21.326 21.328 21.326h859.654c16.798 0 30.422 13.62 30.422 30.422 0.002 16.802-13.622 30.422-30.42 30.422z" p-id="2366"></path><path d="M993.576 769.22V301.028c0-28.578-23.17-51.748-51.748-51.748h-549.72V143.92c0-30.398-24.642-55.04-55.04-55.04H85.462c-30.398 0-55.04 24.642-55.04 55.04v625.304h963.154v-0.004z" p-id="2367"></path><path d="M993.578 799.642H30.422c-16.8 0-30.422-13.62-30.422-30.422V143.92c0-47.124 38.338-85.462 85.462-85.462h251.608c47.124 0 85.462 38.338 85.462 85.462v74.936h519.298c45.31 0 82.172 36.862 82.172 82.17v468.192c-0.002 16.804-13.624 30.424-30.424 30.424zM60.844 738.798h902.312V301.028c0-11.758-9.568-21.326-21.328-21.326h-549.72c-16.798 0-30.422-13.62-30.422-30.422V143.92c0-13.574-11.044-24.618-24.618-24.618H85.462c-13.574 0-24.618 11.044-24.618 24.618v594.878z" p-id="2368"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M11.4239 18.7025L2.67887 7.79998C2.44038 7.50298 2.44038 7.02099 2.67887 6.7235C2.79337 6.58048 2.94887 6.50049 3.11036 6.50049H20.8899C21.2269 6.50049 21.5004 6.84148 21.5004 7.26197C21.5004 7.46398 21.4359 7.65699 21.3214 7.79998L12.5764 18.703C12.2584 19.0995 11.7424 19.0995 11.4244 18.703L11.4239 18.7025Z"/>
</svg>

After

Width:  |  Height:  |  Size: 415 B

View File

@@ -0,0 +1,18 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon_member_filled">
<g id="Union">
<path d="M20 17.3333H12C8.3181 17.3333 4 19.9086 4 24.5333V28.1333C4 28.7961 4.59695 29.3333 5.33333 29.3333H26.6667C27.403 29.3333 28 28.7961 28 28.1333V24.5333C28 19.9047 23.6819 17.3333 20 17.3333Z" fill="url(#paint0_linear_2773_11384)"/>
<path d="M9.33333 9.33334C9.33333 13.0152 12.3181 16 16 16C19.6819 16 22.6667 13.0152 22.6667 9.33334C22.6667 5.65144 19.6819 2.66667 16 2.66667C12.3181 2.66667 9.33333 5.65144 9.33333 9.33334Z" fill="url(#paint1_linear_2773_11384)"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_2773_11384" x1="16" y1="2.66667" x2="16" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_2773_11384" x1="16" y1="2.66667" x2="16" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,9 @@
import { h } from 'vue'
import { ElIcon } from 'element-plus'
import Icon from './src/Icon.vue'
const hIcon = (name: string) => {
return h(ElIcon, null, {
default: () => h(name)
})
}
export { Icon, hIcon }

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps({
prefix: {
type: String,
default: 'icon'
},
name: {
type: String,
default: ''
},
className: {
type: String,
default: ''
},
staticContent: {
type: String,
default: ''
}
})
const svgClass = computed(() => {
if (props.className) {
return `svg-icon ${props.className}`
}
return 'svg-icon'
})
</script>
<template>
<div
class="svg-container"
v-if="staticContent"
v-html="staticContent"
:class="svgClass"
aria-hidden="true"
></div>
<slot v-else />
</template>
<style lang="less" scope>
.svg-icon {
width: 100%;
height: 100%;
display: block;
overflow: hidden;
fill: currentcolor;
}
.svg-container {
width: 100%;
height: 100%;
svg {
width: 100%;
height: 100%;
display: block;
overflow: hidden;
fill: currentcolor;
}
}
</style>

View File

@@ -0,0 +1,225 @@
<template>
<div class="app-container">
<div class="sidebar">
<div class="logo">SQLBot</div>
<div class="workspace-area">
<el-select
v-model="workspace"
placeholder="Select"
class="workspace-select"
size="large"
style="width: 240px"
>
<template #label="{ label }">
<div class="workspace-label">
<el-icon><folder /></el-icon>
<span>{{ label }}</span>
</div>
</template>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<el-menu
:default-active="activeMenu"
class="menu-container">
<el-menu-item v-for="item in routerList" :key="item.path" :index="item.path" @click="menuSelect">
<el-icon v-if="item.meta.icon">
<component :is="resolveIcon(item.meta.icon)" />
</el-icon>
<span>{{ item.meta.title }}</span>
</el-menu-item>
</el-menu>
</div>
<div class="main-content">
<div class="header-container">
<div class="header">
<h1>{{ currentPageTitle }}</h1>
<div class="header-actions">
<el-tooltip content="System manage" placement="bottom">
<el-button class="header-icon-btn">
<el-icon><setting /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="Help" placement="bottom">
<el-button class="header-icon-btn">
<el-icon><question-filled /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="Notice" placement="bottom">
<el-button class="header-icon-btn">
<el-icon><bell /></el-icon>
</el-button>
</el-tooltip>
<el-dropdown trigger="click">
<div class="user-info">
<el-avatar size="small">{{ name?.charAt(0) }}</el-avatar>
<span class="user-name">{{ name }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="logout">Logout</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<div class="page-content">
<router-view />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import folder from '@/assets/svg/folder.svg'
import ds from '@/assets/svg/ds.svg'
import dashboard from '@/assets/svg/dashboard.svg'
import chat from '@/assets/svg/chat.svg'
import {
Setting,
QuestionFilled,
Bell
} from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const name = ref('')
const activeMenu = computed(() => route.path)
const routerList = computed(() => {
return router.getRoutes().filter(route => {
return route.path !== '/login' && !route.redirect && route.path !== '/:pathMatch(.*)*'
})
})
const workspace = ref('1')
const options = [
{ value: '1', label: 'Default workspace' },
{ value: '2', label: 'Workspace 2' },
{ value: '3', label: 'Workspace 3' }
]
const currentPageTitle = computed(() => route.meta.title || 'Dashboard')
const resolveIcon = (iconName: any) => {
const icons: Record<string, any> = {
'ds': ds,
'dashboard': dashboard,
'chat': chat
}
return typeof icons[iconName] === 'function' ? icons[iconName]() : icons[iconName]
}
const menuSelect = (e: any) => {
console.log(routerList.value)
router.push(e.index)
}
const logout = () => {
userStore.logout()
router.push('/login')
}
</script>
<style lang="less" scoped>
.app-container {
display: flex;
height: 100vh;
.sidebar {
width: 240px;
background: #fff;
border-right: 1px solid #e6e6e6;
display: flex;
flex-direction: column;
.workspace-area {
margin: 8px 16px;
overflow: hidden;
.workspace-select {
width: 100% !important;
:deep(.el-select__wrapper) {
border-radius: 10px;
box-shadow: none !important;
background-color: #f1f3f4;
line-height: 32px;
min-height: 48px;
.el-select__selected-item {
height: 32px;
}
.workspace-label {
color: #2d2e31;
font-weight: 600;
display: flex;
column-gap: 8px;
align-items: center;
height: 32px;
}
}
}
}
.logo {
height: 68px;
line-height: 68px;
font-size: 24px;
font-weight: bold;
color: #4285f4;
text-align: left;
margin-left: 24px;
}
.menu-container {
flex: 1;
border-right: none;
}
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background-color: #f5f7fa;
padding: 24px;
box-sizing: border-box;
.header-container {
box-sizing: border-box;
margin: 0;
padding: 0;
height: 60px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
.header {
height: 36px;
line-height: 36px;
color: #202124;
h1 {
font-size: 24px;
font-weight: 500;
height: 36px;
line-height: 36px;
}
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
}
.page-content {
flex: 1;
overflow-y: auto;
}
}
}
</style>

12
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import router from './router'
// import 'element-plus/dist/index.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,58 @@
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/components/layout/index.vue'
import login from '@/views/login/index.vue'
import chat from '@/views/chat/index.vue'
import ds from '@/views/ds/index.vue'
import dashboard from '@/views/dashboard/index.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: login
},
{
path: '/chat',
component: Layout,
redirect: '/chat/index',
children: [
{
path: 'index',
name: 'chat',
component: chat,
meta: { title: 'ChatBI', icon: 'chat'}
}
]
},
{
path: '/ds',
component: Layout,
redirect: '/ds/index',
children: [
{
path: 'index',
name: 'ds',
component: ds,
meta: { title: 'Datasource', icon: 'ds' }
}
]
},
{
path: '/dashboard',
component: Layout,
redirect: '/dashboard/index',
children: [
{
path: 'index',
name: 'dashboard',
component: dashboard,
meta: { title: 'Dashboard', icon: 'dashboard' }
}
]
}
]
})
export default router

View File

@@ -0,0 +1,25 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const token = ref('')
const username = ref('')
const login = async (formData: { username: string; password: string }) => {
token.value = 'mock-token'
username.value = formData.username
return Promise.resolve()
}
const logout = () => {
token.value = ''
username.value = ''
}
return {
token,
username,
login,
logout
}
})

78
frontend/src/style.css Normal file
View File

@@ -0,0 +1,78 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
width: 100%;
margin: 0;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,52 @@
<template>
<div class="welcome-container">
<el-card class="welcome-card">
<h1 class="welcome-title">Welcome</h1>
<p class="welcome-message">You have successfully logged into the system and can now start using various functions.</p>
<el-button type="primary" @click="logout" class="logout-btn">Logout</el-button>
</el-card>
</div>
</template>
<script lang="ts" setup>
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const logout = () => {
userStore.logout()
router.push('/login')
}
</script>
<style lang="less" scoped>
.welcome-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f5f7fa;
.welcome-card {
width: 500px;
padding: 30px;
text-align: center;
.welcome-title {
color: #409eff;
margin-bottom: 20px;
}
.welcome-message {
margin-bottom: 30px;
font-size: 16px;
}
.logout-btn {
width: 200px;
}
}
}
</style>

View File

@@ -0,0 +1,3 @@
<template>
<h2>this is chatbi page</h2>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<h2>this is dashboard page</h2>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<h2>this is ds page</h2>
</template>

View File

@@ -0,0 +1,161 @@
<template>
<div class="login-container">
<div class="login-bg">
<div class="bg-overlay"></div>
<div class="bg-content">
<h1>Welcome back</h1>
<p>Embark on your ChatBI journey</p>
</div>
</div>
<div class="login-form-container">
<div class="login-form">
<el-card class="login-card">
<h2 class="login-title">Login</h2>
<el-form :model="loginForm" :rules="rules" ref="loginFormRef">
<el-form-item prop="username">
<el-input v-model="loginForm.username" placeholder="username" prefix-icon="user"></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="loginForm.password" placeholder="password" type="password" prefix-icon="lock"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm" class="login-btn">Login</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const loginForm = ref({
username: '',
password: ''
})
const rules = {
username: [{ required: true, message: 'Please input username', trigger: 'blur' }],
password: [{ required: true, message: 'Please input password', trigger: 'blur' }]
}
const loginFormRef = ref()
const submitForm = () => {
loginFormRef.value.validate((valid: boolean) => {
if (valid) {
userStore.login(loginForm.value).then(() => {
router.push('/chat')
})
}
})
}
</script>
<style lang="less" scoped>
.login-container {
display: flex;
height: 100vh;
width: 100%;
overflow: hidden;
.login-bg {
overflow: hidden;
height: 100%;
width: 40%;
min-width: 400px;
position: relative;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-size: cover;
display: flex;
justify-content: center;
align-items: center;
color: white;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('@/assets/login-desc-de.png') center/cover no-repeat;
opacity: 0.8;
}
.bg-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
}
.bg-content {
position: relative;
z-index: 2;
text-align: center;
padding: 0 40px;
h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
p {
font-size: 1.2rem;
opacity: 0.9;
}
}
}
.login-form-container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 60%;
min-width: 400px;
.login-form {
width: 500px;
display: flex;
justify-content: center;
align-items: center;
background: #fff;
padding: 0 40px;
.login-card {
width: 100%;
padding: 40px;
border: none;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
.login-title {
text-align: center;
margin-bottom: 30px;
color: #409eff;
font-size: 1.8rem;
}
.login-btn {
width: 100%;
height: 45px;
font-size: 1rem;
letter-spacing: 1px;
}
}
}
}
}
</style>

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

13
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["node"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

46
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,46 @@
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import path from 'path'
import svgLoader from 'vite-svg-loader'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd())
console.log(mode)
console.log(env)
return {
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
svgLoader()
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
css: {
preprocessorOptions: {
less: {
javascriptEnabled: true,
},
},
},
build: {
chunkSizeWarningLimit: 2000,
rollupOptions: {
output: {
manualChunks: {
'element-plus': ['element-plus'],
},
},
},
},
}
})