diff --git a/.envs/.local/.django b/.envs/.local/.django index 247287b..a6cb70f 100644 --- a/.envs/.local/.django +++ b/.envs/.local/.django @@ -2,6 +2,7 @@ # ------------------------------------------------------------------------------ USE_DOCKER=yes IPYTHONDIR=/app/.ipython + # Redis # ------------------------------------------------------------------------------ REDIS_URL=redis://redis:6379/0 @@ -12,3 +13,11 @@ REDIS_URL=redis://redis:6379/0 # Flower CELERY_FLOWER_USER=debug CELERY_FLOWER_PASSWORD=debug + +# Files +# ------------------------------------------------------------------------------ +FILE_UPLOAD_STORAGE=local + +# Firebase +# ------------------------------------------------------------------------------ +GOOGLE_APPLICATION_CREDENTIALS=/home/$USER/google-services.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca8d414..1b6679d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,11 +7,11 @@ env: on: pull_request: - branches: [ "master", "main" ] + branches: [ "master", "main", "dev"] paths-ignore: [ "docs/**" ] push: - branches: [ "master", "main" ] + branches: [ "master", "main", "dev"] paths-ignore: [ "docs/**" ] concurrency: diff --git a/.gitignore b/.gitignore index c2a4752..16c9eec 100644 --- a/.gitignore +++ b/.gitignore @@ -277,3 +277,9 @@ api/media/ .env .envs/* !.envs/.local/ + +# VScode +.vscode + +# Media files +media diff --git a/Makefile b/Makefile index 9182b8c..eb81952 100644 --- a/Makefile +++ b/Makefile @@ -53,4 +53,4 @@ destroy: rm_pyc: - find . -name '__pycache__' -name '*.pyc' | xargs rm -rf \ No newline at end of file + find . -name '__pycache__' -name '*.pyc' | xargs rm -rf diff --git a/api/apis/__init__.py b/api/apis/__init__.py new file mode 100644 index 0000000..f39e5e8 --- /dev/null +++ b/api/apis/__init__.py @@ -0,0 +1 @@ +# Init diff --git a/api/apis/apps.py b/api/apis/apps.py new file mode 100644 index 0000000..1b232ce --- /dev/null +++ b/api/apis/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApisConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api.apis" diff --git a/api/apis/migrations/__init__.py b/api/apis/migrations/__init__.py new file mode 100644 index 0000000..f39e5e8 --- /dev/null +++ b/api/apis/migrations/__init__.py @@ -0,0 +1 @@ +# Init diff --git a/api/apis/pagination.py b/api/apis/pagination.py new file mode 100644 index 0000000..20b849f --- /dev/null +++ b/api/apis/pagination.py @@ -0,0 +1,55 @@ +from collections import OrderedDict + +from rest_framework.pagination import LimitOffsetPagination as _LimitOffsetPagination +from rest_framework.response import Response + + +def get_paginated_response( + *, pagination_class, serializer_class, queryset, request, view +): + paginator = pagination_class() + + page = paginator.paginate_queryset(queryset, request, view=view) + + if page is not None: + serializer = serializer_class(page, many=True) + return paginator.get_paginated_response(serializer.data) + + serializer = serializer_class(queryset, many=True) + + return Response(data=serializer.data) + + +class LimitOffsetPagination(_LimitOffsetPagination): + default_limit = 10 + max_limit = 50 + + def get_paginated_data(self, data): + return OrderedDict( + [ + ("limit", self.limit), + ("offset", self.offset), + ("count", self.count), + ("next", self.get_next_link()), + ("previous", self.get_previous_link()), + ("results", data), + ] + ) + + def get_paginated_response(self, data): + """ + We redefine this method in order to return `limit` and `offset`. + This is used by the frontend to construct the pagination itself. + """ + return Response( + OrderedDict( + [ + ("limit", self.limit), + ("offset", self.offset), + ("count", self.count), + ("next", self.get_next_link()), + ("previous", self.get_previous_link()), + ("results", data), + ] + ) + ) diff --git a/api/apis/tests.py b/api/apis/tests.py new file mode 100644 index 0000000..007eb95 --- /dev/null +++ b/api/apis/tests.py @@ -0,0 +1 @@ +# Tests diff --git a/api/apis/urls.py b/api/apis/urls.py new file mode 100644 index 0000000..bfc54f8 --- /dev/null +++ b/api/apis/urls.py @@ -0,0 +1,11 @@ +from django.urls import include, path + +app_name = "apis" +urlpatterns = [ + path("auth/", include("api.authentication.urls", "authentication")), + path("users/", include("api.users.urls", "users")), + path("cases/", include("api.cases.urls", "cases")), + path("files/", include("api.files.urls", "files")), + path("notifications/", include("api.notifications.urls", "notifications")), + path("locations/", include("api.locations.urls", "locations")), +] diff --git a/api/authentication/__init__.py b/api/authentication/__init__.py new file mode 100644 index 0000000..f39e5e8 --- /dev/null +++ b/api/authentication/__init__.py @@ -0,0 +1 @@ +# Init diff --git a/api/authentication/admin.py b/api/authentication/admin.py new file mode 100644 index 0000000..aad9415 --- /dev/null +++ b/api/authentication/admin.py @@ -0,0 +1 @@ +# Admin diff --git a/api/authentication/apis.py b/api/authentication/apis.py new file mode 100644 index 0000000..455ddec --- /dev/null +++ b/api/authentication/apis.py @@ -0,0 +1,22 @@ +from rest_framework import permissions, serializers, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.authentication.selectors import validate_phone +from api.common.validators import is_phone + + +class ValidatePhoneAPI(APIView): + permission_classes = [permissions.AllowAny] + + class InputSerializer(serializers.Serializer): + phone = serializers.CharField(validators=[is_phone]) + + def post(self, request): + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Raises validation error if phone is taken + validate_phone(**serializer.validated_data) + + return Response(status=status.HTTP_200_OK) diff --git a/api/authentication/apps.py b/api/authentication/apps.py new file mode 100644 index 0000000..3b48b5c --- /dev/null +++ b/api/authentication/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api.authentication" diff --git a/api/authentication/migrations/__init__.py b/api/authentication/migrations/__init__.py new file mode 100644 index 0000000..9cf13eb --- /dev/null +++ b/api/authentication/migrations/__init__.py @@ -0,0 +1 @@ +# Init \ No newline at end of file diff --git a/api/authentication/models.py b/api/authentication/models.py new file mode 100644 index 0000000..9136172 --- /dev/null +++ b/api/authentication/models.py @@ -0,0 +1 @@ +# Models diff --git a/api/authentication/selectors.py b/api/authentication/selectors.py new file mode 100644 index 0000000..aeeffcc --- /dev/null +++ b/api/authentication/selectors.py @@ -0,0 +1,12 @@ +from typing import Union + +from rest_framework.exceptions import ValidationError + +from api.users.models import User + + +def validate_phone(*, phone: str) -> Union[None, ValidationError]: + if User.objects.filter(username=phone).exists(): + raise ValidationError(f"Phone number: {phone} already taken") + + return None diff --git a/api/authentication/urls.py b/api/authentication/urls.py new file mode 100644 index 0000000..e0005a9 --- /dev/null +++ b/api/authentication/urls.py @@ -0,0 +1,16 @@ +from django.urls import path +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenVerifyView, +) + +from api.authentication.apis import ValidatePhoneAPI + +app_name = "auth" +urlpatterns = [ + path("token/", TokenObtainPairView.as_view(), name="obtain_token"), + path("token/refresh/", TokenRefreshView.as_view(), name="refresh_token"), + path("token/verify/", TokenVerifyView.as_view(), name="verify_token"), + path("phone/validate/", ValidatePhoneAPI.as_view(), name="validate_phone"), +] diff --git a/api/cases/__init__.py b/api/cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/cases/admin.py b/api/cases/admin.py new file mode 100644 index 0000000..4c40641 --- /dev/null +++ b/api/cases/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from .models import Case, CaseDetails, CaseMatch, CasePhoto + +admin.site.register((Case, CaseDetails, CaseMatch, CasePhoto)) diff --git a/api/cases/apis.py b/api/cases/apis.py new file mode 100644 index 0000000..e668e74 --- /dev/null +++ b/api/cases/apis.py @@ -0,0 +1,178 @@ +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.apis.pagination import LimitOffsetPagination, get_paginated_response +from api.cases.models import Case +from api.cases.selectors import get_case, list_case, list_case_match +from api.cases.services import create_case, publish_case +from api.common.utils import inline_serializer + + +class CreateCaseApi(APIView): + class InputSerializer(serializers.Serializer): + type = serializers.CharField() + thumbnail = serializers.IntegerField() + file_ids = serializers.ListField(child=serializers.IntegerField()) + location = inline_serializer( + fields={ + "gov": serializers.IntegerField(), + "city": serializers.IntegerField(), + "address": serializers.CharField(required=False), + "lon": serializers.DecimalField( + max_digits=9, decimal_places=6, required=False + ), + "lat": serializers.DecimalField( + max_digits=8, decimal_places=6, required=False + ), + } + ) + details = inline_serializer( + fields={ + "name": serializers.CharField(required=False), + "gender": serializers.CharField(required=False), + "age": serializers.IntegerField(required=False), + "last_seen": serializers.DateField(required=False), + "description": serializers.CharField(required=False), + "location": inline_serializer( + required=False, + fields={**location.fields}, + ), + } + ) + + def post(self, request): + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + create_case(user=request.user, **serializer.validated_data) + + return Response(status=status.HTTP_201_CREATED) + + +class CaseListApi(APIView): + class Pagination(LimitOffsetPagination): + default_limit = 10 + + class FilterSerializer(serializers.Serializer): + type = serializers.CharField(required=False) + start_age = serializers.IntegerField(required=False) + end_age = serializers.IntegerField(required=False) + start_date = serializers.DateField(required=False) + end_date = serializers.DateField(required=False) + gov = serializers.IntegerField(required=False) + name = serializers.CharField(required=False) + + class OutputSerializer(serializers.Serializer): + id = serializers.IntegerField() + type = serializers.CharField() + name = serializers.CharField(source="details.name") + thumbnail = serializers.URLField(source="thumbnail.url") + last_seen = serializers.DateField(source="details.last_seen") + posted_at = serializers.DateTimeField() + location = inline_serializer( + fields={ + "gov": serializers.CharField(source="gov.name_ar"), + "city": serializers.CharField(source="city.name_ar"), + } + ) + + def get(self, request): + # Make sure the filters are valid, if passed + filters_serializer = self.FilterSerializer(data=request.query_params) + filters_serializer.is_valid(raise_exception=True) + + cases = list_case(filters=filters_serializer.validated_data) + + return get_paginated_response( + pagination_class=self.Pagination, + serializer_class=self.OutputSerializer, + queryset=cases, + request=request, + view=self, + ) + + +class DetailsCaseApi(APIView): + class OutputSerializer(serializers.Serializer): + user = serializers.CharField(source="user.username") + type = serializers.CharField() + state = serializers.CharField(source="get_state_display") + photos = serializers.ListField(source="photo_urls") + location = inline_serializer( + fields={ + "gov": serializers.CharField(), + "city": serializers.CharField(), + "address": serializers.CharField(), + "lon": serializers.DecimalField( + max_digits=9, + decimal_places=6, + ), + "lat": serializers.DecimalField( + max_digits=8, + decimal_places=6, + ), + } + ) + details = inline_serializer( + fields={ + "name": serializers.CharField(), + "gender": serializers.CharField(), + "age": serializers.IntegerField(), + "last_seen": serializers.DateField(), + "description": serializers.CharField(), + "location": location, + } + ) + + def get(self, request, case_id): + case = get_case(pk=case_id, fetched_by=request.user) + + serializer = self.OutputSerializer(case) + + return Response(serializer.data) + + +class CaseMatchListApi(APIView): + def get(self, request, case_id): + + # Fetching our case + case = get_case(pk=case_id, fetched_by=request.user) + + # Selecting which cases to serialize depending on case type + case_source = "missing" if case.type == Case.Types.FOUND else "found" + + # Writing our serializer here because of case source decision + class OutputSerializer(serializers.Serializer): + case = inline_serializer( + fields={ + "id": serializers.IntegerField(), + "type": serializers.CharField(), + "name": serializers.CharField(source="details.name"), + "location": inline_serializer( + fields={ + "gov": serializers.CharField(source="gov.name_ar"), + "city": serializers.CharField(source="city.name_ar"), + }, + ), + "thumbnail": serializers.URLField(source="thumbnail.url"), + "last_seen": serializers.DateField(source="details.last_seen"), + "posted_at": serializers.DateTimeField(), + }, + source=case_source, + ) + score = serializers.IntegerField() + + # Listing all case matches + matches = list_case_match(case=case, fetched_by=request.user) + + # Serializing the results + serializer = OutputSerializer(matches, many=True) + + return Response(serializer.data) + + +class CasePublishApi(APIView): + def get(self, request, case_id): + case = get_case(pk=case_id, fetched_by=request.user) + publish_case(case=case, performed_by=request.user) + return Response(status=status.HTTP_200_OK) diff --git a/api/cases/apps.py b/api/cases/apps.py new file mode 100644 index 0000000..65a4208 --- /dev/null +++ b/api/cases/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CasesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api.cases" diff --git a/api/cases/filters.py b/api/cases/filters.py new file mode 100644 index 0000000..af1c4f6 --- /dev/null +++ b/api/cases/filters.py @@ -0,0 +1,35 @@ +import django_filters + +from .models import Case + + +class CaseFilter(django_filters.FilterSet): + + type = django_filters.CharFilter(lookup_expr="iexact") + + gov = django_filters.NumberFilter(field_name="location__gov") + name = django_filters.CharFilter( + field_name="details__name", lookup_expr="icontains" + ) + + start_age = django_filters.NumberFilter( + field_name="details__age", lookup_expr="gte" + ) + end_age = django_filters.NumberFilter(field_name="details__age", lookup_expr="lte") + + start_date = django_filters.DateFilter( + field_name="details__last_seen", lookup_expr="gte" + ) + end_date = django_filters.DateFilter( + field_name="details__last_seen", lookup_expr="lte" + ) + + class Meta: + model = Case + fields = [ + "type", + "details__age", + "details__last_seen", + "location__gov", + "details__name", + ] diff --git a/api/cases/migrations/0001_initial.py b/api/cases/migrations/0001_initial.py new file mode 100644 index 0000000..25e5c87 --- /dev/null +++ b/api/cases/migrations/0001_initial.py @@ -0,0 +1,67 @@ +# Generated by Django 3.2.13 on 2022-04-19 22:39 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django_fsm + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('locations', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Case', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('M', 'Missing'), ('F', 'Found')], max_length=1)), + ('state', django_fsm.FSMField(choices=[('PE', 'Pending'), ('AC', 'Active'), ('DN', 'Finished'), ('AR', 'Archived')], default='PE', editable=False, max_length=2)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('posted_at', models.DateTimeField(blank=True, default=None, null=True)), + ('is_active', models.BooleanField(default=False, editable=False)), + ('location', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='locations.location')), + ], + options={ + 'verbose_name': 'case', + 'verbose_name_plural': 'cases', + 'db_table': 'cases', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='CasePhoto', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.URLField()), + ('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='cases.case')), + ], + ), + migrations.CreateModel( + name='CaseMatch', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('score', models.SmallIntegerField(validators=[django.core.validators.MaxValueValidator(100), django.core.validators.MinValueValidator(1)])), + ('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='matches', to='cases.case')), + ('match', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cases.case')), + ], + ), + migrations.CreateModel( + name='CaseDetails', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, max_length=128, null=True)), + ('gender', models.CharField(choices=[('M', 'Male'), ('F', 'Female'), ('U', 'Unknown')], max_length=1)), + ('age', models.SmallIntegerField(blank=True, default=None, null=True)), + ('last_seen', models.DateTimeField(blank=True, default=None, null=True)), + ('description', models.TextField(blank=True, null=True)), + ('case', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='cases.case')), + ('location', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='locations.location')), + ], + ), + ] diff --git a/api/cases/migrations/0002_case_user.py b/api/cases/migrations/0002_case_user.py new file mode 100644 index 0000000..d68d826 --- /dev/null +++ b/api/cases/migrations/0002_case_user.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.13 on 2022-04-19 22:39 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('cases', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='case', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cases', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/api/cases/migrations/0003_auto_20220421_0014.py b/api/cases/migrations/0003_auto_20220421_0014.py new file mode 100644 index 0000000..a251600 --- /dev/null +++ b/api/cases/migrations/0003_auto_20220421_0014.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.13 on 2022-04-21 00:14 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('locations', '0003_alter_location_address'), + ('cases', '0002_case_user'), + ] + + operations = [ + migrations.AlterField( + model_name='casedetails', + name='age', + field=models.SmallIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='casedetails', + name='last_seen', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='casedetails', + name='location', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='locations.location'), + ), + ] diff --git a/api/cases/migrations/0004_alter_casedetails_case.py b/api/cases/migrations/0004_alter_casedetails_case.py new file mode 100644 index 0000000..e4e0e88 --- /dev/null +++ b/api/cases/migrations/0004_alter_casedetails_case.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.13 on 2022-04-27 15:23 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('cases', '0003_auto_20220421_0014'), + ] + + operations = [ + migrations.AlterField( + model_name='casedetails', + name='case', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='details', to='cases.case'), + ), + ] diff --git a/api/cases/migrations/0005_alter_casedetails_last_seen.py b/api/cases/migrations/0005_alter_casedetails_last_seen.py new file mode 100644 index 0000000..1d76d27 --- /dev/null +++ b/api/cases/migrations/0005_alter_casedetails_last_seen.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-05-01 06:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cases', '0004_alter_casedetails_case'), + ] + + operations = [ + migrations.AlterField( + model_name='casedetails', + name='last_seen', + field=models.DateField(blank=True, null=True), + ), + ] diff --git a/api/cases/migrations/0006_auto_20220508_0416.py b/api/cases/migrations/0006_auto_20220508_0416.py new file mode 100644 index 0000000..cb90f46 --- /dev/null +++ b/api/cases/migrations/0006_auto_20220508_0416.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.13 on 2022-05-08 04:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('cases', '0005_alter_casedetails_last_seen'), + ] + + operations = [ + migrations.AlterField( + model_name='casematch', + name='case', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='old_matches', to='cases.case'), + ), + migrations.AlterField( + model_name='casematch', + name='match', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='new_matches', to='cases.case'), + ), + ] diff --git a/api/cases/migrations/0007_auto_20220510_1459.py b/api/cases/migrations/0007_auto_20220510_1459.py new file mode 100644 index 0000000..0b29fd5 --- /dev/null +++ b/api/cases/migrations/0007_auto_20220510_1459.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.13 on 2022-05-10 14:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('cases', '0006_auto_20220508_0416'), + ] + + operations = [ + migrations.RemoveField( + model_name='casematch', + name='case', + ), + migrations.RemoveField( + model_name='casematch', + name='match', + ), + migrations.AddField( + model_name='casematch', + name='found', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='found_matches', to='cases.case'), + preserve_default=False, + ), + migrations.AddField( + model_name='casematch', + name='missing', + field=models.ForeignKey(default=2, on_delete=django.db.models.deletion.CASCADE, related_name='missing_matches', to='cases.case'), + preserve_default=False, + ), + ] diff --git a/api/cases/migrations/0008_case_thumbnail.py b/api/cases/migrations/0008_case_thumbnail.py new file mode 100644 index 0000000..f2cbfdb --- /dev/null +++ b/api/cases/migrations/0008_case_thumbnail.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.13 on 2022-05-12 12:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cases', '0007_auto_20220510_1459'), + ] + + operations = [ + migrations.AddField( + model_name='case', + name='thumbnail', + field=models.URLField(default='https://picsum.photos/200/300'), + preserve_default=False, + ), + ] diff --git a/api/cases/migrations/0009_auto_20220515_1944.py b/api/cases/migrations/0009_auto_20220515_1944.py new file mode 100644 index 0000000..8d9d0c8 --- /dev/null +++ b/api/cases/migrations/0009_auto_20220515_1944.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.13 on 2022-05-15 19:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('files', '0001_initial'), + ('cases', '0008_case_thumbnail'), + ] + + operations = [ + migrations.RemoveField( + model_name='casephoto', + name='url', + ), + migrations.AddField( + model_name='casephoto', + name='file', + field=models.OneToOneField(default=1, on_delete=django.db.models.deletion.CASCADE, to='files.file'), + preserve_default=False, + ), + migrations.AlterField( + model_name='case', + name='thumbnail', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='files.file'), + ), + ] diff --git a/api/cases/migrations/__init__.py b/api/cases/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/cases/models.py b/api/cases/models.py new file mode 100644 index 0000000..b404b7b --- /dev/null +++ b/api/cases/models.py @@ -0,0 +1,109 @@ +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django_fsm import FSMField, transition + +from api.files.models import File + +from ..locations.models import Location +from ..users.models import User + + +class Case(models.Model): + class Meta: + db_table = "cases" + verbose_name = "case" + verbose_name_plural = "cases" + ordering = ["-created_at"] + + class States(models.TextChoices): + PENDING = "PE", _("Pending") + ACTIVE = "AC", _("Active") + FINISHED = "DN", _("Finished") + ARCHIVED = "AR", _("Archived") + + class Types(models.TextChoices): + MISSING = "M", _("Missing") + FOUND = "F", _("Found") + + type = models.CharField(max_length=1, choices=Types.choices) + user = models.ForeignKey(User, related_name="cases", on_delete=models.CASCADE) + state = FSMField( + max_length=2, + choices=States.choices, + default=States.PENDING, + editable=False, + ) + location = models.OneToOneField(Location, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + posted_at = models.DateTimeField(null=True, default=None, blank=True) + is_active = models.BooleanField(default=False, editable=False) + thumbnail = models.OneToOneField(File, on_delete=models.CASCADE) + + @property + def photo_urls(self): + return [photo.file.url for photo in self.photos.all()] + + def __str__(self): + return f"{self.state} case {self.type}" + + # Pass the case to the model then add matched cases if any. + @transition(field=state, source=States.PENDING, target=States.ACTIVE) + def activate(self): + self.is_active = True + + # If User selected one of the matches to be the correct one + @transition(field=state, source=States.ACTIVE, target=States.FINISHED) + def finish(self): + self.is_active = False + + # Switch to ARCHIVED state from any state except ARCHIVED + @transition(field=state, source="+", target=States.ARCHIVED) + def archive(self): + self.is_active = False + + # If user selected incorrect match or lost again + @transition( + field=state, source=[States.FINISHED, States.ARCHIVED], target=States.ACTIVE + ) + def activate_again(self): + self.is_active = True + + def publish(self): + self.posted_at = timezone.now() + + +class CaseDetails(models.Model): + class Gender(models.TextChoices): + MALE = "M", _("Male") + FEMALE = "F", _("Female") + UNKNOWN = "U", _("Unknown") + + case = models.OneToOneField(Case, on_delete=models.CASCADE, related_name="details") + name = models.CharField(max_length=128, null=True, blank=True) + gender = models.CharField(max_length=1, choices=Gender.choices) + age = models.SmallIntegerField(null=True, blank=True) + location = models.OneToOneField( + Location, on_delete=models.CASCADE, null=True, blank=True + ) + last_seen = models.DateField(null=True, blank=True) + description = models.TextField(null=True, blank=True) + + +class CaseMatch(models.Model): + missing = models.ForeignKey( + Case, on_delete=models.CASCADE, related_name="missing_matches" + ) + found = models.ForeignKey( + Case, on_delete=models.CASCADE, related_name="found_matches" + ) + score = models.SmallIntegerField( + validators=[MaxValueValidator(100), MinValueValidator(1)] + ) + + +class CasePhoto(models.Model): + file = models.OneToOneField(File, on_delete=models.CASCADE) + case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="photos") diff --git a/api/cases/selectors.py b/api/cases/selectors.py new file mode 100644 index 0000000..0cff214 --- /dev/null +++ b/api/cases/selectors.py @@ -0,0 +1,46 @@ +from django.core.exceptions import PermissionDenied +from django.db.models.query import QuerySet +from django.shortcuts import get_object_or_404 + +from api.users.models import User + +from .filters import CaseFilter +from .models import Case, CaseMatch + + +def get_case(*, pk: int, fetched_by: User) -> Case: + case = get_object_or_404(Case, pk=pk) + + if not (case.is_active or fetched_by == case.user): + raise PermissionDenied() + + return case + + +def list_case(*, filters=None) -> QuerySet[Case]: + filters = filters or {} + + # TODO Switch to posted cases only + qs = Case.objects.filter(posted_at__isnull=False) + + return CaseFilter(filters, qs).qs + + +def list_user_case(*, user: User): + return user.cases.all() + + +def list_case_match(*, case: Case, fetched_by: User) -> QuerySet[CaseMatch]: + + if case.user != fetched_by: + raise PermissionDenied() + + qs = [] + + if case.type == Case.Types.FOUND: + qs = case.found_matches.all() + + elif case.type == Case.Types.MISSING: + qs = case.missing_matches.all() + + return qs diff --git a/api/cases/services.py b/api/cases/services.py new file mode 100644 index 0000000..42a3199 --- /dev/null +++ b/api/cases/services.py @@ -0,0 +1,190 @@ +from datetime import date +from typing import Dict, List, Optional + +from django.core.exceptions import PermissionDenied +from django.db import transaction +from rest_framework.exceptions import ValidationError + +from api.common.utils import get_object +from api.files.models import File +from api.locations.models import Location +from api.locations.services import create_location +from api.notifications.models import Notification +from api.notifications.services import create_notification +from api.users.models import User + +from .models import Case, CaseDetails, CaseMatch, CasePhoto + +# from fcm_django.models import FCMDevice +# from firebase_admin.messaging import Message +# from firebase_admin.messaging import Notification as FirebaseNotification + +Gender = CaseDetails.Gender +CaseType = Case.Types + + +def create_case_photo(*, case: Case, file: File) -> CasePhoto: + photo = CasePhoto(case=case, file=file) + photo.full_clean() + photo.save() + + return photo + + +@transaction.atomic +def create_case( + *, + type: CaseType, + user: User, + location: Dict, + details: Dict, + thumbnail: id, + file_ids: List[int], +) -> Case: + + # Fetch & create case related objects + location: Location = create_location(**location) + thumbnail = get_object(File, pk=thumbnail) + + if not thumbnail: + raise ValidationError("Invalid Thumbnail file id") + + case = Case(type=type.upper(), user=user, location=location, thumbnail=thumbnail) + + case.full_clean() + case.save() + + files = File.objects.filter(id__in=file_ids) + + if not files: + raise ValidationError("Invalid files ids") + + for file in files: + create_case_photo(case=case, file=file) + + create_case_details(case=case, **details) + + # TODO Factor out to an async function + activate_case(case) + case.save() + + return case + + +def update_case(): + ... + + +def create_case_details( + *, + case: Case, + name: Optional[str] = None, + gender: Gender = Gender.UNKNOWN, + age: Optional[int] = None, + last_seen: Optional[date] = None, + description: Optional[str] = None, + location: Optional[Dict] = None, +) -> CaseDetails: + loc = None + if location: + loc: Location = create_location(**location) + + case_details = CaseDetails( + case=case, + name=name, + gender=gender, + age=age, + last_seen=last_seen, + description=description, + location=loc, + ) + + case_details.full_clean() + case_details.save() + + return case_details + + +def create_case_match(*, missing: Case, found: Case, score: int) -> CaseMatch: + case_match = CaseMatch(missing=missing, found=found, score=score) + case_match.full_clean() + case_match.save() + + return case_match + + +def process_case(case: Case) -> List[Dict[int, int]]: + """ + Send case id and photos to the machine learing model then + recives list of ids & scores that matched with the case + """ + return [] + + +def case_matching_binding(*, case: Case, matches_list: List[Dict[int, int]]) -> None: + """ + Bind the processed case with it's matches by instaniating CaseMatch objects + """ + if not matches_list: + return + + cases_ids = [match["id"] for match in matches_list] + cases_scores = [match["score"] for match in matches_list] + matches: List[Case] = Case.objects.filter(id__in=cases_ids) + + missing = True if case.type == CaseType.MISSING else False + + for match, score in zip(matches, cases_scores): + if missing: + create_case_match(missing=case, found=match, score=score) + else: + create_case_match(missing=match, found=case, score=score) + + +def activate_case(case: Case): + matches = process_case(case) + case_matching_binding(case=case, matches_list=matches) + case.activate() + # TODO success or failure notification + create_notification( + title="تم رفع الحاله بنجاح", + body="جارى البحث عن المفقود وسنقوم بإشعارك فى حاله العثور لأى نتائج", + level=Notification.Level.INFO, + sent_to=case.user, + ) + + # msg = Message( + # notification=FirebaseNotification( + # title="تم رفع الحاله بنجاح", + # body="جارى البحث عن المفقود وسنقوم بإشعارك فى حاله العثور لأى نتائج", + # ) + # ) + + # case.user.fcmdevice.send_message(msg) + + +def publish_case(*, case: Case, performed_by: User): + if case.user != performed_by: + raise PermissionDenied() + + if not case.is_active: + raise ValidationError("Cannot publish inactive case") + + case.publish() + case.save() + + create_notification( + title="تم نشر الحاله بنجاح", + body="تم نشر بيانات المعثور عليه بنجاح انتظر منا اشعار اخر فى حين الوصول لأى نتائج", + level=Notification.Level.SUCCESS, + sent_to=case.user, + ) + + # msg = Message( + # notification=FirebaseNotification( + # title="تم نشر الحاله بنجاح", + # body="تم نشر بيانات المعثور عليه بنجاح انتظر منا اشعار اخر فى حين الوصول لأى نتائج", + # ) + # ) + # device = FCMDevice.objects.filter(user=case.user).first() + # device.send_message(msg) diff --git a/api/cases/tests.py b/api/cases/tests.py new file mode 100644 index 0000000..a79ca8b --- /dev/null +++ b/api/cases/tests.py @@ -0,0 +1,3 @@ +# from django.test import TestCase + +# Create your tests here. diff --git a/api/cases/urls.py b/api/cases/urls.py new file mode 100644 index 0000000..824d11f --- /dev/null +++ b/api/cases/urls.py @@ -0,0 +1,20 @@ +from django.urls import path + +from .apis import ( + CaseListApi, + CaseMatchListApi, + CasePublishApi, + CreateCaseApi, + DetailsCaseApi, +) + +app_name = "cases" + +urlpatterns = [ + path("", CaseListApi.as_view(), name="list"), + path("/", DetailsCaseApi.as_view(), name="detail"), + path("create/", CreateCaseApi.as_view(), name="create"), + # path("/update/", UpdateCaseApi.as_view(), name="update"), + path("/matches/", CaseMatchListApi.as_view(), name="matches"), + path("/publish/", CasePublishApi.as_view(), name="publish"), +] diff --git a/api/common/__init__.py b/api/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/common/apps.py b/api/common/apps.py new file mode 100644 index 0000000..89ff21a --- /dev/null +++ b/api/common/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CommonConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api.common" diff --git a/api/common/migrations/__init__.py b/api/common/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/common/models.py b/api/common/models.py new file mode 100644 index 0000000..c8c4b0a --- /dev/null +++ b/api/common/models.py @@ -0,0 +1,10 @@ +from django.db import models +from django.utils import timezone + + +class BaseModel(models.Model): + created_at = models.DateTimeField(db_index=True, default=timezone.now) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True diff --git a/api/common/permissions.py b/api/common/permissions.py new file mode 100644 index 0000000..6194057 --- /dev/null +++ b/api/common/permissions.py @@ -0,0 +1,33 @@ +from django.utils import timezone +from rest_framework import permissions + + +class IsVerified(permissions.BasePermission): + """ + Checks whether a user has verified his identity + """ + + # Override the default detail message of PermissionDenied + message = "User has not verified his identity yet" + + def has_permission(self, request, view): + id_exp = request.user.id_exp_date + return id_exp is not None and id_exp > timezone.now() + + +class IsAuthorOrReadOnly(permissions.BasePermission): + """ + Checks whether a user is the creator of a certain + object, otherwise read only access is allowed + """ + + # Call this inside views ⬇️ + # check_object_permissions(request, obj) + + message = "User doesn't have the previlages" + + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return True + + return request.user == obj.user diff --git a/api/common/services.py b/api/common/services.py new file mode 100644 index 0000000..6e21b39 --- /dev/null +++ b/api/common/services.py @@ -0,0 +1,45 @@ +from typing import Any, Dict, List, Tuple + +from api.common.types import DjangoModelType + + +def model_update( + *, instance: DjangoModelType, fields: List[str], data: Dict[str, Any] +) -> Tuple[DjangoModelType, bool]: + """ + Generic update service meant to be reused in local update services + + For example: + + def user_update(*, user: User, data) -> User: + fields = ['first_name', 'last_name'] + user, has_updated = model_update(instance=user, fields=fields, data=data) + + // Do other actions with the user here + + return user + + Return value: Tuple with the following elements: + 1. The instance we updated + 2. A boolean value representing whether we performed an update or not. + """ + has_updated = False + + for field in fields: + # Skip if a field is not present in the actual data + if field not in data: + continue + + if getattr(instance, field) != data[field]: + has_updated = True + setattr(instance, field, data[field]) + + # Perform an update only if any of the fields was actually changed + if has_updated: + instance.full_clean() + # Update only the fields that are meant to be updated. + # Django docs reference: + # https://docs.djangoproject.com/en/dev/ref/models/instances/#specifying-which-fields-to-save + instance.save(update_fields=fields) + + return instance, has_updated diff --git a/api/common/types.py b/api/common/types.py new file mode 100644 index 0000000..4fe6ff6 --- /dev/null +++ b/api/common/types.py @@ -0,0 +1,7 @@ +from typing import TypeVar + +from django.db import models + +# Generic type for a Django model +# Reference: https://mypy.readthedocs.io/en/stable/kinds_of_types.html#the-type-of-class-objects +DjangoModelType = TypeVar("DjangoModelType", bound=models.Model) diff --git a/api/common/utils.py b/api/common/utils.py new file mode 100644 index 0000000..e91abaa --- /dev/null +++ b/api/common/utils.py @@ -0,0 +1,60 @@ +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from rest_framework import serializers + + +def make_mock_object(**kwargs): + return type("", (object,), kwargs) + + +def get_object(model_or_queryset, **kwargs): + """ + Reuse get_object_or_404 since the implementation supports both Model && queryset. + Catch Http404 & return None + """ + try: + return get_object_or_404(model_or_queryset, **kwargs) + except Http404: + return None + + +def create_serializer_class(name, fields): + return type(name, (serializers.Serializer,), fields) + + +def inline_serializer(*, fields, data=None, **kwargs): + serializer_class = create_serializer_class(name="", fields=fields) + + if data is not None: + return serializer_class(data=data, **kwargs) + + return serializer_class(**kwargs) + + +def assert_settings(required_settings, error_message_prefix=""): + """ + Checks if each item from `required_settings` is present in Django settings + """ + not_present = [] + values = {} + + for required_setting in required_settings: + if not hasattr(settings, required_setting): + not_present.append(required_setting) + continue + + values[required_setting] = getattr(settings, required_setting) + + if not_present: + if not error_message_prefix: + error_message_prefix = "Required settings not found." + + stringified_not_present = ", ".join(not_present) + + raise ImproperlyConfigured( + f"{error_message_prefix} Could not find: {stringified_not_present}" + ) + + return values diff --git a/api/common/validators.py b/api/common/validators.py new file mode 100644 index 0000000..bafcb22 --- /dev/null +++ b/api/common/validators.py @@ -0,0 +1,17 @@ +from django.core.exceptions import ValidationError + + +def is_phone(val: str): + """ + Validates a phone number + """ + if not val.isnumeric() or len(val) != 10: + raise ValidationError(f'"Phone: {val}" is not a valid number') + + +def is_national_id(val: str): + """ + Validates an egyptian national id + """ + if not val.isnumeric() or len(val) != 14: + raise ValidationError(f'"ID: {val}" is not a valid national id') diff --git a/api/errors/__init__.py b/api/errors/__init__.py new file mode 100644 index 0000000..f39e5e8 --- /dev/null +++ b/api/errors/__init__.py @@ -0,0 +1 @@ +# Init diff --git a/api/errors/apps.py b/api/errors/apps.py new file mode 100644 index 0000000..78987c2 --- /dev/null +++ b/api/errors/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ErrorsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api.errors" diff --git a/api/errors/handlers.py b/api/errors/handlers.py new file mode 100644 index 0000000..d94ebb3 --- /dev/null +++ b/api/errors/handlers.py @@ -0,0 +1,50 @@ +from typing import Dict + +from django.core.exceptions import PermissionDenied +from django.core.exceptions import ValidationError as DjangoValidationError +from django.http import Http404 +from rest_framework import exceptions +from rest_framework.response import Response +from rest_framework.serializers import as_serializer_error +from rest_framework.views import exception_handler + + +def custom_exception_handler(exc: Exception, ctx: Dict) -> Response: + """ + { + "status_code": 4xx/5xx, + "message": "Error message" + "detail": { + "field": ["Error message"] + } + } + """ + # Handle django exceptions + if isinstance(exc, DjangoValidationError): + exc = exceptions.ValidationError(as_serializer_error(exc)) + + elif isinstance(exc, Http404): + exc = exceptions.NotFound() + + elif isinstance(exc, PermissionDenied): + exc = exceptions.PermissionDenied() + + # Call the default drf exception handler + response = exception_handler(exc, ctx) + + # Raise 500 in case of unexpected error + if response is None: + return response + + response_body = {} + if isinstance(exc.detail, (list, dict)): + response_body["detail"] = response.data + response_body["message"] = "Validation error" + else: + response_body["detail"] = {} + response_body["message"] = exc.detail + + response_body["status_code"] = response.status_code + response.data = response_body + + return response diff --git a/api/errors/migrations/__init__.py b/api/errors/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/errors/tests.py b/api/errors/tests.py new file mode 100644 index 0000000..007eb95 --- /dev/null +++ b/api/errors/tests.py @@ -0,0 +1 @@ +# Tests diff --git a/api/errors/views.py b/api/errors/views.py new file mode 100644 index 0000000..5873694 --- /dev/null +++ b/api/errors/views.py @@ -0,0 +1 @@ +# Views diff --git a/api/files/__init__.py b/api/files/__init__.py new file mode 100644 index 0000000..f39e5e8 --- /dev/null +++ b/api/files/__init__.py @@ -0,0 +1 @@ +# Init diff --git a/api/files/admin.py b/api/files/admin.py new file mode 100644 index 0000000..a89e11d --- /dev/null +++ b/api/files/admin.py @@ -0,0 +1,73 @@ +from django import forms +from django.contrib import admin, messages +from django.core.exceptions import ValidationError + +from api.files.models import File +from api.files.services import FileStandardUploadService + + +class FileForm(forms.ModelForm): + class Meta: + model = File + fields = ["file", "uploaded_by"] + + +@admin.register(File) +class FileAdmin(admin.ModelAdmin): + list_display = [ + "id", + "original_file_name", + "file_name", + "file_type", + "url", + "uploaded_by", + "created_at", + "upload_finished_at", + "is_valid", + ] + list_select_related = ["uploaded_by"] + + ordering = ["-created_at"] + + def get_form(self, request, obj=None, **kwargs): + """ + That's a bit of a hack + Dynamically change self.form, before delegating to the actual ModelAdmin.get_form + Proper kwargs are form, fields, exclude, formfield_callback + """ + if obj is None: + self.form = FileForm + + return super().get_form(request, obj, **kwargs) + + def get_readonly_fields(self, request, obj=None): + """ + We want to show those fields only when we have an existing object. + """ + + if obj is not None: + return [ + "original_file_name", + "file_name", + "file_type", + "created_at", + "updated_at", + "upload_finished_at", + ] + + return [] + + def save_model(self, request, obj, form, change): + try: + cleaned_data = form.cleaned_data + + service = FileStandardUploadService( + file_obj=cleaned_data["file"], user=cleaned_data["uploaded_by"] + ) + + if change: + service.update(file=obj) + else: + service.create() + except ValidationError as exc: + self.message_user(request, str(exc), messages.ERROR) diff --git a/api/files/apps.py b/api/files/apps.py new file mode 100644 index 0000000..3ad1be0 --- /dev/null +++ b/api/files/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FilesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api.files" diff --git a/api/files/enums.py b/api/files/enums.py new file mode 100644 index 0000000..65c56f0 --- /dev/null +++ b/api/files/enums.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class FileUploadStrategy(Enum): + STANDARD = "standard" + DIRECT = "direct" + + +class FileUploadStorage(Enum): + LOCAL = "local" + S3 = "s3" diff --git a/api/files/migrations/0001_initial.py b/api/files/migrations/0001_initial.py new file mode 100644 index 0000000..58eeed8 --- /dev/null +++ b/api/files/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.13 on 2022-04-21 21:04 + +import api.files.utils +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='File', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(blank=True, null=True, upload_to=api.files.utils.file_generate_upload_path)), + ('original_file_name', models.TextField()), + ('file_name', models.CharField(max_length=256, unique=True)), + ('file_type', models.CharField(max_length=256)), + ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('upload_finished_at', models.DateTimeField(blank=True, null=True)), + ('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/api/files/migrations/__init__.py b/api/files/migrations/__init__.py new file mode 100644 index 0000000..f39e5e8 --- /dev/null +++ b/api/files/migrations/__init__.py @@ -0,0 +1 @@ +# Init diff --git a/api/files/models.py b/api/files/models.py new file mode 100644 index 0000000..2af81df --- /dev/null +++ b/api/files/models.py @@ -0,0 +1,46 @@ +from django.conf import settings +from django.db import models +from django.utils import timezone + +from api.files.enums import FileUploadStorage +from api.files.utils import file_generate_upload_path +from api.users.models import User + + +class File(models.Model): + file = models.FileField( + upload_to=file_generate_upload_path, + blank=True, + null=True, + ) + + original_file_name = models.TextField() + + file_name = models.CharField(max_length=256, unique=True) + file_type = models.CharField(max_length=256) + + created_at = models.DateTimeField(db_index=True, default=timezone.now) + updated_at = models.DateTimeField(auto_now=True) + + uploaded_by = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="files", + ) + + upload_finished_at = models.DateTimeField(blank=True, null=True) + + @property + def is_valid(self): + """ + A valid file has the 'upload_finished_at' + set to a value (not null) + """ + return bool(self.upload_finished_at) + + @property + def url(self): + if settings.FILE_UPLOAD_STORAGE == FileUploadStorage.S3: + return self.file.url + + return f"{settings.APP_DOMAIN}{self.file.url}" diff --git a/api/files/services.py b/api/files/services.py new file mode 100644 index 0000000..be78c73 --- /dev/null +++ b/api/files/services.py @@ -0,0 +1,165 @@ +import mimetypes +from typing import Any, Dict, Tuple + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import transaction +from django.utils import timezone + +from api.files.enums import FileUploadStorage +from api.files.models import File +from api.files.utils import ( + bytes_to_mib, + file_generate_local_upload_url, + file_generate_name, + file_generate_upload_path, +) +from api.integrations.aws.client import s3_generate_presigned_post +from api.users.models import User + + +def _validate_file_size(file_obj): + max_size = settings.FILE_MAX_SIZE + + if file_obj.size > max_size: + raise ValidationError( + f"File is too large. It should not exceed {bytes_to_mib(max_size)} MiB" + ) + + +class FileStandardUploadService: + """ + This also serves as an example of a service class, + which encapsulates 2 different behaviors (create & update) under a namespace. + + Meaning, we use the class here for: + + 1. The namespace + 2. The ability to reuse `_infer_file_name_and_type` (which can also be an util) + """ + + def __init__(self, user: User, file_obj): + self.user = user + self.file_obj = file_obj + + def _infer_file_name_and_type( + self, file_name: str = "", file_type: str = "" + ) -> Tuple[str, str]: + if not file_name: + file_name = self.file_obj.name + + if not file_type: + guessed_file_type, encoding = mimetypes.guess_type(file_name) + + if guessed_file_type is None: + file_type = "" + else: + file_type = guessed_file_type + + return file_name, file_type + + @transaction.atomic + def create(self, file_name: str = "", file_type: str = "") -> File: + _validate_file_size(self.file_obj) + + file_name, file_type = self._infer_file_name_and_type(file_name, file_type) + + obj = File( + file=self.file_obj, + original_file_name=file_name, + file_name=file_generate_name(file_name), + file_type=file_type, + uploaded_by=self.user, + upload_finished_at=timezone.now(), + ) + + obj.full_clean() + obj.save() + + return obj + + @transaction.atomic + def update(self, file: File, file_name: str = "", file_type: str = "") -> File: + _validate_file_size(self.file_obj) + + file_name, file_type = self._infer_file_name_and_type(file_name, file_type) + + file.file = self.file_obj + file.original_file_name = file_name + file.file_name = file_generate_name(file_name) + file.file_type = file_type + file.uploaded_by = self.user + file.upload_finished_at = timezone.now() + + file.full_clean() + file.save() + + return file + + +class FileDirectUploadService: + """ + This also serves as an example of a service class, + which encapsulates a flow (start & finish) + one-off action (upload_local) into a namespace. + + Meaning, we use the class here for: + + 1. The namespace + """ + + def __init__(self, user: User): + self.user = user + + @transaction.atomic + def start(self, *, file_name: str, file_type: str) -> Dict[str, Any]: + file = File( + original_file_name=file_name, + file_name=file_generate_name(file_name), + file_type=file_type, + uploaded_by=self.user, + file=None, + ) + file.full_clean() + file.save() + + upload_path = file_generate_upload_path(file, file.file_name) + + """ + We are doing this in order to have an associated file for the field. + """ + file.file = file.file.field.attr_class(file, file.file.field, upload_path) + file.save() + + presigned_data: Dict[str, Any] = {} + + if settings.FILE_UPLOAD_STORAGE == FileUploadStorage.S3: + presigned_data = s3_generate_presigned_post( + file_path=upload_path, file_type=file.file_type + ) + + else: + presigned_data = { + "url": file_generate_local_upload_url(file_id=str(file.id)), + } + + return {"id": file.id, **presigned_data} + + @transaction.atomic + def finish(self, *, file: File) -> File: + # Potentially, check against user + file.upload_finished_at = timezone.now() + file.full_clean() + file.save() + + return file + + @transaction.atomic + def upload_local(self, *, file: File, file_obj) -> File: + _validate_file_size(file_obj) + + # Potentially, check against user + file.file = file_obj + file.full_clean() + file.save() + + return file diff --git a/api/files/tests.py b/api/files/tests.py new file mode 100644 index 0000000..007eb95 --- /dev/null +++ b/api/files/tests.py @@ -0,0 +1 @@ +# Tests diff --git a/api/files/urls.py b/api/files/urls.py new file mode 100644 index 0000000..5c01cfc --- /dev/null +++ b/api/files/urls.py @@ -0,0 +1,48 @@ +from django.urls import include, path + +from api.files.views import ( + FileDirectUploadFinishApi, + FileDirectUploadLocalApi, + FileDirectUploadStartApi, + FileStandardUploadApi, +) + +app_name = "files" +urlpatterns = [ + path( + "upload/", + include( + ( + [ + path("standard/", FileStandardUploadApi.as_view(), name="standard"), + path( + "direct/", + include( + ( + [ + path( + "start/", + FileDirectUploadStartApi.as_view(), + name="start", + ), + path( + "finish/", + FileDirectUploadFinishApi.as_view(), + name="finish", + ), + path( + "local//", + FileDirectUploadLocalApi.as_view(), + name="local", + ), + ], + "direct", + ) + ), + ), + ], + "upload", + ) + ), + ) +] diff --git a/api/files/utils.py b/api/files/utils.py new file mode 100644 index 0000000..312f213 --- /dev/null +++ b/api/files/utils.py @@ -0,0 +1,27 @@ +import pathlib +from uuid import uuid4 + +from django.conf import settings +from django.urls import reverse + + +def file_generate_name(original_file_name): + extension = pathlib.Path(original_file_name).suffix + return f"{uuid4().hex}{extension}" + + +def file_generate_upload_path(instance, filename): + return f"files/{instance.file_name}" + + +def file_generate_local_upload_url(*, file_id: str): + url = reverse( + "api:files:upload:direct:local", + kwargs={"file_id": file_id}, + ) + return f"{settings.APP_DOMAIN}{url}" + + +def bytes_to_mib(value: int) -> float: + # 1 bytes = 9.5367431640625E-7 mebibytes + return value * 9.5367431640625e-7 diff --git a/api/files/views.py b/api/files/views.py new file mode 100644 index 0000000..58a92ea --- /dev/null +++ b/api/files/views.py @@ -0,0 +1,63 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.files.models import File +from api.files.services import FileDirectUploadService, FileStandardUploadService + + +class FileStandardUploadApi(APIView): + def post(self, request): + service = FileStandardUploadService( + user=request.user, + file_obj=request.FILES["file"], + ) + file = service.create() + + return Response(data={"id": file.id}, status=status.HTTP_201_CREATED) + + +class FileDirectUploadStartApi(APIView): + class InputSerializer(serializers.Serializer): + file_name = serializers.CharField() + file_type = serializers.CharField() + + def post(self, request, *args, **kwargs): + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + service = FileDirectUploadService(request.user) + presigned_data = service.start(**serializer.validated_data) + + return Response(data=presigned_data) + + +class FileDirectUploadLocalApi(APIView): + def post(self, request, file_id): + file = get_object_or_404(File, id=file_id) + + file_obj = request.FILES["file"] + + service = FileDirectUploadService(request.user) + file = service.upload_local(file=file, file_obj=file_obj) + + return Response({"id": file.id}) + + +class FileDirectUploadFinishApi(APIView): + class InputSerializer(serializers.Serializer): + file_id = serializers.CharField() + + def post(self, request): + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + file_id = serializer.validated_data["file_id"] + + file = get_object_or_404(File, id=file_id) + + service = FileDirectUploadService(request.user) + service.finish(file=file) + + return Response({"id": file.id}) diff --git a/api/integrations/__init__.py b/api/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/integrations/apps.py b/api/integrations/apps.py new file mode 100644 index 0000000..d6774cb --- /dev/null +++ b/api/integrations/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class IntegrationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api.integrations" diff --git a/api/integrations/aws/__init__.py b/api/integrations/aws/__init__.py new file mode 100644 index 0000000..f39e5e8 --- /dev/null +++ b/api/integrations/aws/__init__.py @@ -0,0 +1 @@ +# Init diff --git a/api/integrations/aws/client.py b/api/integrations/aws/client.py new file mode 100644 index 0000000..c8fc224 --- /dev/null +++ b/api/integrations/aws/client.py @@ -0,0 +1,98 @@ +from functools import lru_cache +from typing import Any, Dict + +import boto3 +from attrs import define + +from api.common.utils import assert_settings + + +@define +class S3Credentials: + access_key_id: str + secret_access_key: str + region_name: str + bucket_name: str + default_acl: str + presigned_expiry: int + max_size: int + + +@lru_cache +def s3_get_credentials() -> S3Credentials: + required_config = assert_settings( + [ + "AWS_S3_ACCESS_KEY_ID", + "AWS_S3_SECRET_ACCESS_KEY", + "AWS_S3_REGION_NAME", + "AWS_STORAGE_BUCKET_NAME", + "AWS_DEFAULT_ACL", + "AWS_PRESIGNED_EXPIRY", + "FILE_MAX_SIZE", + ], + "S3 credentials not found.", + ) + + return S3Credentials( + access_key_id=required_config["AWS_S3_ACCESS_KEY_ID"], + secret_access_key=required_config["AWS_S3_SECRET_ACCESS_KEY"], + region_name=required_config["AWS_S3_REGION_NAME"], + bucket_name=required_config["AWS_STORAGE_BUCKET_NAME"], + default_acl=required_config["AWS_DEFAULT_ACL"], + presigned_expiry=required_config["AWS_PRESIGNED_EXPIRY"], + max_size=required_config["FILE_MAX_SIZE"], + ) + + +def s3_get_client(): + credentials = s3_get_credentials() + + return boto3.client( + service_name="s3", + aws_access_key_id=credentials.access_key_id, + aws_secret_access_key=credentials.secret_access_key, + region_name=credentials.region_name, + ) + + +def s3_generate_presigned_post(*, file_path: str, file_type: str) -> Dict[str, Any]: + credentials = s3_get_credentials() + s3_client = s3_get_client() + + acl = credentials.default_acl + expires_in = credentials.presigned_expiry + + """ + TODO: Create a type for the presigned_data + It looks like this: + + { + 'fields': { + 'Content-Type': 'image/png', + 'acl': 'private', + 'key': 'files/bafdccb665a447468e237781154883b5.png', + 'policy': 'some-long-base64-string', + 'x-amz-algorithm': 'AWS4-HMAC-SHA256', + 'x-amz-credential': 'AKIASOZLZI5FJDJ6XTSZ/20220405/eu-central-1/s3/aws4_request', + 'x-amz-date': '20220405T114912Z', + 'x-amz-signature': '7d8be89aabec12b781d44b5b3f099d07be319b9a41d9a9c804bd1075e1ef5735' + }, + 'url': 'https://django-styleguide-example.s3.amazonaws.com/' + } + """ + presigned_data = s3_client.generate_presigned_post( + credentials.bucket_name, + file_path, + Fields={"acl": acl, "Content-Type": file_type}, + Conditions=[ + {"acl": acl}, + {"Content-Type": file_type}, + # As an example, allow file size up to 10 MiB + # More on conditions, here: + # https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html + ["content-length-range", 1, credentials.max_size], + ], + ExpiresIn=expires_in, + ) + + return presigned_data diff --git a/api/locations/__init__.py b/api/locations/__init__.py new file mode 100644 index 0000000..f39e5e8 --- /dev/null +++ b/api/locations/__init__.py @@ -0,0 +1 @@ +# Init diff --git a/api/locations/admin.py b/api/locations/admin.py new file mode 100644 index 0000000..1b802cc --- /dev/null +++ b/api/locations/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin + +from .models import City, Governorate, Location + + +@admin.register(City) +class CityAdmin(admin.ModelAdmin): + list_display = ("name_ar", "name_en", "gov") + list_filter = ("gov",) + search_fields = ("name_en", "name_ar") + + +@admin.register(Governorate) +class GovernorateAdmin(admin.ModelAdmin): + list_display = ("name_ar", "name_en") + search_fields = ("name_en", "name_ar") + + +@admin.register(Location) +class LocationAdmin(admin.ModelAdmin): + list_display = ("lon", "lat", "address", "gov", "city") + list_filter = ("gov", "city") + search_fields = ("address",) diff --git a/api/locations/apis.py b/api/locations/apis.py new file mode 100644 index 0000000..c04323f --- /dev/null +++ b/api/locations/apis.py @@ -0,0 +1,43 @@ +from rest_framework import permissions, serializers +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.locations.selectors import list_governorate, list_governorate_cities + + +class GovernorateListApi(APIView): + permission_classes = [permissions.AllowAny] + + class OutputSerializer(serializers.Serializer): + id = serializers.IntegerField() + name_ar = serializers.CharField() + name_en = serializers.CharField() + + def get(self, request): + + # Listing all user cases + govs = list_governorate() + + # Serializing the results + serializer = self.OutputSerializer(govs, many=True) + + return Response(serializer.data) + + +class GovernorateCitiesListApi(APIView): + permission_classes = [permissions.AllowAny] + + class OutputSerializer(serializers.Serializer): + id = serializers.IntegerField() + name_ar = serializers.CharField() + name_en = serializers.CharField() + + def get(self, request, gov_id): + + # Listing all user cases + cities = list_governorate_cities(gov_id) + + # Serializing the results + serializer = self.OutputSerializer(cities, many=True) + + return Response(serializer.data) diff --git a/api/locations/apps.py b/api/locations/apps.py new file mode 100644 index 0000000..9a16cf7 --- /dev/null +++ b/api/locations/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LocationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api.locations" diff --git a/api/locations/cities.json b/api/locations/cities.json new file mode 100644 index 0000000..17c76c8 --- /dev/null +++ b/api/locations/cities.json @@ -0,0 +1,2435 @@ +{ + "name": "cities", + "data": { + "1": [ + { + "id": "1", + "governorate_id": "1", + "city_name_ar": "15 مايو", + "city_name_en": "15 May" + }, + { + "id": "2", + "governorate_id": "1", + "city_name_ar": "الازبكية", + "city_name_en": "Al Azbakeyah" + }, + { + "id": "3", + "governorate_id": "1", + "city_name_ar": "البساتين", + "city_name_en": "Al Basatin" + }, + { + "id": "4", + "governorate_id": "1", + "city_name_ar": "التبين", + "city_name_en": "Tebin" + }, + { + "id": "5", + "governorate_id": "1", + "city_name_ar": "الخليفة", + "city_name_en": "El-Khalifa" + }, + { + "id": "6", + "governorate_id": "1", + "city_name_ar": "الدراسة", + "city_name_en": "El darrasa" + }, + { + "id": "7", + "governorate_id": "1", + "city_name_ar": "الدرب الاحمر", + "city_name_en": "Aldarb Alahmar" + }, + { + "id": "8", + "governorate_id": "1", + "city_name_ar": "الزاوية الحمراء", + "city_name_en": "Zawya al-Hamra" + }, + { + "id": "9", + "governorate_id": "1", + "city_name_ar": "الزيتون", + "city_name_en": "El-Zaytoun" + }, + { + "id": "10", + "governorate_id": "1", + "city_name_ar": "الساحل", + "city_name_en": "Sahel" + }, + { + "id": "11", + "governorate_id": "1", + "city_name_ar": "السلام", + "city_name_en": "El Salam" + }, + { + "id": "12", + "governorate_id": "1", + "city_name_ar": "السيدة زينب", + "city_name_en": "Sayeda Zeinab" + }, + { + "id": "13", + "governorate_id": "1", + "city_name_ar": "الشرابية", + "city_name_en": "El Sharabeya" + }, + { + "id": "14", + "governorate_id": "1", + "city_name_ar": "مدينة الشروق", + "city_name_en": "Shorouk" + }, + { + "id": "15", + "governorate_id": "1", + "city_name_ar": "الظاهر", + "city_name_en": "El Daher" + }, + { + "id": "16", + "governorate_id": "1", + "city_name_ar": "العتبة", + "city_name_en": "Ataba" + }, + { + "id": "17", + "governorate_id": "1", + "city_name_ar": "القاهرة الجديدة", + "city_name_en": "New Cairo" + }, + { + "id": "18", + "governorate_id": "1", + "city_name_ar": "المرج", + "city_name_en": "El Marg" + }, + { + "id": "19", + "governorate_id": "1", + "city_name_ar": "عزبة النخل", + "city_name_en": "Ezbet el Nakhl" + }, + { + "id": "20", + "governorate_id": "1", + "city_name_ar": "المطرية", + "city_name_en": "Matareya" + }, + { + "id": "21", + "governorate_id": "1", + "city_name_ar": "المعادى", + "city_name_en": "Maadi" + }, + { + "id": "22", + "governorate_id": "1", + "city_name_ar": "المعصرة", + "city_name_en": "Maasara" + }, + { + "id": "23", + "governorate_id": "1", + "city_name_ar": "المقطم", + "city_name_en": "Mokattam" + }, + { + "id": "24", + "governorate_id": "1", + "city_name_ar": "المنيل", + "city_name_en": "Manyal" + }, + { + "id": "25", + "governorate_id": "1", + "city_name_ar": "الموسكى", + "city_name_en": "Mosky" + }, + { + "id": "26", + "governorate_id": "1", + "city_name_ar": "النزهة", + "city_name_en": "Nozha" + }, + { + "id": "27", + "governorate_id": "1", + "city_name_ar": "الوايلى", + "city_name_en": "Waily" + }, + { + "id": "28", + "governorate_id": "1", + "city_name_ar": "باب الشعرية", + "city_name_en": "Bab al-Shereia" + }, + { + "id": "29", + "governorate_id": "1", + "city_name_ar": "بولاق", + "city_name_en": "Bolaq" + }, + { + "id": "30", + "governorate_id": "1", + "city_name_ar": "جاردن سيتى", + "city_name_en": "Garden City" + }, + { + "id": "31", + "governorate_id": "1", + "city_name_ar": "حدائق القبة", + "city_name_en": "Hadayek El-Kobba" + }, + { + "id": "32", + "governorate_id": "1", + "city_name_ar": "حلوان", + "city_name_en": "Helwan" + }, + { + "id": "33", + "governorate_id": "1", + "city_name_ar": "دار السلام", + "city_name_en": "Dar Al Salam" + }, + { + "id": "34", + "governorate_id": "1", + "city_name_ar": "شبرا", + "city_name_en": "Shubra" + }, + { + "id": "35", + "governorate_id": "1", + "city_name_ar": "طره", + "city_name_en": "Tura" + }, + { + "id": "36", + "governorate_id": "1", + "city_name_ar": "عابدين", + "city_name_en": "Abdeen" + }, + { + "id": "37", + "governorate_id": "1", + "city_name_ar": "عباسية", + "city_name_en": "Abaseya" + }, + { + "id": "38", + "governorate_id": "1", + "city_name_ar": "عين شمس", + "city_name_en": "Ain Shams" + }, + { + "id": "39", + "governorate_id": "1", + "city_name_ar": "مدينة نصر", + "city_name_en": "Nasr City" + }, + { + "id": "40", + "governorate_id": "1", + "city_name_ar": "مصر الجديدة", + "city_name_en": "New Heliopolis" + }, + { + "id": "41", + "governorate_id": "1", + "city_name_ar": "مصر القديمة", + "city_name_en": "Masr Al Qadima" + }, + { + "id": "42", + "governorate_id": "1", + "city_name_ar": "منشية ناصر", + "city_name_en": "Mansheya Nasir" + }, + { + "id": "43", + "governorate_id": "1", + "city_name_ar": "مدينة بدر", + "city_name_en": "Badr City" + }, + { + "id": "44", + "governorate_id": "1", + "city_name_ar": "مدينة العبور", + "city_name_en": "Obour City" + }, + { + "id": "45", + "governorate_id": "1", + "city_name_ar": "وسط البلد", + "city_name_en": "Cairo Downtown" + }, + { + "id": "46", + "governorate_id": "1", + "city_name_ar": "الزمالك", + "city_name_en": "Zamalek" + }, + { + "id": "47", + "governorate_id": "1", + "city_name_ar": "قصر النيل", + "city_name_en": "Kasr El Nile" + }, + { + "id": "48", + "governorate_id": "1", + "city_name_ar": "الرحاب", + "city_name_en": "Rehab" + }, + { + "id": "49", + "governorate_id": "1", + "city_name_ar": "القطامية", + "city_name_en": "Katameya" + }, + { + "id": "50", + "governorate_id": "1", + "city_name_ar": "مدينتي", + "city_name_en": "Madinty" + }, + { + "id": "51", + "governorate_id": "1", + "city_name_ar": "روض الفرج", + "city_name_en": "Rod Alfarag" + }, + { + "id": "52", + "governorate_id": "1", + "city_name_ar": "شيراتون", + "city_name_en": "Sheraton" + }, + { + "id": "53", + "governorate_id": "1", + "city_name_ar": "الجمالية", + "city_name_en": "El-Gamaleya" + }, + { + "id": "54", + "governorate_id": "1", + "city_name_ar": "العاشر من رمضان", + "city_name_en": "10th of Ramadan City" + }, + { + "id": "55", + "governorate_id": "1", + "city_name_ar": "الحلمية", + "city_name_en": "Helmeyat Alzaytoun" + }, + { + "id": "56", + "governorate_id": "1", + "city_name_ar": "النزهة الجديدة", + "city_name_en": "New Nozha" + }, + { + "id": "57", + "governorate_id": "1", + "city_name_ar": "العاصمة الإدارية", + "city_name_en": "Capital New" + } + ], + "2": [ + { + "id": "58", + "governorate_id": "2", + "city_name_ar": "الجيزة", + "city_name_en": "Giza" + }, + { + "id": "59", + "governorate_id": "2", + "city_name_ar": "السادس من أكتوبر", + "city_name_en": "Sixth of October" + }, + { + "id": "60", + "governorate_id": "2", + "city_name_ar": "الشيخ زايد", + "city_name_en": "Cheikh Zayed" + }, + { + "id": "61", + "governorate_id": "2", + "city_name_ar": "الحوامدية", + "city_name_en": "Hawamdiyah" + }, + { + "id": "62", + "governorate_id": "2", + "city_name_ar": "البدرشين", + "city_name_en": "Al Badrasheen" + }, + { + "id": "63", + "governorate_id": "2", + "city_name_ar": "الصف", + "city_name_en": "Saf" + }, + { + "id": "64", + "governorate_id": "2", + "city_name_ar": "أطفيح", + "city_name_en": "Atfih" + }, + { + "id": "65", + "governorate_id": "2", + "city_name_ar": "العياط", + "city_name_en": "Al Ayat" + }, + { + "id": "66", + "governorate_id": "2", + "city_name_ar": "الباويطي", + "city_name_en": "Al-Bawaiti" + }, + { + "id": "67", + "governorate_id": "2", + "city_name_ar": "منشأة القناطر", + "city_name_en": "ManshiyetAl Qanater" + }, + { + "id": "68", + "governorate_id": "2", + "city_name_ar": "أوسيم", + "city_name_en": "Oaseem" + }, + { + "id": "69", + "governorate_id": "2", + "city_name_ar": "كرداسة", + "city_name_en": "Kerdasa" + }, + { + "id": "70", + "governorate_id": "2", + "city_name_ar": "أبو النمرس", + "city_name_en": "Abu Nomros" + }, + { + "id": "71", + "governorate_id": "2", + "city_name_ar": "كفر غطاطي", + "city_name_en": "Kafr Ghati" + }, + { + "id": "72", + "governorate_id": "2", + "city_name_ar": "منشأة البكاري", + "city_name_en": "Manshiyet Al Bakari" + }, + { + "id": "73", + "governorate_id": "2", + "city_name_ar": "الدقى", + "city_name_en": "Dokki" + }, + { + "id": "74", + "governorate_id": "2", + "city_name_ar": "العجوزة", + "city_name_en": "Agouza" + }, + { + "id": "75", + "governorate_id": "2", + "city_name_ar": "الهرم", + "city_name_en": "Haram" + }, + { + "id": "76", + "governorate_id": "2", + "city_name_ar": "الوراق", + "city_name_en": "Warraq" + }, + { + "id": "77", + "governorate_id": "2", + "city_name_ar": "امبابة", + "city_name_en": "Imbaba" + }, + { + "id": "78", + "governorate_id": "2", + "city_name_ar": "بولاق الدكرور", + "city_name_en": "Boulaq Dakrour" + }, + { + "id": "79", + "governorate_id": "2", + "city_name_ar": "الواحات البحرية", + "city_name_en": "Al Wahat Al Baharia" + }, + { + "id": "80", + "governorate_id": "2", + "city_name_ar": "العمرانية", + "city_name_en": "Omraneya" + }, + { + "id": "81", + "governorate_id": "2", + "city_name_ar": "المنيب", + "city_name_en": "Moneeb" + }, + { + "id": "82", + "governorate_id": "2", + "city_name_ar": "بين السرايات", + "city_name_en": "Bin Alsarayat" + }, + { + "id": "83", + "governorate_id": "2", + "city_name_ar": "الكيت كات", + "city_name_en": "Kit Kat" + }, + { + "id": "84", + "governorate_id": "2", + "city_name_ar": "المهندسين", + "city_name_en": "Mohandessin" + }, + { + "id": "85", + "governorate_id": "2", + "city_name_ar": "فيصل", + "city_name_en": "Faisal" + }, + { + "id": "86", + "governorate_id": "2", + "city_name_ar": "أبو رواش", + "city_name_en": "Abu Rawash" + }, + { + "id": "87", + "governorate_id": "2", + "city_name_ar": "حدائق الأهرام", + "city_name_en": "Hadayek Alahram" + }, + { + "id": "88", + "governorate_id": "2", + "city_name_ar": "الحرانية", + "city_name_en": "Haraneya" + }, + { + "id": "89", + "governorate_id": "2", + "city_name_ar": "حدائق اكتوبر", + "city_name_en": "Hadayek October" + }, + { + "id": "90", + "governorate_id": "2", + "city_name_ar": "صفط اللبن", + "city_name_en": "Saft Allaban" + }, + { + "id": "91", + "governorate_id": "2", + "city_name_ar": "القرية الذكية", + "city_name_en": "Smart Village" + }, + { + "id": "92", + "governorate_id": "2", + "city_name_ar": "ارض اللواء", + "city_name_en": "Ard Ellwaa" + } + ], + "3": [ + { + "id": "93", + "governorate_id": "3", + "city_name_ar": "ابو قير", + "city_name_en": "Abu Qir" + }, + { + "id": "94", + "governorate_id": "3", + "city_name_ar": "الابراهيمية", + "city_name_en": "Al Ibrahimeyah" + }, + { + "id": "95", + "governorate_id": "3", + "city_name_ar": "الأزاريطة", + "city_name_en": "Azarita" + }, + { + "id": "96", + "governorate_id": "3", + "city_name_ar": "الانفوشى", + "city_name_en": "Anfoushi" + }, + { + "id": "97", + "governorate_id": "3", + "city_name_ar": "الدخيلة", + "city_name_en": "Dekheila" + }, + { + "id": "98", + "governorate_id": "3", + "city_name_ar": "السيوف", + "city_name_en": "El Soyof" + }, + { + "id": "99", + "governorate_id": "3", + "city_name_ar": "العامرية", + "city_name_en": "Ameria" + }, + { + "id": "100", + "governorate_id": "3", + "city_name_ar": "اللبان", + "city_name_en": "El Labban" + }, + { + "id": "101", + "governorate_id": "3", + "city_name_ar": "المفروزة", + "city_name_en": "Al Mafrouza" + }, + { + "id": "102", + "governorate_id": "3", + "city_name_ar": "المنتزه", + "city_name_en": "El Montaza" + }, + { + "id": "103", + "governorate_id": "3", + "city_name_ar": "المنشية", + "city_name_en": "Mansheya" + }, + { + "id": "104", + "governorate_id": "3", + "city_name_ar": "الناصرية", + "city_name_en": "Naseria" + }, + { + "id": "105", + "governorate_id": "3", + "city_name_ar": "امبروزو", + "city_name_en": "Ambrozo" + }, + { + "id": "106", + "governorate_id": "3", + "city_name_ar": "باب شرق", + "city_name_en": "Bab Sharq" + }, + { + "id": "107", + "governorate_id": "3", + "city_name_ar": "برج العرب", + "city_name_en": "Bourj Alarab" + }, + { + "id": "108", + "governorate_id": "3", + "city_name_ar": "ستانلى", + "city_name_en": "Stanley" + }, + { + "id": "109", + "governorate_id": "3", + "city_name_ar": "سموحة", + "city_name_en": "Smouha" + }, + { + "id": "110", + "governorate_id": "3", + "city_name_ar": "سيدى بشر", + "city_name_en": "Sidi Bishr" + }, + { + "id": "111", + "governorate_id": "3", + "city_name_ar": "شدس", + "city_name_en": "Shads" + }, + { + "id": "112", + "governorate_id": "3", + "city_name_ar": "غيط العنب", + "city_name_en": "Gheet Alenab" + }, + { + "id": "113", + "governorate_id": "3", + "city_name_ar": "فلمينج", + "city_name_en": "Fleming" + }, + { + "id": "114", + "governorate_id": "3", + "city_name_ar": "فيكتوريا", + "city_name_en": "Victoria" + }, + { + "id": "115", + "governorate_id": "3", + "city_name_ar": "كامب شيزار", + "city_name_en": "Camp Shizar" + }, + { + "id": "116", + "governorate_id": "3", + "city_name_ar": "كرموز", + "city_name_en": "Karmooz" + }, + { + "id": "117", + "governorate_id": "3", + "city_name_ar": "محطة الرمل", + "city_name_en": "Mahta Alraml" + }, + { + "id": "118", + "governorate_id": "3", + "city_name_ar": "مينا البصل", + "city_name_en": "Mina El-Basal" + }, + { + "id": "119", + "governorate_id": "3", + "city_name_ar": "العصافرة", + "city_name_en": "Asafra" + }, + { + "id": "120", + "governorate_id": "3", + "city_name_ar": "العجمي", + "city_name_en": "Agamy" + }, + { + "id": "121", + "governorate_id": "3", + "city_name_ar": "بكوس", + "city_name_en": "Bakos" + }, + { + "id": "122", + "governorate_id": "3", + "city_name_ar": "بولكلي", + "city_name_en": "Boulkly" + }, + { + "id": "123", + "governorate_id": "3", + "city_name_ar": "كليوباترا", + "city_name_en": "Cleopatra" + }, + { + "id": "124", + "governorate_id": "3", + "city_name_ar": "جليم", + "city_name_en": "Glim" + }, + { + "id": "125", + "governorate_id": "3", + "city_name_ar": "المعمورة", + "city_name_en": "Al Mamurah" + }, + { + "id": "126", + "governorate_id": "3", + "city_name_ar": "المندرة", + "city_name_en": "Al Mandara" + }, + { + "id": "127", + "governorate_id": "3", + "city_name_ar": "محرم بك", + "city_name_en": "Moharam Bek" + }, + { + "id": "128", + "governorate_id": "3", + "city_name_ar": "الشاطبي", + "city_name_en": "Elshatby" + }, + { + "id": "129", + "governorate_id": "3", + "city_name_ar": "سيدي جابر", + "city_name_en": "Sidi Gaber" + }, + { + "id": "130", + "governorate_id": "3", + "city_name_ar": "الساحل الشمالي", + "city_name_en": "North Coast\/sahel" + }, + { + "id": "131", + "governorate_id": "3", + "city_name_ar": "الحضرة", + "city_name_en": "Alhadra" + }, + { + "id": "132", + "governorate_id": "3", + "city_name_ar": "العطارين", + "city_name_en": "Alattarin" + }, + { + "id": "133", + "governorate_id": "3", + "city_name_ar": "سيدي كرير", + "city_name_en": "Sidi Kerir" + }, + { + "id": "134", + "governorate_id": "3", + "city_name_ar": "الجمرك", + "city_name_en": "Elgomrok" + }, + { + "id": "135", + "governorate_id": "3", + "city_name_ar": "المكس", + "city_name_en": "Al Max" + }, + { + "id": "136", + "governorate_id": "3", + "city_name_ar": "مارينا", + "city_name_en": "Marina" + } + ], + "4": [ + { + "id": "137", + "governorate_id": "4", + "city_name_ar": "المنصورة", + "city_name_en": "Mansoura" + }, + { + "id": "138", + "governorate_id": "4", + "city_name_ar": "طلخا", + "city_name_en": "Talkha" + }, + { + "id": "139", + "governorate_id": "4", + "city_name_ar": "ميت غمر", + "city_name_en": "Mitt Ghamr" + }, + { + "id": "140", + "governorate_id": "4", + "city_name_ar": "دكرنس", + "city_name_en": "Dekernes" + }, + { + "id": "141", + "governorate_id": "4", + "city_name_ar": "أجا", + "city_name_en": "Aga" + }, + { + "id": "142", + "governorate_id": "4", + "city_name_ar": "منية النصر", + "city_name_en": "Menia El Nasr" + }, + { + "id": "143", + "governorate_id": "4", + "city_name_ar": "السنبلاوين", + "city_name_en": "Sinbillawin" + }, + { + "id": "144", + "governorate_id": "4", + "city_name_ar": "الكردي", + "city_name_en": "El Kurdi" + }, + { + "id": "145", + "governorate_id": "4", + "city_name_ar": "بني عبيد", + "city_name_en": "Bani Ubaid" + }, + { + "id": "146", + "governorate_id": "4", + "city_name_ar": "المنزلة", + "city_name_en": "Al Manzala" + }, + { + "id": "147", + "governorate_id": "4", + "city_name_ar": "تمي الأمديد", + "city_name_en": "tami al'amdid" + }, + { + "id": "148", + "governorate_id": "4", + "city_name_ar": "الجمالية", + "city_name_en": "aljamalia" + }, + { + "id": "149", + "governorate_id": "4", + "city_name_ar": "شربين", + "city_name_en": "Sherbin" + }, + { + "id": "150", + "governorate_id": "4", + "city_name_ar": "المطرية", + "city_name_en": "Mataria" + }, + { + "id": "151", + "governorate_id": "4", + "city_name_ar": "بلقاس", + "city_name_en": "Belqas" + }, + { + "id": "152", + "governorate_id": "4", + "city_name_ar": "ميت سلسيل", + "city_name_en": "Meet Salsil" + }, + { + "id": "153", + "governorate_id": "4", + "city_name_ar": "جمصة", + "city_name_en": "Gamasa" + }, + { + "id": "154", + "governorate_id": "4", + "city_name_ar": "محلة دمنة", + "city_name_en": "Mahalat Damana" + }, + { + "id": "155", + "governorate_id": "4", + "city_name_ar": "نبروه", + "city_name_en": "Nabroh" + } + ], + "5": [ + { + "id": "156", + "governorate_id": "5", + "city_name_ar": "الغردقة", + "city_name_en": "Hurghada" + }, + { + "id": "157", + "governorate_id": "5", + "city_name_ar": "رأس غارب", + "city_name_en": "Ras Ghareb" + }, + { + "id": "158", + "governorate_id": "5", + "city_name_ar": "سفاجا", + "city_name_en": "Safaga" + }, + { + "id": "159", + "governorate_id": "5", + "city_name_ar": "القصير", + "city_name_en": "El Qusiar" + }, + { + "id": "160", + "governorate_id": "5", + "city_name_ar": "مرسى علم", + "city_name_en": "Marsa Alam" + }, + { + "id": "161", + "governorate_id": "5", + "city_name_ar": "الشلاتين", + "city_name_en": "Shalatin" + }, + { + "id": "162", + "governorate_id": "5", + "city_name_ar": "حلايب", + "city_name_en": "Halaib" + }, + { + "id": "163", + "governorate_id": "5", + "city_name_ar": "الدهار", + "city_name_en": "Aldahar" + } + ], + "6": [ + { + "id": "164", + "governorate_id": "6", + "city_name_ar": "دمنهور", + "city_name_en": "Damanhour" + }, + { + "id": "165", + "governorate_id": "6", + "city_name_ar": "كفر الدوار", + "city_name_en": "Kafr El Dawar" + }, + { + "id": "166", + "governorate_id": "6", + "city_name_ar": "رشيد", + "city_name_en": "Rashid" + }, + { + "id": "167", + "governorate_id": "6", + "city_name_ar": "إدكو", + "city_name_en": "Edco" + }, + { + "id": "168", + "governorate_id": "6", + "city_name_ar": "أبو المطامير", + "city_name_en": "Abu al-Matamir" + }, + { + "id": "169", + "governorate_id": "6", + "city_name_ar": "أبو حمص", + "city_name_en": "Abu Homs" + }, + { + "id": "170", + "governorate_id": "6", + "city_name_ar": "الدلنجات", + "city_name_en": "Delengat" + }, + { + "id": "171", + "governorate_id": "6", + "city_name_ar": "المحمودية", + "city_name_en": "Mahmoudiyah" + }, + { + "id": "172", + "governorate_id": "6", + "city_name_ar": "الرحمانية", + "city_name_en": "Rahmaniyah" + }, + { + "id": "173", + "governorate_id": "6", + "city_name_ar": "إيتاي البارود", + "city_name_en": "Itai Baroud" + }, + { + "id": "174", + "governorate_id": "6", + "city_name_ar": "حوش عيسى", + "city_name_en": "Housh Eissa" + }, + { + "id": "175", + "governorate_id": "6", + "city_name_ar": "شبراخيت", + "city_name_en": "Shubrakhit" + }, + { + "id": "176", + "governorate_id": "6", + "city_name_ar": "كوم حمادة", + "city_name_en": "Kom Hamada" + }, + { + "id": "177", + "governorate_id": "6", + "city_name_ar": "بدر", + "city_name_en": "Badr" + }, + { + "id": "178", + "governorate_id": "6", + "city_name_ar": "وادي النطرون", + "city_name_en": "Wadi Natrun" + }, + { + "id": "179", + "governorate_id": "6", + "city_name_ar": "النوبارية الجديدة", + "city_name_en": "New Nubaria" + }, + { + "id": "180", + "governorate_id": "6", + "city_name_ar": "النوبارية", + "city_name_en": "Alnoubareya" + } + ], + "7": [ + { + "id": "181", + "governorate_id": "7", + "city_name_ar": "الفيوم", + "city_name_en": "Fayoum" + }, + { + "id": "182", + "governorate_id": "7", + "city_name_ar": "الفيوم الجديدة", + "city_name_en": "Fayoum El Gedida" + }, + { + "id": "183", + "governorate_id": "7", + "city_name_ar": "طامية", + "city_name_en": "Tamiya" + }, + { + "id": "184", + "governorate_id": "7", + "city_name_ar": "سنورس", + "city_name_en": "Snores" + }, + { + "id": "185", + "governorate_id": "7", + "city_name_ar": "إطسا", + "city_name_en": "Etsa" + }, + { + "id": "186", + "governorate_id": "7", + "city_name_ar": "إبشواي", + "city_name_en": "Epschway" + }, + { + "id": "187", + "governorate_id": "7", + "city_name_ar": "يوسف الصديق", + "city_name_en": "Yusuf El Sediaq" + }, + { + "id": "188", + "governorate_id": "7", + "city_name_ar": "الحادقة", + "city_name_en": "Hadqa" + }, + { + "id": "189", + "governorate_id": "7", + "city_name_ar": "اطسا", + "city_name_en": "Atsa" + }, + { + "id": "190", + "governorate_id": "7", + "city_name_ar": "الجامعة", + "city_name_en": "Algamaa" + }, + { + "id": "191", + "governorate_id": "7", + "city_name_ar": "السيالة", + "city_name_en": "Sayala" + } + ], + "8": [ + { + "id": "192", + "governorate_id": "8", + "city_name_ar": "طنطا", + "city_name_en": "Tanta" + }, + { + "id": "193", + "governorate_id": "8", + "city_name_ar": "المحلة الكبرى", + "city_name_en": "Al Mahalla Al Kobra" + }, + { + "id": "194", + "governorate_id": "8", + "city_name_ar": "كفر الزيات", + "city_name_en": "Kafr El Zayat" + }, + { + "id": "195", + "governorate_id": "8", + "city_name_ar": "زفتى", + "city_name_en": "Zefta" + }, + { + "id": "196", + "governorate_id": "8", + "city_name_ar": "السنطة", + "city_name_en": "El Santa" + }, + { + "id": "197", + "governorate_id": "8", + "city_name_ar": "قطور", + "city_name_en": "Qutour" + }, + { + "id": "198", + "governorate_id": "8", + "city_name_ar": "بسيون", + "city_name_en": "Basion" + }, + { + "id": "199", + "governorate_id": "8", + "city_name_ar": "سمنود", + "city_name_en": "Samannoud" + } + ], + "9": [ + { + "id": "200", + "governorate_id": "9", + "city_name_ar": "الإسماعيلية", + "city_name_en": "Ismailia" + }, + { + "id": "201", + "governorate_id": "9", + "city_name_ar": "فايد", + "city_name_en": "Fayed" + }, + { + "id": "202", + "governorate_id": "9", + "city_name_ar": "القنطرة شرق", + "city_name_en": "Qantara Sharq" + }, + { + "id": "203", + "governorate_id": "9", + "city_name_ar": "القنطرة غرب", + "city_name_en": "Qantara Gharb" + }, + { + "id": "204", + "governorate_id": "9", + "city_name_ar": "التل الكبير", + "city_name_en": "El Tal El Kabier" + }, + { + "id": "205", + "governorate_id": "9", + "city_name_ar": "أبو صوير", + "city_name_en": "Abu Sawir" + }, + { + "id": "206", + "governorate_id": "9", + "city_name_ar": "القصاصين الجديدة", + "city_name_en": "Kasasien El Gedida" + }, + { + "id": "207", + "governorate_id": "9", + "city_name_ar": "نفيشة", + "city_name_en": "Nefesha" + }, + { + "id": "208", + "governorate_id": "9", + "city_name_ar": "الشيخ زايد", + "city_name_en": "Sheikh Zayed" + } + ], + "10": [ + { + "id": "209", + "governorate_id": "10", + "city_name_ar": "شبين الكوم", + "city_name_en": "Shbeen El Koom" + }, + { + "id": "210", + "governorate_id": "10", + "city_name_ar": "مدينة السادات", + "city_name_en": "Sadat City" + }, + { + "id": "211", + "governorate_id": "10", + "city_name_ar": "منوف", + "city_name_en": "Menouf" + }, + { + "id": "212", + "governorate_id": "10", + "city_name_ar": "سرس الليان", + "city_name_en": "Sars El-Layan" + }, + { + "id": "213", + "governorate_id": "10", + "city_name_ar": "أشمون", + "city_name_en": "Ashmon" + }, + { + "id": "214", + "governorate_id": "10", + "city_name_ar": "الباجور", + "city_name_en": "Al Bagor" + }, + { + "id": "215", + "governorate_id": "10", + "city_name_ar": "قويسنا", + "city_name_en": "Quesna" + }, + { + "id": "216", + "governorate_id": "10", + "city_name_ar": "بركة السبع", + "city_name_en": "Berkat El Saba" + }, + { + "id": "217", + "governorate_id": "10", + "city_name_ar": "تلا", + "city_name_en": "Tala" + }, + { + "id": "218", + "governorate_id": "10", + "city_name_ar": "الشهداء", + "city_name_en": "Al Shohada" + } + ], + "11": [ + { + "id": "219", + "governorate_id": "11", + "city_name_ar": "المنيا", + "city_name_en": "Minya" + }, + { + "id": "220", + "governorate_id": "11", + "city_name_ar": "المنيا الجديدة", + "city_name_en": "Minya El Gedida" + }, + { + "id": "221", + "governorate_id": "11", + "city_name_ar": "العدوة", + "city_name_en": "El Adwa" + }, + { + "id": "222", + "governorate_id": "11", + "city_name_ar": "مغاغة", + "city_name_en": "Magagha" + }, + { + "id": "223", + "governorate_id": "11", + "city_name_ar": "بني مزار", + "city_name_en": "Bani Mazar" + }, + { + "id": "224", + "governorate_id": "11", + "city_name_ar": "مطاي", + "city_name_en": "Mattay" + }, + { + "id": "225", + "governorate_id": "11", + "city_name_ar": "سمالوط", + "city_name_en": "Samalut" + }, + { + "id": "226", + "governorate_id": "11", + "city_name_ar": "المدينة الفكرية", + "city_name_en": "Madinat El Fekria" + }, + { + "id": "227", + "governorate_id": "11", + "city_name_ar": "ملوي", + "city_name_en": "Meloy" + }, + { + "id": "228", + "governorate_id": "11", + "city_name_ar": "دير مواس", + "city_name_en": "Deir Mawas" + }, + { + "id": "229", + "governorate_id": "11", + "city_name_ar": "ابو قرقاص", + "city_name_en": "Abu Qurqas" + }, + { + "id": "230", + "governorate_id": "11", + "city_name_ar": "ارض سلطان", + "city_name_en": "Ard Sultan" + } + ], + "12": [ + { + "id": "231", + "governorate_id": "12", + "city_name_ar": "بنها", + "city_name_en": "Banha" + }, + { + "id": "232", + "governorate_id": "12", + "city_name_ar": "قليوب", + "city_name_en": "Qalyub" + }, + { + "id": "233", + "governorate_id": "12", + "city_name_ar": "شبرا الخيمة", + "city_name_en": "Shubra Al Khaimah" + }, + { + "id": "234", + "governorate_id": "12", + "city_name_ar": "القناطر الخيرية", + "city_name_en": "Al Qanater Charity" + }, + { + "id": "235", + "governorate_id": "12", + "city_name_ar": "الخانكة", + "city_name_en": "Khanka" + }, + { + "id": "236", + "governorate_id": "12", + "city_name_ar": "كفر شكر", + "city_name_en": "Kafr Shukr" + }, + { + "id": "237", + "governorate_id": "12", + "city_name_ar": "طوخ", + "city_name_en": "Tukh" + }, + { + "id": "238", + "governorate_id": "12", + "city_name_ar": "قها", + "city_name_en": "Qaha" + }, + { + "id": "239", + "governorate_id": "12", + "city_name_ar": "العبور", + "city_name_en": "Obour" + }, + { + "id": "240", + "governorate_id": "12", + "city_name_ar": "الخصوص", + "city_name_en": "Khosous" + }, + { + "id": "241", + "governorate_id": "12", + "city_name_ar": "شبين القناطر", + "city_name_en": "Shibin Al Qanater" + }, + { + "id": "242", + "governorate_id": "12", + "city_name_ar": "مسطرد", + "city_name_en": "Mostorod" + } + ], + "13": [ + { + "id": "243", + "governorate_id": "13", + "city_name_ar": "الخارجة", + "city_name_en": "El Kharga" + }, + { + "id": "244", + "governorate_id": "13", + "city_name_ar": "باريس", + "city_name_en": "Paris" + }, + { + "id": "245", + "governorate_id": "13", + "city_name_ar": "موط", + "city_name_en": "Mout" + }, + { + "id": "246", + "governorate_id": "13", + "city_name_ar": "الفرافرة", + "city_name_en": "Farafra" + }, + { + "id": "247", + "governorate_id": "13", + "city_name_ar": "بلاط", + "city_name_en": "Balat" + }, + { + "id": "248", + "governorate_id": "13", + "city_name_ar": "الداخلة", + "city_name_en": "Dakhla" + } + ], + "14": [ + { + "id": "249", + "governorate_id": "14", + "city_name_ar": "السويس", + "city_name_en": "Suez" + }, + { + "id": "250", + "governorate_id": "14", + "city_name_ar": "الجناين", + "city_name_en": "Alganayen" + }, + { + "id": "251", + "governorate_id": "14", + "city_name_ar": "عتاقة", + "city_name_en": "Ataqah" + }, + { + "id": "252", + "governorate_id": "14", + "city_name_ar": "العين السخنة", + "city_name_en": "Ain Sokhna" + }, + { + "id": "253", + "governorate_id": "14", + "city_name_ar": "فيصل", + "city_name_en": "Faysal" + } + ], + "15": [ + { + "id": "254", + "governorate_id": "15", + "city_name_ar": "أسوان", + "city_name_en": "Aswan" + }, + { + "id": "255", + "governorate_id": "15", + "city_name_ar": "أسوان الجديدة", + "city_name_en": "Aswan El Gedida" + }, + { + "id": "256", + "governorate_id": "15", + "city_name_ar": "دراو", + "city_name_en": "Drau" + }, + { + "id": "257", + "governorate_id": "15", + "city_name_ar": "كوم أمبو", + "city_name_en": "Kom Ombo" + }, + { + "id": "258", + "governorate_id": "15", + "city_name_ar": "نصر النوبة", + "city_name_en": "Nasr Al Nuba" + }, + { + "id": "259", + "governorate_id": "15", + "city_name_ar": "كلابشة", + "city_name_en": "Kalabsha" + }, + { + "id": "260", + "governorate_id": "15", + "city_name_ar": "إدفو", + "city_name_en": "Edfu" + }, + { + "id": "261", + "governorate_id": "15", + "city_name_ar": "الرديسية", + "city_name_en": "Al-Radisiyah" + }, + { + "id": "262", + "governorate_id": "15", + "city_name_ar": "البصيلية", + "city_name_en": "Al Basilia" + }, + { + "id": "263", + "governorate_id": "15", + "city_name_ar": "السباعية", + "city_name_en": "Al Sibaeia" + }, + { + "id": "264", + "governorate_id": "15", + "city_name_ar": "ابوسمبل السياحية", + "city_name_en": "Abo Simbl Al Siyahia" + }, + { + "id": "265", + "governorate_id": "15", + "city_name_ar": "مرسى علم", + "city_name_en": "Marsa Alam" + } + ], + "16": [ + { + "id": "266", + "governorate_id": "16", + "city_name_ar": "أسيوط", + "city_name_en": "Assiut" + }, + { + "id": "267", + "governorate_id": "16", + "city_name_ar": "أسيوط الجديدة", + "city_name_en": "Assiut El Gedida" + }, + { + "id": "268", + "governorate_id": "16", + "city_name_ar": "ديروط", + "city_name_en": "Dayrout" + }, + { + "id": "269", + "governorate_id": "16", + "city_name_ar": "منفلوط", + "city_name_en": "Manfalut" + }, + { + "id": "270", + "governorate_id": "16", + "city_name_ar": "القوصية", + "city_name_en": "Qusiya" + }, + { + "id": "271", + "governorate_id": "16", + "city_name_ar": "أبنوب", + "city_name_en": "Abnoub" + }, + { + "id": "272", + "governorate_id": "16", + "city_name_ar": "أبو تيج", + "city_name_en": "Abu Tig" + }, + { + "id": "273", + "governorate_id": "16", + "city_name_ar": "الغنايم", + "city_name_en": "El Ghanaim" + }, + { + "id": "274", + "governorate_id": "16", + "city_name_ar": "ساحل سليم", + "city_name_en": "Sahel Selim" + }, + { + "id": "275", + "governorate_id": "16", + "city_name_ar": "البداري", + "city_name_en": "El Badari" + }, + { + "id": "276", + "governorate_id": "16", + "city_name_ar": "صدفا", + "city_name_en": "Sidfa" + } + ], + "17": [ + { + "id": "277", + "governorate_id": "17", + "city_name_ar": "بني سويف", + "city_name_en": "Bani Sweif" + }, + { + "id": "278", + "governorate_id": "17", + "city_name_ar": "بني سويف الجديدة", + "city_name_en": "Beni Suef El Gedida" + }, + { + "id": "279", + "governorate_id": "17", + "city_name_ar": "الواسطى", + "city_name_en": "Al Wasta" + }, + { + "id": "280", + "governorate_id": "17", + "city_name_ar": "ناصر", + "city_name_en": "Naser" + }, + { + "id": "281", + "governorate_id": "17", + "city_name_ar": "إهناسيا", + "city_name_en": "Ehnasia" + }, + { + "id": "282", + "governorate_id": "17", + "city_name_ar": "ببا", + "city_name_en": "beba" + }, + { + "id": "283", + "governorate_id": "17", + "city_name_ar": "الفشن", + "city_name_en": "Fashn" + }, + { + "id": "284", + "governorate_id": "17", + "city_name_ar": "سمسطا", + "city_name_en": "Somasta" + }, + { + "id": "285", + "governorate_id": "17", + "city_name_ar": "الاباصيرى", + "city_name_en": "Alabbaseri" + }, + { + "id": "286", + "governorate_id": "17", + "city_name_ar": "مقبل", + "city_name_en": "Mokbel" + } + ], + "18": [ + { + "id": "287", + "governorate_id": "18", + "city_name_ar": "بورسعيد", + "city_name_en": "PorSaid" + }, + { + "id": "288", + "governorate_id": "18", + "city_name_ar": "بورفؤاد", + "city_name_en": "Port Fouad" + }, + { + "id": "289", + "governorate_id": "18", + "city_name_ar": "العرب", + "city_name_en": "Alarab" + }, + { + "id": "290", + "governorate_id": "18", + "city_name_ar": "حى الزهور", + "city_name_en": "Zohour" + }, + { + "id": "291", + "governorate_id": "18", + "city_name_ar": "حى الشرق", + "city_name_en": "Alsharq" + }, + { + "id": "292", + "governorate_id": "18", + "city_name_ar": "حى الضواحى", + "city_name_en": "Aldawahi" + }, + { + "id": "293", + "governorate_id": "18", + "city_name_ar": "حى المناخ", + "city_name_en": "Almanakh" + }, + { + "id": "294", + "governorate_id": "18", + "city_name_ar": "حى مبارك", + "city_name_en": "Mubarak" + } + ], + "19": [ + { + "id": "295", + "governorate_id": "19", + "city_name_ar": "دمياط", + "city_name_en": "Damietta" + }, + { + "id": "296", + "governorate_id": "19", + "city_name_ar": "دمياط الجديدة", + "city_name_en": "New Damietta" + }, + { + "id": "297", + "governorate_id": "19", + "city_name_ar": "رأس البر", + "city_name_en": "Ras El Bar" + }, + { + "id": "298", + "governorate_id": "19", + "city_name_ar": "فارسكور", + "city_name_en": "Faraskour" + }, + { + "id": "299", + "governorate_id": "19", + "city_name_ar": "الزرقا", + "city_name_en": "Zarqa" + }, + { + "id": "300", + "governorate_id": "19", + "city_name_ar": "السرو", + "city_name_en": "alsaru" + }, + { + "id": "301", + "governorate_id": "19", + "city_name_ar": "الروضة", + "city_name_en": "alruwda" + }, + { + "id": "302", + "governorate_id": "19", + "city_name_ar": "كفر البطيخ", + "city_name_en": "Kafr El-Batikh" + }, + { + "id": "303", + "governorate_id": "19", + "city_name_ar": "عزبة البرج", + "city_name_en": "Azbet Al Burg" + }, + { + "id": "304", + "governorate_id": "19", + "city_name_ar": "ميت أبو غالب", + "city_name_en": "Meet Abou Ghalib" + }, + { + "id": "305", + "governorate_id": "19", + "city_name_ar": "كفر سعد", + "city_name_en": "Kafr Saad" + } + ], + "20": [ + { + "id": "306", + "governorate_id": "20", + "city_name_ar": "الزقازيق", + "city_name_en": "Zagazig" + }, + { + "id": "307", + "governorate_id": "20", + "city_name_ar": "العاشر من رمضان", + "city_name_en": "Al Ashr Men Ramadan" + }, + { + "id": "308", + "governorate_id": "20", + "city_name_ar": "منيا القمح", + "city_name_en": "Minya Al Qamh" + }, + { + "id": "309", + "governorate_id": "20", + "city_name_ar": "بلبيس", + "city_name_en": "Belbeis" + }, + { + "id": "310", + "governorate_id": "20", + "city_name_ar": "مشتول السوق", + "city_name_en": "Mashtoul El Souq" + }, + { + "id": "311", + "governorate_id": "20", + "city_name_ar": "القنايات", + "city_name_en": "Qenaiat" + }, + { + "id": "312", + "governorate_id": "20", + "city_name_ar": "أبو حماد", + "city_name_en": "Abu Hammad" + }, + { + "id": "313", + "governorate_id": "20", + "city_name_ar": "القرين", + "city_name_en": "El Qurain" + }, + { + "id": "314", + "governorate_id": "20", + "city_name_ar": "ههيا", + "city_name_en": "Hehia" + }, + { + "id": "315", + "governorate_id": "20", + "city_name_ar": "أبو كبير", + "city_name_en": "Abu Kabir" + }, + { + "id": "316", + "governorate_id": "20", + "city_name_ar": "فاقوس", + "city_name_en": "Faccus" + }, + { + "id": "317", + "governorate_id": "20", + "city_name_ar": "الصالحية الجديدة", + "city_name_en": "El Salihia El Gedida" + }, + { + "id": "318", + "governorate_id": "20", + "city_name_ar": "الإبراهيمية", + "city_name_en": "Al Ibrahimiyah" + }, + { + "id": "319", + "governorate_id": "20", + "city_name_ar": "ديرب نجم", + "city_name_en": "Deirb Negm" + }, + { + "id": "320", + "governorate_id": "20", + "city_name_ar": "كفر صقر", + "city_name_en": "Kafr Saqr" + }, + { + "id": "321", + "governorate_id": "20", + "city_name_ar": "أولاد صقر", + "city_name_en": "Awlad Saqr" + }, + { + "id": "322", + "governorate_id": "20", + "city_name_ar": "الحسينية", + "city_name_en": "Husseiniya" + }, + { + "id": "323", + "governorate_id": "20", + "city_name_ar": "صان الحجر القبلية", + "city_name_en": "san alhajar alqablia" + }, + { + "id": "324", + "governorate_id": "20", + "city_name_ar": "منشأة أبو عمر", + "city_name_en": "Manshayat Abu Omar" + } + ], + "21": [ + { + "id": "325", + "governorate_id": "21", + "city_name_ar": "الطور", + "city_name_en": "Al Toor" + }, + { + "id": "326", + "governorate_id": "21", + "city_name_ar": "شرم الشيخ", + "city_name_en": "Sharm El-Shaikh" + }, + { + "id": "327", + "governorate_id": "21", + "city_name_ar": "دهب", + "city_name_en": "Dahab" + }, + { + "id": "328", + "governorate_id": "21", + "city_name_ar": "نويبع", + "city_name_en": "Nuweiba" + }, + { + "id": "329", + "governorate_id": "21", + "city_name_ar": "طابا", + "city_name_en": "Taba" + }, + { + "id": "330", + "governorate_id": "21", + "city_name_ar": "سانت كاترين", + "city_name_en": "Saint Catherine" + }, + { + "id": "331", + "governorate_id": "21", + "city_name_ar": "أبو رديس", + "city_name_en": "Abu Redis" + }, + { + "id": "332", + "governorate_id": "21", + "city_name_ar": "أبو زنيمة", + "city_name_en": "Abu Zenaima" + }, + { + "id": "333", + "governorate_id": "21", + "city_name_ar": "رأس سدر", + "city_name_en": "Ras Sidr" + } + ], + "22": [ + { + "id": "334", + "governorate_id": "22", + "city_name_ar": "كفر الشيخ", + "city_name_en": "Kafr El Sheikh" + }, + { + "id": "335", + "governorate_id": "22", + "city_name_ar": "وسط البلد كفر الشيخ", + "city_name_en": "Kafr El Sheikh Downtown" + }, + { + "id": "336", + "governorate_id": "22", + "city_name_ar": "دسوق", + "city_name_en": "Desouq" + }, + { + "id": "337", + "governorate_id": "22", + "city_name_ar": "فوه", + "city_name_en": "Fooh" + }, + { + "id": "338", + "governorate_id": "22", + "city_name_ar": "مطوبس", + "city_name_en": "Metobas" + }, + { + "id": "339", + "governorate_id": "22", + "city_name_ar": "برج البرلس", + "city_name_en": "Burg Al Burullus" + }, + { + "id": "340", + "governorate_id": "22", + "city_name_ar": "بلطيم", + "city_name_en": "Baltim" + }, + { + "id": "341", + "governorate_id": "22", + "city_name_ar": "مصيف بلطيم", + "city_name_en": "Masief Baltim" + }, + { + "id": "342", + "governorate_id": "22", + "city_name_ar": "الحامول", + "city_name_en": "Hamol" + }, + { + "id": "343", + "governorate_id": "22", + "city_name_ar": "بيلا", + "city_name_en": "Bella" + }, + { + "id": "344", + "governorate_id": "22", + "city_name_ar": "الرياض", + "city_name_en": "Riyadh" + }, + { + "id": "345", + "governorate_id": "22", + "city_name_ar": "سيدي سالم", + "city_name_en": "Sidi Salm" + }, + { + "id": "346", + "governorate_id": "22", + "city_name_ar": "قلين", + "city_name_en": "Qellen" + }, + { + "id": "347", + "governorate_id": "22", + "city_name_ar": "سيدي غازي", + "city_name_en": "Sidi Ghazi" + } + ], + "23": [ + { + "id": "348", + "governorate_id": "23", + "city_name_ar": "مرسى مطروح", + "city_name_en": "Marsa Matrouh" + }, + { + "id": "349", + "governorate_id": "23", + "city_name_ar": "الحمام", + "city_name_en": "El Hamam" + }, + { + "id": "350", + "governorate_id": "23", + "city_name_ar": "العلمين", + "city_name_en": "Alamein" + }, + { + "id": "351", + "governorate_id": "23", + "city_name_ar": "الضبعة", + "city_name_en": "Dabaa" + }, + { + "id": "352", + "governorate_id": "23", + "city_name_ar": "النجيلة", + "city_name_en": "Al-Nagila" + }, + { + "id": "353", + "governorate_id": "23", + "city_name_ar": "سيدي براني", + "city_name_en": "Sidi Brani" + }, + { + "id": "354", + "governorate_id": "23", + "city_name_ar": "السلوم", + "city_name_en": "Salloum" + }, + { + "id": "355", + "governorate_id": "23", + "city_name_ar": "سيوة", + "city_name_en": "Siwa" + }, + { + "id": "356", + "governorate_id": "23", + "city_name_ar": "مارينا", + "city_name_en": "Marina" + }, + { + "id": "357", + "governorate_id": "23", + "city_name_ar": "الساحل الشمالى", + "city_name_en": "North Coast" + } + ], + "24": [ + { + "id": "358", + "governorate_id": "24", + "city_name_ar": "الأقصر", + "city_name_en": "Luxor" + }, + { + "id": "359", + "governorate_id": "24", + "city_name_ar": "الأقصر الجديدة", + "city_name_en": "New Luxor" + }, + { + "id": "360", + "governorate_id": "24", + "city_name_ar": "إسنا", + "city_name_en": "Esna" + }, + { + "id": "361", + "governorate_id": "24", + "city_name_ar": "طيبة الجديدة", + "city_name_en": "New Tiba" + }, + { + "id": "362", + "governorate_id": "24", + "city_name_ar": "الزينية", + "city_name_en": "Al ziynia" + }, + { + "id": "363", + "governorate_id": "24", + "city_name_ar": "البياضية", + "city_name_en": "Al Bayadieh" + }, + { + "id": "364", + "governorate_id": "24", + "city_name_ar": "القرنة", + "city_name_en": "Al Qarna" + }, + { + "id": "365", + "governorate_id": "24", + "city_name_ar": "أرمنت", + "city_name_en": "Armant" + }, + { + "id": "366", + "governorate_id": "24", + "city_name_ar": "الطود", + "city_name_en": "Al Tud" + } + ], + "25": [ + { + "id": "367", + "governorate_id": "25", + "city_name_ar": "قنا", + "city_name_en": "Qena" + }, + { + "id": "368", + "governorate_id": "25", + "city_name_ar": "قنا الجديدة", + "city_name_en": "New Qena" + }, + { + "id": "369", + "governorate_id": "25", + "city_name_ar": "ابو طشت", + "city_name_en": "Abu Tesht" + }, + { + "id": "370", + "governorate_id": "25", + "city_name_ar": "نجع حمادي", + "city_name_en": "Nag Hammadi" + }, + { + "id": "371", + "governorate_id": "25", + "city_name_ar": "دشنا", + "city_name_en": "Deshna" + }, + { + "id": "372", + "governorate_id": "25", + "city_name_ar": "الوقف", + "city_name_en": "Alwaqf" + }, + { + "id": "373", + "governorate_id": "25", + "city_name_ar": "قفط", + "city_name_en": "Qaft" + }, + { + "id": "374", + "governorate_id": "25", + "city_name_ar": "نقادة", + "city_name_en": "Naqada" + }, + { + "id": "375", + "governorate_id": "25", + "city_name_ar": "فرشوط", + "city_name_en": "Farshout" + }, + { + "id": "376", + "governorate_id": "25", + "city_name_ar": "قوص", + "city_name_en": "Quos" + } + ], + "26": [ + { + "id": "377", + "governorate_id": "26", + "city_name_ar": "العريش", + "city_name_en": "Arish" + }, + { + "id": "378", + "governorate_id": "26", + "city_name_ar": "الشيخ زويد", + "city_name_en": "Sheikh Zowaid" + }, + { + "id": "379", + "governorate_id": "26", + "city_name_ar": "نخل", + "city_name_en": "Nakhl" + }, + { + "id": "380", + "governorate_id": "26", + "city_name_ar": "رفح", + "city_name_en": "Rafah" + }, + { + "id": "381", + "governorate_id": "26", + "city_name_ar": "بئر العبد", + "city_name_en": "Bir al-Abed" + }, + { + "id": "382", + "governorate_id": "26", + "city_name_ar": "الحسنة", + "city_name_en": "Al Hasana" + } + ], + "27": [ + { + "id": "383", + "governorate_id": "27", + "city_name_ar": "سوهاج", + "city_name_en": "Sohag" + }, + { + "id": "384", + "governorate_id": "27", + "city_name_ar": "سوهاج الجديدة", + "city_name_en": "Sohag El Gedida" + }, + { + "id": "385", + "governorate_id": "27", + "city_name_ar": "أخميم", + "city_name_en": "Akhmeem" + }, + { + "id": "386", + "governorate_id": "27", + "city_name_ar": "أخميم الجديدة", + "city_name_en": "Akhmim El Gedida" + }, + { + "id": "387", + "governorate_id": "27", + "city_name_ar": "البلينا", + "city_name_en": "Albalina" + }, + { + "id": "388", + "governorate_id": "27", + "city_name_ar": "المراغة", + "city_name_en": "El Maragha" + }, + { + "id": "389", + "governorate_id": "27", + "city_name_ar": "المنشأة", + "city_name_en": "almunsha'a" + }, + { + "id": "390", + "governorate_id": "27", + "city_name_ar": "دار السلام", + "city_name_en": "Dar AISalaam" + }, + { + "id": "391", + "governorate_id": "27", + "city_name_ar": "جرجا", + "city_name_en": "Gerga" + }, + { + "id": "392", + "governorate_id": "27", + "city_name_ar": "جهينة الغربية", + "city_name_en": "Jahina Al Gharbia" + }, + { + "id": "393", + "governorate_id": "27", + "city_name_ar": "ساقلته", + "city_name_en": "Saqilatuh" + }, + { + "id": "394", + "governorate_id": "27", + "city_name_ar": "طما", + "city_name_en": "Tama" + }, + { + "id": "395", + "governorate_id": "27", + "city_name_ar": "طهطا", + "city_name_en": "Tahta" + }, + { + "id": "396", + "governorate_id": "27", + "city_name_ar": "الكوثر", + "city_name_en": "Alkawthar" + } + ] + } +} diff --git a/api/locations/govs.json b/api/locations/govs.json new file mode 100644 index 0000000..8979792 --- /dev/null +++ b/api/locations/govs.json @@ -0,0 +1,140 @@ +{ + "name": "govs", + "data": [ + { + "id": "1", + "governorate_name_ar": "القاهرة", + "governorate_name_en": "Cairo" + }, + { + "id": "2", + "governorate_name_ar": "الجيزة", + "governorate_name_en": "Giza" + }, + { + "id": "3", + "governorate_name_ar": "الأسكندرية", + "governorate_name_en": "Alexandria" + }, + { + "id": "4", + "governorate_name_ar": "الدقهلية", + "governorate_name_en": "Dakahlia" + }, + { + "id": "5", + "governorate_name_ar": "البحر الأحمر", + "governorate_name_en": "Red Sea" + }, + { + "id": "6", + "governorate_name_ar": "البحيرة", + "governorate_name_en": "Beheira" + }, + { + "id": "7", + "governorate_name_ar": "الفيوم", + "governorate_name_en": "Fayoum" + }, + { + "id": "8", + "governorate_name_ar": "الغربية", + "governorate_name_en": "Gharbiya" + }, + { + "id": "9", + "governorate_name_ar": "الإسماعلية", + "governorate_name_en": "Ismailia" + }, + { + "id": "10", + "governorate_name_ar": "المنوفية", + "governorate_name_en": "Menofia" + }, + { + "id": "11", + "governorate_name_ar": "المنيا", + "governorate_name_en": "Minya" + }, + { + "id": "12", + "governorate_name_ar": "القليوبية", + "governorate_name_en": "Qaliubiya" + }, + { + "id": "13", + "governorate_name_ar": "الوادي الجديد", + "governorate_name_en": "New Valley" + }, + { + "id": "14", + "governorate_name_ar": "السويس", + "governorate_name_en": "Suez" + }, + { + "id": "15", + "governorate_name_ar": "اسوان", + "governorate_name_en": "Aswan" + }, + { + "id": "16", + "governorate_name_ar": "اسيوط", + "governorate_name_en": "Assiut" + }, + { + "id": "17", + "governorate_name_ar": "بني سويف", + "governorate_name_en": "Beni Suef" + }, + { + "id": "18", + "governorate_name_ar": "بورسعيد", + "governorate_name_en": "Port Said" + }, + { + "id": "19", + "governorate_name_ar": "دمياط", + "governorate_name_en": "Damietta" + }, + { + "id": "20", + "governorate_name_ar": "الشرقية", + "governorate_name_en": "Sharkia" + }, + { + "id": "21", + "governorate_name_ar": "جنوب سيناء", + "governorate_name_en": "South Sinai" + }, + { + "id": "22", + "governorate_name_ar": "كفر الشيخ", + "governorate_name_en": "Kafr Al sheikh" + }, + { + "id": "23", + "governorate_name_ar": "مطروح", + "governorate_name_en": "Matrouh" + }, + { + "id": "24", + "governorate_name_ar": "الأقصر", + "governorate_name_en": "Luxor" + }, + { + "id": "25", + "governorate_name_ar": "قنا", + "governorate_name_en": "Qena" + }, + { + "id": "26", + "governorate_name_ar": "شمال سيناء", + "governorate_name_en": "North Sinai" + }, + { + "id": "27", + "governorate_name_ar": "سوهاج", + "governorate_name_en": "Sohag" + } + ] +} diff --git a/api/locations/migrations/0001_initial.py b/api/locations/migrations/0001_initial.py new file mode 100644 index 0000000..952ac21 --- /dev/null +++ b/api/locations/migrations/0001_initial.py @@ -0,0 +1,62 @@ +# Generated by Django 3.2.13 on 2022-04-19 22:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='City', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name_ar', models.CharField(max_length=64)), + ('name_en', models.CharField(max_length=64)), + ], + options={ + 'verbose_name': 'city', + 'verbose_name_plural': 'cities', + 'db_table': 'cities', + }, + ), + migrations.CreateModel( + name='Governorate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name_ar', models.CharField(max_length=64)), + ('name_en', models.CharField(max_length=64)), + ], + options={ + 'verbose_name': 'governorate', + 'verbose_name_plural': 'governorates', + 'db_table': 'governorates', + }, + ), + migrations.CreateModel( + name='Location', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('lon', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('lat', models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True)), + ('address', models.CharField(blank=True, max_length=512, null=True)), + ('city', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='locations.city')), + ('gov', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='locations.governorate')), + ], + options={ + 'verbose_name': 'location', + 'verbose_name_plural': 'locations', + 'db_table': 'locations', + }, + ), + migrations.AddField( + model_name='city', + name='gov', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cities', to='locations.governorate'), + ), + ] diff --git a/api/locations/migrations/0002_auto_20220418_0026.py b/api/locations/migrations/0002_auto_20220418_0026.py new file mode 100644 index 0000000..a977a63 --- /dev/null +++ b/api/locations/migrations/0002_auto_20220418_0026.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.13 on 2022-04-18 00:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('locations', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='location', + name='lat', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True), + ), + migrations.AlterField( + model_name='location', + name='lon', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + ] diff --git a/api/locations/migrations/0003_alter_location_address.py b/api/locations/migrations/0003_alter_location_address.py new file mode 100644 index 0000000..ed3b423 --- /dev/null +++ b/api/locations/migrations/0003_alter_location_address.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-04-18 00:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('locations', '0002_auto_20220418_0026'), + ] + + operations = [ + migrations.AlterField( + model_name='location', + name='address', + field=models.CharField(blank=True, max_length=512, null=True), + ), + ] diff --git a/api/locations/migrations/__init__.py b/api/locations/migrations/__init__.py new file mode 100644 index 0000000..9cf13eb --- /dev/null +++ b/api/locations/migrations/__init__.py @@ -0,0 +1 @@ +# Init \ No newline at end of file diff --git a/api/locations/models.py b/api/locations/models.py new file mode 100644 index 0000000..2ec7ce8 --- /dev/null +++ b/api/locations/models.py @@ -0,0 +1,52 @@ +from django.db import models +from rest_framework.exceptions import ValidationError + + +class Governorate(models.Model): + name_ar = models.CharField(max_length=64) + name_en = models.CharField(max_length=64) + + class Meta: + db_table = "governorates" + verbose_name = "governorate" + verbose_name_plural = "governorates" + + def __str__(self): + return self.name_en + + +class City(models.Model): + name_ar = models.CharField(max_length=64) + name_en = models.CharField(max_length=64) + gov = models.ForeignKey( + Governorate, on_delete=models.CASCADE, related_name="cities" + ) + + class Meta: + db_table = "cities" + verbose_name = "city" + verbose_name_plural = "cities" + + def __str__(self): + return self.name_en + + +class Location(models.Model): + lon = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + lat = models.DecimalField(max_digits=8, decimal_places=6, null=True, blank=True) + address = models.CharField(max_length=512, null=True, blank=True) + + gov = models.ForeignKey(Governorate, on_delete=models.PROTECT) + city = models.ForeignKey(City, on_delete=models.PROTECT) + + def clean(self): + if self.city.gov != self.gov: + raise ValidationError("City does not belong to Governorate") + + class Meta: + db_table = "locations" + verbose_name = "location" + verbose_name_plural = "locations" + + def __str__(self): + return f"" diff --git a/api/locations/selectors.py b/api/locations/selectors.py new file mode 100644 index 0000000..4d7610e --- /dev/null +++ b/api/locations/selectors.py @@ -0,0 +1,13 @@ +from django.db.models.query import QuerySet +from django.shortcuts import get_object_or_404 + +from .models import City, Governorate + + +def list_governorate() -> QuerySet[Governorate]: + return Governorate.objects.all() + + +def list_governorate_cities(gov_id) -> QuerySet[City]: + gov = get_object_or_404(Governorate, pk=gov_id) + return gov.cities.all() diff --git a/api/locations/services.py b/api/locations/services.py new file mode 100644 index 0000000..07b94f4 --- /dev/null +++ b/api/locations/services.py @@ -0,0 +1,92 @@ +from typing import Dict, Optional + +from django.conf import settings +from django.shortcuts import get_object_or_404 + +from api.common.services import model_update +from api.locations.models import City, Governorate, Location +from api.locations.utils import read_json + +apps_dir = settings.APPS_DIR + + +def populate_govs() -> None: + """ + Populates governorates and cities tables + """ + # Read json files + govs_dict = read_json(path=apps_dir / "locations" / "govs.json") + cities_dict = read_json(path=apps_dir / "locations" / "cities.json") + + # Reset tables + Governorate.objects.all().delete() + City.objects.all().delete() + + # Insert govs + for gov in govs_dict["data"]: + g = Governorate( + pk=gov["id"], + name_ar=gov["governorate_name_ar"], + name_en=gov["governorate_name_en"], + ) + g.save() + + # Insert cities in gov + for city in cities_dict["data"][gov["id"]]: + c = City( + gov=g, + pk=city["id"], + name_ar=city["city_name_ar"], + name_en=city["city_name_en"], + ) + c.save() + + +def create_location( + *, + lon: Optional[float] = None, + lat: Optional[float] = None, + address: Optional[str] = None, + gov: int, + city: int, +) -> Location: + + # Fetch Governorate & City + gov = Governorate.objects.get(pk=gov) + city = City.objects.get(pk=city) + + # Pack location data for validation + location = Location(lon=lon, lat=lat, address=address, gov=gov, city=city) + + # Data validation + location.full_clean() + + # Save location instance to the database + location.save() + + return location + + +def update_location( + *, + location: Location, + data: Dict, +) -> Location: + + # Fetch Governorate & City if given + gov_id, city_id = data.get("gov"), data.get("city") + + if gov_id: + data["gov"] = get_object_or_404(Governorate, pk=gov_id) + if city_id: + data["city"] = get_object_or_404(City, pk=city_id) + + non_side_effect_fields = ["lon", "lat", "address", "gov", "city"] + + location, _ = model_update( + instance=location, + fields=non_side_effect_fields, + data=data, + ) + + return location diff --git a/api/locations/tests.py b/api/locations/tests.py new file mode 100644 index 0000000..007eb95 --- /dev/null +++ b/api/locations/tests.py @@ -0,0 +1 @@ +# Tests diff --git a/api/locations/urls.py b/api/locations/urls.py new file mode 100644 index 0000000..e99920d --- /dev/null +++ b/api/locations/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from .apis import GovernorateCitiesListApi, GovernorateListApi + +app_name = "locations" + +urlpatterns = [ + path("governorates/", GovernorateListApi.as_view(), name="Governorates"), + path( + "governorates//cities", + GovernorateCitiesListApi.as_view(), + name="Governorate_cities", + ), +] diff --git a/api/locations/utils.py b/api/locations/utils.py new file mode 100644 index 0000000..8937e44 --- /dev/null +++ b/api/locations/utils.py @@ -0,0 +1,12 @@ +import json +from pathlib import Path +from typing import Any, Dict + + +def read_json(*, path: Path) -> Dict[str, Any]: + """ + Loads a json file in a python dictionary + """ + with open(path, "r") as json_file: + data = json.load(json_file) + return data diff --git a/api/notifications/__init__.py b/api/notifications/__init__.py new file mode 100644 index 0000000..e966b61 --- /dev/null +++ b/api/notifications/__init__.py @@ -0,0 +1 @@ +# __init__ diff --git a/api/notifications/admin.py b/api/notifications/admin.py new file mode 100644 index 0000000..8265a2b --- /dev/null +++ b/api/notifications/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from api.notifications.models import Notification + + +@admin.register(Notification) +class CityAdmin(admin.ModelAdmin): + list_display = ("id", "level", "sent_to") + list_filter = ("level",) diff --git a/api/notifications/apis.py b/api/notifications/apis.py new file mode 100644 index 0000000..5d02d8b --- /dev/null +++ b/api/notifications/apis.py @@ -0,0 +1,31 @@ +from rest_framework import serializers +from rest_framework.views import APIView + +from api.apis.pagination import LimitOffsetPagination, get_paginated_response +from api.notifications.selectors import list_notification + + +class NotificationListApi(APIView): + class Pagination(LimitOffsetPagination): + default_limit = 10 + + class OutputSerializer(serializers.Serializer): + id = serializers.IntegerField() + body = serializers.CharField() + title = serializers.CharField() + level = serializers.CharField() + created_at = serializers.DateTimeField() + read_at = serializers.DateTimeField() + sent_to = serializers.URLField(source="sent_to.get_absolute_url") + + def get(self, request): + + notifications = list_notification(user=request.user) + + return get_paginated_response( + pagination_class=self.Pagination, + serializer_class=self.OutputSerializer, + queryset=notifications, + request=request, + view=self, + ) diff --git a/api/notifications/apps.py b/api/notifications/apps.py new file mode 100644 index 0000000..d445f88 --- /dev/null +++ b/api/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api.notifications" diff --git a/api/notifications/migrations/0001_initial.py b/api/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..bbe984b --- /dev/null +++ b/api/notifications/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2.13 on 2022-05-08 19:59 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('body', models.TextField()), + ('title', models.CharField(max_length=255)), + ('level', models.CharField(choices=[('S', 'SUCCESS'), ('I', 'INFO'), ('W', 'WARNING'), ('E', 'ERROR')], max_length=1)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('read_at', models.DateTimeField(blank=True, default=None, null=True)), + ('hyper_link', models.URLField(blank=True, null=True)), + ('sent_to', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'notification', + 'verbose_name_plural': 'notifications', + 'db_table': 'notifications', + }, + ), + ] diff --git a/api/notifications/migrations/0002_alter_notification_options.py b/api/notifications/migrations/0002_alter_notification_options.py new file mode 100644 index 0000000..3c7240b --- /dev/null +++ b/api/notifications/migrations/0002_alter_notification_options.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.13 on 2022-05-13 11:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='notification', + options={'ordering': ['-created_at'], 'verbose_name': 'notification', 'verbose_name_plural': 'notifications'}, + ), + ] diff --git a/api/notifications/migrations/__init__.py b/api/notifications/migrations/__init__.py new file mode 100644 index 0000000..a6131c1 --- /dev/null +++ b/api/notifications/migrations/__init__.py @@ -0,0 +1 @@ +# init diff --git a/api/notifications/models.py b/api/notifications/models.py new file mode 100644 index 0000000..8f0bd0c --- /dev/null +++ b/api/notifications/models.py @@ -0,0 +1,33 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from api.users.models import User + + +class Notification(models.Model): + class Level(models.TextChoices): + SUCCESS = "S", _("SUCCESS") + INFO = "I", _("INFO") + WARNING = "W", _("WARNING") + ERROR = "E", _("ERROR") + + body = models.TextField() + title = models.CharField(max_length=255) + level = models.CharField(max_length=1, choices=Level.choices) + created_at = models.DateTimeField(auto_now_add=True) + read_at = models.DateTimeField(default=None, null=True, blank=True) + sent_to = models.ForeignKey( + to=User, + on_delete=models.CASCADE, + related_name="notifications", + ) + hyper_link = models.URLField(null=True, blank=True) + + class Meta: + db_table = "notifications" + verbose_name = "notification" + verbose_name_plural = "notifications" + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"" diff --git a/api/notifications/selectors.py b/api/notifications/selectors.py new file mode 100644 index 0000000..4badb85 --- /dev/null +++ b/api/notifications/selectors.py @@ -0,0 +1,6 @@ +from api.notifications.models import Notification +from api.users.models import User + + +def list_notification(*, user: User): + return Notification.objects.filter(sent_to=user) diff --git a/api/notifications/services.py b/api/notifications/services.py new file mode 100644 index 0000000..d2ead96 --- /dev/null +++ b/api/notifications/services.py @@ -0,0 +1,44 @@ +from typing import Optional + +from fcm_django.models import FCMDevice + +from api.notifications.models import Notification +from api.users.models import User + + +def create_notification( + *, + title: str, + body: str, + level: str, + sent_to: User, +) -> Notification: + + notification = Notification( + title=title, + body=body, + level=level, + sent_to=sent_to, + ) + notification.full_clean() + notification.save() + + return notification + + +def create_fcm_device( + *, + user: User, + fcm_token: str, + device_type: Optional[str] = "android", +) -> FCMDevice: + + device = FCMDevice( + user=user, + type=device_type, + registration_id=fcm_token, + ) + device.full_clean() + device.save() + + return device diff --git a/api/notifications/urls.py b/api/notifications/urls.py new file mode 100644 index 0000000..a6368bd --- /dev/null +++ b/api/notifications/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from api.notifications.apis import NotificationListApi + +app_name = "notifications" +urlpatterns = [ + path("", NotificationListApi.as_view(), name="notification_list"), +] diff --git a/api/templates/base.html b/api/templates/base.html index a392329..f2d89b1 100644 --- a/api/templates/base.html +++ b/api/templates/base.html @@ -1,6 +1,8 @@ -{% load static i18n %} +{% load static i18n %} + {% get_current_language as LANGUAGE_CODE %} + @@ -13,7 +15,10 @@ {% block css %} - + @@ -23,12 +28,15 @@ ================================================== --> {# Placed at the top of the document so pages load faster with defer #} {% block javascript %} - - - + + + - - + + {% endblock javascript %} @@ -39,7 +47,10 @@
-{% if object == request.user %} - -
+ {% if object == request.user %} + +
-
- My Info - E-Mail - -
+
+ My Info + E-Mail + +
-
- -{% endif %} +
+ + {% endif %} {% endblock content %} diff --git a/api/templates/users/user_form.html b/api/templates/users/user_form.html index 467357a..1cf1227 100644 --- a/api/templates/users/user_form.html +++ b/api/templates/users/user_form.html @@ -4,14 +4,15 @@ {% block title %}{{ user.username }}{% endblock %} {% block content %} -

