Skip to content

Commit

Permalink
Merge pull request #107 from akvo/feature/106-apk-download-upload-hook
Browse files Browse the repository at this point in the history
Feature/106 apk download upload hook
  • Loading branch information
dedenbangkit committed Aug 3, 2023
2 parents e0396a5 + 3040a30 commit b89b086
Show file tree
Hide file tree
Showing 13 changed files with 275 additions and 7 deletions.
27 changes: 27 additions & 0 deletions backend/api/v1/v1_mobile/migrations/0003_mobileapk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 4.0.4 on 2023-08-03 20:38

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('v1_mobile', '0002_auto_20230725_2053'),
]

operations = [
migrations.CreateModel(
name='MobileApk',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('apk_version', models.CharField(max_length=50)),
('apk_url', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'Mobile Apk',
'verbose_name_plural': 'Mobile Apks',
'db_table': 'mobile_apks',
},
),
]
16 changes: 15 additions & 1 deletion backend/api/v1/v1_mobile/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def create_assignment(self, user, passcode=None):
mobile_assignment = self.create(
user=user,
token=token.access_token,
passcode=CustomPasscode().encode(passcode)
passcode=CustomPasscode().encode(passcode),
)
return mobile_assignment

Expand Down Expand Up @@ -40,3 +40,17 @@ class Meta:
db_table = "mobile_assignments"
verbose_name = "Mobile Assignment"
verbose_name_plural = "Mobile Assignments"


class MobileApk(models.Model):
apk_version = models.CharField(max_length=50)
apk_url = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)

def __str__(self):
return f"{self.version}"

class Meta:
db_table = "mobile_apks"
verbose_name = "Mobile Apk"
verbose_name_plural = "Mobile Apks"
17 changes: 15 additions & 2 deletions backend/api/v1/v1_mobile/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from drf_spectacular.types import OpenApiTypes
from utils.custom_serializer_fields import CustomCharField
from api.v1.v1_profile.constants import UserRoleTypes
from api.v1.v1_mobile.models import MobileAssignment
from api.v1.v1_mobile.models import MobileAssignment, MobileApk
from utils.custom_helper import CustomPasscode


Expand Down Expand Up @@ -42,8 +42,21 @@ def validate_code(self, value):

def to_representation(self, instance):
data = super().to_representation(instance)
data.pop('code', None)
data.pop("code", None)
return data

class Meta:
fields = ["syncToken", "formsUrl", "code"]


class MobileApkSerializer(serializers.Serializer):
apk_version = serializers.CharField(max_length=50)
apk_url = serializers.CharField(max_length=255)
created_at = serializers.DateTimeField(read_only=True)

def create(self, validated_data):
return MobileApk.objects.create(**validated_data)

class Meta:
model = MobileApk
fields = ["apk_version", "apk_url", "created_at"]
114 changes: 114 additions & 0 deletions backend/api/v1/v1_mobile/tests/tests_apk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import os
import requests_mock
import requests as r
from nwmis.settings import BASE_DIR, APP_NAME, MASTER_DATA, APK_UPLOAD_SECRET
from django.test import TestCase
from api.v1.v1_mobile.models import MobileApk


class MobileApkTestCase(TestCase):
@classmethod
def setUpClass(cls):
# Get the path to the APK file and read its content
file_path = os.path.join(BASE_DIR, "source", "testapp.apk")

with open(file_path, "rb") as file:
cls.apk_content = file.read()

cls.mock = requests_mock.Mocker()
cls.mock.start()

# Mocking the GET request with the actual APK content
cls.apk_url = "https://expo.dev/artifacts/eas/dpRpygo9iviyK8k3oDUMzn.apk"
cls.mock.get(cls.apk_url, content=cls.apk_content)

# Mocking the wrong URL with a 401 status code
cls.wrong_apk_url = "http://example.com/wrong-url.apk"
cls.mock.get(cls.wrong_apk_url, status_code=401)

# Create the initial APK
cls.apk_version = "1.0.0"
cls.mobile_apk = MobileApk.objects.create(
apk_url=cls.apk_url, apk_version=cls.apk_version
)
cls.apk_path = os.path.join(BASE_DIR, MASTER_DATA)

@classmethod
def tearDownClass(cls):
os.remove(f"{cls.apk_path}/{APP_NAME}-{cls.mobile_apk.apk_version}.apk")
cls.mock.stop()

def test_if_initial_apk_is_created(self):
mobile_apk = MobileApk.objects.last()
self.assertEqual(mobile_apk.apk_url, MobileApkTestCase.apk_url)
self.assertEqual(mobile_apk.apk_version, self.apk_version)

def test_if_apk_is_downloadable(self):
request = r.get(MobileApkTestCase.apk_url)
self.assertEqual(request.status_code, 200)

