diff --git a/ApiEvents/AuthServiceApi/__init__.py b/ApiEvents/AuthServiceApi/__init__.py new file mode 100644 index 0000000..c9b23a0 --- /dev/null +++ b/ApiEvents/AuthServiceApi/__init__.py @@ -0,0 +1,5 @@ +"""Auth Service API initialization""" + +from .route_configs import get_route_configs + +__all__ = ["get_route_configs"] diff --git a/ApiEvents/AuthServiceApi/auth/auth.py b/ApiEvents/AuthServiceApi/auth/auth.py new file mode 100644 index 0000000..f0b3f20 --- /dev/null +++ b/ApiEvents/AuthServiceApi/auth/auth.py @@ -0,0 +1,977 @@ +""" +Authentication related API endpoints. +""" + +from typing import TYPE_CHECKING, Union, Optional, Dict, Any + +# Regular imports (non-TYPE_CHECKING) +from ApiEvents.abstract_class import ( + MethodToEvent, + RouteFactoryConfig, + EndpointFactoryConfig, +) +from ApiEvents.base_request_model import ( + BaseRequestModel, + DictRequestModel, + SuccessResponse, +) + +if TYPE_CHECKING: + from fastapi import Request + from ApiServices.Token.token_handler import OccupantTokenObject, EmployeeTokenObject + from Schemas import ( + Login, + ChangePassword, + CreatePassword, + Forgot, + Logout, + Remember, + EmployeeSelection, + OccupantSelection, + ) + +from ApiLibrary.common.line_number import get_line_number_for_error +from ApiServices.Login.user_login_handler import UserLoginModule +from ApiValidations.Request import ( + Login, + Logout, + Remember, + Forgot, + ChangePassword, + CreatePassword, + EmployeeSelection, + OccupantSelection, +) +from Services.PostgresDb.Models.alchemy_response import AlchemyJsonResponse +from ApiValidations.Response import AccountRecordResponse +from ApiServices.Token.token_handler import AccessToken, TokenService +from ErrorHandlers import HTTPExceptionApi + +# Type aliases for common types +TokenDictType = Union["EmployeeTokenObject", "OccupantTokenObject"] + + +class LoginRequestModel(BaseRequestModel[Login]): + """Request model for login endpoint.""" + + pass + + +class LogoutRequestModel(BaseRequestModel[Logout]): + """Request model for logout endpoint.""" + + pass + + +class RememberRequestModel(BaseRequestModel[Remember]): + """Request model for remember token endpoint.""" + + pass + + +class ForgotRequestModel(BaseRequestModel[Forgot]): + """Request model for forgot password endpoint.""" + + pass + + +class ChangePasswordRequestModel(BaseRequestModel[ChangePassword]): + """Request model for change password endpoint.""" + + pass + + +class CreatePasswordRequestModel(BaseRequestModel[CreatePassword]): + """Request model for create password endpoint.""" + + pass + + +class EmployeeSelectionRequestModel(BaseRequestModel[EmployeeSelection]): + """Request model for employee selection endpoint.""" + + pass + + +class OccupantSelectionRequestModel(BaseRequestModel[OccupantSelection]): + """Request model for occupant selection endpoint.""" + + pass + + +class AuthenticationLoginEventMethods(MethodToEvent): + event_type = "LOGIN" + event_description = "Login via domain and access key : [email] | [phone]" + event_category = "AUTHENTICATION" + __event_keys__ = { + "e672846d-cc45-4d97-85d5-6f96747fac67": "authentication_login_with_domain_and_creds", + } + __event_validation__ = { + "e672846d-cc45-4d97-85d5-6f96747fac67": SuccessResponse, + } + + @classmethod + async def authentication_login_with_domain_and_creds( + cls, request: "Request", data: LoginRequestModel + ): + """ + Authenticate user with domain and credentials. + + Args: + request: FastAPI request object + data: Request body containing login credentials + { + "domain": "evyos.com.tr", + "access_key": "karatay.berkay.sup@evyos.com.tr", + "password": "string", + "remember_me": false + } + Returns: + SuccessResponse containing authentication token and user info + """ + # Create login module instance + login_module = UserLoginModule(request=request) + + # Get token from login module + token = await login_module.login_user_via_credentials(access_data=data) + + # Return response with token and headers + return { + "token": token, + "headers": dict(request.headers), + } + + +class AuthenticationSelectEventMethods(MethodToEvent): + event_type = "LOGIN" + event_description = "Select Employee Duty or Occupant Type" + event_category = "AUTHENTICATION" + + __event_keys__ = { + "cee96b9b-8487-4e9f-aaed-2e8c79687bf9": "authentication_select_company_or_occupant_type", + } + # __event_validation__ = { + # "cee96b9b-8487-4e9f-aaed-2e8c79687bf9": "authentication_select_company_or_occupant_type", + # } + + @classmethod + def _handle_employee_selection( + cls, + data: EmployeeSelectionRequestModel, + token_dict: TokenDictType, + request: "Request", + ): + return + # """Handle employee company selection""" + # Users.client_arrow = DateTimeLocal(is_client=True, timezone=token_dict.timezone) + # if data.company_uu_id not in token_dict.companies_uu_id_list: + # return ResponseHandler.unauthorized( + # "Company not found in user's company list" + # ) + + # selected_company = Companies.filter_one( + # Companies.uu_id == data.company_uu_id + # ).data + # if not selected_company: + # return ResponseHandler.not_found("Company not found") + + # # Get department IDs for the company + # department_ids = [ + # dept.id + # for dept in Departments.filter_all( + # Departments.company_id == selected_company.id + # ).data + # ] + + # # Get duties IDs for the company + # duties_ids = [ + # duty.id + # for duty in Duties.filter_all(Duties.company_id == selected_company.id).data + # ] + + # # Get staff IDs + # staff_ids = [ + # staff.id for staff in Staff.filter_all(Staff.duties_id.in_(duties_ids)).data + # ] + + # # Get employee + # employee = Employees.filter_one( + # Employees.people_id == token_dict.person_id, + # Employees.staff_id.in_(staff_ids), + # ).data + + # if not employee: + # return ResponseHandler.not_found("Employee not found") + + # # Get reachable events + # reachable_event_list_id = Event2Employee.get_event_id_by_employee_id( + # employee_id=employee.id + # ) + + # # Get staff and duties + # staff = Staff.filter_one(Staff.id == employee.staff_id).data + # duties = Duties.filter_one(Duties.id == staff.duties_id).data + # department = Departments.filter_one(Departments.id == duties.department_id).data + + # # Get bulk duty + # bulk_id = Duty.filter_by_one(system=True, duty_code="BULK").data + # bulk_duty_id = Duties.filter_by_one( + # company_id=selected_company.id, + # duties_id=bulk_id.id, + # **Duties.valid_record_dict, + # ).data + + # # Create company token + # company_token = CompanyToken( + # company_uu_id=selected_company.uu_id.__str__(), + # company_id=selected_company.id, + # department_id=department.id, + # department_uu_id=department.uu_id.__str__(), + # duty_id=duties.id, + # duty_uu_id=duties.uu_id.__str__(), + # bulk_duties_id=bulk_duty_id.id, + # staff_id=staff.id, + # staff_uu_id=staff.uu_id.__str__(), + # employee_id=employee.id, + # employee_uu_id=employee.uu_id.__str__(), + # reachable_event_list_id=reachable_event_list_id, + # ) + + # # Update Redis + # AuthActions.update_selected_to_redis(request=request, add_payload=company_token) + # return ResponseHandler.success("Company selected successfully") + + @classmethod + def _handle_occupant_selection( + cls, + data: OccupantSelectionRequestModel, + token_dict: TokenDictType, + request: "Request", + ): + """Handle selection of company or occupant type""" + try: + if isinstance(token_dict, EmployeeTokenObject): + return cls._handle_employee_selection(data, token_dict, request) + elif isinstance(token_dict, OccupantTokenObject): + return cls._handle_occupant_selection(data, token_dict, request) + raise HTTPExceptionApi( + error_code="HTTP_400_BAD_REQUEST", + lang=token_dict.lang, + loc=get_line_number_for_error(), + ) + except Exception as e: + return ResponseHandler.error( + str(e), status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + @classmethod + def authentication_select_company_or_occupant_type( + cls, + request: "Request", + data: Union[EmployeeSelectionRequestModel, OccupantSelectionRequestModel], + token_dict: TokenDictType, + ): + """Handle selection of company or occupant type""" + # try: + # if isinstance(token_dict, EmployeeTokenObject): + # return cls._handle_employee_selection(data, token_dict, request) + # elif isinstance(token_dict, OccupantTokenObject): + # return cls._handle_occupant_selection(data, token_dict, request) + # return ResponseHandler.error( + # "Invalid token type", status_code=status.HTTP_400_BAD_REQUEST + # ) + # except Exception as e: + # return ResponseHandler.error( + # str(e), status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + # ) + return + + +class AuthenticationCheckTokenEventMethods(MethodToEvent): + event_type = "LOGIN" + event_description = "Check Token is valid for user" + event_category = "AUTHENTICATION" + + __event_keys__ = { + "73d77e45-a33f-4f12-909e-3b56f00d8a12": "authentication_check_token_is_valid", + } + # __event_validation__ = { + # "73d77e45-a33f-4f12-909e-3b56f00d8a12": "authentication_check_token_is_valid", + # } + + @classmethod + def authentication_check_token_is_valid( + cls, request: "Request", data: DictRequestModel + ): + # try: + # if RedisActions.get_object_via_access_key(request=request): + # return ResponseHandler.success("Access Token is valid") + # except HTTPException: + # return ResponseHandler.unauthorized("Access Token is NOT valid") + return + + +class AuthenticationRefreshEventMethods(MethodToEvent): + event_type = "LOGIN" + event_description = "Refresh user info using access token" + event_category = "AUTHENTICATION" + + __event_keys__ = { + "48379bb2-ba81-4d8e-a9dd-58837cfcbf67": "authentication_refresh_user_info", + } + # __event_validation__ = { + # "48379bb2-ba81-4d8e-a9dd-58837cfcbf67": AuthenticationRefreshResponse, + # } + + @classmethod + def authentication_refresh_user_info( + cls, + request: "Request", + token_dict: TokenDictType, + data: DictRequestModel, + ): + # try: + # access_token = request.headers.get(Auth.ACCESS_TOKEN_TAG) + # if not access_token: + # return ResponseHandler.unauthorized() + + # # Get user and token info + # found_user = Users.filter_one(Users.uu_id == token_dict.user_uu_id).data + # if not found_user: + # return ResponseHandler.not_found("User not found") + # user_token = UsersTokens.filter_one( + # UsersTokens.domain == found_user.domain_name, + # UsersTokens.user_id == found_user.id, + # UsersTokens.token_type == "RememberMe", + # ).data + # response_data = { + # "access_token": access_token, + # "refresh_token": getattr(user_token, "token", None), + # "user": found_user.get_dict(), + # } + # return ResponseHandler.success( + # "User info refreshed successfully", + # data=response_data, + # ) + # except Exception as e: + # return ResponseHandler.error(str(e)) + return + + +class AuthenticationChangePasswordEventMethods(MethodToEvent): + event_type = "LOGIN" + event_description = "Change password with access token" + event_category = "AUTHENTICATION" + + __event_keys__ = { + "f09f7c1a-bee6-4e32-8444-962ec8f39091": "authentication_change_password", + } + # __event_validation__ = { + # "f09f7c1a-bee6-4e32-8444-962ec8f39091": "authentication_change_password", + # } + + @classmethod + def authentication_change_password( + cls, + request: "Request", + data: ChangePasswordRequestModel, + token_dict: TokenDictType, + ): + # try: + # if not isinstance(token_dict, EmployeeTokenObject): + # return ResponseHandler.unauthorized( + # "Only employees can change password" + # ) + + # found_user = Users.filter_one(Users.uu_id == token_dict.user_uu_id).data + # if not found_user: + # return ResponseHandler.not_found("User not found") + + # if not found_user.check_password(data.old_password): + # # UserLogger.log_password_change( + # # request, + # # found_user.id, + # # "change", + # # success=False, + # # error="Invalid old password", + # # ) + # return ResponseHandler.unauthorized("Old password is incorrect") + + # found_user.set_password(data.new_password) + # # UserLogger.log_password_change( + # # request, found_user.id, "change", success=True + # # ) + + # return ResponseHandler.success("Password changed successfully") + # except Exception as e: + # # UserLogger.log_password_change( + # # request, + # # found_user.id if found_user else None, + # # "change", + # # success=False, + # # error=str(e), + # # ) + # return ResponseHandler.error(str(e)) + return + + +class AuthenticationCreatePasswordEventMethods(MethodToEvent): + + event_type = "LOGIN" + event_description = "Create password with password reset token requested via email" + event_category = "AUTHENTICATION" + + __event_keys__ = { + "c519f9af-92e1-47b2-abf7-5a3316d075f7": "authentication_create_password", + } + # __event_validation__ = { + # "c519f9af-92e1-47b2-abf7-5a3316d075f7": "authentication_create_password", + # } + + @classmethod + def authentication_create_password( + cls, request: "Request", data: CreatePasswordRequestModel + ): + + # if not data.re_password == data.password: + # raise HTTPException( + # status_code=status.HTTP_406_NOT_ACCEPTABLE, detail="Password must match" + # ) + # if found_user := Users.filter_one( + # Users.password_token == data.password_token + # ).data: + # found_user: Users = found_user + # found_user.create_password(found_user=found_user, password=data.password) + # found_user.password_token = "" + # found_user.save() + # # send_email_completed = send_email( + # # subject=f"Dear {found_user.user_tag}, your password has been changed.", + # # receivers=[str(found_user.email)], + # # html=password_is_changed_template(user_name=found_user.user_tag), + # # ) + # # if not send_email_completed: + # # raise HTTPException( + # # status_code=400, detail="Email can not be sent. Try again later" + # # ) + # return ResponseHandler.success( + # "Password is created successfully", + # data=found_user.get_dict(), + # ) + # return ResponseHandler.not_found("Record not found") + return + + +class AuthenticationDisconnectUserEventMethods(MethodToEvent): + + event_type = "LOGIN" + event_description = "Disconnect all sessions of user in access token" + event_category = "AUTHENTICATION" + + __event_keys__ = { + "8b586848-2fb3-4161-abbe-642157eec7ce": "authentication_disconnect_user", + } + # __event_validation__ = { + # "8b586848-2fb3-4161-abbe-642157eec7ce": "authentication_disconnect_user", + # } + + @classmethod + def authentication_disconnect_user( + cls, request: "Request", data: LogoutRequestModel, token_dict: TokenDictType + ): + # found_user = Users.filter_one(Users.uu_id == token_dict.user_uu_id).data + # if not found_user: + # return ResponseHandler.not_found("User not found") + # if already_tokens := RedisActions.get_object_via_user_uu_id( + # user_id=str(found_user.uu_id) + # ): + # for key, token_user in already_tokens.items(): + # RedisActions.delete(key) + # selected_user = Users.filter_one( + # Users.uu_id == token_user.get("uu_id"), + # ).data + # selected_user.remove_refresher_token( + # domain=data.domain, disconnect=True + # ) + # return ResponseHandler.success( + # "All sessions are disconnected", + # data=selected_user.get_dict(), + # ) + # return ResponseHandler.not_found("Invalid data") + return + + +class AuthenticationLogoutEventMethods(MethodToEvent): + + event_type = "LOGIN" + event_description = "Logout only single session of user which domain is provided" + event_category = "AUTHENTICATION" + + __event_keys__ = { + "5cc22e4e-a0f7-4077-be41-1871feb3dfd1": "authentication_logout_user", + } + # __event_validation__ = { + # "5cc22e4e-a0f7-4077-be41-1871feb3dfd1": "authentication_logout_user", + # } + + @classmethod + def authentication_logout_user( + cls, + request: "Request", + data: LogoutRequestModel, + token_dict: TokenDictType = None, + ): + # token_user = None + # if already_tokens := RedisActions.get_object_via_access_key(request=request): + # for key in already_tokens: + # token_user = RedisActions.get_json(key) + # if token_user.get("domain") == data.domain: + # RedisActions.delete(key) + # selected_user = Users.filter_one( + # Users.uu_id == token_user.get("uu_id"), + # ).data + # selected_user.remove_refresher_token(domain=data.domain) + + # return ResponseHandler.success( + # "Session is logged out", + # data=token_user, + # ) + # return ResponseHandler.not_found("Logout is not successfully completed") + return + + +class AuthenticationRefreshTokenEventMethods(MethodToEvent): + + event_type = "LOGIN" + event_description = "Refresh access token with refresher token" + event_category = "AUTHENTICATION" + + __event_keys__ = { + "c90f3334-10c9-4181-b5ff-90d98a0287b2": "authentication_refresher_token", + } + # __event_validation__ = { + # "c90f3334-10c9-4181-b5ff-90d98a0287b2": AuthenticationRefreshResponse, + # } + + @classmethod + def authentication_refresher_token( + cls, request: "Request", data: RememberRequestModel, token_dict: TokenDictType + ): + # token_refresher = UsersTokens.filter_by_one( + # token=data.refresh_token, + # domain=data.domain, + # **UsersTokens.valid_record_dict, + # ).data + # if not token_refresher: + # return ResponseHandler.not_found("Invalid data") + # if found_user := Users.filter_one( + # Users.id == token_refresher.user_id, + # ).data: + # found_user: Users = found_user + # access_key = AuthActions.save_access_token_to_redis( + # request=request, found_user=found_user, domain=data.domain + # ) + # found_user.last_agent = request.headers.get("User-Agent", None) + # found_user.last_platform = request.headers.get("Origin", None) + # found_user.last_remote_addr = getattr( + # request, "remote_addr", None + # ) or request.headers.get("X-Forwarded-For", None) + # found_user.last_seen = str(system_arrow.now()) + # response_data = { + # "access_token": access_key, + # "refresh_token": data.refresh_token, + # } + # return ResponseHandler.success( + # "User is logged in successfully via refresher token", + # data=response_data, + # ) + # return ResponseHandler.not_found("Invalid data") + return + + +class AuthenticationForgotPasswordEventMethods(MethodToEvent): + + event_type = "LOGIN" + event_description = "Send an email to user for a valid password reset token" + event_category = "AUTHENTICATION" + + __event_keys__ = { + "e3ca6e24-b9f8-4127-949c-3bfa364e3513": "authentication_forgot_password", + } + # __event_validation__ = { + # "e3ca6e24-b9f8-4127-949c-3bfa364e3513": "authentication_forgot_password", + # } + + @classmethod + def authentication_forgot_password( + cls, + request: "Request", + data: ForgotRequestModel, + ): + # found_user: Users = Users.check_user_exits( + # access_key=data.access_key, domain=data.domain + # ) + # forgot_key = AuthActions.save_access_token_to_redis( + # request=request, found_user=found_user, domain=data.domain + # ) + # forgot_link = ApiStatic.forgot_link(forgot_key=forgot_key) + # send_email_completed = send_email( + # subject=f"Dear {found_user.user_tag}, your forgot password link has been sent.", + # receivers=[str(found_user.email)], + # html=change_your_password_template( + # user_name=found_user.user_tag, forgot_link=forgot_link + # ), + # ) + # if not send_email_completed: + # raise HTTPException( + # status_code=400, detail="Email can not be sent. Try again later" + # ) + # found_user.password_token = forgot_key + # found_user.password_token_is_valid = str(system_arrow.shift(days=1)) + # found_user.save() + + # return ResponseHandler.success( + # "Password is change link is sent to your email or phone", + # data={}, + # ) + return + + +class AuthenticationResetPasswordEventMethods(MethodToEvent): + + event_type = "UPDATE" + __event_keys__ = { + "af9e121e-24bb-44ac-a616-471d5754360e": "authentication_reset_password", + } + + @classmethod + def authentication_reset_password( + cls, request: "Request", data: ForgotRequestModel + ): + # from sqlalchemy import or_ + + # found_user = Users.query.filter( + # or_( + # Users.email == str(data.access_key).lower(), + # Users.phone_number == str(data.access_key).replace(" ", ""), + # ), + # ).first() + # if not found_user: + # raise HTTPException( + # status_code=status.HTTP_400_BAD_REQUEST, + # detail="Given access key or domain is not matching with the any user record.", + # ) + + # reset_password_token = found_user.reset_password_token(found_user=found_user) + # send_email_completed = send_email( + # subject=f"Dear {found_user.user_tag}, a password reset request has been received.", + # receivers=[str(found_user.email)], + # html=change_your_password_template( + # user_name=found_user.user_tag, + # forgot_link=ApiStatic.forgot_link(forgot_key=reset_password_token), + # ), + # ) + # if not send_email_completed: + # raise found_user.raise_http_exception( + # status_code=400, message="Email can not be sent. Try again later" + # ) + # return ResponseHandler.success( + # "Password change link is sent to your email or phone", + # data=found_user.get_dict(), + # ) + return + + +class AuthenticationDownloadAvatarEventMethods(MethodToEvent): + + event_type = "LOGIN" + event_description = "Download avatar icon and profile info of user" + event_category = "AUTHENTICATION" + + __event_keys__ = { + "c140cd5f-307f-4046-a93e-3ade032a57a7": "authentication_download_avatar", + } + # __event_validation__ = { + # "c140cd5f-307f-4046-a93e-3ade032a57a7": AuthenticationUserInfoResponse, + # } + + @classmethod + def authentication_download_avatar( + cls, + token_dict: TokenDictType, + request: "Request", + data: DictRequestModel, + ): + # if found_user := Users.filter_one(Users.id == token_dict.user_id).data: + # expired_starts = str( + # system_arrow.now() - system_arrow.get(str(found_user.expiry_ends)) + # ) + # expired_int = ( + # system_arrow.now() - system_arrow.get(str(found_user.expiry_ends)) + # ).days + + # user_info = { + # "lang": token_dict.lang, + # "full_name": found_user.person.full_name, + # "avatar": found_user.avatar, + # "remember_me": found_user.remember_me, + # "expiry_ends": str(found_user.expiry_ends), + # "expired_str": expired_starts, + # "expired_int": int(expired_int), + # } + # return ResponseHandler.success( + # "Avatar and profile is shared via user credentials", + # data=user_info, + # ) + # return ResponseHandler.not_found("Invalid data") + + return + + +async def authentication_select_company_or_occupant_type( + request: "Request", + data: Union[EmployeeSelectionRequestModel, OccupantSelectionRequestModel], +) -> Dict[str, Any]: + return await AuthenticationSelectEventMethods.authentication_select_company_or_occupant_type( + request=request, data=data + ) + + +async def authentication_login_with_domain_and_creds( + request: "Request", data: LoginRequestModel +) -> SuccessResponse: + """ + Authenticate user with domain and credentials. + + Args: + request: FastAPI request object + data: Request body containing login credentials + { + "domain": str, + "username": str, + "password": str + } + + Returns: + SuccessResponse containing authentication token and user info + """ + return await AuthenticationLoginEventMethods.authentication_login_with_domain_and_creds( + request=request, data=data + ) + + +async def authentication_check_token_is_valid( + request: "Request", data: DictRequestModel +) -> Dict[str, Any]: + return ( + await AuthenticationCheckTokenEventMethods.authentication_check_token_is_valid( + request=request, data=data + ) + ) + + +def authentication_refresh_user_info( + request: "Request", data: DictRequestModel +) -> Dict[str, Any]: + return + + +def authentication_change_password( + request: "Request", data: ChangePasswordRequestModel +) -> Dict[str, Any]: + return + + +def authentication_create_password( + request: "Request", data: CreatePasswordRequestModel +) -> Dict[str, Any]: + return + + +def authentication_forgot_password( + request: "Request", data: ForgotRequestModel +) -> Dict[str, Any]: + return + + +def authentication_reset_password( + request: "Request", data: ForgotRequestModel +) -> Dict[str, Any]: + return + + +def authentication_disconnect_user( + request: "Request", data: LogoutRequestModel +) -> Dict[str, Any]: + return + + +def authentication_logout_user( + request: "Request", data: LogoutRequestModel +) -> Dict[str, Any]: + return + + +def authentication_refresher_token( + request: "Request", data: RememberRequestModel +) -> Dict[str, Any]: + return + + +def authentication_download_avatar( + request: "Request", data: DictRequestModel +) -> Dict[str, Any]: + return + + +AUTH_CONFIG = RouteFactoryConfig( + name="authentication", + prefix="/authentication", + tags=["Authentication"], + include_in_schema=True, + endpoints=[ + EndpointFactoryConfig( + url_prefix="/authentication", + url_endpoint="/select", + url_of_endpoint="/authentication/select", + endpoint="/select", + method="POST", + summary="Select company or occupant type", + description="Select company or occupant type", + is_auth_required=True, # Needs token_dict + is_event_required=False, + endpoint_function=authentication_select_company_or_occupant_type, + ), + EndpointFactoryConfig( + url_prefix="/authentication", + url_endpoint="/login", + url_of_endpoint="/authentication/login", + endpoint="/login", + method="POST", + summary="Login user with domain and password", + description="Login user with domain and password", + is_auth_required=False, # Public endpoint + is_event_required=False, + endpoint_function=authentication_login_with_domain_and_creds, + ), + EndpointFactoryConfig( + url_prefix="/authentication", + url_endpoint="/valid", + url_of_endpoint="/authentication/valid", + endpoint="/valid", + method="GET", + summary="Check access token is valid", + description="Check access token is valid", + is_auth_required=True, # Needs token validation + is_event_required=False, + endpoint_function=authentication_check_token_is_valid, + ), + EndpointFactoryConfig( + url_prefix="/authentication", + url_endpoint="/refresh", + url_of_endpoint="/authentication/refresh", + endpoint="/refresh", + method="GET", + summary="Refresh credentials with access token", + description="Refresh credentials with access token", + is_auth_required=True, # Needs token_dict + is_event_required=False, + endpoint_function=authentication_refresh_user_info, + ), + EndpointFactoryConfig( + url_prefix="/authentication", + url_endpoint="/change_password", + url_of_endpoint="/authentication/change_password", + endpoint="/change_password", + method="POST", + summary="Change password with access token", + description="Change password with access token", + is_auth_required=True, # Needs token_dict + is_event_required=False, + endpoint_function=authentication_change_password, + ), + EndpointFactoryConfig( + url_prefix="/authentication", + url_endpoint="/create_password", + url_of_endpoint="/authentication/create_password", + endpoint="/create_password", + method="POST", + summary="Create password with password token", + description="Create password with password token", + is_auth_required=False, # Public endpoint + is_event_required=False, + endpoint_function=authentication_create_password, + ), + EndpointFactoryConfig( + url_prefix="/authentication", + url_endpoint="/reset_password", + url_of_endpoint="/authentication/reset_password", + endpoint="/reset_password", + method="POST", + summary="Create password with password token", + description="Create password with password token", + is_auth_required=False, # Public endpoint + is_event_required=False, + endpoint_function=authentication_reset_password, + ), + EndpointFactoryConfig( + url_prefix="/authentication", + url_endpoint="/disconnect", + url_of_endpoint="/authentication/disconnect", + endpoint="/disconnect", + method="POST", + summary="Disconnect user with access token", + description="Disconnect user with access token", + is_auth_required=True, # Needs token_dict + is_event_required=False, + endpoint_function=authentication_disconnect_user, + ), + EndpointFactoryConfig( + url_prefix="/authentication", + url_endpoint="/logout", + url_of_endpoint="/authentication/logout", + endpoint="/logout", + method="POST", + summary="Logout user with access token", + description="Logout user with access token", + is_auth_required=True, # Needs token_dict + is_event_required=False, + endpoint_function=authentication_logout_user, + ), + EndpointFactoryConfig( + url_prefix="/authentication", + url_endpoint="/refresher", + url_of_endpoint="/authentication/refresher", + endpoint="/refresher", + method="POST", + summary="Refresh token with refresh token", + description="Refresh token with refresh token", + is_auth_required=True, # Needs token_dict + is_event_required=False, + endpoint_function=authentication_refresher_token, + ), + EndpointFactoryConfig( + url_prefix="/authentication", + url_endpoint="/forgot", + url_of_endpoint="/authentication/forgot", + endpoint="/forgot", + method="POST", + summary="Forgot password with email or phone number", + description="Forgot password with email or phone number", + is_auth_required=False, # Public endpoint + is_event_required=False, + endpoint_function=authentication_forgot_password, + ), + EndpointFactoryConfig( + url_prefix="/authentication", + url_endpoint="/avatar", + url_of_endpoint="/authentication/avatar", + endpoint="/avatar", + method="POST", + summary="Get link of avatar with credentials", + description="Get link of avatar with credentials", + is_auth_required=True, # Needs token_dict + is_event_required=False, + endpoint_function=authentication_download_avatar, + ), + ], +).as_dict() diff --git a/ApiEvents/AuthServiceApi/route_configs.py b/ApiEvents/AuthServiceApi/route_configs.py new file mode 100644 index 0000000..fb603f0 --- /dev/null +++ b/ApiEvents/AuthServiceApi/route_configs.py @@ -0,0 +1,20 @@ +""" +Route configuration registry. + +This module collects and registers all route configurations from different modules +to be used by the dynamic route creation system. +""" + +from typing import Dict, List, Any +from .auth.auth import AUTH_CONFIG + + +# Registry of all route configurations +ROUTE_CONFIGS = [ + AUTH_CONFIG, +] + + +def get_route_configs() -> List[Dict[str, Any]]: + """Get all registered route configurations.""" + return [AUTH_CONFIG] diff --git a/ApiEvents/EventServiceApi/__init__.py b/ApiEvents/EventServiceApi/__init__.py index e69de29..a2080bb 100644 --- a/ApiEvents/EventServiceApi/__init__.py +++ b/ApiEvents/EventServiceApi/__init__.py @@ -0,0 +1,5 @@ +"""Event Service API initialization""" + +from .route_configs import get_route_configs + +__all__ = ["get_route_configs"] diff --git a/ApiEvents/EventServiceApi/account/account_records.py b/ApiEvents/EventServiceApi/account/account_records.py index 452a135..3e3413f 100644 --- a/ApiEvents/EventServiceApi/account/account_records.py +++ b/ApiEvents/EventServiceApi/account/account_records.py @@ -1,8 +1,19 @@ import typing -from collections.abc import Callable -from fastapi import Request -from typing import Dict, Any +from ApiEvents.abstract_class import ( + MethodToEvent, + RouteFactoryConfig, + EndpointFactoryConfig, + endpoint_wrapper, +) +from ApiEvents.base_request_model import BaseRequestModel, DictRequestModel + +from typing import TYPE_CHECKING, Dict, Any +from fastapi import Request, Path, Body, Depends, APIRouter +from pydantic import BaseModel, Field + +if TYPE_CHECKING: + from fastapi import Request from ApiValidations.Custom.token_objects import ( OccupantTokenObject, @@ -21,15 +32,43 @@ from ApiValidations.Request import ( UpdateAccountRecord, ListOptions, ) -from Services.PostgresDb.Models.alchemy_response import AlchemyJsonResponse -from ApiValidations.Response import AccountRecordResponse -from events.abstract_class import ( - MethodToEvent, - RouteFactoryConfig, - EndpointFactoryConfig, +from Services.PostgresDb.Models.alchemy_response import ( + AlchemyJsonResponse, + DictJsonResponse, ) +from ApiValidations.Response import AccountRecordResponse -# from events.utils import with_token_event + +class AddressUpdateRequest(BaseModel): + """Request model for address update.""" + + data: Dict[str, Any] = Field(..., description="Updated address data") + + +class AddressUpdateResponse(BaseModel): + """Response model for address update.""" + + address_uu_id: str = Field(..., description="UUID of the updated address") + data: Dict[str, Any] = Field(..., description="Updated address data") + function_code: str = Field(..., description="Function code for the endpoint") + + +class InsertAccountRecordRequestModel(BaseRequestModel[InsertAccountRecord]): + """Request model for inserting account records.""" + + pass + + +class UpdateAccountRecordRequestModel(BaseRequestModel[UpdateAccountRecord]): + """Request model for updating account records.""" + + pass + + +class ListOptionsRequestModel(BaseRequestModel[ListOptions]): + """Request model for list options.""" + + pass class AccountRecordsListEventMethods(MethodToEvent): @@ -50,7 +89,7 @@ class AccountRecordsListEventMethods(MethodToEvent): @classmethod def account_records_list( cls, - list_options: ListOptions, + list_options: ListOptionsRequestModel, token_dict: typing.Union[EmployeeTokenObject, OccupantTokenObject], ): db_session = AccountRecords.new_session() @@ -79,7 +118,7 @@ class AccountRecordsListEventMethods(MethodToEvent): @classmethod def account_records_list_flt_res( cls, - list_options: ListOptions, + list_options: ListOptionsRequestModel, token_dict: typing.Union[EmployeeTokenObject, OccupantTokenObject], ): db_session = AccountRecords.new_session() @@ -229,7 +268,7 @@ class AccountRecordsCreateEventMethods(MethodToEvent): @classmethod def account_records_create( cls, - data: InsertAccountRecord, + data: InsertAccountRecordRequestModel, token_dict: typing.Union[EmployeeTokenObject, OccupantTokenObject], ): data_dict = data.excluded_dump() @@ -318,7 +357,7 @@ class AccountRecordsUpdateEventMethods(MethodToEvent): def build_area_update( cls, build_uu_id: str, - data: UpdateAccountRecord, + data: UpdateAccountRecordRequestModel, token_dict: typing.Union[EmployeeTokenObject, OccupantTokenObject], ): if isinstance(token_dict, OccupantTokenObject): @@ -336,52 +375,19 @@ class AccountRecordsUpdateEventMethods(MethodToEvent): ) -class AccountRecordsPatchEventMethods(MethodToEvent): - - event_type = "PATCH" - event_description = "" - event_category = "" - - __event_keys__ = { - "34c38937-42a2-45f1-b2ef-a23978650aee": "account_records_patch", - } - __event_validation__ = { - "34c38937-42a2-45f1-b2ef-a23978650aee": None, - } - - @classmethod - def build_area_patch( - cls, - build_uu_id: str, - data, - token_dict: typing.Union[EmployeeTokenObject, OccupantTokenObject], - ): - account_record = AccountRecords.patch_one(build_uu_id, data).data - return AlchemyJsonResponse( - completed=True, - message="Account record patched successfully", - result=account_record, - ) - - - -def address_list(request: Request, data: dict) -> Dict[str, Any]: +@endpoint_wrapper("/account/records/address/list") +async def address_list(request: "Request", data: ListOptionsRequestModel): """Handle address list endpoint.""" - # Access context through the handler - handler = address_list.handler - handler_context = address_list.handler.context - function_name = AccountRecordsListEventMethods.__event_keys__.get(handler.function_code) - original_function = getattr(AccountRecordsListEventMethods, function_name) - # original_function(data, request) return { "data": data, - "function_code": handler.function_code, # This will be the URL - "token_dict": handler_context.get('token_dict'), - "url_of_endpoint": handler_context.get('url_of_endpoint'), "request": str(request.headers), + "request_url": str(request.url), + "request_base_url": str(request.base_url), } -def address_create(request: Request, data: dict): + +@endpoint_wrapper("/account/records/address/create") +async def address_create(request: "Request", data: DictRequestModel): """Handle address creation endpoint.""" return { "data": data, @@ -390,80 +396,100 @@ def address_create(request: Request, data: dict): "request_base_url": str(request.base_url), } -def address_search(request: Request, data: dict): + +@endpoint_wrapper("/account/records/address/search") +async def address_search(request: "Request", data: DictRequestModel): """Handle address search endpoint.""" - # Get function_code from the wrapper's closure - function_code = address_search.function_code - return { - "data": data, - "function_code": function_code - } + return {"data": data} -def address_update(request: Request, address_uu_id: str, data: dict): - """Handle address update endpoint.""" - # Get function_code from the wrapper's closure - function_code = address_update.function_code - return { - "address_uu_id": address_uu_id, - "data": data, - "function_code": function_code - } +router = APIRouter() + + +@endpoint_wrapper("/account/records/address/{address_uu_id}") +async def address_update( + request: Request, + address_uu_id: str = Path(..., description="UUID of the address to update"), + request_data: DictRequestModel = Body(..., description="Request body"), +): + """ + Handle address update endpoint. + + Args: + request: FastAPI request object + address_uu_id: UUID of the address to update + request_data: Request body containing updated address data + + Returns: + DictJsonResponse: Response containing updated address info + """ + return DictJsonResponse( + data={ + "address_uu_id": address_uu_id, + "data": request_data.root, + "request": str(request.headers), + "request_url": str(request.url), + "request_base_url": str(request.base_url), + } + ) + + +prefix = "/account/records" # Account Records Router Configuration ACCOUNT_RECORDS_CONFIG = RouteFactoryConfig( - name='account_records', - prefix='/account/records', - tags=['Account Records'], + name="account_records", + prefix=prefix, + tags=["Account Records"], include_in_schema=True, endpoints=[ EndpointFactoryConfig( - url_prefix = "/account/records", + url_prefix=prefix, url_endpoint="/address/list", - url_of_endpoint = "/account/records/address/list", + url_of_endpoint="/account/records/address/list", endpoint="/address/list", method="POST", summary="List Active/Delete/Confirm Address", description="List Active/Delete/Confirm Address", is_auth_required=True, is_event_required=True, - endpoint_function=address_list + endpoint_function=address_list, ), EndpointFactoryConfig( - url_prefix = "/account/records", + url_prefix=prefix, url_endpoint="/address/create", - url_of_endpoint = "/account/records/address/create", + url_of_endpoint="/account/records/address/create", endpoint="/address/create", method="POST", summary="Create Address with given auth levels", description="Create Address with given auth levels", is_auth_required=False, is_event_required=False, - endpoint_function=address_create + endpoint_function=address_create, ), EndpointFactoryConfig( - url_prefix = "/account/records", + url_prefix=prefix, url_endpoint="/address/search", - url_of_endpoint = "/account/records/address/search", + url_of_endpoint="/account/records/address/search", endpoint="/address/search", method="POST", summary="Search Address with given auth levels", description="Search Address with given auth levels", is_auth_required=True, is_event_required=True, - endpoint_function=address_search + endpoint_function=address_search, ), EndpointFactoryConfig( - url_prefix = "/account/records", - url_endpoint="/address/update/{address_uu_id}", - url_of_endpoint="/account/records/address/update/{address_uu_id}", - endpoint="/address/update/{address_uu_id}", + url_prefix=prefix, + url_endpoint="/address/{address_uu_id}", + url_of_endpoint="/account/records/address/{address_uu_id}", + endpoint="/address/{address_uu_id}", method="PUT", summary="Update Address with given auth levels", description="Update Address with given auth levels", is_auth_required=True, is_event_required=True, - endpoint_function=address_update - ) - ] + endpoint_function=address_update, + ), + ], ).as_dict() diff --git a/ApiEvents/EventServiceApi/route_configs.py b/ApiEvents/EventServiceApi/route_configs.py index d5540f7..0206e22 100644 --- a/ApiEvents/EventServiceApi/route_configs.py +++ b/ApiEvents/EventServiceApi/route_configs.py @@ -1,12 +1,12 @@ """ -Route configuration registry. +Route configuration registry for Event Service. This module collects and registers all route configurations from different modules to be used by the dynamic route creation system. """ from typing import Dict, List, Any -from events.account.account_records import ACCOUNT_RECORDS_CONFIG +from .account.account_records import ACCOUNT_RECORDS_CONFIG # Registry of all route configurations @@ -14,6 +14,7 @@ ROUTE_CONFIGS = [ ACCOUNT_RECORDS_CONFIG, ] + def get_route_configs() -> List[Dict[str, Any]]: """Get all registered route configurations.""" - return ROUTE_CONFIGS + return [ACCOUNT_RECORDS_CONFIG] diff --git a/ApiEvents/ValidationServiceApi/__init__.py b/ApiEvents/ValidationServiceApi/__init__.py new file mode 100644 index 0000000..a2080bb --- /dev/null +++ b/ApiEvents/ValidationServiceApi/__init__.py @@ -0,0 +1,5 @@ +"""Event Service API initialization""" + +from .route_configs import get_route_configs + +__all__ = ["get_route_configs"] diff --git a/ApiEvents/ValidationServiceApi/route_configs.py b/ApiEvents/ValidationServiceApi/route_configs.py new file mode 100644 index 0000000..630ae67 --- /dev/null +++ b/ApiEvents/ValidationServiceApi/route_configs.py @@ -0,0 +1,17 @@ +""" +Route configuration registry for Event Service. + +This module collects and registers all route configurations from different modules +to be used by the dynamic route creation system. +""" + +from typing import Dict, List, Any + + +# Registry of all route configurations +ROUTE_CONFIGS = [] + + +def get_route_configs() -> List[Dict[str, Any]]: + """Get all registered route configurations.""" + return [] diff --git a/ApiEvents/__init__.py b/ApiEvents/__init__.py index e69de29..6b9d767 100644 --- a/ApiEvents/__init__.py +++ b/ApiEvents/__init__.py @@ -0,0 +1,21 @@ +"""ApiEvents package initialization. + +This module serves as the main entry point for the ApiEvents package, +making common utilities and base classes available for all API services. +""" + +from .abstract_class import ( + MethodToEvent, + RouteFactoryConfig, + EndpointFactoryConfig, +) +from .base_request_model import BaseRequestModel, DictRequestModel + +# Re-export commonly used classes +__all__ = [ + "MethodToEvent", + "RouteFactoryConfig", + "EndpointFactoryConfig", + "BaseRequestModel", + "DictRequestModel", +] diff --git a/ApiEvents/abstract_class.py b/ApiEvents/abstract_class.py index f6de128..72c43af 100644 --- a/ApiEvents/abstract_class.py +++ b/ApiEvents/abstract_class.py @@ -5,12 +5,66 @@ This module provides core abstractions for route configuration and factory, with support for authentication and event handling. """ -from typing import Optional, Dict, Any, List, Type, Union, ClassVar, Tuple, TypeVar, Callable +from typing import ( + Tuple, + TypeVar, + Optional, + Callable, + Dict, + Any, + List, + Type, + ClassVar, + Union, + Awaitable, +) from dataclasses import dataclass, field from pydantic import BaseModel +from fastapi import Request, Depends, APIRouter +from functools import wraps +import inspect -ResponseModel = TypeVar('ResponseModel', bound=BaseModel) +ResponseModel = TypeVar("ResponseModel", bound=BaseModel) + + +def endpoint_wrapper(url_of_endpoint: Optional[str] = None): + """Create a wrapper for endpoints that stores url_of_endpoint in closure. + + Args: + url_of_endpoint: Optional URL path for the endpoint + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + async def wrapper( + *args: Any, **kwargs: Any + ) -> Union[Dict[str, Any], BaseModel]: + # Handle both async and sync functions + if inspect.iscoroutinefunction(func): + result = await func(*args, **kwargs) + else: + result = func(*args, **kwargs) + + # If result is a coroutine, await it + if inspect.iscoroutine(result): + result = await result + + # Add function_code to the result + if isinstance(result, dict): + result["function_code"] = url_of_endpoint + return result + elif isinstance(result, BaseModel): + # Convert Pydantic model to dict and add function_code + result_dict = result.model_dump() + result_dict["function_code"] = url_of_endpoint + return result_dict + return result + + wrapper.url_of_endpoint = url_of_endpoint + return wrapper + + return decorator @dataclass @@ -25,40 +79,53 @@ class EndpointFactoryConfig: description: Detailed description for API documentation endpoint_function: Function to handle the endpoint is_auth_required: Whether authentication is required + response_model: Optional response model for OpenAPI schema + request_model: Optional request model for OpenAPI schema is_event_required: Whether event handling is required extra_options: Additional endpoint options """ - url_prefix :str + + url_prefix: str url_endpoint: str url_of_endpoint: str endpoint: str method: str summary: str description: str - endpoint_function: Callable + endpoint_function: Callable[..., Any] # Now accepts any parameters and return type + response_model: Optional[type] = None + request_model: Optional[type] = None is_auth_required: bool = True is_event_required: bool = False extra_options: Dict[str, Any] = field(default_factory=dict) def __post_init__(self): - """Post-initialization processing. - - Apply appropriate wrappers based on auth and event requirements: - - If both auth and event required -> wrap with with_token_event + """Post initialization hook. + + Wraps endpoint function with appropriate middleware based on configuration: + - If auth and event required -> wrap with TokenEventMiddleware + - If only event required -> wrap with EventMiddleware - If only auth required -> wrap with MiddlewareModule.auth_required """ - # Store url_of_endpoint for the handler - self.endpoint_function.url_of_endpoint = self.url_of_endpoint - + # Wrap the endpoint function to store url_of_endpoint + self.endpoint_function = endpoint_wrapper(self.url_of_endpoint)( + self.endpoint_function + ) + if self.is_auth_required and self.is_event_required: - from events.utils import with_token_event - self.endpoint_function = with_token_event(self.endpoint_function) + from middleware import TokenEventMiddleware + + self.endpoint_function = TokenEventMiddleware.event_required( + self.endpoint_function + ) elif self.is_auth_required: - from DockerApiServices.AllApiNeeds.middleware.auth_middleware import MiddlewareModule - self.endpoint_function = MiddlewareModule.auth_required(self.endpoint_function) + from middleware import MiddlewareModule + + self.endpoint_function = MiddlewareModule.auth_required( + self.endpoint_function + ) -@dataclass class RouteFactoryConfig: """Configuration class for API route factories. @@ -70,17 +137,29 @@ class RouteFactoryConfig: endpoints: List of endpoint configurations extra_options: Additional route options """ - name: str - tags: Union[str, List[str]] - prefix: str - include_in_schema: bool = True - endpoints: List[EndpointFactoryConfig] = field(default_factory=list) - extra_options: Dict[str, Any] = field(default_factory=dict) + + def __init__( + self, + name: str, + tags: List[str], + prefix: str, + include_in_schema: bool = True, + endpoints: List[EndpointFactoryConfig] = None, + extra_options: Dict[str, Any] = None, + ): + self.name = name + self.tags = tags + self.prefix = prefix + self.include_in_schema = include_in_schema + self.endpoints = endpoints or [] + self.extra_options = extra_options or {} def __post_init__(self): """Validate and normalize configuration after initialization.""" - if isinstance(self.tags, str): - self.tags = [self.tags] + if self.endpoints is None: + self.endpoints = [] + if self.extra_options is None: + self.extra_options = {} def as_dict(self) -> Dict[str, Any]: """Convert configuration to dictionary format.""" @@ -89,20 +168,8 @@ class RouteFactoryConfig: "tags": self.tags, "prefix": self.prefix, "include_in_schema": self.include_in_schema, - "endpoints": [ - { - "endpoint": ep.endpoint, - "method": ep.method, - "summary": ep.summary, - "description": ep.description, - "endpoint_function": ep.endpoint_function, - "is_auth_required": ep.is_auth_required, - "is_event_required": ep.is_event_required, - "extra_options": ep.extra_options - } - for ep in self.endpoints - ], - "extra_options": self.extra_options + "endpoints": [endpoint.__dict__ for endpoint in self.endpoints], + "extra_options": self.extra_options, } @@ -112,6 +179,7 @@ class ActionsSchema: This class handles endpoint registration and validation in the database. Subclasses should implement specific validation logic. """ + def __init__(self, endpoint: str): """Initialize with an API endpoint path. @@ -129,7 +197,9 @@ class ActionsSchema: Raises: HTTPException: If endpoint is not found in database """ - raise NotImplementedError("Subclasses must implement retrieve_action_from_endpoint") + raise NotImplementedError( + "Subclasses must implement retrieve_action_from_endpoint" + ) class ActionsSchemaFactory: @@ -138,6 +208,7 @@ class ActionsSchemaFactory: This class ensures proper initialization and validation of API endpoints through their action schemas. """ + def __init__(self, action: ActionsSchema): """Initialize with an action schema. @@ -161,12 +232,15 @@ class MethodToEvent: TokenType: Type of authentication token ResponseModel: Type of response model """ + action_key: ClassVar[Optional[str]] = None event_type: ClassVar[Optional[str]] = None event_description: ClassVar[str] = "" event_category: ClassVar[str] = "" __event_keys__: ClassVar[Dict[str, str]] = {} - __event_validation__: ClassVar[List[Tuple[Type[ResponseModel], List[Dict[str, Any]]]]] = [] + __event_validation__: ClassVar[Dict[str, Tuple[Type[ResponseModel], List[Any]]]] = ( + {} + ) @classmethod def retrieve_language_parameters( @@ -184,9 +258,9 @@ class MethodToEvent: validation_dict = dict(cls.__event_validation__) if function_code not in validation_dict: return {} - + event_response_model, event_language_models = validation_dict[function_code] - + # Collect language-specific field mappings language_models = {} for model in event_language_models: diff --git a/ApiEvents/base_request_model.py b/ApiEvents/base_request_model.py new file mode 100644 index 0000000..ab57c4a --- /dev/null +++ b/ApiEvents/base_request_model.py @@ -0,0 +1,123 @@ +""" +Base request models for API endpoints. + +This module provides base request models that can be used across different endpoints +to ensure consistent request handling and validation. +""" + +from typing import Dict, Any, Generic, TypeVar, Optional, get_args, get_origin +from pydantic import RootModel, BaseModel, Field, ConfigDict + + +T = TypeVar("T") + + +class BaseRequestModel(RootModel[T]): + """Base model for all API requests. + + This model can be extended to create specific request models for different endpoints. + """ + + model_config = ConfigDict( + json_schema_extra={"example": {}} # Will be populated by subclasses + ) + + @classmethod + def model_json_schema(cls, *args, **kwargs): + schema = super().model_json_schema(*args, **kwargs) + if hasattr(cls, "__orig_bases__"): + generic_type = get_args(cls.__orig_bases__[0])[0] + if generic_type and hasattr(generic_type, "model_json_schema"): + type_schema = generic_type.model_json_schema() + if "properties" in type_schema: + schema["properties"] = type_schema["properties"] + if "required" in type_schema: + schema["required"] = type_schema["required"] + if "title" in type_schema: + schema["title"] = type_schema["title"] + if "example" in type_schema: + schema["example"] = type_schema["example"] + elif "properties" in type_schema: + schema["example"] = { + key: prop.get("example", "string") + for key, prop in type_schema["properties"].items() + } + schema["type"] = "object" + return schema + + @property + def data(self) -> T: + return self.root + + +class DictRequestModel(RootModel[Dict[str, Any]]): + """Request model for endpoints that accept dictionary data.""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "key1": "value1", + "key2": "value2", + "nested": {"inner_key": "inner_value"}, + } + } + ) + + @classmethod + def model_json_schema(cls, *args, **kwargs): + schema = super().model_json_schema(*args, **kwargs) + schema.update( + { + "title": "Dictionary Request Model", + "type": "object", + "properties": { + "key1": {"type": "string", "example": "value1"}, + "key2": {"type": "string", "example": "value2"}, + "nested": { + "type": "object", + "properties": { + "inner_key": {"type": "string", "example": "inner_value"} + }, + }, + }, + "example": { + "key1": "value1", + "key2": "value2", + "nested": {"inner_key": "inner_value"}, + }, + } + ) + return schema + + @property + def data(self) -> Dict[str, Any]: + return self.root + + +class SuccessResponse(BaseModel): + """Standard success response model.""" + + token: str = Field(..., example="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + user_info: Dict[str, Any] = Field( + ..., + example={ + "id": "123", + "username": "john.doe", + "email": "john@example.com", + "role": "user", + }, + ) + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user_info": { + "id": "123", + "username": "john.doe", + "email": "john@example.com", + "role": "user", + }, + } + } + ) diff --git a/ApiEvents/utils.py b/ApiEvents/utils.py deleted file mode 100644 index a4f091e..0000000 --- a/ApiEvents/utils.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -Utility functions for API event handling. -""" - -from typing import TypeVar, Callable, Dict, Any -from functools import wraps -from fastapi import Request - -R = TypeVar('R') - -class BaseEndpointHandler: - """Base class for handling endpoint execution with context.""" - - def __init__(self, func: Callable, url_of_endpoint: str): - self.func = func - self.url_of_endpoint = url_of_endpoint - self.function_code = url_of_endpoint # Set initial function_code - self._context = { - 'url_of_endpoint': url_of_endpoint, - 'function_code': url_of_endpoint, # Initialize with URL - } - - @property - def context(self) -> dict: - """Get the endpoint context.""" - return self._context - - def update_context(self, **kwargs): - """Update the endpoint context with new values.""" - self._context.update(kwargs) - # Update function_code property if it's in the context - if 'function_code' in kwargs: - self.function_code = kwargs['function_code'] - - def __call__(self, *args, **kwargs): - return self.func(*args, **kwargs) - - -class TokenEventHandler(BaseEndpointHandler): - """Handler for endpoints that require token and event tracking.""" - - def __init__(self, func: Callable, url_of_endpoint: str): - super().__init__(func, url_of_endpoint) - self.update_context( - token_dict={ - 'user_id': '1234567890', - 'username': 'test_user', - 'email': 'asda@email.com', - } - ) - - -class AuthHandler(BaseEndpointHandler): - """Handler for endpoints that require only authentication.""" - - def __init__(self, func: Callable, url_of_endpoint: str): - super().__init__(func, url_of_endpoint) - self.update_context( - auth_level="user", - permissions=["read", "write"] - ) - - -def with_token_event(func: Callable[..., Dict[str, Any]]) -> Callable[..., Dict[str, Any]]: - """Decorator for endpoints with token and event requirements.""" - @wraps(func) - def wrapper(*args, **kwargs) -> Dict[str, Any]: - # Create handler with context - handler = TokenEventHandler( - func=func, - url_of_endpoint=func.url_of_endpoint - ) - - # Update event-specific context - handler.update_context( - function_code=f"7192c2aa-5352-4e36-98b3-dafb7d036a3d" # Keep function_code as URL - ) - - # Make context available to the function - func.handler = handler - - # Call the original function - return func(*args, **kwargs) - - return wrapper - - -def auth_required(func: Callable[..., Dict[str, Any]]) -> Callable[..., Dict[str, Any]]: - """Decorator for endpoints with only auth requirements.""" - @wraps(func) - def wrapper(*args, **kwargs) -> Dict[str, Any]: - # Create handler with context - handler = AuthHandler( - func=func, - url_of_endpoint=func.url_of_endpoint - ) - - # Make context available to the function - func.handler = handler - - # Call the original function - return func(*args, **kwargs) - - return wrapper diff --git a/ApiLibrary/common/line_number.py b/ApiLibrary/common/line_number.py new file mode 100644 index 0000000..3638ae6 --- /dev/null +++ b/ApiLibrary/common/line_number.py @@ -0,0 +1,5 @@ +def get_line_number_for_error(): + from inspect import currentframe, getframeinfo + + frameinfo = getframeinfo(currentframe()) + return f"{frameinfo.filename} | {frameinfo.lineno}" diff --git a/ApiServices/Login/user_login_handler.py b/ApiServices/Login/user_login_handler.py new file mode 100644 index 0000000..a97ca48 --- /dev/null +++ b/ApiServices/Login/user_login_handler.py @@ -0,0 +1,68 @@ +from typing import Any, ClassVar, Dict +from sqlalchemy import or_ +from ApiLibrary.common.line_number import get_line_number_for_error +from Schemas import Users +from ErrorHandlers import HTTPExceptionApi +from ApiValidations.Request.authentication import Login +from ApiLibrary.token.password_module import PasswordModule +from ApiServices.Token.token_handler import TokenService + + +class UserLoginModule: + + def __init__(self, request: "Request"): + self.request = request + + @staticmethod + async def check_user_exists(access_key) -> ClassVar[Users]: + """Check if user exists.""" + db_session = Users.new_session() + if "@" in access_key: + found_user = Users.filter_one( + Users.email == access_key.lower(), db=db_session + ).data + else: + found_user = Users.filter_one( + Users.phone_number == access_key.replace(" ", ""), db=db_session + ).data + if not found_user: + raise HTTPExceptionApi( + error_code="HTTP_400_BAD_REQUEST", + lang="en", + loc=get_line_number_for_error(), + ) + return found_user + + async def login_user_via_credentials(self, access_data: "Login") -> Dict[str, Any]: + """Login user via credentials.""" + # Get the actual data from the BaseRequestModel if needed + if hasattr(access_data, "data"): + access_data = access_data.data + + found_user: Users = await self.check_user_exists( + access_key=access_data.access_key + ) + if len(found_user.hash_password) < 5: + raise HTTPExceptionApi( + error_code="HTTP_400_BAD_REQUEST", + lang=found_user.lang, + loc=get_line_number_for_error(), + ) + + if PasswordModule.check_password( + domain=access_data.domain, + id_=found_user.uu_id, + password=access_data.password, + password_hashed=found_user.hash_password, + ): + return TokenService.set_access_token_to_redis( + request=self.request, + user=found_user, + domain=access_data.domain, + remember=access_data.remember_me, + ) + raise HTTPExceptionApi( + error_code="HTTP_400_BAD_REQUEST", + lang=found_user.lang, + loc=get_line_number_for_error(), + ) diff --git a/ApiServices/Token/token_handler.py b/ApiServices/Token/token_handler.py index 526ac38..e7bf97e 100644 --- a/ApiServices/Token/token_handler.py +++ b/ApiServices/Token/token_handler.py @@ -1,78 +1,354 @@ -from typing import Union +"""Token service for handling authentication tokens and user sessions.""" + +import json +from typing import List, Union, TypeVar, Dict, Any, Optional, TYPE_CHECKING from AllConfigs.Token.config import Auth +from ApiLibrary.common.line_number import get_line_number_for_error +from ApiLibrary.date_time_actions.date_functions import DateTimeLocal +from ApiLibrary.token.password_module import PasswordModule from ErrorHandlers import HTTPExceptionApi +from Schemas.identity.identity import UsersTokens from Services.Redis import RedisActions, AccessToken -from fastapi import Request -from ApiValidations.Custom.token_objects import EmployeeTokenObject, OccupantTokenObject +from ApiValidations.Custom.token_objects import ( + EmployeeTokenObject, + OccupantTokenObject, + UserType, +) +from Schemas import ( + Users, + BuildLivingSpace, + BuildParts, + Employees, + Addresses, + Companies, + Staff, + Duty, + Duties, + Departments, + OccupantTypes, +) +from Services.Redis.Models.base import RedisRow +from Services.Redis.Models.response import RedisResponse + +if TYPE_CHECKING: + from fastapi import Request + +T = TypeVar("T", EmployeeTokenObject, OccupantTokenObject) class TokenService: + """Service class for handling authentication tokens and user sessions.""" @classmethod - def raise_error_if_request_has_no_token(cls, request: Request) -> None: - """Get access token from request headers.""" + def _create_access_token(cls, access: bool = True) -> str: + """Generate a new access token.""" + if not access: + return PasswordModule.generate_token(Auth.REFRESHER_TOKEN_LENGTH) + return PasswordModule.generate_token(Auth.ACCESS_TOKEN_LENGTH) + + @classmethod + def _get_user_tokens(cls, user: Users) -> RedisResponse: + """Get all tokens for a user from Redis.""" + return RedisActions.get_json( + list_keys=AccessToken( + userUUID=user.uu_id, + ).to_list() + ) + + @classmethod + def do_occupant_login( + cls, request: "Request", user: Users, domain: str + ) -> Dict[str, Any]: + """Handle occupant login process and return login information.""" + db_session = BuildLivingSpace.new_session() + living_spaces: list[BuildLivingSpace] = BuildLivingSpace.filter_all( + BuildLivingSpace.person_id == user.person_id, db=db_session + ).data + if not living_spaces: + raise HTTPExceptionApi( + error_code="", lang="en", loc=get_line_number_for_error() + ) + + occupants_selection_dict: Dict[str, Any] = {} + + for living_space in living_spaces: + build_parts_selection = BuildParts.filter_all( + BuildParts.id == living_space.build_parts_id, + db=db_session, + ).data + if not build_parts_selection: + raise HTTPExceptionApi( + error_code="", lang="en", loc=get_line_number_for_error() + ) + + build_part = build_parts_selection.get(1) + build = build_part.buildings + occupant_type = OccupantTypes.filter_by_one( + id=living_space.occupant_type, + db=db_session, + system=True, + ).data + + occupant_data = { + "part_uu_id": str(build_part.uu_id), + "part_name": build_part.part_name, + "part_level": build_part.part_level, + "uu_id": str(occupant_type.uu_id), + "description": occupant_type.occupant_description, + "code": occupant_type.occupant_code, + } + + build_key = str(build.uu_id) + if build_key not in occupants_selection_dict: + occupants_selection_dict[build_key] = { + "build_uu_id": build_key, + "build_name": build.build_name, + "build_no": build.build_no, + "occupants": [occupant_data], + } + else: + occupants_selection_dict[build_key]["occupants"].append(occupant_data) + model_value = OccupantTokenObject( + domain=domain, + user_type=UserType.occupant.value, + user_uu_id=str(user.uu_id), + credentials=user.credentials(), + user_id=user.id, + person_id=user.person_id, + person_uu_id=str(user.person.uu_id), + request=dict(request.headers), + available_occupants=occupants_selection_dict, + timezone=user.local_timezone or "GMT+0", + lang=user.lang or "tr", + ).model_dump() + cls.set_object_to_redis(user, model_value) + return { + "user_type": UserType.occupant.name, + "available_occupants": occupants_selection_dict, + } + + @classmethod + def set_object_to_redis(cls, user, model: Dict): + accessObject = AccessToken( + userUUID=user.uu_id, + accessToken=cls._create_access_token(), + ) + return RedisActions.set_json( + list_keys=accessObject.to_list(), + value=json.dumps(model), + expires=Auth.TOKEN_EXPIRE_MINUTES_30.seconds, + ) + + @classmethod + def do_employee_login( + cls, request: "Request", user: Users, domain: str + ) -> Dict[str, Any]: + """Handle employee login process and return login information.""" + db_session = Employees.new_session() + list_employee = Employees.filter_all( + Employees.people_id == user.person_id, db=db_session + ).data + + companies_uu_id_list: List[str] = [] + companies_id_list: List[int] = [] + companies_list: List[Dict[str, Any]] = [] + duty_uu_id_list: List[str] = [] + duty_id_list: List[int] = [] + + for employee in list_employee: + staff = Staff.filter_one(Staff.id == employee.staff_id, db=db_session).data + if duties := Duties.filter_one( + Duties.id == staff.duties_id, db=db_session + ).data: + if duty_found := Duty.filter_by_one( + id=duties.duties_id, db=db_session + ).data: + duty_uu_id_list.append(str(duty_found.uu_id)) + duty_id_list.append(duty_found.id) + + department = Departments.filter_one( + Departments.id == duties.department_id, db=db_session + ).data + + if company := Companies.filter_one( + Companies.id == department.company_id, db=db_session + ).data: + companies_uu_id_list.append(str(company.uu_id)) + companies_id_list.append(company.id) + company_address = Addresses.filter_by_one( + id=company.official_address_id, db=db_session + ).data + + companies_list.append( + { + "uu_id": str(company.uu_id), + "public_name": company.public_name, + "company_type": company.company_type, + "company_address": company_address, + } + ) + + model_value = EmployeeTokenObject( + domain=domain, + user_type=UserType.employee.value, + user_uu_id=str(user.uu_id), + credentials=user.credentials(), + user_id=user.id, + person_id=user.person_id, + person_uu_id=str(user.person.uu_id), + request=dict(request.headers), + companies_uu_id_list=companies_uu_id_list, + companies_id_list=companies_id_list, + duty_uu_id_list=duty_uu_id_list, + duty_id_list=duty_id_list, + timezone=user.local_timezone or "GMT+0", + lang=user.lang or "tr", + ).model_dump() + if cls.set_object_to_redis(user, model_value): + return { + "user_type": UserType.employee.name, + "companies_list": companies_list, + } + raise HTTPExceptionApi( + error_code="", lang="en", loc=get_line_number_for_error() + ) + + @classmethod + def remove_token_with_domain(cls, user: Users, domain: str) -> None: + """Remove all tokens for a user with specific domain.""" + redis_rows = cls._get_user_tokens(user) + for redis_row in redis_rows.all: + if redis_row.get("domain") == domain: + RedisActions.delete_key(redis_row.key) + + @classmethod + def remove_all_token(cls, user: Users) -> None: + """Remove all tokens for a user.""" + redis_rows = cls._get_user_tokens(user) + RedisActions.delete([redis_row.key for redis_row in redis_rows.all]) + + @classmethod + def set_access_token_to_redis( + cls, + request: "Request", + user: Users, + domain: str, + remember: bool, + ) -> Dict[str, Any]: + """Set access token to redis and handle user session.""" + cls.remove_token_with_domain(user=user, domain=domain) + Users.client_arrow = DateTimeLocal(is_client=True, timezone=user.local_timezone) + db_session = UsersTokens.new_session() + # Handle login based on user type + login_dict = ( + cls.do_occupant_login(request=request, user=user, domain=domain) + if user.is_occupant + else ( + cls.do_employee_login(request=request, user=user, domain=domain) + if user.is_employee + else {} + ) + ) + + # Handle remember me functionality + if remember: + users_token = UsersTokens.find_or_create( + db=db_session, + user_id=user.id, + token_type="RememberMe", + token=cls._create_access_token(access=False), + domain=domain, + ) + if users_token.meta_data.get("created"): + user.remember_me = True + else: + if UsersTokens.filter_all( + UsersTokens.user_id == user.id, + UsersTokens.token_type == "RememberMe", + UsersTokens.domain == domain, + db=db_session, + ).data: + UsersTokens.filter_all( + UsersTokens.user_id == user.id, + UsersTokens.token_type == "RememberMe", + UsersTokens.domain == domain, + db=db_session, + ).query.delete(synchronize_session=False) + user.remember_me = False + user.save(db=db_session) + return { + **login_dict, + "user": user.get_dict(), + } + + @classmethod + def raise_error_if_request_has_no_token(cls, request: "Request") -> None: + """Validate request has required token headers.""" if not hasattr(request, "headers"): raise HTTPExceptionApi( - error_code="", - lang="en", + error_code="", lang="en", loc=get_line_number_for_error() ) if not request.headers.get(Auth.ACCESS_TOKEN_TAG): raise HTTPExceptionApi( - error_code="", - lang="en", + error_code="", lang="en", loc=get_line_number_for_error() ) @classmethod - def get_access_token_from_request(cls, request: Request) -> str: - """Get access token from request headers.""" + def access_token_is_valid(cls, request: "Request") -> bool: + """Check if access token in request is valid.""" + access_token = cls.get_access_token_from_request(request=request) + return RedisActions.get_json( + list_keys=AccessToken(accessToken=access_token).to_list() + ).status + + @classmethod + def get_access_token_from_request(cls, request: "Request") -> str: + """Extract access token from request headers.""" cls.raise_error_if_request_has_no_token(request=request) return request.headers.get(Auth.ACCESS_TOKEN_TAG) @classmethod - def get_object_via_access_key(cls, access_token: str) -> Union[EmployeeTokenObject, OccupantTokenObject]: - """Get access token from request headers.""" - access_token = AccessToken( - accessToken=access_token, - userUUID="", - ) - if redis_object := RedisActions.get_json( - list_keys=access_token.to_list() - ).first.data: - access_token.userUUID = redis_object.get("user_uu_id") - if redis_object.get("user_type") == 1: - if not redis_object.get("selected_company", None): - redis_object["selected_company"] = None - return EmployeeTokenObject(**redis_object) - elif redis_object.get("user_type") == 2: - if not redis_object.get("selected_occupant", None): - redis_object["selected_occupant"] = None - return OccupantTokenObject(**redis_object) + def _process_redis_object(cls, redis_object: Dict[str, Any]) -> T: + """Process Redis object and return appropriate token object.""" + if not redis_object.get("selected_company"): + redis_object["selected_company"] = None + if not redis_object.get("selected_occupant"): + redis_object["selected_occupant"] = None + + if redis_object.get("user_type") == UserType.employee.value: + return EmployeeTokenObject(**redis_object) + elif redis_object.get("user_type") == UserType.occupant.value: + return OccupantTokenObject(**redis_object) + raise HTTPExceptionApi( - error_code="", - lang="en", + error_code="", lang="en", loc=get_line_number_for_error() ) @classmethod - def get_object_via_user_uu_id(cls, user_id: str) -> Union[EmployeeTokenObject, OccupantTokenObject]: - """Get access token from user uuid.""" - access_token = AccessToken( - accessToken="", - userUUID=user_id, - ) - if redis_object := RedisActions.get_json( - list_keys=access_token.to_list() - ).first.data: - access_token.userUUID = redis_object.get("user_uu_id") - if redis_object.get("user_type") == 1: - if not redis_object.get("selected_company", None): - redis_object["selected_company"] = None - return EmployeeTokenObject(**redis_object) - elif redis_object.get("user_type") == 2: - if not redis_object.get("selected_occupant", None): - redis_object["selected_occupant"] = None - return OccupantTokenObject(**redis_object) + def get_object_via_access_key(cls, access_token: str) -> T: + """Get token object using access key.""" + access_token_obj = AccessToken(accessToken=access_token) + redis_response = RedisActions.get_json(list_keys=access_token_obj.to_list()) + + if redis_object := redis_response.first.data: + access_token_obj.userUUID = redis_object.get("user_uu_id") + return cls._process_redis_object(redis_object) + raise HTTPExceptionApi( - error_code="", - lang="en", + error_code="", lang="en", loc=get_line_number_for_error() + ) + + @classmethod + def get_object_via_user_uu_id(cls, user_id: str) -> T: + """Get token object using user UUID.""" + access_token = AccessToken(userUUID=user_id) + redis_response = RedisActions.get_json(list_keys=access_token.to_list()) + + if redis_object := redis_response.first.data: + access_token.userUUID = redis_object.get("user_uu_id") + return cls._process_redis_object(redis_object) + + raise HTTPExceptionApi( + error_code="", lang="en", loc=get_line_number_for_error() ) diff --git a/ApiServices/__init__.py b/ApiServices/__init__.py index a4a9419..be0bc54 100644 --- a/ApiServices/__init__.py +++ b/ApiServices/__init__.py @@ -1,5 +1,5 @@ from ApiServices.Token.token_handler import TokenService __all__ = [ - 'TokenService', -] \ No newline at end of file + "TokenService", +] diff --git a/ApiValidations/Request/authentication.py b/ApiValidations/Request/authentication.py index 2526e23..2e33382 100644 --- a/ApiValidations/Request/authentication.py +++ b/ApiValidations/Request/authentication.py @@ -1,7 +1,7 @@ from ApiValidations.Request import BaseModelRegular from typing import Optional -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field class ChangePasswordValidation: @@ -10,8 +10,14 @@ class ChangePasswordValidation: class ChangePassword(BaseModelRegular, ChangePasswordValidation): - old_password: str - new_password: str + old_password: str = Field(..., example="current123") + new_password: str = Field(..., example="newpass456") + + model_config = ConfigDict( + json_schema_extra={ + "example": {"old_password": "current123", "new_password": "newpass456"} + } + ) class CreatePasswordValidation: @@ -28,30 +34,53 @@ class CreatePasswordValidation: class CreatePassword(BaseModelRegular, CreatePasswordValidation): - password_token: str - password: str - re_password: str + password_token: str = Field(..., example="abc123token") + password: str = Field(..., example="newpass123") + re_password: str = Field(..., example="newpass123") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "password_token": "abc123token", + "password": "newpass123", + "re_password": "newpass123", + } + } + ) class OccupantSelectionValidation: - tr = {"occupant_uu_id": "Kiracı UU ID", "build_part_uu_id": "Bölüm UU ID"} en = {"occupant_uu_id": "Occupant UU ID", "build_part_uu_id": "Build Part UU ID"} class OccupantSelection(BaseModel, OccupantSelectionValidation): - occupant_uu_id: str - build_part_uu_id: str + occupant_uu_id: str = Field(..., example="123e4567-e89b-12d3-a456-426614174000") + build_part_uu_id: str = Field(..., example="987fcdeb-51a2-43e7-9876-543210987654") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "occupant_uu_id": "123e4567-e89b-12d3-a456-426614174000", + "build_part_uu_id": "987fcdeb-51a2-43e7-9876-543210987654", + } + } + ) class EmployeeSelectionValidation: - tr = {"company_uu_id": "Şirket UU ID"} en = {"company_uu_id": "Company UU ID"} class EmployeeSelection(BaseModel, EmployeeSelectionValidation): - company_uu_id: str + company_uu_id: str = Field(..., example="abcdef12-3456-7890-abcd-ef1234567890") + + model_config = ConfigDict( + json_schema_extra={ + "example": {"company_uu_id": "abcdef12-3456-7890-abcd-ef1234567890"} + } + ) class LoginValidation: @@ -70,10 +99,21 @@ class LoginValidation: class Login(BaseModelRegular, LoginValidation): - domain: str - access_key: str - password: str - remember_me: Optional[bool] = False + domain: str = Field(..., example="example.com") + access_key: str = Field(..., example="user@example.com") + password: str = Field(..., example="password123") + remember_me: Optional[bool] = Field(False, example=True) + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "domain": "example.com", + "access_key": "user@example.com", + "password": "password123", + "remember_me": True, + } + } + ) class LogoutValidation: @@ -82,7 +122,9 @@ class LogoutValidation: class Logout(BaseModelRegular, LogoutValidation): - domain: str + domain: str = Field(..., example="example.com") + + model_config = ConfigDict(json_schema_extra={"example": {"domain": "example.com"}}) class RememberValidation: @@ -91,8 +133,17 @@ class RememberValidation: class Remember(BaseModelRegular, RememberValidation): - domain: str - refresh_token: str + domain: str = Field(..., example="example.com") + refresh_token: str = Field(..., example="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "domain": "example.com", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + } + } + ) class ForgotValidation: @@ -101,5 +152,11 @@ class ForgotValidation: class Forgot(BaseModelRegular, ForgotValidation): - domain: str - access_key: str + domain: str = Field(..., example="example.com") + access_key: str = Field(..., example="user@example.com") + + model_config = ConfigDict( + json_schema_extra={ + "example": {"domain": "example.com", "access_key": "user@example.com"} + } + ) diff --git a/ApiValidations/Request/employee.py b/ApiValidations/Request/employee.py index 2735304..a1d5c24 100644 --- a/ApiValidations/Request/employee.py +++ b/ApiValidations/Request/employee.py @@ -62,7 +62,6 @@ class UpdateCompanyEmployeesSalaries(PydanticBaseModel): people_id: Optional[int] = None - class InsertCompanyEmployees(BaseModelRegular): employee_description: Optional[str] = None @@ -73,7 +72,6 @@ class InsertCompanyEmployees(BaseModelRegular): stop_date: Optional[str] = None - class UpdateCompanyEmployees(PydanticBaseModel): stop_date: Optional[str] = None employee_description: Optional[str] = None diff --git a/ApiValidations/Request/project_decision_book.py b/ApiValidations/Request/project_decision_book.py index 6004243..709852a 100644 --- a/ApiValidations/Request/project_decision_book.py +++ b/ApiValidations/Request/project_decision_book.py @@ -38,9 +38,7 @@ class UpdateBuildDecisionBookProjectPerson(PydanticBaseModel): project_team_type_uu_id: Optional[str] = None -class InsertBuildDecisionBookProjects( - BaseModelRegular -): +class InsertBuildDecisionBookProjects(BaseModelRegular): build_decision_book_item_uu_id: str project_responsible_person_uu_id: str project_name: str @@ -60,9 +58,7 @@ class InsertBuildDecisionBookProjects( resp_company_uu_id: Optional[str] = None -class UpdateBuildDecisionBookProjects( - PydanticBaseModel -): +class UpdateBuildDecisionBookProjects(PydanticBaseModel): build_decision_book_project_uu_id: str is_out_sourced: Optional[bool] = False project_note: Optional[str] = None @@ -76,10 +72,7 @@ class UpdateBuildDecisionBookProjects( approved_price: Optional[float] = None - -class ApprovalsBuildDecisionBookProjects( - PydanticBaseModel -): +class ApprovalsBuildDecisionBookProjects(PydanticBaseModel): build_decision_book_project_uu_id: str project_stop_date: str status_code: Optional[int] = None @@ -88,9 +81,7 @@ class ApprovalsBuildDecisionBookProjects( ) -class InsertBuildDecisionBookProjectItemDebits( - PydanticBaseModel -): +class InsertBuildDecisionBookProjectItemDebits(PydanticBaseModel): build_decision_book_project_item_uu_id: str payment_date: str dues_values: dict @@ -100,9 +91,7 @@ class InsertBuildDecisionBookProjectItemDebits( decision_taken: Optional[bool] = None -class UpdateBuildDecisionBookProjectItemDebits( - PydanticBaseModel -): +class UpdateBuildDecisionBookProjectItemDebits(PydanticBaseModel): dues_values: Optional[str] = None discount_value: Optional[float] = None discount_fix: Optional[float] = None diff --git a/ApiValidations/Response/account_responses.py b/ApiValidations/Response/account_responses.py index e349046..5e95080 100644 --- a/ApiValidations/Response/account_responses.py +++ b/ApiValidations/Response/account_responses.py @@ -5,6 +5,7 @@ from decimal import Decimal from uuid import UUID from pydantic import BaseModel + class AccountBooksResponse(BaseModel): """Response model for account books""" diff --git a/ApiValidations/handler.py b/ApiValidations/handler.py index e5f0214..e79d4c5 100644 --- a/ApiValidations/handler.py +++ b/ApiValidations/handler.py @@ -1,8 +1,12 @@ -from pydantic import BaseModel +""" +Base validation models and utilities. +""" + +from pydantic import BaseModel, ConfigDict def rewrite_input_data(data): - + """Remove empty and None values from input data.""" return { item[0]: item[1] for item in data.items() @@ -11,6 +15,11 @@ def rewrite_input_data(data): class BaseModelRegular(BaseModel): + """Base model for all validation models with proper schema handling.""" + + model_config = ConfigDict( + json_schema_extra={"example": {}} # Will be populated by subclasses + ) def __init__(self, **kwargs): super().__init__(**rewrite_input_data(kwargs)) @@ -20,3 +29,30 @@ class BaseModelRegular(BaseModel): def dump(self): return self.model_dump() + + @classmethod + def model_json_schema(cls, *args, **kwargs): + """Generate JSON schema with proper examples.""" + schema = super().model_json_schema(*args, **kwargs) + + # Add examples based on field types + if "properties" in schema: + example = {} + for field_name, field_schema in schema["properties"].items(): + field_type = field_schema.get("type") + if field_type == "string": + example[field_name] = f"example_{field_name}" + elif field_type == "integer": + example[field_name] = 0 + elif field_type == "number": + example[field_name] = 0.0 + elif field_type == "boolean": + example[field_name] = False + elif field_type == "array": + example[field_name] = [] + elif field_type == "object": + example[field_name] = {} + + schema["example"] = example + + return schema diff --git a/DockerApiServices/AllApiNeeds/app.py b/DockerApiServices/AllApiNeeds/app.py index 35e22a1..aff539d 100644 --- a/DockerApiServices/AllApiNeeds/app.py +++ b/DockerApiServices/AllApiNeeds/app.py @@ -20,15 +20,44 @@ from fastapi.openapi.utils import get_openapi def create_app() -> FastAPI: """Create and configure the FastAPI application.""" - app = FastAPI() - + app = FastAPI( + responses={ + 422: { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": { + "type": "array", + "items": { + "type": "object", + "properties": { + "loc": { + "type": "array", + "items": {"type": "string"}, + }, + "msg": {"type": "string"}, + "type": {"type": "string"}, + }, + }, + } + }, + } + } + }, + } + } + ) + # Get all routers and protected routes from the new configuration routers, protected_routes = get_all_routers() - + # Include all routers for router in routers: app.include_router(router) - + # Configure OpenAPI schema with security def custom_openapi(): if app.openapi_schema: @@ -47,7 +76,9 @@ def create_app() -> FastAPI: # Configure security for protected routes for path, methods in protected_routes.items(): for method in methods: - configure_route_security(path, method, openapi_schema, list(protected_routes.keys())) + configure_route_security( + path, method, openapi_schema, list(protected_routes.keys()) + ) app.openapi_schema = openapi_schema return app.openapi_schema diff --git a/DockerApiServices/AllApiNeeds/create_file.py b/DockerApiServices/AllApiNeeds/create_file.py index bf1977e..c110a8b 100644 --- a/DockerApiServices/AllApiNeeds/create_file.py +++ b/DockerApiServices/AllApiNeeds/create_file.py @@ -18,6 +18,7 @@ from AllConfigs.main import MainConfig as Config from create_routes import get_all_routers + def setup_security_schema() -> Dict[str, Any]: """ Configure security schema for the OpenAPI documentation. @@ -32,12 +33,13 @@ def setup_security_schema() -> Dict[str, Any]: "type": "http", "scheme": "bearer", "bearerFormat": "JWT", - "description": "Enter the token" + "description": "Enter the token", } } } } + def configure_route_security( path: str, method: str, schema: Dict[str, Any], protected_paths: List[str] ) -> None: @@ -55,6 +57,7 @@ def configure_route_security( if method.lower() in schema["paths"][path]: schema["paths"][path][method.lower()]["security"] = [{"Bearer": []}] + def create_app() -> FastAPI: """ Create and configure a FastAPI application with dynamic route creation. @@ -68,7 +71,7 @@ def create_app() -> FastAPI: description=Config.DESCRIPTION, default_response_class=JSONResponse, ) - + @app.get("/", include_in_schema=False, summary=str(Config.DESCRIPTION)) async def home() -> RedirectResponse: """Redirect root path to API documentation.""" @@ -76,11 +79,11 @@ def create_app() -> FastAPI: # Get all routers and protected routes using the dynamic route creation routers, protected_routes = get_all_routers() - + # Include all routers for router in routers: app.include_router(router) - + # Configure OpenAPI schema with security def custom_openapi(): if app.openapi_schema: @@ -100,7 +103,9 @@ def create_app() -> FastAPI: # Configure security for protected routes for path, methods in protected_routes.items(): for method in methods: - configure_route_security(path, method, openapi_schema, list(protected_routes.keys())) + configure_route_security( + path, method, openapi_schema, list(protected_routes.keys()) + ) app.openapi_schema = openapi_schema return app.openapi_schema diff --git a/DockerApiServices/AllApiNeeds/create_routes.py b/DockerApiServices/AllApiNeeds/create_routes.py index 25b9772..6f242ad 100644 --- a/DockerApiServices/AllApiNeeds/create_routes.py +++ b/DockerApiServices/AllApiNeeds/create_routes.py @@ -5,8 +5,8 @@ Handles dynamic route creation based on configurations. from typing import Optional, Dict, Any, List, Callable, TypeVar, ParamSpec -P = ParamSpec('P') # For function parameters -R = TypeVar('R') # For return type +P = ParamSpec("P") # For function parameters +R = TypeVar("R") # For return type from dataclasses import dataclass from functools import wraps @@ -17,7 +17,6 @@ from pydantic import BaseModel from AllConfigs.main import MainConfig as Config - @dataclass class EndpointFactoryConfig: endpoint: str @@ -39,17 +38,17 @@ class EndpointFactoryConfig: class EnhancedEndpointFactory: def __init__(self, router_config: dict): self.router = APIRouter( - prefix=router_config['prefix'], - tags=router_config['tags'], - include_in_schema=router_config.get('include_in_schema', True) + prefix=router_config["prefix"], + tags=router_config["tags"], + include_in_schema=router_config.get("include_in_schema", True), ) - self.endpoints = router_config['endpoints'] + self.endpoints = router_config["endpoints"] self.protected_routes: Dict[str, List[str]] = {} def create_endpoint(self, config: EndpointFactoryConfig): """ Create an endpoint directly from the configuration. - + Args: config: EndpointFactoryConfig instance containing endpoint configuration """ @@ -70,7 +69,7 @@ class EnhancedEndpointFactory: response_model=config.response_model, summary=config.summary, description=config.description, - **config.extra_options + **config.extra_options, )(endpoint_function) def get_router(self) -> APIRouter: @@ -94,13 +93,13 @@ async def ping_test(request: Request, service_name: str = "base-router"): def get_all_routers() -> tuple[List[APIRouter], Dict[str, List[str]]]: """ - Get all configured routers and their protected routes. - + Get all routers and protected routes from route configurations. + Returns: tuple: (routers, protected_routes) """ - from events.route_configs import get_route_configs - + from ApiEvents.route_configs import get_route_configs + routers = [] all_protected_routes = {} @@ -109,25 +108,23 @@ def get_all_routers() -> tuple[List[APIRouter], Dict[str, List[str]]]: factory_all = [] for config in route_configs: factory = EnhancedEndpointFactory(config) - + # Create endpoints from configuration - for endpoint_dict in config['endpoints']: + for endpoint_dict in config["endpoints"]: endpoint_config = EndpointFactoryConfig( - endpoint=endpoint_dict['endpoint'], - method=endpoint_dict['method'], - summary=endpoint_dict['summary'], - description=endpoint_dict['description'], - endpoint_function=endpoint_dict['endpoint_function'], - is_auth_required=endpoint_dict['is_auth_required'], - is_event_required=endpoint_dict['is_event_required'], - extra_options=endpoint_dict.get('extra_options', {}) + endpoint=endpoint_dict["endpoint"], + method=endpoint_dict["method"], + summary=endpoint_dict["summary"], + description=endpoint_dict["description"], + endpoint_function=endpoint_dict["endpoint_function"], + is_auth_required=endpoint_dict["is_auth_required"], + is_event_required=endpoint_dict["is_event_required"], + extra_options=endpoint_dict.get("extra_options", {}), ) factory.create_endpoint(endpoint_config) - factory_all.append( - endpoint_config.__dict__ - ) - + factory_all.append(endpoint_config.__dict__) + # Add router and protected routes routers.append(factory.get_router()) all_protected_routes.update(factory.get_protected_routes()) - return routers, all_protected_routes \ No newline at end of file + return routers, all_protected_routes diff --git a/DockerApiServices/AllApiNeeds/middleware/__init__.py b/DockerApiServices/AllApiNeeds/middleware/__init__.py index 8b13789..b25549f 100644 --- a/DockerApiServices/AllApiNeeds/middleware/__init__.py +++ b/DockerApiServices/AllApiNeeds/middleware/__init__.py @@ -1 +1,5 @@ +from .token_event_middleware import TokenEventMiddleware +from .auth_middleware import RequestTimingMiddleware, MiddlewareModule + +__all__ = ["TokenEventMiddleware", "RequestTimingMiddleware", "MiddlewareModule"] diff --git a/DockerApiServices/AllApiNeeds/middleware/auth_middleware.py b/DockerApiServices/AllApiNeeds/middleware/auth_middleware.py index 8b109cb..6e7850a 100644 --- a/DockerApiServices/AllApiNeeds/middleware/auth_middleware.py +++ b/DockerApiServices/AllApiNeeds/middleware/auth_middleware.py @@ -6,70 +6,83 @@ and a middleware for request timing measurements. """ from time import perf_counter -from typing import Callable, Optional, Dict, Any, Tuple +from typing import Callable, Optional, Dict, Any, Tuple, Union from functools import wraps -from fastapi import Request, Response from starlette.middleware.base import BaseHTTPMiddleware +from fastapi import Request, Response from AllConfigs.Token.config import Auth +from ApiLibrary.common.line_number import get_line_number_for_error from ErrorHandlers.ErrorHandlers.api_exc_handler import HTTPExceptionApi +from .base_context import BaseContext +from ApiServices.Token.token_handler import OccupantTokenObject, EmployeeTokenObject + + +class AuthContext(BaseContext): + """ + Context class for authentication middleware. + Extends BaseContext to provide authentication-specific functionality. + """ + + def __init__( + self, token_context: Union[OccupantTokenObject, EmployeeTokenObject] + ) -> None: + super().__init__() + self.token_context = token_context + + @property + def is_employee(self) -> bool: + """Check if authenticated token is for an employee.""" + return isinstance(self.token_context, EmployeeTokenObject) + + @property + def is_occupant(self) -> bool: + """Check if authenticated token is for an occupant.""" + return isinstance(self.token_context, OccupantTokenObject) + + @property + def user_id(self) -> str: + """Get the user's UUID from token context.""" + return self.token_context.user_uu_id if self.token_context else "" + + def __repr__(self) -> str: + user_type = "Employee" if self.is_employee else "Occupant" + return f"AuthContext({user_type}Token: {self.user_id})" class MiddlewareModule: """ - Module containing authentication and middleware functionality. - - This class provides: - - Token extraction and validation - - Authentication decorator for endpoints + Middleware module for handling authentication and request timing. """ @staticmethod - def get_access_token(request: Request) -> Tuple[str, str]: + def get_user_from_request( + request: Request, + ) -> AuthContext: """ - Extract access token from request headers. + Get authenticated token context from request. Args: request: FastAPI request object Returns: - Tuple[str, str]: A tuple containing (scheme, token) + AuthContext: Context containing the authenticated token data Raises: - HTTPExceptionApi: If token is missing or malformed + HTTPExceptionApi: If token is missing, invalid, or user not found """ - auth_header = request.headers.get(Auth.ACCESS_TOKEN_TAG) - if not auth_header: - raise HTTPExceptionApi(error_code="HTTP_401_UNAUTHORIZED", lang="tr") + from ApiServices.Token.token_handler import TokenService - try: - scheme, token = auth_header.split() - if scheme.lower() != "bearer": - raise HTTPExceptionApi(error_code="HTTP_401_UNAUTHORIZED", lang="tr") - return scheme, token - except ValueError: - raise HTTPExceptionApi(error_code="HTTP_401_UNAUTHORIZED", lang="tr") + # Get token and validate - will raise HTTPExceptionApi if invalid + redis_token = TokenService.get_access_token_from_request(request=request) - @staticmethod - async def validate_token(token: str) -> Dict[str, Any]: - """ - Validate the authentication token. + # Get token context - will validate token and raise appropriate errors + token_context = TokenService.get_object_via_access_key(access_token=redis_token) + if not token_context: + raise HTTPExceptionApi( + error_code="USER_NOT_FOUND", lang="tr", loc=get_line_number_for_error() + ) - Args: - token: JWT token to validate - - Returns: - Dict[str, Any]: User data extracted from token - - Raises: - HTTPExceptionApi: If token is invalid - """ - try: - # TODO: Implement your token validation logic - # Example: - # return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) - return {"user_id": "test", "role": "user"} # Placeholder - except Exception as e: - raise HTTPExceptionApi(error_code="HTTP_401_UNAUTHORIZED", lang="tr") + return AuthContext(token_context=token_context) @classmethod def auth_required(cls, func: Callable) -> Callable: @@ -80,30 +93,36 @@ class MiddlewareModule: @router.get("/protected") @MiddlewareModule.auth_required async def protected_endpoint(request: Request): - user = request.state.user # Access authenticated user data - return {"message": "Protected content"} - - @router.get("/public") # No decorator = public endpoint - async def public_endpoint(): - return {"message": "Public content"} + auth = protected_endpoint.auth # Access auth context + if auth.is_employee: + # Handle employee logic + employee_id = auth.token_context.employee_id + else: + # Handle occupant logic + occupant_id = auth.token_context.occupant_id + return {"user_id": auth.user_id} Args: func: The FastAPI route handler function to protect Returns: Callable: Wrapped function that checks authentication before execution + + Raises: + HTTPExceptionApi: If authentication fails """ @wraps(func) def wrapper(request: Request, *args, **kwargs): - from ApiServices import TokenService - # Get token from header - # token = TokenService.get_access_token_from_request(request=request) - # print(token) - # if not token: - # raise HTTPExceptionApi(error_code="NOT_AUTHORIZED", lang="tr") + # Get and validate token context from request + auth_context = cls.get_user_from_request(request) + + # Attach auth context to function + func.auth = auth_context + # Call the original endpoint function return func(request, *args, **kwargs) + return wrapper diff --git a/DockerApiServices/AllApiNeeds/middleware/base_context.py b/DockerApiServices/AllApiNeeds/middleware/base_context.py new file mode 100644 index 0000000..9586a01 --- /dev/null +++ b/DockerApiServices/AllApiNeeds/middleware/base_context.py @@ -0,0 +1,47 @@ +"""Base context for middleware.""" + +from typing import Optional, Dict, Any, Union, TYPE_CHECKING + +if TYPE_CHECKING: + from ApiServices.Token.token_handler import OccupantTokenObject, EmployeeTokenObject + + +class BaseContext: + """Base context class for middleware.""" + + def __init__(self) -> None: + self._token_context: Optional[ + Union["OccupantTokenObject", "EmployeeTokenObject"] + ] = None + self._function_code: Optional[str] = None + + @property + def token_context( + self, + ) -> Optional[Union["OccupantTokenObject", "EmployeeTokenObject"]]: + """Get token context if available.""" + return self._token_context + + @token_context.setter + def token_context( + self, value: Union["OccupantTokenObject", "EmployeeTokenObject"] + ) -> None: + """Set token context.""" + self._token_context = value + + @property + def function_code(self) -> Optional[str]: + """Get function code if available.""" + return self._function_code + + @function_code.setter + def function_code(self, value: str) -> None: + """Set function code.""" + self._function_code = value + + def to_dict(self) -> Dict[str, Any]: + """Convert context to dictionary.""" + return { + "token_context": self._token_context, + "function_code": self._function_code, + } diff --git a/DockerApiServices/AllApiNeeds/middleware/token_event_middleware.py b/DockerApiServices/AllApiNeeds/middleware/token_event_middleware.py new file mode 100644 index 0000000..632348f --- /dev/null +++ b/DockerApiServices/AllApiNeeds/middleware/token_event_middleware.py @@ -0,0 +1,76 @@ +""" +Token event middleware for handling authentication and event tracking. +""" + +from functools import wraps +from typing import Callable, Dict, Any +from .auth_middleware import MiddlewareModule +from .base_context import BaseContext + + +class TokenEventHandler(BaseContext): + """Handler for token events with authentication context.""" + + def __init__(self, func: Callable, url_of_endpoint: str): + """Initialize the handler with function and URL.""" + super().__init__() + self.func = func + self.url_of_endpoint = url_of_endpoint + + def update_context(self, function_code: str): + """Update the event context with function code.""" + self.function_code = function_code + + +class TokenEventMiddleware: + """ + Module containing token and event handling functionality. + + This class provides: + - Token and event context management + - Event validation decorator for endpoints + """ + + @staticmethod + def event_required( + func: Callable[..., Dict[str, Any]] + ) -> Callable[..., Dict[str, Any]]: + """ + Decorator for endpoints with token and event requirements. + This decorator: + 1. First validates authentication using MiddlewareModule.auth_required + 2. Then adds event tracking context + + Args: + func: The function to be decorated + + Returns: + Callable: The wrapped function with both auth and event handling + """ + # First apply authentication + authenticated_func = MiddlewareModule.auth_required(func) + + @wraps(authenticated_func) + def wrapper(*args, **kwargs) -> Dict[str, Any]: + # Create handler with context + handler = TokenEventHandler( + func=authenticated_func, + url_of_endpoint=authenticated_func.url_of_endpoint, + ) + + # Update event-specific context + handler.update_context( + function_code="7192c2aa-5352-4e36-98b3-dafb7d036a3d" # Keep function_code as URL + ) + + # Copy auth context from authenticated function + if hasattr(authenticated_func, "auth"): + handler.token_context = authenticated_func.auth.token_context + + # Make handler available to the function + authenticated_func.handler = handler + + # Call the authenticated function + return authenticated_func(*args, **kwargs) + + return wrapper diff --git a/DockerApiServices/AuthServiceApi/Dockerfile b/DockerApiServices/AuthServiceApi/Dockerfile index 008dc07..4f458bf 100644 --- a/DockerApiServices/AuthServiceApi/Dockerfile +++ b/DockerApiServices/AuthServiceApi/Dockerfile @@ -19,7 +19,7 @@ RUN poetry config virtualenvs.create false \ && rm -rf ~/.cache/pypoetry # Copy application code -COPY DockerApiServices/AllApiNeeds /app/ +COPY DockerApiServices/AllApiNeeds /app COPY ErrorHandlers /app/ErrorHandlers COPY LanguageModels /app/LanguageModels COPY ApiLibrary /app/ApiLibrary @@ -28,6 +28,15 @@ COPY AllConfigs /app/AllConfigs COPY ErrorHandlers /app/ErrorHandlers COPY Schemas /app/Schemas COPY Services /app/Services +COPY ApiServices /app/ApiServices + +# Copy Events structure with consistent naming +COPY ApiEvents/AuthServiceApi /app/ApiEvents +COPY ApiEvents/abstract_class.py /app/ApiEvents/abstract_class.py +COPY ApiEvents/base_request_model.py /app/ApiEvents/base_request_model.py + +# Create empty __init__.py files to make directories into Python packages +RUN touch /app/ApiEvents/__init__.py # Set Python path to include app directory ENV PYTHONPATH=/app \ diff --git a/DockerApiServices/EventServiceApi/Dockerfile b/DockerApiServices/EventServiceApi/Dockerfile index a50da42..6960bd4 100644 --- a/DockerApiServices/EventServiceApi/Dockerfile +++ b/DockerApiServices/EventServiceApi/Dockerfile @@ -19,7 +19,7 @@ RUN poetry config virtualenvs.create false \ && rm -rf ~/.cache/pypoetry # Copy application code -COPY DockerApiServices/AllApiNeeds /app/ +COPY DockerApiServices/AllApiNeeds /app COPY ErrorHandlers /app/ErrorHandlers COPY LanguageModels /app/LanguageModels COPY ApiLibrary /app/ApiLibrary @@ -31,9 +31,12 @@ COPY Services /app/Services COPY ApiServices /app/ApiServices # Copy Events structure with consistent naming -COPY ApiEvents/EventServiceApi /app/events -COPY ApiEvents/utils.py /app/events/utils.py -COPY ApiEvents/abstract_class.py /app/events/abstract_class.py +COPY ApiEvents/EventServiceApi /app/ApiEvents +COPY ApiEvents/abstract_class.py /app/ApiEvents/abstract_class.py +COPY ApiEvents/base_request_model.py /app/ApiEvents/base_request_model.py + +# Create empty __init__.py files to make directories into Python packages +RUN touch /app/ApiEvents/__init__.py # Set Python path to include app directory ENV PYTHONPATH=/app \ diff --git a/DockerApiServices/README.md b/DockerApiServices/README.md index 5d25f28..ad934c6 100644 --- a/DockerApiServices/README.md +++ b/DockerApiServices/README.md @@ -35,11 +35,9 @@ docker compose -f docker-compose-services.yml build --no-cache && docker compose ``` ## Service Ports -- Auth Service: `http://localhost:8000` - - `/test/health` - Protected health check endpoint (requires authentication) - - `/test/ping` - Public ping endpoint -- Event Service: `http://localhost:8001` -- Validation Service: `http://localhost:8002` +- Auth Service: http://localhost:41575 +- Event Service: http://localhost:41576 +- Validation Service: http://localhost:41577 ## Development Notes - Use clean build (--no-cache) when: diff --git a/DockerApiServices/ValidationServiceApi/Dockerfile b/DockerApiServices/ValidationServiceApi/Dockerfile index d7414b0..44d25a7 100644 --- a/DockerApiServices/ValidationServiceApi/Dockerfile +++ b/DockerApiServices/ValidationServiceApi/Dockerfile @@ -19,13 +19,21 @@ RUN poetry config virtualenvs.create false \ && rm -rf ~/.cache/pypoetry # Copy application code -COPY DockerApiServices/AllApiNeeds /app/ +COPY DockerApiServices/AllApiNeeds /app +COPY ErrorHandlers /app/ErrorHandlers +COPY LanguageModels /app/LanguageModels COPY ApiLibrary /app/ApiLibrary COPY ApiValidations /app/ApiValidations COPY AllConfigs /app/AllConfigs COPY ErrorHandlers /app/ErrorHandlers COPY Schemas /app/Schemas COPY Services /app/Services +COPY ApiServices /app/ApiServices + +# Copy Events structure with consistent naming +COPY ApiEvents/EventServiceApi /app/ApiEvents +COPY ApiEvents/abstract_class.py /app/ApiEvents/abstract_class.py +COPY ApiEvents/base_request_model.py /app/ApiEvents/base_request_model.py # Set Python path to include app directory ENV PYTHONPATH=/app \ diff --git a/ErrorHandlers/ErrorHandlers/api_exc_handler.py b/ErrorHandlers/ErrorHandlers/api_exc_handler.py index 05db22e..96d8f5f 100644 --- a/ErrorHandlers/ErrorHandlers/api_exc_handler.py +++ b/ErrorHandlers/ErrorHandlers/api_exc_handler.py @@ -18,14 +18,13 @@ class HTTPExceptionApiHandler: @staticmethod def retrieve_error_status_code(exc: HTTPExceptionApi) -> int: error_by_codes = BaseErrorModelClass.retrieve_error_by_codes() - grab_status_code = error_by_codes.get( - str(exc.error_code).upper(), 500 - ) + grab_status_code = error_by_codes.get(str(exc.error_code).upper(), 500) return int(grab_status_code) @staticmethod def retrieve_error_message(exc: HTTPExceptionApi, error_languages) -> str: from ErrorHandlers import DEFAULT_ERROR + return error_languages.get(str(exc.error_code).upper(), DEFAULT_ERROR) async def handle_exception( diff --git a/Schemas/__init__.py b/Schemas/__init__.py index f3278e2..1e28a98 100644 --- a/Schemas/__init__.py +++ b/Schemas/__init__.py @@ -1,5 +1,5 @@ # SQL Models -from Schemas.account.account import ( +from .account.account import ( AccountBooks, AccountCodeParser, AccountRecords, @@ -8,18 +8,18 @@ from Schemas.account.account import ( AccountMaster, AccountRecordExchanges, ) -from Schemas.building.budget import ( +from .building.budget import ( DecisionBookBudgetBooks, DecisionBookBudgetCodes, DecisionBookBudgetMaster, DecisionBookBudgets, ) -from Schemas.account.iban import ( +from .account.iban import ( BuildIbans, BuildIbanDescription, ) -from Schemas.api.encrypter import CrypterEngine -from Schemas.building.build import ( +from .api.encrypter import CrypterEngine +from .building.build import ( Build, BuildTypes, BuildParts, @@ -30,7 +30,7 @@ from Schemas.building.build import ( BuildCompaniesProviding, RelationshipEmployee2Build, ) -from Schemas.building.decision_book import ( +from .building.decision_book import ( BuildDecisionBook, BuildDecisionBookItems, BuildDecisionBookPerson, @@ -43,22 +43,22 @@ from Schemas.building.decision_book import ( BuildDecisionBookPersonOccupants, BuildDecisionBookProjectItems, ) -from Schemas.company.company import ( +from .company.company import ( Companies, RelationshipDutyCompany, ) -from Schemas.company.employee import ( +from .company.employee import ( Employees, EmployeesSalaries, EmployeeHistory, Staff, ) -from Schemas.company.department import ( +from .company.department import ( Duty, Duties, Departments, ) -from Schemas.event.event import ( +from .event.event import ( Modules, Services, Service2Events, @@ -68,7 +68,7 @@ from Schemas.event.event import ( Event2OccupantExtra, Event2EmployeeExtra, ) -from Schemas.identity.identity import ( +from .identity.identity import ( Addresses, AddressCity, AddressStreet, @@ -87,10 +87,10 @@ from Schemas.identity.identity import ( RelationshipEmployee2PostCode, Contracts, ) -from Schemas.others.enums import ( +from .others.enums import ( ApiEnumDropdown, ) -from Schemas.rules.rules import ( +from .rules.rules import ( EndpointRestriction, ) diff --git a/Schemas/identity/identity.py b/Schemas/identity/identity.py index e390528..eef7ce6 100644 --- a/Schemas/identity/identity.py +++ b/Schemas/identity/identity.py @@ -138,6 +138,10 @@ class Users(CrudCollection, UserLoginModule, SelectAction): def is_occupant(self): return not str(self.email).split("@")[1] == Auth.ACCESS_EMAIL_EXT + @property + def is_employee(self): + return str(self.email).split("@")[1] == Auth.ACCESS_EMAIL_EXT + @property def password_expiry_ends(self): """Calculates the expiry end date based on expiry begins and expires day""" @@ -165,8 +169,10 @@ class Users(CrudCollection, UserLoginModule, SelectAction): @classmethod def create_action(cls, create_user: InsertUsers, token_dict): + db_session = cls.new_session() found_person = People.filter_one( People.uu_id == create_user.people_uu_id, + db=db_session, ).data if not found_person: @@ -192,7 +198,10 @@ class Users(CrudCollection, UserLoginModule, SelectAction): @classmethod def credentials(cls): - person_object = People.filter_by_one(system=True, id=cls.person_id).data + db_session = cls.new_session() + person_object = People.filter_by_one( + db=db_session, system=True, id=cls.person_id + ).data if person_object: return { "person_id": person_object.id, @@ -206,17 +215,20 @@ class Users(CrudCollection, UserLoginModule, SelectAction): def get_employee_and_duty_details(self): from Schemas import Employees, Duties + db_session = self.new_session() found_person = People.filter_one( People.id == self.person_id, + db=db_session, ) found_employees = Employees.filter_by_active( - people_id=found_person.id, is_confirmed=True + people_id=found_person.id, is_confirmed=True, db=db_session ) found_duties = Duties.filter_all( Duties.is_confirmed == True, Duties.id.in_( list(found_employee.duty_id for found_employee in found_employees.data) ), + db=db_session, ) if not found_employees.count: raise HTTPException( diff --git a/Services/MongoDb/Models/exception_handlers.py b/Services/MongoDb/Models/exception_handlers.py index 031a60c..bc7d80a 100644 --- a/Services/MongoDb/Models/exception_handlers.py +++ b/Services/MongoDb/Models/exception_handlers.py @@ -10,6 +10,7 @@ from fastapi import Request, status from fastapi.responses import JSONResponse from pymongo.errors import PyMongoError, DuplicateKeyError, ConnectionFailure +from ApiLibrary.common.line_number import get_line_number_for_error from Services.MongoDb.Models.exceptions import ( MongoBaseException, MongoConnectionError, @@ -54,6 +55,7 @@ def handle_mongo_errors(func: Callable) -> Callable: raise HTTPExceptionApi( lang="en", error_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + loc=get_line_number_for_error(), ) return wrapper diff --git a/Services/MongoDb/Models/mixins.py b/Services/MongoDb/Models/mixins.py index c2d2177..da28ce8 100644 --- a/Services/MongoDb/Models/mixins.py +++ b/Services/MongoDb/Models/mixins.py @@ -20,6 +20,7 @@ from pymongo.errors import ( PyMongoError, ) +from ApiLibrary.common.line_number import get_line_number_for_error from ErrorHandlers.ErrorHandlers.api_exc_handler import HTTPExceptionApi @@ -34,14 +35,28 @@ def handle_mongo_errors(func): try: return func(*args, **kwargs) except ConnectionFailure: - raise HTTPExceptionApi(error_code="HTTP_503_SERVICE_UNAVAILABLE", lang="en") + raise HTTPExceptionApi( + error_code="HTTP_503_SERVICE_UNAVAILABLE", + lang="en", + loc=get_line_number_for_error(), + ) except ServerSelectionTimeoutError: - raise HTTPExceptionApi(error_code="HTTP_504_GATEWAY_TIMEOUT", lang="en") + raise HTTPExceptionApi( + error_code="HTTP_504_GATEWAY_TIMEOUT", + lang="en", + loc=get_line_number_for_error(), + ) except OperationFailure as e: - raise HTTPExceptionApi(error_code="HTTP_400_BAD_REQUEST", lang="en") + raise HTTPExceptionApi( + error_code="HTTP_400_BAD_REQUEST", + lang="en", + loc=get_line_number_for_error(), + ) except PyMongoError as e: raise HTTPExceptionApi( - error_code="HTTP_500_INTERNAL_SERVER_ERROR", lang="en" + error_code="HTTP_500_INTERNAL_SERVER_ERROR", + lang="en", + loc=get_line_number_for_error(), ) return wrapper diff --git a/Services/PostgresDb/Models/alchemy_response.py b/Services/PostgresDb/Models/alchemy_response.py index 67fa64c..0636337 100644 --- a/Services/PostgresDb/Models/alchemy_response.py +++ b/Services/PostgresDb/Models/alchemy_response.py @@ -18,6 +18,7 @@ from dataclasses import dataclass from fastapi import status from fastapi.responses import JSONResponse +from ApiLibrary.common.line_number import get_line_number_for_error from Services.PostgresDb.Models.response import PostgresResponse from ErrorHandlers.ErrorHandlers.api_exc_handler import HTTPExceptionApi from Services.pagination import Pagination, PaginationConfig @@ -159,6 +160,7 @@ class BaseJsonResponse(Generic[T]): raise HTTPExceptionApi( lang=cls_object.lang, error_code="HTTP_400_BAD_REQUEST", + loc=get_line_number_for_error(), ) @@ -198,6 +200,7 @@ class SinglePostgresResponse(BaseJsonResponse[T]): raise HTTPExceptionApi( lang=cls_object.lang, error_code="HTTP_400_BAD_REQUEST", + loc=get_line_number_for_error(), ) instance = super().__new__(cls) @@ -253,6 +256,7 @@ class AlchemyJsonResponse(BaseJsonResponse[T]): raise HTTPExceptionApi( lang=cls_object.lang, error_code="HTTP_400_BAD_REQUEST", + loc=get_line_number_for_error(), ) instance = super().__new__(cls) diff --git a/Services/PostgresDb/Models/filter_functions.py b/Services/PostgresDb/Models/filter_functions.py index b72b402..58655aa 100644 --- a/Services/PostgresDb/Models/filter_functions.py +++ b/Services/PostgresDb/Models/filter_functions.py @@ -16,6 +16,7 @@ from sqlalchemy.orm import Query, Session from sqlalchemy.sql.elements import BinaryExpression from ApiLibrary import system_arrow +from ApiLibrary.common.line_number import get_line_number_for_error from ErrorHandlers.Exceptions.api_exc import HTTPExceptionApi from Services.PostgresDb.Models.response import PostgresResponse @@ -137,6 +138,7 @@ class FilterAttributes: raise HTTPExceptionApi( error_code="HTTP_304_NOT_MODIFIED", lang=cls.lang or "tr", + loc=get_line_number_for_error(), ) @classmethod @@ -170,6 +172,7 @@ class FilterAttributes: raise HTTPExceptionApi( error_code="HTTP_304_NOT_MODIFIED", lang=cls.lang or "tr", + loc=get_line_number_for_error(), ) @classmethod @@ -189,6 +192,7 @@ class FilterAttributes: raise HTTPExceptionApi( error_code="HTTP_304_NOT_MODIFIED", lang=cls.lang or "tr", + loc=get_line_number_for_error(), ) @classmethod @@ -220,6 +224,7 @@ class FilterAttributes: raise HTTPExceptionApi( error_code="HTTP_304_NOT_MODIFIED", lang=cls.lang or "tr", + loc=get_line_number_for_error(), ) @classmethod @@ -522,5 +527,5 @@ class FilterAttributes: # """ # raise HTTPExceptionApi( # error_code="HTTP_304_NOT_MODIFIED", - # lang=cls.lang or "tr", + # lang=cls.lang or "tr", loc=get_line_number_for_error() # ) diff --git a/Services/Redis/Actions/actions.py b/Services/Redis/Actions/actions.py index 44f93d2..c799840 100644 --- a/Services/Redis/Actions/actions.py +++ b/Services/Redis/Actions/actions.py @@ -1,3 +1,4 @@ +import json import arrow from typing import Optional, List, Dict, Union @@ -20,6 +21,43 @@ class RedisActions: for unit, multiplier in time_multipliers.items() ) + @classmethod + def delete_key(cls, key: Union[Optional[str], Optional[bytes]]): + try: + redis_cli.delete(key) + return RedisResponse( + status=True, + message="Value is deleted successfully.", + ) + except Exception as e: + return RedisResponse( + status=False, + message="Value is not deleted successfully.", + error=str(e), + ) + + @classmethod + def delete( + cls, list_keys: List[Union[Optional[str], Optional[bytes]]] + ) -> RedisResponse: + try: + regex = RedisRow.regex(list_keys=list_keys) + json_get = redis_cli.scan_iter(match=regex) + + for row in list(json_get): + redis_cli.delete(row) + + return RedisResponse( + status=True, + message="Values are deleted successfully.", + ) + except Exception as e: + return RedisResponse( + status=False, + message="Values are not deleted successfully.", + error=str(e), + ) + @classmethod def set_json( cls, diff --git a/Services/Redis/Models/base.py b/Services/Redis/Models/base.py index 16e9502..7d706fa 100644 --- a/Services/Redis/Models/base.py +++ b/Services/Redis/Models/base.py @@ -131,7 +131,7 @@ class RedisRow: return key_str.split(cls.delimiter) @classmethod - def feed(cls, value: Union[bytes, Dict, List]) -> None: + def feed(cls, value: Union[bytes, Dict, List, str]) -> None: """ Convert and store value in JSON format. @@ -151,6 +151,8 @@ class RedisRow: cls.value = json.dumps(value) elif isinstance(value, bytes): cls.value = json.dumps(json.loads(value.decode())) + elif isinstance(value, str): + cls.value = value else: raise RedisValueError(f"Unsupported value type: {type(value)}") except json.JSONDecodeError as e: diff --git a/Services/Redis/Models/row.py b/Services/Redis/Models/row.py index 7fc83a8..4ac6099 100644 --- a/Services/Redis/Models/row.py +++ b/Services/Redis/Models/row.py @@ -1,12 +1,22 @@ -from pydantic import BaseModel +from typing import Optional +from uuid import UUID +from pydantic import BaseModel, validator class AccessToken(BaseModel): - accessToken: str - userUUID: str + accessToken: Optional[str] = None + userUUID: Optional[str] = None + + @validator("userUUID", pre=True) + def validate_uuid(cls, v): + """Convert UUID to string during validation.""" + if isinstance(v, UUID): + return str(v) + return v def to_list(self): + """Convert to list for Redis storage.""" return [self.accessToken, self.userUUID] @property