{{ user.username }}

-
- {% csrf_token %} - {{ form|crispy }} -
-
- -
+

{{ user.username }}

+ + {% csrf_token %} + {{ form|crispy }} +
+
+
- +
+ {% endblock %} diff --git a/api/users/admin.py b/api/users/admin.py index 27ae319..1d9d7f3 100644 --- a/api/users/admin.py +++ b/api/users/admin.py @@ -10,12 +10,14 @@ @admin.register(User) class UserAdmin(auth_admin.UserAdmin): + list_display = ("id", "name", "username", "id_exp_date", "location") + search_fields = ("name", "username") form = UserAdminChangeForm add_form = UserAdminCreationForm fieldsets = ( (None, {"fields": ("username", "password")}), - (_("Personal info"), {"fields": ("name", "email")}), + (_("Personal info"), {"fields": ("name", "email", "location", "national_id")}), ( _("Permissions"), { @@ -23,12 +25,17 @@ class UserAdmin(auth_admin.UserAdmin): "is_active", "is_staff", "is_superuser", - "groups", - "user_permissions", ), }, ), - (_("Important dates"), {"fields": ("last_login", "date_joined")}), + ( + _("Important dates"), + { + "fields": ( + "id_exp_date", + "last_login", + "date_joined", + ), + }, + ), ) - list_display = ["username", "name", "is_superuser"] - search_fields = ["name"] diff --git a/api/users/apis.py b/api/users/apis.py new file mode 100644 index 0000000..4223b75 --- /dev/null +++ b/api/users/apis.py @@ -0,0 +1,129 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import permissions, serializers, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.common.utils import inline_serializer +from api.users.models import User +from api.users.selectors import get_user, get_user_cases +from api.users.services import create_user, set_national_id, update_user + + +class CreateUserApi(APIView): + permission_classes = [permissions.AllowAny] + + class InputSerializer(serializers.Serializer): + username = serializers.CharField() + password = serializers.CharField() + name = serializers.CharField() + fcm_token = serializers.CharField() + firebase_token = serializers.CharField() + location = inline_serializer( + fields={ + "gov": serializers.IntegerField(), + "city": serializers.IntegerField(), + } + ) + + def post(self, request): + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + create_user(**serializer.validated_data) + + return Response(status=status.HTTP_201_CREATED) + + +class DetailUserApi(APIView): + class OutputSerializer(serializers.Serializer): + username = serializers.CharField() + name = serializers.CharField() + location = inline_serializer( + fields={ + "address": serializers.CharField(), + "gov": serializers.CharField(source="gov.name_ar"), + "city": serializers.CharField(source="city.name_ar"), + } + ) + + def get(self, request, user_id): + user = get_user(user_id) + serializer = self.OutputSerializer(user) + return Response(serializer.data) + + +class UpdateUserApi(APIView): + class InputSerializer(serializers.Serializer): + name = serializers.CharField(required=False) + firebase_token = serializers.CharField(required=False) + fcm_token = serializers.CharField(required=False) + location = inline_serializer( + fields={ + "gov": serializers.IntegerField(), + "city": serializers.IntegerField(), + "address": serializers.CharField(required=False), + "lon": serializers.DecimalField( + max_digits=9, decimal_places=6, required=False + ), + "lat": serializers.DecimalField( + max_digits=8, decimal_places=6, required=False + ), + }, + required=False, + ) + + def post(self, request, user_id): + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + user = get_user(user_id) + + update_user( + user=user, + performed_by=request.user, + data=serializer.validated_data, + ) + return Response(status=status.HTTP_200_OK) + + +class UserCasesListApi(APIView): + class OutputSerializer(serializers.Serializer): + id = serializers.IntegerField() + type = serializers.CharField() + state = serializers.CharField(source="get_state_display") + name = serializers.CharField(source="details.name") + thumbnail = serializers.URLField() + last_seen = serializers.DateField(source="details.last_seen") + posted_at = serializers.DateTimeField() + location = inline_serializer( + fields={ + "gov": serializers.CharField(source="gov.name_ar"), + "city": serializers.CharField(source="city.name_ar"), + } + ) + + def get(self, request): + + # Listing all user cases + cases = get_user_cases(request.user) + + # Serializing the results + serializer = self.OutputSerializer(cases, many=True) + + return Response(serializer.data) + + +class SetNationalIdApi(APIView): + class InputSerializer(serializers.Serializer): + national_id = serializers.CharField() + + def post(self, request, user_id): + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + user = get_object_or_404(User, pk=user_id) + set_national_id( + user=user, + data=serializer.validated_data, + ) + + return Response(status=status.HTTP_200_OK) diff --git a/api/users/app_urls.py b/api/users/app_urls.py new file mode 100644 index 0000000..cb6d970 --- /dev/null +++ b/api/users/app_urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from api.users.views import user_detail_view, user_redirect_view, user_update_view + +app_name = "app_users" +urlpatterns = [ + path("~redirect/", view=user_redirect_view, name="redirect"), + path("~update/", view=user_update_view, name="update"), + path("/", view=user_detail_view, name="detail"), +] diff --git a/api/users/forms.py b/api/users/forms.py index 6e1dd9d..c4963b4 100644 --- a/api/users/forms.py +++ b/api/users/forms.py @@ -1,10 +1,9 @@ from allauth.account.forms import SignupForm from allauth.socialaccount.forms import SignupForm as SocialSignupForm from django.contrib.auth import forms as admin_forms -from django.contrib.auth import get_user_model from django.utils.translation import gettext_lazy as _ -User = get_user_model() +from api.users.models import User class UserAdminChangeForm(admin_forms.UserChangeForm): diff --git a/api/users/migrations/0001_initial.py b/api/users/migrations/0001_initial.py index 4c4d695..ed130ea 100644 --- a/api/users/migrations/0001_initial.py +++ b/api/users/migrations/0001_initial.py @@ -1,7 +1,9 @@ -# Generated by Django 3.2.9 on 2021-11-20 11:23 +# Generated by Django 3.2.13 on 2022-05-10 19:09 + +import api.common.validators import django.contrib.auth.models -import django.contrib.auth.validators from django.db import migrations, models +import django.db.models.deletion import django.utils.timezone @@ -10,116 +12,38 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("auth", "0012_alter_user_first_name_max_length"), + ('locations', '0003_alter_location_address'), + ('auth', '0012_alter_user_first_name_max_length'), ] operations = [ migrations.CreateModel( - name="User", + name='User', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("password", models.CharField(max_length=128, verbose_name="password")), - ( - "last_login", - models.DateTimeField( - blank=True, null=True, verbose_name="last login" - ), - ), - ( - "is_superuser", - models.BooleanField( - default=False, - help_text="Designates that this user has all permissions without explicitly assigning them.", - verbose_name="superuser status", - ), - ), - ( - "username", - models.CharField( - error_messages={ - "unique": "A user with that username already exists." - }, - help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", - max_length=150, - unique=True, - validators=[ - django.contrib.auth.validators.UnicodeUsernameValidator() - ], - verbose_name="username", - ), - ), - ( - "email", - models.EmailField( - blank=True, max_length=254, verbose_name="email address" - ), - ), - ( - "is_staff", - models.BooleanField( - default=False, - help_text="Designates whether the user can log into this admin site.", - verbose_name="staff status", - ), - ), - ( - "is_active", - models.BooleanField( - default=True, - help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", - verbose_name="active", - ), - ), - ( - "date_joined", - models.DateTimeField( - default=django.utils.timezone.now, verbose_name="date joined" - ), - ), - ( - "name", - models.CharField( - blank=True, max_length=255, verbose_name="Name of User" - ), - ), - ( - "groups", - models.ManyToManyField( - blank=True, - help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", - related_name="user_set", - related_query_name="user", - to="auth.Group", - verbose_name="groups", - ), - ), - ( - "user_permissions", - models.ManyToManyField( - blank=True, - help_text="Specific permissions for this user.", - related_name="user_set", - related_query_name="user", - to="auth.Permission", - verbose_name="user permissions", - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('name', models.CharField(max_length=256)), + ('username', models.CharField(max_length=10, unique=True, validators=[api.common.validators.is_phone])), + ('id_exp_date', models.DateTimeField(blank=True, null=True)), + ('national_id', models.CharField(blank=True, max_length=14, null=True, unique=True, validators=[api.common.validators.is_national_id])), + ('firebase_token', models.CharField(blank=True, max_length=256, unique=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('location', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user', to='locations.location')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), ], options={ - "verbose_name": "user", - "verbose_name_plural": "users", - "abstract": False, + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'db_table': 'users', }, managers=[ - ("objects", django.contrib.auth.models.UserManager()), + ('objects', django.contrib.auth.models.UserManager()), ], ), ] diff --git a/api/users/models.py b/api/users/models.py index b802bc7..54d079d 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -1,26 +1,48 @@ from django.contrib.auth.models import AbstractUser -from django.db.models import CharField +from django.db import models from django.urls import reverse -from django.utils.translation import gettext_lazy as _ +from django.utils import timezone + +from api.common.validators import is_national_id, is_phone +from api.locations.models import Location class User(AbstractUser): - """ - Default custom user model for API. - If adding fields that need to be filled at user signup, - check forms.SignupForm and forms.SocialSignupForms accordingly. - """ - - #: First and last name do not cover name patterns around the globe - name = CharField(_("Name of User"), blank=True, max_length=255) first_name = None # type: ignore last_name = None # type: ignore - def get_absolute_url(self): - """Get url for user's detail view. + name = models.CharField(max_length=256) + username = models.CharField(max_length=10, unique=True, validators=[is_phone]) + + id_exp_date = models.DateTimeField(null=True, blank=True) + national_id = models.CharField( + max_length=14, + unique=True, + null=True, + blank=True, + validators=[is_national_id], + ) + + firebase_token = models.CharField(max_length=256, unique=True, blank=True) + + location = models.OneToOneField( + Location, on_delete=models.CASCADE, null=True, related_name="user" + ) + + class Meta: + db_table = "users" + verbose_name = "user" + verbose_name_plural = "users" + + @property + def is_verified(self) -> bool: + return self.id_exp_date and self.id_exp_date > timezone.now() + + def get_absolute_url(self) -> str: + return reverse("apis:users:get_user", args=[str(self.id)]) - Returns: - str: URL for user detail. + def renew_id(self, days: int = 365) -> None: + self.id_exp_date = timezone.now() + timezone.timedelta(days=days) - """ - return reverse("users:detail", kwargs={"username": self.username}) + def __str__(self) -> str: + return self.username diff --git a/api/users/selectors.py b/api/users/selectors.py new file mode 100644 index 0000000..7786b7e --- /dev/null +++ b/api/users/selectors.py @@ -0,0 +1,13 @@ +from django.db.models.query import QuerySet +from django.shortcuts import get_object_or_404 + +from api.cases.models import Case +from api.users.models import User + + +def get_user(user_id: int) -> User: + return get_object_or_404(User, pk=user_id) + + +def get_user_cases(user: User) -> QuerySet[Case]: + return user.cases.all() diff --git a/api/users/services.py b/api/users/services.py new file mode 100644 index 0000000..0e8847e --- /dev/null +++ b/api/users/services.py @@ -0,0 +1,82 @@ +from typing import Dict + +from django.contrib.auth.hashers import make_password +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import PermissionDenied +from django.db import transaction + +from api.common.services import model_update +from api.locations.models import Location +from api.locations.services import create_location, update_location +from api.notifications.services import create_fcm_device +from api.users.models import User + + +@transaction.atomic +def create_user( + *, + name: str, + username: str, + password: str, + firebase_token: str, + location: Dict, + fcm_token: str, +) -> User: + + # Creating user's related entities + location: Location = create_location(**location) + + # Pack user data for validation + user: User = User( + name=name, username=username, firebase_token=firebase_token, location=location + ) + + # Password validation + validate_password(password) + user.password = make_password(password) + + # Data validation + user.full_clean() + + # Saving user to the database + user.save() + + create_fcm_device(user=user, fcm_token=fcm_token) + + return user + + +@transaction.atomic +def update_user( + *, + user: User, + performed_by: User, + data: Dict, +) -> User: + + if user != performed_by: + raise PermissionDenied() + + non_side_effect_fields = ["name", "firebase_token"] + + user, _ = model_update( + instance=user, + fields=non_side_effect_fields, + data=data, + ) + + location_data = data.get("location") + if location_data: + update_location(location=user.location, data=location_data) + + return user + + +def set_national_id(*, user: User, data: Dict) -> None: + national_id = data.get("national_id") + user.national_id = national_id + + user.full_clean() + user.save() + + return None diff --git a/api/users/tests/test_admin.py b/api/users/tests/test_admin.py deleted file mode 100644 index 5839218..0000000 --- a/api/users/tests/test_admin.py +++ /dev/null @@ -1,40 +0,0 @@ -import pytest -from django.urls import reverse - -from api.users.models import User - -pytestmark = pytest.mark.django_db - - -class TestUserAdmin: - def test_changelist(self, admin_client): - url = reverse("admin:users_user_changelist") - response = admin_client.get(url) - assert response.status_code == 200 - - def test_search(self, admin_client): - url = reverse("admin:users_user_changelist") - response = admin_client.get(url, data={"q": "test"}) - assert response.status_code == 200 - - def test_add(self, admin_client): - url = reverse("admin:users_user_add") - response = admin_client.get(url) - assert response.status_code == 200 - - response = admin_client.post( - url, - data={ - "username": "test", - "password1": "My_R@ndom-P@ssw0rd", - "password2": "My_R@ndom-P@ssw0rd", - }, - ) - assert response.status_code == 302 - assert User.objects.filter(username="test").exists() - - def test_view_user(self, admin_client): - user = User.objects.get(username="admin") - url = reverse("admin:users_user_change", kwargs={"object_id": user.pk}) - response = admin_client.get(url) - assert response.status_code == 200 diff --git a/api/users/tests/test_drf_urls.py b/api/users/tests/test_drf_urls.py deleted file mode 100644 index ec14dfc..0000000 --- a/api/users/tests/test_drf_urls.py +++ /dev/null @@ -1,24 +0,0 @@ -import pytest -from django.urls import resolve, reverse - -from api.users.models import User - -pytestmark = pytest.mark.django_db - - -def test_user_detail(user: User): - assert ( - reverse("api:user-detail", kwargs={"username": user.username}) - == f"/api/users/{user.username}/" - ) - assert resolve(f"/api/users/{user.username}/").view_name == "api:user-detail" - - -def test_user_list(): - assert reverse("api:user-list") == "/api/users/" - assert resolve("/api/users/").view_name == "api:user-list" - - -def test_user_me(): - assert reverse("api:user-me") == "/api/users/me/" - assert resolve("/api/users/me/").view_name == "api:user-me" diff --git a/api/users/tests/test_drf_views.py b/api/users/tests/test_drf_views.py deleted file mode 100644 index a36f1d1..0000000 --- a/api/users/tests/test_drf_views.py +++ /dev/null @@ -1,33 +0,0 @@ -import pytest -from django.test import RequestFactory - -from api.users.api.views import UserViewSet -from api.users.models import User - -pytestmark = pytest.mark.django_db - - -class TestUserViewSet: - def test_get_queryset(self, user: User, rf: RequestFactory): - view = UserViewSet() - request = rf.get("/fake-url/") - request.user = user - - view.request = request - - assert user in view.get_queryset() - - def test_me(self, user: User, rf: RequestFactory): - view = UserViewSet() - request = rf.get("/fake-url/") - request.user = user - - view.request = request - - response = view.me(request) - - assert response.data == { - "username": user.username, - "name": user.name, - "url": f"http://testserver/api/users/{user.username}/", - } diff --git a/api/users/tests/test_forms.py b/api/users/tests/test_forms.py deleted file mode 100644 index 614208d..0000000 --- a/api/users/tests/test_forms.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -Module for all Form Tests. -""" -import pytest -from django.utils.translation import gettext_lazy as _ - -from api.users.forms import UserAdminCreationForm -from api.users.models import User - -pytestmark = pytest.mark.django_db - - -class TestUserAdminCreationForm: - """ - Test class for all tests related to the UserAdminCreationForm - """ - - def test_username_validation_error_msg(self, user: User): - """ - Tests UserAdminCreation Form's unique validator functions correctly by testing: - 1) A new user with an existing username cannot be added. - 2) Only 1 error is raised by the UserCreation Form - 3) The desired error message is raised - """ - - # The user already exists, - # hence cannot be created. - form = UserAdminCreationForm( - { - "username": user.username, - "password1": user.password, - "password2": user.password, - } - ) - - assert not form.is_valid() - assert len(form.errors) == 1 - assert "username" in form.errors - assert form.errors["username"][0] == _("This username has already been taken.") diff --git a/api/users/tests/test_models.py b/api/users/tests/test_models.py index b0abbe1..51cdd84 100644 --- a/api/users/tests/test_models.py +++ b/api/users/tests/test_models.py @@ -1,9 +1,47 @@ -import pytest +from django.test import TestCase +from api.locations.services import populate_govs from api.users.models import User +from api.users.services import create_user -pytestmark = pytest.mark.django_db +class UserTests(TestCase): + @classmethod + def setUpTestData(cls): # Called once at the beginning of the test run + populate_govs() + create_user( + name="Osama Yasser", + username="1005499972", + password="hardpassword", + firebase_token="token", + location={"gov": 1, "city": "4"}, + fcm_token="fcm_token", + ) -def test_user_get_absolute_url(user: User): - assert user.get_absolute_url() == f"/users/{user.username}/" + def test_name_max_lenght(self): + user = User.objects.get(id=1) + max_length = user._meta.get_field("name").max_length + self.assertEqual(max_length, 256) + + def test_get_absolute_url(self): + user = User.objects.get(id=1) + self.assertEqual(user.get_absolute_url(), "/api/users/1/") + + def test_object_name_is_username(self): + user = User.objects.get(id=1) + expected_name = user.username + self.assertEqual(str(user), expected_name) + + def test_user_is_not_verified(self): + user = User.objects.get(id=1) + self.assertFalse(user.is_verified) + + def test_user_renew_id_one_year(self): + user = User.objects.get(id=1) + user.renew_id(days=365) + self.assertTrue(user.is_verified) + + def test_user_renew_id_zero_days(self): + user = User.objects.get(id=1) + user.renew_id(days=0) + self.assertFalse(user.is_verified) diff --git a/api/users/tests/test_swagger.py b/api/users/tests/test_swagger.py deleted file mode 100644 index 7f5b758..0000000 --- a/api/users/tests/test_swagger.py +++ /dev/null @@ -1,22 +0,0 @@ -import pytest -from django.urls import reverse - -pytestmark = pytest.mark.django_db - - -def test_swagger_accessible_by_admin(admin_client): - url = reverse("api-docs") - response = admin_client.get(url) - assert response.status_code == 200 - - -def test_swagger_ui_not_accessible_by_normal_user(client): - url = reverse("api-docs") - response = client.get(url) - assert response.status_code == 403 - - -def test_api_schema_generated_successfully(admin_client): - url = reverse("api-schema") - response = admin_client.get(url) - assert response.status_code == 200 diff --git a/api/users/tests/test_tasks.py b/api/users/tests/test_tasks.py deleted file mode 100644 index 55b3011..0000000 --- a/api/users/tests/test_tasks.py +++ /dev/null @@ -1,16 +0,0 @@ -import pytest -from celery.result import EagerResult - -from api.users.tasks import get_users_count -from api.users.tests.factories import UserFactory - -pytestmark = pytest.mark.django_db - - -def test_user_count(settings): - """A basic test to execute the get_users_count Celery task.""" - UserFactory.create_batch(3) - settings.CELERY_TASK_ALWAYS_EAGER = True - task_result = get_users_count.delay() - assert isinstance(task_result, EagerResult) - assert task_result.result == 3 diff --git a/api/users/tests/test_urls.py b/api/users/tests/test_urls.py deleted file mode 100644 index 012ac30..0000000 --- a/api/users/tests/test_urls.py +++ /dev/null @@ -1,24 +0,0 @@ -import pytest -from django.urls import resolve, reverse - -from api.users.models import User - -pytestmark = pytest.mark.django_db - - -def test_detail(user: User): - assert ( - reverse("users:detail", kwargs={"username": user.username}) - == f"/users/{user.username}/" - ) - assert resolve(f"/users/{user.username}/").view_name == "users:detail" - - -def test_update(): - assert reverse("users:update") == "/users/~update/" - assert resolve("/users/~update/").view_name == "users:update" - - -def test_redirect(): - assert reverse("users:redirect") == "/users/~redirect/" - assert resolve("/users/~redirect/").view_name == "users:redirect" diff --git a/api/users/tests/test_views.py b/api/users/tests/test_views.py deleted file mode 100644 index 8a16ed1..0000000 --- a/api/users/tests/test_views.py +++ /dev/null @@ -1,102 +0,0 @@ -import pytest -from django.conf import settings -from django.contrib import messages -from django.contrib.auth.models import AnonymousUser -from django.contrib.messages.middleware import MessageMiddleware -from django.contrib.sessions.middleware import SessionMiddleware -from django.http import HttpRequest, HttpResponseRedirect -from django.test import RequestFactory -from django.urls import reverse - -from api.users.forms import UserAdminChangeForm -from api.users.models import User -from api.users.tests.factories import UserFactory -from api.users.views import ( - UserRedirectView, - UserUpdateView, - user_detail_view, -) - -pytestmark = pytest.mark.django_db - - -class TestUserUpdateView: - """ - TODO: - extracting view initialization code as class-scoped fixture - would be great if only pytest-django supported non-function-scoped - fixture db access -- this is a work-in-progress for now: - https://github.com/pytest-dev/pytest-django/pull/258 - """ - - def dummy_get_response(self, request: HttpRequest): - return None - - def test_get_success_url(self, user: User, rf: RequestFactory): - view = UserUpdateView() - request = rf.get("/fake-url/") - request.user = user - - view.request = request - - assert view.get_success_url() == f"/users/{user.username}/" - - def test_get_object(self, user: User, rf: RequestFactory): - view = UserUpdateView() - request = rf.get("/fake-url/") - request.user = user - - view.request = request - - assert view.get_object() == user - - def test_form_valid(self, user: User, rf: RequestFactory): - view = UserUpdateView() - request = rf.get("/fake-url/") - - # Add the session/message middleware to the request - SessionMiddleware(self.dummy_get_response).process_request(request) - MessageMiddleware(self.dummy_get_response).process_request(request) - request.user = user - - view.request = request - - # Initialize the form - form = UserAdminChangeForm() - form.cleaned_data = [] - view.form_valid(form) - - messages_sent = [m.message for m in messages.get_messages(request)] - assert messages_sent == ["Information successfully updated"] - - -class TestUserRedirectView: - def test_get_redirect_url(self, user: User, rf: RequestFactory): - view = UserRedirectView() - request = rf.get("/fake-url") - request.user = user - - view.request = request - - assert view.get_redirect_url() == f"/users/{user.username}/" - - -class TestUserDetailView: - def test_authenticated(self, user: User, rf: RequestFactory): - request = rf.get("/fake-url/") - request.user = UserFactory() - - response = user_detail_view(request, username=user.username) - - assert response.status_code == 200 - - def test_not_authenticated(self, user: User, rf: RequestFactory): - request = rf.get("/fake-url/") - request.user = AnonymousUser() - - response = user_detail_view(request, username=user.username) - login_url = reverse(settings.LOGIN_URL) - - assert isinstance(response, HttpResponseRedirect) - assert response.status_code == 302 - assert response.url == f"{login_url}?next=/fake-url/" diff --git a/api/users/urls.py b/api/users/urls.py index 3ba575f..8d6daa2 100644 --- a/api/users/urls.py +++ b/api/users/urls.py @@ -1,14 +1,18 @@ from django.urls import path -from api.users.views import ( - user_detail_view, - user_redirect_view, - user_update_view, +from api.users.apis import ( + CreateUserApi, + DetailUserApi, + SetNationalIdApi, + UpdateUserApi, + UserCasesListApi, ) app_name = "users" urlpatterns = [ - path("~redirect/", view=user_redirect_view, name="redirect"), - path("~update/", view=user_update_view, name="update"), - path("/", view=user_detail_view, name="detail"), + path("create/", CreateUserApi.as_view(), name="create_user"), + path("/", DetailUserApi.as_view(), name="get_user"), + path("/update/", UpdateUserApi.as_view(), name="update_user"), + path("cases/", UserCasesListApi.as_view(), name="get_user_cases"), + path("/set/id/", SetNationalIdApi.as_view(), name="set_id"), ] diff --git a/config/env.py b/config/env.py new file mode 100644 index 0000000..f09860d --- /dev/null +++ b/config/env.py @@ -0,0 +1,14 @@ +import environ +from django.core.exceptions import ImproperlyConfigured + +env = environ.Env() + + +def env_to_enum(enum_cls, value): + for x in enum_cls: + if x.value == value: + return x + + raise ImproperlyConfigured( + f"Env value {repr(value)} could not be found in {repr(enum_cls)}" + ) diff --git a/config/settings/base.py b/config/settings/base.py index 6e04cc6..dff242a 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -1,9 +1,14 @@ """ Base settings to build other settings files upon. """ +import os from pathlib import Path import environ +from firebase_admin import initialize_app + +from api.files.enums import FileUploadStorage, FileUploadStrategy +from config.env import env_to_enum ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent # api/ @@ -65,6 +70,7 @@ "django.contrib.admin", "django.forms", ] + THIRD_PARTY_APPS = [ "crispy_forms", "crispy_bootstrap5", @@ -76,11 +82,20 @@ "rest_framework.authtoken", "corsheaders", "drf_spectacular", + "fcm_django", ] LOCAL_APPS = [ "api.users", - # Your stuff: custom apps go here + "api.authentication", + "api.locations", + "api.cases", + "api.errors", + "api.common", + "api.files", + "api.integrations", + "api.apis", + "api.notifications", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS @@ -286,7 +301,7 @@ # https://django-allauth.readthedocs.io/en/latest/configuration.html ACCOUNT_EMAIL_REQUIRED = True # https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_EMAIL_VERIFICATION = "mandatory" +ACCOUNT_EMAIL_VERIFICATION = "optional" # https://django-allauth.readthedocs.io/en/latest/configuration.html ACCOUNT_ADAPTER = "api.users.adapters.AccountAdapter" # https://django-allauth.readthedocs.io/en/latest/forms.html @@ -303,11 +318,19 @@ "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.TokenAuthentication", + "rest_framework_simplejwt.authentication.JWTAuthentication", ), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "EXCEPTION_HANDLER": "api.errors.handlers.custom_exception_handler", } +# JWT config +REST_USE_JWT = True +JWT_AUTH_COOKIE = "my-app-auth" + +APP_DOMAIN = env("APP_DOMAIN", default="http://localhost:8000") + # django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup CORS_URLS_REGEX = r"^/api/.*$" @@ -323,5 +346,51 @@ {"url": "https://mafqud.com", "description": "Production server"}, ], } -# Your stuff... + +# Firebase Notification Settings +# ------------------------------------------------------------------------------ +# fcm-django - https://fcm-django.readthedocs.io/en/latest/ +GOOGLE_APPLICATION_CREDENTIALS = env("GOOGLE_APPLICATION_CREDENTIALS") + +FIREBASE_APP = initialize_app() + +FCM_DJANGO_SETTINGS = { + "ONE_DEVICE_PER_USER": True, + "DELETE_INACTIVE_DEVICES": False, + "UPDATE_ON_DUPLICATE_REG_ID": True, +} + +# File Storages Settings # ------------------------------------------------------------------------------ +FILE_UPLOAD_STRATEGY = env_to_enum( + FileUploadStrategy, + env("FILE_UPLOAD_STRATEGY", default="standard"), +) + +FILE_UPLOAD_STORAGE = env_to_enum( + FileUploadStorage, + env("FILE_UPLOAD_STORAGE", default="local"), +) + +FILE_MAX_SIZE = env.int("FILE_MAX_SIZE", default=10485760) # 10 MB + +if FILE_UPLOAD_STORAGE == FileUploadStorage.LOCAL: + MEDIA_ROOT_NAME = "media" + MEDIA_ROOT = os.path.join(ROOT_DIR, MEDIA_ROOT_NAME) + MEDIA_URL = f"/{MEDIA_ROOT_NAME}/" + +if FILE_UPLOAD_STORAGE == FileUploadStorage.S3: + # Using django-storages + # https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html + DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" + + AWS_S3_ACCESS_KEY_ID = env("AWS_S3_ACCESS_KEY_ID") + AWS_S3_SECRET_ACCESS_KEY = env("AWS_S3_SECRET_ACCESS_KEY") + AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME") + AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME") + AWS_S3_SIGNATURE_VERSION = env("AWS_S3_SIGNATURE_VERSION", default="s3v4") + + # https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl + AWS_DEFAULT_ACL = env("AWS_DEFAULT_ACL", default="public-read") + + AWS_PRESIGNED_EXPIRY = env.int("AWS_PRESIGNED_EXPIRY", default=10) # seconds diff --git a/config/urls.py b/config/urls.py index f5fa0c4..2f1b07a 100644 --- a/config/urls.py +++ b/config/urls.py @@ -16,9 +16,10 @@ # Django Admin, use {% url 'admin:index' %} path(settings.ADMIN_URL, admin.site.urls), # User management - path("users/", include("api.users.urls", namespace="users")), + path("app-users/", include("api.users.app_urls", namespace="app_users")), path("accounts/", include("allauth.urls")), # Your stuff: custom urls includes go here + path("api/", include("api.apis.urls", namespace="apis")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.DEBUG: # Static file serving when using Gunicorn + Uvicorn for local web socket development @@ -26,10 +27,9 @@ # API URLS urlpatterns += [ - # API base url - path("api/", include("config.api_router")), # DRF auth token path("auth-token/", obtain_auth_token), + # Docs path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), path( "api/docs/", diff --git a/requirements/base.txt b/requirements/base.txt index 335f1a9..5981ca8 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -21,5 +21,11 @@ django-redis==5.2.0 # https://github.com/jazzband/django-redis # Django REST Framework djangorestframework==3.13.1 # https://github.com/encode/django-rest-framework django-cors-headers==3.11.0 # https://github.com/adamchainz/django-cors-headers +djangorestframework-simplejwt==5.1.0 # https://django-rest-framework-simplejwt.readthedocs.io/en/latest/ # DRF-spectacular for api documentation drf-spectacular==0.22.0 # https://github.com/tfranzel/drf-spectacular +# 3rd party +django-fsm==2.8.0 # https://github.com/viewflow/django-fsm +boto3==1.21.45 # https://boto3.amazonaws.com/v1/documentation/api/latest/index.html +django-filter==21.1 # https://github.com/carltongibson/django-filter +fcm-django==1.0.11 # https://pypi.org/project/fcm-django/1.0.11/