def test_mobile_apk_download(self):
# SUCCESS DOWNLOAD
cls = MobileApkTestCase
download = self.client.get("/api/v1/device/apk/download")
self.assertEqual(download.status_code, 200)
self.assertEqual(
download["Content-Type"], "application/vnd.android.package-archive"
)
self.assertEqual(
download["Content-Disposition"],
f"attachment; filename={APP_NAME}-{cls.mobile_apk.apk_version}.apk",
)
self.assertTrue(download.has_header("Content-Length"))
apk_file = f"{cls.apk_path}/{APP_NAME}-{cls.mobile_apk.apk_version}.apk"
self.assertTrue(os.path.exists(apk_file))

def test_mobile_apk_upload(self):
# SUCCESS UPLOAD
cls = MobileApkTestCase
new_version = "1.0.1"
upload = self.client.post(
"/api/v1/device/apk/upload",
{
"apk_url": cls.apk_url,
"apk_version": new_version,
"secret": APK_UPLOAD_SECRET,
},
)
self.assertEqual(upload.status_code, 201)
new_file = f"{cls.apk_path}/{APP_NAME}-{new_version}.apk"
self.assertTrue(os.path.exists(new_file))

# NEW VERSION UPLOAD
download = self.client.get("/api/v1/device/apk/download")
self.assertEqual(download.status_code, 200)
self.assertEqual(
download["Content-Type"], "application/vnd.android.package-archive"
)
self.assertEqual(
download["Content-Disposition"],
f"attachment; filename={APP_NAME}-{new_version}.apk",
)

# FAILED UPLOAD WITH WRONG SECRET
upload = self.client.post(
"/api/v1/device/apk/upload",
{
"apk_url": cls.apk_url,
"apk_version": "1.0.0",
"secret": "WRONG_SECRET",
},
)
self.assertEqual(upload.status_code, 400)
self.assertEqual(upload.data["message"], "Secret is required.")

# FAILED UPLOAD WITH WRONG APK URL
upload = self.client.post(
"/api/v1/device/apk/upload",
{
"apk_url": self.wrong_apk_url,
"apk_version": "1.0.0",
"secret": APK_UPLOAD_SECRET,
},
)
self.assertEqual(upload.status_code, 404)
4 changes: 4 additions & 0 deletions backend/api/v1/v1_mobile/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
get_mobile_form_details,
sync_pending_form_data,
download_sqlite_file,
upload_apk_file,
download_apk_file,
)

urlpatterns = [
Expand All @@ -15,4 +17,6 @@
re_path(
r"^(?P<version>(v1))/device/sqlite/(?P<file_name>.*)$", download_sqlite_file
),
re_path(r"^(?P<version>(v1))/device/apk/upload", upload_apk_file),
re_path(r"^(?P<version>(v1))/device/apk/download", download_apk_file),
]
91 changes: 88 additions & 3 deletions backend/api/v1/v1_mobile/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import requests
import mimetypes
from nwmis.settings import MASTER_DATA, BASE_DIR
from nwmis.settings import MASTER_DATA, BASE_DIR, APP_NAME, APK_UPLOAD_SECRET
from drf_spectacular.utils import extend_schema
from django.http import HttpResponse
from rest_framework import status, serializers
Expand All @@ -9,8 +10,11 @@
from rest_framework.generics import get_object_or_404
from rest_framework.permissions import IsAuthenticated
from drf_spectacular.utils import inline_serializer
from .serializers import MobileAssignmentFormsSerializer
from .models import MobileAssignment
from .serializers import (
MobileAssignmentFormsSerializer,
MobileApkSerializer,
)
from .models import MobileAssignment, MobileApk
from api.v1.v1_forms.models import Forms
from api.v1.v1_profile.models import Access
from api.v1.v1_forms.serializers import WebFormDetailSerializer
Expand All @@ -19,6 +23,8 @@
from utils.default_serializers import DefaultResponseSerializer
from utils.custom_serializer_fields import validate_serializers_message

apk_path = os.path.join(BASE_DIR, MASTER_DATA)


