From dbbee72e9e641a7cdd00aea6ba268e2ae4f616af Mon Sep 17 00:00:00 2001 From: root Date: Sat, 16 Apr 2022 16:28:51 +0200 Subject: [PATCH 01/90] chore: crete auth app --- auth/__init__.py | 1 + auth/admin.py | 1 + auth/apps.py | 6 ++++++ auth/migrations/__init__.py | 1 + auth/models.py | 1 + auth/tests.py | 1 + auth/views.py | 1 + 7 files changed, 12 insertions(+) create mode 100644 auth/__init__.py create mode 100644 auth/admin.py create mode 100644 auth/apps.py create mode 100644 auth/migrations/__init__.py create mode 100644 auth/models.py create mode 100644 auth/tests.py create mode 100644 auth/views.py diff --git a/auth/__init__.py b/auth/__init__.py new file mode 100644 index 0000000..f39e5e8 --- /dev/null +++ b/auth/__init__.py @@ -0,0 +1 @@ +# Init diff --git a/auth/admin.py b/auth/admin.py new file mode 100644 index 0000000..aad9415 --- /dev/null +++ b/auth/admin.py @@ -0,0 +1 @@ +# Admin diff --git a/auth/apps.py b/auth/apps.py new file mode 100644 index 0000000..6a09aaa --- /dev/null +++ b/auth/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "auth" diff --git a/auth/migrations/__init__.py b/auth/migrations/__init__.py new file mode 100644 index 0000000..9cf13eb --- /dev/null +++ b/auth/migrations/__init__.py @@ -0,0 +1 @@ +# Init \ No newline at end of file diff --git a/auth/models.py b/auth/models.py new file mode 100644 index 0000000..9136172 --- /dev/null +++ b/auth/models.py @@ -0,0 +1 @@ +# Models diff --git a/auth/tests.py b/auth/tests.py new file mode 100644 index 0000000..007eb95 --- /dev/null +++ b/auth/tests.py @@ -0,0 +1 @@ +# Tests diff --git a/auth/views.py b/auth/views.py new file mode 100644 index 0000000..5873694 --- /dev/null +++ b/auth/views.py @@ -0,0 +1 @@ +# Views From 4d4b0590e9b87e59653979b5957730582f79f2b7 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sat, 16 Apr 2022 16:35:34 +0200 Subject: [PATCH 02/90] chore: add auth to installed apps --- config/settings/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/settings/base.py b/config/settings/base.py index 6e04cc6..fcffff8 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -80,7 +80,7 @@ LOCAL_APPS = [ "api.users", - # Your stuff: custom apps go here + "api.auth", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS From a8ad3b251c07b6e46738f53a92de4e294092929e Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sat, 16 Apr 2022 22:43:57 +0200 Subject: [PATCH 03/90] chore: add .vscode to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index c2a4752..36740c0 100644 --- a/.gitignore +++ b/.gitignore @@ -277,3 +277,6 @@ api/media/ .env .envs/* !.envs/.local/ + +# VScode +.vscode From 94b86966777d6c2afe3340f6343327b0fb5ee8c9 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sat, 16 Apr 2022 22:46:50 +0200 Subject: [PATCH 04/90] feat: create initial user model --- api/users/models.py | 38 +++++++++++++++++++++++--------------- api/users/validators.py | 9 +++++++++ 2 files changed, 32 insertions(+), 15 deletions(-) create mode 100644 api/users/validators.py diff --git a/api/users/models.py b/api/users/models.py index b802bc7..d261a72 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -1,26 +1,34 @@ 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 .validators import is_phone 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) + class Meta: + db_table = "users" + verbose_name = "user" + verbose_name_plural = "users" + first_name = None # type: ignore last_name = None # type: ignore - def get_absolute_url(self): - """Get url for user's detail view. + full_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) + id_photo_url = models.ImageField(upload_to="id-photos/") - Returns: - str: URL for user detail. + firebase_token = models.CharField(max_length=256, unique=True, blank=True) - """ + def renew_id(self, days: int = 365) -> None: + self.id_exp_date = timezone.now() + timezone.timedelta(days=days) + + def get_absolute_url(self) -> str: return reverse("users:detail", kwargs={"username": self.username}) + + def __str__(self) -> str: + return self.username diff --git a/api/users/validators.py b/api/users/validators.py new file mode 100644 index 0000000..76ff011 --- /dev/null +++ b/api/users/validators.py @@ -0,0 +1,9 @@ +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'"{val}" is not a valid nuber') From 9b301907c1c50c702c29edec4cbb2af7a62262ae Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sat, 16 Apr 2022 23:22:48 +0200 Subject: [PATCH 05/90] chore: create locations app --- api/locations/__init__.py | 1 + api/locations/admin.py | 1 + api/locations/apps.py | 6 ++++++ api/locations/migrations/__init__.py | 1 + api/locations/models.py | 1 + api/locations/tests.py | 1 + api/locations/views.py | 1 + config/settings/base.py | 2 +- 8 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 api/locations/__init__.py create mode 100644 api/locations/admin.py create mode 100644 api/locations/apps.py create mode 100644 api/locations/migrations/__init__.py create mode 100644 api/locations/models.py create mode 100644 api/locations/tests.py create mode 100644 api/locations/views.py 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..aad9415 --- /dev/null +++ b/api/locations/admin.py @@ -0,0 +1 @@ +# Admin diff --git a/api/locations/apps.py b/api/locations/apps.py new file mode 100644 index 0000000..f69f4f5 --- /dev/null +++ b/api/locations/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LoctionsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "loctions" 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..9136172 --- /dev/null +++ b/api/locations/models.py @@ -0,0 +1 @@ +# Models 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/views.py b/api/locations/views.py new file mode 100644 index 0000000..5873694 --- /dev/null +++ b/api/locations/views.py @@ -0,0 +1 @@ +# Views diff --git a/config/settings/base.py b/config/settings/base.py index 6e04cc6..f1e9d91 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -80,7 +80,7 @@ LOCAL_APPS = [ "api.users", - # Your stuff: custom apps go here + "api.locations", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS From 7adf5a341c4e212806ce486e3a28d1f02bc17eb6 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 17 Apr 2022 00:50:46 +0200 Subject: [PATCH 06/90] feat: create initial locations models --- api/locations/models.py | 30 +++++++++++++++++++++++++++++- api/users/models.py | 10 +++++----- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/api/locations/models.py b/api/locations/models.py index 9136172..ba89c36 100644 --- a/api/locations/models.py +++ b/api/locations/models.py @@ -1 +1,29 @@ -# Models +from django.db import models + + +class City(models.Model): + name_ar = models.CharField(max_length=64) + name_en = models.CharField(max_length=64) + gov = models.ForeignKey(on_delete=models.CASCADE, related_name="cities") + + def __str__(self): + return self.name_en + + +class Governorate(models.Model): + name_ar = models.CharField(max_length=64) + name_en = models.CharField(max_length=64) + + def __str__(self): + return self.name_en + + +class Location(models.Model): + lon = models.DecimalField() + lat = models.DecimalField() + address = models.CharField(max_length=512, blank=True) + + class Meta: + db_table = "locations" + verbose_name = "location" + verbose_name_plural = "locations" diff --git a/api/users/models.py b/api/users/models.py index d261a72..666a92c 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -7,11 +7,6 @@ class User(AbstractUser): - class Meta: - db_table = "users" - verbose_name = "user" - verbose_name_plural = "users" - first_name = None # type: ignore last_name = None # type: ignore @@ -24,6 +19,11 @@ class Meta: firebase_token = models.CharField(max_length=256, unique=True, blank=True) + class Meta: + db_table = "users" + verbose_name = "user" + verbose_name_plural = "users" + def renew_id(self, days: int = 365) -> None: self.id_exp_date = timezone.now() + timezone.timedelta(days=days) From 120a72425c8a9b1300beecb804f309152a667762 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 17 Apr 2022 00:51:46 +0200 Subject: [PATCH 07/90] feat: add govs and cities data --- api/locations/cities.json | 2435 +++++++++++++++++++++++++++++++++++++ api/locations/govs.json | 140 +++ 2 files changed, 2575 insertions(+) create mode 100644 api/locations/cities.json create mode 100644 api/locations/govs.json 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" + } + ] +} From 974fd8fc64b77fcb49ef230a90bb92dd6bdc125e Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 17 Apr 2022 01:31:55 +0200 Subject: [PATCH 08/90] feat: add gov-city db population service --- api/locations/models.py | 16 ++++++++++++++++ api/locations/services.py | 37 +++++++++++++++++++++++++++++++++++++ api/locations/utils.py | 12 ++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 api/locations/services.py create mode 100644 api/locations/utils.py diff --git a/api/locations/models.py b/api/locations/models.py index ba89c36..55e87cd 100644 --- a/api/locations/models.py +++ b/api/locations/models.py @@ -6,6 +6,11 @@ class City(models.Model): name_en = models.CharField(max_length=64) gov = models.ForeignKey(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 @@ -14,6 +19,11 @@ 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 @@ -23,7 +33,13 @@ class Location(models.Model): lat = models.DecimalField() address = models.CharField(max_length=512, blank=True) + gov = models.ForeignKey(Governorate, on_delete=models.PROTECT) + city = models.ForeignKey(City, on_delete=models.PROTECT) + class Meta: db_table = "locations" verbose_name = "location" verbose_name_plural = "locations" + + def __str__(self): + return f"" diff --git a/api/locations/services.py b/api/locations/services.py new file mode 100644 index 0000000..7e3be12 --- /dev/null +++ b/api/locations/services.py @@ -0,0 +1,37 @@ +from pathlib import Path + +from .models import City, Governorate +from .utils import read_json + +cwd = Path.cwd + + +def populate_govs() -> None: + """ + Populates governorates and cities tables + """ + # Read json files + govs_dict = read_json(cwd / "govs.json") + cities_dict = read_json(cwd / "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( + pk=city["id"], + name_ar=city["city_name_ar"], + name_en=city["city_name_en"], + ) + c.save() 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 From 5abf6cca8b7e780391adf1a0bdf8a5ae5cb8dc59 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 17 Apr 2022 02:38:40 +0200 Subject: [PATCH 09/90] feat: add user create service --- api/locations/models.py | 9 +++++++-- api/locations/services.py | 19 ++++++++++++++++++- api/users/models.py | 15 ++++++++++----- api/users/services.py | 15 +++++++++++++++ 4 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 api/users/services.py diff --git a/api/locations/models.py b/api/locations/models.py index 55e87cd..49b61c2 100644 --- a/api/locations/models.py +++ b/api/locations/models.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.db import models @@ -29,13 +30,17 @@ def __str__(self): class Location(models.Model): - lon = models.DecimalField() - lat = models.DecimalField() + lon = models.DecimalField(max_digits=9, decimal_places=6, null=True) + lat = models.DecimalField(max_digits=8, decimal_places=6, null=True) address = models.CharField(max_length=512, 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" diff --git a/api/locations/services.py b/api/locations/services.py index 7e3be12..e9232ab 100644 --- a/api/locations/services.py +++ b/api/locations/services.py @@ -1,6 +1,7 @@ from pathlib import Path +from typing import Optional -from .models import City, Governorate +from .models import City, Governorate, Location from .utils import read_json cwd = Path.cwd @@ -35,3 +36,19 @@ def populate_govs() -> None: name_en=city["city_name_en"], ) c.save() + + +def create_location( + *, + lon: Optional[float], + lat: Optional[float], + address: Optional[str], + gov: Governorate, + city: City +) -> Location: + + loc = Location(lon=lon, lat=lat, address=address, gov=gov, city=city) + loc.full_clean() + loc.save() + + return loc diff --git a/api/users/models.py b/api/users/models.py index 666a92c..3375295 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -1,8 +1,8 @@ from django.contrib.auth.models import AbstractUser from django.db import models -from django.urls import reverse from django.utils import timezone +from ..locations.models import Location from .validators import is_phone @@ -15,20 +15,25 @@ class User(AbstractUser): username = models.CharField(max_length=10, unique=True, validators=[is_phone]) id_exp_date = models.DateTimeField(null=True, blank=True) - id_photo_url = models.ImageField(upload_to="id-photos/") + id_photo_url = models.ImageField(upload_to="id-photos/", blank=True) 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 > timezone.now() + def renew_id(self, days: int = 365) -> None: self.id_exp_date = timezone.now() + timezone.timedelta(days=days) - def get_absolute_url(self) -> str: - return reverse("users:detail", kwargs={"username": self.username}) - def __str__(self) -> str: return self.username diff --git a/api/users/services.py b/api/users/services.py new file mode 100644 index 0000000..92d2afb --- /dev/null +++ b/api/users/services.py @@ -0,0 +1,15 @@ +from typing import Optional + +from ..locations.models import Location +from .models import User + + +def create_user( + *, full_name: str, username: str, email: Optional[str], location: Location +) -> User: + + user = User(full_name=full_name, username=username, email=email, location=location) + user.full_clean() + user.save() + + return user From d3dc2c4ff2a36ad8d574c9581d1b9faf474056b0 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 17 Apr 2022 02:50:40 +0200 Subject: [PATCH 10/90] refactor: move auth app inside api/ --- {auth => api/auth}/__init__.py | 0 {auth => api/auth}/admin.py | 0 {auth => api/auth}/apps.py | 0 {auth => api/auth}/migrations/__init__.py | 0 {auth => api/auth}/models.py | 0 {auth => api/auth}/tests.py | 0 {auth => api/auth}/views.py | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename {auth => api/auth}/__init__.py (100%) rename {auth => api/auth}/admin.py (100%) rename {auth => api/auth}/apps.py (100%) rename {auth => api/auth}/migrations/__init__.py (100%) rename {auth => api/auth}/models.py (100%) rename {auth => api/auth}/tests.py (100%) rename {auth => api/auth}/views.py (100%) diff --git a/auth/__init__.py b/api/auth/__init__.py similarity index 100% rename from auth/__init__.py rename to api/auth/__init__.py diff --git a/auth/admin.py b/api/auth/admin.py similarity index 100% rename from auth/admin.py rename to api/auth/admin.py diff --git a/auth/apps.py b/api/auth/apps.py similarity index 100% rename from auth/apps.py rename to api/auth/apps.py diff --git a/auth/migrations/__init__.py b/api/auth/migrations/__init__.py similarity index 100% rename from auth/migrations/__init__.py rename to api/auth/migrations/__init__.py diff --git a/auth/models.py b/api/auth/models.py similarity index 100% rename from auth/models.py rename to api/auth/models.py diff --git a/auth/tests.py b/api/auth/tests.py similarity index 100% rename from auth/tests.py rename to api/auth/tests.py diff --git a/auth/views.py b/api/auth/views.py similarity index 100% rename from auth/views.py rename to api/auth/views.py From e5332fda4b81e8d1e3c6c72d22389eb80f0a53ff Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sun, 17 Apr 2022 02:52:18 +0200 Subject: [PATCH 11/90] feat: Implement Case model --- api/cases/__init__.py | 0 api/cases/admin.py | 3 + api/cases/apps.py | 6 ++ api/cases/migrations/__init__.py | 0 api/cases/models.py | 102 +++++++++++++++++++++++++++++++ api/cases/tests.py | 3 + api/cases/views.py | 3 + requirements/base.txt | 2 + 8 files changed, 119 insertions(+) create mode 100644 api/cases/__init__.py create mode 100644 api/cases/admin.py create mode 100644 api/cases/apps.py create mode 100644 api/cases/migrations/__init__.py create mode 100644 api/cases/models.py create mode 100644 api/cases/tests.py create mode 100644 api/cases/views.py 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..4185d36 --- /dev/null +++ b/api/cases/admin.py @@ -0,0 +1,3 @@ +# from django.contrib import admin + +# Register your models here. diff --git a/api/cases/apps.py b/api/cases/apps.py new file mode 100644 index 0000000..e9ea9ff --- /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 = "cases" 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..72508d5 --- /dev/null +++ b/api/cases/models.py @@ -0,0 +1,102 @@ +import datetime + +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_fsm import FSMField, transition +from users.models import User + +from .services import case_matching_binding, process_case + + +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") + state = FSMField( + max_length=2, + choices=States.choices, + default=States.PENDING, + editable=False, + ) + matches = models.ManyToManyField("self", blank=True) + 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) + + @property + def photo_urls(self): + return self.photos.values_list("url", flat=True) + + 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): + matches = process_case(self) + case_matching_binding(matches) + 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 = datetime.now() + + +class CaseDetails(models.Model): + class Gender(models.TextChoices): + MALE = "M", _("Male") + FEMALE = "F", _("Female") + UNKNOWN = "U", _("Unknown") + + name = models.CharField(max_length=128, blank=True, null=True) + gender = models.CharField(max_length=1, choices=Gender.choices) + age = models.SmallIntegerField(null=True, default=None, blank=True) + last_seen = models.DateTimeField(null=True, default=None, blank=True) + description = models.TextField(blank=True, null=True) + case = models.OneToOneField(Case, on_delete=models.CASCADE) + + +class CaseMatch(models.Model): + missing = models.ForeignKey(Case, on_delete=models.CASCADE) + found = models.ForeignKey(Case, on_delete=models.CASCADE) + score = models.SmallIntegerField( + validators=[MaxValueValidator(100), MinValueValidator(1)] + ) + + +class CasePhoto(models.Model): + url = models.URLField() + case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="photos") 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/views.py b/api/cases/views.py new file mode 100644 index 0000000..fd0e044 --- /dev/null +++ b/api/cases/views.py @@ -0,0 +1,3 @@ +# from django.shortcuts import render + +# Create your views here. diff --git a/requirements/base.txt b/requirements/base.txt index 335f1a9..8cd49ce 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -23,3 +23,5 @@ djangorestframework==3.13.1 # https://github.com/encode/django-rest-framework django-cors-headers==3.11.0 # https://github.com/adamchainz/django-cors-headers # 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 From ed92a3dc41328cc086824ceda76d9e8ea1c166b1 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sun, 17 Apr 2022 02:52:56 +0200 Subject: [PATCH 12/90] feat: Implement Case services --- api/cases/services.py | 91 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 api/cases/services.py diff --git a/api/cases/services.py b/api/cases/services.py new file mode 100644 index 0000000..1100fa0 --- /dev/null +++ b/api/cases/services.py @@ -0,0 +1,91 @@ +from datetime import date +from typing import Dict, List, Optional + +from django.db import transaction + +from api.users.models import User + +from .models import Case, CaseDetails, CaseMatch, CasePhoto + +Gender = CaseDetails.Gender +CaseType = Case.Types + + +def create_case_photo(*, case: Case, url: str) -> CasePhoto: + photo = CasePhoto(case=case, url=url) + photo.full_clean() + photo.save() + + return photo + + +@transaction.atomic +def create_case(*, type: CaseType, user: User, photos_urls: List[str]) -> Case: + case = Case(type=type, user=user) + case.full_clean() + case.save() + + for url in photos_urls: + create_case_photo(case=case, url=url) + + return 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, +) -> CaseDetails: + + case_details = CaseDetails( + case=case, + name=name, + gender=gender, + age=age, + last_seen=last_seen, + description=description, + ) + + 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 + """ + ... + + +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 + """ + 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 + + 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) From 07d909bc25dd6da96665d71bf72ba13c5c615f01 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 17 Apr 2022 02:58:57 +0200 Subject: [PATCH 13/90] fix: add cases app to local apps --- config/settings/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/config/settings/base.py b/config/settings/base.py index f1e9d91..6f75992 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -81,6 +81,7 @@ LOCAL_APPS = [ "api.users", "api.locations", + "api.cases", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS From 1e78ea02e2eac032d2bdc79c9fd5e3304c766537 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 17 Apr 2022 03:02:20 +0200 Subject: [PATCH 14/90] fix: locations typo --- api/locations/apps.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/locations/apps.py b/api/locations/apps.py index f69f4f5..caad8f9 100644 --- a/api/locations/apps.py +++ b/api/locations/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class LoctionsConfig(AppConfig): +class LocationsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "loctions" + name = "locations" From 35e28d26a18a55a5f1d31a128f466e43290a15dd Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 17 Apr 2022 03:09:57 +0200 Subject: [PATCH 15/90] fix: add api namespace to app names --- api/cases/apps.py | 2 +- api/locations/apps.py | 2 +- api/locations/models.py | 20 +++++++++++--------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/api/cases/apps.py b/api/cases/apps.py index e9ea9ff..65a4208 100644 --- a/api/cases/apps.py +++ b/api/cases/apps.py @@ -3,4 +3,4 @@ class CasesConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "cases" + name = "api.cases" diff --git a/api/locations/apps.py b/api/locations/apps.py index caad8f9..9a16cf7 100644 --- a/api/locations/apps.py +++ b/api/locations/apps.py @@ -3,4 +3,4 @@ class LocationsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "locations" + name = "api.locations" diff --git a/api/locations/models.py b/api/locations/models.py index 49b61c2..4708ae0 100644 --- a/api/locations/models.py +++ b/api/locations/models.py @@ -2,28 +2,30 @@ from django.db import models -class City(models.Model): +class Governorate(models.Model): name_ar = models.CharField(max_length=64) name_en = models.CharField(max_length=64) - gov = models.ForeignKey(on_delete=models.CASCADE, related_name="cities") class Meta: - db_table = "cities" - verbose_name = "city" - verbose_name_plural = "cities" + db_table = "governorates" + verbose_name = "governorate" + verbose_name_plural = "governorates" def __str__(self): return self.name_en -class Governorate(models.Model): +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 = "governorates" - verbose_name = "governorate" - verbose_name_plural = "governorates" + db_table = "cities" + verbose_name = "city" + verbose_name_plural = "cities" def __str__(self): return self.name_en From fc8d6ebd525f06943fd871cf9be728978e6ea31a Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 17 Apr 2022 03:12:38 +0200 Subject: [PATCH 16/90] fix: rquirements typo --- requirements/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/base.txt b/requirements/base.txt index 8cd49ce..d61033c 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -24,4 +24,4 @@ django-cors-headers==3.11.0 # https://github.com/adamchainz/django-cors-headers # 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 +django-fsm==2.8.0 # https://github.com/viewflow/django-fsm From fee730d090fc5e661416148a79c1fb4a6e2bc70d Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 17 Apr 2022 03:19:49 +0200 Subject: [PATCH 17/90] fix: circular import --- api/cases/models.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/api/cases/models.py b/api/cases/models.py index 72508d5..1b165b9 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -4,9 +4,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from django_fsm import FSMField, transition -from users.models import User -from .services import case_matching_binding, process_case +from ..users.models import User class Case(models.Model): @@ -27,7 +26,7 @@ class Types(models.TextChoices): FOUND = "F", _("Found") type = models.CharField(max_length=1, choices=Types.choices) - user = models.ForeignKey(User, related_name="cases") + user = models.ForeignKey(User, related_name="cases", on_delete=models.CASCADE) state = FSMField( max_length=2, choices=States.choices, @@ -50,6 +49,8 @@ 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 From 6fbdc72521e5081fdbcd25237e5671584bdb82c6 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sun, 17 Apr 2022 22:35:44 +0200 Subject: [PATCH 18/90] feat: add location attribute to case model --- api/cases/models.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/api/cases/models.py b/api/cases/models.py index 72508d5..36f04c1 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -4,9 +4,9 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from django_fsm import FSMField, transition -from users.models import User -from .services import case_matching_binding, process_case +from ..locations.models import Location +from ..users.models import User class Case(models.Model): @@ -34,7 +34,7 @@ class Types(models.TextChoices): default=States.PENDING, editable=False, ) - matches = models.ManyToManyField("self", blank=True) + 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) @@ -50,6 +50,8 @@ 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 @@ -81,17 +83,18 @@ class Gender(models.TextChoices): FEMALE = "F", _("Female") UNKNOWN = "U", _("Unknown") + case = models.OneToOneField(Case, on_delete=models.CASCADE) name = models.CharField(max_length=128, blank=True, null=True) gender = models.CharField(max_length=1, choices=Gender.choices) age = models.SmallIntegerField(null=True, default=None, blank=True) + location = models.OneToOneField(Location, on_delete=models.CASCADE) last_seen = models.DateTimeField(null=True, default=None, blank=True) description = models.TextField(blank=True, null=True) - case = models.OneToOneField(Case, on_delete=models.CASCADE) class CaseMatch(models.Model): - missing = models.ForeignKey(Case, on_delete=models.CASCADE) - found = models.ForeignKey(Case, on_delete=models.CASCADE) + case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="matches") + match = models.ForeignKey(Case, on_delete=models.CASCADE) score = models.SmallIntegerField( validators=[MaxValueValidator(100), MinValueValidator(1)] ) From 796ad07af646b3e06c91957f6c59d27749ed6242 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 17 Apr 2022 22:43:29 +0200 Subject: [PATCH 19/90] fix: admin page typo --- api/users/admin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/users/admin.py b/api/users/admin.py index 27ae319..ff2c148 100644 --- a/api/users/admin.py +++ b/api/users/admin.py @@ -30,5 +30,5 @@ class UserAdmin(auth_admin.UserAdmin): ), (_("Important dates"), {"fields": ("last_login", "date_joined")}), ) - list_display = ["username", "name", "is_superuser"] - search_fields = ["name"] + list_display = ["username", "full_name", "is_superuser"] + search_fields = ["full_name"] From a631c03cb6d7cca958f346e29d72d839ed4cd4ef Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 17 Apr 2022 22:55:39 +0200 Subject: [PATCH 20/90] feat: initial database migration --- api/cases/migrations/0001_initial.py | 70 +++++++++++++++++++ api/locations/migrations/0001_initial.py | 62 ++++++++++++++++ .../migrations/0002_auto_20220417_2053.py | 60 ++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 api/cases/migrations/0001_initial.py create mode 100644 api/locations/migrations/0001_initial.py create mode 100644 api/users/migrations/0002_auto_20220417_2053.py diff --git a/api/cases/migrations/0001_initial.py b/api/cases/migrations/0001_initial.py new file mode 100644 index 0000000..fafe618 --- /dev/null +++ b/api/cases/migrations/0001_initial.py @@ -0,0 +1,70 @@ +# Generated by Django 3.2.13 on 2022-04-17 20:53 + +from django.conf import settings +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 = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('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')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cases', to=settings.AUTH_USER_MODEL)), + ], + 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/locations/migrations/0001_initial.py b/api/locations/migrations/0001_initial.py new file mode 100644 index 0000000..d8c67a3 --- /dev/null +++ b/api/locations/migrations/0001_initial.py @@ -0,0 +1,62 @@ +# Generated by Django 3.2.13 on 2022-04-17 20:53 + +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(decimal_places=6, max_digits=9, null=True)), + ('lat', models.DecimalField(decimal_places=6, max_digits=8, null=True)), + ('address', models.CharField(blank=True, max_length=512)), + ('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/users/migrations/0002_auto_20220417_2053.py b/api/users/migrations/0002_auto_20220417_2053.py new file mode 100644 index 0000000..76de889 --- /dev/null +++ b/api/users/migrations/0002_auto_20220417_2053.py @@ -0,0 +1,60 @@ +# Generated by Django 3.2.13 on 2022-04-17 20:53 + +import api.users.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('locations', '0001_initial'), + ('users', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='user', + name='name', + ), + migrations.AddField( + model_name='user', + name='firebase_token', + field=models.CharField(blank=True, max_length=256, unique=True), + ), + migrations.AddField( + model_name='user', + name='full_name', + field=models.CharField(default='Osama Yasser', max_length=256), + preserve_default=False, + ), + migrations.AddField( + model_name='user', + name='id_exp_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='user', + name='id_photo_url', + field=models.ImageField(blank=True, upload_to='id-photos/'), + ), + migrations.AddField( + model_name='user', + name='location', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user', to='locations.location'), + ), + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(blank=True, max_length=254, null=True), + ), + migrations.AlterField( + model_name='user', + name='username', + field=models.CharField(max_length=10, unique=True, validators=[api.users.validators.is_phone]), + ), + migrations.AlterModelTable( + name='user', + table='users', + ), + ] From 27eed05815516f71994d33d6afc31373837c5342 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sun, 17 Apr 2022 22:59:39 +0200 Subject: [PATCH 21/90] feat: add cases app models to admin site --- api/cases/admin.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/cases/admin.py b/api/cases/admin.py index 4185d36..4c40641 100644 --- a/api/cases/admin.py +++ b/api/cases/admin.py @@ -1,3 +1,5 @@ -# from django.contrib import admin +from django.contrib import admin -# Register your models here. +from .models import Case, CaseDetails, CaseMatch, CasePhoto + +admin.site.register((Case, CaseDetails, CaseMatch, CasePhoto)) From 3aaeba601f55c0f7bf76ff90fd2b6cd0f1aa341b Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 17 Apr 2022 23:10:52 +0200 Subject: [PATCH 22/90] feat: add locations app models to admin site --- api/locations/admin.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/api/locations/admin.py b/api/locations/admin.py index aad9415..1b802cc 100644 --- a/api/locations/admin.py +++ b/api/locations/admin.py @@ -1 +1,23 @@ -# Admin +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",) From 619987c9a99f68be554e21fae2f1c7e63c1adef0 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 17 Apr 2022 23:54:56 +0200 Subject: [PATCH 23/90] chore: update requirements folder --- requirements/base.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements/base.txt b/requirements/base.txt index d61033c..e932e6a 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -21,6 +21,8 @@ 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 +dj-rest-auth==2.2.4 # https://dj-rest-auth.readthedocs.io/en/latest/index.html +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 From 0816e2e1113f7cc65e8844495ef4b7a8f474e23d Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Mon, 18 Apr 2022 00:50:00 +0200 Subject: [PATCH 24/90] fix: json paths --- api/locations/services.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/api/locations/services.py b/api/locations/services.py index e9232ab..29ea47c 100644 --- a/api/locations/services.py +++ b/api/locations/services.py @@ -1,10 +1,11 @@ -from pathlib import Path from typing import Optional +from django.conf import settings + from .models import City, Governorate, Location from .utils import read_json -cwd = Path.cwd +apps_dir = settings.APPS_DIR def populate_govs() -> None: @@ -12,8 +13,8 @@ def populate_govs() -> None: Populates governorates and cities tables """ # Read json files - govs_dict = read_json(cwd / "govs.json") - cities_dict = read_json(cwd / "cities.json") + 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() @@ -31,6 +32,7 @@ def populate_govs() -> None: # 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"], From a7b64e45dc4360ac453cc6e4a9d52fdc74198915 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Mon, 18 Apr 2022 01:15:00 +0200 Subject: [PATCH 25/90] feat: add governorate list selector --- api/locations/selectors.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 api/locations/selectors.py diff --git a/api/locations/selectors.py b/api/locations/selectors.py new file mode 100644 index 0000000..ffc3552 --- /dev/null +++ b/api/locations/selectors.py @@ -0,0 +1,11 @@ +from typing import Iterable + +from .models import City, Governorate + + +def list_governorate() -> Iterable[Governorate]: + return Governorate.objects.all() + + +def list_cities() -> Iterable[City]: + return City.objects.all() From 2e1de07be74e4b58ab026047fb82b53da46bce29 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Mon, 18 Apr 2022 01:16:43 +0200 Subject: [PATCH 26/90] chore: add dj-rest-auth lib --- config/settings/base.py | 7 +++++++ config/urls.py | 2 ++ 2 files changed, 9 insertions(+) diff --git a/config/settings/base.py b/config/settings/base.py index 8c8c941..ac085cb 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -76,6 +76,8 @@ "rest_framework.authtoken", "corsheaders", "drf_spectacular", + "dj_rest_auth", + "dj_rest_auth.registration", ] LOCAL_APPS = [ @@ -305,11 +307,16 @@ "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.TokenAuthentication", + "dj_rest_auth.jwt_auth.JWTCookieAuthentication", ), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", } +# JWT config +REST_USE_JWT = True +JWT_AUTH_COOKIE = "my-app-auth" + # django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup CORS_URLS_REGEX = r"^/api/.*$" diff --git a/config/urls.py b/config/urls.py index f5fa0c4..2a28d70 100644 --- a/config/urls.py +++ b/config/urls.py @@ -28,6 +28,8 @@ urlpatterns += [ # API base url path("api/", include("config.api_router")), + path("dj-rest-auth/", include("dj_rest_auth.urls")), + path("dj-rest-auth/registration/", include("dj_rest_auth.registration.urls")), # DRF auth token path("auth-token/", obtain_auth_token), path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), From 9892c81e92e752dbc0b7896d4829011d0acaf367 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Mon, 18 Apr 2022 01:39:29 +0200 Subject: [PATCH 27/90] fix: add None as default value for lon & lat --- api/locations/services.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/locations/services.py b/api/locations/services.py index 29ea47c..2adbb43 100644 --- a/api/locations/services.py +++ b/api/locations/services.py @@ -42,9 +42,9 @@ def populate_govs() -> None: def create_location( *, - lon: Optional[float], - lat: Optional[float], - address: Optional[str], + lon: Optional[float] = None, + lat: Optional[float] = None, + address: Optional[str] = None, gov: Governorate, city: City ) -> Location: From c5e47a5c8880313f9bb3001fd2531558ab64e139 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Mon, 18 Apr 2022 01:45:48 +0200 Subject: [PATCH 28/90] fix: add firebase token to create user service --- api/users/services.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/api/users/services.py b/api/users/services.py index 92d2afb..4431ecf 100644 --- a/api/users/services.py +++ b/api/users/services.py @@ -5,10 +5,23 @@ def create_user( - *, full_name: str, username: str, email: Optional[str], location: Location + *, + full_name: str, + username: str, + password: str, + email: Optional[str] = None, + firebase_token: Optional[str], + location: Location ) -> User: - user = User(full_name=full_name, username=username, email=email, location=location) + user = User( + full_name=full_name, + username=username, + email=email, + firebase_token=firebase_token, + location=location, + ) + user.set_password(password) user.full_clean() user.save() From 612641ca6a1782a59487aee1f39465963be03913 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Mon, 18 Apr 2022 01:53:01 +0200 Subject: [PATCH 29/90] feat: implement user registration --- api/auth/serializers.py | 51 +++++++++++++++++++++++++++++++++++++++++ config/settings/base.py | 4 ++++ 2 files changed, 55 insertions(+) create mode 100644 api/auth/serializers.py diff --git a/api/auth/serializers.py b/api/auth/serializers.py new file mode 100644 index 0000000..c85e318 --- /dev/null +++ b/api/auth/serializers.py @@ -0,0 +1,51 @@ +from typing import Dict + +from dj_rest_auth.registration.serializers import RegisterSerializer +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from ..locations.models import City, Governorate +from ..locations.services import create_location +from ..users.models import User +from ..users.services import create_user + + +class RegisterSerializer(RegisterSerializer): + password2 = None + + full_name = serializers.CharField() + gov_id = serializers.IntegerField(write_only=True) + city_id = serializers.IntegerField(write_only=True) + firebase_token = serializers.CharField(write_only=True) + + def validate(self, data: Dict): + return data + + def validate_gov_id(self, id: int) -> int: + if not Governorate.objects.filter(pk=id).exists(): + raise serializers.ValidationError(_("Invalid governorate id")) + + return id + + def validate_city_id(self, id: int) -> int: + if not City.objects.filter(pk=id).exists(): + raise serializers.ValidationError(_("Invalid city id")) + + return id + + def save(self) -> User: + gov = Governorate.objects.get(pk=self.validated_data["gov_id"]) + city = City.objects.get(pk=self.validated_data["city_id"]) + + location = create_location(gov=gov, city=city) + + user = create_user( + username=self.validated_data["username"], + password=self.validated_data["password"], + full_name=self.validated_data["full_name"], + email=self.validated_data["email"], + firebase_token=self.validated_data["firebase_token"], + location=location, + ) + + return user diff --git a/config/settings/base.py b/config/settings/base.py index ac085cb..fdd1394 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -317,6 +317,10 @@ REST_USE_JWT = True JWT_AUTH_COOKIE = "my-app-auth" +REST_AUTH_REGISTER_SERIALIZERS = { + "REGISTER_SERIALIZER": "api.auth.serializers.RegisterSerializer" +} + # django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup CORS_URLS_REGEX = r"^/api/.*$" From 6a191ad0b2bacde27d0198499ddbcd39b3a22122 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Mon, 18 Apr 2022 02:18:27 +0200 Subject: [PATCH 30/90] chore: rename auth app to authentication --- api/{auth => authentication}/__init__.py | 0 api/{auth => authentication}/admin.py | 0 api/{auth => authentication}/apps.py | 2 +- api/{auth => authentication}/migrations/__init__.py | 0 api/{auth => authentication}/models.py | 0 api/{auth => authentication}/serializers.py | 3 +++ api/{auth => authentication}/tests.py | 0 api/{auth => authentication}/views.py | 0 config/settings/base.py | 4 ++-- 9 files changed, 6 insertions(+), 3 deletions(-) rename api/{auth => authentication}/__init__.py (100%) rename api/{auth => authentication}/admin.py (100%) rename api/{auth => authentication}/apps.py (79%) rename api/{auth => authentication}/migrations/__init__.py (100%) rename api/{auth => authentication}/models.py (100%) rename api/{auth => authentication}/serializers.py (93%) rename api/{auth => authentication}/tests.py (100%) rename api/{auth => authentication}/views.py (100%) diff --git a/api/auth/__init__.py b/api/authentication/__init__.py similarity index 100% rename from api/auth/__init__.py rename to api/authentication/__init__.py diff --git a/api/auth/admin.py b/api/authentication/admin.py similarity index 100% rename from api/auth/admin.py rename to api/authentication/admin.py diff --git a/api/auth/apps.py b/api/authentication/apps.py similarity index 79% rename from api/auth/apps.py rename to api/authentication/apps.py index 6a09aaa..3b48b5c 100644 --- a/api/auth/apps.py +++ b/api/authentication/apps.py @@ -3,4 +3,4 @@ class AuthConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "auth" + name = "api.authentication" diff --git a/api/auth/migrations/__init__.py b/api/authentication/migrations/__init__.py similarity index 100% rename from api/auth/migrations/__init__.py rename to api/authentication/migrations/__init__.py diff --git a/api/auth/models.py b/api/authentication/models.py similarity index 100% rename from api/auth/models.py rename to api/authentication/models.py diff --git a/api/auth/serializers.py b/api/authentication/serializers.py similarity index 93% rename from api/auth/serializers.py rename to api/authentication/serializers.py index c85e318..2ed9057 100644 --- a/api/auth/serializers.py +++ b/api/authentication/serializers.py @@ -11,8 +11,11 @@ class RegisterSerializer(RegisterSerializer): + password1 = None password2 = None + email = serializers.EmailField(required=False) + password = serializers.CharField(write_only=True) full_name = serializers.CharField() gov_id = serializers.IntegerField(write_only=True) city_id = serializers.IntegerField(write_only=True) diff --git a/api/auth/tests.py b/api/authentication/tests.py similarity index 100% rename from api/auth/tests.py rename to api/authentication/tests.py diff --git a/api/auth/views.py b/api/authentication/views.py similarity index 100% rename from api/auth/views.py rename to api/authentication/views.py diff --git a/config/settings/base.py b/config/settings/base.py index fdd1394..a1684b1 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -82,7 +82,7 @@ LOCAL_APPS = [ "api.users", - "api.auth", + "api.authentication", "api.locations", "api.cases", ] @@ -318,7 +318,7 @@ JWT_AUTH_COOKIE = "my-app-auth" REST_AUTH_REGISTER_SERIALIZERS = { - "REGISTER_SERIALIZER": "api.auth.serializers.RegisterSerializer" + "REGISTER_SERIALIZER": "api.authentication.serializers.RegisterSerializer" } # django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup From cc16528ab306916d24bc23f909aabd1d573f9197 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Mon, 18 Apr 2022 02:31:15 +0200 Subject: [PATCH 31/90] fix: RegisterSerializer fields --- api/authentication/serializers.py | 3 ++- .../migrations/0002_auto_20220418_0026.py | 23 +++++++++++++++++++ api/locations/models.py | 4 ++-- 3 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 api/locations/migrations/0002_auto_20220418_0026.py diff --git a/api/authentication/serializers.py b/api/authentication/serializers.py index 2ed9057..72972e7 100644 --- a/api/authentication/serializers.py +++ b/api/authentication/serializers.py @@ -1,6 +1,7 @@ from typing import Dict from dj_rest_auth.registration.serializers import RegisterSerializer +from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -36,7 +37,7 @@ def validate_city_id(self, id: int) -> int: return id - def save(self) -> User: + def save(self, request: HttpRequest) -> User: gov = Governorate.objects.get(pk=self.validated_data["gov_id"]) city = City.objects.get(pk=self.validated_data["city_id"]) 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/models.py b/api/locations/models.py index 4708ae0..8139729 100644 --- a/api/locations/models.py +++ b/api/locations/models.py @@ -32,8 +32,8 @@ def __str__(self): class Location(models.Model): - lon = models.DecimalField(max_digits=9, decimal_places=6, null=True) - lat = models.DecimalField(max_digits=8, decimal_places=6, null=True) + 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, blank=True) gov = models.ForeignKey(Governorate, on_delete=models.PROTECT) From 10742a9d519bf7433b13b9a0edfd40d03bcead1f Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Mon, 18 Apr 2022 02:34:19 +0200 Subject: [PATCH 32/90] fix: location clean logic --- api/locations/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/locations/models.py b/api/locations/models.py index 4708ae0..c8c05c8 100644 --- a/api/locations/models.py +++ b/api/locations/models.py @@ -1,5 +1,5 @@ -from django.core.exceptions import ValidationError from django.db import models +from rest_framework.exceptions import ValidationError class Governorate(models.Model): @@ -40,7 +40,7 @@ class Location(models.Model): city = models.ForeignKey(City, on_delete=models.PROTECT) def clean(self): - if self.city.gov == self.gov: + if self.city.gov != self.gov: raise ValidationError("City does not belong to Governorate") class Meta: From d6df20c70f55355c0eafea886224115194f0f006 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Mon, 18 Apr 2022 02:39:41 +0200 Subject: [PATCH 33/90] fix: modify address to accpet null --- api/locations/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/locations/models.py b/api/locations/models.py index c8c05c8..2ec7ce8 100644 --- a/api/locations/models.py +++ b/api/locations/models.py @@ -32,9 +32,9 @@ def __str__(self): class Location(models.Model): - lon = models.DecimalField(max_digits=9, decimal_places=6, null=True) - lat = models.DecimalField(max_digits=8, decimal_places=6, null=True) - address = models.CharField(max_length=512, blank=True) + 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) From 198caa6b6d06a75500d8d662f34ecba291d478ab Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Tue, 19 Apr 2022 19:27:15 +0200 Subject: [PATCH 34/90] feat: create custom error handler --- api/errors/__init__.py | 1 + api/errors/apps.py | 6 ++++ api/errors/handlers.py | 48 +++++++++++++++++++++++++++++++ api/errors/migrations/__init__.py | 0 api/errors/tests.py | 1 + api/errors/views.py | 1 + config/settings/base.py | 2 ++ 7 files changed, 59 insertions(+) create mode 100644 api/errors/__init__.py create mode 100644 api/errors/apps.py create mode 100644 api/errors/handlers.py create mode 100644 api/errors/migrations/__init__.py create mode 100644 api/errors/tests.py create mode 100644 api/errors/views.py 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..a7d1cb5 --- /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 = "errors" diff --git a/api/errors/handlers.py b/api/errors/handlers.py new file mode 100644 index 0000000..3fc0911 --- /dev/null +++ b/api/errors/handlers.py @@ -0,0 +1,48 @@ +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 + + if isinstance(exc.detail, (list, dict)): + response.data["detail"] = response.data + response.data["message"] = "Validation error" + else: + response.data["detail"] = {} + response.data["message"] = exc.detail + + response.data["status_code"] = response.status_code + + 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/config/settings/base.py b/config/settings/base.py index 6f75992..a12409a 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -82,6 +82,7 @@ "api.users", "api.locations", "api.cases", + "api.errors", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS @@ -307,6 +308,7 @@ ), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "EXCEPTION_HANDLER": "api.errors.handlers.custom_exception_handler", } # django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup From 1737adfd67eb54d9d9c20355317fe6eadd904d8f Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Tue, 19 Apr 2022 23:36:36 +0200 Subject: [PATCH 35/90] fix: update token auth --- api/authentication/serializers.py | 17 ++++-- .../migrations/0003_alter_location_address.py | 18 ++++++ .../migrations/0002_auto_20220417_2053.py | 60 ------------------- config/settings/base.py | 2 +- config/urls.py | 15 ++++- 5 files changed, 43 insertions(+), 69 deletions(-) create mode 100644 api/locations/migrations/0003_alter_location_address.py delete mode 100644 api/users/migrations/0002_auto_20220417_2053.py diff --git a/api/authentication/serializers.py b/api/authentication/serializers.py index 72972e7..a8cc2fd 100644 --- a/api/authentication/serializers.py +++ b/api/authentication/serializers.py @@ -1,6 +1,7 @@ from typing import Dict from dj_rest_auth.registration.serializers import RegisterSerializer +from dj_rest_auth.serializers import JWTSerializer from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -17,7 +18,7 @@ class RegisterSerializer(RegisterSerializer): email = serializers.EmailField(required=False) password = serializers.CharField(write_only=True) - full_name = serializers.CharField() + name = serializers.CharField() gov_id = serializers.IntegerField(write_only=True) city_id = serializers.IntegerField(write_only=True) firebase_token = serializers.CharField(write_only=True) @@ -44,12 +45,16 @@ def save(self, request: HttpRequest) -> User: location = create_location(gov=gov, city=city) user = create_user( - username=self.validated_data["username"], - password=self.validated_data["password"], - full_name=self.validated_data["full_name"], - email=self.validated_data["email"], - firebase_token=self.validated_data["firebase_token"], + username=self.validated_data.get("username"), + password=self.validated_data.get("password"), + name=self.validated_data.get("name"), + email=self.validated_data.get("email"), + firebase_token=self.validated_data.get("firebase_token"), location=location, ) return user + + +class JWTSerializer(JWTSerializer): + ... 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/users/migrations/0002_auto_20220417_2053.py b/api/users/migrations/0002_auto_20220417_2053.py deleted file mode 100644 index 76de889..0000000 --- a/api/users/migrations/0002_auto_20220417_2053.py +++ /dev/null @@ -1,60 +0,0 @@ -# Generated by Django 3.2.13 on 2022-04-17 20:53 - -import api.users.validators -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('locations', '0001_initial'), - ('users', '0001_initial'), - ] - - operations = [ - migrations.RemoveField( - model_name='user', - name='name', - ), - migrations.AddField( - model_name='user', - name='firebase_token', - field=models.CharField(blank=True, max_length=256, unique=True), - ), - migrations.AddField( - model_name='user', - name='full_name', - field=models.CharField(default='Osama Yasser', max_length=256), - preserve_default=False, - ), - migrations.AddField( - model_name='user', - name='id_exp_date', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name='user', - name='id_photo_url', - field=models.ImageField(blank=True, upload_to='id-photos/'), - ), - migrations.AddField( - model_name='user', - name='location', - field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user', to='locations.location'), - ), - migrations.AlterField( - model_name='user', - name='email', - field=models.EmailField(blank=True, max_length=254, null=True), - ), - migrations.AlterField( - model_name='user', - name='username', - field=models.CharField(max_length=10, unique=True, validators=[api.users.validators.is_phone]), - ), - migrations.AlterModelTable( - name='user', - table='users', - ), - ] diff --git a/config/settings/base.py b/config/settings/base.py index a1684b1..06e948e 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -290,7 +290,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 diff --git a/config/urls.py b/config/urls.py index 2a28d70..dd18ee3 100644 --- a/config/urls.py +++ b/config/urls.py @@ -7,6 +7,13 @@ from django.views.generic import TemplateView from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from rest_framework.authtoken.views import obtain_auth_token +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenVerifyView, +) + +from api.users.apis import CreateUserApi urlpatterns = [ path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), @@ -28,8 +35,12 @@ urlpatterns += [ # API base url path("api/", include("config.api_router")), - path("dj-rest-auth/", include("dj_rest_auth.urls")), - path("dj-rest-auth/registration/", include("dj_rest_auth.registration.urls")), + # path("dj-rest-auth/", include("dj_rest_auth.urls")), + # path("dj-rest-auth/registration/", include("dj_rest_auth.registration.urls")), + path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"), + path("api/user/", CreateUserApi.as_view(), name="user_createeee"), # DRF auth token path("auth-token/", obtain_auth_token), path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), From a4a13065e736b349325d10753ea2e21c7cdbd0cf Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Tue, 19 Apr 2022 23:40:43 +0200 Subject: [PATCH 36/90] feat: implement user registration --- api/users/admin.py | 4 +- api/users/api/views.py | 2 +- api/users/apis.py | 27 ++++++ api/users/migrations/0001_initial.py | 130 ++++++--------------------- api/users/models.py | 2 +- api/users/services.py | 17 ++-- api/users/validators.py | 2 +- 7 files changed, 71 insertions(+), 113 deletions(-) create mode 100644 api/users/apis.py diff --git a/api/users/admin.py b/api/users/admin.py index ff2c148..27ae319 100644 --- a/api/users/admin.py +++ b/api/users/admin.py @@ -30,5 +30,5 @@ class UserAdmin(auth_admin.UserAdmin): ), (_("Important dates"), {"fields": ("last_login", "date_joined")}), ) - list_display = ["username", "full_name", "is_superuser"] - search_fields = ["full_name"] + list_display = ["username", "name", "is_superuser"] + search_fields = ["name"] diff --git a/api/users/api/views.py b/api/users/api/views.py index 98bb04e..a0103fb 100644 --- a/api/users/api/views.py +++ b/api/users/api/views.py @@ -16,7 +16,7 @@ class UserViewSet(RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericV lookup_field = "username" def get_queryset(self, *args, **kwargs): - assert isinstance(self.request.user.id, int) + # assert isinstance(self.request.user.id, int) return self.queryset.filter(id=self.request.user.id) @action(detail=False) diff --git a/api/users/apis.py b/api/users/apis.py new file mode 100644 index 0000000..b84e3f5 --- /dev/null +++ b/api/users/apis.py @@ -0,0 +1,27 @@ +from rest_framework import permissions, serializers, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.users.services import create_user + + +class CreateUserApi(APIView): + permission_classes = [permissions.AllowAny] + + 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() + firebase_token = serializers.CharField() + + def post(self, request): + print(request.data) + serializer = self.InputSerializer(data=request.data) + serializer.is_valid() + + create_user(**serializer.validated_data) + + return Response(status=status.HTTP_201_CREATED) diff --git a/api/users/migrations/0001_initial.py b/api/users/migrations/0001_initial.py index 4c4d695..b41861e 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-04-19 15:32 + +import api.users.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"), + ('auth', '0012_alter_user_first_name_max_length'), + ('locations', '0003_alter_location_address'), ] 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')), + ('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)), + ('email', models.EmailField(blank=True, max_length=254, null=True)), + ('username', models.CharField(max_length=10, unique=True, validators=[api.users.validators.is_phone])), + ('id_exp_date', models.DateTimeField(blank=True, null=True)), + ('id_photo_url', models.ImageField(blank=True, upload_to='id-photos/')), + ('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 3375295..5fe870f 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -10,7 +10,7 @@ class User(AbstractUser): first_name = None # type: ignore last_name = None # type: ignore - full_name = models.CharField(max_length=256) + name = models.CharField(max_length=256) email = models.EmailField(null=True, blank=True) username = models.CharField(max_length=10, unique=True, validators=[is_phone]) diff --git a/api/users/services.py b/api/users/services.py index 4431ecf..f10be65 100644 --- a/api/users/services.py +++ b/api/users/services.py @@ -1,28 +1,35 @@ from typing import Optional +from django.db import transaction + from ..locations.models import Location +from ..locations.services import create_location from .models import User +@transaction.atomic def create_user( *, - full_name: str, + name: str, username: str, password: str, email: Optional[str] = None, firebase_token: Optional[str], - location: Location + gov_id: int, + city_id: int, ) -> User: - user = User( - full_name=full_name, + loc: Location = create_location(gov_id=gov_id, city_id=city_id) + user: User = User( + name=name, username=username, email=email, + location=loc, firebase_token=firebase_token, - location=location, ) user.set_password(password) user.full_clean() + # user.clean() user.save() return user diff --git a/api/users/validators.py b/api/users/validators.py index 76ff011..c7d8d9e 100644 --- a/api/users/validators.py +++ b/api/users/validators.py @@ -6,4 +6,4 @@ def is_phone(val: str): Validates a phone number """ if not val.isnumeric() or len(val) != 10: - raise ValidationError(f'"{val}" is not a valid nuber') + raise ValidationError(f'"{val}" is not a valid number') From 507a8df69ea07a3bf76d637ec2127fea6e9a73e9 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Tue, 19 Apr 2022 23:43:03 +0200 Subject: [PATCH 37/90] refactor: create_location service --- api/locations/services.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/locations/services.py b/api/locations/services.py index 2adbb43..ef7fe85 100644 --- a/api/locations/services.py +++ b/api/locations/services.py @@ -45,12 +45,15 @@ def create_location( lon: Optional[float] = None, lat: Optional[float] = None, address: Optional[str] = None, - gov: Governorate, - city: City + gov_id: int, + city_id: 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() return loc From f08643794eb857838115c6c18a13c79c0054b779 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Wed, 20 Apr 2022 00:27:56 +0200 Subject: [PATCH 38/90] refactor: migrate to simpleJWT --- api/authentication/serializers.py | 60 ------------------------------- config/settings/base.py | 3 -- config/urls.py | 2 -- requirements/base.txt | 1 - 4 files changed, 66 deletions(-) delete mode 100644 api/authentication/serializers.py diff --git a/api/authentication/serializers.py b/api/authentication/serializers.py deleted file mode 100644 index a8cc2fd..0000000 --- a/api/authentication/serializers.py +++ /dev/null @@ -1,60 +0,0 @@ -from typing import Dict - -from dj_rest_auth.registration.serializers import RegisterSerializer -from dj_rest_auth.serializers import JWTSerializer -from django.http import HttpRequest -from django.utils.translation import gettext_lazy as _ -from rest_framework import serializers - -from ..locations.models import City, Governorate -from ..locations.services import create_location -from ..users.models import User -from ..users.services import create_user - - -class RegisterSerializer(RegisterSerializer): - password1 = None - password2 = None - - email = serializers.EmailField(required=False) - password = serializers.CharField(write_only=True) - name = serializers.CharField() - gov_id = serializers.IntegerField(write_only=True) - city_id = serializers.IntegerField(write_only=True) - firebase_token = serializers.CharField(write_only=True) - - def validate(self, data: Dict): - return data - - def validate_gov_id(self, id: int) -> int: - if not Governorate.objects.filter(pk=id).exists(): - raise serializers.ValidationError(_("Invalid governorate id")) - - return id - - def validate_city_id(self, id: int) -> int: - if not City.objects.filter(pk=id).exists(): - raise serializers.ValidationError(_("Invalid city id")) - - return id - - def save(self, request: HttpRequest) -> User: - gov = Governorate.objects.get(pk=self.validated_data["gov_id"]) - city = City.objects.get(pk=self.validated_data["city_id"]) - - location = create_location(gov=gov, city=city) - - user = create_user( - username=self.validated_data.get("username"), - password=self.validated_data.get("password"), - name=self.validated_data.get("name"), - email=self.validated_data.get("email"), - firebase_token=self.validated_data.get("firebase_token"), - location=location, - ) - - return user - - -class JWTSerializer(JWTSerializer): - ... diff --git a/config/settings/base.py b/config/settings/base.py index 824d0ae..4d2e483 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -319,9 +319,6 @@ REST_USE_JWT = True JWT_AUTH_COOKIE = "my-app-auth" -REST_AUTH_REGISTER_SERIALIZERS = { - "REGISTER_SERIALIZER": "api.authentication.serializers.RegisterSerializer" -} # django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup CORS_URLS_REGEX = r"^/api/.*$" diff --git a/config/urls.py b/config/urls.py index dd18ee3..1c6af56 100644 --- a/config/urls.py +++ b/config/urls.py @@ -35,8 +35,6 @@ urlpatterns += [ # API base url path("api/", include("config.api_router")), - # path("dj-rest-auth/", include("dj_rest_auth.urls")), - # path("dj-rest-auth/registration/", include("dj_rest_auth.registration.urls")), path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"), diff --git a/requirements/base.txt b/requirements/base.txt index e932e6a..19b80d0 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -21,7 +21,6 @@ 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 -dj-rest-auth==2.2.4 # https://dj-rest-auth.readthedocs.io/en/latest/index.html 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 From 9ffb86be14605049641b1b832d61ce0b57136eda Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Wed, 20 Apr 2022 00:29:46 +0200 Subject: [PATCH 39/90] fix: CreateUserApi --- api/users/api/views.py | 2 +- api/users/apis.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/api/users/api/views.py b/api/users/api/views.py index a0103fb..98bb04e 100644 --- a/api/users/api/views.py +++ b/api/users/api/views.py @@ -16,7 +16,7 @@ class UserViewSet(RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericV lookup_field = "username" def get_queryset(self, *args, **kwargs): - # assert isinstance(self.request.user.id, int) + assert isinstance(self.request.user.id, int) return self.queryset.filter(id=self.request.user.id) @action(detail=False) diff --git a/api/users/apis.py b/api/users/apis.py index b84e3f5..eec689e 100644 --- a/api/users/apis.py +++ b/api/users/apis.py @@ -18,10 +18,8 @@ class InputSerializer(serializers.Serializer): firebase_token = serializers.CharField() def post(self, request): - print(request.data) serializer = self.InputSerializer(data=request.data) - serializer.is_valid() - + serializer.is_valid(raise_exception=True) create_user(**serializer.validated_data) return Response(status=status.HTTP_201_CREATED) From aa00cfadaf845681d1a3243b13fd30f1b1bf3a43 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Wed, 20 Apr 2022 00:35:02 +0200 Subject: [PATCH 40/90] fix: circural referance --- api/errors/apps.py | 2 +- api/errors/handlers.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/api/errors/apps.py b/api/errors/apps.py index a7d1cb5..78987c2 100644 --- a/api/errors/apps.py +++ b/api/errors/apps.py @@ -3,4 +3,4 @@ class ErrorsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "errors" + name = "api.errors" diff --git a/api/errors/handlers.py b/api/errors/handlers.py index 3fc0911..d94ebb3 100644 --- a/api/errors/handlers.py +++ b/api/errors/handlers.py @@ -36,13 +36,15 @@ def custom_exception_handler(exc: Exception, ctx: Dict) -> Response: if response is None: return response + response_body = {} if isinstance(exc.detail, (list, dict)): - response.data["detail"] = response.data - response.data["message"] = "Validation error" + response_body["detail"] = response.data + response_body["message"] = "Validation error" else: - response.data["detail"] = {} - response.data["message"] = exc.detail + response_body["detail"] = {} + response_body["message"] = exc.detail - response.data["status_code"] = response.status_code + response_body["status_code"] = response.status_code + response.data = response_body return response From 7352c079352ec5c93ea34a25f96d62501eb4c05a Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Wed, 20 Apr 2022 00:40:30 +0200 Subject: [PATCH 41/90] chore: apply new model migrations --- api/cases/migrations/0001_initial.py | 5 +---- api/cases/migrations/0002_case_user.py | 23 +++++++++++++++++++++++ api/locations/migrations/0001_initial.py | 8 ++++---- api/users/migrations/0001_initial.py | 4 ++-- 4 files changed, 30 insertions(+), 10 deletions(-) create mode 100644 api/cases/migrations/0002_case_user.py diff --git a/api/cases/migrations/0001_initial.py b/api/cases/migrations/0001_initial.py index fafe618..25e5c87 100644 --- a/api/cases/migrations/0001_initial.py +++ b/api/cases/migrations/0001_initial.py @@ -1,6 +1,5 @@ -# Generated by Django 3.2.13 on 2022-04-17 20:53 +# Generated by Django 3.2.13 on 2022-04-19 22:39 -from django.conf import settings import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -12,7 +11,6 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('locations', '0001_initial'), ] @@ -28,7 +26,6 @@ class Migration(migrations.Migration): ('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')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cases', to=settings.AUTH_USER_MODEL)), ], options={ 'verbose_name': 'case', 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/locations/migrations/0001_initial.py b/api/locations/migrations/0001_initial.py index d8c67a3..952ac21 100644 --- a/api/locations/migrations/0001_initial.py +++ b/api/locations/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.13 on 2022-04-17 20:53 +# Generated by Django 3.2.13 on 2022-04-19 22:39 from django.db import migrations, models import django.db.models.deletion @@ -42,9 +42,9 @@ class Migration(migrations.Migration): name='Location', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('lon', models.DecimalField(decimal_places=6, max_digits=9, null=True)), - ('lat', models.DecimalField(decimal_places=6, max_digits=8, null=True)), - ('address', models.CharField(blank=True, max_length=512)), + ('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')), ], diff --git a/api/users/migrations/0001_initial.py b/api/users/migrations/0001_initial.py index b41861e..03e3c3a 100644 --- a/api/users/migrations/0001_initial.py +++ b/api/users/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.13 on 2022-04-19 15:32 +# Generated by Django 3.2.13 on 2022-04-19 22:39 import api.users.validators import django.contrib.auth.models @@ -12,8 +12,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('locations', '0001_initial'), ('auth', '0012_alter_user_first_name_max_length'), - ('locations', '0003_alter_location_address'), ] operations = [ From 5f730136a37fa57cda347176a690923b935de709 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Wed, 20 Apr 2022 01:08:00 +0200 Subject: [PATCH 42/90] fix: remove dj-rest-auth from installed apps --- config/settings/base.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/config/settings/base.py b/config/settings/base.py index 4d2e483..41422e8 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -76,8 +76,6 @@ "rest_framework.authtoken", "corsheaders", "drf_spectacular", - "dj_rest_auth", - "dj_rest_auth.registration", ] LOCAL_APPS = [ @@ -308,7 +306,7 @@ "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.TokenAuthentication", - "dj_rest_auth.jwt_auth.JWTCookieAuthentication", + "rest_framework_simplejwt.authentication.JWTAuthentication", ), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", From 0e21f5d2c6bb1a25588561b41a565b6967ef19ad Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Thu, 21 Apr 2022 01:30:44 +0200 Subject: [PATCH 43/90] feat: add common tools --- api/common/__init__.py | 0 api/common/apps.py | 6 ++++ api/common/migrations/__init__.py | 0 api/common/models.py | 10 ++++++ api/common/services.py | 45 +++++++++++++++++++++++ api/common/types.py | 7 ++++ api/common/utils.py | 60 +++++++++++++++++++++++++++++++ 7 files changed, 128 insertions(+) create mode 100644 api/common/__init__.py create mode 100644 api/common/apps.py create mode 100644 api/common/migrations/__init__.py create mode 100644 api/common/models.py create mode 100644 api/common/services.py create mode 100644 api/common/types.py create mode 100644 api/common/utils.py 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/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 From 0850ecced248daab901fed7739d2cb80239b25eb Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Thu, 21 Apr 2022 01:51:10 +0200 Subject: [PATCH 44/90] refactor: case services --- api/cases/services.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/api/cases/services.py b/api/cases/services.py index 1100fa0..1eff9ec 100644 --- a/api/cases/services.py +++ b/api/cases/services.py @@ -3,6 +3,8 @@ from django.db import transaction +from api.locations.models import Location +from api.locations.services import create_location from api.users.models import User from .models import Case, CaseDetails, CaseMatch, CasePhoto @@ -20,14 +22,25 @@ def create_case_photo(*, case: Case, url: str) -> CasePhoto: @transaction.atomic -def create_case(*, type: CaseType, user: User, photos_urls: List[str]) -> Case: - case = Case(type=type, user=user) +def create_case( + *, + type: CaseType, + user: User, + location_data: Dict, + details_data: Dict, + photos_urls: List[str], +) -> Case: + location: Location = create_location(**location_data) + case = Case(type=type, user=user, location=location) + case.full_clean() case.save() for url in photos_urls: create_case_photo(case=case, url=url) + create_case_details(case=case, **details_data) + return case From 7e2d5a324e843b34d1b0c68e994f8cb737874e4d Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Thu, 21 Apr 2022 02:28:29 +0200 Subject: [PATCH 45/90] feat: add custom permissions --- api/common/permissions.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 api/common/permissions.py 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 From 2dd6f161f64e2963cd10198afd2d29992c6671c0 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Thu, 21 Apr 2022 03:57:56 +0200 Subject: [PATCH 46/90] refactor: CaseDetails model --- api/cases/models.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/api/cases/models.py b/api/cases/models.py index b0b7e28..738a50a 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -84,12 +84,14 @@ class Gender(models.TextChoices): UNKNOWN = "U", _("Unknown") case = models.OneToOneField(Case, on_delete=models.CASCADE) - name = models.CharField(max_length=128, blank=True, null=True) + name = models.CharField(max_length=128, null=True, blank=True) gender = models.CharField(max_length=1, choices=Gender.choices) - age = models.SmallIntegerField(null=True, default=None, blank=True) - location = models.OneToOneField(Location, on_delete=models.CASCADE) - last_seen = models.DateTimeField(null=True, default=None, blank=True) - description = models.TextField(blank=True, null=True) + age = models.SmallIntegerField(null=True, blank=True) + location = models.OneToOneField( + Location, on_delete=models.CASCADE, null=True, blank=True + ) + last_seen = models.DateTimeField(null=True, blank=True) + description = models.TextField(null=True, blank=True) class CaseMatch(models.Model): From 191919cecc0ad8cd0741b5cfee8c4b751ff255ea Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Thu, 21 Apr 2022 03:58:25 +0200 Subject: [PATCH 47/90] refactor: case services --- .../migrations/0003_auto_20220421_0014.py | 30 +++++++++++++++++++ api/cases/services.py | 17 ++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 api/cases/migrations/0003_auto_20220421_0014.py 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/services.py b/api/cases/services.py index 1eff9ec..d0bb982 100644 --- a/api/cases/services.py +++ b/api/cases/services.py @@ -26,11 +26,11 @@ def create_case( *, type: CaseType, user: User, - location_data: Dict, - details_data: Dict, + location: Dict, + details: Dict, photos_urls: List[str], ) -> Case: - location: Location = create_location(**location_data) + location: Location = create_location(**location) case = Case(type=type, user=user, location=location) case.full_clean() @@ -39,11 +39,15 @@ def create_case( for url in photos_urls: create_case_photo(case=case, url=url) - create_case_details(case=case, **details_data) + create_case_details(case=case, **details) return case +def update_case(): + ... + + def create_case_details( *, case: Case, @@ -52,7 +56,11 @@ def create_case_details( 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, @@ -61,6 +69,7 @@ def create_case_details( age=age, last_seen=last_seen, description=description, + location=loc, ) case_details.full_clean() From aa9dc340d5310358e15e520b2edd3ab7e9dd6027 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Thu, 21 Apr 2022 03:59:16 +0200 Subject: [PATCH 48/90] feat: implement case create view --- api/cases/apis.py | 70 ++++++++++++++++++++++++++++++++++++++++++++++ api/cases/views.py | 3 -- config/urls.py | 6 +++- 3 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 api/cases/apis.py delete mode 100644 api/cases/views.py diff --git a/api/cases/apis.py b/api/cases/apis.py new file mode 100644 index 0000000..2d5d4f8 --- /dev/null +++ b/api/cases/apis.py @@ -0,0 +1,70 @@ +from rest_framework import permissions, serializers, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.cases.services import create_case +from api.common.utils import inline_serializer +from api.users.models import User + + +class CreateCaseApi(APIView): + permission_classes = [permissions.AllowAny] + + class InputSerializer(serializers.Serializer): + type = serializers.CharField() + photos_urls = serializers.ListField(child=serializers.URLField()) + location = inline_serializer( + fields={ + "gov_id": serializers.IntegerField(), + "city_id": serializers.IntegerField(), + "lon": serializers.DecimalField( + max_digits=9, decimal_places=6, required=False + ), + "lat": serializers.DecimalField( + max_digits=8, decimal_places=6, required=False + ), + "address": serializers.CharField(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=User.objects.all()[0], **serializer.validated_data) + + return Response(status=status.HTTP_201_CREATED) + + +class UpdateCaseApi(APIView): + permission_classes = [permissions.AllowAny] + + class InputSerializer(serializers.Serializer): + photos_urls = serializers.ListField( + child=serializers.URLField(), required=False + ) + location = inline_serializer( + fields={ + "gov_id": serializers.IntegerField(required=False), + "city_id": serializers.IntegerField(required=False), + "lon": serializers.DecimalField( + max_digits=9, decimal_places=6, required=False + ), + "lat": serializers.DecimalField( + max_digits=8, decimal_places=6, required=False + ), + "address": serializers.CharField(required=False), + } + ) diff --git a/api/cases/views.py b/api/cases/views.py deleted file mode 100644 index fd0e044..0000000 --- a/api/cases/views.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.shortcuts import render - -# Create your views here. diff --git a/config/urls.py b/config/urls.py index 1c6af56..9bad624 100644 --- a/config/urls.py +++ b/config/urls.py @@ -13,6 +13,7 @@ TokenVerifyView, ) +from api.cases.apis import CreateCaseApi from api.users.apis import CreateUserApi urlpatterns = [ @@ -38,9 +39,12 @@ path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"), - path("api/user/", CreateUserApi.as_view(), name="user_createeee"), + # TODO Pull out + path("api/user/", CreateUserApi.as_view(), name="user_create"), + path("api/cases/", CreateCaseApi.as_view(), name="case_create"), # DRF auth token path("auth-token/", obtain_auth_token), + # Docs path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), path( "api/docs/", From 390f5193a3f8dbe9a36689ce61df2995d24b3e7f Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Fri, 22 Apr 2022 23:56:44 +0200 Subject: [PATCH 49/90] feat: s3 direct upload --- .envs/.local/.django | 5 + .gitignore | 3 + api/files/__init__.py | 1 + api/files/admin.py | 73 ++++++++ api/files/apps.py | 6 + api/files/enums.py | 11 ++ api/files/migrations/0001_initial.py | 33 ++++ api/files/migrations/__init__.py | 1 + api/files/models.py | 46 +++++ api/files/services.py | 165 ++++++++++++++++++ api/files/tests.py | 1 + api/files/urls.py | 47 +++++ api/files/utils.py | 27 +++ api/files/views.py | 63 +++++++ api/integrations/__init__.py | 0 api/integrations/apps.py | 6 + api/integrations/aws/__init__.py | 1 + api/integrations/aws/client.py | 98 +++++++++++ .../0002_remove_user_id_photo_url.py | 17 ++ api/users/models.py | 5 +- config/env.py | 14 ++ config/settings/base.py | 42 ++++- requirements/base.txt | 1 + 23 files changed, 662 insertions(+), 4 deletions(-) create mode 100644 api/files/__init__.py create mode 100644 api/files/admin.py create mode 100644 api/files/apps.py create mode 100644 api/files/enums.py create mode 100644 api/files/migrations/0001_initial.py create mode 100644 api/files/migrations/__init__.py create mode 100644 api/files/models.py create mode 100644 api/files/services.py create mode 100644 api/files/tests.py create mode 100644 api/files/urls.py create mode 100644 api/files/utils.py create mode 100644 api/files/views.py create mode 100644 api/integrations/__init__.py create mode 100644 api/integrations/apps.py create mode 100644 api/integrations/aws/__init__.py create mode 100644 api/integrations/aws/client.py create mode 100644 api/users/migrations/0002_remove_user_id_photo_url.py create mode 100644 config/env.py diff --git a/.envs/.local/.django b/.envs/.local/.django index 247287b..41f6ca1 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,7 @@ REDIS_URL=redis://redis:6379/0 # Flower CELERY_FLOWER_USER=debug CELERY_FLOWER_PASSWORD=debug + +# Files +# ------------------------------------------------------------------------------ +FILE_UPLOAD_STORAGE="local" diff --git a/.gitignore b/.gitignore index 36740c0..16c9eec 100644 --- a/.gitignore +++ b/.gitignore @@ -280,3 +280,6 @@ api/media/ # VScode .vscode + +# Media files +media 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..8222d85 --- /dev/null +++ b/api/files/urls.py @@ -0,0 +1,47 @@ +from django.urls import include, path + +from api.files.views import ( + FileDirectUploadFinishApi, + FileDirectUploadLocalApi, + FileDirectUploadStartApi, + FileStandardUploadApi, +) + +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/users/migrations/0002_remove_user_id_photo_url.py b/api/users/migrations/0002_remove_user_id_photo_url.py new file mode 100644 index 0000000..fe85649 --- /dev/null +++ b/api/users/migrations/0002_remove_user_id_photo_url.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.13 on 2022-04-21 21:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='user', + name='id_photo_url', + ), + ] diff --git a/api/users/models.py b/api/users/models.py index 5fe870f..791613a 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -2,8 +2,8 @@ from django.db import models from django.utils import timezone -from ..locations.models import Location -from .validators import is_phone +from api.locations.models import Location +from api.users.validators import is_phone class User(AbstractUser): @@ -15,7 +15,6 @@ class User(AbstractUser): username = models.CharField(max_length=10, unique=True, validators=[is_phone]) id_exp_date = models.DateTimeField(null=True, blank=True) - id_photo_url = models.ImageField(upload_to="id-photos/", blank=True) firebase_token = models.CharField(max_length=256, unique=True, blank=True) 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 41422e8..80fff10 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -1,10 +1,14 @@ """ Base settings to build other settings files upon. """ +import os from pathlib import Path import environ +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/ APPS_DIR = ROOT_DIR / "api" @@ -84,6 +88,9 @@ "api.locations", "api.cases", "api.errors", + "api.common", + "api.files", + "api.integrations", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS @@ -317,6 +324,7 @@ 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/.*$" @@ -333,5 +341,37 @@ {"url": "https://mafqud.com", "description": "Production server"}, ], } -# Your stuff... +# 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/requirements/base.txt b/requirements/base.txt index 19b80d0..4e3e5d8 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -26,3 +26,4 @@ djangorestframework-simplejwt==5.1.0 # https://django-rest-framework-simplejwt.r 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 From 898f6d0cb2944a4698e0741ec685153db5865c86 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 24 Apr 2022 01:36:05 +0200 Subject: [PATCH 50/90] feat: implement user detail view --- api/users/admin.py | 19 +++++++++++++------ api/users/apis.py | 34 ++++++++++++++++++++++++++++++++++ api/users/forms.py | 3 +-- api/users/selectors.py | 7 +++++++ config/urls.py | 3 ++- 5 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 api/users/selectors.py diff --git a/api/users/admin.py b/api/users/admin.py index 27ae319..33614eb 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")}), ( _("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 index eec689e..3294857 100644 --- a/api/users/apis.py +++ b/api/users/apis.py @@ -2,6 +2,9 @@ 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.users.services import create_user @@ -23,3 +26,34 @@ def post(self, request): create_user(**serializer.validated_data) return Response(status=status.HTTP_201_CREATED) + + +class DetailUserApi(APIView): + permission_classes = [IsVerified] + + 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(), + } + ), + } + ) + + def get(self, request, user_id): + user = get_object(User, id=user_id) + serializer = self.OutputSerializer(user) + return Response(serializer.data) 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/selectors.py b/api/users/selectors.py new file mode 100644 index 0000000..dc67fcd --- /dev/null +++ b/api/users/selectors.py @@ -0,0 +1,7 @@ +from django.shortcuts import get_object_or_404 + +from api.users.models import User + + +def get_user(*, user_id: int) -> User: + return get_object_or_404(User, pk=user_id) diff --git a/config/urls.py b/config/urls.py index 1c6af56..b11ac0f 100644 --- a/config/urls.py +++ b/config/urls.py @@ -13,7 +13,7 @@ TokenVerifyView, ) -from api.users.apis import CreateUserApi +from api.users.apis import CreateUserApi, DetailUserApi urlpatterns = [ path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), @@ -39,6 +39,7 @@ path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"), path("api/user/", CreateUserApi.as_view(), name="user_createeee"), + path("api/user/", DetailUserApi.as_view(), name="user_detail"), # DRF auth token path("auth-token/", obtain_auth_token), path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), From bcfb922268565d965254422c7763c66cf8ed1779 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 24 Apr 2022 03:05:40 +0200 Subject: [PATCH 51/90] feat: implement user update view --- api/users/apis.py | 21 +++++++++++++++++++- api/users/services.py | 46 +++++++++++++++++++++++++++++++++++++++---- config/urls.py | 3 ++- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/api/users/apis.py b/api/users/apis.py index 3294857..0ec076e 100644 --- a/api/users/apis.py +++ b/api/users/apis.py @@ -5,7 +5,7 @@ from api.common.permissions import IsVerified from api.common.utils import get_object, inline_serializer from api.users.models import User -from api.users.services import create_user +from api.users.services import create_user, update_user class CreateUserApi(APIView): @@ -57,3 +57,22 @@ def get(self, request, user_id): user = get_object(User, id=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) + email = serializers.CharField(required=False) + gov_id = serializers.IntegerField(required=False) + city_id = serializers.IntegerField(required=False) + + def post(self, request, user_id): + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + update_user( + user_id=user_id, + data=serializer.validated_data, + ) + return Response(status=status.HTTP_200_OK) diff --git a/api/users/services.py b/api/users/services.py index f10be65..6f79e2e 100644 --- a/api/users/services.py +++ b/api/users/services.py @@ -1,10 +1,12 @@ -from typing import Optional +from typing import Dict, Optional from django.db import transaction +from django.shortcuts import get_object_or_404 -from ..locations.models import Location -from ..locations.services import create_location -from .models import User +from api.common.services import model_update +from api.locations.models import Location +from api.locations.services import create_location +from api.users.models import User @transaction.atomic @@ -33,3 +35,39 @@ def create_user( user.save() return user + + +@transaction.atomic +def update_user( + *, + user_id: int, + data: Dict, +) -> User: + fields = ["name", "email", "location"] + + user = get_object_or_404(User, pk=user_id) + old_location = user.location + + location_updated = False + city_id = data.get("city_id") + + if (city_id is not None) and (user.location.city.id != city_id): + location = create_location( + gov_id=data.get("gov_id"), + city_id=data.get("city_id"), + ) + data["location"] = location + + location_updated = True + + user, has_updated = model_update( + instance=user, + fields=fields, + data=data, + ) + + # Clean old location + if location_updated: + old_location.delete() + + return user, has_updated diff --git a/config/urls.py b/config/urls.py index b11ac0f..bb87ab0 100644 --- a/config/urls.py +++ b/config/urls.py @@ -13,7 +13,7 @@ TokenVerifyView, ) -from api.users.apis import CreateUserApi, DetailUserApi +from api.users.apis import CreateUserApi, DetailUserApi, UpdateUserApi urlpatterns = [ path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), @@ -40,6 +40,7 @@ path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"), path("api/user/", CreateUserApi.as_view(), name="user_createeee"), path("api/user/", DetailUserApi.as_view(), name="user_detail"), + path("api/user//update", UpdateUserApi.as_view(), name="user_update"), # DRF auth token path("auth-token/", obtain_auth_token), path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), From 9f914c1ed8777dfa427af3d9902247195174c0ae Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 24 Apr 2022 03:31:22 +0200 Subject: [PATCH 52/90] feat: implement update location service --- api/locations/services.py | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/api/locations/services.py b/api/locations/services.py index ef7fe85..f3a8675 100644 --- a/api/locations/services.py +++ b/api/locations/services.py @@ -1,9 +1,11 @@ -from typing import Optional +from typing import Dict, Optional from django.conf import settings +from django.shortcuts import get_object_or_404 -from .models import City, Governorate, Location -from .utils import read_json +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 @@ -46,7 +48,7 @@ def create_location( lat: Optional[float] = None, address: Optional[str] = None, gov_id: int, - city_id: int + city_id: int, ) -> Location: gov = Governorate.objects.get(pk=gov_id) @@ -57,3 +59,29 @@ def create_location( loc.save() return loc + + +def update_location( + *, + location_id: int, + data: Dict, +) -> Location: + + fields = ["lon", "lat", "address", "gov", "city"] + location = get_object_or_404(Location, id=location_id) + + city_id = data.get("city_id") + if city_id is not None and city_id != location.city.id: + city = get_object_or_404(City, city_id) + gov = get_object_or_404(Governorate, city.gov.id) + + data["gov"] = gov + data["city"] = city + + location, has_updated = model_update( + intance=location, + fields=fields, + data=data, + ) + + return location From 0ec293bfcc3aaf854c3e9fed36cf9ea6b056a331 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 24 Apr 2022 04:05:05 +0200 Subject: [PATCH 53/90] refactor: update user location in place --- api/locations/services.py | 21 +++++++++++++-------- api/users/services.py | 29 ++++++++++++----------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/api/locations/services.py b/api/locations/services.py index f3a8675..61defde 100644 --- a/api/locations/services.py +++ b/api/locations/services.py @@ -2,6 +2,7 @@ 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 @@ -68,18 +69,22 @@ def update_location( ) -> Location: fields = ["lon", "lat", "address", "gov", "city"] - location = get_object_or_404(Location, id=location_id) + location = get_object_or_404(Location, pk=location_id) + gov_id = data.get("gov_id") city_id = data.get("city_id") - if city_id is not None and city_id != location.city.id: - city = get_object_or_404(City, city_id) - gov = get_object_or_404(Governorate, city.gov.id) - data["gov"] = gov - data["city"] = city + gov = get_object_or_404(Governorate, pk=gov_id) + city = get_object_or_404(City, pk=city_id) - location, has_updated = model_update( - intance=location, + if city.gov != gov: + raise ValidationError("City does not belong to Governorate") + + data["gov"] = gov + data["city"] = city + + location, _ = model_update( + instance=location, fields=fields, data=data, ) diff --git a/api/users/services.py b/api/users/services.py index 6f79e2e..eb1c6ab 100644 --- a/api/users/services.py +++ b/api/users/services.py @@ -5,7 +5,7 @@ from api.common.services import model_update from api.locations.models import Location -from api.locations.services import create_location +from api.locations.services import create_location, update_location from api.users.models import User @@ -43,31 +43,26 @@ def update_user( user_id: int, data: Dict, ) -> User: - fields = ["name", "email", "location"] + fields = ["name", "email"] user = get_object_or_404(User, pk=user_id) - old_location = user.location - location_updated = False + gov_id = data.get("gov_id") city_id = data.get("city_id") - if (city_id is not None) and (user.location.city.id != city_id): - location = create_location( - 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"), + }, ) - data["location"] = location - location_updated = True - - user, has_updated = model_update( + user, _ = model_update( instance=user, fields=fields, data=data, ) - # Clean old location - if location_updated: - old_location.delete() - - return user, has_updated + return user From 99d549d0594e06c26f6236d7beae22490658fb2e Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 24 Apr 2022 05:26:35 +0200 Subject: [PATCH 54/90] refactor: make urls for each app --- api/apis/__init__.py | 1 + api/apis/apps.py | 6 +++ api/apis/migrations/__init__.py | 1 + api/apis/tests.py | 1 + api/apis/urls.py | 9 ++++ api/authentication/urls.py | 13 +++++ api/files/urls.py | 1 + api/templates/base.html | 81 +++++++++++++++++----------- api/templates/users/user_detail.html | 26 ++++----- api/templates/users/user_form.html | 19 +++---- api/users/app_urls.py | 10 ++++ api/users/urls.py | 12 ++--- config/urls.py | 16 +----- 13 files changed, 122 insertions(+), 74 deletions(-) create mode 100644 api/apis/__init__.py create mode 100644 api/apis/apps.py create mode 100644 api/apis/migrations/__init__.py create mode 100644 api/apis/tests.py create mode 100644 api/apis/urls.py create mode 100644 api/authentication/urls.py create mode 100644 api/users/app_urls.py 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/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..d5e9d4e --- /dev/null +++ b/api/apis/urls.py @@ -0,0 +1,9 @@ +from django.urls import include, path + +app_name = "api" +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")), +] diff --git a/api/authentication/urls.py b/api/authentication/urls.py new file mode 100644 index 0000000..78ccf87 --- /dev/null +++ b/api/authentication/urls.py @@ -0,0 +1,13 @@ +from django.urls import path +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenVerifyView, +) + +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"), +] diff --git a/api/files/urls.py b/api/files/urls.py index 8222d85..5c01cfc 100644 --- a/api/files/urls.py +++ b/api/files/urls.py @@ -7,6 +7,7 @@ FileStandardUploadApi, ) +app_name = "files" urlpatterns = [ path( "upload/", 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/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/urls.py b/api/users/urls.py index 3ba575f..49d7a43 100644 --- a/api/users/urls.py +++ b/api/users/urls.py @@ -1,14 +1,10 @@ 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, UpdateUserApi 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"), ] diff --git a/config/urls.py b/config/urls.py index bb87ab0..09a0c86 100644 --- a/config/urls.py +++ b/config/urls.py @@ -7,13 +7,6 @@ from django.views.generic import TemplateView from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from rest_framework.authtoken.views import obtain_auth_token -from rest_framework_simplejwt.views import ( - TokenObtainPairView, - TokenRefreshView, - TokenVerifyView, -) - -from api.users.apis import CreateUserApi, DetailUserApi, UpdateUserApi urlpatterns = [ path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), @@ -23,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("apis.urls", namespace="api")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.DEBUG: # Static file serving when using Gunicorn + Uvicorn for local web socket development @@ -35,12 +29,6 @@ urlpatterns += [ # API base url path("api/", include("config.api_router")), - path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), - path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), - path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"), - path("api/user/", CreateUserApi.as_view(), name="user_createeee"), - path("api/user/", DetailUserApi.as_view(), name="user_detail"), - path("api/user//update", UpdateUserApi.as_view(), name="user_update"), # DRF auth token path("auth-token/", obtain_auth_token), path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), From 1e650b9d7d5d6758a91620c868ecf0c8eb61df9b Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 24 Apr 2022 05:54:46 +0200 Subject: [PATCH 55/90] fix: api namespace collision --- api/apis/urls.py | 2 +- config/urls.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/api/apis/urls.py b/api/apis/urls.py index d5e9d4e..ff97625 100644 --- a/api/apis/urls.py +++ b/api/apis/urls.py @@ -1,6 +1,6 @@ from django.urls import include, path -app_name = "api" +app_name = "apis" urlpatterns = [ path("auth/", include("api.authentication.urls", "authentication")), path("users/", include("api.users.urls", "users")), diff --git a/config/urls.py b/config/urls.py index 09a0c86..2a4a7a3 100644 --- a/config/urls.py +++ b/config/urls.py @@ -19,7 +19,7 @@ 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("apis.urls", namespace="api")), + path("api/", include("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 @@ -27,8 +27,6 @@ # API URLS urlpatterns += [ - # API base url - path("api/", include("config.api_router")), # DRF auth token path("auth-token/", obtain_auth_token), path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), From 3f20267559f6501d8c89dc7f24bbb9eaa547a397 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 24 Apr 2022 06:31:55 +0200 Subject: [PATCH 56/90] test: user model test cases --- api/users/tests/models/__init__.py | 0 api/users/tests/models/test_user.py | 24 +++++++ api/users/tests/test_admin.py | 40 ----------- api/users/tests/test_drf_urls.py | 24 ------- api/users/tests/test_drf_views.py | 33 --------- api/users/tests/test_forms.py | 39 ----------- api/users/tests/test_models.py | 9 --- api/users/tests/test_swagger.py | 22 ------ api/users/tests/test_tasks.py | 16 ----- api/users/tests/test_urls.py | 24 ------- api/users/tests/test_views.py | 102 ---------------------------- 11 files changed, 24 insertions(+), 309 deletions(-) create mode 100644 api/users/tests/models/__init__.py create mode 100644 api/users/tests/models/test_user.py delete mode 100644 api/users/tests/test_admin.py delete mode 100644 api/users/tests/test_drf_urls.py delete mode 100644 api/users/tests/test_drf_views.py delete mode 100644 api/users/tests/test_forms.py delete mode 100644 api/users/tests/test_models.py delete mode 100644 api/users/tests/test_swagger.py delete mode 100644 api/users/tests/test_tasks.py delete mode 100644 api/users/tests/test_urls.py delete mode 100644 api/users/tests/test_views.py diff --git a/api/users/tests/models/__init__.py b/api/users/tests/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/users/tests/models/test_user.py b/api/users/tests/models/test_user.py new file mode 100644 index 0000000..142f341 --- /dev/null +++ b/api/users/tests/models/test_user.py @@ -0,0 +1,24 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from api.users.models import User + + +class UserTests(TestCase): + def test_username_is_phone_number(self): + user = User( + username="cool_username", + name="Osama Yasser", + firebase_token="token", + ) + with self.assertRaises(ValidationError): + user.full_clean() + + def test_default_id_expiration_date(self): + user = User( + username="1005469972", + name="Osama Yasser", + firebase_token="token", + ) + user.save() + self.assertIsNone(user.id_exp_date) 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 deleted file mode 100644 index b0abbe1..0000000 --- a/api/users/tests/test_models.py +++ /dev/null @@ -1,9 +0,0 @@ -import pytest - -from api.users.models import User - -pytestmark = pytest.mark.django_db - - -def test_user_get_absolute_url(user: User): - assert user.get_absolute_url() == f"/users/{user.username}/" 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/" From e2d1c900f9749962c8d9470c3abd1c28a4901506 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sun, 24 Apr 2022 22:35:07 +0200 Subject: [PATCH 57/90] feat: implement DetailCaseApi --- api/cases/apis.py | 41 ++++++++++++++++++++++++++++++++++++----- api/cases/selectors.py | 14 ++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 api/cases/selectors.py diff --git a/api/cases/apis.py b/api/cases/apis.py index 2d5d4f8..d687db8 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -1,4 +1,4 @@ -from rest_framework import permissions, serializers, status +from rest_framework import serializers, status from rest_framework.response import Response from rest_framework.views import APIView @@ -8,8 +8,6 @@ class CreateCaseApi(APIView): - permission_classes = [permissions.AllowAny] - class InputSerializer(serializers.Serializer): type = serializers.CharField() photos_urls = serializers.ListField(child=serializers.URLField()) @@ -17,13 +15,13 @@ class InputSerializer(serializers.Serializer): fields={ "gov_id": serializers.IntegerField(), "city_id": 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 ), - "address": serializers.CharField(required=False), } ) details = inline_serializer( @@ -49,7 +47,7 @@ def post(self, request): class UpdateCaseApi(APIView): - permission_classes = [permissions.AllowAny] + # TODO Add permissions class InputSerializer(serializers.Serializer): photos_urls = serializers.ListField( @@ -68,3 +66,36 @@ class InputSerializer(serializers.Serializer): "address": serializers.CharField(required=False), } ) + + +class DetailsCaseApi(APIView): + class OutputSerializer(serializers.Serializer): + user = serializers.IntegerField() + type = serializers.CharField() + state = serializers.CharField(source="get_state_display") + photos_urls = serializers.ListField(child=serializers.URLField()) + location = inline_serializer( + fields={ + "gov_id": serializers.IntegerField(), + "city_id": serializers.IntegerField(), + "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, + } + ) diff --git a/api/cases/selectors.py b/api/cases/selectors.py new file mode 100644 index 0000000..334c348 --- /dev/null +++ b/api/cases/selectors.py @@ -0,0 +1,14 @@ +from django.core.exceptions import PermissionDenied + +from api.common.utils import get_object +from api.users.models import User + +from .models import Case + + +def get_case(*, pk: int, fetched_by: User) -> Case: + case = get_object(Case, pk=pk) + if case.is_active or fetched_by == case.user: + return case + + raise PermissionDenied() From 00766bc766336bd8122407cb65dcd513a6a1e6d0 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Wed, 27 Apr 2022 00:01:09 +0200 Subject: [PATCH 58/90] fix: append api namespace to apis url --- api/users/models.py | 6 +++- api/users/tests/models/__init__.py | 0 api/users/tests/models/test_user.py | 24 --------------- api/users/tests/test_models.py | 48 +++++++++++++++++++++++++++++ config/settings/base.py | 1 + config/urls.py | 2 +- 6 files changed, 55 insertions(+), 26 deletions(-) delete mode 100644 api/users/tests/models/__init__.py delete mode 100644 api/users/tests/models/test_user.py create mode 100644 api/users/tests/test_models.py diff --git a/api/users/models.py b/api/users/models.py index 791613a..b906a50 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -1,5 +1,6 @@ from django.contrib.auth.models import AbstractUser from django.db import models +from django.urls import reverse from django.utils import timezone from api.locations.models import Location @@ -29,7 +30,10 @@ class Meta: @property def is_verified(self) -> bool: - return self.id_exp_date > timezone.now() + 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)]) def renew_id(self, days: int = 365) -> None: self.id_exp_date = timezone.now() + timezone.timedelta(days=days) diff --git a/api/users/tests/models/__init__.py b/api/users/tests/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/api/users/tests/models/test_user.py b/api/users/tests/models/test_user.py deleted file mode 100644 index 142f341..0000000 --- a/api/users/tests/models/test_user.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.core.exceptions import ValidationError -from django.test import TestCase - -from api.users.models import User - - -class UserTests(TestCase): - def test_username_is_phone_number(self): - user = User( - username="cool_username", - name="Osama Yasser", - firebase_token="token", - ) - with self.assertRaises(ValidationError): - user.full_clean() - - def test_default_id_expiration_date(self): - user = User( - username="1005469972", - name="Osama Yasser", - firebase_token="token", - ) - user.save() - self.assertIsNone(user.id_exp_date) diff --git a/api/users/tests/test_models.py b/api/users/tests/test_models.py new file mode 100644 index 0000000..d3cfb8a --- /dev/null +++ b/api/users/tests/test_models.py @@ -0,0 +1,48 @@ +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 + + +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", + email="osamayasserr@gmail.com", + firebase_token="token", + gov_id="1", + city_id="4", + ) + + 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/config/settings/base.py b/config/settings/base.py index 80fff10..b18047d 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -91,6 +91,7 @@ "api.common", "api.files", "api.integrations", + "api.apis", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS diff --git a/config/urls.py b/config/urls.py index 2a4a7a3..a78c7b1 100644 --- a/config/urls.py +++ b/config/urls.py @@ -19,7 +19,7 @@ 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("apis.urls", namespace="apis")), + 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 From dac4f4be9dd23a24592effaada98f331a118bc35 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Tue, 3 May 2022 11:21:07 +0200 Subject: [PATCH 59/90] feat: implement CaseListApi --- api/apis/pagination.py | 55 ++++++++++++++++ api/apis/urls.py | 2 +- api/cases/apis.py | 62 +++++++++++++------ api/cases/filters.py | 29 +++++++++ .../migrations/0004_alter_casedetails_case.py | 19 ++++++ .../0005_alter_casedetails_last_seen.py | 18 ++++++ api/cases/models.py | 10 +-- api/cases/selectors.py | 35 +++++++++-- api/cases/services.py | 2 +- api/cases/urls.py | 12 ++++ config/urls.py | 2 +- requirements/base.txt | 1 + 12 files changed, 218 insertions(+), 29 deletions(-) create mode 100644 api/apis/pagination.py create mode 100644 api/cases/filters.py create mode 100644 api/cases/migrations/0004_alter_casedetails_case.py create mode 100644 api/cases/migrations/0005_alter_casedetails_last_seen.py create mode 100644 api/cases/urls.py 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/urls.py b/api/apis/urls.py index ff97625..f713ccf 100644 --- a/api/apis/urls.py +++ b/api/apis/urls.py @@ -4,6 +4,6 @@ urlpatterns = [ path("auth/", include("api.authentication.urls", "authentication")), path("users/", include("api.users.urls", "users")), - # path("cases/", include("api.cases.urls", "cases")), + path("cases/", include("api.cases.urls", "cases")), path("files/", include("api.files.urls", "files")), ] diff --git a/api/cases/apis.py b/api/cases/apis.py index d687db8..abb4e72 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -2,6 +2,8 @@ from rest_framework.response import Response 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.services import create_case from api.common.utils import inline_serializer from api.users.models import User @@ -46,25 +48,42 @@ def post(self, request): return Response(status=status.HTTP_201_CREATED) -class UpdateCaseApi(APIView): - # TODO Add permissions +class CaseListApi(APIView): + class Pagination(LimitOffsetPagination): + default_limit = 10 - class InputSerializer(serializers.Serializer): - photos_urls = serializers.ListField( - child=serializers.URLField(), required=False - ) - location = inline_serializer( - fields={ - "gov_id": serializers.IntegerField(required=False), - "city_id": serializers.IntegerField(required=False), - "lon": serializers.DecimalField( - max_digits=9, decimal_places=6, required=False - ), - "lat": serializers.DecimalField( - max_digits=8, decimal_places=6, required=False - ), - "address": serializers.CharField(required=False), - } + 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") + gov = serializers.CharField(source="location.gov.name_ar") + city = serializers.CharField(source="location.city.name_ar") + photo = serializers.URLField(source="photo_urls") + last_seen = serializers.DateField(source="details.last_seen") + posted_at = serializers.DateField() + + 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, ) @@ -99,3 +118,10 @@ class OutputSerializer(serializers.Serializer): "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) diff --git a/api/cases/filters.py b/api/cases/filters.py new file mode 100644 index 0000000..5a5abb0 --- /dev/null +++ b/api/cases/filters.py @@ -0,0 +1,29 @@ +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", "name"] 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/models.py b/api/cases/models.py index 738a50a..a4fc198 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -83,20 +83,22 @@ class Gender(models.TextChoices): FEMALE = "F", _("Female") UNKNOWN = "U", _("Unknown") - case = models.OneToOneField(Case, on_delete=models.CASCADE) + 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.DateTimeField(null=True, blank=True) + last_seen = models.DateField(null=True, blank=True) description = models.TextField(null=True, blank=True) class CaseMatch(models.Model): - case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="matches") - match = models.ForeignKey(Case, on_delete=models.CASCADE) + case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="old_matches") + match = models.ForeignKey( + Case, on_delete=models.CASCADE, related_name="new_matches" + ) score = models.SmallIntegerField( validators=[MaxValueValidator(100), MinValueValidator(1)] ) diff --git a/api/cases/selectors.py b/api/cases/selectors.py index 334c348..5f265af 100644 --- a/api/cases/selectors.py +++ b/api/cases/selectors.py @@ -1,14 +1,41 @@ from django.core.exceptions import PermissionDenied +from django.db.models import Q from api.common.utils import get_object from api.users.models import User -from .models import Case +from .filters import CaseFilter +from .models import Case, CaseMatch def get_case(*, pk: int, fetched_by: User) -> Case: case = get_object(Case, pk=pk) - if case.is_active or fetched_by == case.user: - return case - raise PermissionDenied() + if not (case.is_active or fetched_by == case.user): + raise PermissionDenied() + + return case + + +def list_case(*, filters=None): + 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(*, pk: int, fetched_by: User): + case = get_object(Case, pk=pk) + + if fetched_by != case.user: + raise PermissionDenied() + + # TODO Wrong approach + qs = CaseMatch.objects.filter(Q(case=case) | Q(match=case)) + return qs diff --git a/api/cases/services.py b/api/cases/services.py index d0bb982..2b9b719 100644 --- a/api/cases/services.py +++ b/api/cases/services.py @@ -31,7 +31,7 @@ def create_case( photos_urls: List[str], ) -> Case: location: Location = create_location(**location) - case = Case(type=type, user=user, location=location) + case = Case(type=type.upper(), user=user, location=location) case.full_clean() case.save() diff --git a/api/cases/urls.py b/api/cases/urls.py new file mode 100644 index 0000000..b333bac --- /dev/null +++ b/api/cases/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from .apis import CaseListApi, 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"), +] diff --git a/config/urls.py b/config/urls.py index 8e7734b..2f1b07a 100644 --- a/config/urls.py +++ b/config/urls.py @@ -19,7 +19,7 @@ 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("apis.urls", namespace="apis")), + 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 diff --git a/requirements/base.txt b/requirements/base.txt index 4e3e5d8..1473f6a 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -27,3 +27,4 @@ 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 From 800eadbc4b3ef401a05639ddffef9e8bc9a610f9 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sun, 8 May 2022 21:52:31 +0200 Subject: [PATCH 60/90] chore: run jobs on dev PR & commits --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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: From f6a96f67155b0fdc07f82d403b3c125cfce6b14c Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 8 May 2022 22:24:00 +0200 Subject: [PATCH 61/90] MAFQUD-208 : notification system (#20) * feat: create initial notification model * chore: create initial notification migration * feat: add notificaions in admin site * feat: implement notification services * feat: implement notification list api * feat: create fcm device on user create * fix: user reference to object instead of class * fix: remove api level permission * feat: make notifications model migration * fix: make fcm token required * fix: linting whitespaces * fix: add fcm token field in tests --- .envs/.local/.django | 6 ++- Makefile | 2 +- api/apis/urls.py | 1 + api/notifications/__init__.py | 1 + api/notifications/admin.py | 9 ++++ api/notifications/apis.py | 31 ++++++++++++++ api/notifications/apps.py | 6 +++ api/notifications/migrations/0001_initial.py | 35 ++++++++++++++++ api/notifications/migrations/__init__.py | 1 + api/notifications/models.py | 32 ++++++++++++++ api/notifications/selectors.py | 6 +++ api/notifications/services.py | 44 ++++++++++++++++++++ api/notifications/urls.py | 8 ++++ api/users/apis.py | 1 + api/users/services.py | 5 ++- api/users/tests/test_models.py | 1 + config/settings/base.py | 18 ++++++++ requirements/base.txt | 1 + 18 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 api/notifications/__init__.py create mode 100644 api/notifications/admin.py create mode 100644 api/notifications/apis.py create mode 100644 api/notifications/apps.py create mode 100644 api/notifications/migrations/0001_initial.py create mode 100644 api/notifications/migrations/__init__.py create mode 100644 api/notifications/models.py create mode 100644 api/notifications/selectors.py create mode 100644 api/notifications/services.py create mode 100644 api/notifications/urls.py diff --git a/.envs/.local/.django b/.envs/.local/.django index 41f6ca1..a6cb70f 100644 --- a/.envs/.local/.django +++ b/.envs/.local/.django @@ -16,4 +16,8 @@ CELERY_FLOWER_PASSWORD=debug # Files # ------------------------------------------------------------------------------ -FILE_UPLOAD_STORAGE="local" +FILE_UPLOAD_STORAGE=local + +# Firebase +# ------------------------------------------------------------------------------ +GOOGLE_APPLICATION_CREDENTIALS=/home/$USER/google-services.json 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/urls.py b/api/apis/urls.py index f713ccf..1e044a7 100644 --- a/api/apis/urls.py +++ b/api/apis/urls.py @@ -6,4 +6,5 @@ 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")), ] 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/__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..ca37412 --- /dev/null +++ b/api/notifications/models.py @@ -0,0 +1,32 @@ +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" + + 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..52c15ac --- /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: FCMDevice, +) -> 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/users/apis.py b/api/users/apis.py index 0ec076e..53c622f 100644 --- a/api/users/apis.py +++ b/api/users/apis.py @@ -18,6 +18,7 @@ class InputSerializer(serializers.Serializer): email = serializers.EmailField(required=False) gov_id = serializers.IntegerField() city_id = serializers.IntegerField() + fcm_token = serializers.CharField() firebase_token = serializers.CharField() def post(self, request): diff --git a/api/users/services.py b/api/users/services.py index eb1c6ab..3205416 100644 --- a/api/users/services.py +++ b/api/users/services.py @@ -6,6 +6,7 @@ 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 @@ -16,6 +17,7 @@ def create_user( username: str, password: str, email: Optional[str] = None, + fcm_token: str, firebase_token: Optional[str], gov_id: int, city_id: int, @@ -31,9 +33,10 @@ def create_user( ) user.set_password(password) user.full_clean() - # user.clean() user.save() + create_fcm_device(user=user, fcm_token=fcm_token) + return user diff --git a/api/users/tests/test_models.py b/api/users/tests/test_models.py index d3cfb8a..bf7b043 100644 --- a/api/users/tests/test_models.py +++ b/api/users/tests/test_models.py @@ -15,6 +15,7 @@ def setUpTestData(cls): # Called once at the beginning of the test run password="hardpassword", email="osamayasserr@gmail.com", firebase_token="token", + fcm_token="fcm_token", gov_id="1", city_id="4", ) diff --git a/config/settings/base.py b/config/settings/base.py index b18047d..dff242a 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -5,6 +5,7 @@ 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 @@ -69,6 +70,7 @@ "django.contrib.admin", "django.forms", ] + THIRD_PARTY_APPS = [ "crispy_forms", "crispy_bootstrap5", @@ -80,6 +82,7 @@ "rest_framework.authtoken", "corsheaders", "drf_spectacular", + "fcm_django", ] LOCAL_APPS = [ @@ -92,6 +95,7 @@ "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 @@ -342,6 +346,20 @@ {"url": "https://mafqud.com", "description": "Production server"}, ], } + +# 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( diff --git a/requirements/base.txt b/requirements/base.txt index 1473f6a..5981ca8 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -28,3 +28,4 @@ drf-spectacular==0.22.0 # https://github.com/tfranzel/drf-spectacular 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/ From a9f24ee695a03b4b0d4592f14c18aef5cda739d1 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Sun, 8 May 2022 22:33:22 +0200 Subject: [PATCH 62/90] MAFQUD-206 : phone validation (#21) * feat: implement validate phone/email apis * refactor: move validators in api.common app * fix: remove email validation --- api/authentication/apis.py | 22 ++++++++++++++++++++++ api/authentication/selectors.py | 12 ++++++++++++ api/authentication/tests.py | 1 - api/authentication/urls.py | 3 +++ api/authentication/views.py | 1 - api/{users => common}/validators.py | 0 api/users/migrations/0001_initial.py | 4 ++-- api/users/models.py | 2 +- 8 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 api/authentication/apis.py create mode 100644 api/authentication/selectors.py delete mode 100644 api/authentication/tests.py delete mode 100644 api/authentication/views.py rename api/{users => common}/validators.py (100%) 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/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/tests.py b/api/authentication/tests.py deleted file mode 100644 index 007eb95..0000000 --- a/api/authentication/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Tests diff --git a/api/authentication/urls.py b/api/authentication/urls.py index 78ccf87..e0005a9 100644 --- a/api/authentication/urls.py +++ b/api/authentication/urls.py @@ -5,9 +5,12 @@ 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/authentication/views.py b/api/authentication/views.py deleted file mode 100644 index 5873694..0000000 --- a/api/authentication/views.py +++ /dev/null @@ -1 +0,0 @@ -# Views diff --git a/api/users/validators.py b/api/common/validators.py similarity index 100% rename from api/users/validators.py rename to api/common/validators.py diff --git a/api/users/migrations/0001_initial.py b/api/users/migrations/0001_initial.py index 03e3c3a..16fd85c 100644 --- a/api/users/migrations/0001_initial.py +++ b/api/users/migrations/0001_initial.py @@ -1,6 +1,6 @@ # Generated by Django 3.2.13 on 2022-04-19 22:39 -import api.users.validators +import api.common.validators import django.contrib.auth.models from django.db import migrations, models import django.db.models.deletion @@ -29,7 +29,7 @@ class Migration(migrations.Migration): ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('name', models.CharField(max_length=256)), ('email', models.EmailField(blank=True, max_length=254, null=True)), - ('username', models.CharField(max_length=10, unique=True, validators=[api.users.validators.is_phone])), + ('username', models.CharField(max_length=10, unique=True, validators=[api.common.validators.is_phone])), ('id_exp_date', models.DateTimeField(blank=True, null=True)), ('id_photo_url', models.ImageField(blank=True, upload_to='id-photos/')), ('firebase_token', models.CharField(blank=True, max_length=256, unique=True)), diff --git a/api/users/models.py b/api/users/models.py index b906a50..ddccd3f 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -3,8 +3,8 @@ from django.urls import reverse from django.utils import timezone +from api.common.validators import is_phone from api.locations.models import Location -from api.users.validators import is_phone class User(AbstractUser): From c5e5f97a0244b904246cca5c6be93f4967c5e872 Mon Sep 17 00:00:00 2001 From: Osama Ragab <54740178+OsamaRagab520@users.noreply.github.com> Date: Sun, 8 May 2022 23:03:20 +0200 Subject: [PATCH 63/90] MAFQUD-207 : Refactor services (#22) * refactor: cases app * refactor: users app * refactor: locations app * refactor: make firebase_token essential field * chore: liniting * update user service test --- 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 ++-- api/locations/selectors.py | 6 +- api/locations/services.py | 48 ++++++++-------- api/users/apis.py | 41 +++++++------- api/users/migrations/0003_alter_user_email.py | 18 ++++++ api/users/models.py | 1 - api/users/services.py | 55 +++++++++---------- api/users/tests/test_models.py | 4 +- 12 files changed, 147 insertions(+), 99 deletions(-) create mode 100644 api/cases/migrations/0006_auto_20220508_0416.py create mode 100644 api/users/migrations/0003_alter_user_email.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() 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, ) diff --git a/api/users/apis.py b/api/users/apis.py index 53c622f..58ed28d 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(), + } + ) fcm_token = serializers.CharField() firebase_token = serializers.CharField() @@ -35,22 +38,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"), } ) @@ -65,9 +57,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 ddccd3f..407d940 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 3205416..a7d0dc2 100644 --- a/api/users/services.py +++ b/api/users/services.py @@ -1,7 +1,8 @@ -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 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 @@ -16,23 +17,27 @@ def create_user( name: str, username: str, password: str, - email: Optional[str] = None, + firebase_token: str, + location: Dict, fcm_token: str, - firebase_token: Optional[str], - gov_id: int, - city_id: int, ) -> 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() + + # Saving user to the database user.save() create_fcm_device(user=user, fcm_token=fcm_token) @@ -43,29 +48,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 diff --git a/api/users/tests/test_models.py b/api/users/tests/test_models.py index bf7b043..51cdd84 100644 --- a/api/users/tests/test_models.py +++ b/api/users/tests/test_models.py @@ -13,11 +13,9 @@ 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", + location={"gov": 1, "city": "4"}, fcm_token="fcm_token", - gov_id="1", - city_id="4", ) def test_name_max_lenght(self): From c1e5893d1fe9fd20128998dd2d744ec218d2734e Mon Sep 17 00:00:00 2001 From: Osama Ragab <54740178+OsamaRagab520@users.noreply.github.com> Date: Tue, 10 May 2022 21:33:49 +0200 Subject: [PATCH 64/90] MAFQUD-205 : Write list case matches Endpoint (#26) * refactor: cases app * refactor: users app * refactor: locations app * refactor: make firebase_token essential field * chore: liniting * update user service test * feat: create CaseMatchApi endpoint * fix: pass user to list_case_match selector * bugfix --- 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..a7dfaf3 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, fetched_by=request.user) + + # 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 c149531c15d1d0817cb0bedef35c972cc4737a5f Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Tue, 10 May 2022 22:09:32 +0200 Subject: [PATCH 65/90] MAFQUD-210: national id (#27) * feat: add national id field in user model * feat: create set national id api --- api/common/validators.py | 10 +++++++- api/users/admin.py | 2 +- api/users/apis.py | 24 ++++++++++++++++--- api/users/migrations/0001_initial.py | 8 +++---- .../0002_remove_user_id_photo_url.py | 17 ------------- api/users/migrations/0003_alter_user_email.py | 18 -------------- api/users/models.py | 9 ++++++- api/users/services.py | 16 +++++++++---- api/users/urls.py | 3 ++- 9 files changed, 56 insertions(+), 51 deletions(-) delete mode 100644 api/users/migrations/0002_remove_user_id_photo_url.py delete mode 100644 api/users/migrations/0003_alter_user_email.py diff --git a/api/common/validators.py b/api/common/validators.py index c7d8d9e..bafcb22 100644 --- a/api/common/validators.py +++ b/api/common/validators.py @@ -6,4 +6,12 @@ def is_phone(val: str): Validates a phone number """ if not val.isnumeric() or len(val) != 10: - raise ValidationError(f'"{val}" is not a valid number') + 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/users/admin.py b/api/users/admin.py index 33614eb..1d9d7f3 100644 --- a/api/users/admin.py +++ b/api/users/admin.py @@ -17,7 +17,7 @@ class UserAdmin(auth_admin.UserAdmin): add_form = UserAdminCreationForm fieldsets = ( (None, {"fields": ("username", "password")}), - (_("Personal info"), {"fields": ("name", "email", "location")}), + (_("Personal info"), {"fields": ("name", "email", "location", "national_id")}), ( _("Permissions"), { diff --git a/api/users/apis.py b/api/users/apis.py index 58ed28d..6aaecf8 100644 --- a/api/users/apis.py +++ b/api/users/apis.py @@ -1,11 +1,12 @@ +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.permissions import IsVerified -from api.common.utils import get_object, inline_serializer +from api.common.utils import inline_serializer from api.users.models import User -from api.users.services import create_user, update_user +from api.users.services import create_user, set_national_id, update_user class CreateUserApi(APIView): @@ -47,7 +48,7 @@ class OutputSerializer(serializers.Serializer): ) def get(self, request, user_id): - user = get_object(User, id=user_id) + user = get_object_or_404(User, id=user_id) serializer = self.OutputSerializer(user) return Response(serializer.data) @@ -80,3 +81,20 @@ def post(self, request, user_id): data=serializer.validated_data, ) return Response(status=status.HTTP_200_OK) + + +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/migrations/0001_initial.py b/api/users/migrations/0001_initial.py index 16fd85c..ed130ea 100644 --- a/api/users/migrations/0001_initial.py +++ b/api/users/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.13 on 2022-04-19 22:39 +# Generated by Django 3.2.13 on 2022-05-10 19:09 import api.common.validators import django.contrib.auth.models @@ -12,7 +12,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('locations', '0001_initial'), + ('locations', '0003_alter_location_address'), ('auth', '0012_alter_user_first_name_max_length'), ] @@ -24,14 +24,14 @@ class Migration(migrations.Migration): ('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)), - ('email', models.EmailField(blank=True, max_length=254, null=True)), ('username', models.CharField(max_length=10, unique=True, validators=[api.common.validators.is_phone])), ('id_exp_date', models.DateTimeField(blank=True, null=True)), - ('id_photo_url', models.ImageField(blank=True, upload_to='id-photos/')), + ('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')), diff --git a/api/users/migrations/0002_remove_user_id_photo_url.py b/api/users/migrations/0002_remove_user_id_photo_url.py deleted file mode 100644 index fe85649..0000000 --- a/api/users/migrations/0002_remove_user_id_photo_url.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.2.13 on 2022-04-21 21:04 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0001_initial'), - ] - - operations = [ - migrations.RemoveField( - model_name='user', - name='id_photo_url', - ), - ] diff --git a/api/users/migrations/0003_alter_user_email.py b/api/users/migrations/0003_alter_user_email.py deleted file mode 100644 index a5e1547..0000000 --- a/api/users/migrations/0003_alter_user_email.py +++ /dev/null @@ -1,18 +0,0 @@ -# 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 407d940..54d079d 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -3,7 +3,7 @@ from django.urls import reverse from django.utils import timezone -from api.common.validators import is_phone +from api.common.validators import is_national_id, is_phone from api.locations.models import Location @@ -15,6 +15,13 @@ class User(AbstractUser): 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) diff --git a/api/users/services.py b/api/users/services.py index a7d0dc2..1f73066 100644 --- a/api/users/services.py +++ b/api/users/services.py @@ -46,11 +46,7 @@ def create_user( @transaction.atomic -def update_user( - *, - user: User, - data: Dict, -) -> User: +def update_user(*, user: User, data: Dict) -> User: non_side_effect_fields = ["name", "firebase_token"] user, _ = model_update( @@ -64,3 +60,13 @@ def update_user( 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/urls.py b/api/users/urls.py index 49d7a43..01897e7 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, SetNationalIdApi, UpdateUserApi 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("/set/id/", SetNationalIdApi.as_view(), name="set_id"), ] From 3f1d6b75ad79ef6a2c234331a33f6bf01e3b1def Mon Sep 17 00:00:00 2001 From: Osama Ragab <54740178+OsamaRagab520@users.noreply.github.com> Date: Fri, 13 May 2022 11:45:15 +0200 Subject: [PATCH 66/90] Final tweaks (#30) * refactor: cases app * refactor: users app * refactor: locations app * refactor: make firebase_token essential field * chore: liniting * update user service test * feat: create CaseMatchApi endpoint * fix: pass user to list_case_match selector * bugfix * feat: write locations app endpoints * fix: minor changes to notifications app * feat: write endpoint for list user cases * feat: write endpoint for pulishing a case & few updates --- api/apis/urls.py | 1 + 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 ++- api/locations/apis.py | 43 +++++++++++++ api/locations/selectors.py | 6 +- api/locations/urls.py | 14 +++++ api/locations/views.py | 1 - api/notifications/models.py | 1 + api/notifications/services.py | 2 +- api/users/apis.py | 47 +++++++++++--- api/users/selectors.py | 8 ++- api/users/services.py | 12 +++- api/users/urls.py | 9 ++- 16 files changed, 235 insertions(+), 36 deletions(-) create mode 100644 api/cases/migrations/0008_case_thumbnail.py 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/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"), ] 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 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( diff --git a/api/users/apis.py b/api/users/apis.py index 6aaecf8..4223b75 100644 --- a/api/users/apis.py +++ b/api/users/apis.py @@ -3,9 +3,9 @@ from rest_framework.response import Response from rest_framework.views import APIView -from api.common.permissions import IsVerified 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 @@ -16,14 +16,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) @@ -34,8 +34,6 @@ def post(self, request): class DetailUserApi(APIView): - permission_classes = [IsVerified] - class OutputSerializer(serializers.Serializer): username = serializers.CharField() name = serializers.CharField() @@ -48,16 +46,16 @@ class OutputSerializer(serializers.Serializer): ) def get(self, request, user_id): - user = get_object_or_404(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(), @@ -76,13 +74,44 @@ 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) + + class SetNationalIdApi(APIView): class InputSerializer(serializers.Serializer): national_id = serializers.CharField() 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 1f73066..0e8847e 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 @@ -46,7 +47,16 @@ def create_user( @transaction.atomic -def update_user(*, user: User, data: Dict) -> 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 01897e7..8d6daa2 100644 --- a/api/users/urls.py +++ b/api/users/urls.py @@ -1,11 +1,18 @@ from django.urls import path -from api.users.apis import CreateUserApi, DetailUserApi, SetNationalIdApi, UpdateUserApi +from api.users.apis import ( + CreateUserApi, + DetailUserApi, + SetNationalIdApi, + 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"), path("/set/id/", SetNationalIdApi.as_view(), name="set_id"), ] From e75b3f7d8670b3c0a156b2fffee907712dca0bc6 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Fri, 13 May 2022 14:50:46 +0200 Subject: [PATCH 67/90] chore: disable push notification & hot fixes --- api/cases/apis.py | 2 +- api/cases/services.py | 36 ++++++++++--------- .../0002_alter_notification_options.py | 17 +++++++++ 3 files changed, 38 insertions(+), 17 deletions(-) create mode 100644 api/notifications/migrations/0002_alter_notification_options.py diff --git a/api/cases/apis.py b/api/cases/apis.py index 154b0ec..f83fe71 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -173,6 +173,6 @@ class OutputSerializer(serializers.Serializer): class CasePublishApi(APIView): def get(self, request, case_id): - case = get_case(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/services.py b/api/cases/services.py index 93c4272..08fa128 100644 --- a/api/cases/services.py +++ b/api/cases/services.py @@ -3,8 +3,6 @@ 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 @@ -15,6 +13,10 @@ 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 @@ -128,6 +130,7 @@ def case_matching_binding(*, case: Case, matches_list: List[Dict[int, int]]) -> 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="تم رفع الحاله بنجاح", @@ -136,14 +139,14 @@ def activate_case(case: Case): sent_to=case.user, ) - msg = Message( - notification=FirebaseNotification( - title="تم رفع الحاله بنجاح", - body="جارى البحث عن المفقود وسنقوم بإشعارك فى حاله العثور لأى نتائج", - ) - ) + # msg = Message( + # notification=FirebaseNotification( + # title="تم رفع الحاله بنجاح", + # body="جارى البحث عن المفقود وسنقوم بإشعارك فى حاله العثور لأى نتائج", + # ) + # ) - case.user.fcmdevice.send_message(msg) + # case.user.fcmdevice.send_message(msg) def publish_case(*, case: Case, performed_by: User): @@ -163,10 +166,11 @@ def publish_case(*, case: Case, performed_by: User): sent_to=case.user, ) - msg = Message( - notification=FirebaseNotification( - title="تم نشر الحاله بنجاح", - body="تم نشر بيانات المعثور عليه بنجاح انتظر منا اشعار اخر فى حين الوصول لأى نتائج", - ) - ) - case.user.fcmdevice.send_message(msg) + # msg = Message( + # notification=FirebaseNotification( + # title="تم نشر الحاله بنجاح", + # body="تم نشر بيانات المعثور عليه بنجاح انتظر منا اشعار اخر فى حين الوصول لأى نتائج", + # ) + # ) + # device = FCMDevice.objects.filter(user=case.user).first() + # device.send_message(msg) 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'}, + ), + ] From de3ab40bfb65b00147cc0728783385a9c5150d83 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sun, 15 May 2022 23:39:56 +0200 Subject: [PATCH 68/90] chore: integrate files into cases instead of urls --- api/cases/apis.py | 8 ++--- .../migrations/0009_auto_20220515_1944.py | 30 +++++++++++++++++++ api/cases/models.py | 8 +++-- api/cases/services.py | 26 ++++++++++++---- 4 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 api/cases/migrations/0009_auto_20220515_1944.py diff --git a/api/cases/apis.py b/api/cases/apis.py index f83fe71..e668e74 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -12,8 +12,8 @@ class CreateCaseApi(APIView): class InputSerializer(serializers.Serializer): type = serializers.CharField() - thumbnail = serializers.URLField() - photos_urls = serializers.ListField(child=serializers.URLField()) + thumbnail = serializers.IntegerField() + file_ids = serializers.ListField(child=serializers.IntegerField()) location = inline_serializer( fields={ "gov": serializers.IntegerField(), @@ -66,7 +66,7 @@ class OutputSerializer(serializers.Serializer): id = serializers.IntegerField() type = serializers.CharField() name = serializers.CharField(source="details.name") - thumbnail = serializers.URLField() + thumbnail = serializers.URLField(source="thumbnail.url") last_seen = serializers.DateField(source="details.last_seen") posted_at = serializers.DateTimeField() location = inline_serializer( @@ -154,7 +154,7 @@ class OutputSerializer(serializers.Serializer): "city": serializers.CharField(source="city.name_ar"), }, ), - "photos": serializers.ListField(source="photo_urls"), + "thumbnail": serializers.URLField(source="thumbnail.url"), "last_seen": serializers.DateField(source="details.last_seen"), "posted_at": serializers.DateTimeField(), }, 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/models.py b/api/cases/models.py index 64b2b8a..b404b7b 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -4,6 +4,8 @@ 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 @@ -38,11 +40,11 @@ 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) - thumbnail = models.URLField() + thumbnail = models.OneToOneField(File, on_delete=models.CASCADE) @property def photo_urls(self): - return self.photos.values_list("url", flat=True) + return [photo.file.url for photo in self.photos.all()] def __str__(self): return f"{self.state} case {self.type}" @@ -103,5 +105,5 @@ class CaseMatch(models.Model): class CasePhoto(models.Model): - url = models.URLField() + file = models.OneToOneField(File, on_delete=models.CASCADE) case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="photos") diff --git a/api/cases/services.py b/api/cases/services.py index 08fa128..42a3199 100644 --- a/api/cases/services.py +++ b/api/cases/services.py @@ -5,6 +5,8 @@ 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 @@ -21,8 +23,8 @@ CaseType = Case.Types -def create_case_photo(*, case: Case, url: str) -> CasePhoto: - photo = CasePhoto(case=case, url=url) +def create_case_photo(*, case: Case, file: File) -> CasePhoto: + photo = CasePhoto(case=case, file=file) photo.full_clean() photo.save() @@ -36,17 +38,29 @@ def create_case( user: User, location: Dict, details: Dict, - thumbnail: str, - photos_urls: List[str], + 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() - for url in photos_urls: - create_case_photo(case=case, url=url) + 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) From 5f21b07704a4c474c438a099e34795a57dea8764 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Mon, 16 May 2022 00:36:22 +0200 Subject: [PATCH 69/90] chore: apply clean migrations --- api/cases/migrations/0001_initial.py | 25 ++++----- api/cases/migrations/0002_initial.py | 53 +++++++++++++++++++ .../migrations/0003_auto_20220421_0014.py | 30 ----------- .../{0002_case_user.py => 0003_case_user.py} | 4 +- .../migrations/0004_alter_casedetails_case.py | 19 ------- .../0005_alter_casedetails_last_seen.py | 18 ------- .../migrations/0006_auto_20220508_0416.py | 24 --------- .../migrations/0007_auto_20220510_1459.py | 34 ------------ api/cases/migrations/0008_case_thumbnail.py | 19 ------- .../migrations/0009_auto_20220515_1944.py | 30 ----------- api/files/migrations/0001_initial.py | 6 +-- api/files/migrations/0002_file_uploaded_by.py | 23 ++++++++ api/locations/migrations/0001_initial.py | 2 +- .../migrations/0002_auto_20220418_0026.py | 23 -------- .../migrations/0003_alter_location_address.py | 18 ------- api/notifications/migrations/0001_initial.py | 7 +-- .../0002_alter_notification_options.py | 17 ------ .../migrations/0002_notification_sent_to.py | 23 ++++++++ api/users/migrations/0001_initial.py | 4 +- 19 files changed, 116 insertions(+), 263 deletions(-) create mode 100644 api/cases/migrations/0002_initial.py delete mode 100644 api/cases/migrations/0003_auto_20220421_0014.py rename api/cases/migrations/{0002_case_user.py => 0003_case_user.py} (86%) delete mode 100644 api/cases/migrations/0004_alter_casedetails_case.py delete mode 100644 api/cases/migrations/0005_alter_casedetails_last_seen.py delete mode 100644 api/cases/migrations/0006_auto_20220508_0416.py delete mode 100644 api/cases/migrations/0007_auto_20220510_1459.py delete mode 100644 api/cases/migrations/0008_case_thumbnail.py delete mode 100644 api/cases/migrations/0009_auto_20220515_1944.py create mode 100644 api/files/migrations/0002_file_uploaded_by.py delete mode 100644 api/locations/migrations/0002_auto_20220418_0026.py delete mode 100644 api/locations/migrations/0003_alter_location_address.py delete mode 100644 api/notifications/migrations/0002_alter_notification_options.py create mode 100644 api/notifications/migrations/0002_notification_sent_to.py diff --git a/api/cases/migrations/0001_initial.py b/api/cases/migrations/0001_initial.py index 25e5c87..63b83d2 100644 --- a/api/cases/migrations/0001_initial.py +++ b/api/cases/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.13 on 2022-04-19 22:39 +# Generated by Django 3.2.13 on 2022-05-15 22:35 import django.core.validators from django.db import migrations, models @@ -11,7 +11,6 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('locations', '0001_initial'), ] operations = [ @@ -25,7 +24,6 @@ class Migration(migrations.Migration): ('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', @@ -35,11 +33,14 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name='CasePhoto', + name='CaseDetails', 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')), + ('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, null=True)), + ('last_seen', models.DateField(blank=True, null=True)), + ('description', models.TextField(blank=True, null=True)), ], ), migrations.CreateModel( @@ -47,21 +48,13 @@ class Migration(migrations.Migration): 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', + name='CasePhoto', 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')), + ('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='cases.case')), ], ), ] diff --git a/api/cases/migrations/0002_initial.py b/api/cases/migrations/0002_initial.py new file mode 100644 index 0000000..8c13725 --- /dev/null +++ b/api/cases/migrations/0002_initial.py @@ -0,0 +1,53 @@ +# Generated by Django 3.2.13 on 2022-05-15 22:35 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('locations', '0001_initial'), + ('files', '0001_initial'), + ('cases', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='casephoto', + name='file', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='files.file'), + ), + migrations.AddField( + model_name='casematch', + name='found', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='found_matches', to='cases.case'), + ), + migrations.AddField( + model_name='casematch', + name='missing', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='missing_matches', to='cases.case'), + ), + migrations.AddField( + model_name='casedetails', + name='case', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='details', to='cases.case'), + ), + migrations.AddField( + model_name='casedetails', + name='location', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='locations.location'), + ), + migrations.AddField( + model_name='case', + name='location', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='locations.location'), + ), + migrations.AddField( + model_name='case', + name='thumbnail', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='files.file'), + ), + ] diff --git a/api/cases/migrations/0003_auto_20220421_0014.py b/api/cases/migrations/0003_auto_20220421_0014.py deleted file mode 100644 index a251600..0000000 --- a/api/cases/migrations/0003_auto_20220421_0014.py +++ /dev/null @@ -1,30 +0,0 @@ -# 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/0002_case_user.py b/api/cases/migrations/0003_case_user.py similarity index 86% rename from api/cases/migrations/0002_case_user.py rename to api/cases/migrations/0003_case_user.py index d68d826..5107a41 100644 --- a/api/cases/migrations/0002_case_user.py +++ b/api/cases/migrations/0003_case_user.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.13 on 2022-04-19 22:39 +# Generated by Django 3.2.13 on 2022-05-15 22:35 from django.conf import settings from django.db import migrations, models @@ -10,8 +10,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('cases', '0002_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('cases', '0001_initial'), ] operations = [ diff --git a/api/cases/migrations/0004_alter_casedetails_case.py b/api/cases/migrations/0004_alter_casedetails_case.py deleted file mode 100644 index e4e0e88..0000000 --- a/api/cases/migrations/0004_alter_casedetails_case.py +++ /dev/null @@ -1,19 +0,0 @@ -# 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 deleted file mode 100644 index 1d76d27..0000000 --- a/api/cases/migrations/0005_alter_casedetails_last_seen.py +++ /dev/null @@ -1,18 +0,0 @@ -# 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 deleted file mode 100644 index cb90f46..0000000 --- a/api/cases/migrations/0006_auto_20220508_0416.py +++ /dev/null @@ -1,24 +0,0 @@ -# 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 deleted file mode 100644 index 0b29fd5..0000000 --- a/api/cases/migrations/0007_auto_20220510_1459.py +++ /dev/null @@ -1,34 +0,0 @@ -# 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 deleted file mode 100644 index f2cbfdb..0000000 --- a/api/cases/migrations/0008_case_thumbnail.py +++ /dev/null @@ -1,19 +0,0 @@ -# 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 deleted file mode 100644 index 8d9d0c8..0000000 --- a/api/cases/migrations/0009_auto_20220515_1944.py +++ /dev/null @@ -1,30 +0,0 @@ -# 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/files/migrations/0001_initial.py b/api/files/migrations/0001_initial.py index 58eeed8..7c1a016 100644 --- a/api/files/migrations/0001_initial.py +++ b/api/files/migrations/0001_initial.py @@ -1,9 +1,7 @@ -# Generated by Django 3.2.13 on 2022-04-21 21:04 +# Generated by Django 3.2.13 on 2022-05-15 22:35 import api.files.utils -from django.conf import settings from django.db import migrations, models -import django.db.models.deletion import django.utils.timezone @@ -12,7 +10,6 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -27,7 +24,6 @@ class Migration(migrations.Migration): ('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/0002_file_uploaded_by.py b/api/files/migrations/0002_file_uploaded_by.py new file mode 100644 index 0000000..5a78cf9 --- /dev/null +++ b/api/files/migrations/0002_file_uploaded_by.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.13 on 2022-05-15 22:35 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('files', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='file', + name='uploaded_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/api/locations/migrations/0001_initial.py b/api/locations/migrations/0001_initial.py index 952ac21..bd2f32a 100644 --- a/api/locations/migrations/0001_initial.py +++ b/api/locations/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.13 on 2022-04-19 22:39 +# Generated by Django 3.2.13 on 2022-05-15 22:35 from django.db import migrations, models import django.db.models.deletion diff --git a/api/locations/migrations/0002_auto_20220418_0026.py b/api/locations/migrations/0002_auto_20220418_0026.py deleted file mode 100644 index a977a63..0000000 --- a/api/locations/migrations/0002_auto_20220418_0026.py +++ /dev/null @@ -1,23 +0,0 @@ -# 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 deleted file mode 100644 index ed3b423..0000000 --- a/api/locations/migrations/0003_alter_location_address.py +++ /dev/null @@ -1,18 +0,0 @@ -# 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/notifications/migrations/0001_initial.py b/api/notifications/migrations/0001_initial.py index bbe984b..fb88339 100644 --- a/api/notifications/migrations/0001_initial.py +++ b/api/notifications/migrations/0001_initial.py @@ -1,8 +1,6 @@ -# Generated by Django 3.2.13 on 2022-05-08 19:59 +# Generated by Django 3.2.13 on 2022-05-15 22:35 -from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): @@ -10,7 +8,6 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -24,12 +21,12 @@ class Migration(migrations.Migration): ('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', + 'ordering': ['-created_at'], }, ), ] diff --git a/api/notifications/migrations/0002_alter_notification_options.py b/api/notifications/migrations/0002_alter_notification_options.py deleted file mode 100644 index 3c7240b..0000000 --- a/api/notifications/migrations/0002_alter_notification_options.py +++ /dev/null @@ -1,17 +0,0 @@ -# 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/0002_notification_sent_to.py b/api/notifications/migrations/0002_notification_sent_to.py new file mode 100644 index 0000000..8aaa6e6 --- /dev/null +++ b/api/notifications/migrations/0002_notification_sent_to.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.13 on 2022-05-15 22:35 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('notifications', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='sent_to', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/api/users/migrations/0001_initial.py b/api/users/migrations/0001_initial.py index ed130ea..61d6f4e 100644 --- a/api/users/migrations/0001_initial.py +++ b/api/users/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.13 on 2022-05-10 19:09 +# Generated by Django 3.2.13 on 2022-05-15 22:35 import api.common.validators import django.contrib.auth.models @@ -12,7 +12,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('locations', '0003_alter_location_address'), + ('locations', '0001_initial'), ('auth', '0012_alter_user_first_name_max_length'), ] From a3696d282f00e22407b67a08867360df0d326661 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Mon, 16 May 2022 00:39:37 +0200 Subject: [PATCH 70/90] chore: update app base settings --- config/settings/base.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/config/settings/base.py b/config/settings/base.py index dff242a..f0f3ddf 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -5,7 +5,7 @@ from pathlib import Path import environ -from firebase_admin import initialize_app +import firebase_admin from api.files.enums import FileUploadStorage, FileUploadStrategy from config.env import env_to_enum @@ -343,7 +343,10 @@ "SERVE_PERMISSIONS": ["rest_framework.permissions.IsAdminUser"], "SERVERS": [ {"url": "http://127.0.0.1:8000", "description": "Local Development server"}, - {"url": "https://mafqud.com", "description": "Production server"}, + { + "url": "ec2-15-160-246-194.eu-south-1.compute.amazonaws.com", + "description": "Production server", + }, ], } @@ -352,7 +355,10 @@ # fcm-django - https://fcm-django.readthedocs.io/en/latest/ GOOGLE_APPLICATION_CREDENTIALS = env("GOOGLE_APPLICATION_CREDENTIALS") -FIREBASE_APP = initialize_app() +if not firebase_admin._apps: + firebase_admin.initialize_app() + +FIREBASE_APP = firebase_admin.get_app(name="[DEFAULT]") FCM_DJANGO_SETTINGS = { "ONE_DEVICE_PER_USER": True, From fb9159f1c435ce02907cb960505de6a2335aed66 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sat, 21 May 2022 16:42:20 +0200 Subject: [PATCH 71/90] fix: minor cases app bugs --- api/cases/services.py | 3 +++ api/users/apis.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/api/cases/services.py b/api/cases/services.py index 42a3199..9c284ed 100644 --- a/api/cases/services.py +++ b/api/cases/services.py @@ -170,6 +170,9 @@ def publish_case(*, case: Case, performed_by: User): if not case.is_active: raise ValidationError("Cannot publish inactive case") + if case.posted_at: + raise ValidationError("Case already published") + case.publish() case.save() diff --git a/api/users/apis.py b/api/users/apis.py index 4223b75..1ec6d43 100644 --- a/api/users/apis.py +++ b/api/users/apis.py @@ -91,7 +91,7 @@ class OutputSerializer(serializers.Serializer): type = serializers.CharField() state = serializers.CharField(source="get_state_display") name = serializers.CharField(source="details.name") - thumbnail = serializers.URLField() + thumbnail = serializers.URLField(source="thumbnail.url") last_seen = serializers.DateField(source="details.last_seen") posted_at = serializers.DateTimeField() location = inline_serializer( From d2211a3432576d58e50b14ac45fc7390fa5bf598 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sat, 21 May 2022 16:47:53 +0200 Subject: [PATCH 72/90] feat: pack user info in the token --- api/authentication/apis.py | 20 ++++++++++++++++++++ api/authentication/urls.py | 10 +++------- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/api/authentication/apis.py b/api/authentication/apis.py index 455ddec..93ec1de 100644 --- a/api/authentication/apis.py +++ b/api/authentication/apis.py @@ -1,11 +1,31 @@ from rest_framework import permissions, serializers, status from rest_framework.response import Response from rest_framework.views import APIView +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from rest_framework_simplejwt.views import TokenObtainPairView from api.authentication.selectors import validate_phone from api.common.validators import is_phone +class MyTokenObtainPairView(TokenObtainPairView): + class TokenSerializer(TokenObtainPairSerializer): + @classmethod + def get_token(cls, user): + token = super().get_token(user) + + # Users Claims + token["name"] = user.name + token["phone"] = user.username + token["national_id"] = user.national_id + token["firebase_token"] = user.firebase_token + token["gov"] = user.location.gov.name_ar + token["city"] = user.location.city.name_ar + return token + + serializer_class = TokenSerializer + + class ValidatePhoneAPI(APIView): permission_classes = [permissions.AllowAny] diff --git a/api/authentication/urls.py b/api/authentication/urls.py index e0005a9..f5dac51 100644 --- a/api/authentication/urls.py +++ b/api/authentication/urls.py @@ -1,15 +1,11 @@ from django.urls import path -from rest_framework_simplejwt.views import ( - TokenObtainPairView, - TokenRefreshView, - TokenVerifyView, -) +from rest_framework_simplejwt.views import TokenRefreshView, TokenVerifyView -from api.authentication.apis import ValidatePhoneAPI +from api.authentication.apis import MyTokenObtainPairView, ValidatePhoneAPI app_name = "auth" urlpatterns = [ - path("token/", TokenObtainPairView.as_view(), name="obtain_token"), + path("token/", MyTokenObtainPairView.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"), From 89e24eef364a4cb7f95dd89a87a5cca9ba092814 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sat, 21 May 2022 16:51:58 +0200 Subject: [PATCH 73/90] chore: use firebase_token as the id for fcm device --- api/notifications/services.py | 4 ++-- api/users/apis.py | 2 -- api/users/services.py | 3 +-- api/users/tests/test_models.py | 1 - 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/api/notifications/services.py b/api/notifications/services.py index d2ead96..5d37617 100644 --- a/api/notifications/services.py +++ b/api/notifications/services.py @@ -29,14 +29,14 @@ def create_notification( def create_fcm_device( *, user: User, - fcm_token: str, + firebase_token: str, device_type: Optional[str] = "android", ) -> FCMDevice: device = FCMDevice( user=user, type=device_type, - registration_id=fcm_token, + registration_id=firebase_token, ) device.full_clean() device.save() diff --git a/api/users/apis.py b/api/users/apis.py index 1ec6d43..eb7421a 100644 --- a/api/users/apis.py +++ b/api/users/apis.py @@ -16,7 +16,6 @@ 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={ @@ -55,7 +54,6 @@ 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(), diff --git a/api/users/services.py b/api/users/services.py index 0e8847e..d8f5fec 100644 --- a/api/users/services.py +++ b/api/users/services.py @@ -20,7 +20,6 @@ def create_user( password: str, firebase_token: str, location: Dict, - fcm_token: str, ) -> User: # Creating user's related entities @@ -41,7 +40,7 @@ def create_user( # Saving user to the database user.save() - create_fcm_device(user=user, fcm_token=fcm_token) + create_fcm_device(user=user, firebase_token=firebase_token) return user diff --git a/api/users/tests/test_models.py b/api/users/tests/test_models.py index 51cdd84..d70bee6 100644 --- a/api/users/tests/test_models.py +++ b/api/users/tests/test_models.py @@ -15,7 +15,6 @@ def setUpTestData(cls): # Called once at the beginning of the test run password="hardpassword", firebase_token="token", location={"gov": 1, "city": "4"}, - fcm_token="fcm_token", ) def test_name_max_lenght(self): From 4ae6c26d592b2311758b970bcfd0ccc93df43169 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sat, 21 May 2022 16:53:01 +0200 Subject: [PATCH 74/90] chore: disable unnessary urls --- config/urls.py | 43 +++++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/config/urls.py b/config/urls.py index 2f1b07a..8de07ed 100644 --- a/config/urls.py +++ b/config/urls.py @@ -2,22 +2,25 @@ from django.conf.urls.static import static from django.contrib import admin from django.contrib.staticfiles.urls import staticfiles_urlpatterns +from django.http import HttpResponseNotFound from django.urls import include, path from django.views import defaults as default_views -from django.views.generic import TemplateView -from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView -from rest_framework.authtoken.views import obtain_auth_token + +# from django.views.generic import TemplateView +# from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView +# from rest_framework.authtoken.views import obtain_auth_token urlpatterns = [ - path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), - path( - "about/", TemplateView.as_view(template_name="pages/about.html"), name="about" - ), + path("", HttpResponseNotFound), + # path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), + # path( + # "about/", TemplateView.as_view(template_name="pages/about.html"), name="about" + # ), # Django Admin, use {% url 'admin:index' %} path(settings.ADMIN_URL, admin.site.urls), # User management - path("app-users/", include("api.users.app_urls", namespace="app_users")), - path("accounts/", include("allauth.urls")), + # 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) @@ -26,17 +29,17 @@ urlpatterns += staticfiles_urlpatterns() # API URLS -urlpatterns += [ - # DRF auth token - path("auth-token/", obtain_auth_token), - # Docs - path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), - path( - "api/docs/", - SpectacularSwaggerView.as_view(url_name="api-schema"), - name="api-docs", - ), -] +# urlpatterns += [ +# # DRF auth token +# path("auth-token/", obtain_auth_token), +# # Docs +# path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), +# path( +# "api/docs/", +# SpectacularSwaggerView.as_view(url_name="api-schema"), +# name="api-docs", +# ), +# ] if settings.DEBUG: # This allows the error pages to be debugged during development, just visit From 682bd816b1da085b959b5da3ca2c478e15e0faa3 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Mon, 23 May 2022 14:53:52 +0200 Subject: [PATCH 75/90] chore: delete unnssary authentication classes and configure jwt life time --- config/settings/base.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/config/settings/base.py b/config/settings/base.py index f0f3ddf..2d00739 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -2,6 +2,7 @@ Base settings to build other settings files upon. """ import os +from datetime import timedelta from pathlib import Path import environ @@ -316,15 +317,16 @@ # django-rest-framework - https://www.django-rest-framework.org/api-guide/settings/ REST_FRAMEWORK = { "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", } - +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(hours=1), + "REFRESH_TOKEN_LIFETIME": timedelta(weeks=24), +} # JWT config REST_USE_JWT = True JWT_AUTH_COOKIE = "my-app-auth" From 5b93bb8499e3ef28fb4c1d20d9c9a08b190206c8 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Wed, 25 May 2022 02:43:24 +0200 Subject: [PATCH 76/90] chore: move UserCasesListApi to cases app instead of users feat: switch to PageNumberPagination instead of LimitOffsetPagination --- api/cases/apis.py | 56 +++++++++++++++++++++++++++++----- api/cases/selectors.py | 4 +++ api/notifications/apis.py | 11 ++++--- api/notifications/selectors.py | 5 ++- api/users/apis.py | 29 +----------------- api/users/selectors.py | 6 ---- api/users/urls.py | 9 ++---- 7 files changed, 63 insertions(+), 57 deletions(-) diff --git a/api/cases/apis.py b/api/cases/apis.py index e668e74..345bc51 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -1,10 +1,11 @@ from rest_framework import serializers, status +from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response from rest_framework.views import APIView -from api.apis.pagination import LimitOffsetPagination, get_paginated_response +from api.apis.pagination import get_paginated_response from api.cases.models import Case -from api.cases.selectors import get_case, list_case, list_case_match +from api.cases.selectors import get_case, list_case, list_case_match, list_user_cases from api.cases.services import create_case, publish_case from api.common.utils import inline_serializer @@ -50,8 +51,8 @@ def post(self, request): class CaseListApi(APIView): - class Pagination(LimitOffsetPagination): - default_limit = 10 + class Pagination(PageNumberPagination): + page_size = 10 class FilterSerializer(serializers.Serializer): type = serializers.CharField(required=False) @@ -133,6 +134,9 @@ def get(self, request, case_id): class CaseMatchListApi(APIView): + class Pagination(PageNumberPagination): + page_size = 10 + def get(self, request, case_id): # Fetching our case @@ -165,10 +169,13 @@ class OutputSerializer(serializers.Serializer): # 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) + return get_paginated_response( + pagination_class=self.Pagination, + serializer_class=OutputSerializer, + queryset=matches, + request=request, + view=self, + ) class CasePublishApi(APIView): @@ -176,3 +183,36 @@ 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) + + +class UserCasesListApi(APIView): + class Pagination(PageNumberPagination): + page_size = 10 + + 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(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): + + # Listing all user cases + cases = list_user_cases(request.user) + + return get_paginated_response( + pagination_class=self.Pagination, + serializer_class=self.OutputSerializer, + queryset=cases, + request=request, + view=self, + ) diff --git a/api/cases/selectors.py b/api/cases/selectors.py index 0cff214..68f7af1 100644 --- a/api/cases/selectors.py +++ b/api/cases/selectors.py @@ -44,3 +44,7 @@ def list_case_match(*, case: Case, fetched_by: User) -> QuerySet[CaseMatch]: qs = case.missing_matches.all() return qs + + +def list_user_cases(user: User) -> QuerySet[Case]: + return user.cases.all() diff --git a/api/notifications/apis.py b/api/notifications/apis.py index 5d02d8b..8a9e9f0 100644 --- a/api/notifications/apis.py +++ b/api/notifications/apis.py @@ -1,13 +1,14 @@ from rest_framework import serializers +from rest_framework.pagination import PageNumberPagination from rest_framework.views import APIView -from api.apis.pagination import LimitOffsetPagination, get_paginated_response -from api.notifications.selectors import list_notification +from api.apis.pagination import get_paginated_response +from api.notifications.selectors import list_user_notification class NotificationListApi(APIView): - class Pagination(LimitOffsetPagination): - default_limit = 10 + class Pagination(PageNumberPagination): + page_size = 15 class OutputSerializer(serializers.Serializer): id = serializers.IntegerField() @@ -20,7 +21,7 @@ class OutputSerializer(serializers.Serializer): def get(self, request): - notifications = list_notification(user=request.user) + notifications = list_user_notification(user=request.user) return get_paginated_response( pagination_class=self.Pagination, diff --git a/api/notifications/selectors.py b/api/notifications/selectors.py index 4badb85..871ee79 100644 --- a/api/notifications/selectors.py +++ b/api/notifications/selectors.py @@ -1,6 +1,5 @@ -from api.notifications.models import Notification from api.users.models import User -def list_notification(*, user: User): - return Notification.objects.filter(sent_to=user) +def list_user_notification(*, user: User): + return user.notifications.all() diff --git a/api/users/apis.py b/api/users/apis.py index eb7421a..d71a5d7 100644 --- a/api/users/apis.py +++ b/api/users/apis.py @@ -5,7 +5,7 @@ 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.selectors import get_user from api.users.services import create_user, set_national_id, update_user @@ -83,33 +83,6 @@ def post(self, request, user_id): 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(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): - - # 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() diff --git a/api/users/selectors.py b/api/users/selectors.py index 7786b7e..af34f71 100644 --- a/api/users/selectors.py +++ b/api/users/selectors.py @@ -1,13 +1,7 @@ -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/urls.py b/api/users/urls.py index 8d6daa2..6aa499d 100644 --- a/api/users/urls.py +++ b/api/users/urls.py @@ -1,12 +1,7 @@ from django.urls import path -from api.users.apis import ( - CreateUserApi, - DetailUserApi, - SetNationalIdApi, - UpdateUserApi, - UserCasesListApi, -) +from api.cases.apis import UserCasesListApi +from api.users.apis import CreateUserApi, DetailUserApi, SetNationalIdApi, UpdateUserApi app_name = "users" urlpatterns = [ From c9cd81a4801d02941228137b1ce08f98bc851915 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Wed, 25 May 2022 16:35:18 +0200 Subject: [PATCH 77/90] chore: nest locations responses --- api/locations/apis.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/locations/apis.py b/api/locations/apis.py index c04323f..ec7f603 100644 --- a/api/locations/apis.py +++ b/api/locations/apis.py @@ -21,7 +21,7 @@ def get(self, request): # Serializing the results serializer = self.OutputSerializer(govs, many=True) - return Response(serializer.data) + return Response({"results": serializer.data}) class GovernorateCitiesListApi(APIView): @@ -40,4 +40,4 @@ def get(self, request, gov_id): # Serializing the results serializer = self.OutputSerializer(cities, many=True) - return Response(serializer.data) + return Response({"results": serializer.data}) From df5f2946d63429ccb08dfb000328edce8d8b3bf6 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Thu, 26 May 2022 12:02:20 +0200 Subject: [PATCH 78/90] chore: change pagination style & add case & action field to notification model --- api/cases/apis.py | 18 +++++++-------- api/cases/services.py | 8 +++++-- api/notifications/apis.py | 7 +++--- api/notifications/migrations/0001_initial.py | 14 +++++++---- .../migrations/0002_notification_sent_to.py | 23 ------------------- api/notifications/models.py | 16 ++++++++++--- api/notifications/services.py | 5 ++++ 7 files changed, 46 insertions(+), 45 deletions(-) delete mode 100644 api/notifications/migrations/0002_notification_sent_to.py diff --git a/api/cases/apis.py b/api/cases/apis.py index 345bc51..7227494 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -1,9 +1,8 @@ from rest_framework import serializers, status -from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response from rest_framework.views import APIView -from api.apis.pagination import get_paginated_response +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, list_user_cases from api.cases.services import create_case, publish_case @@ -29,6 +28,7 @@ class InputSerializer(serializers.Serializer): } ) details = inline_serializer( + required=False, fields={ "name": serializers.CharField(required=False), "gender": serializers.CharField(required=False), @@ -39,7 +39,7 @@ class InputSerializer(serializers.Serializer): required=False, fields={**location.fields}, ), - } + }, ) def post(self, request): @@ -51,8 +51,8 @@ def post(self, request): class CaseListApi(APIView): - class Pagination(PageNumberPagination): - page_size = 10 + class Pagination(LimitOffsetPagination): + default_limit = 10 class FilterSerializer(serializers.Serializer): type = serializers.CharField(required=False) @@ -134,8 +134,8 @@ def get(self, request, case_id): class CaseMatchListApi(APIView): - class Pagination(PageNumberPagination): - page_size = 10 + class Pagination(LimitOffsetPagination): + default_limit = 10 def get(self, request, case_id): @@ -186,8 +186,8 @@ def get(self, request, case_id): class UserCasesListApi(APIView): - class Pagination(PageNumberPagination): - page_size = 10 + class Pagination(LimitOffsetPagination): + default_limit = 10 class OutputSerializer(serializers.Serializer): id = serializers.IntegerField() diff --git a/api/cases/services.py b/api/cases/services.py index 9c284ed..1348fd2 100644 --- a/api/cases/services.py +++ b/api/cases/services.py @@ -37,9 +37,9 @@ def create_case( type: CaseType, user: User, location: Dict, - details: Dict, - thumbnail: id, + thumbnail: int, file_ids: List[int], + details: Optional[Dict] = {}, ) -> Case: # Fetch & create case related objects @@ -147,6 +147,8 @@ def activate_case(case: Case): case.activate() # TODO success or failure notification create_notification( + case=case, + action=Notification.Action.DETAILS, title="تم رفع الحاله بنجاح", body="جارى البحث عن المفقود وسنقوم بإشعارك فى حاله العثور لأى نتائج", level=Notification.Level.INFO, @@ -177,6 +179,8 @@ def publish_case(*, case: Case, performed_by: User): case.save() create_notification( + case=case, + action=Notification.Action.DETAILS, title="تم نشر الحاله بنجاح", body="تم نشر بيانات المعثور عليه بنجاح انتظر منا اشعار اخر فى حين الوصول لأى نتائج", level=Notification.Level.SUCCESS, diff --git a/api/notifications/apis.py b/api/notifications/apis.py index 8a9e9f0..758f010 100644 --- a/api/notifications/apis.py +++ b/api/notifications/apis.py @@ -1,14 +1,13 @@ from rest_framework import serializers -from rest_framework.pagination import PageNumberPagination from rest_framework.views import APIView -from api.apis.pagination import get_paginated_response +from api.apis.pagination import LimitOffsetPagination, get_paginated_response from api.notifications.selectors import list_user_notification class NotificationListApi(APIView): - class Pagination(PageNumberPagination): - page_size = 15 + class Pagination(LimitOffsetPagination): + default_limit = 15 class OutputSerializer(serializers.Serializer): id = serializers.IntegerField() diff --git a/api/notifications/migrations/0001_initial.py b/api/notifications/migrations/0001_initial.py index fb88339..fe3c631 100644 --- a/api/notifications/migrations/0001_initial.py +++ b/api/notifications/migrations/0001_initial.py @@ -1,6 +1,8 @@ -# Generated by Django 3.2.13 on 2022-05-15 22:35 +# Generated by Django 3.2.13 on 2022-05-26 02:48 +from django.conf import settings from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): @@ -8,6 +10,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('cases', '0003_case_user'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -15,12 +19,14 @@ class Migration(migrations.Migration): 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)), + ('action', models.CharField(choices=[('M', 'Matches'), ('P', 'Publish'), ('D', 'Details'), ('N', 'None')], max_length=1)), + ('title', models.CharField(max_length=255)), + ('body', models.TextField()), ('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)), + ('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='cases.case')), + ('sent_to', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), ], options={ 'verbose_name': 'notification', diff --git a/api/notifications/migrations/0002_notification_sent_to.py b/api/notifications/migrations/0002_notification_sent_to.py deleted file mode 100644 index 8aaa6e6..0000000 --- a/api/notifications/migrations/0002_notification_sent_to.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.13 on 2022-05-15 22:35 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('notifications', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name='notification', - name='sent_to', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/api/notifications/models.py b/api/notifications/models.py index 8f0bd0c..b4e9820 100644 --- a/api/notifications/models.py +++ b/api/notifications/models.py @@ -1,6 +1,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from api.cases.models import Case from api.users.models import User @@ -11,9 +12,19 @@ class Level(models.TextChoices): WARNING = "W", _("WARNING") ERROR = "E", _("ERROR") - body = models.TextField() - title = models.CharField(max_length=255) + class Action(models.TextChoices): + MATCHES = "M", _("Matches") + PUBLISH = "P", _("Publish") + DETAILS = "D", _("Details") + NONE = "N", _("None") + + case = models.ForeignKey( + Case, on_delete=models.CASCADE, related_name="notifications" + ) level = models.CharField(max_length=1, choices=Level.choices) + action = models.CharField(max_length=1, choices=Action.choices) + title = models.CharField(max_length=255) + body = models.TextField() created_at = models.DateTimeField(auto_now_add=True) read_at = models.DateTimeField(default=None, null=True, blank=True) sent_to = models.ForeignKey( @@ -21,7 +32,6 @@ class Level(models.TextChoices): on_delete=models.CASCADE, related_name="notifications", ) - hyper_link = models.URLField(null=True, blank=True) class Meta: db_table = "notifications" diff --git a/api/notifications/services.py b/api/notifications/services.py index 5d37617..49b4160 100644 --- a/api/notifications/services.py +++ b/api/notifications/services.py @@ -2,23 +2,28 @@ from fcm_django.models import FCMDevice +from api.cases.models import Case from api.notifications.models import Notification from api.users.models import User def create_notification( *, + case: Case, title: str, body: str, level: str, sent_to: User, + action: Notification.Action, ) -> Notification: notification = Notification( + case=case, title=title, body=body, level=level, sent_to=sent_to, + action=action, ) notification.full_clean() notification.save() From 2157cb05b147d2a632bf86d122b0c0e37b3c9cda Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Thu, 26 May 2022 17:55:36 +0200 Subject: [PATCH 79/90] chore: update notification serializer --- Makefile | 3 +++ api/cases/apis.py | 5 ++--- api/notifications/apis.py | 6 +++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index eb81952..c3e4119 100644 --- a/Makefile +++ b/Makefile @@ -30,6 +30,9 @@ makemessages: compilemessages: docker-compose -f local.yml run --rm django python manage.py compilemessages +superuser: + docker-compose -f local.yml run --rm django python manage.py createsuperuser + urls: docker-compose -f local.yml run django python manage.py show_urls diff --git a/api/cases/apis.py b/api/cases/apis.py index f41d00d..7227494 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -1,5 +1,4 @@ from rest_framework import serializers, status -from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response from rest_framework.views import APIView @@ -52,8 +51,8 @@ def post(self, request): class CaseListApi(APIView): - class Pagination(PageNumberPagination): - page_size = 10 + class Pagination(LimitOffsetPagination): + default_limit = 10 class FilterSerializer(serializers.Serializer): type = serializers.CharField(required=False) diff --git a/api/notifications/apis.py b/api/notifications/apis.py index 758f010..94705a1 100644 --- a/api/notifications/apis.py +++ b/api/notifications/apis.py @@ -11,12 +11,12 @@ class Pagination(LimitOffsetPagination): class OutputSerializer(serializers.Serializer): id = serializers.IntegerField() - body = serializers.CharField() - title = serializers.CharField() + case = serializers.IntegerField(source="case.id") level = serializers.CharField() + action = serializers.CharField() + body = serializers.CharField() created_at = serializers.DateTimeField() read_at = serializers.DateTimeField() - sent_to = serializers.URLField(source="sent_to.get_absolute_url") def get(self, request): From 123808bdbf24a99610cc86f831d6caffc6539ca7 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Fri, 27 May 2022 14:06:35 +0200 Subject: [PATCH 80/90] feat: write case contacts model --- api/cases/models.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/api/cases/models.py b/api/cases/models.py index b404b7b..8d44c6c 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -107,3 +107,10 @@ class CaseMatch(models.Model): class CasePhoto(models.Model): file = models.OneToOneField(File, on_delete=models.CASCADE) case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="photos") + + +class CaseContacts(models.Model): + case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="contacts") + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="contacts") + contacted_at = models.DateTimeField(auto_now_add=True) + answered_at = models.DateTimeField(null=True, blank=True, default=None) From 154cbfbdd20193e501931efc07103041780b8d4d Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Fri, 27 May 2022 14:16:47 +0200 Subject: [PATCH 81/90] chore: make database migration --- api/cases/migrations/0004_casecontacts.py | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 api/cases/migrations/0004_casecontacts.py diff --git a/api/cases/migrations/0004_casecontacts.py b/api/cases/migrations/0004_casecontacts.py new file mode 100644 index 0000000..7910991 --- /dev/null +++ b/api/cases/migrations/0004_casecontacts.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.13 on 2022-05-27 12:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('cases', '0003_case_user'), + ] + + operations = [ + migrations.CreateModel( + name='CaseContacts', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('contacted_at', models.DateTimeField(auto_now_add=True)), + ('answered_at', models.DateTimeField(blank=True, default=None, null=True)), + ('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to='cases.case')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to=settings.AUTH_USER_MODEL)), + ], + ), + ] From 746812944590e5df84e0d621a8875619ebe2b5d7 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Fri, 27 May 2022 15:36:22 +0200 Subject: [PATCH 82/90] feat: implement create and update case contacts --- api/cases/apis.py | 45 ++++++++++++++++++- ...04_casecontacts.py => 0004_casecontact.py} | 4 +- api/cases/models.py | 2 +- api/cases/selectors.py | 6 ++- api/cases/services.py | 20 ++++++++- api/cases/urls.py | 8 ++++ 6 files changed, 78 insertions(+), 7 deletions(-) rename api/cases/migrations/{0004_casecontacts.py => 0004_casecontact.py} (92%) diff --git a/api/cases/apis.py b/api/cases/apis.py index 7227494..2fb3275 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -4,8 +4,19 @@ 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, list_user_cases -from api.cases.services import create_case, publish_case +from api.cases.selectors import ( + get_case, + get_case_contact, + list_case, + list_case_match, + list_user_cases, +) +from api.cases.services import ( + create_case, + create_case_contact, + publish_case, + update_case_contact, +) from api.common.utils import inline_serializer @@ -216,3 +227,33 @@ def get(self, request): request=request, view=self, ) + + +class CaseContactCreateApi(APIView): + class InputSerializer(serializers.Serializer): + case_id = serializers.IntegerField() + + class OutputSerializer(serializers.Serializer): + id = serializers.IntegerField() + + def post(self, request): + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + case = get_case( + pk=serializer.validated_data["case_id"], + fetched_by=request.user, + ) + contact = create_case_contact(user=request.user, case=case) + + return Response( + data=self.OutputSerializer(contact).data, + status=status.HTTP_201_CREATED, + ) + + +class CaseContactUpdateApi(APIView): + def get(self, request, case_contact_id): + contact = get_case_contact(pk=case_contact_id) + update_case_contact(contact=contact) + + return Response(status=status.HTTP_200_OK) diff --git a/api/cases/migrations/0004_casecontacts.py b/api/cases/migrations/0004_casecontact.py similarity index 92% rename from api/cases/migrations/0004_casecontacts.py rename to api/cases/migrations/0004_casecontact.py index 7910991..e927853 100644 --- a/api/cases/migrations/0004_casecontacts.py +++ b/api/cases/migrations/0004_casecontact.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.13 on 2022-05-27 12:15 +# Generated by Django 3.2.13 on 2022-05-27 13:12 from django.conf import settings from django.db import migrations, models @@ -14,7 +14,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='CaseContacts', + name='CaseContact', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('contacted_at', models.DateTimeField(auto_now_add=True)), diff --git a/api/cases/models.py b/api/cases/models.py index 8d44c6c..dda4e8c 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -109,7 +109,7 @@ class CasePhoto(models.Model): case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="photos") -class CaseContacts(models.Model): +class CaseContact(models.Model): case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="contacts") user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="contacts") contacted_at = models.DateTimeField(auto_now_add=True) diff --git a/api/cases/selectors.py b/api/cases/selectors.py index 68f7af1..88e3dff 100644 --- a/api/cases/selectors.py +++ b/api/cases/selectors.py @@ -5,7 +5,7 @@ from api.users.models import User from .filters import CaseFilter -from .models import Case, CaseMatch +from .models import Case, CaseContact, CaseMatch def get_case(*, pk: int, fetched_by: User) -> Case: @@ -48,3 +48,7 @@ def list_case_match(*, case: Case, fetched_by: User) -> QuerySet[CaseMatch]: def list_user_cases(user: User) -> QuerySet[Case]: return user.cases.all() + + +def get_case_contact(pk: int) -> CaseContact: + return get_object_or_404(CaseContact, pk=pk) diff --git a/api/cases/services.py b/api/cases/services.py index 1348fd2..d4fec44 100644 --- a/api/cases/services.py +++ b/api/cases/services.py @@ -3,6 +3,7 @@ from django.core.exceptions import PermissionDenied from django.db import transaction +from django.utils import timezone from rest_framework.exceptions import ValidationError from api.common.utils import get_object @@ -13,7 +14,7 @@ from api.notifications.services import create_notification from api.users.models import User -from .models import Case, CaseDetails, CaseMatch, CasePhoto +from .models import Case, CaseContact, CaseDetails, CaseMatch, CasePhoto # from fcm_django.models import FCMDevice # from firebase_admin.messaging import Message @@ -195,3 +196,20 @@ def publish_case(*, case: Case, performed_by: User): # ) # device = FCMDevice.objects.filter(user=case.user).first() # device.send_message(msg) + + +def create_case_contact(*, user: User, case: Case) -> CaseContact: + contact = CaseContact(case=case, user=user) + contact.full_clean() + contact.save() + + return contact + + +@transaction.atomic +def update_case_contact(contact: CaseContact) -> None: + contact.answered_at = timezone.now() + contact.case.finish() + + contact.case.save() + contact.save() diff --git a/api/cases/urls.py b/api/cases/urls.py index 824d11f..3756b74 100644 --- a/api/cases/urls.py +++ b/api/cases/urls.py @@ -1,6 +1,8 @@ from django.urls import path from .apis import ( + CaseContactCreateApi, + CaseContactUpdateApi, CaseListApi, CaseMatchListApi, CasePublishApi, @@ -14,6 +16,12 @@ path("", CaseListApi.as_view(), name="list"), path("/", DetailsCaseApi.as_view(), name="detail"), path("create/", CreateCaseApi.as_view(), name="create"), + path("contacts/create/", CaseContactCreateApi.as_view(), name="create_contact"), + path( + "contacts//update/", + CaseContactUpdateApi.as_view(), + name="update_contact", + ), # path("/update/", UpdateCaseApi.as_view(), name="update"), path("/matches/", CaseMatchListApi.as_view(), name="matches"), path("/publish/", CasePublishApi.as_view(), name="publish"), From 81d30f88d98e0011885481b175cda880be165a92 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Fri, 27 May 2022 15:38:05 +0200 Subject: [PATCH 83/90] chore: change pagnition style --- api/cases/apis.py | 2 +- api/notifications/apis.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/cases/apis.py b/api/cases/apis.py index 7227494..c4368a5 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -192,7 +192,7 @@ class Pagination(LimitOffsetPagination): class OutputSerializer(serializers.Serializer): id = serializers.IntegerField() type = serializers.CharField() - state = serializers.CharField(source="get_state_display") + state = serializers.CharField() name = serializers.CharField(source="details.name") thumbnail = serializers.URLField(source="thumbnail.url") last_seen = serializers.DateField(source="details.last_seen") diff --git a/api/notifications/apis.py b/api/notifications/apis.py index 94705a1..a7a4cdf 100644 --- a/api/notifications/apis.py +++ b/api/notifications/apis.py @@ -11,7 +11,8 @@ class Pagination(LimitOffsetPagination): class OutputSerializer(serializers.Serializer): id = serializers.IntegerField() - case = serializers.IntegerField(source="case.id") + case_id = serializers.IntegerField(source="case.id") + case_type = serializers.CharField(source="case.type") level = serializers.CharField() action = serializers.CharField() body = serializers.CharField() From 51948bfddc35ea15e81dd3472d79c9a3f47f5cf5 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Fri, 27 May 2022 16:16:12 +0200 Subject: [PATCH 84/90] chore production settings --- compose/production/traefik/traefik.yml | 2 +- config/settings/base.py | 2 +- production.yml | 48 +++++++++++++------------- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/compose/production/traefik/traefik.yml b/compose/production/traefik/traefik.yml index de1f88f..8949a59 100644 --- a/compose/production/traefik/traefik.yml +++ b/compose/production/traefik/traefik.yml @@ -31,7 +31,7 @@ certificatesResolvers: http: routers: web-secure-router: - rule: "Host(`mafqud.com`) || Host(`www.mafqud.com`)" + rule: "Host(`ec2-15-161-158-189.eu-south-1.compute.amazonaws.com`) || Host(`ec2-15-161-158-189.eu-south-1.compute.amazonaws.com`)" entryPoints: - web-secure middlewares: diff --git a/config/settings/base.py b/config/settings/base.py index 2d00739..6f795d1 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -346,7 +346,7 @@ "SERVERS": [ {"url": "http://127.0.0.1:8000", "description": "Local Development server"}, { - "url": "ec2-15-160-246-194.eu-south-1.compute.amazonaws.com", + "url": "ec2-15-161-158-189.eu-south-1.compute.amazonaws.com", "description": "Production server", }, ], diff --git a/production.yml b/production.yml index 2714e81..a2feac3 100644 --- a/production.yml +++ b/production.yml @@ -11,10 +11,10 @@ services: context: . dockerfile: ./compose/production/django/Dockerfile image: api_production_django - platform: linux/x86_64 + # platform: linux/x86_64 depends_on: - postgres - - redis + # - redis env_file: - ./.envs/.production/.django - ./.envs/.production/.postgres @@ -45,29 +45,29 @@ services: - "0.0.0.0:443:443" - "0.0.0.0:5555:5555" - redis: - image: redis:6 + # redis: + # image: redis:6 - celeryworker: - <<: *django - image: api_production_celeryworker - command: /start-celeryworker + # celeryworker: + # <<: *django + # image: api_production_celeryworker + # command: /start-celeryworker - celerybeat: - <<: *django - image: api_production_celerybeat - command: /start-celerybeat + # celerybeat: + # <<: *django + # image: api_production_celerybeat + # command: /start-celerybeat - flower: - <<: *django - image: api_production_flower - command: /start-flower + # flower: + # <<: *django + # image: api_production_flower + # command: /start-flower - awscli: - build: - context: . - dockerfile: ./compose/production/aws/Dockerfile - env_file: - - ./.envs/.production/.django - volumes: - - production_postgres_data_backups:/backups:z + # awscli: + # build: + # context: . + # dockerfile: ./compose/production/aws/Dockerfile + # env_file: + # - ./.envs/.production/.django + # volumes: + # - production_postgres_data_backups:/backups:z From b73ac353b51eab41ca3eb29613f1a2f9cf615632 Mon Sep 17 00:00:00 2001 From: Osama Ragab Date: Sun, 29 May 2022 19:50:00 +0200 Subject: [PATCH 85/90] feat: add two new notifications --- api/cases/services.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/api/cases/services.py b/api/cases/services.py index d4fec44..f7f9d1e 100644 --- a/api/cases/services.py +++ b/api/cases/services.py @@ -123,10 +123,16 @@ def process_case(case: Case) -> List[Dict[int, int]]: 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: + create_notification( + case=case, + action=Notification.Action.MATCHES, + title="لم نجد حالات مشابه هل تود فى نشر الحاله", + body="لم نعثر على اى حالات مشابهه يمكنك نشر بيانات المفقود فى نطاق اوسع لتزيد احتماليه العثور عليه", + level=Notification.Level.WARNING, + sent_to=case.user, + ) return cases_ids = [match["id"] for match in matches_list] @@ -141,6 +147,24 @@ def case_matching_binding(*, case: Case, matches_list: List[Dict[int, int]]) -> else: create_case_match(missing=match, found=case, score=score) + create_notification( + case=match, + action=Notification.Action.MATCHES, + title="تم العثور على حالات مشابه", + body="تم الوصول لبعض النتائج قم بتصفحها الان", + level=Notification.Level.SUCCESS, + sent_to=match.user, + ) + + create_notification( + case=case, + action=Notification.Action.MATCHES, + title="تم العثور على حالات مشابه", + body="تم الوصول لبعض النتائج قم بتصفحها الان", + level=Notification.Level.SUCCESS, + sent_to=case.user, + ) + def activate_case(case: Case): matches = process_case(case) From f82e6b23c2e7b57a25f877e2c9c824828c36b017 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Fri, 3 Jun 2022 20:51:02 +0200 Subject: [PATCH 86/90] fix: update aws signature version to v4 --- api/integrations/aws/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/integrations/aws/client.py b/api/integrations/aws/client.py index c8fc224..bdaeecc 100644 --- a/api/integrations/aws/client.py +++ b/api/integrations/aws/client.py @@ -3,6 +3,7 @@ import boto3 from attrs import define +from botocore.config import Config from api.common.utils import assert_settings @@ -51,7 +52,9 @@ def s3_get_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, + region_name="eu-south-1", + endpoint_url="https://s3.eu-south-1.amazonaws.com", + config=Config(signature_version="s3v4"), ) From 23bd1631b483324d1e45d714078af1d328412649 Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Fri, 3 Jun 2022 20:57:49 +0200 Subject: [PATCH 87/90] fix: increase the expiration time of aws presigned url to 5 mins --- api/integrations/aws/client.py | 2 +- config/settings/base.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/integrations/aws/client.py b/api/integrations/aws/client.py index bdaeecc..0381508 100644 --- a/api/integrations/aws/client.py +++ b/api/integrations/aws/client.py @@ -53,7 +53,7 @@ def s3_get_client(): aws_access_key_id=credentials.access_key_id, aws_secret_access_key=credentials.secret_access_key, region_name="eu-south-1", - endpoint_url="https://s3.eu-south-1.amazonaws.com", + endpoint_url="https://s3.eu-south-1.amazonaws.com/", config=Config(signature_version="s3v4"), ) diff --git a/config/settings/base.py b/config/settings/base.py index 6f795d1..6b8da10 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -401,4 +401,4 @@ # 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 + AWS_PRESIGNED_EXPIRY = env.int("AWS_PRESIGNED_EXPIRY", default=300) # seconds From a5034b2533019deb4a71476151c4ff33b01c5473 Mon Sep 17 00:00:00 2001 From: Osama Ragab <54740178+OsamaRagab520@users.noreply.github.com> Date: Sun, 5 Jun 2022 23:27:07 +0200 Subject: [PATCH 88/90] feat: update case'name filter (#72) --- api/cases/apis.py | 1 + api/cases/filters.py | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/api/cases/apis.py b/api/cases/apis.py index 499d4c7..42003fd 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -73,6 +73,7 @@ class FilterSerializer(serializers.Serializer): end_date = serializers.DateField(required=False) gov = serializers.IntegerField(required=False) name = serializers.CharField(required=False) + include_null = serializers.BooleanField(required=False) class OutputSerializer(serializers.Serializer): id = serializers.IntegerField() diff --git a/api/cases/filters.py b/api/cases/filters.py index af1c4f6..5b62243 100644 --- a/api/cases/filters.py +++ b/api/cases/filters.py @@ -1,4 +1,5 @@ import django_filters +from django.db.models.query import Q from .models import Case @@ -8,9 +9,7 @@ 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" - ) + name = django_filters.CharFilter(field_name="details__name", method="filter_name") start_age = django_filters.NumberFilter( field_name="details__age", lookup_expr="gte" @@ -24,6 +23,14 @@ class CaseFilter(django_filters.FilterSet): field_name="details__last_seen", lookup_expr="lte" ) + def filter_name(self, queryset, name, value): + if self.data.get("include_null"): + return queryset.filter( + Q(details__name__icontains=value) | Q(details__name__isnull=True) + ) + + return queryset.filter(details__name__icontains=value) + class Meta: model = Case fields = [ From c16578b169fdf88e16fcceb0e880c5d18db0ca0f Mon Sep 17 00:00:00 2001 From: Osama Yasser Date: Tue, 7 Jun 2022 03:44:32 +0200 Subject: [PATCH 89/90] feat: activate push notifications --- .envs/.local/.django | 2 +- .gitignore | 3 +++ api/cases/services.py | 38 +++++++++++++++++++------------------- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/.envs/.local/.django b/.envs/.local/.django index a6cb70f..1433f91 100644 --- a/.envs/.local/.django +++ b/.envs/.local/.django @@ -20,4 +20,4 @@ FILE_UPLOAD_STORAGE=local # Firebase # ------------------------------------------------------------------------------ -GOOGLE_APPLICATION_CREDENTIALS=/home/$USER/google-services.json +GOOGLE_APPLICATION_CREDENTIALS=/app/google-credentials.json diff --git a/.gitignore b/.gitignore index 16c9eec..34c36c1 100644 --- a/.gitignore +++ b/.gitignore @@ -283,3 +283,6 @@ api/media/ # Media files media + +# Google credentials +google-credentials.json diff --git a/api/cases/services.py b/api/cases/services.py index fda31bd..338d527 100644 --- a/api/cases/services.py +++ b/api/cases/services.py @@ -4,6 +4,9 @@ from django.core.exceptions import PermissionDenied from django.db import transaction from django.utils import timezone +from fcm_django.models import FCMDevice +from firebase_admin.messaging import Message +from firebase_admin.messaging import Notification as FirebaseNotification from rest_framework.exceptions import ValidationError from api.common.utils import get_object @@ -16,10 +19,6 @@ from .models import Case, CaseContact, 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 @@ -180,14 +179,15 @@ def activate_case(case: Case): sent_to=case.user, ) - # msg = Message( - # notification=FirebaseNotification( - # title="تم رفع الحاله بنجاح", - # body="جارى البحث عن المفقود وسنقوم بإشعارك فى حاله العثور لأى نتائج", - # ) - # ) + msg = Message( + notification=FirebaseNotification( + title="تم رفع الحاله بنجاح", + body="جارى البحث عن المفقود وسنقوم بإشعارك فى حاله العثور لأى نتائج", + ) + ) - # case.user.fcmdevice.send_message(msg) + device = FCMDevice.objects.filter(user=case.user).first() + device.send_message(msg) def publish_case(*, case: Case, performed_by: User): @@ -212,14 +212,14 @@ def publish_case(*, case: Case, performed_by: User): sent_to=case.user, ) - # msg = Message( - # notification=FirebaseNotification( - # title="تم نشر الحاله بنجاح", - # body="تم نشر بيانات المعثور عليه بنجاح انتظر منا اشعار اخر فى حين الوصول لأى نتائج", - # ) - # ) - # device = FCMDevice.objects.filter(user=case.user).first() - # device.send_message(msg) + msg = Message( + notification=FirebaseNotification( + title="تم نشر الحاله بنجاح", + body="تم نشر بيانات المعثور عليه بنجاح انتظر منا اشعار اخر فى حين الوصول لأى نتائج", + ) + ) + device = FCMDevice.objects.filter(user=case.user).first() + device.send_message(msg) def create_case_contact(*, user: User, case: Case) -> CaseContact: From 7a5e921e78a79ceda715d279a6ab1ad57186e065 Mon Sep 17 00:00:00 2001 From: Osama Ragab <54740178+OsamaRagab520@users.noreply.github.com> Date: Tue, 7 Jun 2022 14:32:25 +0200 Subject: [PATCH 90/90] feat: add case state update endpoints (#81) --- api/cases/apis.py | 16 ++++++++++ api/cases/models.py | 2 ++ api/cases/services.py | 74 +++++++++++++++++++++++++++++++++++++++++++ api/cases/urls.py | 4 +++ 4 files changed, 96 insertions(+) diff --git a/api/cases/apis.py b/api/cases/apis.py index 42003fd..7c4493b 100644 --- a/api/cases/apis.py +++ b/api/cases/apis.py @@ -12,8 +12,10 @@ list_user_cases, ) from api.cases.services import ( + archive_case, create_case, create_case_contact, + finish_case, publish_case, update_case_contact, ) @@ -197,6 +199,20 @@ def get(self, request, case_id): return Response(status=status.HTTP_200_OK) +class CaseFinishApi(APIView): + def get(self, request, case_id): + case = get_case(pk=case_id, fetched_by=request.user) + finish_case(case=case, performed_by=request.user) + return Response(status=status.HTTP_200_OK) + + +class CaseArchiveApi(APIView): + def get(self, request, case_id): + case = get_case(pk=case_id, fetched_by=request.user) + archive_case(case=case, performed_by=request.user) + return Response(status=status.HTTP_200_OK) + + class UserCasesListApi(APIView): class Pagination(LimitOffsetPagination): default_limit = 10 diff --git a/api/cases/models.py b/api/cases/models.py index dda4e8c..cd1a5ca 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -58,11 +58,13 @@ def activate(self): @transition(field=state, source=States.ACTIVE, target=States.FINISHED) def finish(self): self.is_active = False + self.posted_at = None # Switch to ARCHIVED state from any state except ARCHIVED @transition(field=state, source="+", target=States.ARCHIVED) def archive(self): self.is_active = False + self.posted_at = None # If user selected incorrect match or lost again @transition( diff --git a/api/cases/services.py b/api/cases/services.py index 338d527..819a056 100644 --- a/api/cases/services.py +++ b/api/cases/services.py @@ -75,6 +75,7 @@ def update_case(): ... +@transaction.atomic def create_case_details( *, case: Case, @@ -124,6 +125,7 @@ def process_case(case: Case) -> List[Dict[int, int]]: def case_matching_binding(*, case: Case, matches_list: List[Dict[int, int]]) -> None: """ """ if not matches_list: + # TODO refactor notifications create_notification( case=case, action=Notification.Action.PUBLISH, @@ -132,6 +134,16 @@ def case_matching_binding(*, case: Case, matches_list: List[Dict[int, int]]) -> level=Notification.Level.WARNING, sent_to=case.user, ) + msg = Message( + notification=FirebaseNotification( + title="لم نجد حالات مشابه هل تود فى نشر الحاله", + body="لم نعثر على اى حالات مشابهه يمكنك نشر بيانات المفقود فى نطاق اوسع لتزيد احتماليه العثور عليه", + ) + ) + + device = FCMDevice.objects.filter(user=case.user).first() + device.send_message(msg) + return cases_ids = [match["id"] for match in matches_list] @@ -154,6 +166,15 @@ def case_matching_binding(*, case: Case, matches_list: List[Dict[int, int]]) -> level=Notification.Level.SUCCESS, sent_to=match.user, ) + msg = Message( + notification=FirebaseNotification( + title="تم العثور على حالات مشابه", + body="تم الوصول لبعض النتائج قم بتصفحها الان", + ) + ) + + device = FCMDevice.objects.filter(user=match.user).first() + device.send_message(msg) create_notification( case=case, @@ -163,8 +184,18 @@ def case_matching_binding(*, case: Case, matches_list: List[Dict[int, int]]) -> 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) + +@transaction.atomic def activate_case(case: Case): matches = process_case(case) case_matching_binding(case=case, matches_list=matches) @@ -190,6 +221,7 @@ def activate_case(case: Case): device.send_message(msg) +# TODO refactor object permission on view level to some mixin or permission class def publish_case(*, case: Case, performed_by: User): if case.user != performed_by: raise PermissionDenied() @@ -222,6 +254,48 @@ def publish_case(*, case: Case, performed_by: User): device.send_message(msg) +def archive_case(*, case: Case, performed_by: User): + if case.user != performed_by: + raise PermissionDenied() + + if case.state == Case.States.ARCHIVED: + raise ValidationError("Case already archived") + + case.archive() + case.save() + create_notification( + case=case, + action=Notification.Action.PUBLISH, + title="تم ارشفه الحاله بنجاح", + body="تم ارشفه الحاله لن يتمكن اى احد للوصول لها غيرك", + level=Notification.Level.WARNING, + sent_to=case.user, + ) + + +def finish_case(*, case: Case, performed_by: User): + if case.user != performed_by: + raise PermissionDenied() + + if not case.is_active: + raise ValidationError("Cannot finish inactive case") + + if case.state == Case.States.FINISHED: + raise ValidationError("Case already closed") + + case.finish() + case.save() + + create_notification( + case=case, + action=Notification.Action.NONE, + title="تم ارشفه الحاله بنجاح", + body="تم اغلاق الحاله بنجاح نرجو ان يكون ذويك على ما يرام", + level=Notification.Level.SUCCESS, + sent_to=case.user, + ) + + def create_case_contact(*, user: User, case: Case) -> CaseContact: contact = CaseContact(case=case, user=user) contact.full_clean() diff --git a/api/cases/urls.py b/api/cases/urls.py index 3756b74..1dd08d3 100644 --- a/api/cases/urls.py +++ b/api/cases/urls.py @@ -1,8 +1,10 @@ from django.urls import path from .apis import ( + CaseArchiveApi, CaseContactCreateApi, CaseContactUpdateApi, + CaseFinishApi, CaseListApi, CaseMatchListApi, CasePublishApi, @@ -25,4 +27,6 @@ # path("/update/", UpdateCaseApi.as_view(), name="update"), path("/matches/", CaseMatchListApi.as_view(), name="matches"), path("/publish/", CasePublishApi.as_view(), name="publish"), + path("/finish/", CaseFinishApi.as_view(), name="finish"), + path("/archive/", CaseArchiveApi.as_view(), name="archive"), ]