From f5ec8cac1ae85829d4a2b896987ee1ce93aba6ae Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sun, 8 May 2022 06:30:16 +0200 Subject: [PATCH 01/13] refactor: cases app --- api/cases/apis.py | 22 ++++++++++------- api/cases/filters.py | 8 ++++++- .../migrations/0006_auto_20220508_0416.py | 24 +++++++++++++++++++ api/cases/models.py | 7 +++--- api/cases/selectors.py | 12 +++++----- 5 files changed, 54 insertions(+), 19 deletions(-) create mode 100644 api/cases/migrations/0006_auto_20220508_0416.py diff --git a/api/cases/apis.py b/api/cases/apis.py index abb4e72..0990c9e 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -15,8 +15,8 @@ class InputSerializer(serializers.Serializer): photos_urls = serializers.ListField(child=serializers.URLField()) location = inline_serializer( fields={ - "gov_id": serializers.IntegerField(), - "city_id": serializers.IntegerField(), + "gov": serializers.IntegerField(), + "city": serializers.IntegerField(), "address": serializers.CharField(required=False), "lon": serializers.DecimalField( max_digits=9, decimal_places=6, required=False @@ -65,11 +65,15 @@ class OutputSerializer(serializers.Serializer): id = serializers.IntegerField() type = serializers.CharField() name = serializers.CharField(source="details.name") - gov = serializers.CharField(source="location.gov.name_ar") - city = serializers.CharField(source="location.city.name_ar") - photo = serializers.URLField(source="photo_urls") + location = inline_serializer( + fields={ + "gov": serializers.CharField(source="gov.name_ar"), + "city": serializers.CharField(source="city.name_ar"), + } + ) + photos = serializers.ListField(source="photo_urls") last_seen = serializers.DateField(source="details.last_seen") - posted_at = serializers.DateField() + posted_at = serializers.DateTimeField() def get(self, request): # Make sure the filters are valid, if passed @@ -92,11 +96,11 @@ class OutputSerializer(serializers.Serializer): user = serializers.IntegerField() type = serializers.CharField() state = serializers.CharField(source="get_state_display") - photos_urls = serializers.ListField(child=serializers.URLField()) + photos = serializers.ListField(source="photo_urls") location = inline_serializer( fields={ - "gov_id": serializers.IntegerField(), - "city_id": serializers.IntegerField(), + "gov": serializers.CharField(), + "city": serializers.CharField(), "address": serializers.CharField(), "lon": serializers.DecimalField( max_digits=9, diff --git a/api/cases/filters.py b/api/cases/filters.py index 5a5abb0..af1c4f6 100644 --- a/api/cases/filters.py +++ b/api/cases/filters.py @@ -26,4 +26,10 @@ class CaseFilter(django_filters.FilterSet): class Meta: model = Case - fields = ["type", "details__age", "details__last_seen", "location__gov", "name"] + fields = [ + "type", + "details__age", + "details__last_seen", + "location__gov", + "details__name", + ] 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/models.py b/api/cases/models.py index a4fc198..a6b5475 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -1,7 +1,6 @@ -import datetime - 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 @@ -39,6 +38,7 @@ class Types(models.TextChoices): 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) + # TODO thumbnail @property def photo_urls(self): @@ -73,8 +73,9 @@ def archive(self): def activate_again(self): self.is_active = True + # FIXME def publish(self): - self.posted_at = datetime.now() + self.posted_at = timezone.now() class CaseDetails(models.Model): diff --git a/api/cases/selectors.py b/api/cases/selectors.py index 5f265af..853a85c 100644 --- a/api/cases/selectors.py +++ b/api/cases/selectors.py @@ -1,7 +1,7 @@ from django.core.exceptions import PermissionDenied -from django.db.models import Q +from django.db.models.query import Q, QuerySet +from django.shortcuts import get_object_or_404 -from api.common.utils import get_object from api.users.models import User from .filters import CaseFilter @@ -9,7 +9,7 @@ def get_case(*, pk: int, fetched_by: User) -> Case: - case = get_object(Case, pk=pk) + case = get_object_or_404(Case, pk=pk) if not (case.is_active or fetched_by == case.user): raise PermissionDenied() @@ -17,7 +17,7 @@ def get_case(*, pk: int, fetched_by: User) -> Case: return case -def list_case(*, filters=None): +def list_case(*, filters=None) -> QuerySet[Case]: filters = filters or {} # TODO Switch to posted cases only @@ -30,8 +30,8 @@ def list_user_case(*, user: User): return user.cases.all() -def list_case_match(*, pk: int, fetched_by: User): - case = get_object(Case, pk=pk) +def list_case_match(*, pk: int, fetched_by: User) -> QuerySet[Case]: + case = get_object_or_404(Case, pk=pk) if fetched_by != case.user: raise PermissionDenied() From 184407a9c19d4fed2129e839fc75de36cb0af909 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sun, 8 May 2022 06:31:38 +0200 Subject: [PATCH 02/13] refactor: users app --- api/users/apis.py | 41 ++++++++------- api/users/migrations/0003_alter_user_email.py | 18 +++++++ api/users/models.py | 1 - api/users/services.py | 52 ++++++++----------- 4 files changed, 63 insertions(+), 49 deletions(-) create mode 100644 api/users/migrations/0003_alter_user_email.py diff --git a/api/users/apis.py b/api/users/apis.py index 0ec076e..252579c 100644 --- a/api/users/apis.py +++ b/api/users/apis.py @@ -15,9 +15,12 @@ class InputSerializer(serializers.Serializer): username = serializers.CharField() password = serializers.CharField() name = serializers.CharField() - email = serializers.EmailField(required=False) - gov_id = serializers.IntegerField() - city_id = serializers.IntegerField() + location = inline_serializer( + fields={ + "gov": serializers.IntegerField(), + "city": serializers.IntegerField(), + } + ) firebase_token = serializers.CharField() def post(self, request): @@ -34,22 +37,11 @@ class DetailUserApi(APIView): class OutputSerializer(serializers.Serializer): username = serializers.CharField() name = serializers.CharField() - email = serializers.CharField() location = inline_serializer( fields={ "address": serializers.CharField(), - "gov": inline_serializer( - fields={ - "name_ar": serializers.CharField(), - "name_en": serializers.CharField(), - } - ), - "city": inline_serializer( - fields={ - "name_ar": serializers.CharField(), - "name_en": serializers.CharField(), - } - ), + "gov": serializers.CharField(source="gov.name_ar"), + "city": serializers.CharField(source="city.name_ar"), } ) @@ -64,9 +56,20 @@ class UpdateUserApi(APIView): class InputSerializer(serializers.Serializer): name = serializers.CharField(required=False) - email = serializers.CharField(required=False) - gov_id = serializers.IntegerField(required=False) - city_id = serializers.IntegerField(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) diff --git a/api/users/migrations/0003_alter_user_email.py b/api/users/migrations/0003_alter_user_email.py new file mode 100644 index 0000000..a5e1547 --- /dev/null +++ b/api/users/migrations/0003_alter_user_email.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-05-08 04:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_remove_user_id_photo_url'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(blank=True, max_length=254, verbose_name='email address'), + ), + ] diff --git a/api/users/models.py b/api/users/models.py index b906a50..8214a13 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -12,7 +12,6 @@ class User(AbstractUser): last_name = None # type: ignore name = models.CharField(max_length=256) - email = models.EmailField(null=True, blank=True) username = models.CharField(max_length=10, unique=True, validators=[is_phone]) id_exp_date = models.DateTimeField(null=True, blank=True) diff --git a/api/users/services.py b/api/users/services.py index eb1c6ab..37b1cca 100644 --- a/api/users/services.py +++ b/api/users/services.py @@ -1,7 +1,8 @@ from typing import Dict, Optional +from django.contrib.auth.hashers import make_password +from django.contrib.auth.password_validation import validate_password from django.db import transaction -from django.shortcuts import get_object_or_404 from api.common.services import model_update from api.locations.models import Location @@ -15,23 +16,26 @@ def create_user( name: str, username: str, password: str, - email: Optional[str] = None, firebase_token: Optional[str], - gov_id: int, - city_id: int, + location: Dict, ) -> User: - loc: Location = create_location(gov_id=gov_id, city_id=city_id) + # Creating user's related entities + location: Location = create_location(**location) + + # Pack user data for validation user: User = User( - name=name, - username=username, - email=email, - location=loc, - firebase_token=firebase_token, + name=name, username=username, firebase_token=firebase_token, location=location ) - user.set_password(password) + + # Password validation + validate_password(password) + user.password = make_password(password) + + # Data validation user.full_clean() - # user.clean() + + # Saving user to the database user.save() return user @@ -40,29 +44,19 @@ def create_user( @transaction.atomic def update_user( *, - user_id: int, + user: User, data: Dict, ) -> User: - fields = ["name", "email"] - - user = get_object_or_404(User, pk=user_id) - - gov_id = data.get("gov_id") - city_id = data.get("city_id") - - if gov_id and city_id: - update_location( - location_id=user.location.id, - data={ - "gov_id": data.get("gov_id"), - "city_id": data.get("city_id"), - }, - ) + non_side_effect_fields = ["name", "firebase_token"] user, _ = model_update( instance=user, - fields=fields, + 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 From eb519062df2b53dd7ead72bd694eaab3ce72503c Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sun, 8 May 2022 06:31:49 +0200 Subject: [PATCH 03/13] refactor: locations app --- api/locations/selectors.py | 6 ++--- api/locations/services.py | 48 +++++++++++++++++++------------------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/api/locations/selectors.py b/api/locations/selectors.py index ffc3552..d400ae0 100644 --- a/api/locations/selectors.py +++ b/api/locations/selectors.py @@ -1,11 +1,11 @@ -from typing import Iterable +from django.db.models.query import QuerySet from .models import City, Governorate -def list_governorate() -> Iterable[Governorate]: +def list_governorate() -> QuerySet[Governorate]: return Governorate.objects.all() -def list_cities() -> Iterable[City]: +def list_cities() -> QuerySet[City]: return City.objects.all() diff --git a/api/locations/services.py b/api/locations/services.py index 61defde..07b94f4 100644 --- a/api/locations/services.py +++ b/api/locations/services.py @@ -2,7 +2,6 @@ from django.conf import settings from django.shortcuts import get_object_or_404 -from rest_framework.exceptions import ValidationError from api.common.services import model_update from api.locations.models import City, Governorate, Location @@ -48,44 +47,45 @@ def create_location( lon: Optional[float] = None, lat: Optional[float] = None, address: Optional[str] = None, - gov_id: int, - city_id: int, + gov: int, + city: int, ) -> Location: - gov = Governorate.objects.get(pk=gov_id) - city = City.objects.get(pk=city_id) - loc = Location(lon=lon, lat=lat, address=address, gov=gov, city=city) - loc.full_clean() - # loc.clean() - loc.save() + # Fetch Governorate & City + gov = Governorate.objects.get(pk=gov) + city = City.objects.get(pk=city) - return loc + # 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_id: int, + location: Location, data: Dict, ) -> Location: - fields = ["lon", "lat", "address", "gov", "city"] - location = get_object_or_404(Location, pk=location_id) - - gov_id = data.get("gov_id") - city_id = data.get("city_id") - - gov = get_object_or_404(Governorate, pk=gov_id) - city = get_object_or_404(City, pk=city_id) + # Fetch Governorate & City if given + gov_id, city_id = data.get("gov"), data.get("city") - if city.gov != gov: - raise ValidationError("City does not belong to Governorate") + 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) - data["gov"] = gov - data["city"] = city + non_side_effect_fields = ["lon", "lat", "address", "gov", "city"] location, _ = model_update( instance=location, - fields=fields, + fields=non_side_effect_fields, data=data, ) From 3b81c07e9ab076ead57d83d1c800de4a6fd04dfe Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sun, 8 May 2022 06:43:25 +0200 Subject: [PATCH 04/13] refactor: make firebase_token essential field --- api/users/services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/users/services.py b/api/users/services.py index 37b1cca..701b1c6 100644 --- a/api/users/services.py +++ b/api/users/services.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional +from typing import Dict from django.contrib.auth.hashers import make_password from django.contrib.auth.password_validation import validate_password @@ -16,7 +16,7 @@ def create_user( name: str, username: str, password: str, - firebase_token: Optional[str], + firebase_token: str, location: Dict, ) -> User: From 1d8ddb0730f4339c8d0cdcde8303527cc76d429a Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sun, 8 May 2022 14:22:40 +0200 Subject: [PATCH 05/13] chore: liniting --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 83a0ab6e97ddbdd7cea1df0e996e074be427a989 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sun, 8 May 2022 14:26:40 +0200 Subject: [PATCH 06/13] update user service test --- api/users/tests/test_models.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/users/tests/test_models.py b/api/users/tests/test_models.py index d3cfb8a..d70bee6 100644 --- a/api/users/tests/test_models.py +++ b/api/users/tests/test_models.py @@ -13,10 +13,8 @@ def setUpTestData(cls): # Called once at the beginning of the test run name="Osama Yasser", username="1005499972", password="hardpassword", - email="osamayasserr@gmail.com", firebase_token="token", - gov_id="1", - city_id="4", + location={"gov": 1, "city": "4"}, ) def test_name_max_lenght(self): From df72a698e3b640b9e18515726cfd22da972b409c Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Tue, 10 May 2022 21:11:56 +0200 Subject: [PATCH 07/13] feat: create CaseMatchApi endpoint --- api/cases/apis.py | 42 ++++++++++++++++++- .../migrations/0007_auto_20220510_1459.py | 34 +++++++++++++++ api/cases/models.py | 8 ++-- api/cases/selectors.py | 17 +++++--- api/cases/urls.py | 3 +- 5 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 api/cases/migrations/0007_auto_20220510_1459.py diff --git a/api/cases/apis.py b/api/cases/apis.py index 0990c9e..e403ddf 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -3,7 +3,8 @@ from rest_framework.views import APIView from api.apis.pagination import LimitOffsetPagination, get_paginated_response -from api.cases.selectors import get_case, list_case +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 from api.common.utils import inline_serializer from api.users.models import User @@ -129,3 +130,42 @@ def get(self, request, case_id): 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 depding 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"), + }, + ), + "photos": serializers.ListField(source="photo_urls"), + "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) + + # Serializing the results + serializer = OutputSerializer(matches, many=True) + + return Response(serializer.data) 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/models.py b/api/cases/models.py index a6b5475..e149b33 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -96,9 +96,11 @@ class Gender(models.TextChoices): class CaseMatch(models.Model): - case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="old_matches") - match = models.ForeignKey( - Case, on_delete=models.CASCADE, related_name="new_matches" + 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)] diff --git a/api/cases/selectors.py b/api/cases/selectors.py index 853a85c..0cff214 100644 --- a/api/cases/selectors.py +++ b/api/cases/selectors.py @@ -1,5 +1,5 @@ from django.core.exceptions import PermissionDenied -from django.db.models.query import Q, QuerySet +from django.db.models.query import QuerySet from django.shortcuts import get_object_or_404 from api.users.models import User @@ -30,12 +30,17 @@ def list_user_case(*, user: User): return user.cases.all() -def list_case_match(*, pk: int, fetched_by: User) -> QuerySet[Case]: - case = get_object_or_404(Case, pk=pk) +def list_case_match(*, case: Case, fetched_by: User) -> QuerySet[CaseMatch]: - if fetched_by != case.user: + if case.user != fetched_by: raise PermissionDenied() - # TODO Wrong approach - qs = CaseMatch.objects.filter(Q(case=case) | Q(match=case)) + 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/urls.py b/api/cases/urls.py index b333bac..b30dd5a 100644 --- a/api/cases/urls.py +++ b/api/cases/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from .apis import CaseListApi, CreateCaseApi, DetailsCaseApi +from .apis import CaseListApi, CaseMatchListApi, CreateCaseApi, DetailsCaseApi app_name = "cases" @@ -9,4 +9,5 @@ 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"), ] From 619aa8025b0d190c3f597be53cbce9bfab6015cd Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Tue, 10 May 2022 21:23:04 +0200 Subject: [PATCH 08/13] fix: pass user to list_case_match selector --- api/cases/apis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/cases/apis.py b/api/cases/apis.py index e403ddf..07a6b96 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -163,7 +163,7 @@ class OutputSerializer(serializers.Serializer): score = serializers.IntegerField() # Listing all case matches - matches = list_case_match(case=case) + matches = list_case_match(case=case, user=request.user) # Serializing the results serializer = OutputSerializer(matches, many=True) From 265cc370f3c5545a9414481c6ad683b8d3055730 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Tue, 10 May 2022 21:27:58 +0200 Subject: [PATCH 09/13] bugfix --- api/cases/apis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/cases/apis.py b/api/cases/apis.py index 07a6b96..a7dfaf3 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -163,7 +163,7 @@ class OutputSerializer(serializers.Serializer): score = serializers.IntegerField() # Listing all case matches - matches = list_case_match(case=case, user=request.user) + matches = list_case_match(case=case, fetched_by=request.user) # Serializing the results serializer = OutputSerializer(matches, many=True) From 32f9f01cd47f5613abcb3ea515a49784967ab308 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Fri, 13 May 2022 11:29:05 +0200 Subject: [PATCH 10/13] feat: write locations app endpoints --- api/apis/urls.py | 1 + api/locations/apis.py | 43 ++++++++++++++++++++++++++++++++++++++ api/locations/selectors.py | 6 ++++-- api/locations/urls.py | 14 +++++++++++++ api/locations/views.py | 1 - 5 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 api/locations/apis.py create mode 100644 api/locations/urls.py delete mode 100644 api/locations/views.py diff --git a/api/apis/urls.py b/api/apis/urls.py index 1e044a7..bfc54f8 100644 --- a/api/apis/urls.py +++ b/api/apis/urls.py @@ -7,4 +7,5 @@ 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/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/selectors.py b/api/locations/selectors.py index d400ae0..4d7610e 100644 --- a/api/locations/selectors.py +++ b/api/locations/selectors.py @@ -1,4 +1,5 @@ from django.db.models.query import QuerySet +from django.shortcuts import get_object_or_404 from .models import City, Governorate @@ -7,5 +8,6 @@ def list_governorate() -> QuerySet[Governorate]: return Governorate.objects.all() -def list_cities() -> QuerySet[City]: - return City.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/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/views.py b/api/locations/views.py deleted file mode 100644 index 5873694..0000000 --- a/api/locations/views.py +++ /dev/null @@ -1 +0,0 @@ -# Views From cfa8a394123ba695e758f51d11a8e037effad57c Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Fri, 13 May 2022 11:29:46 +0200 Subject: [PATCH 11/13] fix: minor changes to notifications app --- api/notifications/models.py | 1 + api/notifications/services.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/api/notifications/models.py b/api/notifications/models.py index ca37412..8f0bd0c 100644 --- a/api/notifications/models.py +++ b/api/notifications/models.py @@ -27,6 +27,7 @@ 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/services.py b/api/notifications/services.py index 52c15ac..d2ead96 100644 --- a/api/notifications/services.py +++ b/api/notifications/services.py @@ -11,7 +11,7 @@ def create_notification( title: str, body: str, level: str, - sent_to: FCMDevice, + sent_to: User, ) -> Notification: notification = Notification( From 9b941c026d48c72836f959c40d48cf7e41ad2a05 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Fri, 13 May 2022 11:31:09 +0200 Subject: [PATCH 12/13] feat: write endpoint for list user cases --- api/users/apis.py | 50 ++++++++++++++++++++++++++++++++---------- api/users/selectors.py | 8 ++++++- api/users/services.py | 6 +++++ api/users/urls.py | 3 ++- 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/api/users/apis.py b/api/users/apis.py index 58ed28d..2d31e0c 100644 --- a/api/users/apis.py +++ b/api/users/apis.py @@ -2,9 +2,8 @@ from rest_framework.response import Response from rest_framework.views import APIView -from api.common.permissions import IsVerified -from api.common.utils import get_object, inline_serializer -from api.users.models import User +from api.common.utils import inline_serializer +from api.users.selectors import get_user, get_user_cases from api.users.services import create_user, update_user @@ -15,14 +14,14 @@ 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(), } ) - fcm_token = serializers.CharField() - firebase_token = serializers.CharField() def post(self, request): serializer = self.InputSerializer(data=request.data) @@ -33,8 +32,6 @@ def post(self, request): class DetailUserApi(APIView): - permission_classes = [IsVerified] - class OutputSerializer(serializers.Serializer): username = serializers.CharField() name = serializers.CharField() @@ -47,16 +44,16 @@ class OutputSerializer(serializers.Serializer): ) def get(self, request, user_id): - user = get_object(User, id=user_id) + user = get_user(user_id) serializer = self.OutputSerializer(user) return Response(serializer.data) class UpdateUserApi(APIView): - permission_classes = [permissions.IsAdminUser] - 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(), @@ -75,8 +72,39 @@ class InputSerializer(serializers.Serializer): 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_id=user_id, + 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) diff --git a/api/users/selectors.py b/api/users/selectors.py index dc67fcd..7786b7e 100644 --- a/api/users/selectors.py +++ b/api/users/selectors.py @@ -1,7 +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: +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 index a7d0dc2..76cfc30 100644 --- a/api/users/services.py +++ b/api/users/services.py @@ -2,6 +2,7 @@ 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 @@ -49,8 +50,13 @@ def create_user( 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( diff --git a/api/users/urls.py b/api/users/urls.py index 49d7a43..6c6e71f 100644 --- a/api/users/urls.py +++ b/api/users/urls.py @@ -1,10 +1,11 @@ from django.urls import path -from api.users.apis import CreateUserApi, DetailUserApi, UpdateUserApi +from api.users.apis import CreateUserApi, DetailUserApi, UpdateUserApi, UserCasesListApi app_name = "users" urlpatterns = [ 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"), ] From 5d931e731ab0e1bff8aa2136da9d77bcf2309fe7 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Fri, 13 May 2022 11:32:30 +0200 Subject: [PATCH 13/13] feat: write endpoint for pulishing a case & few updates --- api/cases/apis.py | 23 ++++--- api/cases/migrations/0008_case_thumbnail.py | 19 ++++++ api/cases/models.py | 7 +-- api/cases/services.py | 69 +++++++++++++++++++-- api/cases/urls.py | 9 ++- 5 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 api/cases/migrations/0008_case_thumbnail.py diff --git a/api/cases/apis.py b/api/cases/apis.py index a7dfaf3..154b0ec 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -5,14 +5,14 @@ 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 +from api.cases.services import create_case, publish_case from api.common.utils import inline_serializer -from api.users.models import User class CreateCaseApi(APIView): class InputSerializer(serializers.Serializer): type = serializers.CharField() + thumbnail = serializers.URLField() photos_urls = serializers.ListField(child=serializers.URLField()) location = inline_serializer( fields={ @@ -44,7 +44,7 @@ class InputSerializer(serializers.Serializer): def post(self, request): serializer = self.InputSerializer(data=request.data) serializer.is_valid(raise_exception=True) - create_case(user=User.objects.all()[0], **serializer.validated_data) + create_case(user=request.user, **serializer.validated_data) return Response(status=status.HTTP_201_CREATED) @@ -66,15 +66,15 @@ class OutputSerializer(serializers.Serializer): id = serializers.IntegerField() type = serializers.CharField() 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"), } ) - photos = serializers.ListField(source="photo_urls") - last_seen = serializers.DateField(source="details.last_seen") - posted_at = serializers.DateTimeField() def get(self, request): # Make sure the filters are valid, if passed @@ -94,7 +94,7 @@ def get(self, request): class DetailsCaseApi(APIView): class OutputSerializer(serializers.Serializer): - user = serializers.IntegerField() + user = serializers.CharField(source="user.username") type = serializers.CharField() state = serializers.CharField(source="get_state_display") photos = serializers.ListField(source="photo_urls") @@ -138,7 +138,7 @@ def get(self, request, case_id): # Fetching our case case = get_case(pk=case_id, fetched_by=request.user) - # Selecting which cases to serialize depding on case type + # 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 @@ -169,3 +169,10 @@ class OutputSerializer(serializers.Serializer): serializer = OutputSerializer(matches, many=True) return Response(serializer.data) + + +class CasePublishApi(APIView): + def get(self, request, case_id): + case = get_case(case_id) + publish_case(case=case, performed_by=request.user) + return Response(status=status.HTTP_200_OK) 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/models.py b/api/cases/models.py index e149b33..64b2b8a 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -38,7 +38,7 @@ class Types(models.TextChoices): 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) - # TODO thumbnail + thumbnail = models.URLField() @property def photo_urls(self): @@ -50,10 +50,6 @@ def __str__(self): # Pass the case to the model then add matched cases if any. @transition(field=state, source=States.PENDING, target=States.ACTIVE) def activate(self): - from .services import case_matching_binding, process_case - - matches = process_case(self) - case_matching_binding(matches) self.is_active = True # If User selected one of the matches to be the correct one @@ -73,7 +69,6 @@ def archive(self): def activate_again(self): self.is_active = True - # FIXME def publish(self): self.posted_at = timezone.now() diff --git a/api/cases/services.py b/api/cases/services.py index 2b9b719..93c4272 100644 --- a/api/cases/services.py +++ b/api/cases/services.py @@ -1,10 +1,16 @@ from datetime import date from typing import Dict, List, Optional +from django.core.exceptions import PermissionDenied from django.db import transaction +from firebase_admin.messaging import Message +from firebase_admin.messaging import Notification as FirebaseNotification +from rest_framework.exceptions import ValidationError 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 @@ -28,10 +34,11 @@ def create_case( user: User, location: Dict, details: Dict, + thumbnail: str, photos_urls: List[str], ) -> Case: location: Location = create_location(**location) - case = Case(type=type.upper(), user=user, location=location) + case = Case(type=type.upper(), user=user, location=location, thumbnail=thumbnail) case.full_clean() case.save() @@ -41,6 +48,10 @@ def create_case( create_case_details(case=case, **details) + # TODO Factor out to an async function + activate_case(case) + case.save() + return case @@ -86,23 +97,24 @@ def create_case_match(*, missing: Case, found: Case, score: int) -> CaseMatch: return case_match -def process_case(*, case: Case) -> List[Dict[int, int]]: +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) - if not matches: - return missing = True if case.type == CaseType.MISSING else False @@ -111,3 +123,50 @@ def case_matching_binding(*, case: Case, matches_list: List[Dict[int, int]]) -> 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) + # 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="تم نشر بيانات المعثور عليه بنجاح انتظر منا اشعار اخر فى حين الوصول لأى نتائج", + ) + ) + case.user.fcmdevice.send_message(msg) diff --git a/api/cases/urls.py b/api/cases/urls.py index b30dd5a..824d11f 100644 --- a/api/cases/urls.py +++ b/api/cases/urls.py @@ -1,6 +1,12 @@ from django.urls import path -from .apis import CaseListApi, CaseMatchListApi, CreateCaseApi, DetailsCaseApi +from .apis import ( + CaseListApi, + CaseMatchListApi, + CasePublishApi, + CreateCaseApi, + DetailsCaseApi, +) app_name = "cases" @@ -10,4 +16,5 @@ 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"), ]