Source code for api.apis

"""These are the supported APIs for Cobalt.

Authentication is handled in the urls.py module, so by the time you get here you are dealing
with an authenticated user. Functions in here are still responsible for rbac calls to
handle access.

Code here should be as short as possible, if anything more complex is required you should
call a function in the 'home' module for the thing you are doing.

APIs should all be versioned with /vx.y at the end of the URI. This is automatically logged
every time an API is called.

APIs should all return JSON with at least one parameter e.g.

    {'status': 'Success'}

    or

    {'status: 'Failure'}

    or

    {'status: 'Access Denied'}

"""
from typing import List

from django.http import HttpResponse
from fcm_django.models import FCMDevice
from firebase_admin.messaging import Message, Notification
from ninja import Router, File, NinjaAPI, Schema, Form
from ninja.errors import ValidationError, HttpError
from ninja.files import UploadedFile

from accounts.backend import CobaltBackend
from accounts.views.api import create_user_session_id
from api.core import api_rbac
import api.urls as api_urls
from cobalt.settings import GLOBAL_TITLE
from logs.views import log_event
from masterpoints.factories import masterpoint_factory_creator
from notifications.apis import (
    notifications_api_file_upload_v1,
    notifications_api_unread_messages_for_user_v1,
    notifications_api_latest_messages_for_user_v1,
    notifications_delete_message_for_user_v1,
    notifications_delete_all_messages_for_user_v1,
)
from notifications.models import RealtimeNotification

router = Router()
api = NinjaAPI()

#########################################################
# Data Structures                                       #
#########################################################