@extend_schema(
request=MobileAssignmentFormsSerializer,
Expand Down Expand Up @@ -142,3 +148,82 @@ def download_sqlite_file(request, version, file_name):
response["Content-Length"] = os.path.getsize(file_path)
response["Content-Disposition"] = "attachment; filename=%s" % file_name
return response


@extend_schema(tags=["Mobile APK"], summary="Get APK File")
@api_view(["GET"])
def download_apk_file(request, version):
apk = MobileApk.objects.last()
if not apk:
return Response({"message": "APK not found."}, status=status.HTTP_404_NOT_FOUND)
file_name = f"{APP_NAME}-{apk.apk_version}.apk"
cache_file_name = os.path.join(apk_path, file_name)
if os.path.exists(cache_file_name):
# Get the file's content type
content_type, _ = mimetypes.guess_type(cache_file_name)
# Read the file content into a variable
with open(cache_file_name, "rb") as file:
file_content = file.read()
# Create the response and set the appropriate headers
response = HttpResponse(file_content, content_type=content_type)
response["Content-Length"] = os.path.getsize(cache_file_name)
response["Content-Disposition"] = "attachment; filename=%s" % f"{file_name}"
return response
download = requests.get(apk.apk_url)
if download.status_code != 200:
return HttpResponse(
{"message": "File not found."}, status=status.HTTP_404_NOT_FOUND
)
file_cache = open(cache_file_name, "wb")
file_cache.write(download.content)
file_cache.close()
# Get the file's content type
content_type, _ = mimetypes.guess_type(cache_file_name)
# Read the file content into a variable
with open(cache_file_name, "rb") as file:
file_content = file.read()
# Read the file content into a variable
response = HttpResponse(file_content, content_type=content_type)
response["Content-Length"] = os.path.getsize(cache_file_name)
response["Content-Disposition"] = "attachment; filename=%s" % f"{file_name}"
return response


@extend_schema(
request=inline_serializer(
name="UploadAPKFile",
fields={
"apk_url": serializers.FileField(),
"apk_version": serializers.CharField(),
"secret": serializers.CharField(),
},
),
tags=["Mobile APK"],
summary="Post APK File",
)
@api_view(["POST"])
def upload_apk_file(request, version):
if request.data.get("secret") != APK_UPLOAD_SECRET:
return Response(
{"message": "Secret is required."}, status=status.HTTP_400_BAD_REQUEST
)
serializer = MobileApkSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"message": serializer.errors}, status=status.HTTP_400_BAD_REQUEST
)
apk_version = serializer.validated_data.get("apk_version")
download = requests.get(
request.data.get("apk_url"), allow_redirects=True, stream=True
)
if download.status_code != 200:
return HttpResponse(
{"message": "File not found."}, status=status.HTTP_404_NOT_FOUND
)
filename = f"{APP_NAME}-{apk_version}.apk"
cache_file_name = os.path.join(apk_path, filename)
file_cache = open(cache_file_name, "wb")
file_cache.write(download.content)
file_cache.close()
serializer.save()
return Response({"message": "ok"}, status=status.HTTP_201_CREATED)
8 changes: 7 additions & 1 deletion backend/nwmis/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
from os import path, environ
from pathlib import Path

# Application definition
APP_NAME = "nwmis"

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent

Expand Down Expand Up @@ -148,7 +151,7 @@
"PAGE_SIZE": 10,
}
SPECTACULAR_SETTINGS = {
"TITLE": "RUSH",
"TITLE": "PDHA",
"DESCRIPTION": "",
"VERSION": "1.0.0",
"SORT_OPERATIONS": False,
Expand Down Expand Up @@ -229,6 +232,9 @@

STATICFILES_DIRS = [f"{MASTER_DATA}/assets/"]

# Apk files
APK_UPLOAD_SECRET = environ.get("APK_UPLOAD_SECRET")

STATIC_ROOT = path.join(BASE_DIR, "staticfiles")

# Static files whitenoise
Expand Down
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ debugpy==1.5.1
msgpack==1.0.4
AkvoDjangoFormGateway==0.0.9
whitenoise==6.4.0
requests-mock==1.11.0
1 change: 1 addition & 0 deletions backend/source/burkina-faso/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.apk
Binary file added backend/source/testapp.apk
Binary file not shown.
1 change: 1 addition & 0 deletions docker-compose.ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ services:
- DB_PASSWORD=password
- DB_HOST=db
- DJANGO_SECRET=ci-secret
- APK_UPLOAD_SECRET=ci-secret
- DEBUG=True
- MAILJET_APIKEY=$MAILJET_APIKEY
- MAILJET_SECRET=$MAILJET_SECRET
Expand Down
1 change: 1 addition & 0 deletions docker-compose.test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ services:
- DB_PASSWORD=password
- DB_HOST=db
- DJANGO_SECRET=ci-secret
- APK_UPLOAD_SECRET=ci-secret
# env vars for coveralls
- COVERALLS_REPO_TOKEN=${COVERALLS_REPO_TOKEN:-}
- SEMAPHORE=${SEMAPHORE:-}
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ services:
- DEBUG=True
- DJANGO_SECRET=local-secret
- NWMIS_INSTANCE
- APK_UPLOAD_SECRET
depends_on:
- db
worker:
Expand Down

0 comments on commit b89b086

Please sign in to comment.