Merge pull request 'feature/profile' (#3) from feature/profile into main

Reviewed-on: sam/fuware#3
This commit is contained in:
Sam Liu 2024-06-12 02:45:43 +00:00
commit 59efe83d76
17 changed files with 250 additions and 105 deletions

View File

@ -1,16 +1,16 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime from sqlalchemy import DateTime
from sqlalchemy.orm import declarative_base, Mapped, mapped_column from sqlalchemy.orm import declarative_base, Mapped, mapped_column, QueryPropertyDescriptor
from text_unidecode import unidecode from text_unidecode import unidecode
from backend.db.db_setup import SessionLocal from backend.db.db_setup import SessionLocal
Model = declarative_base() Model = declarative_base()
Model.query = SessionLocal.query_property()
class SqlAlchemyBase(Model): class SqlAlchemyBase(Model):
__abstract__ = True __abstract__ = True
query: QueryPropertyDescriptor = SessionLocal.query_property()
created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow(), index=True) created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow(), index=True)
updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow(), onupdate=datetime.utcnow()) updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow(), onupdate=datetime.utcnow())

View File

@ -1,7 +1,7 @@
from backend.core.config import get_app_settings from backend.core.config import get_app_settings
from backend.core.security.security import hash_password from backend.core.security.security import hash_password
from backend.db.models import User from backend.db.models import User
from backend.schemas import UserCreate from backend.schemas import UserCreate, UserProfile
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from uuid import UUID from uuid import UUID
@ -20,7 +20,7 @@ class RepositoryUsers:
return self.user.query.filter_by(username=username).first() return self.user.query.filter_by(username=username).first()
def get_by_id(self, user_id: str): def get_by_id(self, user_id: str):
return self.user.query.filter_by(id=UUID(user_id)).first() return self.user.query.filter_by(id=UUID(str(user_id))).one()
def create(self, db: Session, user: UserCreate | UserSeeds): def create(self, db: Session, user: UserCreate | UserSeeds):
try: try:
@ -34,3 +34,18 @@ class RepositoryUsers:
db.refresh(db_user) db.refresh(db_user)
return db_user return db_user
def update(self, db: Session, user: UserProfile, user_id: str):
db_user = self.get_by_id(user_id)
if not db_user:
return None
try:
user.update_password()
self.user.query.where(User.id == user_id).update(user.dict(exclude_unset=True, exclude_none=True))
db.commit()
except Exception:
db.rollback()
raise
db.refresh(db_user)
return db_user

View File

