From fa1b55d68b66b535e906ccfcaa00cece3a1117e4 Mon Sep 17 00:00:00 2001 From: jwkim Date: Tue, 25 Jun 2024 14:25:29 +0900 Subject: [PATCH] first commit --- MYSQL/docker-compose.yml | 22 + README.md | 4 +- docker-compose.yml | 42 ++ fast_api/.dockerignore | 9 + fast_api/.travis.yml | 49 ++ fast_api/Dockerfile | 32 ++ fast_api/README.md | 43 ++ fast_api/app/api_request_sample.py | 46 ++ fast_api/app/common/config.py | 110 ++++ fast_api/app/common/consts.py | 94 ++++ fast_api/app/database/conn.py | 144 +++++ fast_api/app/database/crud.py | 149 +++++ fast_api/app/database/schema.py | 272 +++++++++ fast_api/app/errors/__init__.py | 0 fast_api/app/errors/exceptions.py | 188 +++++++ fast_api/app/main.py | 84 +++ fast_api/app/middlewares/token_validator.py | 166 ++++++ fast_api/app/middlewares/trusted_hosts.py | 63 +++ fast_api/app/models.py | 590 ++++++++++++++++++++ fast_api/app/routes/auth.py | 195 +++++++ fast_api/app/routes/dev.py | 235 ++++++++ fast_api/app/routes/index.py | 32 ++ fast_api/app/routes/services.py | 384 +++++++++++++ fast_api/app/routes/users.py | 275 +++++++++ fast_api/app/utils/date_utils.py | 49 ++ fast_api/app/utils/extra.py | 19 + fast_api/app/utils/logger.py | 65 +++ fast_api/app/utils/query_utils.py | 22 + fast_api/docker-build.sh | 2 + fast_api/docker-compose.yml | 18 + fast_api/environment.yml | 10 + fast_api/gunicorn.conf.py | 222 ++++++++ fast_api/requirements.txt | 14 + fast_api/test_main.py | 18 + fast_api/tests/__init__.py | 0 fast_api/tests/conftest.py | 79 +++ fast_api/tests/test_auth.py | 44 ++ fast_api/tests/test_user.py | 33 ++ readme.md | 27 + 39 files changed, 3848 insertions(+), 2 deletions(-) create mode 100755 MYSQL/docker-compose.yml create mode 100644 docker-compose.yml create mode 100755 fast_api/.dockerignore create mode 100755 fast_api/.travis.yml create mode 100755 fast_api/Dockerfile create mode 100644 fast_api/README.md create mode 100755 fast_api/app/api_request_sample.py create mode 100755 fast_api/app/common/config.py create mode 100755 fast_api/app/common/consts.py create mode 100755 fast_api/app/database/conn.py create mode 100755 fast_api/app/database/crud.py create mode 100755 fast_api/app/database/schema.py create mode 100755 fast_api/app/errors/__init__.py create mode 100755 fast_api/app/errors/exceptions.py create mode 100755 fast_api/app/main.py create mode 100755 fast_api/app/middlewares/token_validator.py create mode 100755 fast_api/app/middlewares/trusted_hosts.py create mode 100755 fast_api/app/models.py create mode 100755 fast_api/app/routes/auth.py create mode 100644 fast_api/app/routes/dev.py create mode 100755 fast_api/app/routes/index.py create mode 100755 fast_api/app/routes/services.py create mode 100755 fast_api/app/routes/users.py create mode 100755 fast_api/app/utils/date_utils.py create mode 100644 fast_api/app/utils/extra.py create mode 100755 fast_api/app/utils/logger.py create mode 100755 fast_api/app/utils/query_utils.py create mode 100644 fast_api/docker-build.sh create mode 100644 fast_api/docker-compose.yml create mode 100755 fast_api/environment.yml create mode 100755 fast_api/gunicorn.conf.py create mode 100755 fast_api/requirements.txt create mode 100755 fast_api/test_main.py create mode 100755 fast_api/tests/__init__.py create mode 100755 fast_api/tests/conftest.py create mode 100755 fast_api/tests/test_auth.py create mode 100755 fast_api/tests/test_user.py create mode 100644 readme.md diff --git a/MYSQL/docker-compose.yml b/MYSQL/docker-compose.yml new file mode 100755 index 0000000..60edc3c --- /dev/null +++ b/MYSQL/docker-compose.yml @@ -0,0 +1,22 @@ + mysql: + image: mysql:latest + container_name: mysql + restart: always + environment: + TZ: Asia/Seoul + MYSQL_ROOT_PASSWORD: 1234 + MYSQL_DATABASE: medical_metaverse + MYSQL_USER: medical_metaverse + MYSQL_PASSWORD: 1234 + # healthcheck: + # test: ["CMD", "echo 'SELECT version();'| mysql"] + # timeout: 5s + # retries: 5 + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + volumes: + - /MYSQL/data/:/var/lib/mysql + - /MYSQL/backup:/backupfiles + ports: + - 3306:3306 \ No newline at end of file diff --git a/README.md b/README.md index 064cc16..f53ac23 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# A2TEC_METAVERSE_HOSPITAL_REST_SERVER +# A2TEC_METAVERSE_MEDICAL_REST_SERVER -A2TEC_METAVERSE_HOSPITAL_REST_SERVER \ No newline at end of file +A2TEC_METAVERSE_MEDICAL_REST_SERVER \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..648c2ce --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +version: '3.7' +services: + api: + depends_on: + mysql: + condition: service_healthy + container_name: metaverse_medical_rest + image: metaverse/medical_rest:latest + build: + context: ./fast_api/ + environment: + - TZ=Asia/Seoul + volumes: + - ./fast_api:/FAST_API + ports: + - 50510-50532:50510-50532 + command: ["uvicorn", "app.main:app", "--host", '0.0.0.0', "--port", "50510"] #local + # command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "50532"] #my + + mysql: + image: mysql:latest + container_name: mysql + restart: always + environment: + TZ: Asia/Seoul + MYSQL_ROOT_PASSWORD: 1234 + MYSQL_DATABASE: medical_metaverse + MYSQL_USER: medical_metaverse + MYSQL_PASSWORD: 1234 + healthcheck: + test: "mysql -uroot -p$$MYSQL_ROOT_PASSWORD -e \"SHOW DATABASES;\"" + timeout: 5s + retries: 5 + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + volumes: + - ./MYSQL/data/:/var/lib/mysql + - ./MYSQL/backup:/backupfiles + ports: + - 3306:3306 + diff --git a/fast_api/.dockerignore b/fast_api/.dockerignore new file mode 100755 index 0000000..50f9578 --- /dev/null +++ b/fast_api/.dockerignore @@ -0,0 +1,9 @@ +.git/ +.gitignore +.idea/ +README.md +Dockerfile +__pycache__ +docker-compose.yml +venv/ +tests/ diff --git a/fast_api/.travis.yml b/fast_api/.travis.yml new file mode 100755 index 0000000..dfa0f3a --- /dev/null +++ b/fast_api/.travis.yml @@ -0,0 +1,49 @@ +os: linux +dist: bionic +language: python +services: + - docker + - mysql +python: + - 3.8 +before_install: + - pip install awscli + - export PATH=$PATH:$HOME/.local/bin +script: + - pytest -v +before_deploy: + - if [ $TRAVIS_BRANCH == "master" ]; then export EB_ENV=notification-prd-api; fi + - if [ $TRAVIS_BRANCH == "develop" ]; then export EB_ENV=notification-dev-api; fi + - export REPO_NAME=$(echo $TRAVIS_REPO_SLUG | sed "s_^.*/__") + - export ELASTIC_BEANSTALK_LABEL=${REPO_NAME}-${TRAVIS_COMMIT::7}-$(date +%y%m%d%H%M%S) +deploy: + skip_cleanup: true + provider: elasticbeanstalk + access_key_id: $AWS_ACCESS + secret_access_key: $AWS_SECRET + region: ap-northeast-2 + bucket: elasticbeanstalk-ap-northeast-2-884465654078 + bucket_path: notification-api + app: notification-api + env: $EB_ENV + on: + all_branches: true + condition: $TRAVIS_BRANCH =~ ^develop|master + +#notifications: +# slack: +# - rooms: +# - secure: *********** +# if: branch = master +# template: +# - "Repo `%{repository_slug}` *%{result}* build (<%{build_url}|#%{build_number}>) for commit (<%{compare_url}|%{commit}>) on branch `%{branch}`." +# - rooms: +# - secure: *********** +# if: branch = staging +# template: +# - "Repo `%{repository_slug}` *%{result}* build (<%{build_url}|#%{build_number}>) for commit (<%{compare_url}|%{commit}>) on branch `%{branch}`." +# - rooms: +# - secure: *********** +# if: branch = develop +# template: +# - "Repo `%{repository_slug}` *%{result}* build (<%{build_url}|#%{build_number}>) for commit (<%{compare_url}|%{commit}>) on branch `%{branch}`." diff --git a/fast_api/Dockerfile b/fast_api/Dockerfile new file mode 100755 index 0000000..be2f4f5 --- /dev/null +++ b/fast_api/Dockerfile @@ -0,0 +1,32 @@ +# ------------------------------------------------------------------------------ +# Base image +# ------------------------------------------------------------------------------ +FROM python:3.9.7-slim + +# ------------------------------------------------------------------------------ +# Informations +# ------------------------------------------------------------------------------ +LABEL maintainer="hsj100 " +LABEL title="A2TEC_METAVERSER_MEDICAL_REST" +LABEL description="Rest API Server with Fast API" + +# ------------------------------------------------------------------------------ +# Source +# ------------------------------------------------------------------------------ +COPY ./app /FAST_API/app + +# ------------------------------------------------------------------------------ +# Install dependencies +# ------------------------------------------------------------------------------ +COPY ./requirements.txt /FAST_API/requirements.txt + +WORKDIR /FAST_API +RUN apt update > /dev/null && \ + apt install -y build-essential && \ + pip install --no-cache-dir --upgrade -r /FAST_API/requirements.txt + +# ------------------------------------------------------------------------------ +# Binary +# ------------------------------------------------------------------------------ + +# CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "50210"] diff --git a/fast_api/README.md b/fast_api/README.md new file mode 100644 index 0000000..4809733 --- /dev/null +++ b/fast_api/README.md @@ -0,0 +1,43 @@ +# METAVERSE_MEDICAL_REST_SERVER + +RESTful API Server + + +### System Requirements + - Ubuntu 20.04 + - docker 20.10.9 + - docker-compose 1.25.0 + - python 3.9 + - python packages: requirements.txt + + +### Docker + - Base Image + * python:3.9.7-slim + + + - Target Image + * medical_metaverse_restapi:latest + + + - Environments variable + + + - Build + ``` + docker-build.sh + ``` + + + - Run + * docker-compose.yml 파일의 환경변수(environment) 수정 후 아래 명령으로 도커 실행 + ``` + docker-compose up -d + ``` + + +### LOG + - 서버 로그 파일 + ``` + TBD + ``` diff --git a/fast_api/app/api_request_sample.py b/fast_api/app/api_request_sample.py new file mode 100755 index 0000000..cb742ad --- /dev/null +++ b/fast_api/app/api_request_sample.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +""" +@File: api_request_sample.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: test api key +""" + +import base64 +import hmac +from datetime import datetime, timedelta + +import requests + + +def parse_params_to_str(params): + url = "?" + for key, value in params.items(): + url = url + str(key) + '=' + str(value) + '&' + return url[1:-1] + + +def hash_string(qs, secret_key): + mac = hmac.new(bytes(secret_key, encoding='utf8'), bytes(qs, encoding='utf-8'), digestmod='sha256') + d = mac.digest() + validating_secret = str(base64.b64encode(d).decode('utf-8')) + return validating_secret + + +def sample_request(): + access_key = 'c0883231-4aa9-4a1f-a77b-3ef250af-e449-42e9-856a-b3ada17c426b' + secret_key = 'QhOaeXTAAkW6yWt31jWDeERkBsZ3X4UmPds656YD' + cur_time = datetime.utcnow()+timedelta(hours=9) + cur_timestamp = int(cur_time.timestamp()) + qs = dict(key=access_key, timestamp=cur_timestamp) + header_secret = hash_string(parse_params_to_str(qs), secret_key) + + url = f'http://127.0.0.1:8080/api/services?{parse_params_to_str(qs)}' + res = requests.get(url, headers=dict(secret=header_secret)) + return res + + +print(sample_request().json()) diff --git a/fast_api/app/common/config.py b/fast_api/app/common/config.py new file mode 100755 index 0000000..8b83414 --- /dev/null +++ b/fast_api/app/common/config.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +""" +@File: config.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: Configurations +""" + +from dataclasses import dataclass +from os import path, environ + +from app.common import consts +from app.models import UserInfo + + +base_dir = path.dirname(path.dirname(path.dirname(path.abspath(__file__)))) + + +@dataclass +class Config: + """ + 기본 Configuration + """ + BASE_DIR: str = base_dir + DB_POOL_RECYCLE: int = 900 + DB_ECHO: bool = True + DEBUG: bool = False + TEST_MODE: bool = False + DEV_TEST_CONNECT_ACCOUNT: str = None + + # NOTE(hsj100): SERVICE_AUTH_API_KEY (token 방식으로 진행) + SERVICE_AUTH_API_KEY: bool = False + + DB_URL: str = environ.get('DB_URL', f'mysql+pymysql://{consts.DB_USER_ID}:{consts.DB_USER_PW}@{consts.DB_ADDRESS}/{consts.DB_NAME}?charset={consts.DB_CHARSET}') + REST_SERVER_PORT = consts.REST_SERVER_PORT + + SW_TITLE = consts.SW_TITLE + SW_VERSION = consts.SW_VERSION + SW_DESCRIPTION = consts.SW_DESCRIPTION + TERMS_OF_SERVICE = consts.TERMS_OF_SERVICE + CONTEACT = consts.CONTEACT + LICENSE_INFO = consts.LICENSE_INFO + + # GLOBAL_TOKEN = consts.ADMIN_ACCOUNT_TOKEN + GLOBAL_TOKEN = None + + +@dataclass +class LocalConfig(Config): + TRUSTED_HOSTS = ['*'] + ALLOW_SITE = ['*'] + DEBUG: bool = True + + +@dataclass +class ProdConfig(Config): + TRUSTED_HOSTS = ['*'] + ALLOW_SITE = ['*'] + + +@dataclass +class TestConfig(Config): + DB_URL: str = environ.get('DB_URL', f'mysql+pymysql://{consts.DB_USER_ID}:{consts.DB_USER_PW}@{consts.DB_ADDRESS}/{consts.DB_NAME}_test?charset={consts.DB_CHARSET}') + TRUSTED_HOSTS = ['*'] + ALLOW_SITE = ['*'] + TEST_MODE: bool = True + + +@dataclass +class DevConfig(Config): + TRUSTED_HOSTS = ['*'] + ALLOW_SITE = ['*'] + DEBUG: bool = True + DB_URL: str = environ.get('DB_URL', f'mysql+pymysql://{consts.DB_USER_ID}:{consts.DB_USER_PW}@{consts.DB_ADDRESS}/{consts.DB_NAME}_dev?charset={consts.DB_CHARSET}') + REST_SERVER_PORT = consts.REST_SERVER_PORT + 1 + + SW_TITLE = '[Dev] ' + consts.SW_TITLE + + +@dataclass +class MyConfig(Config): + TRUSTED_HOSTS = ['*'] + ALLOW_SITE = ['*'] + DEBUG: bool = True + DB_URL: str = environ.get('DB_URL', f'mysql+pymysql://{consts.DB_USER_ID}:{consts.DB_USER_PW}@{consts.DB_ADDRESS}/{consts.DB_NAME}_my?charset={consts.DB_CHARSET}') + REST_SERVER_PORT = consts.REST_SERVER_PORT + 2 + + # NOTE(hsj100): DEV_TEST_CONNECT_ACCOUNT + DEV_TEST_CONNECT_ACCOUNT: UserInfo = UserInfo(**consts.ADMIN_INIT_ACCOUNT_INFO) + # DEV_TEST_CONNECT_ACCOUNT: str = None + + # NOTE(hsj100): SERVICE_AUTH_API_KEY (token 방식으로 진행) + SERVICE_AUTH_API_KEY: bool = False + + SW_TITLE = '[My] ' + consts.SW_TITLE + + +def conf(): + """ + 환경 불러오기 + :return: + """ + config = dict(prod=ProdConfig, local=LocalConfig, test=TestConfig, dev=DevConfig, my=MyConfig) + return config[environ.get('API_ENV', 'local')]() + return config[environ.get('API_ENV', 'dev')]() + return config[environ.get('API_ENV', 'my')]() + return config[environ.get('API_ENV', 'test')]() diff --git a/fast_api/app/common/consts.py b/fast_api/app/common/consts.py new file mode 100755 index 0000000..fc54a78 --- /dev/null +++ b/fast_api/app/common/consts.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +""" +@File: consts.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: consts +""" + +# SUPPORT PROJECT +SUPPORT_PROJECT_BASIC = 'PROJECT_BASIC' +SUPPORT_PROJECT_MEDICAL_METAVERSE = 'METAVERSE - MEDICAL' +# SUPPORT_PROJECT_SW_LICENSE_MANAGER = 'SW LICENSE MANAGER' + +PROJECT_NAME = 'METAVERSE - MEDICAL' +SW_TITLE= f'{PROJECT_NAME} - REST API' +SW_VERSION = '0.9' +SW_DESCRIPTION = f''' +### METAVERSE MEDICAL REST API + +## API 이용법 + - **사용자 접속**시 토큰정보(***Authorization*** 항목) 획득 (이후 API 이용가능) + - 수신받은 토큰정보(***Authorization*** 항목)를 ***Request Header*** 에 포함해서 기타 API 사용 + - 개별 API 설명과 Request/Response schema 참조 + +**시스템에서 사용하는 날짜형식**\n + YYYY-mm-ddTHH:MM:SS + YYYY-mm-dd HH:MM:SS +''' +TERMS_OF_SERVICE = 'http://www.a2tec.com' +CONTEACT={ + 'name': 'A2TEC (주)에이투텍', + 'url': 'http://www.a2tec.co.kr', + 'email': 'marketing@a2tec.co.kr' +} +LICENSE_INFO = { + 'name': 'Copyright by A2TEC', 'url': 'http://www.a2tec.co.kr' +} + +REST_SERVER_PORT = 50510 + +# ADMINISTRATOR ACCOUNT INFORMATION +ADMIN_INIT_ACCOUNT_INFO = dict( + id=1, + account='a2tecrest01@gmail.com', + pw='!dpdlxnxpr1', + name='administrator', + email='a2tecrest01@gmail.com', + email_pw='!dpdlxnxpr1', +) +ADMIN_ACCOUNT_TOKEN = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiYWNjb3VudCI6ImEydGVjcmVzdDAxQGdtYWlsLmNvbSIsIm5hbWUiOiJhZG1pbmlzdHJhdG9yIiwicGhvbmVfbnVtYmVyIjpudWxsLCJwaWN0dXJlIjpudWxsLCJhY2NvdW50X3R5cGUiOiJlbWFpbCJ9.QNiF4sc7BZYwzoir0oPqLCUIyglUgMjZuBc1mI2GRCE' + +COOKIES_AUTH = 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MTQsImVtYWlsIjoia29hbGFAZGluZ3JyLmNvbSIsIm5hbWUiOm51bGwsInBob25lX251bWJlciI6bnVsbCwicHJvZmlsZV9pbWciOm51bGwsInNuc190eXBlIjpudWxsfQ.4vgrFvxgH8odoXMvV70BBqyqXOFa2NDQtzYkGywhV48' + +JWT_SECRET = 'ABCD1234!' +JWT_ALGORITHM = 'HS256' +EXCEPT_PATH_LIST = ['/', '/openapi.json'] +EXCEPT_PATH_REGEX = '^(/docs|/redoc|/api/auth' +\ + '|/api/user/check_account_exist' +\ + ')' +MAX_API_KEY = 3 +MAX_API_WHITELIST = 10 + +NUM_RETRY_UUID_GEN = 3 + +# DATABASE +# DB_ADDRESS = '52.78.56.148' +DB_ADDRESS = 'localhost' +DB_USER_ID = 'medical_metaverse' +DB_USER_PW = '1234' +DB_NAME = 'medical_metaverse' +DB_CHARSET = 'utf8mb4' + +# Mail +MAIL_REG_TITLE = f'{PROJECT_NAME} - Registration' +MAIL_REG_CONTENTS = ''' +안녕하세요. +[A2TEC] Metaverse medical Manager 입니다. + +요청하신 라이선스가 정상 등록되었습니다. + +라이선스 정보는 다음과 같습니다. +Account: {} +MAC: {} + + +감사합니다. + +''' + +# MEETING_ROOM +MEETING_ROOM_MAX_PERSON = 10 \ No newline at end of file diff --git a/fast_api/app/database/conn.py b/fast_api/app/database/conn.py new file mode 100755 index 0000000..3ae40d6 --- /dev/null +++ b/fast_api/app/database/conn.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +""" +@File: conn.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: DB Connections +""" + +from fastapi import FastAPI +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import logging + + +def _database_exist(engine, schema_name): + query = f'SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = "{schema_name}"' + with engine.connect() as conn: + result_proxy = conn.execute(query) + result = result_proxy.scalar() + return bool(result) + + +def _drop_database(engine, schema_name): + with engine.connect() as conn: + conn.execute(f'DROP DATABASE {schema_name};') + + +def _create_database(engine, schema_name): + with engine.connect() as conn: + conn.execute(f'CREATE DATABASE {schema_name} CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;') + + +class SQLAlchemy: + def __init__(self, app: FastAPI = None, **kwargs): + self._engine = None + self._session = None + if app is not None: + self.init_app(app=app, **kwargs) + + def init_app(self, app: FastAPI, **kwargs): + """ + DB 초기화 함수 + :param app: FastAPI 인스턴스 + :param kwargs: + :return: + """ + database_url = kwargs.get('DB_URL') + pool_recycle = kwargs.setdefault('DB_POOL_RECYCLE', 900) + is_testing = kwargs.setdefault('TEST_MODE', False) + echo = kwargs.setdefault('DB_ECHO', True) + + self._engine = create_engine( + database_url, + echo=echo, + pool_recycle=pool_recycle, + pool_pre_ping=True, + ) + if is_testing: # create schema + db_url = self._engine.url + if db_url.host != 'localhost': + raise Exception('db host must be \'localhost\' in test environment') + except_schema_db_url = f'{db_url.drivername}://{db_url.username}:{db_url.password}@{db_url.host}' + schema_name = db_url.database + temp_engine = create_engine(except_schema_db_url, echo=echo, pool_recycle=pool_recycle, pool_pre_ping=True) + if _database_exist(temp_engine, schema_name): + _drop_database(temp_engine, schema_name) + _create_database(temp_engine, schema_name) + temp_engine.dispose() + else: + db_url = self._engine.url + except_schema_db_url = f'{db_url.drivername}://{db_url.username}:{db_url.password}@{db_url.host}' + schema_name = db_url.database + temp_engine = create_engine(except_schema_db_url, echo=echo, pool_recycle=pool_recycle, pool_pre_ping=True) + if not _database_exist(temp_engine, schema_name): + _create_database(temp_engine, schema_name) + Base.metadata.create_all(db.engine) + temp_engine.dispose() + + self._session = sessionmaker(autocommit=False, autoflush=False, bind=self._engine) + + # NOTE(hsj100): ADMINISTRATOR + create_admin(self._session) + + @app.on_event('startup') + def startup(): + self._engine.connect() + logging.info('DB connected.') + + @app.on_event('shutdown') + def shutdown(): + self._session.close_all() + self._engine.dispose() + logging.info('DB disconnected') + + def get_db(self): + """ + 요청마다 DB 세션 유지 함수 + :return: + """ + if self._session is None: + raise Exception('must be called \'init_app\'') + db_session = None + try: + db_session = self._session() + yield db_session + finally: + db_session.close() + + @property + def session(self): + return self.get_db + + @property + def engine(self): + return self._engine + + +db = SQLAlchemy() +Base = declarative_base() + + +# NOTE(hsj100): ADMINISTRATOR +def create_admin(db_session): + import bcrypt + from app.database.schema import Users + from app.common.consts import ADMIN_INIT_ACCOUNT_INFO + + session = db_session() + + if not session: + raise Exception('cat`t create account of admin') + + if Users.get(account=ADMIN_INIT_ACCOUNT_INFO['account']): + return + + admin = {**ADMIN_INIT_ACCOUNT_INFO} + hash_pw = bcrypt.hashpw(str(admin['pw']).encode('utf-8'), bcrypt.gensalt()) + admin['pw'] = hash_pw + + Users.create(session=session, auto_commit=True, **admin) diff --git a/fast_api/app/database/crud.py b/fast_api/app/database/crud.py new file mode 100755 index 0000000..2478e1f --- /dev/null +++ b/fast_api/app/database/crud.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +""" +@File: crud.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: CRUD +""" + + +async def table_read(accessor_info, target_table, request_body_info, response_model, response_model_data): + """ + table_read + """ + try: + # parameter + if not accessor_info: + raise Exception('invalid accessor') + + if not target_table: + raise Exception(f'invalid table_name:{target_table}') + + # if not request_body_info: + # raise Exception('invalid request_body_info') + + if not response_model: + raise Exception('invalid response_model') + + if not response_model_data: + raise Exception('invalid response_model_data') + + # request + request_info = dict() + for key, val in request_body_info.dict().items(): + if val: + request_info[key] = val + + # search + search_info = target_table.filter(**request_info).all() + if not search_info: + raise Exception('not found data') + + # result + result_info = list() + for value in search_info: + result_info.append(response_model_data.from_orm(value)) + return response_model(data=result_info) + except Exception as e: + return response_model.set_error(str(e)) + + +async def table_update(accessor_info, target_table, request_body_info, response_model, response_model_data=None): + search_info = None + + try: + + # parameter + if not accessor_info: + raise Exception('invalid accessor') + + if not target_table: + raise Exception(f'invalid table_name:{target_table}') + + if not request_body_info: + raise Exception('invalid request_body_info') + + if not response_model: + raise Exception('invalid response_model') + + # if not response_model_data: + # raise Exception('invalid response_model_data') + + # request + if not request_body_info.search_info: + raise Exception('invalid request_body: search_info') + request_search_info = dict() + if request_body_info.search_info: + for key, val in request_body_info.search_info.dict().items(): + if val: + request_search_info[key] = val + if not request_search_info: + raise Exception('invalid request_body: search_info') + + if not request_body_info.update_info: + raise Exception('invalid request_body: update_info') + request_update_info = dict() + for key, val in request_body_info.update_info.dict().items(): + if val: + request_update_info[key] = val + if not request_update_info: + raise Exception('invalid request_body: update_info') + + # search + search_info = target_table.filter(**request_search_info) + + # process + search_info.update(auto_commit=True, synchronize_session=False, **request_update_info) + + # result + return response_model() + except Exception as e: + if search_info: + search_info.close() + return response_model.set_error(str(e)) + + +async def table_delete(accessor_info, target_table, request_body_info, response_model, response_model_data=None): + search_info = None + + try: + # request + if not accessor_info: + raise Exception('invalid accessor') + + if not target_table: + raise Exception(f'invalid table_name:{target_table}') + + if not request_body_info: + raise Exception('invalid request_body_info') + + if not response_model: + raise Exception('invalid response_model') + + # if not response_model_data: + # raise Exception('invalid response_model_data') + + # request + request_search_info = dict() + if request_body_info: + for key, val in request_body_info.dict().items(): + if val: + request_search_info[key] = val + if not request_search_info: + raise Exception('invalid request_body') + + # search + search_info = target_table.filter(**request_search_info) + + # process + search_info.delete(auto_commit=True, synchronize_session=False) + + # result + return response_model() + except Exception as e: + if search_info: + search_info.close() + return response_model.set_error(str(e)) diff --git a/fast_api/app/database/schema.py b/fast_api/app/database/schema.py new file mode 100755 index 0000000..c6b353e --- /dev/null +++ b/fast_api/app/database/schema.py @@ -0,0 +1,272 @@ +# -*- coding: utf-8 -*- +""" +@File: schema.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: database schema +""" + +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + func, + Enum, + Boolean, + ForeignKey, +) +from sqlalchemy.orm import Session, relationship + +from app.database.conn import Base, db +from app.utils.date_utils import D +from app.models import ( + SexType, + UserType, + AccountType, + UserStatusType, + UserLoginType, + RoomType, + AppointmentStatusType, + RoomStatusType +) +from app.common.consts import MEETING_ROOM_MAX_PERSON + + +class BaseMixin: + id = Column(Integer, primary_key=True, index=True) + created_at = Column(DateTime, nullable=False, default=D.datetime()) + updated_at = Column(DateTime, nullable=False, default=D.datetime(), onupdate=D.datetime()) + + def __init__(self): + self._q = None + self._session = None + self.served = None + + def all_columns(self): + return [c for c in self.__table__.columns if c.primary_key is False and c.name != 'created_at'] + + def __hash__(self): + return hash(self.id) + + @classmethod + def create(cls, session: Session, auto_commit=False, **kwargs): + """ + 테이블 데이터 적재 전용 함수 + :param session: + :param auto_commit: 자동 커밋 여부 + :param kwargs: 적재 할 데이터 + :return: + """ + obj = cls() + + # NOTE(hsj100) : FIX_DATETIME + if 'created_at' not in kwargs: + obj.created_at = D.datetime() + if 'updated_at' not in kwargs: + obj.updated_at = D.datetime() + + for col in obj.all_columns(): + col_name = col.name + if col_name in kwargs: + setattr(obj, col_name, kwargs.get(col_name)) + session.add(obj) + session.flush() + if auto_commit: + session.commit() + return obj + + @classmethod + def get(cls, session: Session = None, **kwargs): + """ + Simply get a Row + :param session: + :param kwargs: + :return: + """ + sess = next(db.session()) if not session else session + query = sess.query(cls) + for key, val in kwargs.items(): + col = getattr(cls, key) + query = query.filter(col == val) + + if query.count() > 1: + raise Exception('Only one row is supposed to be returned, but got more than one.') + result = query.first() + if not session: + sess.close() + return result + + @classmethod + def filter(cls, session: Session = None, **kwargs): + """ + Simply get a Row + :param session: + :param kwargs: + :return: + """ + cond = [] + for key, val in kwargs.items(): + key = key.split('__') + if len(key) > 2: + raise Exception('No 2 more dunders') + col = getattr(cls, key[0]) + if len(key) == 1: cond.append((col == val)) + elif len(key) == 2 and key[1] == 'gt': cond.append((col > val)) + elif len(key) == 2 and key[1] == 'gte': cond.append((col >= val)) + elif len(key) == 2 and key[1] == 'lt': cond.append((col < val)) + elif len(key) == 2 and key[1] == 'lte': cond.append((col <= val)) + elif len(key) == 2 and key[1] == 'in': cond.append((col.in_(val))) + elif len(key) == 2 and key[1] == 'like': cond.append((col.like(val))) + + obj = cls() + if session: + obj._session = session + obj.served = True + else: + obj._session = next(db.session()) + obj.served = False + query = obj._session.query(cls) + query = query.filter(*cond) + obj._q = query + return obj + + @classmethod + def cls_attr(cls, col_name=None): + if col_name: + col = getattr(cls, col_name) + return col + else: + return cls + + def order_by(self, *args: str): + for a in args: + if a.startswith('-'): + col_name = a[1:] + is_asc = False + else: + col_name = a + is_asc = True + col = self.cls_attr(col_name) + self._q = self._q.order_by(col.asc()) if is_asc else self._q.order_by(col.desc()) + return self + + def update(self, auto_commit: bool = False, synchronize_session='evaluate', **kwargs): + # NOTE(hsj100) : FIX_DATETIME + if 'updated_at' not in kwargs: + kwargs['updated_at'] = D.datetime() + + qs = self._q.update(kwargs, synchronize_session=synchronize_session) + get_id = self.id + ret = None + + self._session.flush() + if qs > 0 : + ret = self._q.first() + if auto_commit: + self._session.commit() + return ret + + def first(self): + result = self._q.first() + self.close() + return result + + def delete(self, auto_commit: bool = False, synchronize_session='evaluate'): + self._q.delete(synchronize_session=synchronize_session) + if auto_commit: + self._session.commit() + + def all(self): + print(self.served) + result = self._q.all() + self.close() + return result + + def count(self): + result = self._q.count() + self.close() + return result + + def close(self): + if not self.served: + self._session.close() + else: + self._session.flush() + + +class ApiKeys(Base, BaseMixin): + __tablename__ = 'api_keys' + access_key = Column(String(length=64), nullable=False, index=True) + secret_key = Column(String(length=64), nullable=False) + user_memo = Column(String(length=40), nullable=True) + status = Column(Enum('active', 'stopped', 'deleted'), default='active') + is_whitelisted = Column(Boolean, default=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + whitelist = relationship('ApiWhiteLists', backref='api_keys') + users = relationship('Users', back_populates='keys') + + +class ApiWhiteLists(Base, BaseMixin): + __tablename__ = 'api_whitelists' + ip_addr = Column(String(length=64), nullable=False) + api_key_id = Column(Integer, ForeignKey('api_keys.id'), nullable=False) + + +class Users(Base, BaseMixin): + __tablename__ = 'users' + status = Column(Enum(UserStatusType), nullable=False, default=UserStatusType.active) + # TODO(hsj100): LOGIN_STATUS + login = Column(Enum(UserLoginType), nullable=False, default=UserLoginType.logout) + account_type = Column(Enum(AccountType), nullable=False, default=AccountType.email) + account = Column(String(length=255), nullable=False, unique=True) + pw = Column(String(length=2000), nullable=True) + email = Column(String(length=255), nullable=True) + name = Column(String(length=255), nullable=False) + sex = Column(Enum(SexType), nullable=False, default=SexType.male) + rrn = Column(String(length=255), nullable=True) + address = Column(String(length=2000), nullable=True) + phone_number = Column(String(length=20), nullable=True) + picture = Column(String(length=1000), nullable=True) + marketing_agree = Column(Boolean, nullable=True, default=False) + + keys = relationship('ApiKeys', back_populates='users') + + user_type = Column(Enum(UserType), nullable=False, default=UserType.patient) + major = Column(String(length=255), nullable=True, default=None) + character = Column(String(length=255), nullable=True) + + room_id = Column(Integer, ForeignKey('room.id'), nullable=True) + + +class Appointment(Base, BaseMixin): + __tablename__ = 'appointment' + create_account = Column(String(length=255), nullable=False) + doctor_account = Column(String(length=255), nullable=False) + patient_account = Column(String(length=255), nullable=False) + treatment_subject = Column(String(length=64), nullable=False) + date = Column(DateTime, nullable=False, default=func.timestamp()) + status = Column(Enum(AppointmentStatusType), default=AppointmentStatusType.wait) + + +class TreatmentDivision(Base, BaseMixin): + __tablename__ = 'treat' + short_name = Column(String(length=64), nullable=False) + full_name = Column(String(length=255), nullable=False) + + +class Room(Base, BaseMixin): + __tablename__ = 'room' + type = Column(Enum(RoomType), nullable=False, default=RoomType.meeting) + auto_enter = Column(Boolean, nullable=False, default=True) + auto_delete = Column(Boolean, nullable=False, default=True) + name = Column(String(length=255), nullable=False) + pw = Column(String(length=2000), nullable=False) + create_account = Column(String(length=255), nullable=False) + status = Column(Enum(RoomStatusType), nullable=False, default=RoomStatusType.open) + cur_person = Column(Integer, nullable=False, default=0) + max_person = Column(Integer, nullable=False, default=MEETING_ROOM_MAX_PERSON) + desc = Column(String(length=2000), nullable=True) diff --git a/fast_api/app/errors/__init__.py b/fast_api/app/errors/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/fast_api/app/errors/exceptions.py b/fast_api/app/errors/exceptions.py new file mode 100755 index 0000000..6e2bc4a --- /dev/null +++ b/fast_api/app/errors/exceptions.py @@ -0,0 +1,188 @@ +from app.common.consts import MAX_API_KEY, MAX_API_WHITELIST + + +class StatusCode: + HTTP_500 = 500 + HTTP_400 = 400 + HTTP_401 = 401 + HTTP_403 = 403 + HTTP_404 = 404 + HTTP_405 = 405 + + +class APIException(Exception): + status_code: int + code: str + msg: str + detail: str + ex: Exception + + def __init__( + self, + *, + status_code: int = StatusCode.HTTP_500, + code: str = '000000', + msg: str = None, + detail: str = None, + ex: Exception = None, + ): + self.status_code = status_code + self.code = code + self.msg = msg + self.detail = detail + self.ex = ex + super().__init__(ex) + + +class NotFoundUserEx(APIException): + def __init__(self, user_id: int = None, ex: Exception = None): + super().__init__( + status_code=StatusCode.HTTP_404, + msg=f'해당 유저를 찾을 수 없습니다.', + detail=f'Not Found User ID : {user_id}', + code=f'{StatusCode.HTTP_400}{"1".zfill(4)}', + ex=ex, + ) + + +class NotAuthorized(APIException): + def __init__(self, ex: Exception = None): + super().__init__( + status_code=StatusCode.HTTP_401, + msg=f'로그인이 필요한 서비스 입니다.', + detail='Authorization Required', + code=f'{StatusCode.HTTP_401}{"1".zfill(4)}', + ex=ex, + ) + + +class TokenExpiredEx(APIException): + def __init__(self, ex: Exception = None): + super().__init__( + status_code=StatusCode.HTTP_400, + msg=f'세션이 만료되어 로그아웃 되었습니다.', + detail='Token Expired', + code=f'{StatusCode.HTTP_400}{"1".zfill(4)}', + ex=ex, + ) + + +class TokenDecodeEx(APIException): + def __init__(self, ex: Exception = None): + super().__init__( + status_code=StatusCode.HTTP_400, + msg=f'비정상적인 접근입니다.', + detail='Token has been compromised.', + code=f'{StatusCode.HTTP_400}{"2".zfill(4)}', + ex=ex, + ) + + +class NoKeyMatchEx(APIException): + def __init__(self, ex: Exception = None): + super().__init__( + status_code=StatusCode.HTTP_404, + msg=f'해당 키에 대한 권한이 없거나 해당 키가 없습니다.', + detail='No Keys Matched', + code=f'{StatusCode.HTTP_404}{"3".zfill(4)}', + ex=ex, + ) + + +class MaxKeyCountEx(APIException): + def __init__(self, ex: Exception = None): + super().__init__( + status_code=StatusCode.HTTP_400, + msg=f'API 키 생성은 {MAX_API_KEY}개 까지 가능합니다.', + detail='Max Key Count Reached', + code=f'{StatusCode.HTTP_400}{"4".zfill(4)}', + ex=ex, + ) + + +class MaxWLCountEx(APIException): + def __init__(self, ex: Exception = None): + super().__init__( + status_code=StatusCode.HTTP_400, + msg=f'화이트리스트 생성은 {MAX_API_WHITELIST}개 까지 가능합니다.', + detail='Max Whitelist Count Reached', + code=f'{StatusCode.HTTP_400}{"5".zfill(4)}', + ex=ex, + ) + + +class InvalidIpEx(APIException): + def __init__(self, ip: str, ex: Exception = None): + super().__init__( + status_code=StatusCode.HTTP_400, + msg=f'{ip}는 올바른 IP 가 아닙니다.', + detail=f'invalid IP : {ip}', + code=f'{StatusCode.HTTP_400}{"6".zfill(4)}', + ex=ex, + ) + + +class SqlFailureEx(APIException): + def __init__(self, ex: Exception = None): + super().__init__( + status_code=StatusCode.HTTP_500, + msg=f'이 에러는 서버측 에러 입니다. 자동으로 리포팅 되며, 빠르게 수정하겠습니다.', + detail='Internal Server Error', + code=f'{StatusCode.HTTP_500}{"2".zfill(4)}', + ex=ex, + ) + + +class APIQueryStringEx(APIException): + def __init__(self, ex: Exception = None): + super().__init__( + status_code=StatusCode.HTTP_400, + msg=f'쿼리스트링은 key, timestamp 2개만 허용되며, 2개 모두 요청시 제출되어야 합니다.', + detail='Query String Only Accept key and timestamp.', + code=f'{StatusCode.HTTP_400}{"7".zfill(4)}', + ex=ex, + ) + + +class APIHeaderInvalidEx(APIException): + def __init__(self, ex: Exception = None): + super().__init__( + status_code=StatusCode.HTTP_400, + msg=f'헤더에 키 해싱된 Secret 이 없거나, 유효하지 않습니다.', + detail='Invalid HMAC secret in Header', + code=f'{StatusCode.HTTP_400}{"8".zfill(4)}', + ex=ex, + ) + + +class APITimestampEx(APIException): + def __init__(self, ex: Exception = None): + super().__init__( + status_code=StatusCode.HTTP_400, + msg=f'쿼리스트링에 포함된 타임스탬프는 KST 이며, 현재 시간보다 작아야 하고, 현재시간 - 10초 보다는 커야 합니다.', + detail='timestamp in Query String must be KST, Timestamp must be less than now, and greater than now - 10.', + code=f'{StatusCode.HTTP_400}{"9".zfill(4)}', + ex=ex, + ) + + +class NotFoundAccessKeyEx(APIException): + def __init__(self, api_key: str, ex: Exception = None): + super().__init__( + status_code=StatusCode.HTTP_404, + msg=f'API 키를 찾을 수 없습니다.', + detail=f'Not found such API Access Key : {api_key}', + code=f'{StatusCode.HTTP_404}{"10".zfill(4)}', + ex=ex, + ) + + +class KakaoSendFailureEx(APIException): + def __init__(self, ex: Exception = None): + super().__init__( + status_code=StatusCode.HTTP_400, + msg=f'카카오톡 전송에 실패했습니다.', + detail=f'Failed to send KAKAO MSG.', + code=f'{StatusCode.HTTP_400}{"11".zfill(4)}', + ex=ex, + ) diff --git a/fast_api/app/main.py b/fast_api/app/main.py new file mode 100755 index 0000000..4457bec --- /dev/null +++ b/fast_api/app/main.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" +@File: main.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: Main +""" + +from dataclasses import asdict + +import uvicorn +from fastapi import FastAPI, Depends +from fastapi.security import APIKeyHeader +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.cors import CORSMiddleware + +from app.common import consts + +from app.database.conn import db +from app.common.config import conf +from app.middlewares.token_validator import access_control +from app.middlewares.trusted_hosts import TrustedHostMiddleware +from app.routes import dev, index, auth, users, services + + +API_KEY_HEADER = APIKeyHeader(name='Authorization', auto_error=False) + + +def create_app(): + """ + fast_api_app 생성 + + :return: fast_api_app + """ + configurations = conf() + fast_api_app = FastAPI( + title=configurations.SW_TITLE, + version=configurations.SW_VERSION, + description=configurations.SW_DESCRIPTION, + terms_of_service=configurations.TERMS_OF_SERVICE, + contact=configurations.CONTEACT, + license_info={'name': 'Copyright by A2TEC', 'url': 'http://www.a2tec.co.kr'} + ) + + # 데이터 베이스 이니셜라이즈 + conf_dict = asdict(configurations) + db.init_app(fast_api_app, **conf_dict) + + # 레디스 이니셜라이즈 + + # 미들웨어 정의 + fast_api_app.add_middleware(middleware_class=BaseHTTPMiddleware, dispatch=access_control) + fast_api_app.add_middleware( + CORSMiddleware, + allow_origins=conf().ALLOW_SITE, + allow_credentials=True, + allow_methods=['*'], + allow_headers=['*'], + ) + fast_api_app.add_middleware(TrustedHostMiddleware, allowed_hosts=conf().TRUSTED_HOSTS, except_path=['/health']) + + # 라우터 정의 + fast_api_app.include_router(index.router, tags=['Defaults']) + fast_api_app.include_router(auth.router, tags=['Authentication'], prefix='/api') + + if conf().DEBUG: + fast_api_app.include_router(services.router, tags=['Services'], prefix='/api', dependencies=[Depends(API_KEY_HEADER)]) + else: + fast_api_app.include_router(services.router, tags=['Services'], prefix='/api') + + fast_api_app.include_router(users.router, tags=['Users'], prefix='/api', dependencies=[Depends(API_KEY_HEADER)]) + + if conf().DEBUG: + fast_api_app.include_router(dev.router, tags=['Developments'], prefix='/api', dependencies=[Depends(API_KEY_HEADER)]) + return fast_api_app + +app = create_app() + + +if __name__ == '__main__': + uvicorn.run('main:app', host='0.0.0.0', port=conf().REST_SERVER_PORT, reload=True) diff --git a/fast_api/app/middlewares/token_validator.py b/fast_api/app/middlewares/token_validator.py new file mode 100755 index 0000000..218d0a0 --- /dev/null +++ b/fast_api/app/middlewares/token_validator.py @@ -0,0 +1,166 @@ +import base64 +import hmac +import json +import time +import typing +import re + +import jwt +import sqlalchemy.exc + +from jwt.exceptions import ExpiredSignatureError, DecodeError +from starlette.requests import Request +from starlette.responses import JSONResponse + +from app.common.consts import EXCEPT_PATH_LIST, EXCEPT_PATH_REGEX +from app.database.conn import db +from app.database.schema import Users, ApiKeys +from app.errors import exceptions as ex + +from app.common import consts +from app.common.config import conf +from app.errors.exceptions import APIException, SqlFailureEx, APIQueryStringEx +from app.models import UserToken + +from app.utils.date_utils import D +from app.utils.logger import api_logger +from app.utils.query_utils import to_dict + +from dataclasses import asdict + + +async def access_control(request: Request, call_next): + request.state.req_time = D.datetime() + request.state.start = time.time() + request.state.inspect = None + request.state.user = None + request.state.service = None + + ip = request.headers['x-forwarded-for'] if 'x-forwarded-for' in request.headers.keys() else request.client.host + request.state.ip = ip.split(',')[0] if ',' in ip else ip + headers = request.headers + cookies = request.cookies + + url = request.url.path + if await url_pattern_check(url, EXCEPT_PATH_REGEX) or url in EXCEPT_PATH_LIST: + response = await call_next(request) + if url != '/': + await api_logger(request=request, response=response) + return response + + try: + if url.startswith('/api'): + # api 인경우 헤더로 토큰 검사 + # NOTE(hsj100): SERVICE_AUTH_API_KEY (token 방식으로 진행) + if url.startswith('/api/services') and conf().SERVICE_AUTH_API_KEY: + qs = str(request.query_params) + qs_list = qs.split('&') + session = next(db.session()) + if not conf().DEBUG: + try: + qs_dict = {qs_split.split('=')[0]: qs_split.split('=')[1] for qs_split in qs_list} + except Exception: + raise ex.APIQueryStringEx() + + qs_keys = qs_dict.keys() + + if 'key' not in qs_keys or 'timestamp' not in qs_keys: + raise ex.APIQueryStringEx() + + if 'secret' not in headers.keys(): + raise ex.APIHeaderInvalidEx() + + api_key = ApiKeys.get(session=session, access_key=qs_dict['key']) + + if not api_key: + raise ex.NotFoundAccessKeyEx(api_key=qs_dict['key']) + mac = hmac.new(bytes(api_key.secret_key, encoding='utf8'), bytes(qs, encoding='utf-8'), digestmod='sha256') + d = mac.digest() + validating_secret = str(base64.b64encode(d).decode('utf-8')) + + if headers['secret'] != validating_secret: + raise ex.APIHeaderInvalidEx() + + now_timestamp = int(D.datetime(diff=9).timestamp()) + if now_timestamp - 10 > int(qs_dict['timestamp']) or now_timestamp < int(qs_dict['timestamp']): + raise ex.APITimestampEx() + + user_info = to_dict(api_key.users) + request.state.user = UserToken(**user_info) + + else: + # Request User 가 필요함 + if 'authorization' in headers.keys(): + key = headers.get('Authorization') + api_key_obj = ApiKeys.get(session=session, access_key=key) + user_info = to_dict(Users.get(session=session, id=api_key_obj.user_id)) + request.state.user = UserToken(**user_info) + # 토큰 없음 + else: + if 'Authorization' not in headers.keys(): + raise ex.NotAuthorized() + session.close() + response = await call_next(request) + return response + else: + if 'authorization' in headers.keys(): + # 토큰 존재 + token_info = await token_decode(access_token=headers.get('Authorization')) + request.state.user = UserToken(**token_info) + elif conf().DEV_TEST_CONNECT_ACCOUNT: + # NOTE(hsj100): DEV_TEST_CONNECT_ACCOUNT + request.state.user = UserToken.from_orm(conf().DEV_TEST_CONNECT_ACCOUNT) + else: + # 토큰 없음 + if 'Authorization' not in headers.keys(): + raise ex.NotAuthorized() + else: + # 템플릿 렌더링인 경우 쿠키에서 토큰 검사 + cookies['Authorization'] = conf().COOKIES_AUTH + + if 'Authorization' not in cookies.keys(): + raise ex.NotAuthorized() + + token_info = await token_decode(access_token=cookies.get('Authorization')) + request.state.user = UserToken(**token_info) + response = await call_next(request) + await api_logger(request=request, response=response) + except Exception as e: + + error = await exception_handler(e) + error_dict = dict(status=error.status_code, msg=error.msg, detail=error.detail, code=error.code) + response = JSONResponse(status_code=error.status_code, content=error_dict) + await api_logger(request=request, error=error) + + return response + + +async def url_pattern_check(path, pattern): + result = re.match(pattern, path) + if result: + return True + return False + + +async def token_decode(access_token): + """ + :param access_token: + :return: + """ + try: + access_token = access_token.replace('Bearer ', "") + payload = jwt.decode(access_token, key=consts.JWT_SECRET, algorithms=[consts.JWT_ALGORITHM]) + except ExpiredSignatureError: + raise ex.TokenExpiredEx() + except DecodeError: + raise ex.TokenDecodeEx() + return payload + + +async def exception_handler(error: Exception): + print(error) + if isinstance(error, sqlalchemy.exc.OperationalError): + error = SqlFailureEx(ex=error) + if not isinstance(error, APIException): + error = APIException(ex=error, detail=str(error)) + return error diff --git a/fast_api/app/middlewares/trusted_hosts.py b/fast_api/app/middlewares/trusted_hosts.py new file mode 100755 index 0000000..5580f3b --- /dev/null +++ b/fast_api/app/middlewares/trusted_hosts.py @@ -0,0 +1,63 @@ +import typing + + +from starlette.datastructures import URL, Headers +from starlette.responses import PlainTextResponse, RedirectResponse, Response +from starlette.types import ASGIApp, Receive, Scope, Send + +ENFORCE_DOMAIN_WILDCARD = 'Domain wildcard patterns must be lNo module named \'app\'ike \'*.example.com\'.' + + +class TrustedHostMiddleware: + def __init__( + self, + app: ASGIApp, + allowed_hosts: typing.Sequence[str] = None, + except_path: typing.Sequence[str] = None, + www_redirect: bool = True, + ) -> None: + if allowed_hosts is None: + allowed_hosts = ['*'] + if except_path is None: + except_path = [] + for pattern in allowed_hosts: + assert '*' not in pattern[1:], ENFORCE_DOMAIN_WILDCARD + if pattern.startswith('*') and pattern != '*': + assert pattern.startswith('*.'), ENFORCE_DOMAIN_WILDCARD + self.app = app + self.allowed_hosts = list(allowed_hosts) + self.allow_any = '*' in allowed_hosts + self.www_redirect = www_redirect + self.except_path = list(except_path) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if self.allow_any or scope['type'] not in ('http', 'websocket',): # pragma: no cover + await self.app(scope, receive, send) + return + + headers = Headers(scope=scope) + host = headers.get('host', "").split(':')[0] + is_valid_host = False + found_www_redirect = False + for pattern in self.allowed_hosts: + if ( + host == pattern + or (pattern.startswith('*') and host.endswith(pattern[1:])) + or URL(scope=scope).path in self.except_path + ): + is_valid_host = True + break + elif 'www.' + host == pattern: + found_www_redirect = True + + if is_valid_host: + await self.app(scope, receive, send) + else: + if found_www_redirect and self.www_redirect: + url = URL(scope=scope) + redirect_url = url.replace(netloc='www.' + url.netloc) + response = RedirectResponse(url=str(redirect_url)) # type: Response + else: + response = PlainTextResponse('Invalid host header', status_code=400) + + await response(scope, receive, send) diff --git a/fast_api/app/models.py b/fast_api/app/models.py new file mode 100755 index 0000000..4e7ab76 --- /dev/null +++ b/fast_api/app/models.py @@ -0,0 +1,590 @@ +# -*- coding: utf-8 -*- +""" +@File: models.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: data models +""" + +from datetime import datetime +from enum import Enum +from typing import List + +from pydantic import Field +from pydantic.main import BaseModel +from pydantic.networks import EmailStr, IPvAnyAddress +from typing import Optional + +from app.common.consts import SW_TITLE, SW_VERSION, MEETING_ROOM_MAX_PERSON +from app.utils.date_utils import D + + +class SWInfo(BaseModel): + """ + ### 서비스 정보 + """ + name: str = Field(SW_TITLE, description='SW 이름', example=SW_VERSION) + version: str = Field(SW_VERSION, description='SW 버전', example=SW_VERSION) + date: str = Field(D.date_str(), description='현재날짜', example='%Y.%m.%dT%H:%M:%S') + + +class CustomEnum(Enum): + @classmethod + def get_elements_str(cls, is_key=True): + if is_key: + result_str = cls.__members__.keys() + else: + result_str = cls.__members__.values() + return '[' + ', '.join(result_str) + ']' + + +class SexType(str, CustomEnum): + male: str = 'male' + female: str = 'female' + + +class AccountType(str, CustomEnum): + email: str = 'email' + # facebook: str = 'facebook' + # google: str = 'google' + # kakao: str = 'kakao' + + +class UserStatusType(str, CustomEnum): + active: str = 'active' + deleted: str = 'deleted' + blocked: str = 'blocked' + + +class UserLoginType(str, CustomEnum): + login: str = 'login' + logout: str = 'logout' + +class UserType(str, CustomEnum): + doctor: str = 'doctor' + patient: str = 'patient' + + +class RoomType(str, CustomEnum): + meeting: str = 'meeting' + lecture: str = 'lecture' + + +class AppointmentStatusType(str, CustomEnum): + wait: str = 'wait' + reserve: str = 'reserve' + cancel: str = 'cancel' + treatment: str = 'treatment' + + +class RoomStatusType(str, CustomEnum): + open: str = 'open' + close: str = 'close' + tclose: str = 'tclose' + full: str = 'full' + + +class Token(BaseModel): + Authorization: str = Field(None, description='인증키', example='Bearer [token]') + + +class EmailRecipients(BaseModel): + name: str + email: str + + +class SendEmail(BaseModel): + email_to: List[EmailRecipients] = None + + +class KakaoMsgBody(BaseModel): + msg: str = None + + +class MessageOk(BaseModel): + message: str = Field(default='OK') + + +class UserToken(BaseModel): + id: int + account: str = None + name: str = None + phone_number: str = None + picture: str = None + account_type: str = None + + class Config: + orm_mode = True + + +class AddApiKey(BaseModel): + user_memo: str = None + + class Config: + orm_mode = True + + +class GetApiKeyList(AddApiKey): + id: int = None + access_key: str = None + created_at: datetime = None + + +class GetApiKeys(GetApiKeyList): + secret_key: str = None + + +class CreateAPIWhiteLists(BaseModel): + ip_addr: str = None + + +class GetAPIWhiteLists(CreateAPIWhiteLists): + id: int + + class Config: + orm_mode = True + + +class ResponseBase(BaseModel): + """ + ### [Response] API End-Point + + **정상처리**\n + - result: true\n + - error: null\n + + **오류발생**\n + - result: false\n + - error: 오류내용\n + """ + result: bool = Field(True, description='처리상태(성공: true, 실패: false)', example=True) + error: str = Field(None, description='오류내용(성공: null, 실패: 오류내용)', example='invalid data') + + @staticmethod + def set_error(error): + ResponseBase.result = False + ResponseBase.error = str(error) + return ResponseBase + + + class Config: + orm_mode = True + + +class TokenRes(ResponseBase): + """ + ### [Response] 토큰 정보 + """ + Authorization: str = Field(None, description='인증키', example='Bearer [token]') + + class Config: + orm_mode = True + + +class UserInfo(BaseModel): + """ + ### 유저 정보 + """ + id: int = Field(None, description='Table Index', example='1') + created_at: datetime = Field(None, description='생성날짜', example='2022-01-01T12:34:56') + updated_at: datetime = Field(None, description='수정날짜', example='2022-01-01T12:34:56') + + status: UserStatusType = Field(None, description='계정상태' + UserStatusType.get_elements_str(), example=UserStatusType.active) + login: UserLoginType = Field(None, description='로그인상태' + UserLoginType.get_elements_str(), example=UserLoginType.logout) + account_type: AccountType = Field(None, description='계정종류' + AccountType.get_elements_str(), example=AccountType.email) + account: str = Field(None, description='계정', example='user1@test.com') + pw: str = Field(None, description='비밀번호', example='1234') + email: str = Field(None, description='전자메일', example='user1@test.com') + name: str = Field(None, description='이름', example='user1') + sex: str = Field(None, description='성별' + SexType.get_elements_str(), example=SexType.male) + rrn: str = Field(None, description='주민등록번호', example='123456-1234567') + address: str = Field(None, description='주소', example='대구1') + phone_number: str = Field(None, description='연락처', example='010-1234-1234') + picture: str = Field(None, description='프로필사진', example='profile1.png') + marketing_agree: bool = Field(False, description='마케팅동의 여부', example=False) + # extra + user_type: str = Field(None, description='유저종류', example=UserType.patient) + major: str = Field(None, description='진료과목', example='정형외과') + character: str = Field(None, description='아바타 정보', example='Male_CH01') + room_id: int = Field(None, description='입장한 방번호', example='1') + + class Config: + orm_mode = True + + +class UserInfoRes(ResponseBase): + """ + ### [Response] 유저 정보 + """ + data: UserInfo = None + + class Config: + orm_mode = True + + +class UserLoginReq(BaseModel): + """ + ### 유저 로그인 정보 + """ + account: str = Field(None, description='계정', example='user1@test.com') + pw: str = Field(None, description='비밀번호', example='1234') + + +class UserRegisterReq(BaseModel): + """ + ### [Request] 유저 등록 + """ + # pip install 'pydantic' + # users + status: Optional[str] = Field(UserStatusType.blocked, description='계정상태' + UserStatusType.get_elements_str(), example=UserStatusType.active) + login: Optional[str] = Field(None, description='로그인상태' + UserLoginType.get_elements_str(), example=UserLoginType.logout) + account: str = Field(description='계정', example='test@test.com') + pw: str = Field(None, description='비밀번호', example='1234') + email: Optional[str] = Field(None, description='전자메일', example='test@test.com') + name: str = Field(description='이름', example='test') + sex: str = Field(SexType.male, description='성별' + SexType.get_elements_str(), example=SexType.male) + rrn: Optional[str] = Field(None, description='주민등록번호', example='19910101-1234567') + address: Optional[str] = Field(None, description='주소', example='대구광역시 동구 동촌로 351, 4층 (용계동, 에이스빌딩)') + phone_number: Optional[str] = Field(None, description='휴대전화', example='010-1234-1234') + picture: Optional[str] = Field(None, description='사진', example='profile1.png') + marketing_agree: Optional[bool] = Field(False, description='마케팅동의 여부', example=False) + # extra + user_type: str = Field(UserType.patient, description='유저종류', example=UserType.patient) + major: Optional[str] = Field(None, description='진료과목', example='정형외과') + character: Optional[str] = Field(default='Male_CH01', description='아바타 정보', example='Male_CH01') + # room_id: Optional[int] = Field(None, description='입장한 방번호', example='1') + + +class UserSearchReq(BaseModel): + """ + ### [Request] 유저 검색 + """ + id: Optional[int] = Field(None, description='등록번호', example='1') + created_at: Optional[datetime] = Field(None, description='생성날짜', example='2022-01-01T12:34:56') + created_at__gt: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 이후)', example='2022-02-10T15:00:00') + created_at__gte: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 포함 이후)', example='2022-02-10T15:00:00') + created_at__lt: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 이전)', example='2022-02-10T15:00:00') + created_at__lte: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 포함 이전)', example='2022-02-10T15:00:00') + updated_at: Optional[datetime] = Field(None, description='수정날짜', example='2022-01-01T12:34:56') + updated_at__gt: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 이후)', example='2022-02-10T15:00:00') + updated_at__gte: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 포함 이후)', example='2022-02-10T15:00:00') + updated_at__lt: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 이전)', example='2022-02-10T15:00:00') + updated_at__lte: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 포함 이전)', example='2022-02-10T15:00:00') + + status: Optional[str] = Field(None, description='계정상태' + UserStatusType.get_elements_str(), example=UserStatusType.active) + login: Optional[str] = Field(None, description='로그인상태' + UserLoginType.get_elements_str(), example=UserLoginType.logout) + account_type: Optional[str] = Field(None, description='계정종류' + AccountType.get_elements_str(), example=AccountType.email) + account: Optional[str] = Field(None, description='계정', example='user1@test.com') + account__like: Optional[str] = Field(None, description='계정 부분검색', example='%user%') + email: Optional[str] = Field(None, description='전자메일', example='user1@test.com') + email__like: Optional[str] = Field(None, description='전자메일 부분검색', example='%user%') + name: Optional[str] = Field(None, description='이름', example='test') + name__like: Optional[str] = Field(None, description='이름 부분검색', example='%user%') + sex: Optional[str] = Field(None, description='성별' + SexType.get_elements_str(), example=SexType.male) + rrn: Optional[str] = Field(None, description='주민등록번호', example='123456-1234567') + address: Optional[str] = Field(None, description='주소', example='대구1') + address__like: Optional[str] = Field(None, description='주소 부분검색', example='%대구%') + phone_number: Optional[str] = Field(None, description='연락처', example='010-1234-1234') + picture: Optional[str] = Field(None, description='프로필사진', example='profile1.png') + marketing_agree: Optional[bool] = Field(False, description='마케팅동의 여부', example=False) + # extra + user_type: Optional[str] = Field(None, description='유저종류' + UserType.get_elements_str(), example=UserType.patient) + major: Optional[str] = Field(None, description='전공과목[patient 제외]', example='정형외과') + character: Optional[str] = Field(None, description='아바타 정보', example='Male_CH01') + room_id: Optional[int] = Field(None, description='입장한 방번호', example='1') + + class Config: + orm_mode = True + + +class UserSearchRes(ResponseBase): + """ + ### [Response] 유저 검색 + """ + data: List[UserInfo] = [] + + class Config: + orm_mode = True + + +class UserUpdateReq(BaseModel): + """ + ### 유저정보 변경 + """ + status: Optional[str] = Field(None, description='계정상태' + UserStatusType.get_elements_str(), example=UserStatusType.active) + login: Optional[str] = Field(None, description='로그인상태' + UserLoginType.get_elements_str(), example=UserLoginType.logout) + account_type: Optional[str] = Field(None, description='계정종류' + AccountType.get_elements_str(), example=AccountType.email) + account: Optional[str] = Field(None, description='계정', example='user1@test.com') + # pw: Optional[str] = Field(None, description='비밀번호', example='1234') + name: Optional[str] = Field(None, description='이름', example='test') + sex: Optional[str] = Field(None, description='성별' + SexType.get_elements_str(), example=SexType.male) + rrn: Optional[str] = Field(None, description='주민등록번호', example='123456-1234567') + address: Optional[str] = Field(None, description='주소', example='대구1') + phone_number: Optional[str] = Field(None, description='연락처', example='010-1234-1234') + picture: Optional[str] = Field(None, description='프로필사진', example='profile1.png') + marketing_agree: Optional[bool] = Field(False, description='마케팅동의 여부', example=False) + # extra + user_type: Optional[str] = Field(None, description='유저종류', example='patient') + major: Optional[str] = Field(None, description='전공과목[patient 제외]', example='정형외과') + character: Optional[str] = Field(None, description='전공과목[patient 제외]', example='정형외과') + room_id: Optional[int] = Field(None, description='입장한 방번호', example='1') + + class Config: + orm_mode = True + + +class UserUpdateMultiReq(BaseModel): + """ + ### [Request] 유저 변경 (multi) + """ + search_info: UserSearchReq = None + update_info: UserUpdateReq = None + + class Config: + orm_mode = True + + +class UserUpdatePWReq(BaseModel): + """ + ### [Request] 유저 비밀번호 변경 + """ + account: str = Field(None, description='계정', example='user1@test.com') + current_pw: str = Field(None, description='현재 비밀번호', example='1234') + new_pw: str = Field(None, description='신규 비밀번호', example='5678') + + +class AppointmentInfo(BaseModel): + """ + ### 진료예약 정보 + """ + id: int = Field(None, description='Table Index', example='1') + created_at: datetime = Field(None, description='생성날짜', example='2022-01-01T12:34:56') + updated_at: datetime = Field(None, description='수정날짜', example='2022-01-01T12:34:56') + + create_account: str = Field(None, description='등록자 계정', example='doctor1@doctor.com') + doctor_account: str = Field(None, description='의사 계정', example='doctor1@doctor.com') + patient_account: str = Field(None, description='환자 계정', example='test@test.com') + treatment_subject: str = Field(None, description='진료과목', example='정형외과') + date: datetime = Field(None, description='예약날짜', example='2022-01-01 10:00:00') + status: str = Field(None, description='예약상태' + AppointmentStatusType.get_elements_str(), example=AppointmentStatusType.wait) + + class Config: + orm_mode = True + + +class AppointmentInfoRes(ResponseBase): + """ + ### [Response] 진료예약 정보 + """ + data: AppointmentInfo = None + + class Config: + orm_mode = True + + +class AppointmentRegisterReq(BaseModel): + """ + ### [Request] 진료예약 등록 + """ + create_account: str = Field(None, description='등록자 계정', example='doctor1@doctor.com') + doctor_account: str = Field(None, description='의사 계정', example='doctor1@doctor.com') + patient_account: str = Field(None, description='환자 계정', example='test@test.com') + treatment_subject: str = Field(None, description='진료과목', example='정형외과') + date: datetime = Field(None, description='예약날짜', example='2022-01-01 10:00:00') + status: Optional[str] = Field(AppointmentStatusType.wait, description='예약상태' + AppointmentStatusType.get_elements_str(), example=AppointmentStatusType.wait) + + class Config: + orm_mode = True + + +class AppointmentSearchReq(BaseModel): + """ + ### [Request] 진료예약 검색 + """ + id: Optional[int] = Field(None, description='등록번호', example='1') + created_at: Optional[datetime] = Field(None, description='생성날짜', example='2022-01-01T12:34:56') + created_at__gt: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 이후)', example='2022-02-10T15:00:00') + created_at__gte: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 포함 이후)', example='2022-02-10T15:00:00') + created_at__lt: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 이전)', example='2022-02-10T15:00:00') + created_at__lte: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 포함 이전)', example='2022-02-10T15:00:00') + updated_at: Optional[datetime] = Field(None, description='수정날짜', example='2022-01-01T12:34:56') + updated_at__gt: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 이후)', example='2022-02-10T15:00:00') + updated_at__gte: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 포함 이후)', example='2022-02-10T15:00:00') + updated_at__lt: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 이전)', example='2022-02-10T15:00:00') + updated_at__lte: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 포함 이전)', example='2022-02-10T15:00:00') + + create_account: Optional[str] = Field(None, description='등록자 계정', example='doctor1@doctor.com') + doctor_account: Optional[str] = Field(None, description='의사 계정', example='doctor1@doctor.com') + patient_account: Optional[str] = Field(None, description='환자 계정', example='test@test.com') + treatment_subject: Optional[str] = Field(None, description='진료과목', example='정형외과') + status: Optional[str] = Field(None, description='진료예약상태' + AppointmentStatusType.get_elements_str(), example=AppointmentStatusType.wait) + date: Optional[datetime] = Field(None, description='예약날짜', example='2022-01-01 10:00:00') + date__gt: Optional[datetime] = Field(None, description='예약날짜(주어진 날짜 이후)', example='2022-01-01T10:00:00') + date__gte: Optional[datetime] = Field(None, description='예약날짜(주어진 날짜 포함 이후)', example='2022-01-01T10:00:00') + date__lt: Optional[datetime] = Field(None, description='예약날짜(주어진 날짜 이전)', example='2022-01-01T10:00:00') + date__lte: Optional[datetime] = Field(None, description='예약날짜(주어진 날짜 포함 이전)', example='2022-01-01T10:00:00') + + class Config: + orm_mode = True + + +class AppointmentSearchRes(ResponseBase): + """ + ### [Response] 진료예약 검색 + """ + data: List[AppointmentInfo] = [] + + class Config: + orm_mode = True + + +class AppointmentUpdateReq(BaseModel): + """ + ### [Request] 진료예약 변경 + """ + create_account: Optional[str] = Field(None, description='등록자 계정', example='doctor1@doctor.com') + doctor_account: Optional[str] = Field(None, description='의사 계정', example='doctor1@doctor.com') + patient_account: Optional[str] = Field(None, description='환자 계정', example='test@test.com') + treatment_subject: Optional[str] = Field(None, description='진료과목', example='정형외과') + date: Optional[datetime] = Field(None, description='예약날짜', example='2022-01-01 10:00:00') + status: Optional[str] = Field(None, description='진료예약상태' + AppointmentStatusType.get_elements_str(), example=AppointmentStatusType.wait) + + class Config: + orm_mode = True + + +class AppointmentUpdateMultiReq(BaseModel): + """ + ### [Request] 라이선스 변경 (multi) + """ + search_info: AppointmentSearchReq = None + update_info: AppointmentUpdateReq = None + + class Config: + orm_mode = True + + +class RoomInfo(BaseModel): + """ + ### 방 정보 + """ + id: int = Field(None, description='Table Index', example='1') + created_at: datetime = Field(None, description='생성날짜', example='2022-01-01T12:34:56') + updated_at: datetime = Field(None, description='수정날짜', example='2022-01-01T12:34:56') + + type: str = Field(None, description='방 종류' + RoomType.get_elements_str(), example=RoomType.meeting) + auto_enter: bool = Field(None, description='방 생성후, 자동 입장', example='true') + auto_delete: bool = Field(None, description='방 퇴장후, 방인원이 없을 경우 방 삭제', example='true') + name: str = Field(None, description='방제목', example='홍삼원외과') + pw: str = Field(None, description='비밀번호', example='1234') + create_account: str = Field(None, description='등록자 계정', example='doctor1@doctor.com') + status: str = Field(None, description='상태' + RoomStatusType.get_elements_str(), example=RoomStatusType.open) + cur_person: int = Field(None, description='현재 인원수', example='1') + max_person: int = Field(None, description=f'입장 최대 인원수(기본값: {MEETING_ROOM_MAX_PERSON})', example=MEETING_ROOM_MAX_PERSON) + desc: str = Field(None, description='부가정보', example='영업시간: 09:00 ~ 16:00') + + class Config: + orm_mode = True + + +class RoomInfoRes(ResponseBase): + """ + ### [Response] 방 정보 + """ + data: RoomInfo = None + + class Config: + orm_mode = True + + +class RoomRegisterReq(BaseModel): + """ + ### [Request] 방 등록 + """ + type: str = Field(RoomType.meeting, description='방 종류' + RoomType.get_elements_str(), example=RoomType.meeting) + auto_enter: Optional[bool] = Field(True, description='방 생성후, 자동 입장', example='true') + auto_delete: Optional[bool] = Field(True, description='방 퇴장후, 방인원이 없을 경우 방 삭제', example='true') + name: str = Field(None, description='방제목', example='홍삼원외과') + pw: str = Field(description='비밀번호', example='1234') + create_account: str = Field(None, description='등록자 계정', example='doctor1@doctor.com') + status: Optional[str] = Field(RoomStatusType.open, description='상태' + RoomStatusType.get_elements_str(), example=RoomStatusType.open) + max_person: Optional[int] = Field(MEETING_ROOM_MAX_PERSON, description=f'입장 최대 인원수(기본값: {MEETING_ROOM_MAX_PERSON})', example=MEETING_ROOM_MAX_PERSON) + desc: Optional[str] = Field(None, description='부가정보', example='영업시간: 09:00 ~ 16:00') + + class Config: + orm_mode = True + + +class RoomSearchReq(BaseModel): + """ + ### [Request] 방 검색 + """ + id: Optional[int] = Field(None, description='등록번호', example='1') + created_at: Optional[datetime] = Field(None, description='생성날짜', example='2022-01-01T12:34:56') + created_at__gt: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 이후)', example='2022-02-10T15:00:00') + created_at__gte: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 포함 이후)', example='2022-02-10T15:00:00') + created_at__lt: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 이전)', example='2022-02-10T15:00:00') + created_at__lte: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 포함 이전)', example='2022-02-10T15:00:00') + updated_at: Optional[datetime] = Field(None, description='수정날짜', example='2022-01-01T12:34:56') + updated_at__gt: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 이후)', example='2022-02-10T15:00:00') + updated_at__gte: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 포함 이후)', example='2022-02-10T15:00:00') + updated_at__lt: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 이전)', example='2022-02-10T15:00:00') + updated_at__lte: Optional[datetime] = Field(None, description='생성날짜(주어진 날짜 포함 이전)', example='2022-02-10T15:00:00') + + type: Optional[str] = Field(None, description='방 종류' + RoomType.get_elements_str(), example=RoomType.meeting) + auto_enter: Optional[bool] = Field(None, description='방 생성후, 자동 입장', example='true') + auto_delete: Optional[bool] = Field(None, description='방 퇴장후, 방인원이 없을 경우 방 삭제', example='true') + name: Optional[str] = Field(None, description='방제목', example='홍삼원외과') + name__like: Optional[str] = Field(None, description='방제목 부분 검색(sql문법)', example='%외과%') + create_account: Optional[str] = Field(None, description='등록자 계정', example='doctor1@doctor.com') + status: Optional[str] = Field(None, description='상태' + RoomStatusType.get_elements_str(), example=RoomStatusType.open) + cur_person: Optional[int] = Field(None, description='현재 인원수', example='1') + max_person: Optional[int] = Field(None, description=f'입장 최대 인원수(기본값: {MEETING_ROOM_MAX_PERSON})', example=MEETING_ROOM_MAX_PERSON) + desc: Optional[str] = Field(None, description='부가정보', example='영업시간: 09:00 ~ 16:00') + desc__like: Optional[str] = Field(None, description='부가정보 부분 검색(sql문법)', example='%영업%') + + class Config: + orm_mode = True + + +class RoomSearchRes(ResponseBase): + """ + ### [Response] 방 검색 + """ + data: List[RoomInfo] = [] + + class Config: + orm_mode = True + + +class RoomUpdateReq(BaseModel): + """ + ### [Request] 방 수정 + """ + type: Optional[str] = Field(None, description='방 종류' + RoomType.get_elements_str(), example=RoomType.meeting) + auto_enter: Optional[bool] = Field(None, description='방 생성후, 자동 입장', example='true') + auto_delete: Optional[bool] = Field(None, description='방 퇴장후, 방인원이 없을 경우 방 삭제', example='true') + name: Optional[str] = Field(None, description='방제목', example='홍삼원외과') + pw: Optional[str] = Field(description='비밀번호', example='1234') + create_account: Optional[str] = Field(None, description='등록자 계정', example='doctor1@doctor.com') + status: Optional[str] = Field(None, description='상태' + RoomStatusType.get_elements_str(), example=RoomStatusType.open) + cur_person: Optional[int] = Field(None, description='현재 인원수', example='1') + max_person: Optional[int] = Field(None, description=f'입장 최대 인원수(기본값: {MEETING_ROOM_MAX_PERSON})', example=MEETING_ROOM_MAX_PERSON) + desc: Optional[str] = Field(None, description='부가정보', example='영업시간: 09:00 ~ 16:00') + + class Config: + orm_mode = True + + +class RoomUpdateMultiReq(BaseModel): + """ + ### [Request] 방 변경 (multi) + """ + search_info: RoomSearchReq = None + update_info: RoomUpdateReq = None + + class Config: + orm_mode = True \ No newline at end of file diff --git a/fast_api/app/routes/auth.py b/fast_api/app/routes/auth.py new file mode 100755 index 0000000..e3d5cdb --- /dev/null +++ b/fast_api/app/routes/auth.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +""" +@File: auth.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: authentication api +""" + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +import bcrypt +import jwt +from datetime import datetime, timedelta + +from app.common import consts +from app import models as M +from app.database.conn import db +from app.database.schema import Users, Room + + +router = APIRouter(prefix='/auth') + + +@router.get('/find-account/{account}', response_model=M.ResponseBase, summary='계정유무 검사') +async def find_account(account: str): + """ + ## 계정유무 검사 + + 주어진 계정이 존재하면 true, 없으면 false 처리 + + **결과** + - ResponseBase + """ + try: + search_info = Users.get(account=account) + if not search_info: + raise Exception(f'not found data: {account}') + return M.ResponseBase() + except Exception as e: + return M.ResponseBase.set_error(str(e)) + + +@router.post('/register/{account_type}', status_code=201, response_model=M.TokenRes, summary='회원가입') +async def register(account_type: M.AccountType, request_body_info: M.UserRegisterReq, session: Session = Depends(db.session)): + """ + ## 회원가입 (신규등록) + + **결과** + - TokenRes + """ + new_user = None + + try: + # request + if not request_body_info.account or not request_body_info.pw: + raise Exception('Account and PW must be provided') + + register_user = Users.get(account=request_body_info.account) + if register_user: + raise Exception(f'exist email: {request_body_info.account}') + + if account_type == M.AccountType.email: + hash_pw = None + if request_body_info.pw: + hash_pw = bcrypt.hashpw(request_body_info.pw.encode('utf-8'), bcrypt.gensalt()) + + # create users + new_user = Users.create(session, auto_commit=True, + status=request_body_info.status, + account_type=account_type, + account=request_body_info.account, + pw=hash_pw, + email=request_body_info.email, + name=request_body_info.name, + sex=request_body_info.sex, + rrn=request_body_info.rrn, + address=request_body_info.address, + phone_number=request_body_info.phone_number, + picture=request_body_info.picture, + # extra + user_type=request_body_info.user_type, + major=request_body_info.major, + character=request_body_info.character + ) + + token = dict(Authorization=f'Bearer {create_access_token(data=M.UserToken.from_orm(new_user).dict(exclude={"pw", "marketing_agree"}),)}') + return token + + raise Exception('not supported') + except Exception as e: + if new_user: + new_user.close() + return M.ResponseBase.set_error(str(e)) + + +@router.post('/login/{account_type}', status_code=200, response_model=M.TokenRes, summary='사용자 접속') +async def login(account_type: M.AccountType, request_body_info: M.UserLoginReq): + """ + ## 사용자 접속 + + **결과** + - TokenRes + """ + login_user = None + update_user = None + + try: + # request + if not request_body_info.account: + raise Exception('invalid request: account') + + if account_type == M.AccountType.email: + # basic auth + login_user = Users.get(account=request_body_info.account) + if not login_user: + raise Exception('not found user') + + # check pw + if not request_body_info.pw: + raise Exception('invalid request: pw') + is_verified = bcrypt.checkpw(request_body_info.pw.encode('utf-8'), login_user.pw.encode('utf-8')) + if not is_verified: + raise Exception('invalid password') + + # TODO(hsj100): LOGIN_STATUS + update_user = Users.filter(account=request_body_info.account) + update_user.update(auto_commit=True, login='login') + + token = dict(Authorization=f'Bearer {create_access_token(data=M.UserToken.from_orm(login_user).dict(exclude={"pw", "marketing_agree"}), )}') + + return token + + return M.ResponseBase.set_error('not supported') + except Exception as e: + if update_user: + update_user.close() + return M.ResponseBase.set_error(str(e)) + + +@router.post('/login_out/{user_email}', status_code=200, response_model=M.TokenRes, summary='사용자 접속종료') +async def login_out(user_email: str): + """ + ## 사용자 접속종료 + + ** 방소유권을 가진 유저가 접속종료시에는 방삭제가 되며, 방에 속한 유저들의 방번호도 초기화 된다. ** + + 현재 버전에서는 로그인/로그아웃의 상태를 유지하지 않고 상태값만을 서버에서 사용하기 때문에,\n + ***로그상태는 실제상황과 다를 수 있다.*** + + 정상처리시 Authorization(null) 반환 + + **결과** + - TokenRes + """ + user_info = None + + try: + # TODO(hsj100): LOGIN_STATUS + user_info = Users.get(email=user_email) + if not user_info: + raise Exception('not found user') + + # check room (creator) + room_info = Room.get(create_account=user_email) + if room_info: + # initialize the room index of users + search_info = Users.filter(room_id=room_info.id) + search_info.update(auto_commit=True, synchronize_session=False, room_id=None) + # delete room + # search + Room.filter(id=room_info.id).delete(auto_commit=True, synchronize_session=False) + + Users.filter(email=user_email).update(auto_commit=True, login='logout') + return M.TokenRes() + except Exception as e: + if user_info: + user_info.close() + return M.ResponseBase.set_error(e) + + + +async def is_account_exist(account: str): + get_account = Users.get(account=account) + return True if get_account else False + + +def create_access_token(*, data: dict = None, expires_delta: int = None): + to_encode = data.copy() + if expires_delta: + to_encode.update({'exp': datetime.utcnow() + timedelta(hours=expires_delta)}) + encoded_jwt = jwt.encode(to_encode, consts.JWT_SECRET, algorithm=consts.JWT_ALGORITHM) + return encoded_jwt diff --git a/fast_api/app/routes/dev.py b/fast_api/app/routes/dev.py new file mode 100644 index 0000000..d6e1e48 --- /dev/null +++ b/fast_api/app/routes/dev.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- +""" +@File: dev.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: Developments Test +""" + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +import bcrypt +from starlette.responses import JSONResponse +from starlette.requests import Request +from inspect import currentframe as frame +import uuid + +from app import models as M +from app.database.conn import db, Base +from app.database.schema import Users, Appointment, Room + + +# mail test +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + + +def send_mail(): + """ + 구글 계정사용시 : 보안 수준이 낮은 앱에서의 접근 활성화 + + :return: + """ + sender = 'jolimola@gmail.com' + sender_pw = '!ghkdtmdwns1' + # recipient = 'hsj100@a2tec.co.kr' + recipient = 'jwkim@daooldns.co.kr' + list_cc = ['cc1@gmail.com', 'cc2@naver.com'] + str_cc = ','.join(list_cc) + + title = 'Test mail' + contents = ''' + This is test mail + using smtplib. + ''' + + smtp_server = smtplib.SMTP( # 1 + host='smtp.gmail.com', + port=587 + ) + + smtp_server.ehlo() # 2 + smtp_server.starttls() # 2 + smtp_server.ehlo() # 2 + smtp_server.login(sender, sender_pw) # 3 + + msg = MIMEMultipart() # 4 + msg['From'] = sender # 5 + msg['To'] = recipient # 5 + # msg['Cc'] = str_cc # 5 + msg['Subject'] = contents # 5 + msg.attach(MIMEText(contents, 'plain')) # 6 + + smtp_server.send_message(msg) # 7 + smtp_server.quit() # 8 + + +router = APIRouter(prefix='/dev') + + +@router.get('/test', summary='테스트', response_model=M.SWInfo) +async def test(request: Request): + """ + ## ELB 상태 체크용 API + + **결과** + - SWInfo + """ + # send_mail() + # print('state.user', request.state.user) + # try: + # a = 1/0 + # except Exception as e: + # request.state.inspect = frame() + # raise e + result_info = dict(uuid=uuid.uuid1()) + t = len(str(result_info['uuid'])) + return result_info + + return M.SWInfo() + + +@router.post('/db/add_test_data', summary='[DB] 테스트 데이터 생성', response_model=M.ResponseBase) +async def db_add_test_data(session: Session = Depends(db.session)): + """ + ## 테스트 데이터 생성 + + 프로젝트(DATABASE)에 테스트 데이터를 생성한다. + + **결과** + - ResponseBase + """ + # user + hash_pw = bcrypt.hashpw(str('1234').encode('utf-8'), bcrypt.gensalt()) + user_info = Users.create(session=session, auto_commit=True + , status=M.UserStatusType.active + , account='user1@test.com', pw=hash_pw + , email='user1@test.com' + , name='user1', sex=M.SexType.male + , rrn='111111-1000001', address='대구광역시 동구 1' + , phone_number='010-1111-1111', picture='Boy_1.png.png' + ,user_type = 'patient', major = None, charactor = 'Male_ch01') + + user_info = Users.create(session=session, auto_commit=True + , status=M.UserStatusType.active + , account='user2@test.com', pw=hash_pw + , email='user2@test.com' + , name='user2', sex=M.SexType.female + , rrn='111111-1000002', address='대구광역시 북구 2' + , phone_number='010-1111-2222', picture='Girl_1.png' + , user_type='patient', major=None, charactor='Female_ch01') + + user_info = Users.create(session=session, auto_commit=True + , status=M.UserStatusType.active + , account='doctor1@doctor.com', pw=hash_pw + , email='doctor1@doctor.com' + , name='doctor1', sex=M.SexType.male + , rrn='111111-2000002', address='대구광역시 서구 3' + , phone_number='010-1111-3333', picture='Boy_2.png' + , user_type='doctor', major='정형외과', charactor='Male_ch02') + + user_info = Users.create(session=session, auto_commit=True + , status=M.UserStatusType.active + , account='doctor2@doctor.com', pw=hash_pw + , email='doctor2@doctor.com' + , name='doctor2', sex=M.SexType.female + , rrn='111111-2000004', address='대구광역시 수성구 4' + , phone_number='010-1111-4444', picture='Girl_2.png' + , user_type='doctor', major='산부인과', charactor='Female_ch02') + + Appointment.create(session=session, auto_commit=True, + create_account='doctor1@doctor.com', + doctor_account='doctor1@doctor.com', + patient_account='user1@test.com', + treatment_subject='정형외과', + date='2022-01-30 10:00.00') + Appointment.create(session=session, auto_commit=True, + create_account='doctor1@doctor.com', + doctor_account='doctor1@doctor.com', + patient_account='user2@test.com', + treatment_subject='정형외과', + date='2022-02-10 16:30.00') + Appointment.create(session=session, auto_commit=True, + create_account='doctor2@doctor.com', + doctor_account='doctor2@doctor.com', + patient_account='user1@test.com', + treatment_subject='산부인과', + date='2022-03-30 11:00.00') + Appointment.create(session=session, auto_commit=True, + create_account='doctor2@doctor.com', + doctor_account='doctor2@doctor.com', + patient_account='user2@test.com', + treatment_subject='산부인과', + date='2022-04-10 13:30.00') + + return M.ResponseBase() + + +@router.post('/db/delete_all_data', summary='[DB] 데이터 삭제[전체]', response_model=M.ResponseBase) +async def db_delete_all_data(): + """ + ## DB 데이터 삭제 + + 프로젝트(DATABASE)의 모든 테이블의 내용을 삭제한다. (테이블은 유지) + + **결과** + - ResponseBase + """ + engine = db.engine + metadata = Base.metadata + + foreign_key_turn_off = { + 'mysql': 'SET FOREIGN_KEY_CHECKS=0;', + 'postgresql': 'SET CONSTRAINTS ALL DEFERRED;', + 'sqlite': 'PRAGMA foreign_keys = OFF;', + } + foreign_key_turn_on = { + 'mysql': 'SET FOREIGN_KEY_CHECKS=1;', + 'postgresql': 'SET CONSTRAINTS ALL IMMEDIATE;', + 'sqlite': 'PRAGMA foreign_keys = ON;', + } + truncate_query = { + 'mysql': 'TRUNCATE TABLE {};', + 'postgresql': 'TRUNCATE TABLE {} RESTART IDENTITY CASCADE;', + 'sqlite': 'DELETE FROM {};', + } + + with engine.begin() as conn: + conn.execute(foreign_key_turn_off[engine.name]) + + for table in reversed(metadata.sorted_tables): + conn.execute(truncate_query[engine.name].format(table.name)) + + conn.execute(foreign_key_turn_on[engine.name]) + + return M.ResponseBase() + + +@router.post('/db/delete_all_tables', summary='[DB] 테이블 삭제[전체]', response_model=M.ResponseBase) +async def db_delete_all_tables(): + """ + ## DB 데이터 삭제 + + 프로젝트(DATABASE)의 모든 테이블을 삭제한다. + + **결과** + - ResponseBase + """ + engine = db.engine + metadata = Base.metadata + + truncate_query = { + 'mysql': 'DROP TABLE IF EXISTS {};', + 'postgresql': 'DROP TABLE IF EXISTS {};', + 'sqlite': 'DROP TABLE IF EXISTS {};', + } + + with engine.begin() as conn: + for table in reversed(metadata.sorted_tables): + conn.execute(truncate_query[engine.name].format(table.name)) + + return M.ResponseBase() diff --git a/fast_api/app/routes/index.py b/fast_api/app/routes/index.py new file mode 100755 index 0000000..886504b --- /dev/null +++ b/fast_api/app/routes/index.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +""" +@File: index.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: basic & test api +""" + +from fastapi import APIRouter + +from app.utils.date_utils import D +from app.models import SWInfo + + +router = APIRouter() + + +@router.get('/', summary='서비스 정보', response_model=SWInfo) +async def index(): + """ + ## 서비스 정보 + 소프트웨어 이름, 버전정보, 현재시간 + + **결과** + - SWInfo + """ + sw_info = SWInfo() + sw_info.date = D.date_str() + return sw_info \ No newline at end of file diff --git a/fast_api/app/routes/services.py b/fast_api/app/routes/services.py new file mode 100755 index 0000000..9e5ebcb --- /dev/null +++ b/fast_api/app/routes/services.py @@ -0,0 +1,384 @@ +# -*- coding: utf-8 -*- +""" +@File: services.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: services api +""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app import models as M +from app.database.conn import db +from app.common.config import conf +from app.database.schema import Users, Appointment, Room +from app.database.crud import table_read, table_update, table_delete + + +router = APIRouter(prefix='/services') + + +@router.post('/appointment/search', response_model=M.AppointmentSearchRes, summary='진료예약 검색') +async def search_appointment(request: Request, request_body_info: M.AppointmentSearchReq): + """ + ## 진료예약 검색 + + 검색에 필요한 항목들을 Request body 에 사용한다.\n + 검색에 사용된 각 항목들은 AND 조건으로 처리된다. + + **전체검색**\n + empty object 사용 ( {} )\n + 예) Request body: {}\n + + **검색항목**\n + - 부분검색 항목\n + SQL 문법( %, _ )을 사용한다.\n + * __like: 시작포함(X%), 중간포함(%X%), 끝포함(%X) + + - 구간검색 항목 + * __lt: 주어진 값보다 작은값 + * __lte: 주어진 값보다 같거나 작은 값 + * __gt: 주어진 값보다 큰값 + * __gte: 주어진 값보다 같거나 큰값 + + **결과** + - AppointmentSearchRes + """ + # result + return await table_read(request.state.user, Appointment, request_body_info, M.AppointmentSearchRes, M.AppointmentInfo) + + +@router.post('/appointment/add', response_model=M.AppointmentInfoRes, summary='진료예약 등록') +async def add_appointment(request: Request, request_body_info: M.AppointmentRegisterReq, session: Session = Depends(db.session)): + """ + ## 진료예약 등록 + + 현재 접속 계정으로 진료예약정보를 등록한다.\n + + **진료 상태(status) 흐름**\n + - wait: 최초 진료예약 신청 상태 + - reserve: 해당 병원 조직원(doctor)에 의해서 예약 상태로 변경 + - cancel: 해당 병원 조직원(doctor)에 의해서 취소 상태로 변경 + - treatment: 해당 병원 조직원(doctor)에 의해서 진료완료 상태로 변경 + + **결과** + - AppointmentInfoRes + """ + result_info = None + + try: + # request + accessor_info = request.state.user + if not accessor_info: + raise Exception('invalid accessor') + request_info = dict() + for key, val in request_body_info.dict().items(): + if val: + request_info[key] = val + if not request_info: + raise Exception('invalid request_body') + + # check create_account + user_info = Users.get(account=request_body_info.create_account) + if not user_info: + raise Exception(f'not found user: {request_body_info.create_account}') + + # check doctor_account + user_info = Users.get(account=request_body_info.doctor_account) + if not user_info: + raise Exception(f'not found user: {request_body_info.doctor_account}') + + # check patient_account + user_info = Users.get(account=request_body_info.patient_account) + if not user_info: + raise Exception(f'not found user: {request_body_info.patient_account}') + + # create + result_info = Appointment.create(session=session, auto_commit=True, + create_account=request_body_info.create_account, + doctor_account=request_body_info.doctor_account, + patient_account=request_body_info.patient_account, + treatment_subject=request_body_info.treatment_subject, + date=request_body_info.date, + status=request_body_info.status) + + # result + return M.AppointmentInfoRes(data=result_info) + except Exception as e: + if result_info: + result_info.close() + return M.ResponseBase.set_error(str(e)) + + + +@router.put('/appointment/update', response_model=M.ResponseBase, summary='진료예약 변경') +async def update_appointment(request: Request, request_body_info: M.AppointmentUpdateMultiReq): + """ + ## 진료예약 변경 + + **search_info**: 변경대상\n + + **update_info**: 변경내용\n + - **비밀번호** 제외 + + **결과** + - ResponseBase + """ + # result + return await table_update(request.state.user, Appointment, request_body_info, M.ResponseBase) + + +@router.delete('/appointment/delete', response_model=M.ResponseBase, summary='진료예약 삭제') +async def delete_appointment_info(request: Request, request_body_info: M.AppointmentSearchReq): + """ + ## 진료예약 삭제 + + 조건에 해당하는 유저정보를 모두 삭제한다.\n + - **본 API는 DB에서 완적삭제를 하는 함수이며, 서버관리자가 사용하는 것을 권장한다.** + - **update API를 사용하여 상태 항목을 변경해서 사용하는 것을 권장.** + + `유저삭제시 관계 테이블의 정보도 같이 삭제된다.` + + **결과** + - ResponseBase + """ + # result + return await table_delete(request.state.user, Appointment, request_body_info, M.ResponseBase) + + +@router.post('/room/search', response_model=M.RoomSearchRes, summary='방 검색') +async def get_room(request: Request, request_body_info: M.RoomSearchReq): + """ + ## 방 검색 + + 검색에 필요한 항목들을 Request body 에 사용한다.\n + 검색에 사용된 각 항목들은 AND 조건으로 처리된다. + + **전체검색**\n + empty object 사용 ( {} )\n + 예) Request body: {}\n + + **검색항목**\n + - 부분검색 항목\n + SQL 문법( %, _ )을 사용한다.\n + * __like: 시작포함(X%), 중간포함(%X%), 끝포함(%X) + + - 구간검색 항목 + * __lt: 주어진 값보다 작은값 + * __lte: 주어진 값보다 같거나 작은 값 + * __gt: 주어진 값보다 큰값 + * __gte: 주어진 값보다 같거나 큰값 + + **결과** + - RoomSearchRes + """ + # result + return await table_read(request.state.user, Room, request_body_info, M.RoomSearchRes, M.RoomInfo) + + +@router.post('/room/add', response_model=M.RoomInfoRes, summary='방 생성') +async def add_room(request: Request, request_body_info: M.RoomRegisterReq, session: Session = Depends(db.session)): + """ + ## 방 등록 + + 현재 접속 계정으로 방을 생성한다.\n + + **방 상태(status) 흐름**\n + - open: 입장가능 + - close: 입장불가 + - tclose: 입장불가(일시적으로 입장불가 상태로 할 경우) + - full: 입장불가(만원) + + **결과** + - RoomInfoRes + """ + result_info = None + + try: + # request + accessor_info = request.state.user + if not accessor_info: + raise Exception('invalid accessor') + request_info = dict() + for key, val in request_body_info.dict().items(): + if val: + request_info[key] = val + if not request_info: + raise Exception('invalid request_body') + + # check creator + user_info = Users.get(account=request_body_info.create_account) + if not user_info: + raise Exception(f'not found user: {request_body_info.create_account}') + + # check conditions + search_info = Room.filter(create_account=request_body_info.create_account).all() + if len(search_info) > 0: + raise Exception(f'already exists the rooms in user: {len(search_info)}') + + # create + result_info = Room.create(session=session, auto_commit=True, + type=request_body_info.type, + name=request_body_info.name, + pw=request_body_info.pw, + create_account=request_body_info.create_account, + status=request_body_info.status, + cur_person=1 if request_body_info.auto_enter else 0, + max_person=request_body_info.max_person, + desc=request_body_info.desc) + + # enter + if request_body_info.auto_enter: + Users.filter(account=request_body_info.create_account).update(auto_commit=True, synchronize_session=False, room_id=result_info.id) + + # result + return M.RoomInfoRes(data=result_info) + except Exception as e: + if result_info: + result_info.close() + return M.ResponseBase.set_error(str(e)) + + +@router.put('/room/update', response_model=M.ResponseBase, summary='방 변경') +async def update_room(request: Request, request_body_info: M.RoomUpdateMultiReq): + """ + ## 방 변경 + + **search_info**: 변경대상\n + + **update_info**: 변경내용\n + - **비밀번호** 제외 + + **결과** + - ResponseBase + """ + return await table_update(request.state.user, Room, request_body_info, M.ResponseBase) + + +@router.delete('/room/delete', response_model=M.ResponseBase, summary='방 삭제') +async def delete_room(request: Request, request_body_info: M.RoomSearchReq): + """ + ## 방 삭제 + 조건에 해당하는 정보를 모두 삭제한다.\n + - **본 API는 DB에서 완적삭제를 하는 함수이며, 서버관리자가 사용하는 것을 권장한다.** + - **update API를 사용하여 상태 항목을 변경해서 사용하는 것을 권장.** + + `삭제시, 이미 입장한 인원의 방번호는 전부 초기화 된다.` + + **결과** + - ResponseBase + """ + search_info = None + + try: + # request + accessor_info = request.state.user + if not accessor_info: + raise Exception('invalid accessor') + + # initialize the room index of users + search_info = Users.filter(room_id=request_body_info.id) + search_info.update(auto_commit=True, synchronize_session=False, room_id=None) + + # result + return await table_delete(request.state.user, Room, request_body_info, M.ResponseBase) + except Exception as e: + if search_info: + search_info.close() + return M.ResponseBase.set_error(str(e)) + + +@router.post('/room/enter', response_model=M.RoomInfoRes, summary='방 입장') +async def enter_room(request: Request, room_id: int = Query(None, description='입장할 방 번호'), room_pw: str = Query(None, description='비밀번호')): + """ + ## 방 입장 + + **결과** + - RoomInfoRes + """ + try: + # check - user + connect_user = request.state.user + user_info = Users.get(account=connect_user.account) + if not user_info: + raise Exception('invalid user(current)') + + # check - room + room_info = Room.get(id=room_id) + if not room_info: + raise Exception('not found room') + if room_pw != room_info.pw: + raise Exception('invalid room_pw') + + # check - number of person + cur_person = room_info.cur_person + if room_info.max_person <= room_info.cur_person: + raise Exception(f'full! (cur_person: {room_info.cur_person})') + + # check - creator + is_creator = False + if room_info.create_account == user_info.account: + is_creator = True + + if room_info.status != 'open' and not is_creator: + raise Exception('abort entrance') + + # update - user + Users.filter(id=user_info.id).update(auto_commit=True, room_id=room_info.id) + + # update - room + user_num = Users.filter(room_id=room_info.id).count() + result_room_info = Room.filter(id=room_info.id).update(auto_commit=True, cur_person=user_num) + + # result + return M.RoomInfoRes(data=result_room_info) + except Exception as e: + return M.ResponseBase.set_error(str(e)) + + +@router.post('/room/leave', response_model=M.RoomInfoRes, summary='방 퇴장') +async def leave_room(request: Request, room_id: int = Query(None, description='퇴장할 방 번호')): + """ + ## 방 퇴장 + + **결과** + - RoomInfoRes + - 자동 방 삭제(**auto_delete**)가 될 경우에는 **data** 항목은 null로 반환 + """ + try: + # check - user + connect_user = request.state.user + user_info = Users.get(account=connect_user.account) + if not user_info: + raise Exception(f'not found user: {connect_user.account}') + if user_info.room_id != room_id: + raise Exception(f'invalid room: {user_info.room_id}') + + # check - room + room_info = Room.get(id=room_id) + if not room_info: + raise Exception('not found room') + + # update - user + Users.filter(id=user_info.id).update(auto_commit=True, room_id=None) + + # update - room + user_num = Users.filter(room_id=room_info.id).count() + result_room_info = Room.filter(id=room_info.id).update(auto_commit=True, cur_person=user_num) + + # delete - room + if result_room_info.cur_person < 1: + if room_info.auto_delete: + Room.filter(id=room_info.id).delete(auto_commit=True) + # result + return M.ResponseBase() + + # result + return M.RoomInfoRes(data=result_room_info) + except Exception as e: + return M.ResponseBase.set_error(str(e)) diff --git a/fast_api/app/routes/users.py b/fast_api/app/routes/users.py new file mode 100755 index 0000000..71dc5b3 --- /dev/null +++ b/fast_api/app/routes/users.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +""" +@File: users.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: users api +""" + +from fastapi import APIRouter +from starlette.requests import Request +import bcrypt + +from app import models as M +from app.common.config import conf +from app.database.schema import Users +from app.database.crud import table_read, table_update, table_delete + + +router = APIRouter(prefix='/user') + + +@router.get('/me', response_model=M.UserSearchRes, summary='접속자 정보') +async def get_me(request: Request): + """ + ## 현재 접속된 자신정보 확인 + + **결과** + - UserSearchRes + """ + target_table = Users + search_info = None + + try: + # request + if conf().GLOBAL_TOKEN: + raise Exception('not supported: use search api!') + + accessor_info = request.state.user + if not accessor_info: + raise Exception('invalid accessor') + + # search + search_info = target_table.get(account=accessor_info.account) + if not search_info: + raise Exception('not found data') + + # result + result_info = list() + result_info.append(M.UserInfo.from_orm(search_info)) + return M.UserSearchRes(data=result_info) + except Exception as e: + if search_info: + search_info.close() + return M.ResponseBase.set_error(str(e)) + + +@router.post('/search', response_model=M.UserSearchRes, summary='유저정보 검색') +async def search_user(request: Request, request_body_info: M.UserSearchReq): + """ + ## 유저정보 검색 + + 검색에 필요한 항목들을 Request body 에 사용한다.\n + 검색에 사용된 각 항목들은 AND 조건으로 처리된다. + + **전체검색**\n + empty object 사용 ( {} )\n + 예) Request body: {}\n + + **검색항목**\n + - 부분검색 항목\n + SQL 문법( %, _ )을 사용한다.\n + * __like: 시작포함(X%), 중간포함(%X%), 끝포함(%X) + + - 구간검색 항목 + * __lt: 주어진 값보다 작은값 + * __lte: 주어진 값보다 같거나 작은 값 + * __gt: 주어진 값보다 큰값 + * __gte: 주어진 값보다 같거나 큰값 + + **결과** + - UserSearchRes + """ + return await table_read(request.state.user, Users, request_body_info, M.UserSearchRes, M.UserInfo) + + +@router.put('/update', response_model=M.ResponseBase, summary='유저정보 변경') +async def update_user(request: Request, request_body_info: M.UserUpdateMultiReq): + """ + ## 유저정보 변경 + + **search_info**: 변경대상\n + + **update_info**: 변경내용\n + - **비밀번호** 제외 + + **결과** + - ResponseBase + """ + return await table_update(request.state.user, Users, request_body_info, M.ResponseBase) + + +@router.put('/update_pw', response_model=M.ResponseBase, summary='유저 비밀번호 변경') +async def update_user_pw(request: Request, request_body_info: M.UserUpdatePWReq): + """ + ## 유저정보 비밀번호 변경 + + **account**의 **비밀번호**를 변경한다. + + **결과** + - ResponseBase + """ + target_table = Users + search_info = None + + try: + # request + accessor_info = request.state.user + if not accessor_info: + raise Exception('invalid accessor') + if not request_body_info.account: + raise Exception('invalid account') + + # search + target_user = target_table.get(account=request_body_info.account) + is_verified = bcrypt.checkpw(request_body_info.current_pw.encode('utf-8'), target_user.pw.encode('utf-8')) + if not is_verified: + raise Exception('invalid password') + + search_info = target_table.filter(id=target_user.id) + if not search_info.first(): + raise Exception('not found data') + + # process + hash_pw = bcrypt.hashpw(request_body_info.new_pw.encode('utf-8'), bcrypt.gensalt()) + result_info = search_info.update(auto_commit=True, pw=hash_pw) + if not result_info or not result_info.id: + raise Exception('failed update') + + # result + return M.ResponseBase() + except Exception as e: + if search_info: + search_info.close() + return M.ResponseBase.set_error(str(e)) + + +@router.delete('/delete', response_model=M.ResponseBase, summary='유저정보 삭제') +async def delete_user(request: Request, request_body_info: M.UserSearchReq): + """ + ## 유저정보 삭제 + 조건에 해당하는 정보를 모두 삭제한다.\n + - **본 API는 DB에서 완적삭제를 하는 함수이며, 서버관리자가 사용하는 것을 권장한다.** + - **update API를 사용하여 상태 항목을 변경해서 사용하는 것을 권장.** + + `유저삭제시 관계 테이블의 정보도 같이 삭제된다.` + + **결과** + - ResponseBase + """ + return await table_delete(request.state.user, Users, request_body_info, M.ResponseBase) + + +# NOTE(hsj100): apikey +""" +""" +# @router.get('/apikeys', response_model=List[M.GetApiKeyList]) +# async def get_api_keys(request: Request): +# """ +# API KEY 조회 +# :param request: +# :return: +# """ +# user = request.state.user +# api_keys = ApiKeys.filter(user_id=user.id).all() +# return api_keys +# +# +# @router.post('/apikeys', response_model=M.GetApiKeys) +# async def create_api_keys(request: Request, key_info: M.AddApiKey, session: Session = Depends(db.session)): +# """ +# API KEY 생성 +# :param request: +# :param key_info: +# :param session: +# :return: +# """ +# user = request.state.user +# +# api_keys = ApiKeys.filter(session, user_id=user.id, status='active').count() +# if api_keys == MAX_API_KEY: +# raise ex.MaxKeyCountEx() +# +# alphabet = string.ascii_letters + string.digits +# s_key = ''.join(secrets.choice(alphabet) for _ in range(40)) +# uid = None +# while not uid: +# uid_candidate = f'{str(uuid4())[:-12]}{str(uuid4())}' +# uid_check = ApiKeys.get(access_key=uid_candidate) +# if not uid_check: +# uid = uid_candidate +# +# key_info = key_info.dict() +# new_key = ApiKeys.create(session, auto_commit=True, secret_key=s_key, user_id=user.id, access_key=uid, **key_info) +# return new_key +# +# +# @router.put('/apikeys/{key_id}', response_model=M.GetApiKeyList) +# async def update_api_keys(request: Request, key_id: int, key_info: M.AddApiKey): +# """ +# API KEY User Memo Update +# :param request: +# :param key_id: +# :param key_info: +# :return: +# """ +# user = request.state.user +# key_data = ApiKeys.filter(id=key_id) +# if key_data and key_data.first().user_id == user.id: +# return key_data.update(auto_commit=True, **key_info.dict()) +# raise ex.NoKeyMatchEx() +# +# +# @router.delete('/apikeys/{key_id}') +# async def delete_api_keys(request: Request, key_id: int, access_key: str): +# user = request.state.user +# await check_api_owner(user.id, key_id) +# search_by_key = ApiKeys.filter(access_key=access_key) +# if not search_by_key.first(): +# raise ex.NoKeyMatchEx() +# search_by_key.delete(auto_commit=True) +# return MessageOk() +# +# +# @router.get('/apikeys/{key_id}/whitelists', response_model=List[M.GetAPIWhiteLists]) +# async def get_api_keys(request: Request, key_id: int): +# user = request.state.user +# await check_api_owner(user.id, key_id) +# whitelists = ApiWhiteLists.filter(api_key_id=key_id).all() +# return whitelists +# +# +# @router.post('/apikeys/{key_id}/whitelists', response_model=M.GetAPIWhiteLists) +# async def create_api_keys(request: Request, key_id: int, ip: M.CreateAPIWhiteLists, session: Session = Depends(db.session)): +# user = request.state.user +# await check_api_owner(user.id, key_id) +# import ipaddress +# try: +# _ip = ipaddress.ip_address(ip.ip_addr) +# except Exception as e: +# raise ex.InvalidIpEx(ip.ip_addr, e) +# if ApiWhiteLists.filter(api_key_id=key_id).count() == MAX_API_WHITELIST: +# raise ex.MaxWLCountEx() +# ip_dup = ApiWhiteLists.get(api_key_id=key_id, ip_addr=ip.ip_addr) +# if ip_dup: +# return ip_dup +# ip_reg = ApiWhiteLists.create(session=session, auto_commit=True, api_key_id=key_id, ip_addr=ip.ip_addr) +# return ip_reg +# +# +# @router.delete('/apikeys/{key_id}/whitelists/{list_id}') +# async def delete_api_keys(request: Request, key_id: int, list_id: int): +# user = request.state.user +# await check_api_owner(user.id, key_id) +# ApiWhiteLists.filter(id=list_id, api_key_id=key_id).delete() +# +# return MessageOk() +# +# +# async def check_api_owner(user_id, key_id): +# api_keys = ApiKeys.get(id=key_id, user_id=user_id) +# if not api_keys: +# raise ex.NoKeyMatchEx() diff --git a/fast_api/app/utils/date_utils.py b/fast_api/app/utils/date_utils.py new file mode 100755 index 0000000..4ac70df --- /dev/null +++ b/fast_api/app/utils/date_utils.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +""" +@File: date_utils.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: date-utility functions +""" + +from datetime import datetime, date, timedelta + +_TIMEDELTA = 9 + +class D: + def __init__(self, *args): + self.utc_now = datetime.utcnow() + # NOTE(hsj100): utc->kst + self.timedelta = _TIMEDELTA + + @classmethod + def datetime(cls, diff: int=_TIMEDELTA) -> datetime: + return datetime.utcnow() + timedelta(hours=diff) if diff > 0 else datetime.now() + + @classmethod + def date(cls, diff: int=_TIMEDELTA) -> date: + return cls.datetime(diff=diff).date() + + @classmethod + def date_num(cls, diff: int=_TIMEDELTA) -> int: + return int(cls.date(diff=diff).strftime('%Y%m%d')) + + @classmethod + def validate(cls, date_text): + try: + datetime.strptime(date_text, '%Y-%m-%d') + except ValueError: + raise ValueError('Incorrect data format, should be YYYY-MM-DD') + + @classmethod + def date_str(cls, diff: int = _TIMEDELTA) -> str: + return cls.datetime(diff=diff).strftime('%Y-%m-%dT%H:%M:%S') + + @classmethod + def check_expire_date(cls, expire_date: datetime): + td = expire_date - datetime.now() + timestamp = td.total_seconds() + return timestamp diff --git a/fast_api/app/utils/extra.py b/fast_api/app/utils/extra.py new file mode 100644 index 0000000..286b95f --- /dev/null +++ b/fast_api/app/utils/extra.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +""" +@File: extra.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: extra functions +""" + +import uuid +from datetime import datetime, timedelta + +from app.common.consts import NUM_RETRY_UUID_GEN +from app.database.schema import License +from app.utils.date_utils import D +from app import models as M + diff --git a/fast_api/app/utils/logger.py b/fast_api/app/utils/logger.py new file mode 100755 index 0000000..e34cea6 --- /dev/null +++ b/fast_api/app/utils/logger.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +""" +@File: logger.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: logger +""" + +import json +import logging +from datetime import timedelta, datetime +from time import time +from fastapi.requests import Request +from fastapi import Body +from fastapi.logger import logger + +logger.setLevel(logging.INFO) + + +async def api_logger(request: Request, response=None, error=None): + time_format = '%Y/%m/%d %H:%M:%S' + t = time() - request.state.start + status_code = error.status_code if error else response.status_code + error_log = None + user = request.state.user + if error: + if request.state.inspect: + frame = request.state.inspect + error_file = frame.f_code.co_filename + error_func = frame.f_code.co_name + error_line = frame.f_lineno + else: + error_func = error_file = error_line = 'UNKNOWN' + + error_log = dict( + errorFunc=error_func, + location='{} line in {}'.format(str(error_line), error_file), + raised=str(error.__class__.__name__), + msg=str(error.ex), + ) + + account = user.account.split('@') if user and user.account else None + user_log = dict( + client=request.state.ip, + user=user.id if user and user.id else None, + account='**' + account[0][2:-1] + '*@' + account[1] if user and user.account else None, + ) + + log_dict = dict( + url=request.url.hostname + request.url.path, + method=str(request.method), + statusCode=status_code, + errorDetail=error_log, + client=user_log, + processedTime=str(round(t * 1000, 5)) + 'ms', + datetimeUTC=datetime.utcnow().strftime(time_format), + datetimeKST=(datetime.utcnow() + timedelta(hours=9)).strftime(time_format), + ) + if error and error.status_code >= 500: + logger.error(json.dumps(log_dict)) + else: + logger.info(json.dumps(log_dict)) diff --git a/fast_api/app/utils/query_utils.py b/fast_api/app/utils/query_utils.py new file mode 100755 index 0000000..1e7c524 --- /dev/null +++ b/fast_api/app/utils/query_utils.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +""" +@File: query_utils.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: query-utility functions +""" + +from typing import List + + +def to_dict(model, *args, exclude: List = None): + q_dict = {} + for c in model.__table__.columns: + if not args or c.name in args: + if not exclude or c.name not in exclude: + q_dict[c.name] = getattr(model, c.name) + + return q_dict diff --git a/fast_api/docker-build.sh b/fast_api/docker-build.sh new file mode 100644 index 0000000..c4b12b5 --- /dev/null +++ b/fast_api/docker-build.sh @@ -0,0 +1,2 @@ +docker build -t metaverse/medical_rest:latest . + diff --git a/fast_api/docker-compose.yml b/fast_api/docker-compose.yml new file mode 100644 index 0000000..3db4a25 --- /dev/null +++ b/fast_api/docker-compose.yml @@ -0,0 +1,18 @@ +version: '2.1' +services: + api: + # depends_on: + # mysql: + # condition: service_healthy + container_name: metaverse_medical_rest + image: metaverse/medical_rest:latest + build: + context: . + environment: + - TZ=Asia/Seoul + volumes: + - ./fast_api:/FAST_API + ports: + - 50510-50512:50510-50512 + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "50510"] #local + # command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "50532"] #my \ No newline at end of file diff --git a/fast_api/environment.yml b/fast_api/environment.yml new file mode 100755 index 0000000..711e597 --- /dev/null +++ b/fast_api/environment.yml @@ -0,0 +1,10 @@ +name: medical_metaverse + +channels: + - conda-forge + +dependencies: + - python=3.9 + - pip + - pip: + - -r requirements.txt diff --git a/fast_api/gunicorn.conf.py b/fast_api/gunicorn.conf.py new file mode 100755 index 0000000..b5db00a --- /dev/null +++ b/fast_api/gunicorn.conf.py @@ -0,0 +1,222 @@ +# Gunicorn configuration file. +# +# Server socket +# +# bind - The socket to bind. +# +# A string of the form: 'HOST', 'HOST:PORT', 'unix:PATH'. +# An IP is a valid HOST. +# +# backlog - The number of pending connections. This refers +# to the number of clients that can be waiting to be +# served. Exceeding this number results in the client +# getting an error when attempting to connect. It should +# only affect servers under significant load. +# +# Must be a positive integer. Generally set in the 64-2048 +# range. +# + +bind = "0.0.0.0:5000" +backlog = 2048 + +# +# Worker processes +# +# workers - The number of worker processes that this server +# should keep alive for handling requests. +# +# A positive integer generally in the 2-4 x $(NUM_CORES) +# range. You'll want to vary this a bit to find the best +# for your particular application's work load. +# +# worker_class - The type of workers to use. The default +# sync class should handle most 'normal' types of work +# loads. You'll want to read +# http://docs.gunicorn.org/en/latest/design.html#choosing-a-worker-type +# for information on when you might want to choose one +# of the other worker classes. +# +# A string referring to a Python path to a subclass of +# gunicorn.workers.base.Worker. The default provided values +# can be seen at +# http://docs.gunicorn.org/en/latest/settings.html#worker-class +# +# worker_connections - For the eventlet and gevent worker classes +# this limits the maximum number of simultaneous clients that +# a single process can handle. +# +# A positive integer generally set to around 1000. +# +# timeout - If a worker does not notify the master process in this +# number of seconds it is killed and a new worker is spawned +# to replace it. +# +# Generally set to thirty seconds. Only set this noticeably +# higher if you're sure of the repercussions for sync workers. +# For the non sync workers it just means that the worker +# process is still communicating and is not tied to the length +# of time required to handle a single request. +# +# keepalive - The number of seconds to wait for the next request +# on a Keep-Alive HTTP connection. +# +# A positive integer. Generally set in the 1-5 seconds range. +# +# reload - Restart workers when code changes. +# +# This setting is intended for development. It will cause +# workers to be restarted whenever application code changes. +workers = 3 +threads = 3 +worker_class = "uvicorn.workers.UvicornWorker" +worker_connections = 1000 +timeout = 60 +keepalive = 2 +reload = True + +# +# spew - Install a trace function that spews every line of Python +# that is executed when running the server. This is the +# nuclear option. +# +# True or False +# + +spew = False + +# +# Server mechanics +# +# daemon - Detach the main Gunicorn process from the controlling +# terminal with a standard fork/fork sequence. +# +# True or False +# +# raw_env - Pass environment variables to the execution environment. +# +# pidfile - The path to a pid file to write +# +# A path string or None to not write a pid file. +# +# user - Switch worker processes to run as this user. +# +# A valid user id (as an integer) or the name of a user that +# can be retrieved with a call to pwd.getpwnam(value) or None +# to not change the worker process user. +# +# group - Switch worker process to run as this group. +# +# A valid group id (as an integer) or the name of a user that +# can be retrieved with a call to pwd.getgrnam(value) or None +# to change the worker processes group. +# +# umask - A mask for file permissions written by Gunicorn. Note that +# this affects unix socket permissions. +# +# A valid value for the os.umask(mode) call or a string +# compatible with int(value, 0) (0 means Python guesses +# the base, so values like "0", "0xFF", "0022" are valid +# for decimal, hex, and octal representations) +# +# tmp_upload_dir - A directory to store temporary request data when +# requests are read. This will most likely be disappearing soon. +# +# A path to a directory where the process owner can write. Or +# None to signal that Python should choose one on its own. +# + +daemon = False +pidfile = None +umask = 0 +user = None +group = None +tmp_upload_dir = None + +# +# Logging +# +# logfile - The path to a log file to write to. +# +# A path string. "-" means log to stdout. +# +# loglevel - The granularity of log output +# +# A string of "debug", "info", "warning", "error", "critical" +# + +errorlog = "-" +loglevel = "info" +accesslog = None +access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + +# +# Process naming +# +# proc_name - A base to use with setproctitle to change the way +# that Gunicorn processes are reported in the system process +# table. This affects things like 'ps' and 'top'. If you're +# going to be running more than one instance of Gunicorn you'll +# probably want to set a name to tell them apart. This requires +# that you install the setproctitle module. +# +# A string or None to choose a default of something like 'gunicorn'. +# + +proc_name = "NotificationAPI" + + +# +# Server hooks +# +# post_fork - Called just after a worker has been forked. +# +# A callable that takes a server and worker instance +# as arguments. +# +# pre_fork - Called just prior to forking the worker subprocess. +# +# A callable that accepts the same arguments as after_fork +# +# pre_exec - Called just prior to forking off a secondary +# master process during things like config reloading. +# +# A callable that takes a server instance as the sole argument. +# + + +def post_fork(server, worker): + server.log.info("Worker spawned (pid: %s)", worker.pid) + + +def pre_fork(server, worker): + pass + + +def pre_exec(server): + server.log.info("Forked child, re-executing.") + + +def when_ready(server): + server.log.info("Server is ready. Spawning workers") + + +def worker_int(worker): + worker.log.info("worker received INT or QUIT signal") + + # get traceback info + import threading, sys, traceback + + id2name = {th.ident: th.name for th in threading.enumerate()} + code = [] + for threadId, stack in sys._current_frames().items(): + code.append("\n# Thread: %s(%d)" % (id2name.get(threadId, ""), threadId)) + for filename, lineno, name, line in traceback.extract_stack(stack): + code.append('File: "%s", line %d, in %s' % (filename, lineno, name)) + if line: + code.append(" %s" % (line.strip())) + worker.log.debug("\n".join(code)) + + +def worker_abort(worker): + worker.log.info("worker received SIGABRT signal") diff --git a/fast_api/requirements.txt b/fast_api/requirements.txt new file mode 100755 index 0000000..b0f1a2b --- /dev/null +++ b/fast_api/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.70.1 +uvicorn==0.16.0 +pymysql==1.0.2 +sqlalchemy==1.4.29 +bcrypt==3.2.0 +pyjwt==2.3.0 +yagmail==0.14.261 +boto3==1.20.32 +pytest==6.2.5 +cryptography + +#extra +pycryptodomex +pycryptodome \ No newline at end of file diff --git a/fast_api/test_main.py b/fast_api/test_main.py new file mode 100755 index 0000000..0062446 --- /dev/null +++ b/fast_api/test_main.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +""" +@File: test_main.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: 개발용 실행 +""" + +import uvicorn +from app.common.config import conf + + +if __name__ == '__main__': + print('test_main.py run') + uvicorn.run('app.main:app', host='0.0.0.0', port=conf().REST_SERVER_PORT, reload=True) diff --git a/fast_api/tests/__init__.py b/fast_api/tests/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/fast_api/tests/conftest.py b/fast_api/tests/conftest.py new file mode 100755 index 0000000..5221c4c --- /dev/null +++ b/fast_api/tests/conftest.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +""" +@File: conftest.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: test config +""" + +import asyncio +import os +from os import path +from typing import List + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.database.schema import Users +from app.main import create_app +from app.database.conn import db, Base +from app.models import UserToken +from app.routes.auth import create_access_token + + +""" +1. DB 생성 +2. 테이블 생성 +3. 테스트 코드 작동 +4. 테이블 레코드 삭제 +""" + +@pytest.fixture(scope='session') +def app(): + os.environ['API_ENV'] = 'test' + return create_app() + + +@pytest.fixture(scope='session') +def client(app): + # Create tables + Base.metadata.create_all(db.engine) + return TestClient(app=app) + + +@pytest.fixture(scope='function', autouse=True) +def session(): + sess = next(db.session()) + yield sess + clear_all_table_data( + session=sess, + metadata=Base.metadata, + except_tables=[] + ) + sess.rollback() + + +@pytest.fixture(scope='function') +def login(session): + """ + 테스트전 사용자 미리 등록 + :param session: + :return: + """ + db_user = Users.create(session=session, email='ryan_test@dingrr.com', pw='123') + session.commit() + access_token = create_access_token(data=UserToken.from_orm(db_user).dict(exclude={'pw', 'marketing_agree'}),) + return dict(Authorization=f'Bearer {access_token}') + + +def clear_all_table_data(session: Session, metadata, except_tables: List[str] = None): + session.execute('SET FOREIGN_KEY_CHECKS = 0;') + for table in metadata.sorted_tables: + if table.name not in except_tables: + session.execute(table.delete()) + session.execute('SET FOREIGN_KEY_CHECKS = 1;') + session.commit() diff --git a/fast_api/tests/test_auth.py b/fast_api/tests/test_auth.py new file mode 100755 index 0000000..b3669c2 --- /dev/null +++ b/fast_api/tests/test_auth.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +""" +@File: test_auth.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: test auth. +""" + +from app.database.conn import db +from app.database.schema import Users + + +def test_registration(client, session): + """ + 레버 로그인 + :param client: + :param session: + :return: + """ + user = dict(email='ryan@dingrr.com', pw='123', name='라이언', phone='01099999999') + res = client.post('api/auth/register/email', json=user) + res_body = res.json() + print(res.json()) + assert res.status_code == 201 + assert 'Authorization' in res_body.keys() + + +def test_registration_exist_email(client, session): + """ + 레버 로그인 + :param client: + :param session: + :return: + """ + user = dict(email='Hello@dingrr.com', pw='123', name='라이언', phone='01099999999') + db_user = Users.create(session=session, **user) + session.commit() + res = client.post('api/auth/register/email', json=user) + res_body = res.json() + assert res.status_code == 400 + assert 'EMAIL_EXISTS' == res_body['msg'] diff --git a/fast_api/tests/test_user.py b/fast_api/tests/test_user.py new file mode 100755 index 0000000..7d04437 --- /dev/null +++ b/fast_api/tests/test_user.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +""" +@File: test_user.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: test user +""" + +from app.database.conn import db +from app.database.schema import Users + + +def test_create_get_apikey(client, session, login): + """ + 레버 로그인 + :param client: + :param session: + :return: + """ + key = dict(user_memo='ryan__key') + res = client.post('api/user/apikeys', json=key, headers=login) + res_body = res.json() + assert res.status_code == 200 + assert 'secret_key' in res_body + + res = client.get('api/user/apikeys', headers=login) + res_body = res.json() + assert res.status_code == 200 + assert 'ryan__key' in res_body[0]['user_memo'] + diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..8f01768 --- /dev/null +++ b/readme.md @@ -0,0 +1,27 @@ +# A2TEC_METAVERSE_MEDICAL_REST_SERVER + +A2TEC_METAVERSE_MEDICAL_REST_SERVER +RESTful API Server + + +### System Requirements + - Ubuntu 20.04 + - docker 20.10.17 + - docker-compose 1.29.2 + + +### Docker + - Base Image + * python:3.9.7-slim + * mysql:latest + + - Target Image + * metaverse/medical_rest:latest + + - Build & Run + * docker-compose.yml 파일의 환경변수(environment) 수정 + * fast_api/app/common/consts.py 환경에 맞게 수정 후 실행 + ``` + docker-compose up -d + ``` +