diff --git a/.gitignore b/.gitignore index 88727ca..333c032 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +scoop-dev.pem # Byte-compiled / optimized / DLL files __pycache__/ @@ -75,4 +76,5 @@ env/ venv/ ENV/ env.bak/ -venv.bak/ \ No newline at end of file +venv.bak/ +credentials.json \ No newline at end of file diff --git a/envrc.template b/envrc.template index af68cec..c632811 100644 --- a/envrc.template +++ b/envrc.template @@ -1,6 +1,7 @@ export AUTH_PASSWORD_SALT=FILL_IN export DJANGO_SECRET_KEY=FILL_IN export DJANGO_DEBUG=FILL_IN +export FCM_API_KEY=FILL_IN export GOOGLE_DEBUG=FILL_IN export IMAGE_BUCKET_NAME=FILL_IN export IMAGE_UPLOAD_URL=FILL_IN \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1547159..2eb80a2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,25 @@ asgiref==3.5.0 attrs==23.1.0 black==22.1.0 +CacheControl==0.13.1 cachetools==5.0.0 certifi==2021.10.8 +cffi==1.16.0 cfgv==3.3.1 charset-normalizer==2.0.12 click==8.0.3 coreapi==2.3.3 coreschema==0.0.4 +cryptography==41.0.4 distlib==0.3.4 Django==4.0.2 django-rest-framework==0.1.0 django-rest-swagger==2.2.0 djangorestframework==3.13.1 drf-spectacular==0.26.4 +fcm-django==2.0.0 filelock==3.4.2 +firebase-admin==6.0.1 flake8==4.0.1 flake8-import-order==0.18.1 geographiclib==2.0 @@ -23,7 +28,14 @@ google-api-core==2.5.0 google-api-python-client==2.37.0 google-auth==2.6.0 google-auth-httplib2==0.1.0 +google-cloud-core==2.3.3 +google-cloud-firestore==2.5.3 +google-cloud-storage==2.11.0 +google-crc32c==1.5.0 +google-resumable-media==2.6.0 googleapis-common-protos==1.54.0 +grpcio==1.59.0 +grpcio-status==1.48.2 httplib2==0.20.4 identify==2.4.8 idna==3.3 @@ -34,18 +46,22 @@ jsonschema==4.19.0 jsonschema-specifications==2023.7.1 MarkupSafe==2.1.3 mccabe==0.6.1 +msgpack==1.0.7 mypy-extensions==0.4.3 nodeenv==1.6.0 openapi-codec==1.3.2 pathspec==0.9.0 platformdirs==2.4.1 pre-commit==2.17.0 +proto-plus==1.22.3 protobuf==3.19.4 psycopg2==2.9.6 pyasn1==0.4.8 pyasn1-modules==0.2.8 pycodestyle==2.8.0 +pycparser==2.21 pyflakes==2.4.0 +PyJWT==2.8.0 pyparsing==3.0.7 pytz==2021.3 PyYAML==6.0 diff --git a/src/api/urls.py b/src/api/urls.py index 96a5c9a..0871398 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -1,23 +1,29 @@ -from django.urls import include -from django.urls import path -from django.urls import re_path +from django.urls import include, path, re_path from person.views import AuthenticateView from person.views import DeveloperView from person.views import MeView +from person.views import SendMessageView from ride.views import RidesView from ride.views import RideView from ride.views import SearchView from rest_framework_swagger.views import get_swagger_view +from rest_framework.routers import DefaultRouter +from fcm_django.api.rest_framework import FCMDeviceViewSet schema_view = get_swagger_view(title='Pastebin API') +router = DefaultRouter() +router.register(r'devices', FCMDeviceViewSet) + urlpatterns = [ path("authenticate/", AuthenticateView.as_view(), name="authenticate"), path("dev/", DeveloperView.as_view(), name="dev"), path("me/", MeView.as_view(), name="me"), + path("users//message/", SendMessageView.as_view(), name="message"), path("rides//", RideView.as_view(), name="ride"), path("rides/", RidesView.as_view(), name="rides"), path("search/", SearchView.as_view(), name="search"), re_path(r"^requests/", include("request.urls")), - re_path(r"^prompts/", include("prompts.urls")) + re_path(r"^prompts/", include("prompts.urls")), + re_path(r"^", include(router.urls)), ] diff --git a/src/person/controllers/send_message_controller.py b/src/person/controllers/send_message_controller.py new file mode 100644 index 0000000..247f8c1 --- /dev/null +++ b/src/person/controllers/send_message_controller.py @@ -0,0 +1,45 @@ +from api.utils import failure_response +from api.utils import success_response +from django.contrib.auth.models import User +from fcm_django.models import FCMDevice +from firebase_admin.messaging import Message, Notification +from rest_framework import status + + +class SendMessageController: + def __init__(self, user, data, receiving_user_id): + self._user = user + self._data = data + self._receiving_user_id = receiving_user_id + + def process(self): + message = self._data.get("message") + if not message: + return failure_response("Message is required", status.HTTP_400_BAD_REQUEST) + receiving_user = User.objects.filter(id=self._receiving_user_id) + if not receiving_user: + return failure_response( + "Receiving user not found", status.HTTP_404_NOT_FOUND + ) + receiving_user = receiving_user[0] + if not receiving_user.person.fcm_registration_token: + # Receiving user does not have notifications enabled, but message will still be delivered through Firebase + # device = FCMDevice() + # device.registration_id = ... + return success_response(status=status.HTTP_200_OK) + device = FCMDevice.objects.get( + registration_id=receiving_user.person.fcm_registration_token + ) + response = device.send_message( + Message( + notification=Notification( + title=f"New Ride Request from {self._user.first_name}", body=message + ) + ) + ) + if response.get("failure") == 1: + return failure_response( + f"Failed to send message, FCM Response: {response}", + status.HTTP_502_BAD_GATEWAY, + ) + return success_response(status=status.HTTP_201_CREATED) diff --git a/src/person/controllers/update_controller.py b/src/person/controllers/update_controller.py index 84260fa..7666591 100644 --- a/src/person/controllers/update_controller.py +++ b/src/person/controllers/update_controller.py @@ -5,6 +5,7 @@ from api.utils import update from django.contrib.auth.models import User from prompts.models import Prompt +from fcm_django.models import FCMDevice from ..utils import remove_profile_pic from ..utils import upload_profile_pic @@ -21,6 +22,7 @@ def process(self): netid = self._data.get("netid") first_name = self._data.get("first_name") last_name = self._data.get("last_name") + fcm_registration_token = self._data.get("fcm_registration_token") grade = self._data.get("grade") phone_number = self._data.get("phone_number") profile_pic_base64 = self._data.get("profile_pic_base64") @@ -44,6 +46,18 @@ def process(self): self._person.prompt_questions.set(prompt_ids) update(self._person, "prompt_answers", json.dumps(prompt_answers)) + if fcm_registration_token is not None and self._person.fcm_registration_token != fcm_registration_token: + FCMDevice.objects.filter( + registration_id=self._person.fcm_registration_token + ).delete() + self._person.fcm_registration_token = fcm_registration_token + fcm_device = FCMDevice.objects.create( + registration_id=fcm_registration_token, + cloud_message_type="FCM", + user=self._user, + ) + self._user.fcm_device = fcm_device + update(self._person, "netid", netid) update(self._user, "first_name", first_name) update(self._user, "last_name", last_name) diff --git a/src/person/migrations/0002_person_fcm_registration_token.py b/src/person/migrations/0002_person_fcm_registration_token.py new file mode 100644 index 0000000..f1e08ee --- /dev/null +++ b/src/person/migrations/0002_person_fcm_registration_token.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.2 on 2023-10-05 18:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('person', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='person', + name='fcm_registration_token', + field=models.TextField(default=None, null=True), + ), + ] diff --git a/src/person/models.py b/src/person/models.py index ba6d405..4814d6b 100644 --- a/src/person/models.py +++ b/src/person/models.py @@ -9,8 +9,10 @@ class Person(models.Model): user = models.OneToOneField( User, on_delete=models.CASCADE, unique=True, default=None ) + fcm_registration_token = models.TextField(default=None, null=True) grade = models.CharField(max_length=20, default=None, null=True) profile_pic_url = models.TextField(default=None, null=True) pronouns = models.CharField(max_length=20, default=None, null=True) prompt_questions = models.ManyToManyField(Prompt, default=None, blank=True) prompt_answers = models.TextField(default=None, null=True) + diff --git a/src/person/views.py b/src/person/views.py index dcd2d8a..19a3647 100644 --- a/src/person/views.py +++ b/src/person/views.py @@ -9,6 +9,7 @@ from .controllers.authenticate_controller import AuthenticateController from .controllers.developer_controller import DeveloperController from .controllers.update_controller import UpdatePersonController +from .controllers.send_message_controller import SendMessageController from .serializers import AuthenticateSerializer from .serializers import UserSerializer @@ -64,3 +65,8 @@ def post(self, request): except json.JSONDecodeError: data = request.data return DeveloperController(request, data, self.serializer_class, id).process() + +class SendMessageView(generics.GenericAPIView): + def post(self, request, id): + """Send push notification to user by user id.""" + return SendMessageController(request.user, request.data, id).process() diff --git a/src/request/migrations/0002_alter_request_timestamp.py b/src/request/migrations/0002_alter_request_timestamp.py new file mode 100644 index 0000000..3c0553d --- /dev/null +++ b/src/request/migrations/0002_alter_request_timestamp.py @@ -0,0 +1,20 @@ +# Generated by Django 4.0.2 on 2023-10-05 18:24 + +import datetime +from django.db import migrations, models +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + dependencies = [ + ('request', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='request', + name='timestamp', + field=models.DateTimeField(default=datetime.datetime(2023, 10, 5, 18, 24, 37, 364420, tzinfo=utc)), + ), + ] diff --git a/src/request/migrations/0003_alter_request_timestamp.py b/src/request/migrations/0003_alter_request_timestamp.py new file mode 100644 index 0000000..923fb39 --- /dev/null +++ b/src/request/migrations/0003_alter_request_timestamp.py @@ -0,0 +1,20 @@ +# Generated by Django 4.0.2 on 2023-11-08 22:56 + +import datetime +from django.db import migrations, models +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + dependencies = [ + ('request', '0002_alter_request_timestamp'), + ] + + operations = [ + migrations.AlterField( + model_name='request', + name='timestamp', + field=models.DateTimeField(default=datetime.datetime(2023, 11, 8, 22, 56, 23, 143367, tzinfo=utc)), + ), + ] diff --git a/src/request/migrations/0004_alter_request_timestamp.py b/src/request/migrations/0004_alter_request_timestamp.py new file mode 100644 index 0000000..6a904ce --- /dev/null +++ b/src/request/migrations/0004_alter_request_timestamp.py @@ -0,0 +1,20 @@ +# Generated by Django 4.0.2 on 2023-11-12 17:34 + +import datetime +from django.db import migrations, models +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + dependencies = [ + ('request', '0003_alter_request_timestamp'), + ] + + operations = [ + migrations.AlterField( + model_name='request', + name='timestamp', + field=models.DateTimeField(default=datetime.datetime(2023, 11, 12, 17, 34, 6, 28835, tzinfo=utc)), + ), + ] diff --git a/src/rideshare/settings.py b/src/rideshare/settings.py index cf57c50..8f71f1f 100644 --- a/src/rideshare/settings.py +++ b/src/rideshare/settings.py @@ -12,6 +12,22 @@ from os import environ from pathlib import Path +from firebase_admin import initialize_app, credentials +from google.auth import load_credentials_from_file +from google.oauth2.service_account import Credentials + + +# create a custom Credentials class to load a non-default google service account JSON +class CustomFirebaseCredentials(credentials.ApplicationDefault): + def __init__(self, account_file_path: str): + super().__init__() + self._account_file_path = account_file_path + + def _load_credential(self): + if not self._g_credential: + self._g_credential, self._project_id = load_credentials_from_file(self._account_file_path, + scopes=credentials._scopes) + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -42,6 +58,7 @@ "rest_framework.authtoken", "rest_framework", "drf_spectacular", + "fcm_django", "person", "ride", "path", @@ -91,6 +108,19 @@ 'TITLE': 'Scooped API', } +FIREBASE_APP = initialize_app() + +custom_credentials = CustomFirebaseCredentials(environ.get('CUSTOM_GOOGLE_APPLICATION_CREDENTIALS')) +FIREBASE_MESSAGING_APP = initialize_app(custom_credentials, name='messaging') + +FCM_DJANGO_SETTINGS = { + "APP_VERBOSE_NAME": "scooped", + "DEFAULT_FIREBASE_APP": FIREBASE_MESSAGING_APP, + # devices to which notifications cannot be sent, + # are deleted upon receiving error response from FCM + # default: False + "DELETE_INACTIVE_DEVICES": True, +} # Database # https://docs.djangoproject.com/en/4.0/ref/settings/#databases diff --git a/src/schema.yml b/src/schema.yml index 61d1665..1564945 100644 --- a/src/schema.yml +++ b/src/schema.yml @@ -3,26 +3,6 @@ info: title: Scooped API version: 0.0.0 paths: - /api/: - get: - operationId: api_retrieve - parameters: - - in: query - name: format - schema: - type: string - enum: - - corejson - - openapi - - swagger - tags: - - api - security: - - tokenAuth: [] - - {} - responses: - '200': - description: No response body /api/authenticate/: post: operationId: api_authenticate_create @@ -91,6 +71,153 @@ paths: schema: $ref: '#/components/schemas/Authenticate' description: '' + /api/devices/: + get: + operationId: api_devices_list + tags: + - api + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/FCMDevice' + description: '' + post: + operationId: api_devices_create + tags: + - api + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/FCMDevice' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/FCMDevice' + multipart/form-data: + schema: + $ref: '#/components/schemas/FCMDevice' + required: true + security: + - tokenAuth: [] + - {} + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/FCMDevice' + description: '' + /api/devices/{registration_id}/: + get: + operationId: api_devices_retrieve + parameters: + - in: path + name: registration_id + schema: + type: string + title: Registration token + required: true + tags: + - api + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/FCMDevice' + description: '' + put: + operationId: api_devices_update + parameters: + - in: path + name: registration_id + schema: + type: string + title: Registration token + required: true + tags: + - api + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/FCMDevice' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/FCMDevice' + multipart/form-data: + schema: + $ref: '#/components/schemas/FCMDevice' + required: true + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/FCMDevice' + description: '' + patch: + operationId: api_devices_partial_update + parameters: + - in: path + name: registration_id + schema: + type: string + title: Registration token + required: true + tags: + - api + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedFCMDevice' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedFCMDevice' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedFCMDevice' + security: + - tokenAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/FCMDevice' + description: '' + delete: + operationId: api_devices_destroy + parameters: + - in: path + name: registration_id + schema: + type: string + title: Registration token + required: true + tags: + - api + security: + - tokenAuth: [] + - {} + responses: + '204': + description: No response body /api/me/: get: operationId: api_me_retrieve @@ -435,6 +562,24 @@ paths: schema: $ref: '#/components/schemas/Ride' description: '' + /api/users/{id}/message/: + post: + operationId: api_users_message_create + description: Send push notification to user by user id. + parameters: + - in: path + name: id + schema: + type: integer + required: true + tags: + - api + security: + - tokenAuth: [] + - {} + responses: + '200': + description: No response body /schema/: get: operationId: schema_retrieve @@ -602,6 +747,81 @@ components: - first_name - last_name - username + FCMDevice: + type: object + properties: + id: + type: integer + readOnly: true + name: + type: string + nullable: true + maxLength: 255 + registration_id: + type: string + title: Registration token + device_id: + type: string + nullable: true + description: Unique device identifier + maxLength: 255 + active: + type: boolean + default: true + title: Is active + description: Inactive devices will not be sent notifications + date_created: + type: string + format: date-time + readOnly: true + title: Creation date + type: + $ref: '#/components/schemas/FCMDeviceTypeEnum' + required: + - date_created + - id + - registration_id + - type + FCMDeviceTypeEnum: + enum: + - ios + - android + - web + type: string + description: |- + * `ios` - ios + * `android` - android + * `web` - web + PatchedFCMDevice: + type: object + properties: + id: + type: integer + readOnly: true + name: + type: string + nullable: true + maxLength: 255 + registration_id: + type: string + title: Registration token + device_id: + type: string + nullable: true + description: Unique device identifier + maxLength: 255 + active: + type: boolean + default: true + title: Is active + description: Inactive devices will not be sent notifications + date_created: + type: string + format: date-time + readOnly: true + title: Creation date + type: + $ref: '#/components/schemas/FCMDeviceTypeEnum' Path: type: object properties: @@ -721,7 +941,7 @@ components: $ref: '#/components/schemas/Path' type: allOf: - - $ref: '#/components/schemas/TypeEnum' + - $ref: '#/components/schemas/Type22aEnum' readOnly: true required: - creator @@ -799,7 +1019,7 @@ components: $ref: '#/components/schemas/Path' type: allOf: - - $ref: '#/components/schemas/TypeEnum' + - $ref: '#/components/schemas/Type22aEnum' readOnly: true required: - departure_datetime @@ -810,7 +1030,7 @@ components: - min_travelers - path - type - TypeEnum: + Type22aEnum: enum: - rideshare - studentdriver