diff --git a/.gitignore b/.gitignore index 30a79af..84c7053 100644 --- a/.gitignore +++ b/.gitignore @@ -144,4 +144,5 @@ cython_debug/ log sheet_counter.txt -*output \ No newline at end of file +*output +datas/eyewear_all/* \ No newline at end of file diff --git a/README.md b/README.md index 574c72e..ee62f85 100644 --- a/README.md +++ b/README.md @@ -24,5 +24,10 @@ RESTful API Server https://github.com/acheong08/BingImageCreator ``` +### faiss + 검색된 이미지는 search_'matching'.png 로 저장됨 + matching : 매칭률 (클수록 좋음) + ### notice - const.py 에 지정한 OUTPUT_FOLDER 하위폴더에 이미지 저장됨. \ No newline at end of file + const.py 에 지정한 OUTPUT_FOLDER 하위폴더에 이미지 저장됨. + faiss api 사용시 rest_vactor 서버 구동해야함. \ No newline at end of file diff --git a/const.py b/const.py index 2a0cd73..80abf35 100644 --- a/const.py +++ b/const.py @@ -1,3 +1,4 @@ +# OUTPUT_FOLDER = "/home/fermat/STORAGE/01.Projects/A2TEC/K_EYEWEAR/02.ML_DATA/Image_generator_result" OUTPUT_FOLDER = "./output" ILLEGAL_FILE_NAME = ['<', '>', ':', '"', '/', '\ ', '|', '?', '*'] \ No newline at end of file diff --git a/custom_apps/bingart/bingart.py b/custom_apps/bingart/bingart.py index 7809f81..268755b 100644 --- a/custom_apps/bingart/bingart.py +++ b/custom_apps/bingart/bingart.py @@ -4,8 +4,8 @@ from pathlib import Path from bingart import BingArt from custom_apps.utils import cookie_manager -from rest.app.utils.parsing_utils import prompt_to_filenames -from rest.app.utils.date_utils import D +from main_rest.app.utils.parsing_utils import prompt_to_filenames +from main_rest.app.utils.date_utils import D from const import OUTPUT_FOLDER diff --git a/custom_apps/bingimagecreator/BingImageCreator.py b/custom_apps/bingimagecreator/BingImageCreator.py index 1d7833e..610c8b0 100644 --- a/custom_apps/bingimagecreator/BingImageCreator.py +++ b/custom_apps/bingimagecreator/BingImageCreator.py @@ -16,8 +16,8 @@ import pkg_resources import regex import requests -from rest.app.utils.parsing_utils import prompt_to_filenames -from rest.app.utils.date_utils import D +from main_rest.app.utils.parsing_utils import prompt_to_filenames +from main_rest.app.utils.date_utils import D BING_URL = os.getenv("BING_URL", "https://www.bing.com") # Generate random IP between range 13.104.0.0/14 diff --git a/custom_apps/faiss/const.py b/custom_apps/faiss/const.py new file mode 100644 index 0000000..f3b5ad1 --- /dev/null +++ b/custom_apps/faiss/const.py @@ -0,0 +1,2 @@ +DATASET_BIN = "./datas/eyewear_all.fvecs.bin" +DATASET_TEXT = "./datas/eyewear_all.fnames.txt" \ No newline at end of file diff --git a/custom_apps/faiss/main.py b/custom_apps/faiss/main.py new file mode 100644 index 0000000..91d4a95 --- /dev/null +++ b/custom_apps/faiss/main.py @@ -0,0 +1,50 @@ +import os +import shutil + +from custom_apps.faiss.utils import preprocessing, preprocessing_quary, normalize, get_dataset_list +from custom_apps.faiss.const import * +# from rest.app.utils.date_utils import D +# from const import OUTPUT_FOLDER + +dataset_list = get_dataset_list(DATASET_TEXT) + +def search_idxs(image_path,dataset_bin=DATASET_BIN,index_type="hnsw",search_num=4): + + if index_type not in ["hnsw", "l2"]: + raise ValueError("index_type must be either 'hnsw' or 'l2'") + + DIM = 1280 + + dataset_fvces, dataset_index = preprocessing(DIM,dataset_bin,index_type) + + org_fvces, org_index = preprocessing_quary(DIM,image_path,index_type) + + dists, idxs = dataset_index.search(normalize(org_fvces), search_num) + + # print(dists[0]) + # print(idxs[0]) + + index_image_save(image_path, dists[0], idxs[0]) + +def index_image_save(query_image_path, dists, idxs): + directory_path, file = os.path.split(query_image_path) + _name, extension = os.path.splitext(file) + + if not os.path.exists(directory_path): + raise ValueError(f"Folder {directory_path} does not exist.") + + for dist,index in zip(dists, idxs): + + if dist > 1: + dist = 0 + else: + dist = 1-dist + + origin_file_path = dataset_list[index] + dest_file_path = os.path.join(directory_path, f"search_{round(float(dist),4)}{extension}") + shutil.copy(origin_file_path, dest_file_path) + + + + + \ No newline at end of file diff --git a/custom_apps/faiss/utils.py b/custom_apps/faiss/utils.py new file mode 100644 index 0000000..1d28c69 --- /dev/null +++ b/custom_apps/faiss/utils.py @@ -0,0 +1,116 @@ +""" +quary 이미지(이미지 경로) 입력받아 처리 +""" +import os +import time +import math +import numpy as np +from sklearn.preprocessing import normalize +import faiss +import tensorflow as tf +import tensorflow.keras.layers as layers +from tensorflow.keras.models import Model +from tensorflow.keras.applications.mobilenet_v2 import preprocess_input +from PIL import Image + + +def populate(index, fvecs, batch_size=1000): + nloop = math.ceil(fvecs.shape[0] / batch_size) + for n in range(nloop): + s = time.time() + index.add(normalize(fvecs[n * batch_size : min((n + 1) * batch_size, fvecs.shape[0])])) + # print(n * batch_size, time.time() - s) + + return index + +def get_index(index_type, dim): + if index_type == 'hnsw': + m = 48 + index = faiss.IndexHNSWFlat(dim, m) + index.hnsw.efConstruction = 128 + return index + elif index_type == 'l2': + return faiss.IndexFlatL2(dim) + raise + +def preprocessing(dim,fvec_file,index_type): + # index_type = 'hnsw' + # index_type = 'l2' + + # f-string 방식 (python3 이상에서 지원) + index_file = f'{fvec_file}.{index_type}.index' + + fvecs = np.memmap(fvec_file, dtype='float32', mode='r').view('float32').reshape(-1, dim) + + if os.path.exists(index_file): + index = faiss.read_index(index_file) + if index_type == 'hnsw': + index.hnsw.efSearch = 256 + else: + index = get_index(index_type, dim) + index = populate(index, fvecs) + faiss.write_index(index, index_file) + # print(index.ntotal) + + return fvecs,index + +def preprocessing_quary(dim,image_path,index_type): + # index_type = 'hnsw' + # index_type = 'l2' + + fvecs = fvces_quary(image_path) + index = get_index(index_type, dim) + index = populate(index, fvecs) + + return fvecs,index + +def preprocess(img_path, input_shape): + + img = tf.io.read_file(img_path) + img = tf.image.decode_jpeg(img, channels=input_shape[2]) + img = tf.image.resize(img, input_shape[:2]) + img = preprocess_input(img) + return img + +def preprocess_pil(pil_img_data, input_shape): + + pil_img = np.asarray(Image.open(pil_img_data)) + pil_img = tf.image.resize(pil_img, input_shape[:2]) + pil_img = preprocess_input(pil_img) + return pil_img + +def fvces_quary(image_path): + + batch_size = 100 + input_shape = (224, 224, 3) + base = tf.keras.applications.MobileNetV2(input_shape=input_shape, + include_top=False, + weights='imagenet') + base.trainable = False + model = Model(inputs=base.input, outputs=layers.GlobalAveragePooling2D()(base.output)) + + list_ds = tf.data.Dataset.from_tensor_slices([image_path]) + + ds = list_ds.map(lambda x: preprocess(x, input_shape), num_parallel_calls=-1) + + dataset = ds.batch(batch_size).prefetch(-1) + + for batch in dataset: + fvecs = model.predict(batch) + return fvecs + + +# index_type = 'hnsw' +# index_type = 'l2' + +def get_dataset_list(filepath): + content_list = [] + import os + if os.path.isfile(filepath) and filepath.endswith(".txt"): + with open(filepath, 'r', encoding='utf-8') as file: + for line in file: + content_list.append(line.strip()) + return content_list + + + \ No newline at end of file diff --git a/custom_apps/imagen/custom_imagen.py b/custom_apps/imagen/custom_imagen.py index e506997..3988837 100644 --- a/custom_apps/imagen/custom_imagen.py +++ b/custom_apps/imagen/custom_imagen.py @@ -5,8 +5,8 @@ import os from vertexai.preview.vision_models import ImageGenerationModel from const import OUTPUT_FOLDER -from rest.app.utils.parsing_utils import prompt_to_filenames -from rest.app.utils.date_utils import D +from main_rest.app.utils.parsing_utils import prompt_to_filenames +from main_rest.app.utils.date_utils import D class ImagenConst: @@ -48,4 +48,44 @@ def imagen_generate_image(prompt,download_count=1): for i in range(len(images.images)): images[i].save(location=os.path.join(_folder,f"imagen_{_file_name}_{i+1}_{_datetime}.png"), include_generation_parameters=False) - return len(images.images) \ No newline at end of file + return len(images.images) + +def imagen_generate_image_data(prompt,download_count=1): + vertexai.init(project=ImagenConst.project_id, location=ImagenConst.location) + + model = ImageGenerationModel.from_pretrained(ImagenConst.model) + + images = model.generate_images( + prompt=prompt, + # Optional parameters + number_of_images=download_count, + language="ko", + # You can't use a seed value and watermark at the same time. + # add_watermark=False, + # seed=100, + aspect_ratio="1:1", + safety_filter_level="block_some", + person_generation="dont_allow", + ) + + return images.images[0]._pil_image + +def imagen_generate_image_path(image_prompt): + + MODEL = "imagen" + QUERY = "query" + create_time = D.date_file_name() + + folder_name = os.path.join(OUTPUT_FOLDER,f"{MODEL}_{QUERY}_{image_prompt}_{create_time}") + if not os.path.exists(folder_name): + os.makedirs(folder_name) + + generate_img = imagen_generate_image_data(image_prompt) + + generate_img.save(os.path.join(folder_name,f"query.png")) + + return os.path.join(folder_name,f"query.png") + +if __name__ == '__main__': + pass + # imagen_generate_image_data("cat") \ No newline at end of file diff --git a/custom_logger/custom_log.py b/custom_logger/custom_log.py index 09f8abe..abf1718 100644 --- a/custom_logger/custom_log.py +++ b/custom_logger/custom_log.py @@ -95,18 +95,18 @@ def get_logger(): return custom_logger -if custom_logger is None: - custom_logger = logging.getLogger(LOGGER_NAME) +# if custom_logger is None: +# custom_logger = logging.getLogger(LOGGER_NAME) - if not os.path.exists(LOGGER_DIR): - os.makedirs(LOGGER_DIR) +# if not os.path.exists(LOGGER_DIR): +# os.makedirs(LOGGER_DIR) - if not __LOGGER_FILE_PATH: - logger_init(custom_logger, level=LOGGER_LEVEL) - else: - if not os.path.exists(LOGGER_DIR): - os.makedirs(LOGGER_DIR) - logger_init(custom_logger, level=LOGGER_LEVEL, file_log_path=__LOGGER_FILE_PATH) +# if not __LOGGER_FILE_PATH: +# logger_init(custom_logger, level=LOGGER_LEVEL) +# else: +# if not os.path.exists(LOGGER_DIR): +# os.makedirs(LOGGER_DIR) +# logger_init(custom_logger, level=LOGGER_LEVEL, file_log_path=__LOGGER_FILE_PATH) def test(): custom_logger.info('Module: custom_log.py') diff --git a/custom_logger/main_log.py b/custom_logger/main_log.py new file mode 100644 index 0000000..1cc0c16 --- /dev/null +++ b/custom_logger/main_log.py @@ -0,0 +1,42 @@ +import datetime +import os +import logging + +from custom_logger.custom_log import logger_init + +main_logger = None + +_now = datetime.datetime.now() + +LOGGER_NAME = 'main' +LOGGER_FILE_NAME = f'{_now.strftime("%Y-%m-%d %H_%M_%S")}_{LOGGER_NAME}.log' + +LOGGER_LEVEL = logging.INFO +LOGGER_DIR = "log/" +__LOGGER_FILE_PATH = LOGGER_DIR + LOGGER_FILE_NAME + +def get_main_logger(): + """ + 로거 객체 반환 + 로거 객체가 없을 경우에는 로그 초기화를 진행하고 생성된 로그 객체를 반환함 + :return: 로그 객체 + """ + + if not main_logger.handlers: + logger_init(main_logger) + + return main_logger + +if main_logger is None: + + main_logger = logging.getLogger(LOGGER_NAME) + + if not os.path.exists(LOGGER_DIR): + os.makedirs(LOGGER_DIR) + + if not __LOGGER_FILE_PATH: + logger_init(main_logger, level=LOGGER_LEVEL) + else: + if not os.path.exists(LOGGER_DIR): + os.makedirs(LOGGER_DIR) + logger_init(main_logger, level=LOGGER_LEVEL, file_log_path=__LOGGER_FILE_PATH) \ No newline at end of file diff --git a/custom_logger/vactor_log.py b/custom_logger/vactor_log.py new file mode 100644 index 0000000..5b65d18 --- /dev/null +++ b/custom_logger/vactor_log.py @@ -0,0 +1,42 @@ +import datetime +import os +import logging + +from custom_logger.custom_log import logger_init + +vactor_logger = None + +_now = datetime.datetime.now() + +LOGGER_NAME = 'vactor' +LOGGER_FILE_NAME = f'{_now.strftime("%Y-%m-%d %H_%M_%S")}_{LOGGER_NAME}.log' + +LOGGER_LEVEL = logging.INFO +LOGGER_DIR = "log/" +__LOGGER_FILE_PATH = LOGGER_DIR + LOGGER_FILE_NAME + +def get_vactor_logger(): + """ + 로거 객체 반환 + 로거 객체가 없을 경우에는 로그 초기화를 진행하고 생성된 로그 객체를 반환함 + :return: 로그 객체 + """ + + if not vactor_logger.handlers: + logger_init(vactor_logger) + + return vactor_logger + +if vactor_logger is None: + + vactor_logger = logging.getLogger(LOGGER_NAME) + + if not os.path.exists(LOGGER_DIR): + os.makedirs(LOGGER_DIR) + + if not __LOGGER_FILE_PATH: + logger_init(vactor_logger, level=LOGGER_LEVEL) + else: + if not os.path.exists(LOGGER_DIR): + os.makedirs(LOGGER_DIR) + logger_init(vactor_logger, level=LOGGER_LEVEL, file_log_path=__LOGGER_FILE_PATH) \ No newline at end of file diff --git a/datas/eyewear_all.fnames.txt b/datas/eyewear_all.fnames.txt new file mode 100644 index 0000000..dc8d4f3 --- /dev/null +++ b/datas/eyewear_all.fnames.txt @@ -0,0 +1,2000 @@ +datas/eyewear_all/Glass_409_45deg.png +datas/eyewear_all/Glass_441_Left.png +datas/eyewear_all/Glass_222_Front.png +datas/eyewear_all/Glass_063_45deg.png +datas/eyewear_all/Glass_379_Front.png +datas/eyewear_all/Glass_120_45deg.png +datas/eyewear_all/Glass_162_Top.png +datas/eyewear_all/Glass_129_Left.png +datas/eyewear_all/Glass_485_45deg.png +datas/eyewear_all/Glass_444_Left.png +datas/eyewear_all/Glass_004_45deg.png +datas/eyewear_all/Glass_433_Left.png +datas/eyewear_all/Glass_341_Front.png +datas/eyewear_all/Glass_010_45deg.png +datas/eyewear_all/Glass_389_Front.png +datas/eyewear_all/Glass_201_Left.png +datas/eyewear_all/Glass_226_Top.png +datas/eyewear_all/Glass_466_Top.png +datas/eyewear_all/Glass_089_Top.png +datas/eyewear_all/Glass_129_Front.png +datas/eyewear_all/Glass_218_Top.png +datas/eyewear_all/Glass_160_45deg.png +datas/eyewear_all/Glass_434_Top.png +datas/eyewear_all/Glass_436_45deg.png +datas/eyewear_all/Glass_105_45deg.png +datas/eyewear_all/Glass_041_Top.png +datas/eyewear_all/Glass_441_Front.png +datas/eyewear_all/Glass_116_Left.png +datas/eyewear_all/Glass_488_45deg.png +datas/eyewear_all/Glass_463_45deg.png +datas/eyewear_all/Glass_305_45deg.png +datas/eyewear_all/Glass_118_Left.png +datas/eyewear_all/Glass_178_45deg.png +datas/eyewear_all/Glass_139_Left.png +datas/eyewear_all/Glass_045_Left.png +datas/eyewear_all/Glass_448_Top.png +datas/eyewear_all/Glass_320_Top.png +datas/eyewear_all/Glass_232_45deg.png +datas/eyewear_all/Glass_064_Front.png +datas/eyewear_all/Glass_497_Front.png +datas/eyewear_all/Glass_124_Left.png +datas/eyewear_all/Glass_393_Front.png +datas/eyewear_all/Glass_415_45deg.png +datas/eyewear_all/Glass_054_Front.png +datas/eyewear_all/Glass_030_Left.png +datas/eyewear_all/Glass_388_Front.png +datas/eyewear_all/Glass_272_45deg.png +datas/eyewear_all/Glass_291_Front.png +datas/eyewear_all/Glass_043_Front.png +datas/eyewear_all/Glass_121_Top.png +datas/eyewear_all/Glass_260_45deg.png +datas/eyewear_all/Glass_105_Left.png +datas/eyewear_all/Glass_202_45deg.png +datas/eyewear_all/Glass_400_45deg.png +datas/eyewear_all/Glass_144_Top.png +datas/eyewear_all/Glass_109_Left.png +datas/eyewear_all/Glass_297_Front.png +datas/eyewear_all/Glass_086_45deg.png +datas/eyewear_all/Glass_444_Front.png +datas/eyewear_all/Glass_391_Top.png +datas/eyewear_all/Glass_361_Top.png +datas/eyewear_all/Glass_182_45deg.png +datas/eyewear_all/Glass_151_Front.png +datas/eyewear_all/Glass_028_Front.png +datas/eyewear_all/Glass_409_Top.png +datas/eyewear_all/Glass_041_Front.png +datas/eyewear_all/Glass_358_Front.png +datas/eyewear_all/Glass_189_Front.png +datas/eyewear_all/Glass_212_Top.png +datas/eyewear_all/Glass_378_45deg.png +datas/eyewear_all/Glass_219_Front.png +datas/eyewear_all/Glass_121_Front.png +datas/eyewear_all/Glass_280_45deg.png +datas/eyewear_all/Glass_407_Left.png +datas/eyewear_all/Glass_157_Left.png +datas/eyewear_all/Glass_221_45deg.png +datas/eyewear_all/Glass_288_Top.png +datas/eyewear_all/Glass_053_Left.png +datas/eyewear_all/Glass_258_Front.png +datas/eyewear_all/Glass_318_Top.png +datas/eyewear_all/Glass_111_Front.png +datas/eyewear_all/Glass_080_Left.png +datas/eyewear_all/Glass_169_45deg.png +datas/eyewear_all/Glass_366_45deg.png +datas/eyewear_all/Glass_099_Left.png +datas/eyewear_all/Glass_369_Front.png +datas/eyewear_all/Glass_454_45deg.png +datas/eyewear_all/Glass_292_Top.png +datas/eyewear_all/Glass_031_45deg.png +datas/eyewear_all/Glass_445_Left.png +datas/eyewear_all/Glass_150_Front.png +datas/eyewear_all/Glass_092_Front.png +datas/eyewear_all/Glass_158_Left.png +datas/eyewear_all/Glass_023_Top.png +datas/eyewear_all/Glass_352_Front.png +datas/eyewear_all/Glass_446_Left.png +datas/eyewear_all/Glass_004_Top.png +datas/eyewear_all/Glass_281_Left.png +datas/eyewear_all/Glass_478_Left.png +datas/eyewear_all/Glass_404_45deg.png +datas/eyewear_all/Glass_323_Front.png +datas/eyewear_all/Glass_410_Left.png +datas/eyewear_all/Glass_481_Top.png +datas/eyewear_all/Glass_257_Left.png +datas/eyewear_all/Glass_496_45deg.png +datas/eyewear_all/Glass_085_Left.png +datas/eyewear_all/Glass_237_Front.png +datas/eyewear_all/Glass_252_Left.png +datas/eyewear_all/Glass_349_Front.png +datas/eyewear_all/Glass_037_Front.png +datas/eyewear_all/Glass_473_Top.png +datas/eyewear_all/Glass_471_Front.png +datas/eyewear_all/Glass_204_Front.png +datas/eyewear_all/Glass_046_45deg.png +datas/eyewear_all/Glass_023_Left.png +datas/eyewear_all/Glass_442_Left.png +datas/eyewear_all/Glass_391_Front.png +datas/eyewear_all/Glass_414_Front.png +datas/eyewear_all/Glass_164_Front.png +datas/eyewear_all/Glass_397_45deg.png +datas/eyewear_all/Glass_096_45deg.png +datas/eyewear_all/Glass_307_Left.png +datas/eyewear_all/Glass_432_Top.png +datas/eyewear_all/Glass_445_Front.png +datas/eyewear_all/Glass_482_Top.png +datas/eyewear_all/Glass_422_Top.png +datas/eyewear_all/Glass_200_Top.png +datas/eyewear_all/Glass_135_Left.png +datas/eyewear_all/Glass_304_Left.png +datas/eyewear_all/Glass_464_45deg.png +datas/eyewear_all/Glass_246_Front.png +datas/eyewear_all/Glass_273_Top.png +datas/eyewear_all/Glass_192_45deg.png +datas/eyewear_all/Glass_384_45deg.png +datas/eyewear_all/Glass_442_Top.png +datas/eyewear_all/Glass_367_Left.png +datas/eyewear_all/Glass_165_45deg.png +datas/eyewear_all/Glass_033_45deg.png +datas/eyewear_all/Glass_014_Left.png +datas/eyewear_all/Glass_497_45deg.png +datas/eyewear_all/Glass_293_Left.png +datas/eyewear_all/Glass_015_Left.png +datas/eyewear_all/Glass_108_Left.png +datas/eyewear_all/Glass_452_Front.png +datas/eyewear_all/Glass_143_Left.png +datas/eyewear_all/Glass_413_45deg.png +datas/eyewear_all/Glass_091_Left.png +datas/eyewear_all/Glass_486_45deg.png +datas/eyewear_all/Glass_100_45deg.png +datas/eyewear_all/Glass_361_Front.png +datas/eyewear_all/Glass_453_45deg.png +datas/eyewear_all/Glass_108_Front.png +datas/eyewear_all/Glass_109_Top.png +datas/eyewear_all/Glass_347_Top.png +datas/eyewear_all/Glass_395_Top.png +datas/eyewear_all/Glass_256_45deg.png +datas/eyewear_all/Glass_004_Left.png +datas/eyewear_all/Glass_463_Top.png +datas/eyewear_all/Glass_367_Top.png +datas/eyewear_all/Glass_273_45deg.png +datas/eyewear_all/Glass_102_Left.png +datas/eyewear_all/Glass_025_Left.png +datas/eyewear_all/Glass_476_Left.png +datas/eyewear_all/Glass_103_45deg.png +datas/eyewear_all/Glass_140_Front.png +datas/eyewear_all/Glass_416_Left.png +datas/eyewear_all/Glass_098_Front.png +datas/eyewear_all/Glass_361_Left.png +datas/eyewear_all/Glass_461_Front.png +datas/eyewear_all/Glass_080_Top.png +datas/eyewear_all/Glass_097_Left.png +datas/eyewear_all/Glass_243_Front.png +datas/eyewear_all/Glass_227_45deg.png +datas/eyewear_all/Glass_179_45deg.png +datas/eyewear_all/Glass_172_Front.png +datas/eyewear_all/Glass_425_Left.png +datas/eyewear_all/Glass_306_Left.png +datas/eyewear_all/Glass_049_45deg.png +datas/eyewear_all/Glass_041_45deg.png +datas/eyewear_all/Glass_230_Top.png +datas/eyewear_all/Glass_437_45deg.png +datas/eyewear_all/Glass_286_Top.png +datas/eyewear_all/Glass_314_Front.png +datas/eyewear_all/Glass_134_45deg.png +datas/eyewear_all/Glass_311_Left.png +datas/eyewear_all/Glass_382_Left.png +datas/eyewear_all/Glass_068_Top.png +datas/eyewear_all/Glass_037_Left.png +datas/eyewear_all/Glass_406_Top.png +datas/eyewear_all/Glass_424_Left.png +datas/eyewear_all/Glass_250_Front.png +datas/eyewear_all/Glass_457_Left.png +datas/eyewear_all/Glass_159_45deg.png +datas/eyewear_all/Glass_495_Left.png +datas/eyewear_all/Glass_192_Left.png +datas/eyewear_all/Glass_310_45deg.png +datas/eyewear_all/Glass_128_Front.png +datas/eyewear_all/Glass_231_Top.png +datas/eyewear_all/Glass_431_Left.png +datas/eyewear_all/Glass_017_Front.png +datas/eyewear_all/Glass_281_Top.png +datas/eyewear_all/Glass_408_Front.png +datas/eyewear_all/Glass_224_45deg.png +datas/eyewear_all/Glass_302_Left.png +datas/eyewear_all/Glass_353_Top.png +datas/eyewear_all/Glass_198_Top.png +datas/eyewear_all/Glass_162_45deg.png +datas/eyewear_all/Glass_191_45deg.png +datas/eyewear_all/Glass_310_Top.png +datas/eyewear_all/Glass_198_Front.png +datas/eyewear_all/Glass_303_45deg.png +datas/eyewear_all/Glass_461_Top.png +datas/eyewear_all/Glass_261_Front.png +datas/eyewear_all/Glass_324_45deg.png +datas/eyewear_all/Glass_067_Front.png +datas/eyewear_all/Glass_480_45deg.png +datas/eyewear_all/Glass_272_Front.png +datas/eyewear_all/Glass_141_Front.png +datas/eyewear_all/Glass_432_Left.png +datas/eyewear_all/Glass_326_Top.png +datas/eyewear_all/Glass_027_Front.png +datas/eyewear_all/Glass_083_Left.png +datas/eyewear_all/Glass_163_Front.png +datas/eyewear_all/Glass_164_Top.png +datas/eyewear_all/Glass_327_45deg.png +datas/eyewear_all/Glass_093_45deg.png +datas/eyewear_all/Glass_483_Top.png +datas/eyewear_all/Glass_156_45deg.png +datas/eyewear_all/Glass_187_Front.png +datas/eyewear_all/Glass_288_Left.png +datas/eyewear_all/Glass_421_Left.png +datas/eyewear_all/Glass_294_Front.png +datas/eyewear_all/Glass_420_Top.png +datas/eyewear_all/Glass_067_Left.png +datas/eyewear_all/Glass_157_Top.png +datas/eyewear_all/Glass_185_Top.png +datas/eyewear_all/Glass_159_Front.png +datas/eyewear_all/Glass_300_Top.png +datas/eyewear_all/Glass_298_45deg.png +datas/eyewear_all/Glass_411_Top.png +datas/eyewear_all/Glass_150_45deg.png +datas/eyewear_all/Glass_368_45deg.png +datas/eyewear_all/Glass_317_Top.png +datas/eyewear_all/Glass_068_45deg.png +datas/eyewear_all/Glass_494_45deg.png +datas/eyewear_all/Glass_050_Front.png +datas/eyewear_all/Glass_320_Left.png +datas/eyewear_all/Glass_496_Top.png +datas/eyewear_all/Glass_180_Top.png +datas/eyewear_all/Glass_294_Left.png +datas/eyewear_all/Glass_013_Left.png +datas/eyewear_all/Glass_440_Left.png +datas/eyewear_all/Glass_267_Front.png +datas/eyewear_all/Glass_147_Left.png +datas/eyewear_all/Glass_175_45deg.png +datas/eyewear_all/Glass_440_Top.png +datas/eyewear_all/Glass_487_Front.png +datas/eyewear_all/Glass_435_Front.png +datas/eyewear_all/Glass_034_Left.png +datas/eyewear_all/Glass_014_Top.png +datas/eyewear_all/Glass_365_Left.png +datas/eyewear_all/Glass_035_Left.png +datas/eyewear_all/Glass_183_Top.png +datas/eyewear_all/Glass_092_Left.png +datas/eyewear_all/Glass_420_45deg.png +datas/eyewear_all/Glass_401_45deg.png +datas/eyewear_all/Glass_472_45deg.png +datas/eyewear_all/Glass_339_Top.png +datas/eyewear_all/Glass_438_45deg.png +datas/eyewear_all/Glass_054_45deg.png +datas/eyewear_all/Glass_098_Left.png +datas/eyewear_all/Glass_366_Front.png +datas/eyewear_all/Glass_347_Left.png +datas/eyewear_all/Glass_300_Front.png +datas/eyewear_all/Glass_315_Front.png +datas/eyewear_all/Glass_283_45deg.png +datas/eyewear_all/Glass_033_Front.png +datas/eyewear_all/Glass_215_45deg.png +datas/eyewear_all/Glass_424_45deg.png +datas/eyewear_all/Glass_188_Left.png +datas/eyewear_all/Glass_056_Left.png +datas/eyewear_all/Glass_128_Top.png +datas/eyewear_all/Glass_115_Left.png +datas/eyewear_all/Glass_378_Top.png +datas/eyewear_all/Glass_319_Front.png +datas/eyewear_all/Glass_405_45deg.png +datas/eyewear_all/Glass_455_Top.png +datas/eyewear_all/Glass_002_Left.png +datas/eyewear_all/Glass_336_Top.png +datas/eyewear_all/Glass_110_Left.png +datas/eyewear_all/Glass_374_Top.png +datas/eyewear_all/Glass_037_Top.png +datas/eyewear_all/Glass_094_Front.png +datas/eyewear_all/Glass_500_Top.png +datas/eyewear_all/Glass_217_45deg.png +datas/eyewear_all/Glass_101_Left.png +datas/eyewear_all/Glass_304_Top.png +datas/eyewear_all/Glass_165_Left.png +datas/eyewear_all/Glass_392_Front.png +datas/eyewear_all/Glass_348_Front.png +datas/eyewear_all/Glass_047_Left.png +datas/eyewear_all/Glass_328_45deg.png +datas/eyewear_all/Glass_190_Left.png +datas/eyewear_all/Glass_188_45deg.png +datas/eyewear_all/Glass_045_Top.png +datas/eyewear_all/Glass_083_Front.png +datas/eyewear_all/Glass_358_45deg.png +datas/eyewear_all/Glass_207_Left.png +datas/eyewear_all/Glass_177_Front.png +datas/eyewear_all/Glass_285_45deg.png +datas/eyewear_all/Glass_208_Top.png +datas/eyewear_all/Glass_398_Front.png +datas/eyewear_all/Glass_323_45deg.png +datas/eyewear_all/Glass_292_Left.png +datas/eyewear_all/Glass_450_Top.png +datas/eyewear_all/Glass_199_Left.png +datas/eyewear_all/Glass_249_Top.png +datas/eyewear_all/Glass_475_Front.png +datas/eyewear_all/Glass_176_Front.png +datas/eyewear_all/Glass_481_Left.png +datas/eyewear_all/Glass_184_Left.png +datas/eyewear_all/Glass_264_Front.png +datas/eyewear_all/Glass_435_45deg.png +datas/eyewear_all/Glass_051_Left.png +datas/eyewear_all/Glass_328_Left.png +datas/eyewear_all/Glass_326_Left.png +datas/eyewear_all/Glass_480_Front.png +datas/eyewear_all/Glass_055_45deg.png +datas/eyewear_all/Glass_190_Front.png +datas/eyewear_all/Glass_016_Left.png +datas/eyewear_all/Glass_153_Front.png +datas/eyewear_all/Glass_018_45deg.png +datas/eyewear_all/Glass_402_Left.png +datas/eyewear_all/Glass_212_45deg.png +datas/eyewear_all/Glass_474_45deg.png +datas/eyewear_all/Glass_345_Left.png +datas/eyewear_all/Glass_082_Top.png +datas/eyewear_all/Glass_038_Front.png +datas/eyewear_all/Glass_122_45deg.png +datas/eyewear_all/Glass_343_Front.png +datas/eyewear_all/Glass_182_Top.png +datas/eyewear_all/Glass_279_Left.png +datas/eyewear_all/Glass_376_Left.png +datas/eyewear_all/Glass_498_Top.png +datas/eyewear_all/Glass_257_Front.png +datas/eyewear_all/Glass_151_Left.png +datas/eyewear_all/Glass_300_45deg.png +datas/eyewear_all/Glass_177_Left.png +datas/eyewear_all/Glass_336_45deg.png +datas/eyewear_all/Glass_209_45deg.png +datas/eyewear_all/Glass_427_Left.png +datas/eyewear_all/Glass_013_Top.png +datas/eyewear_all/Glass_099_Top.png +datas/eyewear_all/Glass_244_Left.png +datas/eyewear_all/Glass_131_45deg.png +datas/eyewear_all/Glass_253_Top.png +datas/eyewear_all/Glass_426_45deg.png +datas/eyewear_all/Glass_382_Top.png +datas/eyewear_all/Glass_302_Front.png +datas/eyewear_all/Glass_201_45deg.png +datas/eyewear_all/Glass_181_Top.png +datas/eyewear_all/Glass_024_Front.png +datas/eyewear_all/Glass_220_45deg.png +datas/eyewear_all/Glass_226_45deg.png +datas/eyewear_all/Glass_091_45deg.png +datas/eyewear_all/Glass_297_Top.png +datas/eyewear_all/Glass_395_Front.png +datas/eyewear_all/Glass_126_45deg.png +datas/eyewear_all/Glass_134_Left.png +datas/eyewear_all/Glass_061_Left.png +datas/eyewear_all/Glass_112_Front.png +datas/eyewear_all/Glass_161_Front.png +datas/eyewear_all/Glass_139_Top.png +datas/eyewear_all/Glass_482_Left.png +datas/eyewear_all/Glass_100_Left.png +datas/eyewear_all/Glass_119_Front.png +datas/eyewear_all/Glass_018_Left.png +datas/eyewear_all/Glass_379_45deg.png +datas/eyewear_all/Glass_489_Left.png +datas/eyewear_all/Glass_078_45deg.png +datas/eyewear_all/Glass_111_45deg.png +datas/eyewear_all/Glass_101_Top.png +datas/eyewear_all/Glass_270_45deg.png +datas/eyewear_all/Glass_220_Left.png +datas/eyewear_all/Glass_468_Front.png +datas/eyewear_all/Glass_494_Top.png +datas/eyewear_all/Glass_216_Left.png +datas/eyewear_all/Glass_252_Top.png +datas/eyewear_all/Glass_290_Front.png +datas/eyewear_all/Glass_286_Front.png +datas/eyewear_all/Glass_013_45deg.png +datas/eyewear_all/Glass_232_Left.png +datas/eyewear_all/Glass_015_45deg.png +datas/eyewear_all/Glass_433_45deg.png +datas/eyewear_all/Glass_079_Left.png +datas/eyewear_all/Glass_260_Front.png +datas/eyewear_all/Glass_308_Top.png +datas/eyewear_all/Glass_443_Top.png +datas/eyewear_all/Glass_048_Left.png +datas/eyewear_all/Glass_085_Front.png +datas/eyewear_all/Glass_410_Front.png +datas/eyewear_all/Glass_311_45deg.png +datas/eyewear_all/Glass_208_Left.png +datas/eyewear_all/Glass_378_Front.png +datas/eyewear_all/Glass_121_45deg.png +datas/eyewear_all/Glass_298_Left.png +datas/eyewear_all/Glass_299_Top.png +datas/eyewear_all/Glass_206_Front.png +datas/eyewear_all/Glass_224_Left.png +datas/eyewear_all/Glass_458_Front.png +datas/eyewear_all/Glass_123_45deg.png +datas/eyewear_all/Glass_070_Top.png +datas/eyewear_all/Glass_309_Front.png +datas/eyewear_all/Glass_377_Top.png +datas/eyewear_all/Glass_090_Top.png +datas/eyewear_all/Glass_213_45deg.png +datas/eyewear_all/Glass_405_Left.png +datas/eyewear_all/Glass_022_Front.png +datas/eyewear_all/Glass_047_Top.png +datas/eyewear_all/Glass_167_Top.png +datas/eyewear_all/Glass_322_Front.png +datas/eyewear_all/Glass_174_Top.png +datas/eyewear_all/Glass_251_Front.png +datas/eyewear_all/Glass_148_45deg.png +datas/eyewear_all/Glass_095_Top.png +datas/eyewear_all/Glass_337_Front.png +datas/eyewear_all/Glass_054_Top.png +datas/eyewear_all/Glass_356_Left.png +datas/eyewear_all/Glass_005_Left.png +datas/eyewear_all/Glass_281_Front.png +datas/eyewear_all/Glass_299_45deg.png +datas/eyewear_all/Glass_322_45deg.png +datas/eyewear_all/Glass_243_Left.png +datas/eyewear_all/Glass_114_45deg.png +datas/eyewear_all/Glass_332_Left.png +datas/eyewear_all/Glass_146_Front.png +datas/eyewear_all/Glass_228_Front.png +datas/eyewear_all/Glass_384_Front.png +datas/eyewear_all/Glass_096_Front.png +datas/eyewear_all/Glass_329_Top.png +datas/eyewear_all/Glass_345_Front.png +datas/eyewear_all/Glass_183_Left.png +datas/eyewear_all/Glass_409_Front.png +datas/eyewear_all/Glass_278_Left.png +datas/eyewear_all/Glass_099_Front.png +datas/eyewear_all/Glass_094_Left.png +datas/eyewear_all/Glass_258_45deg.png +datas/eyewear_all/Glass_449_Left.png +datas/eyewear_all/Glass_270_Front.png +datas/eyewear_all/Glass_044_45deg.png +datas/eyewear_all/Glass_175_Top.png +datas/eyewear_all/Glass_032_Front.png +datas/eyewear_all/Glass_252_Front.png +datas/eyewear_all/Glass_312_Left.png +datas/eyewear_all/Glass_205_45deg.png +datas/eyewear_all/Glass_050_Left.png +datas/eyewear_all/Glass_068_Front.png +datas/eyewear_all/Glass_064_Top.png +datas/eyewear_all/Glass_277_Left.png +datas/eyewear_all/Glass_237_Left.png +datas/eyewear_all/Glass_381_45deg.png +datas/eyewear_all/Glass_395_45deg.png +datas/eyewear_all/Glass_024_45deg.png +datas/eyewear_all/Glass_019_Top.png +datas/eyewear_all/Glass_423_Front.png +datas/eyewear_all/Glass_284_Left.png +datas/eyewear_all/Glass_116_45deg.png +datas/eyewear_all/Glass_265_Left.png +datas/eyewear_all/Glass_248_Left.png +datas/eyewear_all/Glass_360_Front.png +datas/eyewear_all/Glass_080_Front.png +datas/eyewear_all/Glass_227_Left.png +datas/eyewear_all/Glass_066_Top.png +datas/eyewear_all/Glass_455_Left.png +datas/eyewear_all/Glass_044_Front.png +datas/eyewear_all/Glass_398_45deg.png +datas/eyewear_all/Glass_023_45deg.png +datas/eyewear_all/Glass_398_Left.png +datas/eyewear_all/Glass_330_45deg.png +datas/eyewear_all/Glass_136_Left.png +datas/eyewear_all/Glass_479_45deg.png +datas/eyewear_all/Glass_380_Left.png +datas/eyewear_all/Glass_399_Left.png +datas/eyewear_all/Glass_103_Top.png +datas/eyewear_all/Glass_242_45deg.png +datas/eyewear_all/Glass_027_Top.png +datas/eyewear_all/Glass_168_Top.png +datas/eyewear_all/Glass_499_Top.png +datas/eyewear_all/Glass_188_Top.png +datas/eyewear_all/Glass_021_45deg.png +datas/eyewear_all/Glass_375_Left.png +datas/eyewear_all/Glass_293_45deg.png +datas/eyewear_all/Glass_264_45deg.png +datas/eyewear_all/Glass_083_Top.png +datas/eyewear_all/Glass_306_Top.png +datas/eyewear_all/Glass_119_45deg.png +datas/eyewear_all/Glass_367_Front.png +datas/eyewear_all/Glass_298_Front.png +datas/eyewear_all/Glass_307_45deg.png +datas/eyewear_all/Glass_235_Front.png +datas/eyewear_all/Glass_214_Left.png +datas/eyewear_all/Glass_006_Left.png +datas/eyewear_all/Glass_366_Top.png +datas/eyewear_all/Glass_082_Left.png +datas/eyewear_all/Glass_306_45deg.png +datas/eyewear_all/Glass_189_45deg.png +datas/eyewear_all/Glass_088_Left.png +datas/eyewear_all/Glass_151_45deg.png +datas/eyewear_all/Glass_117_45deg.png +datas/eyewear_all/Glass_278_Top.png +datas/eyewear_all/Glass_354_Top.png +datas/eyewear_all/Glass_084_Top.png +datas/eyewear_all/Glass_103_Left.png +datas/eyewear_all/Glass_328_Front.png +datas/eyewear_all/Glass_397_Left.png +datas/eyewear_all/Glass_485_Top.png +datas/eyewear_all/Glass_395_Left.png +datas/eyewear_all/Glass_020_Front.png +datas/eyewear_all/Glass_311_Top.png +datas/eyewear_all/Glass_422_Left.png +datas/eyewear_all/Glass_199_45deg.png +datas/eyewear_all/Glass_138_Left.png +datas/eyewear_all/Glass_387_45deg.png +datas/eyewear_all/Glass_369_Left.png +datas/eyewear_all/Glass_323_Left.png +datas/eyewear_all/Glass_325_Top.png +datas/eyewear_all/Glass_490_Top.png +datas/eyewear_all/Glass_106_Left.png +datas/eyewear_all/Glass_005_Front.png +datas/eyewear_all/Glass_287_Top.png +datas/eyewear_all/Glass_095_Left.png +datas/eyewear_all/Glass_449_45deg.png +datas/eyewear_all/Glass_428_Front.png +datas/eyewear_all/Glass_112_45deg.png +datas/eyewear_all/Glass_451_Top.png +datas/eyewear_all/Glass_161_Left.png +datas/eyewear_all/Glass_060_Top.png +datas/eyewear_all/Glass_113_Front.png +datas/eyewear_all/Glass_115_45deg.png +datas/eyewear_all/Glass_171_Top.png +datas/eyewear_all/Glass_374_Front.png +datas/eyewear_all/Glass_364_45deg.png +datas/eyewear_all/Glass_118_Front.png +datas/eyewear_all/Glass_259_45deg.png +datas/eyewear_all/Glass_002_45deg.png +datas/eyewear_all/Glass_113_45deg.png +datas/eyewear_all/Glass_411_Front.png +datas/eyewear_all/Glass_175_Front.png +datas/eyewear_all/Glass_078_Front.png +datas/eyewear_all/Glass_337_Top.png +datas/eyewear_all/Glass_234_Left.png +datas/eyewear_all/Glass_437_Top.png +datas/eyewear_all/Glass_483_Left.png +datas/eyewear_all/Glass_186_Top.png +datas/eyewear_all/Glass_036_Left.png +datas/eyewear_all/Glass_357_Front.png +datas/eyewear_all/Glass_465_Front.png +datas/eyewear_all/Glass_130_Top.png +datas/eyewear_all/Glass_303_Top.png +datas/eyewear_all/Glass_298_Top.png +datas/eyewear_all/Glass_430_Left.png +datas/eyewear_all/Glass_124_Front.png +datas/eyewear_all/Glass_500_Front.png +datas/eyewear_all/Glass_147_45deg.png +datas/eyewear_all/Glass_441_Top.png +datas/eyewear_all/Glass_385_Top.png +datas/eyewear_all/Glass_153_Left.png +datas/eyewear_all/Glass_002_Top.png +datas/eyewear_all/Glass_070_Left.png +datas/eyewear_all/Glass_153_Top.png +datas/eyewear_all/Glass_217_Front.png +datas/eyewear_all/Glass_034_45deg.png +datas/eyewear_all/Glass_043_Top.png +datas/eyewear_all/Glass_140_45deg.png +datas/eyewear_all/Glass_227_Top.png +datas/eyewear_all/Glass_122_Front.png +datas/eyewear_all/Glass_318_Front.png +datas/eyewear_all/Glass_495_Top.png +datas/eyewear_all/Glass_234_Top.png +datas/eyewear_all/Glass_253_Left.png +datas/eyewear_all/Glass_012_Top.png +datas/eyewear_all/Glass_218_45deg.png +datas/eyewear_all/Glass_493_Front.png +datas/eyewear_all/Glass_490_Front.png +datas/eyewear_all/Glass_185_45deg.png +datas/eyewear_all/Glass_190_Top.png +datas/eyewear_all/Glass_072_Front.png +datas/eyewear_all/Glass_411_45deg.png +datas/eyewear_all/Glass_424_Top.png +datas/eyewear_all/Glass_211_Front.png +datas/eyewear_all/Glass_174_Left.png +datas/eyewear_all/Glass_340_Front.png +datas/eyewear_all/Glass_032_Left.png +datas/eyewear_all/Glass_373_45deg.png +datas/eyewear_all/Glass_422_45deg.png +datas/eyewear_all/Glass_491_Front.png +datas/eyewear_all/Glass_427_Top.png +datas/eyewear_all/Glass_075_45deg.png +datas/eyewear_all/Glass_093_Top.png +datas/eyewear_all/Glass_254_Top.png +datas/eyewear_all/Glass_239_Top.png +datas/eyewear_all/Glass_018_Top.png +datas/eyewear_all/Glass_211_Top.png +datas/eyewear_all/Glass_401_Top.png +datas/eyewear_all/Glass_322_Left.png +datas/eyewear_all/Glass_336_Front.png +datas/eyewear_all/Glass_453_Left.png +datas/eyewear_all/Glass_046_Left.png +datas/eyewear_all/Glass_104_Front.png +datas/eyewear_all/Glass_490_45deg.png +datas/eyewear_all/Glass_372_45deg.png +datas/eyewear_all/Glass_284_45deg.png +datas/eyewear_all/Glass_016_Top.png +datas/eyewear_all/Glass_168_Left.png +datas/eyewear_all/Glass_394_Top.png +datas/eyewear_all/Glass_239_Left.png +datas/eyewear_all/Glass_175_Left.png +datas/eyewear_all/Glass_340_Top.png +datas/eyewear_all/Glass_324_Left.png +datas/eyewear_all/Glass_210_Left.png +datas/eyewear_all/Glass_076_Left.png +datas/eyewear_all/Glass_231_Left.png +datas/eyewear_all/Glass_247_45deg.png +datas/eyewear_all/Glass_132_Top.png +datas/eyewear_all/Glass_489_Front.png +datas/eyewear_all/Glass_207_Top.png +datas/eyewear_all/Glass_034_Front.png +datas/eyewear_all/Glass_167_Front.png +datas/eyewear_all/Glass_173_Left.png +datas/eyewear_all/Glass_497_Left.png +datas/eyewear_all/Glass_114_Front.png +datas/eyewear_all/Glass_220_Top.png +datas/eyewear_all/Glass_105_Top.png +datas/eyewear_all/Glass_483_Front.png +datas/eyewear_all/Glass_309_Top.png +datas/eyewear_all/Glass_410_Top.png +datas/eyewear_all/Glass_168_Front.png +datas/eyewear_all/Glass_462_45deg.png +datas/eyewear_all/Glass_223_Top.png +datas/eyewear_all/Glass_439_45deg.png +datas/eyewear_all/Glass_025_45deg.png +datas/eyewear_all/Glass_384_Top.png +datas/eyewear_all/Glass_025_Top.png +datas/eyewear_all/Glass_208_Front.png +datas/eyewear_all/Glass_294_Top.png +datas/eyewear_all/Glass_294_45deg.png +datas/eyewear_all/Glass_451_Left.png +datas/eyewear_all/Glass_265_Top.png +datas/eyewear_all/Glass_486_Top.png +datas/eyewear_all/Glass_267_Left.png +datas/eyewear_all/Glass_069_Left.png +datas/eyewear_all/Glass_466_Front.png +datas/eyewear_all/Glass_338_Front.png +datas/eyewear_all/Glass_249_Front.png +datas/eyewear_all/Glass_166_Left.png +datas/eyewear_all/Glass_223_45deg.png +datas/eyewear_all/Glass_135_Front.png +datas/eyewear_all/Glass_358_Top.png +datas/eyewear_all/Glass_463_Front.png +datas/eyewear_all/Glass_426_Front.png +datas/eyewear_all/Glass_311_Front.png +datas/eyewear_all/Glass_218_Front.png +datas/eyewear_all/Glass_107_45deg.png +datas/eyewear_all/Glass_200_45deg.png +datas/eyewear_all/Glass_239_45deg.png +datas/eyewear_all/Glass_127_Top.png +datas/eyewear_all/Glass_075_Front.png +datas/eyewear_all/Glass_430_Top.png +datas/eyewear_all/Glass_021_Top.png +datas/eyewear_all/Glass_015_Top.png +datas/eyewear_all/Glass_371_45deg.png +datas/eyewear_all/Glass_359_Front.png +datas/eyewear_all/Glass_040_45deg.png +datas/eyewear_all/Glass_379_Top.png +datas/eyewear_all/Glass_352_45deg.png +datas/eyewear_all/Glass_177_45deg.png +datas/eyewear_all/Glass_453_Top.png +datas/eyewear_all/Glass_163_Left.png +datas/eyewear_all/Glass_106_45deg.png +datas/eyewear_all/Glass_207_45deg.png +datas/eyewear_all/Glass_276_45deg.png +datas/eyewear_all/Glass_202_Left.png +datas/eyewear_all/Glass_313_Front.png +datas/eyewear_all/Glass_015_Front.png +datas/eyewear_all/Glass_308_45deg.png +datas/eyewear_all/Glass_008_Top.png +datas/eyewear_all/Glass_102_Top.png +datas/eyewear_all/Glass_225_Front.png +datas/eyewear_all/Glass_170_Top.png +datas/eyewear_all/Glass_362_Left.png +datas/eyewear_all/Glass_109_Front.png +datas/eyewear_all/Glass_164_Left.png +datas/eyewear_all/Glass_058_Top.png +datas/eyewear_all/Glass_066_45deg.png +datas/eyewear_all/Glass_076_Front.png +datas/eyewear_all/Glass_271_45deg.png +datas/eyewear_all/Glass_088_45deg.png +datas/eyewear_all/Glass_089_Left.png +datas/eyewear_all/Glass_475_Left.png +datas/eyewear_all/Glass_314_Top.png +datas/eyewear_all/Glass_364_Left.png +datas/eyewear_all/Glass_254_45deg.png +datas/eyewear_all/Glass_305_Top.png +datas/eyewear_all/Glass_304_Front.png +datas/eyewear_all/Glass_126_Front.png +datas/eyewear_all/Glass_003_45deg.png +datas/eyewear_all/Glass_434_Front.png +datas/eyewear_all/Glass_149_Left.png +datas/eyewear_all/Glass_040_Front.png +datas/eyewear_all/Glass_204_Top.png +datas/eyewear_all/Glass_226_Left.png +datas/eyewear_all/Glass_468_45deg.png +datas/eyewear_all/Glass_476_Top.png +datas/eyewear_all/Glass_025_Front.png +datas/eyewear_all/Glass_236_Front.png +datas/eyewear_all/Glass_374_Left.png +datas/eyewear_all/Glass_317_Front.png +datas/eyewear_all/Glass_392_Left.png +datas/eyewear_all/Glass_396_45deg.png +datas/eyewear_all/Glass_342_45deg.png +datas/eyewear_all/Glass_438_Top.png +datas/eyewear_all/Glass_332_Front.png +datas/eyewear_all/Glass_144_Left.png +datas/eyewear_all/Glass_348_Top.png +datas/eyewear_all/Glass_408_Top.png +datas/eyewear_all/Glass_187_Left.png +datas/eyewear_all/Glass_285_Top.png +datas/eyewear_all/Glass_133_45deg.png +datas/eyewear_all/Glass_383_Left.png +datas/eyewear_all/Glass_500_45deg.png +datas/eyewear_all/Glass_491_Top.png +datas/eyewear_all/Glass_484_Left.png +datas/eyewear_all/Glass_221_Left.png +datas/eyewear_all/Glass_020_45deg.png +datas/eyewear_all/Glass_438_Front.png +datas/eyewear_all/Glass_381_Top.png +datas/eyewear_all/Glass_304_45deg.png +datas/eyewear_all/Glass_034_Top.png +datas/eyewear_all/Glass_276_Left.png +datas/eyewear_all/Glass_230_Front.png +datas/eyewear_all/Glass_147_Top.png +datas/eyewear_all/Glass_499_45deg.png +datas/eyewear_all/Glass_489_Top.png +datas/eyewear_all/Glass_132_45deg.png +datas/eyewear_all/Glass_440_45deg.png +datas/eyewear_all/Glass_118_45deg.png +datas/eyewear_all/Glass_187_Top.png +datas/eyewear_all/Glass_038_Left.png +datas/eyewear_all/Glass_208_45deg.png +datas/eyewear_all/Glass_464_Top.png +datas/eyewear_all/Glass_124_Top.png +datas/eyewear_all/Glass_073_Left.png +datas/eyewear_all/Glass_459_45deg.png +datas/eyewear_all/Glass_413_Top.png +datas/eyewear_all/Glass_042_45deg.png +datas/eyewear_all/Glass_215_Top.png +datas/eyewear_all/Glass_334_Left.png +datas/eyewear_all/Glass_213_Top.png +datas/eyewear_all/Glass_063_Front.png +datas/eyewear_all/Glass_437_Front.png +datas/eyewear_all/Glass_371_Top.png +datas/eyewear_all/Glass_480_Top.png +datas/eyewear_all/Glass_238_Left.png +datas/eyewear_all/Glass_255_Top.png +datas/eyewear_all/Glass_270_Left.png +datas/eyewear_all/Glass_240_Top.png +datas/eyewear_all/Glass_330_Front.png +datas/eyewear_all/Glass_084_45deg.png +datas/eyewear_all/Glass_406_45deg.png +datas/eyewear_all/Glass_320_45deg.png +datas/eyewear_all/Glass_292_45deg.png +datas/eyewear_all/Glass_344_Left.png +datas/eyewear_all/Glass_082_45deg.png +datas/eyewear_all/Glass_178_Left.png +datas/eyewear_all/Glass_074_45deg.png +datas/eyewear_all/Glass_028_Top.png +datas/eyewear_all/Glass_423_Left.png +datas/eyewear_all/Glass_286_45deg.png +datas/eyewear_all/Glass_205_Top.png +datas/eyewear_all/Glass_381_Front.png +datas/eyewear_all/Glass_206_Top.png +datas/eyewear_all/Glass_007_Top.png +datas/eyewear_all/Glass_401_Left.png +datas/eyewear_all/Glass_100_Top.png +datas/eyewear_all/Glass_017_Top.png +datas/eyewear_all/Glass_091_Front.png +datas/eyewear_all/Glass_052_Front.png +datas/eyewear_all/Glass_478_45deg.png +datas/eyewear_all/Glass_302_45deg.png +datas/eyewear_all/Glass_007_Left.png +datas/eyewear_all/Glass_487_Top.png +datas/eyewear_all/Glass_179_Left.png +datas/eyewear_all/Glass_225_Top.png +datas/eyewear_all/Glass_155_Front.png +datas/eyewear_all/Glass_413_Left.png +datas/eyewear_all/Glass_356_Top.png +datas/eyewear_all/Glass_098_Top.png +datas/eyewear_all/Glass_491_Left.png +datas/eyewear_all/Glass_313_Left.png +datas/eyewear_all/Glass_076_Top.png +datas/eyewear_all/Glass_001_Front.png +datas/eyewear_all/Glass_128_45deg.png +datas/eyewear_all/Glass_071_45deg.png +datas/eyewear_all/Glass_421_Top.png +datas/eyewear_all/Glass_454_Front.png +datas/eyewear_all/Glass_044_Top.png +datas/eyewear_all/Glass_392_Top.png +datas/eyewear_all/Glass_475_Top.png +datas/eyewear_all/Glass_237_Top.png +datas/eyewear_all/Glass_405_Front.png +datas/eyewear_all/Glass_036_45deg.png +datas/eyewear_all/Glass_331_Left.png +datas/eyewear_all/Glass_373_Front.png +datas/eyewear_all/Glass_020_Left.png +datas/eyewear_all/Glass_229_Left.png +datas/eyewear_all/Glass_026_Left.png +datas/eyewear_all/Glass_372_Front.png +datas/eyewear_all/Glass_359_Left.png +datas/eyewear_all/Glass_146_45deg.png +datas/eyewear_all/Glass_375_Top.png +datas/eyewear_all/Glass_254_Left.png +datas/eyewear_all/Glass_316_Front.png +datas/eyewear_all/Glass_359_45deg.png +datas/eyewear_all/Glass_156_Left.png +datas/eyewear_all/Glass_070_Front.png +datas/eyewear_all/Glass_321_Left.png +datas/eyewear_all/Glass_259_Front.png +datas/eyewear_all/Glass_037_45deg.png +datas/eyewear_all/Glass_241_Left.png +datas/eyewear_all/Glass_255_Left.png +datas/eyewear_all/Glass_353_Front.png +datas/eyewear_all/Glass_402_45deg.png +datas/eyewear_all/Glass_240_Left.png +datas/eyewear_all/Glass_420_Front.png +datas/eyewear_all/Glass_380_45deg.png +datas/eyewear_all/Glass_174_Front.png +datas/eyewear_all/Glass_404_Left.png +datas/eyewear_all/Glass_271_Left.png +datas/eyewear_all/Glass_194_45deg.png +datas/eyewear_all/Glass_061_Front.png +datas/eyewear_all/Glass_344_Top.png +datas/eyewear_all/Glass_180_45deg.png +datas/eyewear_all/Glass_431_Top.png +datas/eyewear_all/Glass_474_Left.png +datas/eyewear_all/Glass_006_Top.png +datas/eyewear_all/Glass_365_Top.png +datas/eyewear_all/Glass_397_Top.png +datas/eyewear_all/Glass_161_Top.png +datas/eyewear_all/Glass_193_45deg.png +datas/eyewear_all/Glass_120_Left.png +datas/eyewear_all/Glass_056_45deg.png +datas/eyewear_all/Glass_136_45deg.png +datas/eyewear_all/Glass_275_Left.png +datas/eyewear_all/Glass_469_Front.png +datas/eyewear_all/Glass_157_45deg.png +datas/eyewear_all/Glass_440_Front.png +datas/eyewear_all/Glass_246_45deg.png +datas/eyewear_all/Glass_172_Left.png +datas/eyewear_all/Glass_359_Top.png +datas/eyewear_all/Glass_388_Left.png +datas/eyewear_all/Glass_069_Front.png +datas/eyewear_all/Glass_340_Left.png +datas/eyewear_all/Glass_301_Left.png +datas/eyewear_all/Glass_318_Left.png +datas/eyewear_all/Glass_016_Front.png +datas/eyewear_all/Glass_299_Front.png +datas/eyewear_all/Glass_096_Top.png +datas/eyewear_all/Glass_251_45deg.png +datas/eyewear_all/Glass_485_Left.png +datas/eyewear_all/Glass_165_Top.png +datas/eyewear_all/Glass_288_Front.png +datas/eyewear_all/Glass_154_45deg.png +datas/eyewear_all/Glass_185_Front.png +datas/eyewear_all/Glass_132_Front.png +datas/eyewear_all/Glass_200_Left.png +datas/eyewear_all/Glass_264_Top.png +datas/eyewear_all/Glass_011_Front.png +datas/eyewear_all/Glass_443_Left.png +datas/eyewear_all/Glass_498_Front.png +datas/eyewear_all/Glass_072_45deg.png +datas/eyewear_all/Glass_006_45deg.png +datas/eyewear_all/Glass_233_Front.png +datas/eyewear_all/Glass_155_Left.png +datas/eyewear_all/Glass_476_45deg.png +datas/eyewear_all/Glass_494_Left.png +datas/eyewear_all/Glass_346_Front.png +datas/eyewear_all/Glass_218_Left.png +datas/eyewear_all/Glass_065_45deg.png +datas/eyewear_all/Glass_249_45deg.png +datas/eyewear_all/Glass_316_Top.png +datas/eyewear_all/Glass_302_Top.png +datas/eyewear_all/Glass_283_Left.png +datas/eyewear_all/Glass_199_Top.png +datas/eyewear_all/Glass_492_Left.png +datas/eyewear_all/Glass_299_Left.png +datas/eyewear_all/Glass_012_45deg.png +datas/eyewear_all/Glass_488_Left.png +datas/eyewear_all/Glass_255_45deg.png +datas/eyewear_all/Glass_174_45deg.png +datas/eyewear_all/Glass_056_Front.png +datas/eyewear_all/Glass_321_Front.png +datas/eyewear_all/Glass_178_Top.png +datas/eyewear_all/Glass_394_Front.png +datas/eyewear_all/Glass_163_45deg.png +datas/eyewear_all/Glass_434_45deg.png +datas/eyewear_all/Glass_446_45deg.png +datas/eyewear_all/Glass_021_Left.png +datas/eyewear_all/Glass_403_45deg.png +datas/eyewear_all/Glass_414_Left.png +datas/eyewear_all/Glass_139_45deg.png +datas/eyewear_all/Glass_169_Left.png +datas/eyewear_all/Glass_355_Left.png +datas/eyewear_all/Glass_292_Front.png +datas/eyewear_all/Glass_039_Left.png +datas/eyewear_all/Glass_235_Left.png +datas/eyewear_all/Glass_445_45deg.png +datas/eyewear_all/Glass_007_45deg.png +datas/eyewear_all/Glass_363_Top.png +datas/eyewear_all/Glass_169_Top.png +datas/eyewear_all/Glass_168_45deg.png +datas/eyewear_all/Glass_107_Top.png +datas/eyewear_all/Glass_390_Front.png +datas/eyewear_all/Glass_053_Front.png +datas/eyewear_all/Glass_335_Top.png +datas/eyewear_all/Glass_130_Left.png +datas/eyewear_all/Glass_363_Front.png +datas/eyewear_all/Glass_081_Front.png +datas/eyewear_all/Glass_485_Front.png +datas/eyewear_all/Glass_148_Front.png +datas/eyewear_all/Glass_427_45deg.png +datas/eyewear_all/Glass_357_Left.png +datas/eyewear_all/Glass_026_45deg.png +datas/eyewear_all/Glass_119_Top.png +datas/eyewear_all/Glass_231_45deg.png +datas/eyewear_all/Glass_057_Left.png +datas/eyewear_all/Glass_250_Left.png +datas/eyewear_all/Glass_479_Top.png +datas/eyewear_all/Glass_057_Top.png +datas/eyewear_all/Glass_350_Front.png +datas/eyewear_all/Glass_214_45deg.png +datas/eyewear_all/Glass_075_Top.png +datas/eyewear_all/Glass_437_Left.png +datas/eyewear_all/Glass_261_45deg.png +datas/eyewear_all/Glass_132_Left.png +datas/eyewear_all/Glass_244_45deg.png +datas/eyewear_all/Glass_066_Front.png +datas/eyewear_all/Glass_001_45deg.png +datas/eyewear_all/Glass_222_45deg.png +datas/eyewear_all/Glass_403_Front.png +datas/eyewear_all/Glass_086_Left.png +datas/eyewear_all/Glass_368_Left.png +datas/eyewear_all/Glass_159_Left.png +datas/eyewear_all/Glass_251_Top.png +datas/eyewear_all/Glass_198_45deg.png +datas/eyewear_all/Glass_398_Top.png +datas/eyewear_all/Glass_142_Top.png +datas/eyewear_all/Glass_242_Front.png +datas/eyewear_all/Glass_274_Left.png +datas/eyewear_all/Glass_248_Front.png +datas/eyewear_all/Glass_039_Front.png +datas/eyewear_all/Glass_030_45deg.png +datas/eyewear_all/Glass_487_Left.png +datas/eyewear_all/Glass_062_45deg.png +datas/eyewear_all/Glass_167_Left.png +datas/eyewear_all/Glass_282_Front.png +datas/eyewear_all/Glass_363_45deg.png +datas/eyewear_all/Glass_333_45deg.png +datas/eyewear_all/Glass_192_Top.png +datas/eyewear_all/Glass_195_Front.png +datas/eyewear_all/Glass_454_Top.png +datas/eyewear_all/Glass_479_Front.png +datas/eyewear_all/Glass_216_Top.png +datas/eyewear_all/Glass_467_Left.png +datas/eyewear_all/Glass_375_45deg.png +datas/eyewear_all/Glass_133_Top.png +datas/eyewear_all/Glass_077_Top.png +datas/eyewear_all/Glass_334_Front.png +datas/eyewear_all/Glass_196_45deg.png +datas/eyewear_all/Glass_458_Top.png +datas/eyewear_all/Glass_138_45deg.png +datas/eyewear_all/Glass_191_Left.png +datas/eyewear_all/Glass_349_45deg.png +datas/eyewear_all/Glass_221_Front.png +datas/eyewear_all/Glass_152_Front.png +datas/eyewear_all/Glass_348_45deg.png +datas/eyewear_all/Glass_500_Left.png +datas/eyewear_all/Glass_315_Top.png +datas/eyewear_all/Glass_206_45deg.png +datas/eyewear_all/Glass_144_Front.png +datas/eyewear_all/Glass_142_45deg.png +datas/eyewear_all/Glass_477_Top.png +datas/eyewear_all/Glass_130_Front.png +datas/eyewear_all/Glass_431_Front.png +datas/eyewear_all/Glass_295_Top.png +datas/eyewear_all/Glass_148_Left.png +datas/eyewear_all/Glass_289_45deg.png +datas/eyewear_all/Glass_137_Left.png +datas/eyewear_all/Glass_046_Front.png +datas/eyewear_all/Glass_030_Top.png +datas/eyewear_all/Glass_151_Top.png +datas/eyewear_all/Glass_397_Front.png +datas/eyewear_all/Glass_067_45deg.png +datas/eyewear_all/Glass_498_Left.png +datas/eyewear_all/Glass_453_Front.png +datas/eyewear_all/Glass_272_Left.png +datas/eyewear_all/Glass_155_45deg.png +datas/eyewear_all/Glass_308_Left.png +datas/eyewear_all/Glass_301_Front.png +datas/eyewear_all/Glass_441_45deg.png +datas/eyewear_all/Glass_456_Front.png +datas/eyewear_all/Glass_134_Top.png +datas/eyewear_all/Glass_003_Top.png +datas/eyewear_all/Glass_108_Top.png +datas/eyewear_all/Glass_110_Top.png +datas/eyewear_all/Glass_366_Left.png +datas/eyewear_all/Glass_071_Left.png +datas/eyewear_all/Glass_253_45deg.png +datas/eyewear_all/Glass_472_Left.png +datas/eyewear_all/Glass_274_Top.png +datas/eyewear_all/Glass_084_Left.png +datas/eyewear_all/Glass_089_45deg.png +datas/eyewear_all/Glass_243_Top.png +datas/eyewear_all/Glass_266_Top.png +datas/eyewear_all/Glass_353_45deg.png +datas/eyewear_all/Glass_071_Front.png +datas/eyewear_all/Glass_137_Top.png +datas/eyewear_all/Glass_428_45deg.png +datas/eyewear_all/Glass_391_45deg.png +datas/eyewear_all/Glass_334_Top.png +datas/eyewear_all/Glass_496_Left.png +datas/eyewear_all/Glass_382_Front.png +datas/eyewear_all/Glass_209_Top.png +datas/eyewear_all/Glass_263_Front.png +datas/eyewear_all/Glass_393_45deg.png +datas/eyewear_all/Glass_418_Left.png +datas/eyewear_all/Glass_035_45deg.png +datas/eyewear_all/Glass_229_Top.png +datas/eyewear_all/Glass_343_Top.png +datas/eyewear_all/Glass_484_45deg.png +datas/eyewear_all/Glass_455_45deg.png +datas/eyewear_all/Glass_345_45deg.png +datas/eyewear_all/Glass_187_45deg.png +datas/eyewear_all/Glass_006_Front.png +datas/eyewear_all/Glass_049_Left.png +datas/eyewear_all/Glass_424_Front.png +datas/eyewear_all/Glass_043_45deg.png +datas/eyewear_all/Glass_128_Left.png +datas/eyewear_all/Glass_314_45deg.png +datas/eyewear_all/Glass_445_Top.png +datas/eyewear_all/Glass_373_Left.png +datas/eyewear_all/Glass_461_Left.png +datas/eyewear_all/Glass_180_Left.png +datas/eyewear_all/Glass_261_Top.png +datas/eyewear_all/Glass_389_45deg.png +datas/eyewear_all/Glass_077_45deg.png +datas/eyewear_all/Glass_110_45deg.png +datas/eyewear_all/Glass_450_Front.png +datas/eyewear_all/Glass_381_Left.png +datas/eyewear_all/Glass_035_Top.png +datas/eyewear_all/Glass_195_Top.png +datas/eyewear_all/Glass_346_Left.png +datas/eyewear_all/Glass_405_Top.png +datas/eyewear_all/Glass_029_Front.png +datas/eyewear_all/Glass_058_45deg.png +datas/eyewear_all/Glass_198_Left.png +datas/eyewear_all/Glass_447_Top.png +datas/eyewear_all/Glass_269_Top.png +datas/eyewear_all/Glass_421_Front.png +datas/eyewear_all/Glass_452_45deg.png +datas/eyewear_all/Glass_195_45deg.png +datas/eyewear_all/Glass_196_Front.png +datas/eyewear_all/Glass_446_Front.png +datas/eyewear_all/Glass_362_Top.png +datas/eyewear_all/Glass_362_45deg.png +datas/eyewear_all/Glass_396_Front.png +datas/eyewear_all/Glass_143_45deg.png +datas/eyewear_all/Glass_410_45deg.png +datas/eyewear_all/Glass_369_Top.png +datas/eyewear_all/Glass_277_Front.png +datas/eyewear_all/Glass_429_Top.png +datas/eyewear_all/Glass_010_Left.png +datas/eyewear_all/Glass_173_Front.png +datas/eyewear_all/Glass_021_Front.png +datas/eyewear_all/Glass_434_Left.png +datas/eyewear_all/Glass_191_Front.png +datas/eyewear_all/Glass_447_Left.png +datas/eyewear_all/Glass_216_45deg.png +datas/eyewear_all/Glass_002_Front.png +datas/eyewear_all/Glass_125_45deg.png +datas/eyewear_all/Glass_321_45deg.png +datas/eyewear_all/Glass_460_Top.png +datas/eyewear_all/Glass_416_Top.png +datas/eyewear_all/Glass_170_Left.png +datas/eyewear_all/Glass_125_Top.png +datas/eyewear_all/Glass_123_Top.png +datas/eyewear_all/Glass_011_Left.png +datas/eyewear_all/Glass_200_Front.png +datas/eyewear_all/Glass_350_45deg.png +datas/eyewear_all/Glass_296_Left.png +datas/eyewear_all/Glass_429_45deg.png +datas/eyewear_all/Glass_139_Front.png +datas/eyewear_all/Glass_256_Top.png +datas/eyewear_all/Glass_067_Top.png +datas/eyewear_all/Glass_412_45deg.png +datas/eyewear_all/Glass_131_Left.png +datas/eyewear_all/Glass_348_Left.png +datas/eyewear_all/Glass_343_45deg.png +datas/eyewear_all/Glass_223_Front.png +datas/eyewear_all/Glass_386_Front.png +datas/eyewear_all/Glass_344_Front.png +datas/eyewear_all/Glass_360_Left.png +datas/eyewear_all/Glass_087_Top.png +datas/eyewear_all/Glass_423_Top.png +datas/eyewear_all/Glass_357_45deg.png +datas/eyewear_all/Glass_492_45deg.png +datas/eyewear_all/Glass_033_Top.png +datas/eyewear_all/Glass_141_Top.png +datas/eyewear_all/Glass_153_45deg.png +datas/eyewear_all/Glass_421_45deg.png +datas/eyewear_all/Glass_046_Top.png +datas/eyewear_all/Glass_086_Top.png +datas/eyewear_all/Glass_125_Front.png +datas/eyewear_all/Glass_232_Front.png +datas/eyewear_all/Glass_370_Top.png +datas/eyewear_all/Glass_267_45deg.png +datas/eyewear_all/Glass_354_45deg.png +datas/eyewear_all/Glass_462_Left.png +datas/eyewear_all/Glass_233_Left.png +datas/eyewear_all/Glass_210_Front.png +datas/eyewear_all/Glass_204_Left.png +datas/eyewear_all/Glass_248_45deg.png +datas/eyewear_all/Glass_329_Left.png +datas/eyewear_all/Glass_263_Top.png +datas/eyewear_all/Glass_181_Left.png +datas/eyewear_all/Glass_088_Front.png +datas/eyewear_all/Glass_116_Front.png +datas/eyewear_all/Glass_032_Top.png +datas/eyewear_all/Glass_270_Top.png +datas/eyewear_all/Glass_133_Front.png +datas/eyewear_all/Glass_242_Left.png +datas/eyewear_all/Glass_407_45deg.png +datas/eyewear_all/Glass_172_45deg.png +datas/eyewear_all/Glass_309_Left.png +datas/eyewear_all/Glass_152_45deg.png +datas/eyewear_all/Glass_307_Front.png +datas/eyewear_all/Glass_250_45deg.png +datas/eyewear_all/Glass_349_Top.png +datas/eyewear_all/Glass_392_45deg.png +datas/eyewear_all/Glass_087_45deg.png +datas/eyewear_all/Glass_352_Left.png +datas/eyewear_all/Glass_062_Left.png +datas/eyewear_all/Glass_387_Top.png +datas/eyewear_all/Glass_417_45deg.png +datas/eyewear_all/Glass_057_45deg.png +datas/eyewear_all/Glass_497_Top.png +datas/eyewear_all/Glass_100_Front.png +datas/eyewear_all/Glass_254_Front.png +datas/eyewear_all/Glass_012_Left.png +datas/eyewear_all/Glass_052_Left.png +datas/eyewear_all/Glass_486_Front.png +datas/eyewear_all/Glass_103_Front.png +datas/eyewear_all/Glass_125_Left.png +datas/eyewear_all/Glass_072_Top.png +datas/eyewear_all/Glass_022_45deg.png +datas/eyewear_all/Glass_450_Left.png +datas/eyewear_all/Glass_278_45deg.png +datas/eyewear_all/Glass_422_Front.png +datas/eyewear_all/Glass_097_Top.png +datas/eyewear_all/Glass_131_Top.png +datas/eyewear_all/Glass_194_Top.png +datas/eyewear_all/Glass_101_Front.png +datas/eyewear_all/Glass_301_Top.png +datas/eyewear_all/Glass_048_Top.png +datas/eyewear_all/Glass_492_Top.png +datas/eyewear_all/Glass_330_Top.png +datas/eyewear_all/Glass_356_Front.png +datas/eyewear_all/Glass_215_Left.png +datas/eyewear_all/Glass_426_Top.png +datas/eyewear_all/Glass_163_Top.png +datas/eyewear_all/Glass_499_Left.png +datas/eyewear_all/Glass_044_Left.png +datas/eyewear_all/Glass_318_45deg.png +datas/eyewear_all/Glass_337_Left.png +datas/eyewear_all/Glass_238_Top.png +datas/eyewear_all/Glass_171_Front.png +datas/eyewear_all/Glass_335_Front.png +datas/eyewear_all/Glass_355_Front.png +datas/eyewear_all/Glass_287_Left.png +datas/eyewear_all/Glass_481_Front.png +datas/eyewear_all/Glass_377_Left.png +datas/eyewear_all/Glass_425_Front.png +datas/eyewear_all/Glass_186_45deg.png +datas/eyewear_all/Glass_271_Front.png +datas/eyewear_all/Glass_224_Front.png +datas/eyewear_all/Glass_107_Left.png +datas/eyewear_all/Glass_251_Left.png +datas/eyewear_all/Glass_407_Top.png +datas/eyewear_all/Glass_053_Top.png +datas/eyewear_all/Glass_184_Top.png +datas/eyewear_all/Glass_295_Front.png +datas/eyewear_all/Glass_095_45deg.png +datas/eyewear_all/Glass_143_Top.png +datas/eyewear_all/Glass_296_Top.png +datas/eyewear_all/Glass_300_Left.png +datas/eyewear_all/Glass_240_45deg.png +datas/eyewear_all/Glass_267_Top.png +datas/eyewear_all/Glass_470_Left.png +datas/eyewear_all/Glass_390_45deg.png +datas/eyewear_all/Glass_429_Front.png +datas/eyewear_all/Glass_083_45deg.png +datas/eyewear_all/Glass_355_Top.png +datas/eyewear_all/Glass_005_Top.png +datas/eyewear_all/Glass_335_Left.png +datas/eyewear_all/Glass_354_Left.png +datas/eyewear_all/Glass_092_Top.png +datas/eyewear_all/Glass_214_Front.png +datas/eyewear_all/Glass_008_Front.png +datas/eyewear_all/Glass_072_Left.png +datas/eyewear_all/Glass_055_Left.png +datas/eyewear_all/Glass_047_Front.png +datas/eyewear_all/Glass_245_Front.png +datas/eyewear_all/Glass_463_Left.png +datas/eyewear_all/Glass_378_Left.png +datas/eyewear_all/Glass_323_Top.png +datas/eyewear_all/Glass_488_Top.png +datas/eyewear_all/Glass_464_Front.png +datas/eyewear_all/Glass_032_45deg.png +datas/eyewear_all/Glass_019_Front.png +datas/eyewear_all/Glass_367_45deg.png +datas/eyewear_all/Glass_158_45deg.png +datas/eyewear_all/Glass_274_45deg.png +datas/eyewear_all/Glass_283_Front.png +datas/eyewear_all/Glass_014_45deg.png +datas/eyewear_all/Glass_059_45deg.png +datas/eyewear_all/Glass_115_Top.png +datas/eyewear_all/Glass_282_Left.png +datas/eyewear_all/Glass_319_Top.png +datas/eyewear_all/Glass_458_Left.png +datas/eyewear_all/Glass_277_45deg.png +datas/eyewear_all/Glass_081_Left.png +datas/eyewear_all/Glass_438_Left.png +datas/eyewear_all/Glass_389_Left.png +datas/eyewear_all/Glass_250_Top.png +datas/eyewear_all/Glass_277_Top.png +datas/eyewear_all/Glass_468_Top.png +datas/eyewear_all/Glass_457_45deg.png +datas/eyewear_all/Glass_370_45deg.png +datas/eyewear_all/Glass_063_Top.png +datas/eyewear_all/Glass_390_Left.png +datas/eyewear_all/Glass_279_Front.png +datas/eyewear_all/Glass_337_45deg.png +datas/eyewear_all/Glass_217_Left.png +datas/eyewear_all/Glass_388_Top.png +datas/eyewear_all/Glass_090_45deg.png +datas/eyewear_all/Glass_178_Front.png +datas/eyewear_all/Glass_266_45deg.png +datas/eyewear_all/Glass_394_Left.png +datas/eyewear_all/Glass_393_Top.png +datas/eyewear_all/Glass_236_45deg.png +datas/eyewear_all/Glass_087_Left.png +datas/eyewear_all/Glass_451_45deg.png +datas/eyewear_all/Glass_268_Top.png +datas/eyewear_all/Glass_140_Left.png +datas/eyewear_all/Glass_217_Top.png +datas/eyewear_all/Glass_102_45deg.png +datas/eyewear_all/Glass_042_Front.png +datas/eyewear_all/Glass_085_Top.png +datas/eyewear_all/Glass_332_Top.png +datas/eyewear_all/Glass_481_45deg.png +datas/eyewear_all/Glass_020_Top.png +datas/eyewear_all/Glass_127_Left.png +datas/eyewear_all/Glass_400_Front.png +datas/eyewear_all/Glass_084_Front.png +datas/eyewear_all/Glass_106_Front.png +datas/eyewear_all/Glass_065_Top.png +datas/eyewear_all/Glass_159_Top.png +datas/eyewear_all/Glass_464_Left.png +datas/eyewear_all/Glass_494_Front.png +datas/eyewear_all/Glass_065_Front.png +datas/eyewear_all/Glass_039_Top.png +datas/eyewear_all/Glass_089_Front.png +datas/eyewear_all/Glass_326_45deg.png +datas/eyewear_all/Glass_003_Left.png +datas/eyewear_all/Glass_060_Front.png +datas/eyewear_all/Glass_154_Left.png +datas/eyewear_all/Glass_031_Top.png +datas/eyewear_all/Glass_279_45deg.png +datas/eyewear_all/Glass_171_Left.png +datas/eyewear_all/Glass_371_Front.png +datas/eyewear_all/Glass_415_Left.png +datas/eyewear_all/Glass_310_Front.png +datas/eyewear_all/Glass_351_Front.png +datas/eyewear_all/Glass_176_Left.png +datas/eyewear_all/Glass_317_Left.png +datas/eyewear_all/Glass_127_Front.png +datas/eyewear_all/Glass_439_Front.png +datas/eyewear_all/Glass_090_Left.png +datas/eyewear_all/Glass_022_Left.png +datas/eyewear_all/Glass_470_Top.png +datas/eyewear_all/Glass_142_Left.png +datas/eyewear_all/Glass_455_Front.png +datas/eyewear_all/Glass_077_Left.png +datas/eyewear_all/Glass_296_45deg.png +datas/eyewear_all/Glass_194_Front.png +datas/eyewear_all/Glass_396_Left.png +datas/eyewear_all/Glass_449_Top.png +datas/eyewear_all/Glass_101_45deg.png +datas/eyewear_all/Glass_349_Left.png +datas/eyewear_all/Glass_372_Top.png +datas/eyewear_all/Glass_272_Top.png +datas/eyewear_all/Glass_156_Top.png +datas/eyewear_all/Glass_280_Left.png +datas/eyewear_all/Glass_059_Front.png +datas/eyewear_all/Glass_009_45deg.png +datas/eyewear_all/Glass_287_Front.png +datas/eyewear_all/Glass_119_Left.png +datas/eyewear_all/Glass_451_Front.png +datas/eyewear_all/Glass_446_Top.png +datas/eyewear_all/Glass_426_Left.png +datas/eyewear_all/Glass_271_Top.png +datas/eyewear_all/Glass_350_Left.png +datas/eyewear_all/Glass_477_Front.png +datas/eyewear_all/Glass_375_Front.png +datas/eyewear_all/Glass_416_Front.png +datas/eyewear_all/Glass_240_Front.png +datas/eyewear_all/Glass_379_Left.png +datas/eyewear_all/Glass_454_Left.png +datas/eyewear_all/Glass_342_Front.png +datas/eyewear_all/Glass_061_Top.png +datas/eyewear_all/Glass_402_Front.png +datas/eyewear_all/Glass_213_Front.png +datas/eyewear_all/Glass_431_45deg.png +datas/eyewear_all/Glass_341_Top.png +datas/eyewear_all/Glass_123_Front.png +datas/eyewear_all/Glass_291_45deg.png +datas/eyewear_all/Glass_245_Left.png +datas/eyewear_all/Glass_339_Left.png +datas/eyewear_all/Glass_051_Front.png +datas/eyewear_all/Glass_462_Front.png +datas/eyewear_all/Glass_430_45deg.png +datas/eyewear_all/Glass_052_Top.png +datas/eyewear_all/Glass_048_45deg.png +datas/eyewear_all/Glass_071_Top.png +datas/eyewear_all/Glass_167_45deg.png +datas/eyewear_all/Glass_352_Top.png +datas/eyewear_all/Glass_117_Top.png +datas/eyewear_all/Glass_443_Front.png +datas/eyewear_all/Glass_417_Front.png +datas/eyewear_all/Glass_166_Front.png +datas/eyewear_all/Glass_442_Front.png +datas/eyewear_all/Glass_180_Front.png +datas/eyewear_all/Glass_113_Top.png +datas/eyewear_all/Glass_482_Front.png +datas/eyewear_all/Glass_456_45deg.png +datas/eyewear_all/Glass_001_Left.png +datas/eyewear_all/Glass_209_Left.png +datas/eyewear_all/Glass_279_Top.png +datas/eyewear_all/Glass_129_Top.png +datas/eyewear_all/Glass_342_Left.png +datas/eyewear_all/Glass_468_Left.png +datas/eyewear_all/Glass_399_45deg.png +datas/eyewear_all/Glass_235_Top.png +datas/eyewear_all/Glass_492_Front.png +datas/eyewear_all/Glass_210_Top.png +datas/eyewear_all/Glass_052_45deg.png +datas/eyewear_all/Glass_263_45deg.png +datas/eyewear_all/Glass_442_45deg.png +datas/eyewear_all/Glass_332_45deg.png +datas/eyewear_all/Glass_068_Left.png +datas/eyewear_all/Glass_457_Front.png +datas/eyewear_all/Glass_160_Top.png +datas/eyewear_all/Glass_374_45deg.png +datas/eyewear_all/Glass_009_Top.png +datas/eyewear_all/Glass_017_Left.png +datas/eyewear_all/Glass_042_Top.png +datas/eyewear_all/Glass_285_Left.png +datas/eyewear_all/Glass_498_45deg.png +datas/eyewear_all/Glass_284_Top.png +datas/eyewear_all/Glass_172_Top.png +datas/eyewear_all/Glass_314_Left.png +datas/eyewear_all/Glass_472_Top.png +datas/eyewear_all/Glass_287_45deg.png +datas/eyewear_all/Glass_255_Front.png +datas/eyewear_all/Glass_368_Front.png +datas/eyewear_all/Glass_202_Front.png +datas/eyewear_all/Glass_335_45deg.png +datas/eyewear_all/Glass_102_Front.png +datas/eyewear_all/Glass_140_Top.png +datas/eyewear_all/Glass_459_Front.png +datas/eyewear_all/Glass_203_Left.png +datas/eyewear_all/Glass_176_45deg.png +datas/eyewear_all/Glass_476_Front.png +datas/eyewear_all/Glass_171_45deg.png +datas/eyewear_all/Glass_268_Left.png +datas/eyewear_all/Glass_086_Front.png +datas/eyewear_all/Glass_226_Front.png +datas/eyewear_all/Glass_143_Front.png +datas/eyewear_all/Glass_155_Top.png +datas/eyewear_all/Glass_008_Left.png +datas/eyewear_all/Glass_058_Front.png +datas/eyewear_all/Glass_227_Front.png +datas/eyewear_all/Glass_387_Left.png +datas/eyewear_all/Glass_413_Front.png +datas/eyewear_all/Glass_038_Top.png +datas/eyewear_all/Glass_150_Top.png +datas/eyewear_all/Glass_470_Front.png +datas/eyewear_all/Glass_157_Front.png +datas/eyewear_all/Glass_331_45deg.png +datas/eyewear_all/Glass_351_Top.png +datas/eyewear_all/Glass_412_Front.png +datas/eyewear_all/Glass_241_Front.png +datas/eyewear_all/Glass_273_Front.png +datas/eyewear_all/Glass_243_45deg.png +datas/eyewear_all/Glass_368_Top.png +datas/eyewear_all/Glass_382_45deg.png +datas/eyewear_all/Glass_242_Top.png +datas/eyewear_all/Glass_333_Top.png +datas/eyewear_all/Glass_247_Left.png +datas/eyewear_all/Glass_024_Top.png +datas/eyewear_all/Glass_297_Left.png +datas/eyewear_all/Glass_483_45deg.png +datas/eyewear_all/Glass_427_Front.png +datas/eyewear_all/Glass_065_Left.png +datas/eyewear_all/Glass_074_Left.png +datas/eyewear_all/Glass_245_Top.png +datas/eyewear_all/Glass_045_45deg.png +datas/eyewear_all/Glass_297_45deg.png +datas/eyewear_all/Glass_358_Left.png +datas/eyewear_all/Glass_028_45deg.png +datas/eyewear_all/Glass_138_Top.png +datas/eyewear_all/Glass_290_Top.png +datas/eyewear_all/Glass_197_Top.png +datas/eyewear_all/Glass_460_Front.png +datas/eyewear_all/Glass_338_Top.png +datas/eyewear_all/Glass_018_Front.png +datas/eyewear_all/Glass_390_Top.png +datas/eyewear_all/Glass_094_Top.png +datas/eyewear_all/Glass_212_Front.png +datas/eyewear_all/Glass_211_Left.png +datas/eyewear_all/Glass_069_Top.png +datas/eyewear_all/Glass_430_Front.png +datas/eyewear_all/Glass_433_Front.png +datas/eyewear_all/Glass_305_Left.png +datas/eyewear_all/Glass_029_Top.png +datas/eyewear_all/Glass_372_Left.png +datas/eyewear_all/Glass_284_Front.png +datas/eyewear_all/Glass_356_45deg.png +datas/eyewear_all/Glass_295_45deg.png +datas/eyewear_all/Glass_339_Front.png +datas/eyewear_all/Glass_197_Left.png +datas/eyewear_all/Glass_064_45deg.png +datas/eyewear_all/Glass_001_Top.png +datas/eyewear_all/Glass_047_45deg.png +datas/eyewear_all/Glass_042_Left.png +datas/eyewear_all/Glass_377_45deg.png +datas/eyewear_all/Glass_488_Front.png +datas/eyewear_all/Glass_184_45deg.png +datas/eyewear_all/Glass_493_Left.png +datas/eyewear_all/Glass_237_45deg.png +datas/eyewear_all/Glass_418_45deg.png +datas/eyewear_all/Glass_219_45deg.png +datas/eyewear_all/Glass_229_45deg.png +datas/eyewear_all/Glass_471_Left.png +datas/eyewear_all/Glass_499_Front.png +datas/eyewear_all/Glass_035_Front.png +datas/eyewear_all/Glass_055_Top.png +datas/eyewear_all/Glass_211_45deg.png +datas/eyewear_all/Glass_012_Front.png +datas/eyewear_all/Glass_293_Front.png +datas/eyewear_all/Glass_110_Front.png +datas/eyewear_all/Glass_401_Front.png +datas/eyewear_all/Glass_482_45deg.png +datas/eyewear_all/Glass_325_Left.png +datas/eyewear_all/Glass_260_Left.png +datas/eyewear_all/Glass_008_45deg.png +datas/eyewear_all/Glass_286_Left.png +datas/eyewear_all/Glass_460_Left.png +datas/eyewear_all/Glass_472_Front.png +datas/eyewear_all/Glass_013_Front.png +datas/eyewear_all/Glass_188_Front.png +datas/eyewear_all/Glass_129_45deg.png +datas/eyewear_all/Glass_324_Top.png +datas/eyewear_all/Glass_385_Front.png +datas/eyewear_all/Glass_266_Left.png +datas/eyewear_all/Glass_038_45deg.png +datas/eyewear_all/Glass_141_Left.png +datas/eyewear_all/Glass_289_Top.png +datas/eyewear_all/Glass_154_Top.png +datas/eyewear_all/Glass_066_Left.png +datas/eyewear_all/Glass_262_Front.png +datas/eyewear_all/Glass_458_45deg.png +datas/eyewear_all/Glass_209_Front.png +datas/eyewear_all/Glass_161_45deg.png +datas/eyewear_all/Glass_490_Left.png +datas/eyewear_all/Glass_263_Left.png +datas/eyewear_all/Glass_470_45deg.png +datas/eyewear_all/Glass_232_Top.png +datas/eyewear_all/Glass_162_Left.png +datas/eyewear_all/Glass_259_Top.png +datas/eyewear_all/Glass_061_45deg.png +datas/eyewear_all/Glass_107_Front.png +datas/eyewear_all/Glass_467_Front.png +datas/eyewear_all/Glass_329_45deg.png +datas/eyewear_all/Glass_054_Left.png +datas/eyewear_all/Glass_448_Front.png +datas/eyewear_all/Glass_039_45deg.png +datas/eyewear_all/Glass_312_45deg.png +datas/eyewear_all/Glass_179_Top.png +datas/eyewear_all/Glass_135_45deg.png +datas/eyewear_all/Glass_075_Left.png +datas/eyewear_all/Glass_036_Front.png +datas/eyewear_all/Glass_436_Left.png +datas/eyewear_all/Glass_428_Top.png +datas/eyewear_all/Glass_081_Top.png +datas/eyewear_all/Glass_078_Left.png +datas/eyewear_all/Glass_111_Left.png +datas/eyewear_all/Glass_122_Left.png +datas/eyewear_all/Glass_408_Left.png +datas/eyewear_all/Glass_448_Left.png +datas/eyewear_all/Glass_092_45deg.png +datas/eyewear_all/Glass_097_Front.png +datas/eyewear_all/Glass_357_Top.png +datas/eyewear_all/Glass_290_Left.png +datas/eyewear_all/Glass_197_45deg.png +datas/eyewear_all/Glass_486_Left.png +datas/eyewear_all/Glass_443_45deg.png +datas/eyewear_all/Glass_329_Front.png +datas/eyewear_all/Glass_216_Front.png +datas/eyewear_all/Glass_423_45deg.png +datas/eyewear_all/Glass_220_Front.png +datas/eyewear_all/Glass_384_Left.png +datas/eyewear_all/Glass_466_45deg.png +datas/eyewear_all/Glass_120_Top.png +datas/eyewear_all/Glass_377_Front.png +datas/eyewear_all/Glass_182_Left.png +datas/eyewear_all/Glass_376_Top.png +datas/eyewear_all/Glass_126_Left.png +datas/eyewear_all/Glass_104_Left.png +datas/eyewear_all/Glass_428_Left.png +datas/eyewear_all/Glass_436_Front.png +datas/eyewear_all/Glass_064_Left.png +datas/eyewear_all/Glass_023_Front.png +datas/eyewear_all/Glass_373_Top.png +datas/eyewear_all/Glass_429_Left.png +datas/eyewear_all/Glass_308_Front.png +datas/eyewear_all/Glass_489_45deg.png +datas/eyewear_all/Glass_073_Front.png +datas/eyewear_all/Glass_246_Left.png +datas/eyewear_all/Glass_183_Front.png +datas/eyewear_all/Glass_050_Top.png +datas/eyewear_all/Glass_058_Left.png +datas/eyewear_all/Glass_094_45deg.png +datas/eyewear_all/Glass_197_Front.png +datas/eyewear_all/Glass_316_45deg.png +datas/eyewear_all/Glass_333_Front.png +datas/eyewear_all/Glass_414_45deg.png +datas/eyewear_all/Glass_303_Left.png +datas/eyewear_all/Glass_393_Left.png +datas/eyewear_all/Glass_079_45deg.png +datas/eyewear_all/Glass_265_Front.png +datas/eyewear_all/Glass_313_Top.png +datas/eyewear_all/Glass_285_Front.png +datas/eyewear_all/Glass_269_45deg.png +datas/eyewear_all/Glass_436_Top.png +datas/eyewear_all/Glass_247_Front.png +datas/eyewear_all/Glass_093_Front.png +datas/eyewear_all/Glass_394_45deg.png +datas/eyewear_all/Glass_457_Top.png +datas/eyewear_all/Glass_154_Front.png +datas/eyewear_all/Glass_027_45deg.png +datas/eyewear_all/Glass_152_Top.png +datas/eyewear_all/Glass_241_45deg.png +datas/eyewear_all/Glass_266_Front.png +datas/eyewear_all/Glass_203_45deg.png +datas/eyewear_all/Glass_328_Top.png +datas/eyewear_all/Glass_387_Front.png +datas/eyewear_all/Glass_386_Left.png +datas/eyewear_all/Glass_127_45deg.png +datas/eyewear_all/Glass_420_Left.png +datas/eyewear_all/Glass_212_Left.png +datas/eyewear_all/Glass_164_45deg.png +datas/eyewear_all/Glass_041_Left.png +datas/eyewear_all/Glass_027_Left.png +datas/eyewear_all/Glass_160_Left.png +datas/eyewear_all/Glass_363_Left.png +datas/eyewear_all/Glass_104_Top.png +datas/eyewear_all/Glass_312_Front.png +datas/eyewear_all/Glass_010_Front.png +datas/eyewear_all/Glass_141_45deg.png +datas/eyewear_all/Glass_280_Top.png +datas/eyewear_all/Glass_355_45deg.png +datas/eyewear_all/Glass_258_Top.png +datas/eyewear_all/Glass_364_Front.png +datas/eyewear_all/Glass_383_45deg.png +datas/eyewear_all/Glass_471_Top.png +datas/eyewear_all/Glass_258_Left.png +datas/eyewear_all/Glass_120_Front.png +datas/eyewear_all/Glass_115_Front.png +datas/eyewear_all/Glass_138_Front.png +datas/eyewear_all/Glass_425_45deg.png +datas/eyewear_all/Glass_024_Left.png +datas/eyewear_all/Glass_469_Top.png +datas/eyewear_all/Glass_031_Front.png +datas/eyewear_all/Glass_131_Front.png +datas/eyewear_all/Glass_315_45deg.png +datas/eyewear_all/Glass_478_Top.png +datas/eyewear_all/Glass_327_Top.png +datas/eyewear_all/Glass_327_Left.png +datas/eyewear_all/Glass_074_Top.png +datas/eyewear_all/Glass_246_Top.png +datas/eyewear_all/Glass_275_Front.png +datas/eyewear_all/Glass_414_Top.png +datas/eyewear_all/Glass_076_45deg.png +datas/eyewear_all/Glass_111_Top.png +datas/eyewear_all/Glass_404_Top.png +datas/eyewear_all/Glass_260_Top.png +datas/eyewear_all/Glass_325_Front.png +datas/eyewear_all/Glass_108_45deg.png +datas/eyewear_all/Glass_233_Top.png +datas/eyewear_all/Glass_219_Top.png +datas/eyewear_all/Glass_146_Top.png +datas/eyewear_all/Glass_236_Top.png +datas/eyewear_all/Glass_223_Left.png +datas/eyewear_all/Glass_005_45deg.png +datas/eyewear_all/Glass_346_45deg.png +datas/eyewear_all/Glass_496_Front.png +datas/eyewear_all/Glass_444_45deg.png +datas/eyewear_all/Glass_259_Left.png +datas/eyewear_all/Glass_362_Front.png +datas/eyewear_all/Glass_199_Front.png +datas/eyewear_all/Glass_011_45deg.png +datas/eyewear_all/Glass_347_45deg.png +datas/eyewear_all/Glass_225_Left.png +datas/eyewear_all/Glass_079_Top.png +datas/eyewear_all/Glass_136_Front.png +datas/eyewear_all/Glass_418_Front.png +datas/eyewear_all/Glass_134_Front.png +datas/eyewear_all/Glass_158_Top.png +datas/eyewear_all/Glass_166_45deg.png +datas/eyewear_all/Glass_031_Left.png +datas/eyewear_all/Glass_469_Left.png +datas/eyewear_all/Glass_048_Front.png +datas/eyewear_all/Glass_330_Left.png +datas/eyewear_all/Glass_097_45deg.png +datas/eyewear_all/Glass_007_Front.png +datas/eyewear_all/Glass_275_Top.png +datas/eyewear_all/Glass_364_Top.png +datas/eyewear_all/Glass_474_Top.png +datas/eyewear_all/Glass_465_Left.png +datas/eyewear_all/Glass_369_45deg.png +datas/eyewear_all/Glass_350_Top.png +datas/eyewear_all/Glass_186_Front.png +datas/eyewear_all/Glass_144_45deg.png +datas/eyewear_all/Glass_196_Left.png +datas/eyewear_all/Glass_371_Left.png +datas/eyewear_all/Glass_149_Top.png +datas/eyewear_all/Glass_473_45deg.png +datas/eyewear_all/Glass_166_Top.png +datas/eyewear_all/Glass_145_Left.png +datas/eyewear_all/Glass_269_Left.png +datas/eyewear_all/Glass_347_Front.png +datas/eyewear_all/Glass_495_45deg.png +datas/eyewear_all/Glass_396_Top.png +datas/eyewear_all/Glass_247_Top.png +datas/eyewear_all/Glass_234_Front.png +datas/eyewear_all/Glass_376_Front.png +datas/eyewear_all/Glass_057_Front.png +datas/eyewear_all/Glass_262_45deg.png +datas/eyewear_all/Glass_465_45deg.png +datas/eyewear_all/Glass_105_Front.png +datas/eyewear_all/Glass_088_Top.png +datas/eyewear_all/Glass_194_Left.png +datas/eyewear_all/Glass_234_45deg.png +datas/eyewear_all/Glass_189_Left.png +datas/eyewear_all/Glass_173_Top.png +datas/eyewear_all/Glass_145_Front.png +datas/eyewear_all/Glass_051_Top.png +datas/eyewear_all/Glass_399_Top.png +datas/eyewear_all/Glass_233_45deg.png +datas/eyewear_all/Glass_060_45deg.png +datas/eyewear_all/Glass_385_Left.png +datas/eyewear_all/Glass_201_Front.png +datas/eyewear_all/Glass_402_Top.png +datas/eyewear_all/Glass_276_Top.png +datas/eyewear_all/Glass_191_Top.png +datas/eyewear_all/Glass_028_Left.png +datas/eyewear_all/Glass_053_45deg.png +datas/eyewear_all/Glass_283_Top.png +datas/eyewear_all/Glass_090_Front.png +datas/eyewear_all/Glass_122_Top.png +datas/eyewear_all/Glass_205_Front.png +datas/eyewear_all/Glass_406_Front.png +datas/eyewear_all/Glass_130_45deg.png +datas/eyewear_all/Glass_049_Top.png +datas/eyewear_all/Glass_228_Left.png +datas/eyewear_all/Glass_082_Front.png +datas/eyewear_all/Glass_386_45deg.png +datas/eyewear_all/Glass_473_Front.png +datas/eyewear_all/Glass_183_45deg.png +datas/eyewear_all/Glass_184_Front.png +datas/eyewear_all/Glass_245_45deg.png +datas/eyewear_all/Glass_275_45deg.png +datas/eyewear_all/Glass_278_Front.png +datas/eyewear_all/Glass_419_45deg.png +datas/eyewear_all/Glass_014_Front.png +datas/eyewear_all/Glass_206_Left.png +datas/eyewear_all/Glass_045_Front.png +datas/eyewear_all/Glass_303_Front.png +datas/eyewear_all/Glass_244_Top.png +datas/eyewear_all/Glass_296_Front.png +datas/eyewear_all/Glass_169_Front.png +datas/eyewear_all/Glass_121_Left.png +datas/eyewear_all/Glass_460_45deg.png +datas/eyewear_all/Glass_415_Front.png +datas/eyewear_all/Glass_181_45deg.png +datas/eyewear_all/Glass_116_Top.png +datas/eyewear_all/Glass_495_Front.png +datas/eyewear_all/Glass_133_Left.png +datas/eyewear_all/Glass_170_Front.png +datas/eyewear_all/Glass_380_Front.png +datas/eyewear_all/Glass_235_45deg.png +datas/eyewear_all/Glass_077_Front.png +datas/eyewear_all/Glass_248_Top.png +datas/eyewear_all/Glass_265_45deg.png +datas/eyewear_all/Glass_281_45deg.png +datas/eyewear_all/Glass_412_Left.png +datas/eyewear_all/Glass_203_Front.png +datas/eyewear_all/Glass_370_Front.png +datas/eyewear_all/Glass_439_Top.png +datas/eyewear_all/Glass_338_45deg.png +datas/eyewear_all/Glass_230_Left.png +datas/eyewear_all/Glass_117_Front.png +datas/eyewear_all/Glass_231_Front.png +datas/eyewear_all/Glass_124_45deg.png +datas/eyewear_all/Glass_238_45deg.png +datas/eyewear_all/Glass_370_Left.png +datas/eyewear_all/Glass_291_Top.png +datas/eyewear_all/Glass_262_Top.png +datas/eyewear_all/Glass_452_Left.png +datas/eyewear_all/Glass_343_Left.png +datas/eyewear_all/Glass_321_Top.png +datas/eyewear_all/Glass_029_Left.png +datas/eyewear_all/Glass_091_Top.png +datas/eyewear_all/Glass_126_Top.png +datas/eyewear_all/Glass_477_Left.png +datas/eyewear_all/Glass_333_Left.png +datas/eyewear_all/Glass_104_45deg.png +datas/eyewear_all/Glass_416_45deg.png +datas/eyewear_all/Glass_059_Left.png +datas/eyewear_all/Glass_282_Top.png +datas/eyewear_all/Glass_456_Left.png +datas/eyewear_all/Glass_004_Front.png +datas/eyewear_all/Glass_079_Front.png +datas/eyewear_all/Glass_280_Front.png +datas/eyewear_all/Glass_149_Front.png +datas/eyewear_all/Glass_051_45deg.png +datas/eyewear_all/Glass_085_45deg.png +datas/eyewear_all/Glass_009_Left.png +datas/eyewear_all/Glass_473_Left.png +datas/eyewear_all/Glass_160_Front.png +datas/eyewear_all/Glass_475_45deg.png +datas/eyewear_all/Glass_185_Left.png +datas/eyewear_all/Glass_069_45deg.png +datas/eyewear_all/Glass_484_Top.png +datas/eyewear_all/Glass_170_45deg.png +datas/eyewear_all/Glass_210_45deg.png +datas/eyewear_all/Glass_376_45deg.png +datas/eyewear_all/Glass_203_Top.png +datas/eyewear_all/Glass_360_45deg.png +datas/eyewear_all/Glass_477_45deg.png +datas/eyewear_all/Glass_010_Top.png +datas/eyewear_all/Glass_268_45deg.png +datas/eyewear_all/Glass_461_45deg.png +datas/eyewear_all/Glass_310_Left.png +datas/eyewear_all/Glass_325_45deg.png +datas/eyewear_all/Glass_291_Left.png +datas/eyewear_all/Glass_432_45deg.png +datas/eyewear_all/Glass_320_Front.png +datas/eyewear_all/Glass_249_Left.png +datas/eyewear_all/Glass_306_Front.png +datas/eyewear_all/Glass_201_Top.png +datas/eyewear_all/Glass_087_Front.png +datas/eyewear_all/Glass_408_45deg.png +datas/eyewear_all/Glass_326_Front.png +datas/eyewear_all/Glass_391_Left.png +datas/eyewear_all/Glass_274_Front.png +datas/eyewear_all/Glass_095_Front.png +datas/eyewear_all/Glass_415_Top.png +datas/eyewear_all/Glass_181_Front.png +datas/eyewear_all/Glass_156_Front.png +datas/eyewear_all/Glass_176_Top.png +datas/eyewear_all/Glass_432_Front.png +datas/eyewear_all/Glass_312_Top.png +datas/eyewear_all/Glass_050_45deg.png +datas/eyewear_all/Glass_202_Top.png +datas/eyewear_all/Glass_060_Left.png +datas/eyewear_all/Glass_334_45deg.png +datas/eyewear_all/Glass_081_45deg.png +datas/eyewear_all/Glass_346_Top.png +datas/eyewear_all/Glass_264_Left.png +datas/eyewear_all/Glass_136_Top.png +datas/eyewear_all/Glass_222_Top.png +datas/eyewear_all/Glass_137_Front.png +datas/eyewear_all/Glass_152_Left.png +datas/eyewear_all/Glass_215_Front.png +datas/eyewear_all/Glass_447_45deg.png +datas/eyewear_all/Glass_282_45deg.png +datas/eyewear_all/Glass_158_Front.png +datas/eyewear_all/Glass_219_Left.png +datas/eyewear_all/Glass_309_45deg.png +datas/eyewear_all/Glass_137_45deg.png +datas/eyewear_all/Glass_301_45deg.png +datas/eyewear_all/Glass_417_Top.png +datas/eyewear_all/Glass_493_45deg.png +datas/eyewear_all/Glass_073_45deg.png +datas/eyewear_all/Glass_433_Top.png +datas/eyewear_all/Glass_114_Left.png +datas/eyewear_all/Glass_479_Left.png +datas/eyewear_all/Glass_324_Front.png +datas/eyewear_all/Glass_043_Left.png +datas/eyewear_all/Glass_062_Top.png +datas/eyewear_all/Glass_056_Top.png +datas/eyewear_all/Glass_341_Left.png +datas/eyewear_all/Glass_112_Top.png +datas/eyewear_all/Glass_062_Front.png +datas/eyewear_all/Glass_113_Left.png +datas/eyewear_all/Glass_229_Front.png +datas/eyewear_all/Glass_400_Left.png +datas/eyewear_all/Glass_419_Front.png +datas/eyewear_all/Glass_135_Top.png +datas/eyewear_all/Glass_190_45deg.png +datas/eyewear_all/Glass_450_45deg.png +datas/eyewear_all/Glass_150_Left.png +datas/eyewear_all/Glass_192_Front.png +datas/eyewear_all/Glass_204_45deg.png +datas/eyewear_all/Glass_117_Left.png +datas/eyewear_all/Glass_225_45deg.png +datas/eyewear_all/Glass_026_Top.png +datas/eyewear_all/Glass_189_Top.png +datas/eyewear_all/Glass_230_45deg.png +datas/eyewear_all/Glass_383_Front.png +datas/eyewear_all/Glass_383_Top.png +datas/eyewear_all/Glass_193_Front.png +datas/eyewear_all/Glass_316_Left.png +datas/eyewear_all/Glass_256_Front.png +datas/eyewear_all/Glass_322_Top.png +datas/eyewear_all/Glass_435_Left.png +datas/eyewear_all/Glass_487_45deg.png +datas/eyewear_all/Glass_070_45deg.png +datas/eyewear_all/Glass_425_Top.png +datas/eyewear_all/Glass_262_Left.png +datas/eyewear_all/Glass_491_45deg.png +datas/eyewear_all/Glass_338_Left.png +datas/eyewear_all/Glass_182_Front.png +datas/eyewear_all/Glass_186_Left.png +datas/eyewear_all/Glass_386_Top.png +datas/eyewear_all/Glass_099_45deg.png +datas/eyewear_all/Glass_003_Front.png +datas/eyewear_all/Glass_412_Top.png +datas/eyewear_all/Glass_288_45deg.png +datas/eyewear_all/Glass_307_Top.png +datas/eyewear_all/Glass_239_Front.png +datas/eyewear_all/Glass_074_Front.png +datas/eyewear_all/Glass_305_Front.png +datas/eyewear_all/Glass_096_Left.png +datas/eyewear_all/Glass_480_Left.png +datas/eyewear_all/Glass_213_Left.png +datas/eyewear_all/Glass_417_Left.png +datas/eyewear_all/Glass_344_45deg.png +datas/eyewear_all/Glass_238_Front.png +datas/eyewear_all/Glass_388_45deg.png +datas/eyewear_all/Glass_123_Left.png +datas/eyewear_all/Glass_162_Front.png +datas/eyewear_all/Glass_029_45deg.png +datas/eyewear_all/Glass_244_Front.png +datas/eyewear_all/Glass_403_Top.png +datas/eyewear_all/Glass_400_Top.png +datas/eyewear_all/Glass_148_Top.png +datas/eyewear_all/Glass_179_Front.png +datas/eyewear_all/Glass_019_45deg.png +datas/eyewear_all/Glass_403_Left.png +datas/eyewear_all/Glass_471_45deg.png +datas/eyewear_all/Glass_207_Front.png +datas/eyewear_all/Glass_317_45deg.png +datas/eyewear_all/Glass_268_Front.png +datas/eyewear_all/Glass_418_Top.png +datas/eyewear_all/Glass_040_Top.png +datas/eyewear_all/Glass_361_45deg.png +datas/eyewear_all/Glass_462_Top.png +datas/eyewear_all/Glass_036_Top.png +datas/eyewear_all/Glass_145_Top.png +datas/eyewear_all/Glass_224_Top.png +datas/eyewear_all/Glass_033_Left.png +datas/eyewear_all/Glass_049_Front.png +datas/eyewear_all/Glass_106_Top.png +datas/eyewear_all/Glass_331_Front.png +datas/eyewear_all/Glass_444_Top.png +datas/eyewear_all/Glass_055_Front.png +datas/eyewear_all/Glass_345_Top.png +datas/eyewear_all/Glass_146_Left.png +datas/eyewear_all/Glass_030_Front.png +datas/eyewear_all/Glass_456_Top.png +datas/eyewear_all/Glass_078_Top.png +datas/eyewear_all/Glass_063_Left.png +datas/eyewear_all/Glass_389_Top.png +datas/eyewear_all/Glass_459_Left.png +datas/eyewear_all/Glass_256_Left.png +datas/eyewear_all/Glass_214_Top.png +datas/eyewear_all/Glass_252_45deg.png +datas/eyewear_all/Glass_439_Left.png +datas/eyewear_all/Glass_269_Front.png +datas/eyewear_all/Glass_447_Front.png +datas/eyewear_all/Glass_409_Left.png +datas/eyewear_all/Glass_449_Front.png +datas/eyewear_all/Glass_118_Top.png +datas/eyewear_all/Glass_149_45deg.png +datas/eyewear_all/Glass_466_Left.png +datas/eyewear_all/Glass_142_Front.png +datas/eyewear_all/Glass_195_Left.png +datas/eyewear_all/Glass_411_Left.png +datas/eyewear_all/Glass_221_Top.png +datas/eyewear_all/Glass_016_45deg.png +datas/eyewear_all/Glass_261_Left.png +datas/eyewear_all/Glass_293_Top.png +datas/eyewear_all/Glass_228_45deg.png +datas/eyewear_all/Glass_435_Top.png +datas/eyewear_all/Glass_484_Front.png +datas/eyewear_all/Glass_236_Left.png +datas/eyewear_all/Glass_465_Top.png +datas/eyewear_all/Glass_452_Top.png +datas/eyewear_all/Glass_448_45deg.png +datas/eyewear_all/Glass_342_Top.png +datas/eyewear_all/Glass_273_Left.png +datas/eyewear_all/Glass_385_45deg.png +datas/eyewear_all/Glass_351_Left.png +datas/eyewear_all/Glass_295_Left.png +datas/eyewear_all/Glass_419_Top.png +datas/eyewear_all/Glass_365_Front.png +datas/eyewear_all/Glass_080_45deg.png +datas/eyewear_all/Glass_327_Front.png +datas/eyewear_all/Glass_331_Top.png +datas/eyewear_all/Glass_011_Top.png +datas/eyewear_all/Glass_315_Left.png +datas/eyewear_all/Glass_173_45deg.png +datas/eyewear_all/Glass_478_Front.png +datas/eyewear_all/Glass_474_Front.png +datas/eyewear_all/Glass_459_Top.png +datas/eyewear_all/Glass_257_45deg.png +datas/eyewear_all/Glass_467_Top.png +datas/eyewear_all/Glass_257_Top.png +datas/eyewear_all/Glass_419_Left.png +datas/eyewear_all/Glass_289_Left.png +datas/eyewear_all/Glass_339_45deg.png +datas/eyewear_all/Glass_351_45deg.png +datas/eyewear_all/Glass_040_Left.png +datas/eyewear_all/Glass_336_Left.png +datas/eyewear_all/Glass_222_Left.png +datas/eyewear_all/Glass_493_Top.png +datas/eyewear_all/Glass_399_Front.png +datas/eyewear_all/Glass_165_Front.png +datas/eyewear_all/Glass_177_Top.png +datas/eyewear_all/Glass_109_45deg.png +datas/eyewear_all/Glass_205_Left.png +datas/eyewear_all/Glass_467_45deg.png +datas/eyewear_all/Glass_193_Top.png +datas/eyewear_all/Glass_147_Front.png +datas/eyewear_all/Glass_193_Left.png +datas/eyewear_all/Glass_319_Left.png +datas/eyewear_all/Glass_241_Top.png +datas/eyewear_all/Glass_073_Top.png +datas/eyewear_all/Glass_145_45deg.png +datas/eyewear_all/Glass_022_Top.png +datas/eyewear_all/Glass_290_45deg.png +datas/eyewear_all/Glass_380_Top.png +datas/eyewear_all/Glass_313_45deg.png +datas/eyewear_all/Glass_009_Front.png +datas/eyewear_all/Glass_407_Front.png +datas/eyewear_all/Glass_340_45deg.png +datas/eyewear_all/Glass_059_Top.png +datas/eyewear_all/Glass_026_Front.png +datas/eyewear_all/Glass_469_45deg.png +datas/eyewear_all/Glass_098_45deg.png +datas/eyewear_all/Glass_093_Left.png +datas/eyewear_all/Glass_112_Left.png +datas/eyewear_all/Glass_019_Left.png +datas/eyewear_all/Glass_365_45deg.png +datas/eyewear_all/Glass_360_Top.png +datas/eyewear_all/Glass_341_45deg.png +datas/eyewear_all/Glass_319_45deg.png +datas/eyewear_all/Glass_353_Left.png +datas/eyewear_all/Glass_228_Top.png +datas/eyewear_all/Glass_114_Top.png +datas/eyewear_all/Glass_017_45deg.png +datas/eyewear_all/Glass_276_Front.png +datas/eyewear_all/Glass_404_Front.png +datas/eyewear_all/Glass_253_Front.png +datas/eyewear_all/Glass_196_Top.png +datas/eyewear_all/Glass_406_Left.png +datas/eyewear_all/Glass_289_Front.png +datas/eyewear_all/Glass_354_Front.png \ No newline at end of file diff --git a/datas/eyewear_all.fvecs.bin b/datas/eyewear_all.fvecs.bin new file mode 100644 index 0000000..c100482 Binary files /dev/null and b/datas/eyewear_all.fvecs.bin differ diff --git a/datas/eyewear_all.fvecs.bin.hnsw.index b/datas/eyewear_all.fvecs.bin.hnsw.index new file mode 100644 index 0000000..d91a8ab Binary files /dev/null and b/datas/eyewear_all.fvecs.bin.hnsw.index differ diff --git a/datas/eyewear_all.fvecs.bin.l2.index b/datas/eyewear_all.fvecs.bin.l2.index new file mode 100644 index 0000000..0a3a358 Binary files /dev/null and b/datas/eyewear_all.fvecs.bin.l2.index differ diff --git a/environment_vactor.yml b/environment_vactor.yml new file mode 100644 index 0000000..99bd06b --- /dev/null +++ b/environment_vactor.yml @@ -0,0 +1,14 @@ +name: fm_rest_vactor +channels: + - pytorch + - nvidia + - rapidsai + - conda-forge + - defaults +dependencies: + - python=3.10 + - libnvjitlink + - faiss-gpu-cuvs=1.10.0 + - tensorflow=2.11.0 + - pip: + - -r requirements_vactor.txt \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index 7a36a1a..0000000 --- a/main.py +++ /dev/null @@ -1,6 +0,0 @@ -import uvicorn -from rest.app.common.config import conf - - -if __name__ == '__main__': - uvicorn.run('rest.app.main:app', host='0.0.0.0', port=conf().REST_SERVER_PORT, reload=True) \ No newline at end of file diff --git a/rest/.travis.yml b/main_rest/.travis.yml similarity index 100% rename from rest/.travis.yml rename to main_rest/.travis.yml diff --git a/rest/app/api_request_sample.py b/main_rest/app/api_request_sample.py similarity index 100% rename from rest/app/api_request_sample.py rename to main_rest/app/api_request_sample.py diff --git a/rest/app/common/config.py b/main_rest/app/common/config.py similarity index 97% rename from rest/app/common/config.py rename to main_rest/app/common/config.py index cf45518..f433fd5 100644 --- a/rest/app/common/config.py +++ b/main_rest/app/common/config.py @@ -12,8 +12,8 @@ from dataclasses import dataclass from os import path, environ -from rest.app.common import consts -from rest.app.models import UserInfo +from main_rest.app.common import consts +from main_rest.app.models import UserInfo base_dir = path.dirname(path.dirname(path.dirname(path.abspath(__file__)))) diff --git a/rest/app/common/consts.py b/main_rest/app/common/consts.py similarity index 100% rename from rest/app/common/consts.py rename to main_rest/app/common/consts.py diff --git a/rest/app/database/conn.py b/main_rest/app/database/conn.py similarity index 97% rename from rest/app/database/conn.py rename to main_rest/app/database/conn.py index 1dc7ecb..fd10094 100644 --- a/rest/app/database/conn.py +++ b/main_rest/app/database/conn.py @@ -126,8 +126,8 @@ Base = declarative_base() # NOTE(hsj100): ADMINISTRATOR def create_admin(db_session): import bcrypt - from rest.app.database.schema import Users - from rest.app.common.consts import ADMIN_INIT_ACCOUNT_INFO + from main_rest.app.database.schema import Users + from main_rest.app.common.consts import ADMIN_INIT_ACCOUNT_INFO session = db_session() diff --git a/rest/app/database/crud.py b/main_rest/app/database/crud.py similarity index 97% rename from rest/app/database/crud.py rename to main_rest/app/database/crud.py index 6bdfe38..87c724f 100644 --- a/rest/app/database/crud.py +++ b/main_rest/app/database/crud.py @@ -16,10 +16,10 @@ from sqlalchemy import func, desc from fastapi import APIRouter, Depends, Body from sqlalchemy.orm import Session -from rest.app import models as M -from rest.app.database.conn import Base, db -from rest.app.database.schema import Users, UserLog -from rest.app.utils.extra import query_to_groupby, query_to_groupby_date +from main_rest.app import models as M +from main_rest.app.database.conn import Base, db +from main_rest.app.database.schema import Users, UserLog +from main_rest.app.utils.extra import query_to_groupby, query_to_groupby_date def get_month_info_list(start: datetime, end: datetime): diff --git a/rest/app/database/schema.py b/main_rest/app/database/schema.py similarity index 98% rename from rest/app/database/schema.py rename to main_rest/app/database/schema.py index b074e7e..5951ee6 100644 --- a/rest/app/database/schema.py +++ b/main_rest/app/database/schema.py @@ -21,9 +21,9 @@ from sqlalchemy import ( ) from sqlalchemy.orm import Session, relationship -from rest.app.database.conn import Base, db -from rest.app.utils.date_utils import D -from rest.app.models import ( +from main_rest.app.database.conn import Base, db +from main_rest.app.utils.date_utils import D +from main_rest.app.models import ( SexType, UserType, MemberType, diff --git a/rest/app/errors/exceptions.py b/main_rest/app/errors/exceptions.py similarity index 98% rename from rest/app/errors/exceptions.py rename to main_rest/app/errors/exceptions.py index c094c72..bd3ef19 100644 --- a/rest/app/errors/exceptions.py +++ b/main_rest/app/errors/exceptions.py @@ -1,4 +1,4 @@ -from rest.app.common.consts import MAX_API_KEY, MAX_API_WHITELIST +from main_rest.app.common.consts import MAX_API_KEY, MAX_API_WHITELIST class StatusCode: diff --git a/rest/app/main.py b/main_rest/app/main.py similarity index 89% rename from rest/app/main.py rename to main_rest/app/main.py index a1665a1..206fae1 100644 --- a/rest/app/main.py +++ b/main_rest/app/main.py @@ -20,16 +20,16 @@ from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.cors import CORSMiddleware -from rest.app.common import consts +from main_rest.app.common import consts -from rest.app.database.conn import db -from rest.app.common.config import conf -from rest.app.middlewares.token_validator import access_control -from rest.app.middlewares.trusted_hosts import TrustedHostMiddleware -from rest.app.routes import dev, index, auth, users, services +from main_rest.app.database.conn import db +from main_rest.app.common.config import conf +from main_rest.app.middlewares.token_validator import access_control +from main_rest.app.middlewares.trusted_hosts import TrustedHostMiddleware +from main_rest.app.routes import dev, index, auth, users, services from contextlib import asynccontextmanager -from custom_logger.custom_log import custom_logger as LOG +from custom_logger.main_log import main_logger as LOG API_KEY_HEADER = APIKeyHeader(name='Authorization', auto_error=False) diff --git a/rest/app/middlewares/token_validator.py b/main_rest/app/middlewares/token_validator.py similarity index 90% rename from rest/app/middlewares/token_validator.py rename to main_rest/app/middlewares/token_validator.py index 7ae9d0c..a3772c6 100644 --- a/rest/app/middlewares/token_validator.py +++ b/main_rest/app/middlewares/token_validator.py @@ -12,19 +12,19 @@ from jwt.exceptions import ExpiredSignatureError, DecodeError from starlette.requests import Request from starlette.responses import JSONResponse -from rest.app.common.consts import EXCEPT_PATH_LIST, EXCEPT_PATH_REGEX -from rest.app.database.conn import db -from rest.app.database.schema import Users, ApiKeys -from rest.app.errors import exceptions as ex +from main_rest.app.common.consts import EXCEPT_PATH_LIST, EXCEPT_PATH_REGEX +from main_rest.app.database.conn import db +from main_rest.app.database.schema import Users, ApiKeys +from main_rest.app.errors import exceptions as ex -from rest.app.common import consts -from rest.app.common.config import conf -from rest.app.errors.exceptions import APIException, SqlFailureEx, APIQueryStringEx -from rest.app.models import UserToken +from main_rest.app.common import consts +from main_rest.app.common.config import conf +from main_rest.app.errors.exceptions import APIException, SqlFailureEx, APIQueryStringEx +from main_rest.app.models import UserToken -from rest.app.utils.date_utils import D -from rest.app.utils.logger import api_logger -from rest.app.utils.query_utils import to_dict +from main_rest.app.utils.date_utils import D +from main_rest.app.utils.logger import api_logger +from main_rest.app.utils.query_utils import to_dict from dataclasses import asdict diff --git a/rest/app/middlewares/trusted_hosts.py b/main_rest/app/middlewares/trusted_hosts.py similarity index 100% rename from rest/app/middlewares/trusted_hosts.py rename to main_rest/app/middlewares/trusted_hosts.py diff --git a/rest/app/models.py b/main_rest/app/models.py similarity index 98% rename from rest/app/models.py rename to main_rest/app/models.py index 82b06bf..85dd23d 100644 --- a/rest/app/models.py +++ b/main_rest/app/models.py @@ -19,7 +19,7 @@ from pydantic.main import BaseModel from pydantic.networks import EmailStr, IPvAnyAddress from typing import Optional -from rest.app.common.consts import ( +from main_rest.app.common.consts import ( SW_TITLE, SW_VERSION, MAIL_REG_TITLE, @@ -29,7 +29,7 @@ from rest.app.common.consts import ( ADMIN_INIT_ACCOUNT_INFO, DEFAULT_USER_ACCOUNT_PW ) -from rest.app.utils.date_utils import D +from main_rest.app.utils.date_utils import D class SWInfo(BaseModel): @@ -566,6 +566,10 @@ class UserLogUpdateMultiReq(BaseModel): #=============================================================================== #=============================================================================== +class IndexType: + hnsw = "hnsw" + l2 = "l2" + class ImageGenerateReq(BaseModel): """ ### [Request] image generate request @@ -580,6 +584,15 @@ class BingCookieSetReq(BaseModel): """ cookie : str = Field('',description='쿠키 데이터', example='') + +class VactorImageSearchReq(BaseModel): + """ + ### [Request] vactor image search request + """ + prompt : str = Field(description='프롬프트', example='검은색 안경') + index_type : str = Field(IndexType.hnsw, description='인덱스 타입', example=IndexType.hnsw) + search_num : int = Field(4, description='검색결과 이미지 갯수', example=4) + #=============================================================================== #=============================================================================== diff --git a/rest/app/routes/auth.py b/main_rest/app/routes/auth.py similarity index 87% rename from rest/app/routes/auth.py rename to main_rest/app/routes/auth.py index 7581df6..8f5464f 100644 --- a/rest/app/routes/auth.py +++ b/main_rest/app/routes/auth.py @@ -17,13 +17,13 @@ import bcrypt import jwt from datetime import datetime, timedelta -from rest.app.common import consts -from rest.app import models as M -from rest.app.database.conn import db -from rest.app.common.config import conf -from rest.app.database.schema import Users, UserLog -from rest.app.utils.extra import query_to_groupby, AESCryptoCBC -from rest.app.utils.date_utils import D +from main_rest.app.common import consts +from main_rest.app import models as M +from main_rest.app.database.conn import db +from main_rest.app.common.config import conf +from main_rest.app.database.schema import Users, UserLog +from main_rest.app.utils.extra import query_to_groupby, AESCryptoCBC +from main_rest.app.utils.date_utils import D router = APIRouter(prefix='/auth') diff --git a/rest/app/routes/dev.py b/main_rest/app/routes/dev.py similarity index 91% rename from rest/app/routes/dev.py rename to main_rest/app/routes/dev.py index 97b40d6..9832de0 100644 --- a/rest/app/routes/dev.py +++ b/main_rest/app/routes/dev.py @@ -15,13 +15,13 @@ from sqlalchemy.orm import Session import bcrypt from starlette.requests import Request -from rest.app.common import consts -from rest.app import models as M -from rest.app.database.conn import db, Base -from rest.app.database.schema import Users, UserLog +from main_rest.app.common import consts +from main_rest.app import models as M +from main_rest.app.database.conn import db, Base +from main_rest.app.database.schema import Users, UserLog -from rest.app.utils.extra import FernetCrypto, AESCryptoCBC, AESCipher -from custom_logger.custom_log import custom_logger as LOG +from main_rest.app.utils.extra import FernetCrypto, AESCryptoCBC, AESCipher +from custom_logger.main_log import main_logger as LOG # mail test diff --git a/rest/app/routes/index.py b/main_rest/app/routes/index.py similarity index 86% rename from rest/app/routes/index.py rename to main_rest/app/routes/index.py index 8f7f32d..1df10b0 100644 --- a/rest/app/routes/index.py +++ b/main_rest/app/routes/index.py @@ -11,8 +11,8 @@ from fastapi import APIRouter -from rest.app.utils.date_utils import D -from rest.app.models import SWInfo +from main_rest.app.utils.date_utils import D +from main_rest.app.models import SWInfo router = APIRouter() diff --git a/rest/app/routes/services.py b/main_rest/app/routes/services.py similarity index 72% rename from rest/app/routes/services.py rename to main_rest/app/routes/services.py index acafdf5..30c9a93 100644 --- a/rest/app/routes/services.py +++ b/main_rest/app/routes/services.py @@ -14,15 +14,15 @@ from fastapi import APIRouter, Depends, Body from starlette.requests import Request from typing import Annotated, List -from rest.app.common import consts -from rest.app import models as M -from rest.app.utils.date_utils import D -from custom_logger.custom_log import custom_logger as LOG +from main_rest.app.common import consts +from main_rest.app import models as M +from main_rest.app.utils.date_utils import D +from custom_logger.main_log import main_logger as LOG from custom_apps.bingimagecreator.utils import DallEArgument,dalle3_generate_image from custom_apps.bingart.bingart import BingArtGenerator -from custom_apps.imagen.custom_imagen import imagen_generate_image -from rest.app.utils.parsing_utils import download_range +from custom_apps.imagen.custom_imagen import imagen_generate_image, imagen_generate_image_path +from main_rest.app.utils.parsing_utils import download_range from custom_apps.utils import cookie_manager router = APIRouter(prefix="/services") @@ -144,6 +144,40 @@ async def imagen(request: Request, request_body_info: M.ImageGenerateReq): return response.set_message(img_len=img_length) + except Exception as e: + LOG.error(traceback.format_exc()) + return response.set_error(error=e) + + +@router.post("/vactorImageSearch/imageGenerate/imagen", summary="벡터 이미지 검색 - imagen", response_model=M.ResponseBase) +async def vactor_image(request: Request, request_body_info: M.VactorImageSearchReq): + """ + ## 벡터 이미지 검색 - imagen + > imagen AI를 이용하여 이미지 생성 후 vactor 검색 + + ### Requriements + > - googlecli 설치(https://cloud.google.com/sdk/docs/install?hl=ko#linux) + > - const.py 에 지정한 OUTPUT_FOLDER 하위에 imagen 폴더가 있어야함. + + """ + response = M.ResponseBase() + try: + if request_body_info.index_type not in [M.IndexType.hnsw, M.IndexType.l2]: + raise Exception(f"index_type is hnsw or l2 (current value = {request_body_info.index_type})") + + img_path = imagen_generate_image_path(image_prompt=request_body_info.prompt) + + vactor_request_data = {'quary_image_path' : img_path,'index_type' : request_body_info.index_type, 'search_num' : request_body_info.search_num} + vactor_response = requests.post('http://localhost:51002/api/services/faiss/vactor/search', data=json.dumps(vactor_request_data)) + + if vactor_response.status_code != 200: + raise Exception(f"response error: {json.loads(vactor_response.text)['error']}") + + if json.loads(vactor_response.text)["error"] != None: + raise Exception(f"vactor error: {json.loads(vactor_response.text)['error']}") + + return response.set_message() + except Exception as e: LOG.error(traceback.format_exc()) return response.set_error(error=e) \ No newline at end of file diff --git a/rest/app/routes/users.py b/main_rest/app/routes/users.py similarity index 96% rename from rest/app/routes/users.py rename to main_rest/app/routes/users.py index 046e023..764fd1b 100644 --- a/rest/app/routes/users.py +++ b/main_rest/app/routes/users.py @@ -13,13 +13,13 @@ from fastapi import APIRouter from starlette.requests import Request import bcrypt -from rest.app.common import consts -from rest.app import models as M -from rest.app.common.config import conf -from rest.app.database.schema import Users -from rest.app.database.crud import table_select, table_update, table_delete +from main_rest.app.common import consts +from main_rest.app import models as M +from main_rest.app.common.config import conf +from main_rest.app.database.schema import Users +from main_rest.app.database.crud import table_select, table_update, table_delete -from rest.app.utils.extra import AESCryptoCBC +from main_rest.app.utils.extra import AESCryptoCBC router = APIRouter(prefix='/user') diff --git a/rest/app/utils/date_utils.py b/main_rest/app/utils/date_utils.py similarity index 100% rename from rest/app/utils/date_utils.py rename to main_rest/app/utils/date_utils.py diff --git a/rest/app/utils/extra.py b/main_rest/app/utils/extra.py similarity index 96% rename from rest/app/utils/extra.py rename to main_rest/app/utils/extra.py index 86046b1..6adc2ef 100644 --- a/rest/app/utils/extra.py +++ b/main_rest/app/utils/extra.py @@ -26,10 +26,10 @@ from itertools import groupby from operator import attrgetter import uuid -from rest.app.common.consts import NUM_RETRY_UUID_GEN, SMTP_HOST, SMTP_PORT -from rest.app.utils.date_utils import D -from rest.app import models as M -from rest.app.common.consts import AES_CBC_PUBLIC_KEY, AES_CBC_IV, FERNET_SECRET_KEY +from main_rest.app.common.consts import NUM_RETRY_UUID_GEN, SMTP_HOST, SMTP_PORT +from main_rest.app.utils.date_utils import D +from main_rest.app import models as M +from main_rest.app.common.consts import AES_CBC_PUBLIC_KEY, AES_CBC_IV, FERNET_SECRET_KEY async def send_mail(sender, sender_pw, title, recipient, contents_plain, contents_html, cc_list, smtp_host=SMTP_HOST, smtp_port=SMTP_PORT): diff --git a/rest/app/utils/logger.py b/main_rest/app/utils/logger.py similarity index 100% rename from rest/app/utils/logger.py rename to main_rest/app/utils/logger.py diff --git a/rest/app/utils/parsing_utils.py b/main_rest/app/utils/parsing_utils.py similarity index 100% rename from rest/app/utils/parsing_utils.py rename to main_rest/app/utils/parsing_utils.py diff --git a/rest/app/utils/query_utils.py b/main_rest/app/utils/query_utils.py similarity index 100% rename from rest/app/utils/query_utils.py rename to main_rest/app/utils/query_utils.py diff --git a/rest/gunicorn.conf.py b/main_rest/gunicorn.conf.py similarity index 100% rename from rest/gunicorn.conf.py rename to main_rest/gunicorn.conf.py diff --git a/rest/tests/__init__.py b/main_rest/tests/__init__.py similarity index 100% rename from rest/tests/__init__.py rename to main_rest/tests/__init__.py diff --git a/rest/tests/conftest.py b/main_rest/tests/conftest.py similarity index 100% rename from rest/tests/conftest.py rename to main_rest/tests/conftest.py diff --git a/rest/tests/test_auth.py b/main_rest/tests/test_auth.py similarity index 100% rename from rest/tests/test_auth.py rename to main_rest/tests/test_auth.py diff --git a/rest/tests/test_user.py b/main_rest/tests/test_user.py similarity index 100% rename from rest/tests/test_user.py rename to main_rest/tests/test_user.py diff --git a/requirements.txt b/requirements_main.txt similarity index 74% rename from requirements.txt rename to requirements_main.txt index d367ebc..ef71b5d 100644 --- a/requirements.txt +++ b/requirements_main.txt @@ -12,20 +12,26 @@ cryptography pycryptodomex pycryptodome email-validator +requests #imagen google-cloud-aiplatform Pillow -#bing img +# #bing img aiohttp regex requests httpx nest_asyncio -#bing art -bingart==1.10 +# #bing art +bingart==1.1.0 #DALL-E 3 -# openai \ No newline at end of file +# openai + +# faiss +# scikit-learn +# flax +# tensorflow \ No newline at end of file diff --git a/requirements_vactor.txt b/requirements_vactor.txt new file mode 100644 index 0000000..45ad0bd --- /dev/null +++ b/requirements_vactor.txt @@ -0,0 +1,18 @@ +#rest +fastapi==0.115.0 +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 +pycryptodomex +pycryptodome +email-validator + +#faiss +scikit-learn +pillow \ No newline at end of file diff --git a/rest_main.py b/rest_main.py new file mode 100644 index 0000000..efffb43 --- /dev/null +++ b/rest_main.py @@ -0,0 +1,6 @@ +import uvicorn +from main_rest.app.common.config import conf + + +if __name__ == '__main__': + uvicorn.run('main_rest.app.main:app', host='0.0.0.0', port=conf().REST_SERVER_PORT, reload=True) \ No newline at end of file diff --git a/rest_vactor.py b/rest_vactor.py new file mode 100644 index 0000000..9df1583 --- /dev/null +++ b/rest_vactor.py @@ -0,0 +1,6 @@ +import uvicorn +from vactor_rest.app.common.config import conf + + +if __name__ == '__main__': + uvicorn.run('vactor_rest.app.main:app', host='0.0.0.0', port=conf().REST_SERVER_PORT, reload=True) \ No newline at end of file diff --git a/vactor_rest/.travis.yml b/vactor_rest/.travis.yml new file mode 100644 index 0000000..dfa0f3a --- /dev/null +++ b/vactor_rest/.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/vactor_rest/app/api_request_sample.py b/vactor_rest/app/api_request_sample.py new file mode 100644 index 0000000..cb742ad --- /dev/null +++ b/vactor_rest/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/vactor_rest/app/common/config.py b/vactor_rest/app/common/config.py new file mode 100644 index 0000000..4513ccb --- /dev/null +++ b/vactor_rest/app/common/config.py @@ -0,0 +1,111 @@ +# -*- 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 vactor_rest.app.common import consts +from vactor_rest.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 = 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_PORT}/{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_INIT_ACCOUNT_INFO.connect_token + + COOKIES_AUTH = 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MTQsImVtYWlsIjoia29hbGFAZGluZ3JyLmNvbSIsIm5hbWUiOm51bGwsInBob25lX251bWJlciI6bnVsbCwicHJvZmlsZV9pbWciOm51bGwsInNuc190eXBlIjpudWxsfQ.4vgrFvxgH8odoXMvV70BBqyqXOFa2NDQtzYkGywhV48' + + +@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/vactor_rest/app/common/consts.py b/vactor_rest/app/common/consts.py new file mode 100644 index 0000000..db15083 --- /dev/null +++ b/vactor_rest/app/common/consts.py @@ -0,0 +1,104 @@ +# -*- 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' + +PROJECT_NAME = 'FERMAT-TEST(Vactor REST API)' +SW_TITLE= f'{PROJECT_NAME} - REST API' +SW_VERSION = '0.1.0' +SW_DESCRIPTION = f''' +### FERMAT-TEST(Vactor REST API) REST API + +## API 이용법 + - 개별 API 설명과 Request/Response schema 참조 + + +''' +TERMS_OF_SERVICE = 'http://www.a2tec.co.kr' +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 = 51002 +DEFAULT_USER_ACCOUNT_PW = '1234' + + +class AdminInfo: + def __init__(self): + self.id: int = 1 + self.user_type: str = 'admin' + self.account: str = 'a2d2_lc_manager@naver.com' # !ekdnfeldpsdptm1 다울디엔에스 + self.pw: str = '$2b$12$PklBvVXdLhOQnIiNanlnIu.DJh5MspRARVChJQfFu1qg35vBoIuX2' + self.name: str = 'administrator' + self.email: str = 'a2d2_lc_manager@naver.com' # daool1020 + self.email_pw: str = 'gAAAAABioV5NucuS9nQugZJnz-KjVG_FGnaowB9KAfhOoWjjiQ4jGLuYJh4Qe94mT_lCm6m3HhuOJqUeOgjppwREDpIQYzrUXA==' + self.address: str = '대구광역시 동구 동촌로351 에이스빌딩 4F' + self.phone_number: str = '053-384-3010' + self.connect_token: str = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiYWNjb3VudCI6ImEyZDJfbGNfbWFuYWdlckBuYXZlci5jb20iLCJuYW1lIjoiYWRtaW5pc3RyYXRvciIsInBob25lX251bWJlciI6IjA1My0zODQtMzAxMCIsInByb2ZpbGVfaW1nIjpudWxsLCJhY2NvdW50X3R5cGUiOiJlbWFpbCJ9.SlQSCfAof1bv2YxmW2DO4dIBrbHLg1jPO3AJsX6xKbw' + + def get_dict(self): + info = {} + for k, v in self.__dict__.items(): + if type(v) is tuple: + info[k] = v[0] + else: + info[k] = v + return info + + +ADMIN_INIT_ACCOUNT_INFO = AdminInfo() + +FERNET_SECRET_KEY = b'wQjpSYkmc4kX8MaAovk1NIHF02R2wZX760eeBTeIHW4=' +AES_CBC_PUBLIC_KEY = b'daooldns12345678' +AES_CBC_IV = b'daooldns12345678' + +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' +\ + '|/api/services' + \ + '|/api/temp' + \ + '|/api/dev' + \ + '|/static' + \ + ')' +MAX_API_KEY = 3 +MAX_API_WHITELIST = 10 + +NUM_RETRY_UUID_GEN = 3 + +# DATABASE +DB_ADDRESS = "localhost" +DB_PORT = 53306 +DB_USER_ID = 'root' +DB_USER_PW = '1234' +DB_NAME = 'FM_TEST' +DB_CHARSET = 'utf8mb4' + +# MAIL +# SMTP_HOST = 'smtp.gmail.com' +# SMTP_PORT = 587 +SMTP_HOST = 'smtp.naver.com' +SMTP_PORT = 587 +MAIL_REG_TITLE = f'{PROJECT_NAME} - Registration' +MAIL_REG_CONTENTS = ''' +안녕하세요. +감사합니다. + +''' \ No newline at end of file diff --git a/vactor_rest/app/database/conn.py b/vactor_rest/app/database/conn.py new file mode 100644 index 0000000..45e6d12 --- /dev/null +++ b/vactor_rest/app/database/conn.py @@ -0,0 +1,142 @@ +# -*- 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}:{db_url.port}' + 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}:{db_url.port}' + 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 vactor_rest.app.database.schema import Users + from vactor_rest.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.get_dict()} + + Users.create(session=session, auto_commit=True, **admin) diff --git a/vactor_rest/app/database/crud.py b/vactor_rest/app/database/crud.py new file mode 100644 index 0000000..286058d --- /dev/null +++ b/vactor_rest/app/database/crud.py @@ -0,0 +1,300 @@ +# -*- coding: utf-8 -*- +""" +@File: crud.py +@Date: 2020-09-14 +@author: A2TEC +@section MODIFYINFO 수정정보 +- 수정자/수정일 : 수정내역 +- 2022-01-14/hsj100@a2tec.co.kr : refactoring +@brief: CRUD +""" + +import math +from datetime import datetime +from dateutil.relativedelta import relativedelta +from sqlalchemy import func, desc + +from fastapi import APIRouter, Depends, Body +from sqlalchemy.orm import Session +from vactor_rest.app import models as M +from vactor_rest.app.database.conn import Base, db +from vactor_rest.app.database.schema import Users, UserLog +from vactor_rest.app.utils.extra import query_to_groupby, query_to_groupby_date + + +def get_month_info_list(start: datetime, end: datetime): + delta = relativedelta(end, start) + month_delta = 12 * delta.years + delta.months + (1 if delta.days > 0 else 0) + + month_list = list() + for i in range(month_delta): + count = start + relativedelta(months=i) + month_info = dict() + month_info['period_title'] = count.strftime('%Y-%m') + month_info['year'] = count.year + month_info['month'] = count.month + month_info['start'] = (start + relativedelta(months=i)).replace(day=1) + month_info['end'] = (start + relativedelta(months=i + 1)).replace(day=1) + month_list.append(month_info) + return month_list + + +def request_parser(request_data: dict = None) -> dict: + """ + request information -> dict + + :param request_data: + :return: + """ + result_dict = dict() + if not request_data: + return result_dict + for key, val in request_data.items(): + if val is not None: + result_dict[key] = val if val != 'null' else None + return result_dict + + +def dict_to_filter_fmt(dict_data, get_attributes_callback=None): + """ + dict -> sqlalchemy filter (criterion: sql expression) + + :param dict_data: + :param get_attributes_callback: + :return: + """ + if get_attributes_callback is None: + raise Exception('invalid get_attributes_callback') + + criterion = list() + + for key, val in dict_data.items(): + key = key.split('__') + if len(key) > 2: + raise Exception('length of split(key) should be no more than 2.') + + key_length = len(key) + col = get_attributes_callback(key[0]) + + if col is None: + continue + + if key_length == 1: + criterion.append((col == val)) + elif key_length == 2 and key[1] == 'gt': + criterion.append((col > val)) + elif key_length == 2 and key[1] == 'gte': + criterion.append((col >= val)) + elif key_length == 2 and key[1] == 'lt': + criterion.append((col < val)) + elif key_length == 2 and key[1] == 'lte': + criterion.append((col <= val)) + elif key_length == 2 and key[1] == 'in': + criterion.append((col.in_(val))) + elif key_length == 2 and key[1] == 'like': + criterion.append((col.like(val))) + + return criterion + + +async def table_select(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') + + # paging - request + paging_request = None + if request_body_info: + if hasattr(request_body_info, 'paging'): + paging_request = request_body_info.paging + request_body_info = request_body_info.search + + # request + request_info = request_parser(request_body_info.dict()) + + # search + criterion = None + if isinstance(request_body_info, M.UserLogDaySearchReq): + # UserLog search + # request + request_info = request_parser(request_body_info.dict()) + + # search UserLog + def get_attributes_callback(key: str): + return getattr(UserLog, key) + criterion = dict_to_filter_fmt(request_info, get_attributes_callback) + + # search + session = next(db.session()) + search_info = session.query(UserLog)\ + .filter(UserLog.mac.isnot(None) + , UserLog.api == '/api/auth/login' + , UserLog.type == M.UserLogMessageType.info + , UserLog.message == 'ok' + , *criterion)\ + .order_by(desc(UserLog.created_at), desc(UserLog.updated_at))\ + .all() + if not search_info: + raise Exception('not found data') + + group_by_day = query_to_groupby_date(search_info, 'updated_at') + + # result + result_info = list() + for day, info_list in group_by_day.items(): + info_by_mac = query_to_groupby(info_list, 'mac', first=True) + for log_info in info_by_mac.values(): + result_info.append(response_model_data.from_orm(log_info)) + else: + # basic search (single table) + # request + request_info = request_parser(request_body_info.dict()) + + # search + search_info = target_table.filter(**request_info).all() + if not search_info: + raise Exception('not found data') + + # result + result_info = list() + for purchase_info in search_info: + result_info.append(response_model_data.from_orm(purchase_info)) + + # response - paging + paging_response = None + if paging_request: + total_contents_num = len(result_info) + total_page_num = math.ceil(total_contents_num / paging_request.page_contents_num) + start_contents_index = (paging_request.start_page - 1) * paging_request.page_contents_num + end_contents_index = start_contents_index + paging_request.page_contents_num + if end_contents_index < total_contents_num: + result_info = result_info[start_contents_index: end_contents_index] + else: + result_info = result_info[start_contents_index:] + + paging_response = M.PagingRes() + paging_response.total_page_num = total_page_num + paging_response.total_contents_num = total_contents_num + paging_response.start_page = paging_request.start_page + paging_response.search_contents_num = len(result_info) + + return response_model(data=result_info, paging=paging_response) + 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 = request_parser(request_body_info.search_info.dict()) + 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 = request_parser(request_body_info.update_info.dict()) + 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 = request_parser(request_body_info.dict()) + if not request_search_info: + raise Exception('invalid request_body') + + # search + search_info = target_table.filter(**request_search_info) + temp_search = search_info.all() + + # process + search_info.delete(auto_commit=True, synchronize_session=False) + + # update license num + uuid_list = list() + for _license in temp_search: + if not hasattr(temp_search, 'uuid'): + # case: license + break + if _license.uuid not in uuid_list: + uuid_list.append(_license.uuid) + license_num = target_table.filter(uuid=_license.uuid).count() + target_table.filter(uuid=_license.uuid).update(auto_commit=True, synchronize_session=False, num=license_num) + + # result + return response_model() + except Exception as e: + if search_info: + search_info.close() + return response_model.set_error(str(e)) diff --git a/vactor_rest/app/database/schema.py b/vactor_rest/app/database/schema.py new file mode 100644 index 0000000..9dcfb71 --- /dev/null +++ b/vactor_rest/app/database/schema.py @@ -0,0 +1,304 @@ +# -*- 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 vactor_rest.app.database.conn import Base, db +from vactor_rest.app.utils.date_utils import D +from vactor_rest.app.models import ( + SexType, + UserType, + MemberType, + AccountType, + UserStatusType, + UserLoginType, + UserLogMessageType +) + + +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('length of split(key) should be no more than 2.') + 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' + __table_args__ = {'extend_existing': True} + + 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' + __table_args__ = {'extend_existing': True} + + 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' + __table_args__ = {'extend_existing': True} + + status = Column(Enum(UserStatusType), nullable=False, default=UserStatusType.active) + user_type = Column(Enum(UserType), nullable=False, default=UserType.user) + 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=False, default=False) + keys = relationship('ApiKeys', back_populates='users') + + # extra + login = Column(Enum(UserLoginType), nullable=False, default=UserLoginType.logout) # TODO(hsj100): LOGIN_STATUS + member_type = Column(Enum(MemberType), nullable=False, default=MemberType.personal) + # uuid = Column(String(length=36), nullable=True, unique=True) + + +class UserLog(Base, BaseMixin): + __tablename__ = 'userlog' + __table_args__ = {'extend_existing': True} + + account = Column(String(length=255), nullable=False) + mac = Column(String(length=255), nullable=True) + type = Column(Enum(UserLogMessageType), nullable=False, default=UserLogMessageType.info) + api = Column(String(length=511), nullable=False) + message = Column(String(length=5000), nullable=True) + # NOTE(hsj100): 다단계 자동 삭제 + # user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + # user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + + # users = relationship('Users', back_populates='userlog') + # users = relationship('Users', back_populates='userlog', lazy=False) + + +# =============================================================================================== +class CustomBaseMixin(BaseMixin): + id = Column(Integer, primary_key=True, index=True) + + def all_columns(self): + return [c for c in self.__table__.columns if c.primary_key is False] + + + @classmethod + def create(cls, session: Session, auto_commit=False, **kwargs): + """ + 테이블 데이터 적재 전용 함수 + :param session: + :param auto_commit: 자동 커밋 여부 + :param kwargs: 적재 할 데이터 + :return: + """ + obj = cls() + + 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 + + def update(self, auto_commit: bool = False, synchronize_session='evaluate', **kwargs): + + 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 + diff --git a/vactor_rest/app/errors/exceptions.py b/vactor_rest/app/errors/exceptions.py new file mode 100644 index 0000000..3980eb5 --- /dev/null +++ b/vactor_rest/app/errors/exceptions.py @@ -0,0 +1,188 @@ +from vactor_rest.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 = None, + detail: str | None = 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/vactor_rest/app/main.py b/vactor_rest/app/main.py new file mode 100644 index 0000000..ca13f9a --- /dev/null +++ b/vactor_rest/app/main.py @@ -0,0 +1,116 @@ +# -*- 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 fastapi.staticfiles import StaticFiles +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.cors import CORSMiddleware + +from vactor_rest.app.common import consts + +from vactor_rest.app.database.conn import db +from vactor_rest.app.common.config import conf +from vactor_rest.app.middlewares.token_validator import access_control +from vactor_rest.app.middlewares.trusted_hosts import TrustedHostMiddleware +from vactor_rest.app.routes import dev, index, auth, users, services +from contextlib import asynccontextmanager + +from custom_logger.vactor_log import vactor_logger as LOG + + +API_KEY_HEADER = APIKeyHeader(name='Authorization', auto_error=False) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # When service starts. + LOG.info("REST start") + + yield + + # When service is stopped. + LOG.info("REST shutdown") + + +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'}, + lifespan=lifespan + ) + + # 데이터 베이스 이니셜라이즈 + 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']) + + # if conf().DEBUG: + # fast_api_app.include_router(auth.router, tags=['Authentication'], prefix='/api') + + fast_api_app.include_router(services.router, tags=['Services'], prefix='/api', dependencies=[Depends(API_KEY_HEADER)]) + + # if conf().DEBUG: + # fast_api_app.include_router(users.router, tags=['Users'], prefix='/api', dependencies=[Depends(API_KEY_HEADER)]) + # fast_api_app.include_router(dev.router, tags=['Developments'], prefix='/api', dependencies=[Depends(API_KEY_HEADER)]) + + import os + # fast_api_app.mount('/static', StaticFiles(directory=os.path.abspath('./rest/app/static')), name="static") + + return fast_api_app + + +app = create_app() + +#TODO(jwkim): 422 error handler +# @app.exception_handler(RequestValidationError) +# async def validation_exception_handler(request, exc): +# err = exc.errors() +# return JSONResponse( +# status_code=422, +# content={ +# "result": False, +# "error": f"VALIDATION ERROR : {err}", +# "data": None}, +# ) + +if __name__ == '__main__': + uvicorn.run('main:app', host='0.0.0.0', port=conf().REST_SERVER_PORT, reload=True) diff --git a/vactor_rest/app/middlewares/token_validator.py b/vactor_rest/app/middlewares/token_validator.py new file mode 100644 index 0000000..bdba04f --- /dev/null +++ b/vactor_rest/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 vactor_rest.app.common.consts import EXCEPT_PATH_LIST, EXCEPT_PATH_REGEX +from vactor_rest.app.database.conn import db +from vactor_rest.app.database.schema import Users, ApiKeys +from vactor_rest.app.errors import exceptions as ex + +from vactor_rest.app.common import consts +from vactor_rest.app.common.config import conf +from vactor_rest.app.errors.exceptions import APIException, SqlFailureEx, APIQueryStringEx +from vactor_rest.app.models import UserToken + +from vactor_rest.app.utils.date_utils import D +from vactor_rest.app.utils.logger import api_logger +from vactor_rest.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/vactor_rest/app/middlewares/trusted_hosts.py b/vactor_rest/app/middlewares/trusted_hosts.py new file mode 100644 index 0000000..5580f3b --- /dev/null +++ b/vactor_rest/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/vactor_rest/app/models.py b/vactor_rest/app/models.py new file mode 100644 index 0000000..076f320 --- /dev/null +++ b/vactor_rest/app/models.py @@ -0,0 +1,581 @@ +# -*- 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 TypeAdapter +from pydantic import Field, ConfigDict +from pydantic.main import BaseModel +from pydantic.networks import EmailStr, IPvAnyAddress +from typing import Optional + +from vactor_rest.app.common.consts import ( + SW_TITLE, + SW_VERSION, + MAIL_REG_TITLE, + MAIL_REG_CONTENTS, + SMTP_HOST, + SMTP_PORT, + ADMIN_INIT_ACCOUNT_INFO, + DEFAULT_USER_ACCOUNT_PW +) +from vactor_rest.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') + data1: str = Field(None, description='TestData1', example=None) + data2: str = Field(None, description='TestData2', example=None) + data3: str = Field(None, description='TestData3', example=None) + data4: str = Field(None, description='TestData4', example=None) + + data5: str = Field(None, description='TestData1', example=None) + data6: str = Field(None, description='TestData2', example=None) + data7: str = Field(None, description='TestData3', example=None) + data8: str = Field(None, description='TestData4', example=None) + + +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 MemberType(str, CustomEnum): + personal: str = 'personal' + company: str = 'company' + + +class UserType(str, CustomEnum): + admin: str = 'admin' + user: str = 'user' + + +class UserLogMessageType(str, CustomEnum): + info: str = 'info' + error: str = 'error' + + +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 = None + + +class MessageOk(BaseModel): + message: str = Field(default='OK') + + +class UserToken(BaseModel): + id: int + account: str | None = None + name: str | None = None + phone_number: str | None = None + profile_img: str | None = None + account_type: str | None = None + + # model_config = ConfigDict(from_attributes=True) + + +class AddApiKey(BaseModel): + user_memo: str | None = None + + model_config = ConfigDict(from_attributes=True) + + +class GetApiKeyList(AddApiKey): + id: int = None + access_key: str | None = None + created_at: datetime = None + + +class GetApiKeys(GetApiKeyList): + secret_key: str | None = None + + +class CreateAPIWhiteLists(BaseModel): + ip_addr: str | None = None + + +class GetAPIWhiteLists(CreateAPIWhiteLists): + id: int + + model_config = ConfigDict(from_attributes=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 | None = Field(None, description='오류내용(성공: null, 실패: 오류내용)', example=None) + + @staticmethod + def set_error(error): + ResponseBase.result = False + ResponseBase.error = str(error) + + return ResponseBase + + @staticmethod + def set_message(): + ResponseBase.result = True + ResponseBase.error = None + return ResponseBase + + model_config = ConfigDict(from_attributes=True) + + +class PagingReq(BaseModel): + """ + ### [Request] 페이징 정보 + """ + start_page: int = Field(None, description='시작 페이지 번호(base: 1)', example=1) + page_contents_num: int = Field(None, description='페이지 내용 개수', example=2) + + model_config = ConfigDict(from_attributes=True) + + +class PagingRes(BaseModel): + """ + ### [Response] 페이징 정보 + """ + total_page_num: int = Field(None, description='전체 페이지 개수', example=100) + total_contents_num: int = Field(None, description='전체 내용 개수', example=100) + start_page: int = Field(None, description='시작 페이지 번호(base: 1)', example=1) + search_contents_num: int = Field(None, description='검색된 내용 개수', example=100) + + model_config = ConfigDict(from_attributes=True) + + +class TokenRes(ResponseBase): + """ + ### [Response] 토큰 정보 + """ + Authorization: str = Field(None, description='인증키', example='Bearer [token]') + + model_config = ConfigDict(from_attributes=True) + + +class UserLogInfo(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') + + account: str = Field(None, description='계정', example='user1@test.com') + mac: str = Field(None, description='MAC(네트워크 인터페이스 식별자)', example='11:22:33:44:55:66') + type: str = Field(None, description='로그 타입' + UserLogMessageType.get_elements_str(), example=UserLogMessageType.info) + api: str = Field(None, description='API 이름', example='/api/auth/login') + message: str = Field(None, description='로그 내용', example='ok') + + model_config = ConfigDict(from_attributes=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) + user_type: UserType = Field(None, description='유저 타입' + UserType.get_elements_str(), example=UserType.user) + account_type: AccountType = Field(None, description='계정종류' + AccountType.get_elements_str(), example=AccountType.email) + account: str = Field(None, description='계정', example='user1@test.com') + 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 + login: UserLoginType = Field(None, description='로그인 상태' + UserLoginType.get_elements_str(), example=UserLoginType.logout) # TODO(hsj100): LOGIN_STATUS + member_type: MemberType = Field(None, description='회원 타입' + MemberType.get_elements_str(), example=MemberType.personal) + + model_config = ConfigDict(from_attributes=True) + + +class SendMailReq(BaseModel): + """ + ### [Request] 메일 전송 + """ + smtp_host: str = Field(None, description='SMTP 서버 주소', example=SMTP_HOST) + smtp_port: int = Field(None, description='SMTP 서버 포트', example=SMTP_PORT) + title: str = Field(None, description='제목', example=MAIL_REG_TITLE) + recipient: str = Field(None, description='수신자', example='user1@test.com') + cc_list: list = Field(None, description='참조 리스트', example='["user2@test.com", "user3@test.com"]') + # recipient: str = Field(None, description='수신자', example='hsj100@a2tec.co.kr') + # cc_list: list = Field(None, description='참조 리스트', example=None) + contents_plain: str = Field(None, description='내용', example=MAIL_REG_CONTENTS.format('user1@test.com', 10)) + contents_html: str = Field(None, description='내용', example='

내 고양이는 아주 고약해.

') + + model_config = ConfigDict(from_attributes=True) + + +class UserInfoRes(ResponseBase): + """ + ### [Response] 유저 정보 + """ + data: UserInfo = None + + model_config = ConfigDict(from_attributes=True) + + +class UserLoginReq(BaseModel): + """ + ### 유저 로그인 정보 + """ + account: str = Field(description='계정', example='user1@test.com') + pw: str = Field(None, description='비밀번호 [관리자 필수]', example='1234') + # SW-LICENSE + sw: str = Field(None, description='라이선스 SW 이름 [유저 필수]', example='저작도구') + mac: str = Field(None, description='MAC [유저 필수]', example='11:22:33:44:55:01') + + +class UserRegisterReq(BaseModel): + """ + ### [Request] 유저 등록 + """ + status: Optional[str] = Field(UserStatusType.active, description='계정상태' + UserStatusType.get_elements_str(), example=UserStatusType.active) + user_type: Optional[UserType] = Field(UserType.user, description='유저 타입' + UserType.get_elements_str(), example=UserType.user) + account: str = Field(description='계정', example='test@test.com') + pw: Optional[str] = Field(DEFAULT_USER_ACCOUNT_PW, description='비밀번호', example='1234') + email: Optional[str] = Field(None, description='전자메일', example='test@test.com') + name: str = Field(description='이름', example='test') + sex: Optional[SexType] = 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 + member_type: Optional[MemberType] = Field(MemberType.personal, description='회원 타입' + MemberType.get_elements_str(), example=MemberType.personal) + # relationship:license + license_sw: str = Field(description='라이선스 SW 이름', example='저작도구') + license_start: datetime = Field(description='라이선스 시작날짜', example='2022-02-10T15:00:00') + license_end: datetime = Field(description='라이선스 시작날짜', example='2023-02-10T15:00:00') + license_num: Optional[int] = Field(1, description='계약된 라이선스 개수', example=1) + license_manager_name: Optional[str] = Field(ADMIN_INIT_ACCOUNT_INFO.name, description='담당자 이름', example=ADMIN_INIT_ACCOUNT_INFO.name) + license_manager_phone: Optional[str] = Field(ADMIN_INIT_ACCOUNT_INFO.phone_number, description='담당자 연락처', example=ADMIN_INIT_ACCOUNT_INFO.phone_number) + + model_config = ConfigDict(from_attributes=True) + + +class UserSearchReq(BaseModel): + """ + ### [Request] 유저 검색 (기본) + """ + # basic + id: Optional[int] = Field(None, description='등록번호', example='1') + id__in: Optional[list] = Field(None, description='등록번호 리스트', example=[1,]) + created_at: Optional[str] = Field(None, description='생성날짜', example='2022-01-01T12:34:56') + created_at__gt: Optional[str] = Field(None, description='생성날짜(주어진 날짜 이후)', example='2022-02-10T15:00:00') + created_at__gte: Optional[str] = Field(None, description='생성날짜(주어진 날짜 포함 이후)', example='2022-02-10T15:00:00') + created_at__lt: Optional[str] = Field(None, description='생성날짜(주어진 날짜 이전)', example='2022-02-10T15:00:00') + created_at__lte: Optional[str] = Field(None, description='생성날짜(주어진 날짜 포함 이전)', example='2022-02-10T15:00:00') + updated_at: Optional[str] = Field(None, description='수정날짜', example='2022-01-01T12:34:56') + updated_at__gt: Optional[str] = Field(None, description='생성날짜(주어진 날짜 이후)', example='2022-02-10T15:00:00') + updated_at__gte: Optional[str] = Field(None, description='생성날짜(주어진 날짜 포함 이후)', example='2022-02-10T15:00:00') + updated_at__lt: Optional[str] = Field(None, description='생성날짜(주어진 날짜 이전)', example='2022-02-10T15:00:00') + updated_at__lte: Optional[str] = Field(None, description='생성날짜(주어진 날짜 포함 이전)', example='2022-02-10T15:00:00') + + status: Optional[UserStatusType] = Field(None, description='계정상태' + UserStatusType.get_elements_str(), example=UserStatusType.active) + user_type: Optional[UserType] = Field(None, description='유저 타입' + UserType.get_elements_str(), example=UserType.user) + account_type: Optional[AccountType] = 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[SexType] = 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(None, description='마케팅동의 여부', example=False) + # extra + login: Optional[UserLoginType] = Field(None, description='로그인 상태' + UserLoginType.get_elements_str(), example=UserLoginType.logout) # TODO(hsj100): LOGIN_STATUS + member_type: Optional[MemberType] = Field(None, description='회원 타입' + MemberType.get_elements_str(), example=MemberType.personal) + + model_config = ConfigDict(from_attributes=True) + + +class UserSearchRes(ResponseBase): + """ + ### [Response] 유저 검색 + """ + data: List[UserInfo] = [] + + model_config = ConfigDict(from_attributes=True) + + +class UserSearchPagingReq(BaseModel): + """ + ### [Request] 유저 페이징 검색 + """ + # paging + paging: Optional[PagingReq] = None + search: Optional[UserSearchReq] = None + + +class UserSearchPagingRes(ResponseBase): + """ + ### [Response] 유저 페이징 검색 + """ + paging: PagingRes = None + data: List[UserInfo] = [] + + model_config = ConfigDict(from_attributes=True) + + +class UserUpdateReq(BaseModel): + """ + ### [Request] 유저 변경 + """ + status: Optional[UserStatusType] = Field(None, description='계정상태' + UserStatusType.get_elements_str(), example=UserStatusType.active) + user_type: Optional[UserType] = Field(None, description='유저 타입' + UserType.get_elements_str(), example=UserType.user) + account_type: Optional[AccountType] = Field(None, description='계정종류' + AccountType.get_elements_str(), example=AccountType.email) + account: Optional[str] = Field(None, description='계정', example='user1@test.com') + email: Optional[str] = Field(None, description='전자메일', example='user1@test.com') + name: Optional[str] = Field(None, description='이름', example='test') + sex: Optional[SexType] = 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 + login: Optional[UserLoginType] = Field(None, description='로그인 상태' + UserLoginType.get_elements_str(), example=UserLoginType.logout) # TODO(hsj100): LOGIN_STATUS + # uuid: Optional[str] = Field(None, description='UUID', example='12345678-1234-5678-1234-567800000001') + member_type: Optional[MemberType] = Field(None, description='회원 타입' + MemberType.get_elements_str(), example=MemberType.personal) + + model_config = ConfigDict(from_attributes=True) + + +class UserUpdateMultiReq(BaseModel): + """ + ### [Request] 유저 변경 (multi) + """ + search_info: UserSearchReq = None + update_info: UserUpdateReq = None + + model_config = ConfigDict(from_attributes=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 UserLogSearchReq(BaseModel): + """ + ### [Request] 유저로그 검색 + """ + # basic + id: Optional[int] = Field(None, description='등록번호', example='1') + id__in: Optional[list] = Field(None, description='등록번호 리스트', example=[1,]) + created_at: Optional[str] = Field(None, description='생성날짜', example='2022-01-01T12:34:56') + created_at__gt: Optional[str] = Field(None, description='생성날짜(주어진 날짜 이후)', example='2022-02-10T15:00:00') + created_at__gte: Optional[str] = Field(None, description='생성날짜(주어진 날짜 포함 이후)', example='2022-02-10T15:00:00') + created_at__lt: Optional[str] = Field(None, description='생성날짜(주어진 날짜 이전)', example='2022-02-10T15:00:00') + created_at__lte: Optional[str] = Field(None, description='생성날짜(주어진 날짜 포함 이전)', example='2022-02-10T15:00:00') + updated_at: Optional[str] = Field(None, description='수정날짜', example='2022-01-01T12:34:56') + updated_at__gt: Optional[str] = Field(None, description='생성날짜(주어진 날짜 이후)', example='2022-02-10T15:00:00') + updated_at__gte: Optional[str] = Field(None, description='생성날짜(주어진 날짜 포함 이후)', example='2022-02-10T15:00:00') + updated_at__lt: Optional[str] = Field(None, description='생성날짜(주어진 날짜 이전)', example='2022-02-10T15:00:00') + updated_at__lte: Optional[str] = Field(None, description='생성날짜(주어진 날짜 포함 이전)', example='2022-02-10T15:00:00') + + account: Optional[str] = Field(None, description='계정', example='user1@test.com') + account__like: Optional[str] = Field(None, description='계정 부분검색', example='%user%') + mac: Optional[str] = Field(None, description='MAC', example='11:22:33:44:55:01') + mac__like: Optional[str] = Field(None, description='MAC 부분검색', example='%33%') + type: Optional[UserLogMessageType] = Field(None, description='유저로그 메시지 타입' + UserLogMessageType.get_elements_str(), example=UserLogMessageType.error) + api: Optional[str] = Field(None, description='API 이름', example='/api/auth/login') + api__like: Optional[str] = Field(None, description='API 이름 부분검색', example='%login%') + message: Optional[str] = Field(None, description='로그내용', example='invalid password') + message__like: Optional[str] = Field(None, description='로그내용 부분검색', example='%invalid%') + + model_config = ConfigDict(from_attributes=True) + + +class UserLogDayInfo(BaseModel): + """ + ### 유저로그 일별 접속 정보 (출석) + """ + account: str = Field(None, description='계정', example='user1@test.com') + mac: str = Field(None, description='MAC(네트워크 인터페이스 식별자)', example='11:22:33:44:55:66') + updated_at: datetime = Field(None, description='수정날짜', example='2022-01-01T12:34:56') + message: str = Field(None, description='로그 내용', example='ok') + + model_config = ConfigDict(from_attributes=True) + + +class UserLogDaySearchReq(BaseModel): + """ + ### [Request] 유저로그 일별 마지막 접속 검색 + """ + updated_at: Optional[str] = Field(None, description='수정날짜', example='2022-01-01T12:34:56') + updated_at__gt: Optional[str] = Field(None, description='생성날짜(주어진 날짜 이후)', example='2022-02-10T15:00:00') + updated_at__gte: Optional[str] = Field(None, description='생성날짜(주어진 날짜 포함 이후)', example='2022-02-10T15:00:00') + updated_at__lt: Optional[str] = Field(None, description='생성날짜(주어진 날짜 이전)', example='2022-02-10T15:00:00') + updated_at__lte: Optional[str] = Field(None, description='생성날짜(주어진 날짜 포함 이전)', example='2022-02-10T15:00:00') + + account: Optional[str] = Field(None, description='계정', example='user1@test.com') + account__like: Optional[str] = Field(None, description='계정 부분검색', example='%user%') + mac: Optional[str] = Field(None, description='MAC', example='11:22:33:44:55:01') + mac__like: Optional[str] = Field(None, description='MAC 부분검색', example='%33%') + + model_config = ConfigDict(from_attributes=True) + + +class UserLogDaySearchPagingReq(BaseModel): + """ + ### [Request] 유저로그 일별 마지막 접속 페이징 검색 + """ + # paging + paging: Optional[PagingReq] = None + search: Optional[UserLogDaySearchReq] = None + + +class UserLogDaySearchPagingRes(ResponseBase): + """ + ### [Response] 유저로그 일별 마지막 접속 검색 + """ + paging: PagingRes = None + data: List[UserLogDayInfo] = [] + + model_config = ConfigDict(from_attributes=True) + + +class UserLogPagingReq(BaseModel): + """ + ### [Request] 유저로그 페이징 검색 + """ + # paging + paging: Optional[PagingReq] = None + search: Optional[UserLogSearchReq] = None + + +class UserLogPagingRes(ResponseBase): + """ + ### [Response] 유저로그 페이징 검색 + """ + paging: PagingRes = None + data: List[UserLogInfo] = [] + + model_config = ConfigDict(from_attributes=True) + + +class UserLogUpdateReq(BaseModel): + """ + ### [Request] 유저로그 변경 + """ + account: Optional[str] = Field(None, description='계정', example='user1@test.com') + mac: Optional[str] = Field(None, description='MAC', example='11:22:33:44:55:01') + type: Optional[UserLogMessageType] = Field(None, description='유저로그 메시지 타입' + UserLogMessageType.get_elements_str(), example=UserLogMessageType.error) + api: Optional[str] = Field(None, description='API 이름', example='/api/auth/login') + message: Optional[str] = Field(None, description='로그내용', example='invalid password') + + model_config = ConfigDict(from_attributes=True) + + +class UserLogUpdateMultiReq(BaseModel): + """ + ### [Request] 유저로그 변경 (multi) + """ + search_info: UserLogSearchReq = None + update_info: UserLogUpdateReq = None + + model_config = ConfigDict(from_attributes=True) + +#=============================================================================== +#=============================================================================== +#=============================================================================== +#=============================================================================== + +class IndexType(str, Enum): + hnsw = "hnsw" + l2 = "l2" + +class VactorSearchReq(BaseModel): + """ + ### [Request] vactor 검색 + """ + quary_image_path : str = Field(description='quary image', example='path') + index_type : IndexType = Field(IndexType.hnsw, description='인덱스 타입', example=IndexType.hnsw) + search_num : int = Field(4, description='검색결과 이미지 갯수', example=4) + + \ No newline at end of file diff --git a/vactor_rest/app/routes/auth.py b/vactor_rest/app/routes/auth.py new file mode 100644 index 0000000..1a98b19 --- /dev/null +++ b/vactor_rest/app/routes/auth.py @@ -0,0 +1,93 @@ +# -*- 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 itertools import groupby +from operator import attrgetter +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +import bcrypt +import jwt +from datetime import datetime, timedelta + +from vactor_rest.app.common import consts +from vactor_rest.app import models as M +from vactor_rest.app.database.conn import db +from vactor_rest.app.common.config import conf +from vactor_rest.app.database.schema import Users, UserLog +from vactor_rest.app.utils.extra import query_to_groupby, AESCryptoCBC +from vactor_rest.app.utils.date_utils import D + +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('/logout/{account}', status_code=200, response_model=M.TokenRes, summary='사용자 접속종료') +async def logout(account: str): + """ + ## 사용자 접속종료 + + 현재 버전에서는 로그인/로그아웃의 상태를 유지하지 않고 상태값만을 서버에서 사용하기 때문에,\n + ***로그상태는 실제상황과 다를 수 있다.*** + + 정상처리시 Authorization(null) 반환 + + **결과** + - TokenRes + """ + user_info = None + + try: + # TODO(hsj100): LOGIN_STATUS + user_info = Users.filter(account=account) + if not user_info: + raise Exception('not found user') + + user_info.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): + + if conf().GLOBAL_TOKEN: + return conf().GLOBAL_TOKEN + + 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/vactor_rest/app/routes/dev.py b/vactor_rest/app/routes/dev.py new file mode 100644 index 0000000..315a341 --- /dev/null +++ b/vactor_rest/app/routes/dev.py @@ -0,0 +1,137 @@ +# -*- 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 +""" +import struct + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +import bcrypt +from starlette.requests import Request + +from vactor_rest.app.common import consts +from vactor_rest.app import models as M +from vactor_rest.app.database.conn import db, Base +from vactor_rest.app.database.schema import Users, UserLog + +from vactor_rest.app.utils.extra import FernetCrypto, AESCryptoCBC, AESCipher +from custom_logger.vactor_log import vactor_logger as LOG + + +# 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 + """ + + a = M.SWInfo() + + a.name = '!ekdnfeldpsdptm1' + # a.name = 'testtesttest123' + simpleEnDecrypt = FernetCrypto() + a.data1 = simpleEnDecrypt.encrypt(a.name) + a.data2 = bcrypt.hashpw(a.name.encode('utf-8'), bcrypt.gensalt()) + + t = bytes(a.name.encode('utf-8')) + + enc = AESCryptoCBC().encrypt(t) + dec = AESCryptoCBC().decrypt(enc) + + t = enc.decode('utf-8') + + # enc = AESCipher('daooldns12345678').encrypt(a.name).decode('utf-8') + # enc = 'E563ZFt+yJL8YY5yYlYyk602MSscPP2SCCD8UtXXpMI=' + # dec = AESCipher('daooldns12345678').decrypt(enc).decode('utf-8') + + a.data3 = f'enc: {enc}, {t}' + a.data4 = f'dec: {dec}' + + a.name = '!ekdnfeldpsdptm1' + + + # simpleEnDecrypt = SimpleEnDecrypt() + a.data5 = simpleEnDecrypt.encrypt(a.name) + a.data6 = bcrypt.hashpw(a.name.encode('utf-8'), bcrypt.gensalt()) + + key = consts.ADMIN_INIT_ACCOUNT_INFO['aes_cbc_key'] + + t = bytes(a.name.encode('utf-8')) + + enc = AESCryptoCBC(key).encrypt(t) + dec = AESCryptoCBC(key).decrypt(enc) + + t = enc.decode('utf-8') + + # enc = AESCipher('daooldns12345678').encrypt(a.name).decode('utf-8') + # enc = 'E563ZFt+yJL8YY5yYlYyk602MSscPP2SCCD8UtXXpMI=' + # dec = AESCipher('daooldns12345678').decrypt(enc).decode('utf-8') + + print(f'key: {key}') + a.data7 = f'enc: {enc}, {t}' + a.data8 = f'dec: {dec}' + + + eee = "gAAAAABioV5NucuS9nQugZJnz-KjVG_FGnaowB9KAfhOoWjjiQ4jGLuYJh4Qe94mT_lCm6m3HhuOJqUeOgjppwREDpIQYzrUXA==" + a.data8 = simpleEnDecrypt.decrypt(eee) + + return a + + diff --git a/vactor_rest/app/routes/index.py b/vactor_rest/app/routes/index.py new file mode 100644 index 0000000..3e5d198 --- /dev/null +++ b/vactor_rest/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 vactor_rest.app.utils.date_utils import D +from vactor_rest.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 diff --git a/vactor_rest/app/routes/services.py b/vactor_rest/app/routes/services.py new file mode 100644 index 0000000..71a83f4 --- /dev/null +++ b/vactor_rest/app/routes/services.py @@ -0,0 +1,49 @@ +# -*- 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 +""" + +import requests, json, traceback, os +from fastapi import APIRouter, Depends, Body +from starlette.requests import Request +from typing import Annotated, List + +from vactor_rest.app.common import consts +from vactor_rest.app import models as M +from vactor_rest.app.utils.date_utils import D +from custom_logger.vactor_log import vactor_logger as LOG + +from custom_apps.faiss.main import search_idxs + +router = APIRouter(prefix="/services") + + +@router.post("/faiss/vactor/search", summary="vactor search", response_model=M.ResponseBase) +async def vactor_search(request: Request, request_body_info: M.VactorSearchReq): + """ + ## 벡터검색 + + ## 정보 + > + + """ + response = M.ResponseBase() + try: + if os.path.exists(request_body_info.quary_image_path): + search_idxs(image_path=request_body_info.quary_image_path, + index_type=request_body_info.index_type, + search_num=request_body_info.search_num) + else: + raise Exception(f"File {request_body_info.quary_image_path} does not exist.") + + return response.set_message() + + except Exception as e: + LOG.error(traceback.format_exc()) + return response.set_error(e) \ No newline at end of file diff --git a/vactor_rest/app/routes/users.py b/vactor_rest/app/routes/users.py new file mode 100644 index 0000000..357023d --- /dev/null +++ b/vactor_rest/app/routes/users.py @@ -0,0 +1,298 @@ +# -*- 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 vactor_rest.app.common import consts +from vactor_rest.app import models as M +from vactor_rest.app.common.config import conf +from vactor_rest.app.database.schema import Users +from vactor_rest.app.database.crud import table_select, table_update, table_delete + +from vactor_rest.app.utils.extra import AESCryptoCBC + + +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.UserSearchPagingRes, summary='유저정보 검색') +async def user_search(request: Request, request_body_info: M.UserSearchPagingReq): + """ + ## 유저정보 검색 (기본) + 검색 조건은 유저 테이블 항목만 가능 (Request body: Schema 참조)\n + 관련 정보 연동 검색은 별도 API 사용 + + **세부항목** + - **paging**\n + 항목 미사용시에는 페이징기능 없이 검색조건(search) 결과 모두 반환 + + - **search**\n + 검색에 필요한 항목들을 search 에 포함시킨다.\n + 검색에 사용된 각 항목들은 AND 조건으로 처리된다. + + - **전체검색**\n + empty object 사용 ( {} )\n + 예) "search": {} + + - **검색항목**\n + - 부분검색 항목\n + SQL 문법( %, _ )을 사용한다.\n + __like: 시작포함(X%), 중간포함(%X%), 끝포함(%X) + - 구간검색 항목 + * __lt: 주어진 값보다 작은값 + * __lte: 주어진 값보다 같거나 작은 값 + * __gt: 주어진 값보다 큰값 + * __gte: 주어진 값보다 같거나 큰값 + + **결과** + - UserSearchPagingRes + """ + return await table_select(request.state.user, Users, request_body_info, M.UserSearchPagingRes, M.UserInfo) + + +@router.put('/update', response_model=M.ResponseBase, summary='유저정보 변경') +async def user_update(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 user_update_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') + + # decrypt pw + try: + decode_cur_pw = request_body_info.current_pw.encode('utf-8') + desc_cur_pw = AESCryptoCBC().decrypt(decode_cur_pw) + except Exception as e: + raise Exception(f'failed decryption [current_pw]: {e}') + try: + decode_new_pw = request_body_info.new_pw.encode('utf-8') + desc_new_pw = AESCryptoCBC().decrypt(decode_new_pw) + except Exception as e: + raise Exception(f'failed decryption [new_pw]: {e}') + + # search + target_user = target_table.get(account=request_body_info.account) + is_verified = bcrypt.checkpw(desc_cur_pw, 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(desc_new_pw, 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 user_delete(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/vactor_rest/app/utils/date_utils.py b/vactor_rest/app/utils/date_utils.py new file mode 100644 index 0000000..a9f227b --- /dev/null +++ b/vactor_rest/app/utils/date_utils.py @@ -0,0 +1,58 @@ +# -*- 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 + + @classmethod + def date_file_name(cls): + date = datetime.now() + return date.strftime('%y%m%d_%H%M%S.%s') + +if __name__ == "__main__": + _date = D() diff --git a/vactor_rest/app/utils/extra.py b/vactor_rest/app/utils/extra.py new file mode 100644 index 0000000..8f7da52 --- /dev/null +++ b/vactor_rest/app/utils/extra.py @@ -0,0 +1,205 @@ +# -*- 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 +""" + +from hashlib import md5 +from base64 import b64decode +from base64 import b64encode + +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad, unpad +from cryptography.fernet import Fernet # symmetric encryption + +# mail test +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +from itertools import groupby +from operator import attrgetter +import uuid + +from vactor_rest.app.common.consts import NUM_RETRY_UUID_GEN, SMTP_HOST, SMTP_PORT +from vactor_rest.app.utils.date_utils import D +from vactor_rest.app import models as M +from vactor_rest.app.common.consts import AES_CBC_PUBLIC_KEY, AES_CBC_IV, FERNET_SECRET_KEY + + +async def send_mail(sender, sender_pw, title, recipient, contents_plain, contents_html, cc_list, smtp_host=SMTP_HOST, smtp_port=SMTP_PORT): + """ + 구글 계정사용시 : 보안 수준이 낮은 앱에서의 접근 활성화 + + :return: + None: success + Str. Message: error + """ + try: + # check parameters + if not sender: + raise Exception('invalid sender') + if not title: + raise Exception('invalid title') + if not recipient: + raise Exception('invalid recipient') + + # sender info. + # sender = consts.ADMIN_INIT_ACCOUNT_INFO.email + # sender_pw = consts.ADMIN_INIT_ACCOUNT_INFO.email_pw + + # message + msg = MIMEMultipart() + msg['From'] = sender + msg['To'] = recipient + if cc_list: + list_cc = cc_list + str_cc = ','.join(list_cc) + msg['Cc'] = str_cc + msg['Subject'] = title + + if contents_plain: + msg.attach(MIMEText(contents_plain, 'plain')) + if contents_html: + msg.attach(MIMEText(contents_html, 'html')) + + # smtp server + smtp_server = smtplib.SMTP(host=smtp_host, port=smtp_port) + smtp_server.ehlo() + smtp_server.starttls() + smtp_server.ehlo() + smtp_server.login(sender, sender_pw) + smtp_server.send_message(msg) + smtp_server.quit() + return None + except Exception as e: + return str(e) + + +def query_to_groupby(query_result, key, first=False): + """ + 쿼리 결과물(list)을 항목값(key)으로 그룹화한다. + + :param query_result: 쿼리 결과 리스트 + :param key: 그룹 항목값 + :return: dict + """ + group_info = dict() + for k, g in groupby(query_result, attrgetter(key)): + if k not in group_info: + if not first: + group_info[k] = list(g) + else: + group_info[k] = list(g)[0] + else: + if not first: + group_info[k].extend(list(g)) + return group_info + + +def query_to_groupby_date(query_result, key): + """ + 쿼리 결과물(list)을 항목값(key)으로 그룹화한다. + + :param query_result: 쿼리 결과 리스트 + :param key: 그룹 항목값 + :return: dict + """ + group_info = dict() + for k, g in groupby(query_result, attrgetter(key)): + day_str = k.strftime("%Y-%m-%d") + if day_str not in group_info: + group_info[day_str] = list(g) + else: + group_info[day_str].extend(list(g)) + return group_info + + +class FernetCrypto: + def __init__(self, key=FERNET_SECRET_KEY): + self.key = key + self.f = Fernet(self.key) + + def encrypt(self, data, is_out_string=True): + if isinstance(data, bytes): + ou = self.f.encrypt(data) # 바이트형태이면 바로 암호화 + else: + ou = self.f.encrypt(data.encode('utf-8')) # 인코딩 후 암호화 + if is_out_string is True: + return ou.decode('utf-8') # 출력이 문자열이면 디코딩 후 반환 + else: + return ou + + def decrypt(self, data, is_out_string=True): + if isinstance(data, bytes): + ou = self.f.decrypt(data) # 바이트형태이면 바로 복호화 + else: + ou = self.f.decrypt(data.encode('utf-8')) # 인코딩 후 복호화 + if is_out_string is True: + return ou.decode('utf-8') # 출력이 문자열이면 디코딩 후 반환 + else: + return ou + + +class AESCryptoCBC: + def __init__(self, key=AES_CBC_PUBLIC_KEY, iv=AES_CBC_IV): + # Initial vector를 0으로 초기화하여 16바이트 할당함 + # iv = chr(0) * 16 #pycrypto 기준 + # iv = bytes([0x00] * 16) #pycryptodomex 기준 + # aes cbc 생성 + self.key = key + self.iv = iv + self.crypto = AES.new(self.key, AES.MODE_CBC, self.iv) + + def encrypt(self, data): + # 암호화 message는 16의 배수여야 한다. + # enc = self.crypto.encrypt(data) + # return enc + enc = self.crypto.encrypt(pad(data, AES.block_size)) + return b64encode(enc) + + def decrypt(self, enc): + # 복호화 enc는 16의 배수여야 한다. + # dec = self.crypto.decrypt(enc) + # return dec + enc = b64decode(enc) + dec = self.crypto.decrypt(enc) + return unpad(dec, AES.block_size) + + +class AESCipher: + def __init__(self, key): + # self.key = md5(key.encode('utf8')).digest() + self.key = bytes(key.encode('utf-8')) + + def encrypt(self, data): + # iv = get_random_bytes(AES.block_size) + iv = bytes('daooldns12345678'.encode('utf-8')) + + self.cipher = AES.new(self.key, AES.MODE_CBC, iv) + t = b64encode(self.cipher.encrypt(pad(data.encode('utf-8'), AES.block_size))) + return b64encode(iv + self.cipher.encrypt(pad(data.encode('utf-8'), AES.block_size))) + + def decrypt(self, data): + raw = b64decode(data) + self.cipher = AES.new(self.key, AES.MODE_CBC, raw[:AES.block_size]) + return unpad(self.cipher.decrypt(raw[AES.block_size:]), AES.block_size) + +def cls_list_to_dict_list(list): + """ + list 내부 element가 dict로 변환 가능한 class일경우 + 내부 element를 dict 로 변경 + """ + _result = [] + for i in list: + if isinstance(i, dict): + _result = list + break + + _result.append(i.dict()) + return _result \ No newline at end of file diff --git a/vactor_rest/app/utils/logger.py b/vactor_rest/app/utils/logger.py new file mode 100644 index 0000000..e34cea6 --- /dev/null +++ b/vactor_rest/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/vactor_rest/app/utils/parsing_utils.py b/vactor_rest/app/utils/parsing_utils.py new file mode 100644 index 0000000..946dfbc --- /dev/null +++ b/vactor_rest/app/utils/parsing_utils.py @@ -0,0 +1,25 @@ +from const import ILLEGAL_FILE_NAME + +def prompt_to_filenames(prompt): + """ + prompt 에 사용할 수 없는 문자가 있으면 '_' 로 치환 + """ + filename = '' + for i in prompt: + if i in ILLEGAL_FILE_NAME: + filename += '_' + else: + filename += i + + return filename + + +def download_range(download_count:int,max=4): + _min = 1 + _max = max + + if _min <= download_count and download_count <= _max: + return True + + return False + \ No newline at end of file diff --git a/vactor_rest/app/utils/query_utils.py b/vactor_rest/app/utils/query_utils.py new file mode 100644 index 0000000..1e7c524 --- /dev/null +++ b/vactor_rest/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/vactor_rest/gunicorn.conf.py b/vactor_rest/gunicorn.conf.py new file mode 100644 index 0000000..b5db00a --- /dev/null +++ b/vactor_rest/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/vactor_rest/tests/__init__.py b/vactor_rest/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vactor_rest/tests/conftest.py b/vactor_rest/tests/conftest.py new file mode 100644 index 0000000..5221c4c --- /dev/null +++ b/vactor_rest/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/vactor_rest/tests/test_auth.py b/vactor_rest/tests/test_auth.py new file mode 100644 index 0000000..b3669c2 --- /dev/null +++ b/vactor_rest/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/vactor_rest/tests/test_user.py b/vactor_rest/tests/test_user.py new file mode 100644 index 0000000..7d04437 --- /dev/null +++ b/vactor_rest/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'] +