From ded2c4f20c00f503de5860ad5be0fec811fec452 Mon Sep 17 00:00:00 2001 From: Nanthawat <87483528+anannnchim@users.noreply.github.com> Date: Thu, 19 Sep 2024 16:39:54 +0700 Subject: [PATCH] Chim/feature/setup notification repository (#284) ## Describe your changes Add fcm token repository and Api endpoint for register and unregister fcm token. Add a new domain FCMToken for representing fcm token. ## Issue ticket number and link - [Jira](https://fireapp-emergiq-2024.atlassian.net/jira/software/projects/FIR/boards/2?selectedIssue=FIR-99) - [Documentation](https://fireapp-emergiq-2024.atlassian.net/wiki/spaces/fireapp202/pages/99713028/Notification+Feature) --- controllers/v2/__init__.py | 3 +- controllers/v2/fcm_tokens/__init__.py | 1 + controllers/v2/fcm_tokens/api.py | 93 ++++++++++++++++++++ controllers/v2/fcm_tokens/response_models.py | 6 ++ domain/entity/__init__.py | 3 +- domain/entity/fcm_tokens.py | 18 ++++ exception/__init__.py | 1 + exception/invalid_token_exception.py | 10 +++ repository/fcm_token_repository.py | 57 ++++++++++++ repository/user_repository.py | 18 +++- 10 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 controllers/v2/fcm_tokens/__init__.py create mode 100644 controllers/v2/fcm_tokens/api.py create mode 100644 controllers/v2/fcm_tokens/response_models.py create mode 100644 domain/entity/fcm_tokens.py create mode 100644 exception/invalid_token_exception.py create mode 100644 repository/fcm_token_repository.py diff --git a/controllers/v2/__init__.py b/controllers/v2/__init__.py index 245b41ff..9af86e57 100644 --- a/controllers/v2/__init__.py +++ b/controllers/v2/__init__.py @@ -3,4 +3,5 @@ from .diet import * from .v2_blueprint import v2_api, v2_bp from .unavailability import * -from .shift import * \ No newline at end of file +from .shift import * +from .fcm_tokens import * \ No newline at end of file diff --git a/controllers/v2/fcm_tokens/__init__.py b/controllers/v2/fcm_tokens/__init__.py new file mode 100644 index 00000000..7c7b600d --- /dev/null +++ b/controllers/v2/fcm_tokens/__init__.py @@ -0,0 +1 @@ +from .api import FCMToken \ No newline at end of file diff --git a/controllers/v2/fcm_tokens/api.py b/controllers/v2/fcm_tokens/api.py new file mode 100644 index 00000000..e1dbc3d0 --- /dev/null +++ b/controllers/v2/fcm_tokens/api.py @@ -0,0 +1,93 @@ +import logging +from controllers.v2.fcm_tokens.response_models import response_model +from controllers.v2.v2_blueprint import v2_api +from exception import InvalidTokenError +from flask_restful import Resource, reqparse, marshal_with +from repository.fcm_token_repository import FCMTokenRepository +from repository.user_repository import UserRepository +from services.jwk import requires_auth, JWKService + + +parser = reqparse.RequestParser() +parser.add_argument('token', type=str, required=True, help ="Token must be provided.") +parser.add_argument('device_type', type=str, required=True, help ="DeviceType must be provided.") +unregister_parser = reqparse.RequestParser() +unregister_parser.add_argument('token', type=str, required=True, help ="Token must be provided.") + + +class FCMToken(Resource): + + token_repository: FCMTokenRepository + user_repository: UserRepository + + def __init__( + self, + token_repository: FCMTokenRepository = FCMTokenRepository(), + user_repository: UserRepository = UserRepository() + ): + self.token_repository = token_repository + self.user_repository = user_repository + + @requires_auth + @marshal_with(response_model) + def post(self, user_id: int): + + # Decode authenticated user id + authenticated_user_id = JWKService.decode_user_id() + + # Check if they are matched + if authenticated_user_id != user_id: + return {"message": "User ID mismatch"}, 403 + + # Check if decoding fail + args = parser.parse_args() + fcm_token = args['token'] + device_type = args['device_type'] + + try: + # 1. Check if the user exist + if not self.user_repository.check_user_exists(user_id): + return {"message": "User not found"}, 400 + + # 2. Register the token for the user + self.token_repository.register_token(user_id, fcm_token, device_type) + return {"message": "FCM token registered successfully"}, 200 + + except Exception as e: + + logging.error(f"Error registering FCM token: {e}") + return {"message": "Internal server error"}, 500 + + @requires_auth + @marshal_with(response_model) + def delete(self, user_id: int): + + # Decode authenticated user id + authenticated_user_id = JWKService.decode_user_id() + + # Check if they are matched + if authenticated_user_id != user_id: + return {"message": "User ID mismatch"}, 403 + + args = unregister_parser.parse_args() + fcm_token = args['token'] + + try: + # Check if user exist + if not self.user_repository.check_user_exists(user_id): + return {"message": "User not found"}, 400 + + # Unregister the token + self.token_repository.unregister_token(user_id, fcm_token) + return {"message": "FCM token unregistered successfully"}, 200 + + except InvalidTokenError as e: + logging.error(f"Error unregistering FCM token: {e}") + return {"message": "Invalid FCM token"}, 400 + + except Exception as e: + logging.error(f"Error unregistering FCM token: {e}") + return {"message": "Internal server error"}, 500 + + +v2_api.add_resource(FCMToken,'/v2/user//token') diff --git a/controllers/v2/fcm_tokens/response_models.py b/controllers/v2/fcm_tokens/response_models.py new file mode 100644 index 00000000..ddf89503 --- /dev/null +++ b/controllers/v2/fcm_tokens/response_models.py @@ -0,0 +1,6 @@ +from flask_restful import fields + + +response_model = { + 'message': fields.String +} diff --git a/domain/entity/__init__.py b/domain/entity/__init__.py index 794360e8..b8d23bd0 100644 --- a/domain/entity/__init__.py +++ b/domain/entity/__init__.py @@ -13,4 +13,5 @@ from .unavailability_time import UnavailabilityTime from .chatbot_input import ChatbotInput from .shift_request import ShiftRequest -from .shift_request_volunteer import ShiftRequestVolunteer \ No newline at end of file +from .shift_request_volunteer import ShiftRequestVolunteer +from .fcm_tokens import FCMToken \ No newline at end of file diff --git a/domain/entity/fcm_tokens.py b/domain/entity/fcm_tokens.py new file mode 100644 index 00000000..adb354e8 --- /dev/null +++ b/domain/entity/fcm_tokens.py @@ -0,0 +1,18 @@ +from datetime import datetime +from domain.base import Base +from sqlalchemy import Column, String, ForeignKey, Integer, Boolean, TIMESTAMP +from sqlalchemy.orm import relationship + + +class FCMToken(Base): + + __tablename__ = 'fcm_tokens' + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey('user.id'), name='user_id', nullable=False) + fcm_token = Column(String(255), name='fcm_token', nullable=False) + device_type = Column(String(50), name='device_type', nullable=False) + created_at = Column(TIMESTAMP, name='created_at', default=datetime.now(), nullable=False) + updated_at = Column(TIMESTAMP, name='updated_at', default=datetime.now(), onupdate=datetime.now, nullable=False) + is_active = Column(Boolean, name='is_active', default=True, nullable=False) + + user = relationship("User") diff --git a/exception/__init__.py b/exception/__init__.py index fd6bb24f..d606bf96 100644 --- a/exception/__init__.py +++ b/exception/__init__.py @@ -1,2 +1,3 @@ from .client_exception import EventNotFoundError, InvalidArgumentError +from .invalid_token_exception import InvalidTokenError diff --git a/exception/invalid_token_exception.py b/exception/invalid_token_exception.py new file mode 100644 index 00000000..0d7747d1 --- /dev/null +++ b/exception/invalid_token_exception.py @@ -0,0 +1,10 @@ +from .fireapp_exception import FireAppException + + +class InvalidTokenError(FireAppException): + """ + A custom exception used to handle invalid tokens for the user. + """ + def __init__(self, message='Invalid token for the user'): + self.message = message + super().__init__(self.message) diff --git a/repository/fcm_token_repository.py b/repository/fcm_token_repository.py new file mode 100644 index 00000000..cbc2f6e9 --- /dev/null +++ b/repository/fcm_token_repository.py @@ -0,0 +1,57 @@ +import logging +from datetime import datetime +from domain.entity.fcm_tokens import FCMToken +from domain import session_scope +from exception import InvalidTokenError +from sqlalchemy.exc import SQLAlchemyError + + +class FCMTokenRepository: + def __init__(self): + pass + + def register_token(self, user_id: int, fcm_token: str, device_type: str) -> None: + + with session_scope() as session: + try: + # 1. Check if there is user given userId + existing_token = session.query(FCMToken).filter_by(user_id=user_id, fcm_token=fcm_token).first() + + if existing_token: + existing_token.updated_at = datetime.now() + session.commit() + logging.info(f" A New token is registered for user {user_id}") + else: + new_token = FCMToken( + user_id=user_id, + fcm_token=fcm_token, + device_type=device_type, + created_at=datetime.now(), + updated_at=datetime.now() + ) + session.add(new_token) + session.commit() + logging.info(f" A New token is registered for user {user_id}") + + except Exception as e: + logging.error(f"Error registering FCM token for user {user_id}: {e}") + session.rollback() + + def unregister_token(self, user_id: int, fcm_token: str) -> None: + + with session_scope() as session: + try: + existing_token = session.query(FCMToken).filter_by(user_id=user_id, fcm_token=fcm_token).first() + + if existing_token: + session.delete(existing_token) + session.commit() + logging.info(f" Unregistered the token for user {user_id}") + else: + logging.error(f"Invalid token for user {user_id}") + raise InvalidTokenError(f"Invalid token for user {user_id}") + + except SQLAlchemyError as e: + logging.error(f"Database error while unregistering FCM token for user {user_id}:{e}") + session.rollback() + raise e diff --git a/repository/user_repository.py b/repository/user_repository.py index 7ecdc6bc..8e116d5a 100644 --- a/repository/user_repository.py +++ b/repository/user_repository.py @@ -1,4 +1,20 @@ -from domain import User, UserType +from domain import User, UserType, session_scope + + +class UserRepository: + + def __init__(self): + pass + + def check_user_exists(self, user_id: int) -> bool: + """ + Check if a user exists in the database + :param user_id + :return bool: True if the user exists, False otherwise + """ + with session_scope() as session: + user = session.query(User).filter_by(id=user_id).first() + return user is not None def get_user_role(session, user_id):