diff --git a/.DS_Store b/.DS_Store index c0229d4..5accdfe 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a2a97c8..6616edd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,7 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/rust { - "name": "fission:ailbl-tag", + "name": "fission:ailbl-user-email", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile // "image": "mcr.microsoft.com/devcontainers/rust:0-1-bullseye", // Use docker compose file @@ -21,17 +21,17 @@ }, "extensions": [ // VS Code specific - "ms-azuretools.vscode-docker" , - "dbaeumer.vscode-eslint" , - "EditorConfig.EditorConfig" , + "ms-azuretools.vscode-docker", + "dbaeumer.vscode-eslint", + "EditorConfig.EditorConfig", // Python specific - "ms-python.python" , - "ms-python.black-formatter" , + "ms-python.python", + "ms-python.black-formatter", // C++ specific - "ms-vscode.cpptools" , - "twxs.cmake" , + "ms-vscode.cpptools", + "twxs.cmake", // Markdown specific - "yzhang.markdown-all-in-one" , + "yzhang.markdown-all-in-one", // YAML formatter "kennylong.kubernetes-yaml-formatter", // hightlight and format `pyproject.toml` @@ -39,7 +39,7 @@ ] } }, - "mounts": [ ], + "mounts": [], // "runArgs": [ // "--env-file", // ".devcontainer/.env" @@ -47,4 +47,4 @@ "postStartCommand": "/workspaces/${localWorkspaceFolderBasename}/.devcontainer/initscript.sh", // Use 'forwardPorts' to make a list of ports inside the container available locally. "forwardPorts": [] -} +} \ No newline at end of file diff --git a/.devcontainer/initscript.sh b/.devcontainer/initscript.sh index 723f299..ddd871c 100755 --- a/.devcontainer/initscript.sh +++ b/.devcontainer/initscript.sh @@ -135,7 +135,6 @@ spec: port: number: 80 EOF - # ## install without helm # kubectl create -k "github.com/fission/fission/crds/v1?ref=${FISSION_VER}" # kubectl create namespace $FISSION_NAMESPACE diff --git a/.fission/local-deployment.json b/.fission/local-deployment.json index 173e36b..b8c26f5 100644 --- a/.fission/local-deployment.json +++ b/.fission/local-deployment.json @@ -3,11 +3,13 @@ "secrets": { "fission-ailbl-user-email-env": { "literals": [ - "S3_BUCKET=ailbl", - "S3_ENDPOINT_URL=http://160.30.113.113:9000", - "S3_ACCESS_KEY_ID=quyen", - "S3_SECRET_ACCESS_KEY=12345678", - "S3_PREFIX=user/avatar" + "PG_HOST=160.30.113.113", + "PG_PORT=45432", + "PG_DB=postgres", + "PG_USER=postgres", + "PG_PASS=q2q32RQx9R9qVAp3vkVrrASnSUUhzKvC", + "KRATOS_ADMIN_ENDPOINT=http://160.30.113.113:4434", + "KRATOS_PUBLIC_ENDPOINT=http://160.30.113.113:4433" ] } } diff --git a/apps/ailbl-admin_avatar-insert-update-delete-get.py b/apps/ailbl-admin_avatar-insert-update-delete-get.py deleted file mode 100644 index 7f36a4a..0000000 --- a/apps/ailbl-admin_avatar-insert-update-delete-get.py +++ /dev/null @@ -1,89 +0,0 @@ -import crud -from flask import jsonify, request - -ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} - - -def main(): - """ - ```fission - { - "name": "avatar-admin-get-insert-delete-put", - "http_triggers": { - "avatar-admin-get-insert-delete-put-http": { - "url": "/ailbl/admin/avatars", - "methods": ["PUT", "POST", "DELETE", "GET"] - } - } - } - ``` - """ - try: - if request.method == "PUT": - return make_update_avatar_request() - elif request.method == "DELETE": - return make_delete_avatar_request() - elif request.method == "POST": - return make_insert_request() - elif request.method == "GET": - return make_get_avatar_request() - else: - return {"error": "Method not allow"}, 405 - except Exception as ex: - return jsonify({"error": str(ex)}), 500 - - -def make_insert_request(): - try: - user_id = request.headers.get("X-User") - file = request.files.get("avatar") - if not user_id or not file: - return jsonify({"error": "user_id or file is required"}), 400 - if file.mimetype not in ALLOWED_IMAGE_TYPES: - return jsonify( - {"error": "Invalid file type. Only JPG, PNG, GIF, WEBP are allowed."} - ), 400 - response, status = crud.update_or_create_avatar(user_id, file) - return jsonify(response), status - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -def make_get_avatar_request(): - try: - user_id = request.headers.get("X-User") - if not user_id: - return jsonify({"error": "user_id is required"}), 400 - - return crud.get_avatar_url(user_id) - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -def make_delete_avatar_request(): - try: - user_id = request.headers.get("X-User") - if not user_id: - return jsonify({"error": "user_id is required"}), 400 - - response, status = crud.delete_avatar(user_id) - return jsonify(response), status - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -def make_update_avatar_request(): - try: - user_id = request.headers.get("X-User") - file = request.files.get("avatar") - if not user_id or not file: - return jsonify({"error": "user_id or file is required"}), 400 - if file.mimetype not in ALLOWED_IMAGE_TYPES: - return jsonify( - {"error": "Invalid file type. Only JPG, PNG, GIF, WEBP are allowed."} - ), 400 - - response, status = crud.update_or_create_avatar(user_id, file) - return jsonify(response), status - except Exception as e: - return jsonify({"error": str(e)}), 500 diff --git a/apps/ailbl-admin_email-insert-get-filter.py b/apps/ailbl-admin_email-insert-get-filter.py new file mode 100644 index 0000000..e92504f --- /dev/null +++ b/apps/ailbl-admin_email-insert-get-filter.py @@ -0,0 +1,199 @@ +import dataclasses +from typing import Optional +import typing +import crud +from flask import jsonify, request +from helpers import CORS_HEADERS, db_rows_to_array, kratos, str_to_bool +from pydantic import BaseModel, Field, ValidationError +from helpers import kratos, init_db_connection +from schemas import UserEmailRequest + + +@dataclasses.dataclass # Filter user bao nhieu email +class EmailFilter: + ids: typing.Optional[typing.List[str]] = None + email: typing.Optional[str] = None + provider: typing.Optional[str] = None + created_from: typing.Optional[str] = None + created_to: typing.Optional[str] = None + modified_from: typing.Optional[str] = None + modified_to: typing.Optional[str] = None + keywords: typing.Optional[str] = None + primary: typing.Optional[bool] = None + + @classmethod + def from_request_queries(cls) -> "EmailFilter": + return cls( + ids=request.args.getlist("filter[ids]"), + email=request.args.get("filter[email]"), + provider=request.args.get("filter[provider]"), + created_from=request.args.get("filter[created_from]"), + created_to=request.args.get("filter[created_to]"), + modified_from=request.args.get("filter[modified_from]"), + modified_to=request.args.get("filter[modified_to]"), + keywords=request.args.get("filter[key]"), + primary=str_to_bool(request.args.get("filter[primary]")) + ) + + +@dataclasses.dataclass +class Page: + page: int = 0 + size: int = 10 + asc: bool = False + + @classmethod + def from_request_queries(cls) -> "Page": + return Page( + page=int(request.args.get("page", 0)), + size=int(request.args.get("size", 10)), + asc=request.args.get("asc", type=str_to_bool) or False + ) + + +@dataclasses.dataclass +class EmailPage(Page): + sortby: typing.Optional[str] = None + filter: EmailFilter = dataclasses.field( + default_factory=EmailFilter.from_request_queries) + + @classmethod + def from_request_queries(cls) -> "EmailPage": + base = Page.from_request_queries() + return cls(**dataclasses.asdict(base), sortby=request.args.get("sortby")) + + +def main(): + """ + ```fission + { + "name": "email-admin-insert-get-filter", + "http_triggers": { + "email-admin-insert-get-filter-http": { + "url": "/ailbl/admin/users/{UserId}/emails ", + "methods": ["POST", "GET"] + } + } + } + ``` + """ + try: + if request.method == "POST": + return insert_email() + elif request.method == "GET": + return filter_emails() + else: + return {"error": "Method not allow"}, 405 + except Exception as ex: + return jsonify({"error": str(ex)}), 500 + + +def insert_email(): + user_id = request.headers.get("X-Fission-Params-UserId") + if not user_id: + return jsonify({"errorCode": "USER_ID_REQUIRED"}), 400, CORS_HEADERS + try: + data = request.get_json() + if not data: + return jsonify({"errorCode": "NO_DATA_PROVIDED"}), 400, CORS_HEADERS + + parsed = UserEmailRequest(**data) + except ValidationError as e: + return jsonify({"errorCode": "VALIDATION_ERROR", "details": e.errors()}), 422, CORS_HEADERS + except Exception as e: + return jsonify({"errorCode": "BAD_REQUEST"}), 400, CORS_HEADERS + try: + add_email, status_code, headers = crud.add_email_to_user( + user_id, parsed.email) + if parsed.is_primary: + # update email kratos + identity = kratos.get_identity(user_id) + traits = identity.traits + traits["email"] = parsed.email + res = kratos.update_identity( + id=user_id, + update_identity_body={ + "schema_id": identity.schema_id, + "traits": traits, + "state": identity.state, + }, + ) + return jsonify(add_email), status_code, headers + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +def filter_emails(): + paging = EmailPage.from_request_queries() + user_id = request.headers.get( + "X-Fission-Params-UserId") # X-Fission lay tren path + if not user_id: + return jsonify({"errorCode": "USER_ID_REQUIRED"}), 400, CORS_HEADERS + + conn = None + try: + conn = init_db_connection() + with conn.cursor() as cursor: + records = __filter_email(cursor, paging, user_id) + + return jsonify( + records, + ), 200, CORS_HEADERS + except Exception as e: + # current_app.logger.error(f"[filter_emails] DB Error: {e}") + return jsonify({"errorCode": "DATABASE_ERROR"}), 500, CORS_HEADERS + finally: + if conn: + conn.close() + + +def __filter_email(cursor, paging: EmailPage, user_id: str): + conditions = ["user_id = %(user_id)s"] + values = {"user_id": user_id} + if paging.filter.ids: + conditions.append("id = ANY(%(ids)s)") + values["ids"] = paging.filter.ids + if paging.filter.email: + conditions.append("LOWER(email) LIKE %(email)s") + values["email"] = f"%{paging.filter.email.lower()}%" + if paging.filter.provider: + conditions.append("LOWER(provider) LIKE %(provider)s") + values["provider"] = f"%{paging.filter.provider.lower()}%" + if paging.filter.created_from: + conditions.append("created >= %(created_from)s") + values["created_from"] = paging.filter.created_from + if paging.filter.created_to: + conditions.append("created <= %(created_to)s") + values["created_to"] = paging.filter.created_to + if paging.filter.modified_from: + conditions.append("modified >= %(modified_from)s") + values["modified_from"] = paging.filter.modified_from + if paging.filter.modified_to: + conditions.append("modified <= %(modified_to)s") + values["modified_to"] = paging.filter.modified_to + if paging.filter.keywords: + conditions.append( + "(LOWER(email) LIKE %(keywords)s OR LOWER(provider) LIKE %(keywords)s)") + values["keywords"] = f"%{paging.filter.keywords.lower()}%" + + where_clause = " AND ".join(conditions) + if where_clause: + where_clause = "WHERE " + where_clause + + order_clause = "" + if paging.sortby: + direction = "ASC" if paging.asc else "DESC" + order_clause = f"ORDER BY {paging.sortby} {direction}" + + sql = f""" + SELECT *, COUNT(*) OVER() AS total + FROM ailbl_user_email + {where_clause} + {order_clause} + LIMIT %(limit)s OFFSET %(offset)s + """ + values["limit"] = paging.size + values["offset"] = paging.page * paging.size + + cursor.execute(sql, values) + return db_rows_to_array(cursor, cursor.fetchall()) diff --git a/apps/ailbl-admin_email_update_delete.py b/apps/ailbl-admin_email_update_delete.py new file mode 100644 index 0000000..0896723 --- /dev/null +++ b/apps/ailbl-admin_email_update_delete.py @@ -0,0 +1,190 @@ +from typing import Optional + +import crud +from email_validator import EmailNotValidError, validate_email +from flask import jsonify, request +from pydantic import BaseModel, Field, ValidationError +from sqlalchemy.orm import Session +from helpers import kratos, init_db_connection, CORS_HEADERS + + +def main(): + """ + ```fission + { + "name": "email-admin-delete", + "http_triggers": { + "email-admin-delete-http": { + "url": "/ailbl/admin/users/{UserId}/emails/{UserEmailId}", + "methods": ["DELETE"] + } + } + } + ``` + """ + try: + if request.method == "DELETE": + return delete_email() + else: + return {"error": "Method not allow"}, 405 + except Exception as ex: + return jsonify({"error": str(ex)}), 500 + + +def delete_email(): + user_id = request.headers.get("X-Fission-Params-UserId") + email_id = request.headers.get("X-Fission-Params-UserEmailId") + + if not user_id: + return jsonify({"errorCode": "USER_ID_REQUIRED"}), 400, CORS_HEADERS + if not email_id: + return jsonify({"errorCode": "USER_EMAIL_ID_REQUIRED"}), 400, CORS_HEADERS + # check if email exists + if exists_email(email_id) is False: + return jsonify({"errorCode": "EMAIL_NOT_FOUND"}), 404, CORS_HEADERS + + # check if email is primary = email chinh + # config , status = get_config_account(user_id) + # if status == 200: + # email_primary = config["profile_setting"]["primary"]["email"] + # if check_is_primary(email_primary, email_id): + # return jsonify({"errorCode": "CANNOT_DELETE_PRIMARY_EMAIL"}), 400, CORS_HEADERS + + # Proceed to delete the email + conn = None + cursor = None + try: + conn = init_db_connection() + cursor = conn.cursor() + + cursor.execute( + """ + DELETE FROM ailbl_user_email + WHERE id = %s AND user_id = %s + RETURNING id; + """, + (email_id, user_id) + ) + + result = cursor.fetchone() + if not result: + return jsonify({"errorCode": "EMAIL_NOT_FOUND"}), 404, CORS_HEADERS + + conn.commit() + return jsonify({ + "id": email_id, + "status": "deleted" + }), 200, CORS_HEADERS + + except Exception as e: + if conn: + conn.rollback() + # current_app.logger.error(f"[delete_email] Database error: {str(e)}") + return jsonify({"errorCode": "DATABASE_ERROR"}), 500, CORS_HEADERS + + finally: + if cursor: + cursor.close() + if conn: + conn.close() + +def exists_email(email_id: str) -> bool: + conn = None + cursor = None + try: + conn = init_db_connection() + with conn.cursor() as cursor: + cursor.execute(""" + SELECT 1 + FROM ailbl_user_email + WHERE id = %s; + """, (email_id,)) + row = cursor.fetchone() + return row is not None + except Exception as e: + # current_app.logger.error(f"[exists_email] DB Error: {e}") + return False + finally: + if cursor: + cursor.close() + if conn: + conn.close() + + + + # update email : set as primary + + +# def update_email(): +# user_id = request.headers.get("X-Fission-Params-UserId") +# email_id = request.headers.get("X-Fission-Params-UserEmailId") +# request_data = request.get_json() +# is_primary = request_data.get("is_primary") +# if not user_id: +# return jsonify({"errorCode": "USER_ID_REQUIRED"}), 400, CORS_HEADERS +# if not email_id: +# return jsonify({"errorCode": "USER_EMAIL_ID_REQUIRED"}), 400, CORS_HEADERS +# if not is_primary: +# return jsonify({"errorCode": "NO_UPDATES_PROVIDED"}), 400, CORS_HEADERS + +# # ensure config account exists +# # create_config_account_if_not_exists(user_id) + +# # check if email exists +# email = get_email_by_id(email_id=email_id) +# if not email: +# return jsonify({"errorCode": "EMAIL_NOT_FOUND"}), 404, CORS_HEADERS + +# # set as primary in config account +# if is_primary: +# identity = kratos.get_identity(user_id) +# traits = identity.traits +# traits["email"] = email +# res = kratos.update_identity( +# id=user_id, +# update_identity_body={ +# "schema_id": identity.schema_id, +# "traits": traits, +# "state": identity.state, +# }, +# ) +# # update config email +# # r, status = create_or_update_config_account(email=email, key=user_id, type="update") +# # if status != 200: +# # return jsonify({"errorCode": "FAILED_TO_UPDATE_PRIMARY_EMAIL"}), status + +# return jsonify({ +# "id": email_id, +# "user_id": user_id, +# "email": email, +# "status": "set_as_primary" +# }), 200, CORS_HEADERS + + +# def get_email_by_id(email_id: str): # GET EMAIL ra id moi update duoc +# conn = None +# cursor = None +# try: +# conn = init_db_connection() +# with conn.cursor() as cursor: +# cursor.execute(""" +# SELECT email +# FROM ailbl_user_email +# WHERE id = %s; +# """, (email_id,)) +# row = cursor.fetchone() +# if row: +# return row[0] +# else: +# return None +# except Exception as e: +# # current_app.logger.error(f"[get_email_by_id] DB Error: {e}") +# return {"error": "DATABASE_ERROR"}, 500 +# finally: +# if cursor: +# cursor.close() +# if conn: +# conn.close() + + + diff --git a/apps/ailbl-user_avatar-insert-update-delete-get.py b/apps/ailbl-user_avatar-insert-update-delete-get.py deleted file mode 100644 index b851dd5..0000000 --- a/apps/ailbl-user_avatar-insert-update-delete-get.py +++ /dev/null @@ -1,95 +0,0 @@ -import crud -from flask import jsonify, request - -# from storage.minio_client import get_minio_client, check_existing_avatar_on_minio, upload_to_minio -ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} - - -def main(): - """ - ```fission - { - "name": "avatar-users-get-insert-delete-put", - "http_triggers": { - "avatar-users-get-insert-delete-put-http": { - "url": "/ailbl/users/avatars", - "methods": ["PUT", "POST", "DELETE", "GET"] - } - } - } - ``` - """ - try: - if request.method == "PUT": - return make_update_avatar_request() - elif request.method == "DELETE": - return make_delete_avatar_request() - elif request.method == "POST": - return make_insert_request() - elif request.method == "GET": - return make_get_avatar_request() - else: - return {"error": "Method not allow"}, 405 - except Exception as ex: - return jsonify({"error": str(ex)}), 500 - - -def make_insert_request(): - try: - user_id = request.headers.get("X-User") # Lay user_id tu header X-User - # Lay file tu form-data voi key la 'avatar' - file = request.files.get("avatar") - if not user_id or not file: - return jsonify({"error": "user_id or file is required"}), 400 - # Check mimetype(kieu du lieu cua file anh) - if file.mimetype not in ALLOWED_IMAGE_TYPES: - return jsonify( - {"error": "Invalid file type. Only JPG, PNG, GIF, WEBP are allowed."} - ), 400 - response, status = crud.update_or_create_avatar(user_id, file) - return jsonify(response), status - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -def make_update_avatar_request(): - try: - # Lay user_id tu header X-User, neu co giao dien roi thi cookies se tu dong duoc gui len o trong header - user_id = request.headers.get("X-User") - # Lay file tu form-data voi key la 'avatar' - file = request.files.get("avatar") - if not user_id or not file: - return jsonify({"error": "user_id or file is required"}), 400 - # Check mimetype(kieu du lieu cua file anh) - if file.mimetype not in ALLOWED_IMAGE_TYPES: - return jsonify( - {"error": "Invalid file type. Only JPG, PNG, GIF, WEBP are allowed."} - ), 400 - response, status = crud.update_or_create_avatar( - user_id, file) # Call CRUD function to update avatar - return jsonify(response), status - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -def make_delete_avatar_request(): - try: - user_id = request.headers.get("X-User") # Lay user_id tu header X-User - if not user_id: - return jsonify({"error": "user_id is required"}), 400 - # Call CRUD function to delete avatar - response, status = crud.delete_avatar(user_id) - return jsonify(response), status - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -def make_get_avatar_request(): - try: - user_id = request.headers.get("X-User") - if not user_id: - return jsonify({"error": "user_id is required"}), 400 - return crud.get_avatar_url(user_id) - # return jsonify(response), status - except Exception as e: - return jsonify({"error": str(e)}), 500 diff --git a/apps/ailbl-users_email_delete.py b/apps/ailbl-users_email_delete.py new file mode 100644 index 0000000..2299322 --- /dev/null +++ b/apps/ailbl-users_email_delete.py @@ -0,0 +1,112 @@ +from typing import Optional + +import crud +from email_validator import EmailNotValidError, validate_email +from flask import jsonify, request +from pydantic import BaseModel, Field, ValidationError +from sqlalchemy.orm import Session +from helpers import kratos, init_db_connection, CORS_HEADERS + + +def main(): + """ + ```fission + { + "name": "email-users-delete", + "http_triggers": { + "email-users-delete-http": { + "url": "/ailbl/users/emails/{UserEmailId}", + "methods": ["DELETE"] + } + } + } + ``` + """ + try: + if request.method == "DELETE": + return delete_email() + else: + return {"error": "Method not allow"}, 405 + except Exception as ex: + return jsonify({"error": str(ex)}), 500 + + +def delete_email(): + user_id = request.headers.get("X-UserId") + email_id = request.headers.get("X-Fission-Params-UserEmailId") + + if not user_id: + return jsonify({"errorCode": "USER_ID_REQUIRED"}), 400, CORS_HEADERS + if not email_id: + return jsonify({"errorCode": "USER_EMAIL_ID_REQUIRED"}), 400, CORS_HEADERS + # check if email exists + if exists_email(email_id) is False: + return jsonify({"errorCode": "EMAIL_NOT_FOUND"}), 404, CORS_HEADERS + + # check if email is primary = email chinh + # config , status = get_config_account(user_id) + # if status == 200: + # email_primary = config["profile_setting"]["primary"]["email"] + # if check_is_primary(email_primary, email_id): + # return jsonify({"errorCode": "CANNOT_DELETE_PRIMARY_EMAIL"}), 400, CORS_HEADERS + + # Proceed to delete the email + conn = None + cursor = None + try: + conn = init_db_connection() + cursor = conn.cursor() + + cursor.execute( + """ + DELETE FROM ailbl_user_email + WHERE id = %s AND user_id = %s + RETURNING id; + """, + (email_id, user_id) + ) + + result = cursor.fetchone() + if not result: + return jsonify({"errorCode": "EMAIL_NOT_FOUND"}), 404, CORS_HEADERS + + conn.commit() + return jsonify({ + "id": email_id, + "status": "deleted" + }), 200, CORS_HEADERS + + except Exception as e: + if conn: + conn.rollback() + # current_app.logger.error(f"[delete_email] Database error: {str(e)}") + return jsonify({"errorCode": "DATABASE_ERROR"}), 500, CORS_HEADERS + + finally: + if cursor: + cursor.close() + if conn: + conn.close() + + +def exists_email(email_id: str) -> bool: + conn = None + cursor = None + try: + conn = init_db_connection() + with conn.cursor() as cursor: + cursor.execute(""" + SELECT 1 + FROM ailbl_user_email + WHERE id = %s; + """, (email_id,)) + row = cursor.fetchone() + return row is not None + except Exception as e: + # current_app.logger.error(f"[exists_email] DB Error: {e}") + return False + finally: + if cursor: + cursor.close() + if conn: + conn.close() diff --git a/apps/ailbl-users_email_insert-get.py b/apps/ailbl-users_email_insert-get.py new file mode 100644 index 0000000..98bc650 --- /dev/null +++ b/apps/ailbl-users_email_insert-get.py @@ -0,0 +1,200 @@ +import dataclasses +from typing import Optional +import typing +import crud +from flask import jsonify, request +from helpers import CORS_HEADERS, db_rows_to_array, kratos, str_to_bool +from pydantic import BaseModel, Field, ValidationError +from helpers import kratos, init_db_connection +from schemas import UserEmailRequest + + +@dataclasses.dataclass # Filter user bao nhieu email +class EmailFilter: + ids: typing.Optional[typing.List[str]] = None + email: typing.Optional[str] = None + provider: typing.Optional[str] = None + created_from: typing.Optional[str] = None + created_to: typing.Optional[str] = None + modified_from: typing.Optional[str] = None + modified_to: typing.Optional[str] = None + keywords: typing.Optional[str] = None + primary: typing.Optional[bool] = None + + @classmethod + def from_request_queries(cls) -> "EmailFilter": + return cls( + ids=request.args.getlist("filter[ids]"), + email=request.args.get("filter[email]"), + provider=request.args.get("filter[provider]"), + created_from=request.args.get("filter[created_from]"), + created_to=request.args.get("filter[created_to]"), + modified_from=request.args.get("filter[modified_from]"), + modified_to=request.args.get("filter[modified_to]"), + keywords=request.args.get("filter[key]"), + primary=str_to_bool(request.args.get("filter[primary]")) + ) + + +@dataclasses.dataclass +class Page: + page: int = 0 + size: int = 10 + asc: bool = False + + @classmethod + def from_request_queries(cls) -> "Page": + return Page( + page=int(request.args.get("page", 0)), + size=int(request.args.get("size", 10)), + asc=request.args.get("asc", type=str_to_bool) or False + ) + + +@dataclasses.dataclass +class EmailPage(Page): + sortby: typing.Optional[str] = None + filter: EmailFilter = dataclasses.field( + default_factory=EmailFilter.from_request_queries) + + @classmethod + def from_request_queries(cls) -> "EmailPage": + base = Page.from_request_queries() + return cls(**dataclasses.asdict(base), sortby=request.args.get("sortby")) + + +def main(): + """ + ```fission + { + "name": "email-users-insert-get-filter", + "http_triggers": { + "email-users-insert-get-filter-http": { + "url": "/ailbl/users/emails", + "methods": ["POST", "GET"] + } + } + } + ``` + """ + try: + if request.method == "POST": + return user_insert_email() + elif request.method == "GET": + return user_filter_emails() + else: + return {"error": "Method not allow"}, 405 + except Exception as ex: + return jsonify({"error": str(ex)}), 500 + + +def user_insert_email(): + user_id = request.headers.get("X-UserId") + if not user_id: + return jsonify({"errorCode": "USER_ID_REQUIRED"}), 400, CORS_HEADERS + try: + data = request.get_json() + if not data: + return jsonify({"errorCode": "NO_DATA_PROVIDED"}), 400, CORS_HEADERS + + parsed = UserEmailRequest(**data) # parsed luu du lieu gi ? + except ValidationError as e: + return jsonify({"errorCode": "VALIDATION_ERROR", "details": e.errors()}), 422, CORS_HEADERS + except Exception as e: + return jsonify({"errorCode": "BAD_REQUEST"}), 400, CORS_HEADERS + + try: + add_email, status_code, headers = crud.add_email_to_user( + user_id, parsed.email) + if parsed.is_primary: + # update email kratos + identity = kratos.get_identity(user_id) + traits = identity.traits + traits["email"] = parsed.email + res = kratos.update_identity( + id=user_id, + update_identity_body={ + "schema_id": identity.schema_id, + "traits": traits, + "state": identity.state, + }, + ) + return jsonify(add_email), status_code, headers + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +def user_filter_emails(): + paging = EmailPage.from_request_queries() + user_id = request.headers.get( + "X-UserId") # X-Fission lay tren path + if not user_id: + return jsonify({"errorCode": "USER_ID_REQUIRED"}), 400, CORS_HEADERS + + conn = None + try: + conn = init_db_connection() + with conn.cursor() as cursor: + records = __filter_email(cursor, paging, user_id) # Xu ly gi day ? + + return jsonify( + records, + ), 200, CORS_HEADERS + except Exception as e: + # current_app.logger.error(f"[filter_emails] DB Error: {e}") + return jsonify({"errorCode": "DATABASE_ERROR"}), 500, CORS_HEADERS + finally: + if conn: + conn.close() + + +def __filter_email(cursor, paging: EmailPage, user_id: str): + conditions = ["user_id = %(user_id)s"] + values = {"user_id": user_id} + if paging.filter.ids: + conditions.append("id = ANY(%(ids)s)") + values["ids"] = paging.filter.ids + if paging.filter.email: + conditions.append("LOWER(email) LIKE %(email)s") + values["email"] = f"%{paging.filter.email.lower()}%" + if paging.filter.provider: + conditions.append("LOWER(provider) LIKE %(provider)s") + values["provider"] = f"%{paging.filter.provider.lower()}%" + if paging.filter.created_from: + conditions.append("created >= %(created_from)s") + values["created_from"] = paging.filter.created_from + if paging.filter.created_to: + conditions.append("created <= %(created_to)s") + values["created_to"] = paging.filter.created_to + if paging.filter.modified_from: + conditions.append("modified >= %(modified_from)s") + values["modified_from"] = paging.filter.modified_from + if paging.filter.modified_to: + conditions.append("modified <= %(modified_to)s") + values["modified_to"] = paging.filter.modified_to + if paging.filter.keywords: + conditions.append( + "(LOWER(email) LIKE %(keywords)s OR LOWER(provider) LIKE %(keywords)s)") + values["keywords"] = f"%{paging.filter.keywords.lower()}%" + + where_clause = " AND ".join(conditions) + if where_clause: + where_clause = "WHERE " + where_clause + + order_clause = "" + if paging.sortby: + direction = "ASC" if paging.asc else "DESC" + order_clause = f"ORDER BY {paging.sortby} {direction}" + + sql = f""" + SELECT *, COUNT(*) OVER() AS total + FROM ailbl_user_email + {where_clause} + {order_clause} + LIMIT %(limit)s OFFSET %(offset)s + """ + values["limit"] = paging.size + values["offset"] = paging.page * paging.size + + cursor.execute(sql, values) + return db_rows_to_array(cursor, cursor.fetchall()) diff --git a/apps/crud.py b/apps/crud.py index 225daae..ae1fdf1 100644 --- a/apps/crud.py +++ b/apps/crud.py @@ -1,65 +1,286 @@ -import io - -from flask import Response -from helpers import S3_BUCKET, get_secret, s3_client -from PIL import Image +import json +import urllib.parse +import uuid +from typing import List +from flask import current_app +import requests +from helpers import CORS_HEADERS, get_secret, init_db_connection, kratos +import uuid as uuid_lib -# Create&Update function to upload or update user avatar S3/Minio -def update_or_create_avatar(user_id: str, file): +def get_user_email_from_kratos(user_id: str): # Get email from Kratos try: - file_data = file.read() - # Bản chất là đường dẫn trong bucket + tên file = user_id - object_name = f"{get_secret('S3_PREFIX')}/{user_id}" - result = s3_client.put_object( - Bucket=S3_BUCKET, - Key=object_name, - Body=io.BytesIO(file_data), - ContentLength=len(file_data), - ContentType=file.content_type, - ) - - return result, 200 - + current_app.logger.info( + f"[get_user_email_from_kratos] Checking Kratos connection for user_id={user_id}") + identity = kratos.get_identity(id=user_id) + current_app.logger.info( + f"[get_user_email_from_kratos] Kratos response: {identity}") + return identity.traits.get("email") except Exception as e: - return {"error": str(e)}, 500 + current_app.logger.error( + f"[get_user_email_from_kratos] Error connecting to Kratos: {e}") + return None -def get_avatar_url(user_id: str): # Read function to get user avatar from S3/Minio +def get_emails_by_user(user_id: str): + conn = None try: - response = s3_client.get_object( - Bucket=S3_BUCKET, - Key=f"{get_secret('S3_PREFIX')}/{user_id}" - ) - # image_data = response["body"].read(content_type) - image_data = response['Body'].read() - - with Image.open(io.BytesIO(image_data)) as img: - fmt = img.format.lower() # ví dụ: 'jpeg', 'png', 'webp' - content_type = f"image/{'jpeg' if fmt == 'jpg' else fmt}" - # return Response( - # io.BytesIO(image_data), - # content_type=content_type, - # direct_passthrough=True, - # ) - - return Response( - image_data, - content_type=content_type, - direct_passthrough=True - ), 200 - + conn = init_db_connection() + with conn.cursor() as cursor: + cursor.execute(""" + SELECT id, user_id, email, provider, created, modified + FROM ailbl_user_email + WHERE user_id = %s + """, (user_id,)) + rows = cursor.fetchall() + emails = [] + for row in rows: + emails.append({ + "id": row[0], + "user_id": row[1], + "email": row[2], + "provider": row[3], + "created": row[4].isoformat(), + "modified": row[5].isoformat() + }) + return emails, 200, CORS_HEADERS except Exception as e: - return {"error": str(e)}, 500 + current_app.logger.error(f"[get_emails_by_user] DB Error: {str(e)}") + return {"error": "DATABASE_ERROR"}, 500, CORS_HEADERS + finally: + if conn: + conn.close() -# Delete Function to delete user avatar from S3/Minio -def delete_avatar(user_id: str) -> dict: +# def create_or_update_config_account(email: str, key: str, type: str = "create"): +# url_get_config = f"{get_secret('API_CONFIG_USER')}/{get_secret('KEY_CONFIG_USER')}" +# if type == "update": +# url_get_config = f"{get_secret('API_CONFIG_USER')}/{get_secret('PREFIX_KEY_CONFIG_USER')}_{key}" +# headers = {} +# payload = {} + +# response = requests.request( +# "GET", url_get_config, headers=headers, data=payload) +# if response.status_code != 200: +# url_get_config = ( +# f"{get_secret('API_CONFIG_USER')}/{get_secret('KEY_CONFIG_USER')}" +# ) +# response = requests.request( +# "GET", url_get_config, headers=headers, data=payload +# ) +# if response.status_code != 200: +# return {"error": "Failed to retrieve configuration"}, response.status_code + +# config_user = response.json() +# if email != "": +# config_user["profile_setting"]["primary"]["email"] = email +# config_user["user_id"] = key +# json_str = json.dumps(config_user) # Convert dict to JSON string +# encoded_json = urllib.parse.quote(json_str) # URL encode + +# if ( +# type == "update" +# and url_get_config +# != f"{get_secret('API_CONFIG_USER')}/{get_secret('KEY_CONFIG_USER')}" +# ): +# url_save = f"{get_secret('API_CONFIG_USER')}/{get_secret('PREFIX_KEY_CONFIG_USER')}_{key}" +# payload = ( +# f"content={encoded_json}" +# f"&mime_type=application/json" +# f"&public=true" +# f"&active=true" +# f"&refer=GEOHUB" +# ) +# method = "PUT" +# else: +# url_save = f"{get_secret('API_CONFIG_USER')}" +# payload = f"content={encoded_json}&key={get_secret('PREFIX_KEY_CONFIG_USER')}_{key}&mime_type=application/json&public=true&active=true" +# method = "POST" +# headers = {"Content-Type": "application/x-www-form-urlencoded"} +# response_save = requests.request( +# method, url_save, headers=headers, data=payload) + +# return response_save.json(), response_save.status_code + + +# def get_config_account(key: str): +# url_get_config = ( +# f"{get_secret('API_CONFIG_USER')}/{get_secret('PREFIX_KEY_CONFIG_USER')}_{key}" +# ) +# headers = {} +# payload = {} +# response = requests.request( +# "GET", url_get_config, headers=headers, data=payload) + +# if response.status_code != 200: +# email = get_user_email_from_kratos(key) +# if not email: +# return {"error": "User email not found"}, 404 +# res, status = create_or_update_config_account( +# email=email, key=key, type="create" +# ) +# if status != 200: +# return res, status + +# email_new, email_status, headers = add_email_to_user( +# user_id=key, email=email) + +# if email_status != 200: +# return email_new, email_status +# response = requests.request( +# "GET", url_get_config, headers=headers, data=payload +# ) + +# return response.json(), response.status_code + + +# def create_config_account_if_not_exists(user_id: str): +# url_get_config = ( +# f"{get_secret('API_CONFIG_USER')}/{get_secret('PREFIX_KEY_CONFIG_USER')}_{user_id}" +# ) +# headers = {} +# payload = {} +# response = requests.request( +# "GET", url_get_config, headers=headers, data=payload) +# if response.status_code != 200: +# email = get_user_email_from_kratos(user_id) +# if not email: +# return {"error": "User email not found"}, 404 +# res, status = create_or_update_config_account( +# email=email, key=user_id, type="create" +# ) +# if status != 200: +# return res, status + +# email_new, email_status, headers = add_email_to_user( +# user_id=user_id, email=email) + +# if email_status != 200: +# return email_new, email_status +# return {"message": "Config account exists or created successfully"}, 200 + + +def add_email_to_user(user_id: str, email: str): # Add email + conn = None + cursor = None try: - result = s3_client.delete_object( - Bucket=S3_BUCKET, - Key=f"{get_secret('S3_PREFIX')}/{user_id}" - ) - return result, 200 + conn = init_db_connection() + with conn.cursor() as cursor: + cursor.execute(""" + SELECT user_id FROM ailbl_user_email WHERE email = %s; + """, (email,)) + existing = cursor.fetchone() + if existing: + return {"errorCode": "EMAIL_ALREADY_EXISTS"}, 409, CORS_HEADERS + + id = str(uuid_lib.uuid4()) + provider = auto_detect_provider(email) + cursor.execute(""" + INSERT INTO ailbl_user_email (id, user_id, email, provider, created, modified) + VALUES (%s, %s, %s, %s, now(), now()) + RETURNING id; + """, (id, user_id, email, provider)) + email_id = cursor.fetchone()[0] + conn.commit() + + return { + "id": email_id, + "user_id": user_id, + "email": email, + "provider": provider, + "status": "added" + }, 200, CORS_HEADERS except Exception as e: - return {"error": str(e)}, 500 + current_app.logger.error(f"[add_email_to_user] DB Error: {e}") + return {"error": "DATABASE_ERROR"}, 500, CORS_HEADERS + finally: + if cursor: + cursor.close() + if conn: + conn.close() + + +# Check provide lay phan duoi de xem ai la nha cung cap +def auto_detect_provider(email: str) -> str: + domain_part = email.split('@')[-1] + return domain_part.split('.')[0] + + +def get_max_mail(): # Setting config , setting user toi da duoc luu bao nhieu email + url = f"{get_secret('KEY_MAX_EMAIL_PER_USER')}" + try: + max_email = requests.get(url).json() + return int(max_email) + except Exception as e: + current_app.logger.error( + f"[get_max_email_per_user] Error retrieving max email per user: {str(e)}") + return None + + +def cout_email(user_id): # kiem tra email cua 1 user, roi dem email cua user do roi tra ra + conn = None + try: + conn = init_db_connection() + with conn.cursor() as cursor: + cursor.execute(""" + SELECT COUNT(*) FROM ailbl_user_email + WHERE user_id = %s + """, (user_id,)) + count = cursor.fetchone()[0] + return count + except Exception as e: + current_app.logger.error(f"[count_user_emails] DB Error: {str(e)}") + return 0 + finally: + if conn: + conn.close() + current_app.logger.info("Closed DB connection") + + +def get_email_by_id(email_id: str): #Admin update email, update email phai goi ra duoc + conn = None + cursor = None + try: + conn = init_db_connection() + with conn.cursor() as cursor: + cursor.execute(""" + SELECT email + FROM ailbl_user_email + WHERE id = %s; + """, (email_id,)) + row = cursor.fetchone() + if row: + return row[0] + else: + return None + except Exception as e: + current_app.logger.error(f"[get_email_by_id] DB Error: {e}") + return {"error": "DATABASE_ERROR"}, 500 + finally: + if cursor: + cursor.close() + if conn: + conn.close() + + +def exists_email(email_id: str) -> bool: + conn = None + cursor = None + try: + conn = init_db_connection() + with conn.cursor() as cursor: + cursor.execute(""" + SELECT 1 + FROM ailbl_user_email + WHERE id = %s; + """, (email_id,)) + row = cursor.fetchone() + return row is not None + except Exception as e: + current_app.logger.error(f"[exists_email] DB Error: {e}") + return False + finally: + if cursor: + cursor.close() + if conn: + conn.close() \ No newline at end of file diff --git a/apps/helpers.py b/apps/helpers.py index 27594f9..2147272 100644 --- a/apps/helpers.py +++ b/apps/helpers.py @@ -1,35 +1,131 @@ +import datetime import logging -import boto3 -SECRET_NAME = "fission-ailbl-user-avatar-env" +import socket +import typing +import ory_kratos_client +from ory_kratos_client.api import identity_api +from ory_kratos_client.configuration import Configuration +import psycopg2 +from flask import current_app +from psycopg2.extras import LoggingConnection + +CORS_HEADERS = { + "Content-Type": "application/json", +} +SECRET_NAME = "fission-ailbl-user-email-env" +CONFIG_NAME = "fission-eom-notification-config" K8S_NAMESPACE = "default" +KRATOS_ADMIN_ENDPOINT_CONFIG_KEY = "KRATOS_ADMIN_ENDPOINT" + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + + +def init_db_connection(): + db_host = get_secret("PG_HOST", "locahost") + db_port = int(get_secret("PG_PORT", 55432)) + + if not check_port_open(ip=db_host, port=db_port): + raise Exception( + f"Establishing A Database Connection. {db_host}:{db_port}") + + # options = get_secret("PG_DBSCHEMA") + # if options: + # options = f"-c search_path={options}" # if specific db schema + conn = psycopg2.connect( + database=get_secret("PG_DB", "postgres"), + user=get_secret("PG_USER", "postgres"), + password=get_secret("PG_PASS", "secret"), + host=get_secret("PG_HOST", "127.0.0.1"), + port=int(get_secret("PG_PORT", 5432)), + # options=options, + # cursor_factory=NamedTupleCursor, + connection_factory=LoggingConnection, + ) + conn.initialize(logger) + return conn + + +# def db_row_to_dict(cursor, row): +# record = {} +# for i, column in enumerate(cursor.description): +# data = row[i] +# if isinstance(data, datetime.datetime): +# data = data.isoformat() +# record[column.name] = data +# return record + +def db_row_to_dict(cursor, row): + record = {} + for i, column in enumerate(cursor.description): + data = row[i] + if isinstance(data, (datetime.datetime, datetime.date)): + data = data.isoformat() + record[column.name] = data + return record + + +def db_rows_to_array(cursor, rows): + return [db_row_to_dict(cursor, row) for row in rows] + + def get_current_namespace() -> str: try: with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "r") as f: namespace = f.read() - except: + except Exception as err: + current_app.logger.error(err) namespace = K8S_NAMESPACE return str(namespace) -def get_secret(key: str, default=None) -> str: +def get_secret(key: str, default=None): namespace = get_current_namespace() path = f"/secrets/{namespace}/{SECRET_NAME}/{key}" try: with open(path, "r") as f: return f.read() - except: + except Exception as err: + current_app.logger.error(path, err) return default -S3_BUCKET = get_secret("S3_BUCKET") -S3_PREFIX = get_secret("S3_PREFIX") +def get_config(key: str, default=None): + namespace = get_current_namespace() + path = f"/configs/{namespace}/{CONFIG_NAME}/{key}" + try: + with open(path, "r") as f: + return f.read() + except Exception as err: + current_app.logger.error(path, err) + return default -s3_client = boto3.client( - "s3", - endpoint_url=get_secret("S3_ENDPOINT_URL"), - aws_access_key_id=get_secret("S3_ACCESS_KEY_ID"), - aws_secret_access_key=get_secret("S3_SECRET_ACCESS_KEY"), - config=boto3.session.Config(signature_version="s3v4"), -) + +def str_to_bool(value: str | None) -> typing.Optional[bool]: + if value is None: + return None + val = value.strip().lower() + if val in ("true", "1", "yes"): + return True + if val in ("false", "0", "no"): + return False + return None + + +def check_port_open(ip: str, port: int, timeout: int = 30): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(timeout) + result = s.connect_ex((ip, port)) + return result == 0 + except Exception as err: + current_app.logger.err(f"Check port open error: {err}") + return False + + +kratos_config = Configuration( + host=get_secret(KRATOS_ADMIN_ENDPOINT_CONFIG_KEY)) + +kratos = identity_api.IdentityApi(ory_kratos_client.ApiClient(kratos_config)) diff --git a/apps/requirements.txt b/apps/requirements.txt index f922a61..987b24a 100644 --- a/apps/requirements.txt +++ b/apps/requirements.txt @@ -1,6 +1,8 @@ -# Flask==3.1.0 -# psycopg2-binary==2.9.10 -# pydantic==2.11.3 -# minio==7.2.5 -# Pillow==10.4.0 -# boto3==1.35.70 \ No newline at end of file +Flask==2.2.* +requests==2.32.3 +psycopg2-binary==2.9.10 +sqlalchemy==2.0.40 +flask-sqlalchemy==3.1.1 +email-validator==2.2.0 +pydantic==2.11.3 +ory-kratos-client==1.3.8 \ No newline at end of file diff --git a/apps/schemas.py b/apps/schemas.py index f223a07..8d730bb 100644 --- a/apps/schemas.py +++ b/apps/schemas.py @@ -1,33 +1,37 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, EmailStr, root_validator from typing import Optional -from enum import IntEnum + +# Validate dau vao email + +class UserEmailRequest(BaseModel): + email: EmailStr = Field(..., description="Email address") + provider: Optional[str] = Field(None, description="Email service provider") + is_primary: Optional[bool] = Field(None, description= "is primary email ") + + @root_validator(pre=True) + def auto_detect_provider(cls, values): + email = values.get("email") + provider = values.get("provider") + + if not provider and email: + domain_part = email.split('@')[-1] + values["provider"] = domain_part.split('.')[0] + + return values -class TagKind(IntEnum): - ProjectGroup = 1 - ProjectData = 2 - ProjectMember = 3 - ProjectDiscussionTopic = 4 - Project = 5 - Ticket = 6 +class UserEmailUpdateRequest(BaseModel): + email: Optional[EmailStr] = Field(None, description="Email address") + provider: Optional[str] = Field(None, description="Email service provider") + is_primary: Optional[bool] = Field(None, description= "is primary email ") + @root_validator(pre=True) + def auto_detect_provider(cls, values): + email = values.get("email") + provider = values.get("provider") -class TagRequest(BaseModel): - tag: str = Field(..., max_length=128) - kind: TagKind - ref: Optional[str] = Field(default=None, max_length=36) - primary_color: Optional[str] = Field(default=None, max_length=8) - secondary_color: Optional[str] = Field(default=None, max_length=8) + if not provider and email: + domain_part = email.split('@')[-1] + values["provider"] = domain_part.split('.')[0] - -class TagRequestUpdate(BaseModel): - tag: str = Field(..., max_length=128) - kind: TagKind - ref: Optional[str] = Field(default=None, max_length=36) - primary_color: Optional[str] = Field(default=None, max_length=8) - secondary_color: Optional[str] = Field(default=None, max_length=8) - - -class TagRefRequest(BaseModel): - ref: str = Field(..., max_length=64) - sub_ref: Optional[str] = Field(default=None, max_length=1024) + return values \ No newline at end of file diff --git a/specs/fission-deployment-config.yaml b/specs/fission-deployment-config.yaml index 1c83a90..73e1847 100644 --- a/specs/fission-deployment-config.yaml +++ b/specs/fission-deployment-config.yaml @@ -3,5 +3,5 @@ # Do not edit the UID below: that will break 'fission spec apply' apiVersion: fission.io/v1 kind: DeploymentConfig -name: py-ailbl-tag -uid: dfd4b9c6-7e2f-4f57-aad5-38b34209eeb1 +name: py-ailbl-user-email +uid: 93d0e78a-2e6f-4137-9651-e210a3e4623a