@ -9,7 +9,7 @@ from backend.core.config import get_app_settings
from backend.core.dependencies.dependencies import get_current_user from backend.core.dependencies.dependencies import get_current_user
from backend.core import MessageCode from backend.core import MessageCode
from backend.db.db_setup import generate_session from backend.db.db_setup import generate_session
from backend.schemas import ReturnValue, UserRequest, LoginResponse, UserCreate, PrivateUser from backend.schemas import ReturnValue, UserRequest, LoginResponse, PrivateUser
from backend.services.user import UserService from backend.services.user import UserService
@ -29,14 +29,6 @@ async def get_token(form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
return {'access_token': token, 'token_type': 'bearer'} return {'access_token': token, 'token_type': 'bearer'}
@auth_router.put('/register')
def register_user(user: UserCreate, db: db_dependency) -> ReturnValue[Any]:
db_user = user_service.get_by_username(username=user.username)
if db_user:
raise HTTPException(status_code=400, detail=MessageCode.CREATED_USER)
user_service.create(db=db, user=user)
return ReturnValue(status=200, data="created")
@auth_router.post('/login', response_model=ReturnValue[LoginResponse]) @auth_router.post('/login', response_model=ReturnValue[LoginResponse])
def user_login(user: UserRequest, response: Response) -> ReturnValue[Any]: def user_login(user: UserRequest, response: Response) -> ReturnValue[Any]:
db_user = user_service.check_exist(user=user) db_user = user_service.check_exist(user=user)

View File

@ -1,11 +1,12 @@
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.core.config import get_app_settings from backend.core.config import get_app_settings
from backend.core.dependencies import is_logged_in from backend.core.dependencies import is_logged_in
from backend.core.message_code import MessageCode
from backend.db.db_setup import generate_session from backend.db.db_setup import generate_session
from backend.schemas.common import ReturnValue from backend.schemas.common import ReturnValue
from backend.schemas.user import ProfileResponse from backend.schemas.user import ProfileResponse, UserCreate, UserProfile
from backend.services.user import UserService from backend.services.user import UserService
@ -16,6 +17,19 @@ settings = get_app_settings()
db_dependency = Annotated[Session, Depends(generate_session)] db_dependency = Annotated[Session, Depends(generate_session)]
current_user_token = Annotated[ProfileResponse, Depends(is_logged_in)] current_user_token = Annotated[ProfileResponse, Depends(is_logged_in)]
@public_router.put('/register')
def register_user(user: UserCreate, db: db_dependency) -> ReturnValue[Any]:
db_user = user_service.get_by_username(username=user.username)
if db_user:
raise HTTPException(status_code=400, detail=MessageCode.CREATED_USER)
user_service.create(db=db, user=user)
return ReturnValue(status=200, data="created")
@public_router.get("/me", response_model=ReturnValue[ProfileResponse]) @public_router.get("/me", response_model=ReturnValue[ProfileResponse])
def get_user(current_user: current_user_token) -> ReturnValue[Any]: def get_user(current_user: current_user_token) -> ReturnValue[Any]:
return ReturnValue(status=200, data=current_user) return ReturnValue(status=200, data=current_user)
@public_router.put("/update-profile", response_model=ReturnValue[ProfileResponse])
def update_user(user: UserProfile, current_user: current_user_token, db: db_dependency) -> ReturnValue[Any]:
db_user = user_service.update(db=db, user=user, user_id=current_user.id)
return ReturnValue(status=200, data=db_user)

View File

@ -3,6 +3,7 @@ from uuid import UUID
from pydantic import ConfigDict from pydantic import ConfigDict
from fastapi import Form from fastapi import Form
from backend.core.security.security import hash_password
from backend.schemas.main_model import MainModel from backend.schemas.main_model import MainModel
class UserBase(MainModel): class UserBase(MainModel):
@ -14,6 +15,16 @@ class UserRequest(UserBase):
class UserCreate(UserRequest): class UserCreate(UserRequest):
name: str name: str
class UserProfile(MainModel):
name: str | None = None
password: str | None = None
is_admin: bool | None = None
is_lock: bool | None = None
model_config = ConfigDict(from_attributes=True)
def update_password(self):
self.password = (None if self.password is None else hash_password(self.password))
class UserSeeds(UserCreate): class UserSeeds(UserCreate):
is_admin: bool is_admin: bool
is_lock: bool is_lock: bool

View File

@ -10,12 +10,20 @@ class UserService(BaseService):
def __init__(self): def __init__(self):
self.repos = RepositoryUsers() self.repos = RepositoryUsers()
def get_all(self, skip: int = 0, limit: int = 100): def generate_token(self, user_id: str):
return self.repos.get_all(skip=skip, limit=limit) access_token = create_access_token(data={"sub": str(user_id)})
refresh_token = create_refresh_token(data={"sub": str(user_id)})
return access_token, refresh_token
def get_by_username(self, username: str): def get_by_username(self, username: str):
return self.repos.get_by_username(username) return self.repos.get_by_username(username)
def get_access_token(self, user_id: str):
return create_access_token(data={"sub": str(user_id)})
def get_all(self, skip: int = 0, limit: int = 100):
return self.repos.get_all(skip=skip, limit=limit)
def get_by_id(self, user_id: str): def get_by_id(self, user_id: str):
return self.repos.get_by_id(user_id) return self.repos.get_by_id(user_id)
@ -23,6 +31,7 @@ class UserService(BaseService):
return self.repos.create(db=db, user=user) return self.repos.create(db=db, user=user)
def check_exist(self, user: UserRequest): def check_exist(self, user: UserRequest):
print(f"user: {user}")
db_user = self.get_by_username(username=user.username) db_user = self.get_by_username(username=user.username)
if not db_user: if not db_user:
return False return False
@ -30,10 +39,5 @@ class UserService(BaseService):
return False return False
return db_user return db_user
def generate_token(self, user_id: str): def update(self, db: Session, user: UserCreate, user_id: str):
access_token = create_access_token(data={"sub": str(user_id)}) return self.repos.update(db=db, user=user, user_id=user_id)
refresh_token = create_refresh_token(data={"sub": str(user_id)})
return access_token, refresh_token
def get_access_token(self, user_id: str):
return create_access_token(data={"sub": str(user_id)})

View File

@ -2,3 +2,4 @@ export const POST_LOGIN = '/api/auth/login'
export const POST_LOGOUT = '/api/auth/logout' export const POST_LOGOUT = '/api/auth/logout'
export const POST_REFRESH = '/api/auth/refresh' export const POST_REFRESH = '/api/auth/refresh'
export const GET_USER_PROFILE = '/api/user/me' export const GET_USER_PROFILE = '/api/user/me'
export const PUT_UPDATE_USER_PROFILE = '/api/user/update-profile'

View File

@ -1,6 +1,10 @@
import { protocol } from './index' import { protocol } from './index'
import { GET_USER_PROFILE } from './url' import { GET_USER_PROFILE, PUT_UPDATE_USER_PROFILE } from './url'
export const getProfile = () => { export const getProfile = () => {
return protocol.get(GET_USER_PROFILE, {}) return protocol.get(GET_USER_PROFILE, {})
} }
export const putUpdateProfile = (payload) => {
return protocol.put(PUT_UPDATE_USER_PROFILE, payload)
}

View File

@ -0,0 +1,39 @@
import { Field } from 'solid-form-handler'
import { Show, splitProps } from 'solid-js'
import { Dynamic } from 'solid-js/web'
export default function Finput(props) {
const [local, rest] = splitProps(props, ['label', 'icon'])
return (
<Field
{...props}
mode="input"
render={(field) => (
<div class="form-control w-full [&:not(:last-child)]:pb-3">
<Show when={local.label}>
<div class="label">
<span class="label-text">{local.label}</span>
</div>
</Show>
<label
class="input input-bordered flex items-center gap-2 w-full"
classList={{ 'input-error': field.helpers.error }}
>
<Show when={local.icon}>
<Dynamic component={local.icon} size={18} />
</Show>
<input {...rest} class="grow w-full" {...field.props} />
</label>
<Show when={field.helpers.error}>
<div class="label">
<span class="label-text-alt text-red-600">
{field.helpers.errorMessage}
</span>
</div>
</Show>
</div>
)}
/>
)
}

View File

@ -0,0 +1 @@
export { default } from './TextInput'

View File

@ -7,12 +7,7 @@ export default function useAuth(setAuth) {
const navigate = useNavigate() const navigate = useNavigate()
const clickLogIn = async (username, password, cbFormReset) => { const clickLogIn = async (username, password, cbFormReset) => {
const loginData = { const resp = await postLogin({ username, password })
username: username,
password: password,
}
const resp = await postLogin(loginData)
if (resp.status === 200) { if (resp.status === 200) {
const token = resp.data || {} const token = resp.data || {}

View File

@ -6,7 +6,13 @@
"logout": "Đăng xuất", "logout": "Đăng xuất",
"dashboard": "Bảng điều khiển", "dashboard": "Bảng điều khiển",
"profile": "Hồ sơ", "profile": "Hồ sơ",
"houses": "Kho" "changeInfo": "Đổi thông tin",
"save": "Lưu",
"clear": "Xóa",
"houses": "Kho",
"displayName": "Display Name",
"newPassword": "New Password",
"confirmNewPassword": "Confirm New Password"
}, },
"message": { "message": {
"CREATED_USER": "Username already registered!", "CREATED_USER": "Username already registered!",

View File

@ -1,13 +1,15 @@
import { useSiteContext } from '@context/SiteContext' import { useSiteContext } from '@context/SiteContext'
import useLanguage from '@hooks/useLanguage' import useLanguage from '@hooks/useLanguage'
import { useNavigate } from '@solidjs/router' import { useNavigate } from '@solidjs/router'
import { Field, useFormHandler } from 'solid-form-handler' import { IconKey, IconUser } from '@tabler/icons-solidjs'
import { useFormHandler } from 'solid-form-handler'
import { yupSchema } from 'solid-form-handler/yup' import { yupSchema } from 'solid-form-handler/yup'
import { Show, onMount } from 'solid-js' import { onMount } from 'solid-js'
import * as yup from 'yup' import * as yup from 'yup'
import './login.scss' import './login.scss'
import Logo from '@assets/logo.svg' import Logo from '@assets/logo.svg'
import TextInput from '@components/common/TextInput'
import useAuth from '@hooks/useAuth' import useAuth from '@hooks/useAuth'
import useToast from '@hooks/useToast' import useToast from '@hooks/useToast'
@ -66,79 +68,18 @@ export default function Login() {
<div class="card-body"> <div class="card-body">
<h1 class="card-title">{language.ui.login}</h1> <h1 class="card-title">{language.ui.login}</h1>
<form autoComplete="off" onSubmit={submit}> <form autoComplete="off" onSubmit={submit}>
<Field <TextInput
mode="input"
name="username" name="username"
formHandler={formHandler}
render={(field) => (
<label class="form-control w-full pb-5">
<label
class="input input-bordered flex items-center gap-2 w-full"
classList={{ 'input-error': field.helpers.error }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4 opacity-70"
>
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12.735 14c.618 0 1.093-.561.872-1.139a6.002 6.002 0 0 0-11.215 0c-.22.578.254 1.139.872 1.139h9.47Z" />
</svg>
<input
id="username"
type="text"
class="grow w-full"
placeholder="Username" placeholder="Username"
{...field.props} icon={IconUser}
/>
</label>
<Show when={field.helpers.error}>
<div class="label">
<span class="label-text-alt text-red-600">
{field.helpers.errorMessage}
</span>
</div>
</Show>
</label>
)}
/>
<Field
mode="input"
name="password"
formHandler={formHandler} formHandler={formHandler}
render={(field) => (
<label class="form-control w-full">
<label
class="input input-bordered flex items-center gap-2 w-full"
classList={{ 'input-error': field.helpers.error }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4 opacity-70"
>
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12.735 14c.618 0 1.093-.561.872-1.139a6.002 6.002 0 0 0-11.215 0c-.22.578.254 1.139.872 1.139h9.47Z" />
</svg>
<input
id="password"
type="password"
class="grow w-full"
placeholder="Password"
{...field.props}
/> />
</label> <TextInput
name="password"
<Show when={field.helpers.error}> type="password"
<div class="label"> placeholder="Password"
<span class="label-text-alt text-red-600"> icon={IconKey}
{field.helpers.errorMessage} formHandler={formHandler}
</span>
</div>
</Show>
</label>
)}
/> />
<div class="card-actions justify-end mt-5"> <div class="card-actions justify-end mt-5">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">

View File

@ -1,3 +0,0 @@
export default function Profile() {
return <>Profile</>
}

View File

@ -0,0 +1,115 @@
import { putUpdateProfile } from '@api/user'
import TextInput from '@components/common/TextInput'
import { useSiteContext } from '@context/SiteContext'
import useLanguage from '@hooks/useLanguage'
import useToast from '@hooks/useToast'
import { IconKey, IconLetterN, IconUserCircle } from '@tabler/icons-solidjs'
import { Helpers } from '@utils/helper'
import { useFormHandler } from 'solid-form-handler'
import { yupSchema } from 'solid-form-handler/yup'
import { createEffect } from 'solid-js'
import * as yup from 'yup'
const profileSchema = yup.object({
name: yup.string().required('Name is required'),
password: yup.string().nullable().optional(),
'confirm-password': yup.string().when('password', {
is: (val) => !!(val && val.length > 0),
then: (schema) =>
schema.oneOf([yup.ref('password'), null], 'Passwords must match'),
}),
})
const language = useLanguage()
const notify = useToast()
export default function Profile() {
const {
store: { userInfo },
setUser,
} = useSiteContext()
const formHandler = useFormHandler(yupSchema(profileSchema))
const { formData } = formHandler
createEffect(() => {
formHandler.fillForm({
name: userInfo?.name,
})
})
const submit = async (event) => {
event.preventDefault()
await formHandler.validateForm()
try {
const { name, password } = formData()
const clearObj = Helpers.clearObject({
name: name || null,
password: password || null,
})
const resp = await putUpdateProfile(clearObj)
if (resp.status === 200) {
setUser(resp.data)
formHandler.setFieldValue('password', '')
formHandler.setFieldValue('confirm-password', '')
notify.success({
title: 'Update profile success!',
description: 'Your profile has been updated!',
})
}
} catch (error) {
notify.error({
title: 'Update profile fail!',
description: error?.data
? language.message[error.data]
: 'Your username or password input is wrong!',
closable: true,
})
}
}
return (
<div class="profile">
<div class="divider divider-accent text-xl">
<span class="text-green-400">
<IconUserCircle size={30} />
</span>
{language.ui.profile}
</div>
<div class="card w-full bg-base-100 shadow-lg border border-gray-200">
<div class="card-body">
<form autoComplete="off" onSubmit={submit}>
<p class="card-title">{language.ui.changeInfo}</p>
<div class="form-content py-5">
<TextInput
icon={IconLetterN}
name="name"
placeholder={language.ui.displayName}
formHandler={formHandler}
/>
<TextInput
icon={IconKey}
name="password"
type="password"
placeholder={language.ui.newPassword}
formHandler={formHandler}
/>
<TextInput
icon={IconKey}
name="confirm-password"
type="password"
placeholder={language.ui.confirmNewPassword}
formHandler={formHandler}
/>
</div>
<div class="card-actions">
<button type="submit" class="btn btn-primary">
{language.ui.save}
</button>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1 @@
export { default } from './Profile'

View File

@ -47,4 +47,13 @@ export class Helpers {
? JSON.parse(AES.decrypt(hash, SECRET_KEY).toString(enc.Utf8)) ? JSON.parse(AES.decrypt(hash, SECRET_KEY).toString(enc.Utf8))
: defaultValue : defaultValue
} }
static clearObject = (object) => {
for (var propName in object) {
if (object[propName] === null || object[propName] === undefined) {
delete object[propName]
}
}
return object
}
} }