[docs] class APIStatus: """Status messages from the API""" SUCCESS = "Success" FAILURE = "Failure" ACCESS_DENIED = "Access Denied"
[docs] class StatusResponseV1(Schema): """Standard error/response format when no data is returned""" status: str message: str
[docs] class UnauthorizedV1(Schema): """Standard error format""" detail: str
# class SmsResponseV1(Schema): # """Success response format from sms_file_upload""" # # status: str # sender: str # filename: str # attempted: int # sent: int
[docs] class UserDataResponseV1(Schema): """Response format from mobile_client_register_v1"""
[docs] class UserResponseV1(Schema): first_name: str last_name: str system_number: int
status: str user: UserResponseV1
[docs] class UserDataResponseV11(Schema): """Response format from mobile_client_register_v1.1"""
[docs] class UserResponseV1(Schema): first_name: str last_name: str system_number: int session_id: str
status: str user: UserResponseV1
[docs] class MobileClientRegisterRequestV1(Schema): """Request format from mobile_client_register_v1""" username: str = "" password: str = "" fcm_token: str = ""
[docs] class MobileClientRegisterRequestV11(Schema): """Request format from mobile_client_register_v1.1""" username: str = "" password: str = "" fcm_token: str = "" OS: str = "" name: str = ""
[docs] class MobileClientUpdateRequestV1(Schema): """Request format from mobile_client_update_v1""" old_fcm_token: str = "" new_fcm_token: str = ""
[docs] class MobileClientFCMRequestV1(Schema): fcm_token: str
[docs] class UnreadMessageV1(Schema): id: int message: str created_datetime: str
[docs] class MobileClientUnreadMessagesResponseV1(Schema): status: str un_read_messages: List[UnreadMessageV1]
[docs] class MobileClientSingleMessageRequestV1(Schema): """Structure for requests that access a single message by id""" fcm_token: str message_id: int
[docs] class NotificationFileUploadV1(Schema): sender_identification: str = None
[docs] @router.get("/keycheck/v1.0", tags=["Utility"]) def key_check_v1(request): """Allow a developer to check that their key is valid""" return f"Your key is valid. You are authenticated as {request.auth}."
[docs] @router.post( "/sms-file-upload/v1.0", # response={200: SmsResponseV1, 401: UnauthorizedV1, 403: ErrorV1}, summary="Deprecated. SMS file upload API for distribution of different messages to a list of players.", tags=["Notifications"], ) def sms_file_upload_v1(request, file: UploadedFile = File(...)): """Allow scorers to upload a file with ABF numbers and messages to send to members. File format is abf_number[tab character (\\t)]message The filename is used as the description. If the message contains \\<NL\\> then we change this to a newline (\\n). Messages over 140 characters will be sent as multiple SMSs. """ # Check access role = "notifications.realtime_send.edit" status, return_error = api_rbac(request, role) if not status: return return_error return notifications_api_file_upload_v1(request, file)
[docs] @router.post( "/notification-file-upload/v1.0", # response={200: SmsResponseV1, 401: UnauthorizedV1, 403: ErrorV1}, summary="Notification file upload API for distribution of different messages to a list of players.", tags=["Notifications"], ) def notification_file_upload_v1( request, data: NotificationFileUploadV1 = Form(...), file: UploadedFile = File(...) ): """Allow scorers to upload a file with ABF numbers and messages to send to members. File format is abf_number[tab character (\\t)]message The filename is used as the description. If the message contains \\<NL\\> then we change this to a newline (\\n). Messages are sent through Google Firebase Messaging to a mobile app. """ # Check access role = "notifications.realtime_send.edit" status, return_error = api_rbac(request, role) if not status: return return_error return notifications_api_file_upload_v1(request, file, data.sender_identification)
[docs] @router.post( "/mobile-client-register/v1.0", summary="Register a mobile client to receive notifications.", response={ 200: UserDataResponseV1, 403: StatusResponseV1, }, # Disable global authorisation, we will check this ourselves auth=None, tags=["Mobile App"], ) def mobile_client_register_v1(request, data: MobileClientRegisterRequestV1): """ Called by the Flutter front end to register a new FCM Token for a user. User is NOT authenticated, but username and password are passed in. Args: username - username of this user, can be ABF No, email or actual username, same as login password - as provided by user fcm_token - client device's Google Firebase Cloud Messaging token. Used to send messages to this user/device """ # Log Api call ourselves as we aren't going through authentication api_urls.log_api_call(request) # Generic Log log_event( "Unknown", "INFO", "Mobile", "Registration", f"Mobile Register Client 1 - Attempt using username:{data.username} fcm_token: {data.fcm_token}", ) # Validate if data.fcm_token == "": # Don't provide any details about failures for security reasons return 403, {"status": APIStatus.FAILURE, "message": APIStatus.ACCESS_DENIED} # Try to Authenticate the user user = CobaltBackend().authenticate(request, data.username, data.password) if user: # Save device fcm_device = FCMDevice(user=user, registration_id=data.fcm_token) fcm_device.save() # Mark all messages previously sent to the user as read to prevent swamping them with old messages RealtimeNotification.objects.filter(member=user).update(has_been_read=True) # Create a welcome message welcome_msg = ( f"Hi {user.first_name},\n" f"This device is now set up to receive messages from {GLOBAL_TITLE}.\n\n" f"You can manage your devices through Settings within {GLOBAL_TITLE}." ) RealtimeNotification( member=user, admin=user, msg=welcome_msg, ).save() msg = Message( notification=Notification( title=f"Welcome to {GLOBAL_TITLE} messaging.", body="Welcome!\n\nNew device successfully registered.", ) ) fcm_device.send_message(msg) return 200, { "status": APIStatus.SUCCESS, "user": { "first_name": user.first_name, "last_name": user.last_name, "system_number": user.system_number, }, } # Don't provide any details about failures for security reasons return 403, {"status": APIStatus.FAILURE, "message": APIStatus.ACCESS_DENIED}
[docs] @router.post( "/mobile-client-register/v1.1", summary="Register a mobile client to receive notifications.", response={ 200: UserDataResponseV11, 403: StatusResponseV1, }, # Disable global authorisation, we will check this ourselves auth=None, tags=["Mobile App"], ) def mobile_client_register_v11(request, data: MobileClientRegisterRequestV11): """ Called by the Flutter front end to register a new FCM Token for a user. User is NOT authenticated, but username and password are passed in. Args: username - username of this user, can be ABF No, email or actual username, same as login password - as provided by user fcm_token - client device's Google Firebase Cloud Messaging token. Used to send messages to this user/device OS - operating system of mobile device name - name of mobile device """ # Log Api call ourselves as we aren't going through authentication api_urls.log_api_call(request) # Generic Log log_event( "Unknown", "INFO", "Mobile", "Registration", f"Mobile Client Register V11 - Attempt using username:{data.username} fcm_token: {data.fcm_token} os:{data.OS} name: {data.name}", ) # Validate if not data.fcm_token: log_event( "Unknown", "ERROR", "Mobile", "Registration", f"Mobile Client Register V11 - FCM Token is None. Attempt using username:{data.username} fcm_token: {data.fcm_token}", ) # Don't provide any details about failures for security reasons return 403, {"status": APIStatus.FAILURE, "message": APIStatus.ACCESS_DENIED} if data.fcm_token == "": log_event( "Unknown", "ERROR", "Mobile", "Registration", f"Mobile Client Register V11 - FCM Token is empty string. Attempt using username:{data.username} fcm_token: {data.fcm_token}", ) # Don't provide any details about failures for security reasons return 403, {"status": APIStatus.FAILURE, "message": APIStatus.ACCESS_DENIED} # Try to Authenticate the user user = CobaltBackend().authenticate(request, data.username, data.password) if not user: log_event( "Unknown", "ERROR", "Mobile", "Registration", f"Mobile Client Register V11 - User not found. Attempt using username:{data.username} fcm_token: {data.fcm_token}", ) # Don't provide any details about failures for security reasons return 403, {"status": APIStatus.FAILURE, "message": APIStatus.ACCESS_DENIED} # Set a value for an empty name if data.name == "": data.name = "Mobile Device" # Delete token if used before (token will be the same if a user logs out and another logs in, same device) FCMDevice.objects.filter(registration_id=data.fcm_token).delete() # Save new device fcm_device = FCMDevice( user=user, type=data.OS, name=data.name, registration_id=data.fcm_token ) fcm_device.save() log_event( "Unknown", "INFO", "Mobile", "Registration", f"Mobile Client Register V11 - Registered new fcm_device. Attempt using username:{data.username} fcm_token: {data.fcm_token}", ) # Mark all messages previously sent to the user as read to prevent swamping them with old messages RealtimeNotification.objects.filter(member=user).update(has_been_read=True) # Create a welcome message welcome_msg = ( f"Hi {user.first_name},\n" f"This device ({data.name}) is now set up to receive messages from {GLOBAL_TITLE}.\n\n" f"You can manage your devices through Settings within {GLOBAL_TITLE}." ) RealtimeNotification( member=user, admin=user, msg=welcome_msg, ).save() msg = Message( notification=Notification( title=f"Welcome to {GLOBAL_TITLE} messaging.", body="Welcome!\n\nNew device successfully registered.", ) ) fcm_device.send_message(msg) # Get a session id for this user session_id = create_user_session_id(user) return 200, { "status": APIStatus.SUCCESS, "user": { "first_name": user.first_name, "last_name": user.last_name, "system_number": user.system_number, "session_id": session_id, }, }
[docs] @router.get( "/system-number-lookup/v1.0", summary="Get name from system number", response={ 200: UserDataResponseV1, 404: StatusResponseV1, }, # Disable global authorisation, this can be called by anyone auth=None, tags=["Utility"], ) def system_number_lookup_v1(request, system_number: int): # Masterpoints uses a factory - get an instance to talk to mp_source = masterpoint_factory_creator() # Call function to lookup system_number status, return_value = mp_source.system_number_lookup_api(system_number) if status: return 200, { "status": APIStatus.SUCCESS, "user": { "first_name": return_value[0], "last_name": return_value[1], "system_number": system_number, }, } else: return 404, {"status": APIStatus.FAILURE, "message": return_value}
[docs] @router.post( "/mobile-client-update/v1.0", summary="Update a users FCM Token, takes old token as security check", response={ 200: UserDataResponseV1, 404: StatusResponseV1, }, # Disable global authorisation, we use the old token as the authentication method auth=None, tags=["Mobile App"], ) def mobile_client_update_v1(request, data: MobileClientUpdateRequestV1): """Called to update the FCM token""" # Log Api call ourselves as we aren't going through authentication api_urls.log_api_call(request) # Not sure if we really need this. Log it to find out. log_event( "Unknown", "INFO", "Mobile", "Update", f"Mobile Client Update V1 - Attempt using old token: {data.old_fcm_token} new_token: {data.new_fcm_token}", ) # Validate if not data.new_fcm_token: log_event( "Unknown", "ERROR", "Mobile", "Update", f"Mobile Client Update V1 - New FCM Token is None. Old token : {data.old_fcm_token}", ) # Don't provide any details about failures for security reasons return 404, {"status": APIStatus.FAILURE, "message": APIStatus.ACCESS_DENIED} if data.new_fcm_token == "": log_event( "Unknown", "ERROR", "Mobile", "Update", f"Mobile Client Update V1 - New FCM Token is Empty string. Old token : {data.old_fcm_token}", ) # Don't provide any details about failures for security reasons return 404, {"status": APIStatus.FAILURE, "message": APIStatus.ACCESS_DENIED} fcm_device = ( FCMDevice.objects.filter(registration_id=data.old_fcm_token) .select_related("user") .first() ) if not fcm_device: return 404, { "status": APIStatus.FAILURE, "message": f"Existing token not found ({data.old_fcm_token})", } fcm_device.registration_id = data.new_fcm_token fcm_device.save() return 200, { "status": APIStatus.SUCCESS, "user": { "first_name": fcm_device.user.first_name, "last_name": fcm_device.user.last_name, "system_number": fcm_device.user.system_number, }, }
[docs] @router.post( "/mobile-client-get-unread-messages/v1.0", summary="Get unread messages for a user by passing FCM_token", response={ 200: MobileClientUnreadMessagesResponseV1, 403: StatusResponseV1, 404: StatusResponseV1, }, # Disable global authorisation, we use the token as the authentication method auth=None, tags=["Mobile App"], ) def api_notifications_unread_messages_for_user_v1( request, data: MobileClientFCMRequestV1 ): """Return any unread messages for this user""" # Log Api call ourselves as we aren't going through authentication api_urls.log_api_call(request) return notifications_api_unread_messages_for_user_v1(data.fcm_token)
[docs] @router.post( "/mobile-client-get-latest-messages/v1.0", summary="Get latest messages (max 50) for a user, regardless of if they are read, by passing FCM_token", response={ 200: MobileClientUnreadMessagesResponseV1, 403: StatusResponseV1, 404: StatusResponseV1, }, # Disable global authorisation, we use the token as the authentication method auth=None, tags=["Mobile App"], ) def api_notifications_latest_messages_for_user_v1( request, data: MobileClientFCMRequestV1 ): """Return last 50 messages for this user""" # Log Api call ourselves as we aren't going through authentication api_urls.log_api_call(request) return notifications_api_latest_messages_for_user_v1(data.fcm_token)
[docs] @router.post( "/mobile-client-delete-message/v1.0", summary="Delete a single message", response={ 200: StatusResponseV1, 403: StatusResponseV1, 404: StatusResponseV1, }, # Disable global authorisation, we use the token as the authentication method auth=None, tags=["Mobile App"], ) def api_notifications_delete_message_for_user_v1( request, data: MobileClientSingleMessageRequestV1 ): """Delete a single message""" # Log Api call ourselves as we aren't going through authentication api_urls.log_api_call(request) return notifications_delete_message_for_user_v1(data)
[docs] @router.post( "/mobile-client-delete-all-messages/v1.0", summary="Delete all messages for a user", response={ 200: StatusResponseV1, 403: StatusResponseV1, 404: StatusResponseV1, }, # Disable global authorisation, we use the token as the authentication method auth=None, tags=["Mobile App"], ) def api_notifications_delete_all_messages_for_user_v1( request, data: MobileClientFCMRequestV1 ): """Delete all message""" # Log Api call ourselves as we aren't going through authentication api_urls.log_api_call(request) return notifications_delete_all_messages_for_user_v1(data)