chore: Initialize project
27
.env.example
Normal 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
@@ -0,0 +1,2 @@
|
||||
* text=auto
|
||||
*.sh text eol=lf
|
||||
9
.gitignore
vendored
@@ -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
@@ -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
@@ -0,0 +1,8 @@
|
||||
# Python
|
||||
__pycache__
|
||||
app.egg-info
|
||||
*.pyc
|
||||
.mypy_cache
|
||||
.coverage
|
||||
htmlcov
|
||||
.venv
|
||||
10
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
__pycache__
|
||||
app.egg-info
|
||||
*.pyc
|
||||
.mypy_cache
|
||||
.coverage
|
||||
htmlcov
|
||||
.cache
|
||||
.venv
|
||||
*.egg
|
||||
*.lock
|
||||
1
backend/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# FastAPI Project - Backend
|
||||
71
backend/alembic.ini
Executable 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
1
backend/app/alembic/README
Executable file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
84
backend/app/alembic/env.py
Executable 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()
|
||||
25
backend/app/alembic/script.py.mako
Executable 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"}
|
||||
0
backend/app/alembic/versions/.keep
Executable 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 ###
|
||||
@@ -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)
|
||||
@@ -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'])
|
||||
@@ -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 ###
|
||||
0
backend/app/api/__init__.py
Normal file
57
backend/app/api/deps.py
Normal 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
@@ -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)
|
||||
0
backend/app/api/routes/__init__.py
Normal file
109
backend/app/api/routes/items.py
Normal 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")
|
||||
72
backend/app/api/routes/login.py
Normal 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")
|
||||
38
backend/app/api/routes/private.py
Normal 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
|
||||
199
backend/app/api/routes/users.py
Normal 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")
|
||||
14
backend/app/api/routes/utils.py
Normal 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
|
||||
39
backend/app/backend_pre_start.py
Normal 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()
|
||||
0
backend/app/core/__init__.py
Normal file
120
backend/app/core/config.py
Normal 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
@@ -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)
|
||||
27
backend/app/core/security.py
Normal 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
@@ -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
|
||||
23
backend/app/initial_data.py
Normal 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
@@ -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
@@ -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)
|
||||
0
backend/app/tests/__init__.py
Normal file
0
backend/app/tests/api/__init__.py
Normal file
0
backend/app/tests/api/routes/__init__.py
Normal file
164
backend/app/tests/api/routes/test_items.py
Normal 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"
|
||||
118
backend/app/tests/api/routes/test_login.py
Normal 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"
|
||||
26
backend/app/tests/api/routes/test_private.py
Normal 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"
|
||||
486
backend/app/tests/api/routes/test_users.py
Normal 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"
|
||||
42
backend/app/tests/conftest.py
Normal 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
|
||||
)
|
||||
0
backend/app/tests/crud/__init__.py
Normal file
91
backend/app/tests/crud/test_user.py
Normal 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)
|
||||
0
backend/app/tests/scripts/__init__.py
Normal file
33
backend/app/tests/scripts/test_backend_pre_start.py
Normal 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."
|
||||
33
backend/app/tests/scripts/test_test_pre_start.py
Normal 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."
|
||||
0
backend/app/tests/utils/__init__.py
Normal file
16
backend/app/tests/utils/item.py
Normal 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)
|
||||
49
backend/app/tests/utils/user.py
Normal 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)
|
||||
26
backend/app/tests/utils/utils.py
Normal 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
|
||||
39
backend/app/tests_pre_start.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
mypy app
|
||||
ruff check app
|
||||
ruff format app --check
|
||||
13
backend/scripts/prestart.sh
Normal 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
@@ -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}"
|
||||
7
backend/scripts/tests-start.sh
Normal file
@@ -0,0 +1,7 @@
|
||||
#! /usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
python app/tests_pre_start.py
|
||||
|
||||
bash scripts/test.sh "$@"
|
||||
2
frontend/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=http://localhost:3000
|
||||
VITE_APP_TITLE=SQLBot (Development)
|
||||
2
frontend/.env.production
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=https://api.SQLBot.com
|
||||
VITE_APP_TITLE=SQLBot
|
||||
24
frontend/.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
BIN
frontend/src/assets/login-desc-de.png
Normal file
|
After Width: | Height: | Size: 325 KiB |
BIN
frontend/src/assets/login-desc.png
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
frontend/src/assets/logo-bg.jpg
Normal file
|
After Width: | Height: | Size: 561 KiB |
1
frontend/src/assets/svg/chat.svg
Normal 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 |
1
frontend/src/assets/svg/dashboard.svg
Normal 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 |
1
frontend/src/assets/svg/ds.svg
Normal 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 |
1
frontend/src/assets/svg/folder.svg
Normal 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 |
3
frontend/src/assets/svg/icon_expand-down_filled.svg
Normal 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 |
18
frontend/src/assets/svg/user-img.svg
Normal 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 |
1
frontend/src/assets/vue.svg
Normal 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 |
9
frontend/src/components/icon-custom/index.ts
Normal 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 }
|
||||
60
frontend/src/components/icon-custom/src/Icon.vue
Normal 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>
|
||||
225
frontend/src/components/layout/index.vue
Normal 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
@@ -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')
|
||||
58
frontend/src/router/index.ts
Normal 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
|
||||
25
frontend/src/stores/user.ts
Normal 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
@@ -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;
|
||||
}
|
||||
}
|
||||
52
frontend/src/views/WelcomeView.vue
Normal 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>
|
||||
3
frontend/src/views/chat/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<h2>this is chatbi page</h2>
|
||||
</template>
|
||||
3
frontend/src/views/dashboard/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<h2>this is dashboard page</h2>
|
||||
</template>
|
||||
3
frontend/src/views/ds/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<h2>this is ds page</h2>
|
||||
</template>
|
||||
161
frontend/src/views/login/index.vue
Normal 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
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
18
frontend/tsconfig.app.json
Normal 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
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
25
frontend/tsconfig.node.json
Normal 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
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||