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 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 backend.db.db_setup import SessionLocal
Model = declarative_base()
Model.query = SessionLocal.query_property()
class SqlAlchemyBase(Model):
__abstract__ = True
query: QueryPropertyDescriptor = SessionLocal.query_property()
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())

View File

@ -1,7 +1,7 @@
from backend.core.config import get_app_settings
from backend.core.security.security import hash_password
from backend.db.models import User
from backend.schemas import UserCreate
from backend.schemas import UserCreate, UserProfile
from sqlalchemy.orm import Session
from uuid import UUID
@ -20,7 +20,7 @@ class RepositoryUsers:
return self.user.query.filter_by(username=username).first()
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):
try:
@ -34,3 +34,18 @@ class RepositoryUsers:
db.refresh(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 import MessageCode
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
@ -29,14 +29,6 @@ async def get_token(form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
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])
def user_login(user: UserRequest, response: Response) -> ReturnValue[Any]:
db_user = user_service.check_exist(user=user)

View File

@ -1,11 +1,12 @@
from typing import Annotated, Any
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from backend.core.config import get_app_settings
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.schemas.common import ReturnValue
from backend.schemas.user import ProfileResponse
from backend.schemas.user import ProfileResponse, UserCreate, UserProfile
from backend.services.user import UserService
@ -16,6 +17,19 @@ settings = get_app_settings()
db_dependency = Annotated[Session, Depends(generate_session)]
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])
def get_user(current_user: current_user_token) -> ReturnValue[Any]:
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 fastapi import Form
from backend.core.security.security import hash_password
from backend.schemas.main_model import MainModel
class UserBase(MainModel):
@ -14,6 +15,16 @@ class UserRequest(UserBase):
class UserCreate(UserRequest):
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):
is_admin: bool
is_lock: bool

View File

@ -10,12 +10,20 @@ class UserService(BaseService):
def __init__(self):
self.repos = RepositoryUsers()
def get_all(self, skip: int = 0, limit: int = 100):
return self.repos.get_all(skip=skip, limit=limit)
def generate_token(self, user_id: str):
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):
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):
return self.repos.get_by_id(user_id)
@ -23,6 +31,7 @@ class UserService(BaseService):
return self.repos.create(db=db, user=user)
def check_exist(self, user: UserRequest):
print(f"user: {user}")
db_user = self.get_by_username(username=user.username)
if not db_user:
return False
@ -30,10 +39,5 @@ class UserService(BaseService):
return False
return db_user
def generate_token(self, user_id: str):
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_access_token(self, user_id: str):
return create_access_token(data={"sub": str(user_id)})
def update(self, db: Session, user: UserCreate, user_id: str):
return self.repos.update(db=db, user=user, user_id=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_REFRESH = '/api/auth/refresh'
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 { GET_USER_PROFILE } from './url'
import { GET_USER_PROFILE, PUT_UPDATE_USER_PROFILE } from './url'
export const getProfile = () => {
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 clickLogIn = async (username, password, cbFormReset) => {
const loginData = {
username: username,
password: password,
}
const resp = await postLogin(loginData)
const resp = await postLogin({ username, password })
if (resp.status === 200) {
const token = resp.data || {}

View File

@ -6,7 +6,13 @@
"logout": "Đăng xuất",
"dashboard": "Bảng điều khiển",
"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": {
"CREATED_USER": "Username already registered!",

View File

@ -1,13 +1,15 @@
import { useSiteContext } from '@context/SiteContext'
import useLanguage from '@hooks/useLanguage'
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 { Show, onMount } from 'solid-js'
import { onMount } from 'solid-js'
import * as yup from 'yup'
import './login.scss'
import Logo from '@assets/logo.svg'
import TextInput from '@components/common/TextInput'
import useAuth from '@hooks/useAuth'
import useToast from '@hooks/useToast'
@ -66,79 +68,18 @@ export default function Login() {
<div class="card-body">
<h1 class="card-title">{language.ui.login}</h1>
<form autoComplete="off" onSubmit={submit}>
<Field
mode="input"
<TextInput
name="username"
placeholder="Username"
icon={IconUser}
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"
{...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>
</label>
)}
/>
<Field
mode="input"
<TextInput
name="password"
type="password"
placeholder="Password"
icon={IconKey}
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>
<Show when={field.helpers.error}>
<div class="label">
<span class="label-text-alt text-red-600">
{field.helpers.errorMessage}
</span>
</div>
</Show>
</label>
)}
/>
<div class="card-actions justify-end mt-5">
<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))
: defaultValue
}
static clearObject = (object) => {
for (var propName in object) {
if (object[propName] === null || object[propName] === undefined) {
delete object[propName]
}
}
return object
}
}