diff --git a/apps/api/afb/settings.py b/apps/api/afb/settings.py index 28f57597..2ad97815 100644 --- a/apps/api/afb/settings.py +++ b/apps/api/afb/settings.py @@ -167,7 +167,7 @@ # https://docs.djangoproject.com/en/4.2/ref/csrf/#how-it-works # CSRF_COOKIE_DOMAIN = "" -TOKEN_EXPIRED_AFTER_WEEKS = 2 +TOKEN_EXPIRED_AFTER_WEEKS = 1 # Static files (CSS, JavaScript, Images) @@ -400,7 +400,15 @@ "PASSWORD": os.getenv("DB_PASSWORD"), "HOST": os.getenv("DB_HOST"), "PORT": os.getenv("DB_PORT"), - } + }, + "test": { + "ENGINE": os.getenv("DB_ENGINE"), + "NAME": "test_" + (os.getenv("DB_NAME") or ""), + "USER": os.getenv("DB_USER"), + "PASSWORD": os.getenv("DB_PASSWORD"), + "HOST": os.getenv("DB_HOST"), + "PORT": os.getenv("DB_PORT"), + }, } # Password validation @@ -451,14 +459,40 @@ class QueryFormatter(logging.Formatter): + """ + This formatter class is designed to enhance logging capabilities by + formatting SQL queries to be more readable. It checks for the presence + of an SQL query in the log record and formats it using `sqlparse` for + better readability in the logs. + """ + def format(self, record): + # Implementation remains the same... record.prettysql = "" - try: - _, rawsql, *_ = record.args - except ValueError: - print("record.args does not contain enough values to unpack") + if hasattr(record, "sql"): + # If record has 'sql' attribute, use it directly + rawsql = record.sql + elif ( + hasattr(record, "args") + and isinstance(record.args, tuple) + and len(record.args) > 1 + ): + # If record has 'args' and it's a tuple with at least 2 elements + rawsql = record.args[1] + else: + # If we can't find SQL, just use an empty string + rawsql = "" + + if isinstance(rawsql, str): + try: + record.prettysql = sqlparse.format(rawsql, reindent=True) + except Exception as e: + # If formatting fails, just use the raw SQL + record.prettysql = rawsql + print(f"SQL formatting failed: {e}") else: - record.prettysql = sqlparse.format(rawsql, reindent=True) + # If rawsql is not a string, convert it to a string + record.prettysql = str(rawsql) # Call the original formatter class to do the actual formatting return super().format(record) @@ -488,7 +522,7 @@ def format(self, record): "filters": ["request_id"], }, "console_db": { - "level": "DEBUG", + "level": "FATAL", "class": "logging.StreamHandler", "formatter": "sql", "filters": ["request_id"], @@ -508,3 +542,17 @@ def format(self, record): }, "root": {"level": LOG_LEVEL, "handlers": ["console"]}, } + +if DEBUG: + # RunServerPlus settings + # https://django-extensions.readthedocs.io/en/stable/runserver_plus.html + RUNSERVER_PLUS_PRINT_SQL_TRUNCATE = 1000 + RUNSERVERPLUS_POLLER_RELOADER_TYPE = "stat" # or 'watchdog' or 'auto' + RUNSERVER_PLUS_EXCLUDE_PATTERNS = [ + "*.sqlite3", + "*.sqlite3-journal", + "#{BASE_DIR}/.local/*", + "#{BASE_DIR}/.git/*", + "**/.pytest_cache/*", + "**/__pycache__/*", + ] diff --git a/apps/api/afb/urls/__init__.py b/apps/api/afb/urls/__init__.py index 4642bd8c..398917bd 100644 --- a/apps/api/afb/urls/__init__.py +++ b/apps/api/afb/urls/__init__.py @@ -4,10 +4,12 @@ """ from afbcore.views import ( + BranchViewSet, FoodRequestViewSet, + PetViewSet, ProfileViewSet, - authtoken, - users, + logout_view, + user_view, ) from django.conf import settings from django.contrib import admin as afbcore_admin @@ -28,17 +30,19 @@ # e.g. /api/v1/requests/abcdef1234/ router.register("requests", FoodRequestViewSet, basename="foodrequest") router.register("profiles", ProfileViewSet, basename="profile") +router.register("branches", BranchViewSet, basename="branch") +router.register("pets", PetViewSet, basename="pet") urlpatterns = [ path("afbadmin/", afbcore_admin.site.urls, name="admin"), path( "api//register/", - users.RegisterUserAPIView.as_view(), + user_view.RegisterUserAPIView.as_view(), name="registration", ), path( "api//current_user/", - users.CurrentUserAPIView.as_view(), + user_view.CurrentUserAPIView.as_view(), name="current_user", ), path("api//", include(router.urls)), @@ -50,7 +54,7 @@ ), path( "api//authtoken/logout/", - authtoken.LogoutView.as_view(), + logout_view.LogoutView.as_view(), name="api_token_logout", ), path( diff --git a/apps/api/afbcore/admin.py b/apps/api/afbcore/admin.py index 848355e0..09ef0547 100644 --- a/apps/api/afbcore/admin.py +++ b/apps/api/afbcore/admin.py @@ -13,6 +13,7 @@ Profile, User, ) +from .views.admin.branch_admin import BranchAdmin class UserAdmin(BaseUserAdmin): @@ -33,7 +34,7 @@ class AuthorAdmin(admin.ModelAdmin): # Register your models here. admin.site.register(User, UserAdmin) admin.site.register(Profile) -admin.site.register(Branch) +# admin.site.register(Branch, BranchAdmin) admin.site.register(FoodRequest) admin.site.register(Delivery) admin.site.register(DeliveryRegion) diff --git a/apps/api/afbcore/fixtures/branches.json b/apps/api/afbcore/fixtures/branches.json index 299f1a48..93f88aea 100644 --- a/apps/api/afbcore/fixtures/branches.json +++ b/apps/api/afbcore/fixtures/branches.json @@ -1,17 +1,47 @@ [ { "model": "afbcore.branch", - "pk": "383e8f22-b671-4b94-90e0-df864828ab05", + "pk": "f1b1b1b1-1b1b-1b1b-1b1b-1b1b1b1b1b1b", "fields": { - "location_name": "Michael's", - "address_line1": "1910 Pembina Hwy", + "location_name": "No branch", + "address_line1": "1234 56th Street", "address_line2": null, - "city": "Winnipeg", - "state_or_province": "MB", - "postal_code": "R3T 4S5", - "country": null, + "city": "Calgary", + "state_or_province": "AB", + "postal_code": "T2N 1A1", + "country": "Canada", + "ext_id": null, + "display_name": "No branch", + "pickup_locations": null, + "frequency_of_requests": "7", + "spay_neuter_requirement": false, + "pets_per_household_max": 4, + "delivery_deadline_days": 14, + "delivery_type": "drop_off", + "delivery_pickup_details": " ", + "blurb": " ", + "blurb_image": "", + "delivery_regions": [], + "latitude": 51.0447, + "longitude": -114.0719, + "delivery_radius": 10, + "hidden": false, + "operational": true + } + }, + { + "model": "afbcore.branch", + "pk": "f684ab06-c155-4b30-b046-80a721879b52", + "fields": { + "location_name": "Medicine Hat", + "address_line1": "375 Kipling Street SE", + "address_line2": null, + "city": "Medicine Hat", + "state_or_province": "AB", + "postal_code": "T1A 1Y9", + "country": "Canada", "ext_id": null, - "display_name": "Michael's Tots", + "display_name": "Medicine Hat", "pickup_locations": null, "frequency_of_requests": "30", "spay_neuter_requirement": false, @@ -21,49 +51,89 @@ "delivery_pickup_details": " ", "blurb": " ", "blurb_image": "", - "delivery_regions": [] + "delivery_regions": [], + "latitude": 50.0417, + "longitude": -110.6766, + "delivery_radius": 20, + "hidden": true, + "operational": false } }, { "model": "afbcore.branch", "pk": "5c3549e0-a728-4510-a64a-69bcd26d52d5", "fields": { - "location_name": "Xanadu's Lot", - "address_line1": "1225 St Mary's Rd", + "location_name": "Osoyoos", + "address_line1": "8702 Main Street", + "address_line2": null, + "city": "Osoyoos", + "state_or_province": "BC", + "postal_code": "V0H 1V0", + "country": "Canada", + "ext_id": null, + "display_name": "Osoyoos", + "pickup_locations": null, + "frequency_of_requests": "14", + "spay_neuter_requirement": false, + "pets_per_household_max": 4, + "delivery_deadline_days": 14, + "delivery_type": "drop_off", + "delivery_pickup_details": " ", + "blurb": " ", + "blurb_image": "", + "delivery_regions": [], + "latitude": 49.0323, + "longitude": -119.4692, + "delivery_radius": 15, + "hidden": false, + "operational": true + } + }, + { + "model": "afbcore.branch", + "pk": "383e8f22-b671-4b94-90e0-df864828ab05", + "fields": { + "location_name": "Winnipeg NW", + "address_line1": "1910 Pembina Hwy", "address_line2": null, "city": "Winnipeg", "state_or_province": "MB", - "postal_code": "R2M 5E5", - "country": null, + "postal_code": "R3T 4S5", + "country": "Canada", "ext_id": null, - "display_name": "Xanadu", + "display_name": "Winnipeg Northwest", "pickup_locations": null, "frequency_of_requests": "7", "spay_neuter_requirement": false, "pets_per_household_max": 4, - "delivery_deadline_days": 30, + "delivery_deadline_days": 14, "delivery_type": "drop_off", "delivery_pickup_details": " ", "blurb": " ", "blurb_image": "", - "delivery_regions": [] + "delivery_regions": [], + "latitude": 49.9530, + "longitude": -97.2110, + "delivery_radius": 10, + "hidden": true, + "operational": false } }, { "model": "afbcore.branch", "pk": "8f786370-f10b-11ee-934b-0fdb3c7dc37f", "fields": { - "location_name": "Martha's", + "location_name": "Winnipeg NE", "address_line1": "400 North Town Rd", "address_line2": null, "city": "Winnipeg", "state_or_province": "MB", "postal_code": "R2V 3C4", - "country": null, + "country": "Canada", "ext_id": null, - "display_name": "Martha's Vines", + "display_name": "Winnipeg Northeast", "pickup_locations": null, - "frequency_of_requests": "14", + "frequency_of_requests": "7", "spay_neuter_requirement": false, "pets_per_household_max": 4, "delivery_deadline_days": 14, @@ -71,24 +141,59 @@ "delivery_pickup_details": " ", "blurb": " ", "blurb_image": "", - "delivery_regions": [] + "delivery_regions": [], + "latitude": 49.9530, + "longitude": -97.0660, + "delivery_radius": 10, + "hidden": true, + "operational": false } }, { "model": "afbcore.branch", - "pk": "f684ab06-c155-4b30-b046-80a721879b52", + "pk": "b5f7e8d2-f10b-11ee-934b-0fdb3c7dc37f", + "fields": { + "location_name": "Winnipeg SW", + "address_line1": "1225 St Mary's Rd", + "address_line2": null, + "city": "Winnipeg", + "state_or_province": "MB", + "postal_code": "R2M 5E5", + "country": "Canada", + "ext_id": null, + "display_name": "Winnipeg Southwest", + "pickup_locations": null, + "frequency_of_requests": "7", + "spay_neuter_requirement": false, + "pets_per_household_max": 4, + "delivery_deadline_days": 14, + "delivery_type": "drop_off", + "delivery_pickup_details": " ", + "blurb": " ", + "blurb_image": "", + "delivery_regions": [], + "latitude": 49.8370, + "longitude": -97.2110, + "delivery_radius": 10, + "hidden": true, + "operational": false + } + }, + { + "model": "afbcore.branch", + "pk": "d1a9f634-f10b-11ee-934b-0fdb3c7dc37f", "fields": { - "location_name": "The Uni", + "location_name": "Winnipeg SE", "address_line1": "1555 Regent Ave W", "address_line2": null, "city": "Winnipeg", "state_or_province": "MB", "postal_code": "R2C 4J2", - "country": null, + "country": "Canada", "ext_id": null, - "display_name": "University Square ", + "display_name": "Winnipeg Southeast", "pickup_locations": null, - "frequency_of_requests": "21", + "frequency_of_requests": "7", "spay_neuter_requirement": false, "pets_per_household_max": 4, "delivery_deadline_days": 14, @@ -96,7 +201,12 @@ "delivery_pickup_details": " ", "blurb": " ", "blurb_image": "", - "delivery_regions": [] + "delivery_regions": [], + "latitude": 49.8370, + "longitude": -97.0660, + "delivery_radius": 10, + "hidden": true, + "operational": false } } ] diff --git a/apps/api/afbcore/migrations/0003_alter_profile_options_branch_delivery_radius_and_more.py b/apps/api/afbcore/migrations/0003_alter_profile_options_branch_delivery_radius_and_more.py new file mode 100644 index 00000000..24671dd0 --- /dev/null +++ b/apps/api/afbcore/migrations/0003_alter_profile_options_branch_delivery_radius_and_more.py @@ -0,0 +1,71 @@ +# Generated by Django 5.1b1 on 2024-06-27 22:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("afbcore", "0002_rename_status_foodrequest_request_status"), + ] + + operations = [ + migrations.AlterModelOptions( + name="profile", + options={"ordering": ["-created"]}, + ), + migrations.AddField( + model_name="branch", + name="delivery_radius", + field=models.IntegerField( + blank=True, + default=1, + help_text="Delivery radius in kilometers", + null=True, + ), + ), + migrations.AddField( + model_name="branch", + name="latitude", + field=models.FloatField( + blank=True, help_text="Latitude", null=True + ), + ), + migrations.AddField( + model_name="branch", + name="longitude", + field=models.FloatField( + blank=True, help_text="Longitude", null=True + ), + ), + migrations.AddField( + model_name="foodrequest", + name="address_instructions", + field=models.TextField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name="foodrequest", + name="request_status", + field=models.CharField( + choices=[ + ("submitted", "Request Submitted"), + ("approved", "Request Approved & in Queue"), + ("denied", "Request Denied"), + ("assigned", "Volunteer Assigned"), + ("ready_for_pickup", "Request Ready For Volunteer Pickup"), + ("scheduled", "Delivery Scheduled"), + ("out_for_delivery", "Out For Delivery"), + ("delivered", "Delivered"), + ("undeliverable", "Undeliverable"), + ], + default="received", + max_length=50, + ), + ), + migrations.AlterField( + model_name="profile", + name="delivery_regions", + field=models.ManyToManyField( + related_name="delivery_regions", to="afbcore.deliveryregion" + ), + ), + ] diff --git a/apps/api/afbcore/migrations/0004_branch_hidden_branch_operational.py b/apps/api/afbcore/migrations/0004_branch_hidden_branch_operational.py new file mode 100644 index 00000000..44bc54d2 --- /dev/null +++ b/apps/api/afbcore/migrations/0004_branch_hidden_branch_operational.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1b1 on 2024-06-27 23:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "afbcore", + "0003_alter_profile_options_branch_delivery_radius_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="branch", + name="hidden", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="branch", + name="operational", + field=models.BooleanField(default=True), + ), + ] diff --git a/apps/api/afbcore/migrations/0005_branch_created_branch_details_branch_is_removed_and_more.py b/apps/api/afbcore/migrations/0005_branch_created_branch_details_branch_is_removed_and_more.py new file mode 100644 index 00000000..5f35de04 --- /dev/null +++ b/apps/api/afbcore/migrations/0005_branch_created_branch_details_branch_is_removed_and_more.py @@ -0,0 +1,147 @@ +# Generated by Django 5.1b1 on 2024-06-28 14:51 + +import django.utils.timezone +import model_utils.fields +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("afbcore", "0004_branch_hidden_branch_operational"), + ] + + operations = [ + migrations.AddField( + model_name="branch", + name="created", + field=model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + migrations.AddField( + model_name="branch", + name="details", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="branch", + name="is_removed", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="branch", + name="modified", + field=model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + migrations.AddField( + model_name="delivery", + name="created", + field=model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + migrations.AddField( + model_name="delivery", + name="details", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="delivery", + name="is_removed", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="delivery", + name="modified", + field=model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + migrations.AddField( + model_name="deliveryregion", + name="details", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="foodavailable", + name="created", + field=model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + migrations.AddField( + model_name="foodavailable", + name="details", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="foodavailable", + name="is_removed", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="foodavailable", + name="modified", + field=model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + migrations.AddField( + model_name="foodrequest", + name="details", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="pet", + name="created", + field=model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + migrations.AddField( + model_name="pet", + name="details", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="pet", + name="is_removed", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="pet", + name="modified", + field=model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + migrations.AlterField( + model_name="delivery", + name="id", + field=model_utils.fields.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ] diff --git a/apps/api/afbcore/migrations/0006_foodrequest_ext_address_details.py b/apps/api/afbcore/migrations/0006_foodrequest_ext_address_details.py new file mode 100644 index 00000000..7aa7094d --- /dev/null +++ b/apps/api/afbcore/migrations/0006_foodrequest_ext_address_details.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1b1 on 2024-06-30 01:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "afbcore", + "0005_branch_created_branch_details_branch_is_removed_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="foodrequest", + name="ext_address_details", + field=models.JSONField(default=dict), + ), + ] diff --git a/apps/api/afbcore/migrations/0007_profile_address_details_profile_ext_address_details.py b/apps/api/afbcore/migrations/0007_profile_address_details_profile_ext_address_details.py new file mode 100644 index 00000000..3429fd0f --- /dev/null +++ b/apps/api/afbcore/migrations/0007_profile_address_details_profile_ext_address_details.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1b1 on 2024-06-30 14:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("afbcore", "0006_foodrequest_ext_address_details"), + ] + + operations = [ + migrations.AddField( + model_name="profile", + name="address_details", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="profile", + name="ext_address_details", + field=models.JSONField(default=dict), + ), + ] diff --git a/apps/api/afbcore/migrations/0008_remove_profile_branches_profile_branch.py b/apps/api/afbcore/migrations/0008_remove_profile_branches_profile_branch.py new file mode 100644 index 00000000..ef82581f --- /dev/null +++ b/apps/api/afbcore/migrations/0008_remove_profile_branches_profile_branch.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1b1 on 2024-06-30 16:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("afbcore", "0007_profile_address_details_profile_ext_address_details"), + ] + + operations = [ + migrations.RemoveField( + model_name="profile", + name="branches", + ), + migrations.AddField( + model_name="profile", + name="branch", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="profiles", + to="afbcore.branch", + ), + ), + ] diff --git a/apps/api/afbcore/migrations/0009_alter_branch_options_pet_profile.py b/apps/api/afbcore/migrations/0009_alter_branch_options_pet_profile.py new file mode 100644 index 00000000..51e6bf31 --- /dev/null +++ b/apps/api/afbcore/migrations/0009_alter_branch_options_pet_profile.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1b1 on 2024-06-30 21:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("afbcore", "0008_remove_profile_branches_profile_branch"), + ] + + operations = [ + migrations.AlterModelOptions( + name="branch", + options={ + "ordering": ["-created"], + "verbose_name_plural": "Branches", + }, + ), + migrations.AddField( + model_name="pet", + name="profile", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="pets", + to="afbcore.profile", + ), + ), + ] diff --git a/apps/api/afbcore/migrations/0010_alter_deliveryregion_options_alter_profile_options_and_more.py b/apps/api/afbcore/migrations/0010_alter_deliveryregion_options_alter_profile_options_and_more.py new file mode 100644 index 00000000..eff751fe --- /dev/null +++ b/apps/api/afbcore/migrations/0010_alter_deliveryregion_options_alter_profile_options_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1b1 on 2024-07-01 15:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("afbcore", "0009_alter_branch_options_pet_profile"), + ] + + operations = [ + migrations.AlterModelOptions( + name="deliveryregion", + options={ + "ordering": ["name"], + "verbose_name_plural": "Delivery Regions", + }, + ), + migrations.AlterModelOptions( + name="profile", + options={"ordering": ["created"]}, + ), + migrations.AddField( + model_name="pet", + name="spay_or_neutered", + field=models.BooleanField(default=None, null=True), + ), + ] diff --git a/apps/api/afbcore/migrations/0011_pet_animal_details.py b/apps/api/afbcore/migrations/0011_pet_animal_details.py new file mode 100644 index 00000000..718f6a91 --- /dev/null +++ b/apps/api/afbcore/migrations/0011_pet_animal_details.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1b1 on 2024-07-01 15:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "afbcore", + "0010_alter_deliveryregion_options_alter_profile_options_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="pet", + name="animal_details", + field=models.JSONField(default=dict), + ), + ] diff --git a/apps/api/afbcore/migrations/0012_alter_foodrequest_contact_email_and_more.py b/apps/api/afbcore/migrations/0012_alter_foodrequest_contact_email_and_more.py new file mode 100644 index 00000000..d8483c74 --- /dev/null +++ b/apps/api/afbcore/migrations/0012_alter_foodrequest_contact_email_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1b1 on 2024-07-01 18:19 + +import phonenumber_field.modelfields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("afbcore", "0011_pet_animal_details"), + ] + + operations = [ + migrations.AlterField( + model_name="foodrequest", + name="contact_email", + field=models.EmailField(blank=True, max_length=254, null=True), + ), + migrations.AlterField( + model_name="foodrequest", + name="contact_phone", + field=phonenumber_field.modelfields.PhoneNumberField( + blank=True, max_length=128, null=True, region="CA" + ), + ), + ] diff --git a/apps/api/afbcore/models/branch.py b/apps/api/afbcore/models/branch.py index 249c363f..30fae892 100644 --- a/apps/api/afbcore/models/branch.py +++ b/apps/api/afbcore/models/branch.py @@ -1,12 +1,16 @@ import uuid from django.db import models -from .mixins import PhysicalLocationMixin +from .base import BaseAbstractModel +from .mixins import HasDetailsMixin, PhysicalLocationMixin -class Branch(PhysicalLocationMixin): + +class Branch(HasDetailsMixin, PhysicalLocationMixin, BaseAbstractModel): class Meta: + app_label = "afbcore" verbose_name_plural = "Branches" + ordering = ["-created"] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -53,4 +57,21 @@ class Meta: blurb = models.TextField(blank=True, null=True) # Blurb image A picture to go along with the blurb - blurb_image = models.ImageField(upload_to="branch_images/", blank=True, null=True) + blurb_image = models.ImageField( + upload_to="branch_images/", blank=True, null=True + ) + + latitude = models.FloatField(null=True, blank=True, help_text="Latitude") + longitude = models.FloatField(null=True, blank=True, help_text="Longitude") + delivery_radius = models.IntegerField( + default=1, + help_text="Delivery radius in kilometers", + null=True, + blank=True, + ) + + # Hide this branch from the public site + hidden = models.BooleanField(default=False) + + # Operational status of the branch + operational = models.BooleanField(default=True) diff --git a/apps/api/afbcore/models/delivery.py b/apps/api/afbcore/models/delivery.py index 01334978..55d29834 100644 --- a/apps/api/afbcore/models/delivery.py +++ b/apps/api/afbcore/models/delivery.py @@ -1,12 +1,18 @@ import uuid + from django.db import models +from .base import BaseAbstractModel +from .mixins import HasDetailsMixin + -class Delivery(models.Model): +class Delivery(HasDetailsMixin, BaseAbstractModel): client = models.ForeignKey("User", on_delete=models.DO_NOTHING) food_request = models.ForeignKey("FoodRequest", on_delete=models.DO_NOTHING) - food_available = models.ForeignKey("FoodAvailable", on_delete=models.DO_NOTHING) + food_available = models.ForeignKey( + "FoodAvailable", on_delete=models.DO_NOTHING + ) delivery_date = models.DateField() delivery_time = models.TimeField() diff --git a/apps/api/afbcore/models/delivery_region.py b/apps/api/afbcore/models/delivery_region.py index d2aad85e..5db11ec1 100644 --- a/apps/api/afbcore/models/delivery_region.py +++ b/apps/api/afbcore/models/delivery_region.py @@ -3,7 +3,7 @@ from django.db import models from .base import BaseAbstractModel -from .mixins import PhysicalLocationMixin +from .mixins import HasDetailsMixin, PhysicalLocationMixin """ Run these tests with the following command: @@ -12,7 +12,7 @@ """ -class DeliveryRegion(PhysicalLocationMixin, BaseAbstractModel): +class DeliveryRegion(PhysicalLocationMixin, HasDetailsMixin, BaseAbstractModel): """ Represents a delivery region with an address. @@ -38,6 +38,11 @@ class DeliveryRegion(PhysicalLocationMixin, BaseAbstractModel): ) description = models.TextField(null=True, blank=True) + class Meta: + app_label = "afbcore" + verbose_name_plural = "Delivery Regions" + ordering = ["name"] + def __str__(self): return self.name diff --git a/apps/api/afbcore/models/food_available.py b/apps/api/afbcore/models/food_available.py index 520b412b..60b08090 100644 --- a/apps/api/afbcore/models/food_available.py +++ b/apps/api/afbcore/models/food_available.py @@ -1,9 +1,12 @@ """This module defines the FoodAvailable model.""" import uuid +from enum import Enum from django.db import models -from enum import Enum + +from .base import BaseAbstractModel +from .mixins import HasDetailsMixin class PetType(Enum): @@ -12,7 +15,7 @@ class PetType(Enum): OTHER = "Other" -class FoodAvailable(models.Model): +class FoodAvailable(HasDetailsMixin, BaseAbstractModel): """ Needs to support fields for different types of pets (e.g. cats, dogs, and other pets), as well as different types of food (e.g. dry and wet) and different amounts of food based on the size of the pet. diff --git a/apps/api/afbcore/models/food_request.py b/apps/api/afbcore/models/food_request.py index eff49912..8deee365 100644 --- a/apps/api/afbcore/models/food_request.py +++ b/apps/api/afbcore/models/food_request.py @@ -7,6 +7,7 @@ from phonenumber_field.modelfields import PhoneNumberField from .base import BaseAbstractModel +from .mixins import HasDetailsMixin STATUS_CHOICES = [ ("submitted", "Request Submitted"), @@ -32,7 +33,7 @@ ) -class FoodRequest(BaseAbstractModel): +class FoodRequest(HasDetailsMixin, BaseAbstractModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # TODO: Should this be a foreign key to the user model? A user can have @@ -60,12 +61,17 @@ class FoodRequest(BaseAbstractModel): default=BUILDING_TYPE_CHOICES.NOT_SPECIFIED, ) address_details = models.JSONField(default=dict) + ext_address_details = models.JSONField(default=dict) + + address_instructions = models.TextField( + max_length=50, null=True, blank=True + ) # A PhoneNumberField, which is a custom field provided by the 'phonenumber_field' library. It stores a phone number in a standardized format and includes region-specific validation. The 'region' parameter is set to "CA" to indicate that the phone number should be formatted according to Canadian standards. - contact_phone = PhoneNumberField(region="CA", blank=True) + contact_phone = PhoneNumberField(region="CA", blank=True, null=True) # An EmailField, which is a built-in field provided by Django. It stores an email address and performs basic email validation. - contact_email = models.EmailField(blank=True) + contact_email = models.EmailField(blank=True, null=True) # Someone else may be there for the food delivery, or they may prefer a different name. contact_name = models.CharField(max_length=100) diff --git a/apps/api/afbcore/models/mixins.py b/apps/api/afbcore/models/mixins.py index 1f531b93..f1475180 100644 --- a/apps/api/afbcore/models/mixins.py +++ b/apps/api/afbcore/models/mixins.py @@ -1,7 +1,7 @@ from django.db import models -class HasDetails(models.Model): +class HasDetailsMixin(models.Model): """ A mixin for models that have a details field. diff --git a/apps/api/afbcore/models/pet.py b/apps/api/afbcore/models/pet.py index b629affb..682412b5 100644 --- a/apps/api/afbcore/models/pet.py +++ b/apps/api/afbcore/models/pet.py @@ -4,6 +4,9 @@ from django.db import models from django.db.models import JSONField +from .base import BaseAbstractModel +from .mixins import HasDetailsMixin + class PetSize(Enum): TOY = "Toy - up to 10lbs" @@ -13,25 +16,38 @@ class PetSize(Enum): GIANT_BREED = "Giant Breed - 100+ lbs" -class Pet(models.Model): - +class Pet(HasDetailsMixin, BaseAbstractModel): """ Pet model to store information about pets belonging to a profile. - The maximum number of pet profiles that would be allowed - to be created would be deteremined from the Branch - setting of "Number of Pet's Serviced/Houeshold" above + The maximum number of pet profiles that would be allowed to be + created would be deteremined from the Branch setting of "Number + of Pet's Serviced/Houeshold". The default is 4. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - pet_type = models.CharField(max_length=50) # e.g. "Dog", "Cat", etc. + profile = models.ForeignKey( + "Profile", + on_delete=models.CASCADE, + related_name="pets", + null=True, + blank=True, + ) + + pet_type = models.CharField(max_length=50) # e.g. "dog", "cat", etc. pet_name = models.CharField(max_length=50) # e.g. "Frankie" pet_dob = models.CharField(max_length=10) # date of birth food_details = JSONField(default=dict) # JSON blob dog_details = JSONField(default=dict) # JSON blob + animal_details = JSONField(default=dict) # JSON blob + spay_or_neutered = models.BooleanField(default=None, null=True) + + def save(self, *args, **kwargs): + self.pet_type = self.pet_type.lower() + super().save(*args, **kwargs) def __str__(self): - return self.pet_name + return "%s (%s)" % (self.pet_name, self.pet_type) diff --git a/apps/api/afbcore/models/users/profile.py b/apps/api/afbcore/models/users/profile.py index 4311bb3f..13ccef80 100644 --- a/apps/api/afbcore/models/users/profile.py +++ b/apps/api/afbcore/models/users/profile.py @@ -6,7 +6,7 @@ from phonenumber_field.modelfields import PhoneNumberField from ..base import BaseAbstractModel -from ..mixins import HasDetails +from ..mixins import HasDetailsMixin # Profile depends on User and not the other way around from .user import User # noqa: F401 @@ -34,15 +34,16 @@ ] -class Profile(HasDetails, BaseAbstractModel): +class Profile(HasDetailsMixin, BaseAbstractModel): """ A model representing a user profile. Fields: - id: UUIDField, primary key - user: ForeignKey to User model + - pets: ManyToManyField to Pet model + - branch: ForeignKey to Branch model - role: OneToOneField to Role model - - branches: ManyToManyField to Branch model - preferred_name: CharField, max length 64 - email: EmailField, unique - phone_number: PhoneNumberField, max length 20, region US @@ -61,7 +62,9 @@ class Profile(HasDetails, BaseAbstractModel): """ class Meta: - ordering = ["-created"] + # Order by oldest to newest by default so that the first + # profile created is always the "main" profile. + ordering = ["created"] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) user = models.ForeignKey( @@ -69,7 +72,15 @@ class Meta: ) # Usually just one, but can be multiple - branches = models.ManyToManyField("Branch", **MANY_TO_MANY_DEFAULTS) + branch = models.ForeignKey( + "Branch", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="profiles", + ) + + # pets = models.ManyToManyField("Pet") # Name fields preferred_name = models.CharField(max_length=64, null=True) @@ -89,6 +100,15 @@ class Meta: # i.e. An address from Canada Post or Google Maps address = models.CharField(max_length=255, blank=True, null=True) + # An open-ended field for additional address details if needed + address_details = models.JSONField(default=dict) + + # Used to store the google places `PlaceResult` object. + # + # e.g. { 'place': { 'place_id': 'ChIJd8BlQ2BZwokRAFUEcm_qrcA', 'formatted_address': '123 Main St, Winnipeg, MB R3C 1A5, Canada', 'geometry': { 'location': { 'lat': 49.895077, 'lng': -97.138451 }, 'viewport': { 'northeast': { 'lat': 49.89642582989272, 'lng': -97.13710217010728 }, 'southwest': { 'lat': 49.89372617010728, 'lng': -97.13980182989273 } } }, 'name': '123 Main St', 'types': [ 'street_address' ] } + # + ext_address_details = models.JSONField(default=dict) + role = models.CharField( _("role"), choices=ROLE_CHOICES, @@ -132,4 +152,4 @@ class Meta: ) def __str__(self): - return f"{self.preferred_name}" + return f"{self.user}/{self.id}" diff --git a/apps/api/afbcore/models/users/user.py b/apps/api/afbcore/models/users/user.py index 6c7d7294..d68064f0 100644 --- a/apps/api/afbcore/models/users/user.py +++ b/apps/api/afbcore/models/users/user.py @@ -21,7 +21,7 @@ from django.utils.translation import gettext_lazy as _ from model_utils.models import TimeStampedModel, UUIDModel -from ..mixins import HasDetails +from ..mixins import HasDetailsMixin logger = logging.getLogger(__name__) @@ -89,7 +89,7 @@ def create_superuser(self, email, name, password=None): return user -class User(UUIDModel, TimeStampedModel, HasDetails, AbstractUser): +class User(UUIDModel, TimeStampedModel, HasDetailsMixin, AbstractUser): """ A custom user model that extends Django's built-in AbstractUser model. @@ -144,3 +144,9 @@ class User(UUIDModel, TimeStampedModel, HasDetails, AbstractUser): "Indicates whether the user has agreed to the terms of service." ), ) + + def __str__(self): + return "%s" % self.id + + def get_default_profile(self): + return self.profiles.first() diff --git a/apps/api/afbcore/serializers/__init__.py b/apps/api/afbcore/serializers/__init__.py index bd1d4b4f..a19747bc 100644 --- a/apps/api/afbcore/serializers/__init__.py +++ b/apps/api/afbcore/serializers/__init__.py @@ -1,7 +1,9 @@ +from .branch_serializer import BranchSerializer # noqa: F401 from .delivery_region_serializer import DeliveryRegionSerializer # noqa: F401 -from .profile.profile_serializer import ProfileSerializer # noqa: F401 -from .request.food_request_serializer import ( +from .food_request_serializer import ( FoodRequestCreateSerializer, # noqa: F401 FoodRequestUpdateSerializer, # noqa: F401 ) -from .user.user_serializer import UserSerializer # noqa: F401 +from .pet_serializer import PetSerializer # noqa: F401 +from .profile_serializer import ProfileSerializer # noqa: F401 +from .user_serializer import UserSerializer # noqa: F401 diff --git a/apps/api/afbcore/serializers/branch_serializer.py b/apps/api/afbcore/serializers/branch_serializer.py new file mode 100644 index 00000000..8d41b852 --- /dev/null +++ b/apps/api/afbcore/serializers/branch_serializer.py @@ -0,0 +1,105 @@ +from rest_framework import serializers + +from ..models import Branch +from .delivery_region_serializer import DeliveryRegionSerializer + + +class BranchSerializer(serializers.ModelSerializer): + delivery_regions = DeliveryRegionSerializer(many=True, read_only=True) + + class Meta: + model = Branch + fields = [ + "id", + "display_name", + "delivery_regions", + "pickup_locations", + "frequency_of_requests", + "spay_neuter_requirement", + "pets_per_household_max", + "delivery_deadline_days", + "delivery_type", + "delivery_pickup_details", + "blurb", + "blurb_image", + "latitude", + "longitude", + "delivery_radius", + # Updated fields from PhysicalLocationMixin + "location_name", + "address_line1", + "address_line2", + "city", + "state_or_province", + "postal_code", + "country", + "ext_id", + "hidden", + "operational", + "created", + "modified", + ] + # Branch details are read-only in the API. They can + # only be updated via the Django admin interface. + read_only_fields = [ + "id", + "display_name", + "delivery_regions", + "pickup_locations", + "frequency_of_requests", + "spay_neuter_requirement", + "pets_per_household_max", + "delivery_deadline_days", + "delivery_type", + "delivery_pickup_details", + "blurb", + "blurb_image", + "latitude", + "longitude", + "delivery_radius", + # Updated fields from PhysicalLocationMixin + "location_name", + "address_line1", + "address_line2", + "city", + "state_or_province", + "postal_code", + "country", + "ext_id", + "hidden", + "operational", + "created", + "modified", + ] + + def validate_delivery_radius(self, value): + if value is not None and value <= 0: + raise serializers.ValidationError( + "Delivery radius must be greater than 0." + ) + return value + + def validate_pets_per_household_max(self, value): + if value <= 0: + raise serializers.ValidationError( + "Maximum pets per household must be greater than 0." + ) + return value + + def validate_delivery_deadline_days(self, value): + if value <= 0: + raise serializers.ValidationError( + "Delivery deadline days must be greater than 0." + ) + return value + + def validate(self, data): + if data.get("latitude") is not None and data.get("longitude") is None: + raise serializers.ValidationError( + "Both latitude and longitude must be provided together." + ) + if data.get("latitude") is None and data.get("longitude") is not None: + raise serializers.ValidationError( + "Both latitude and longitude must be provided together." + ) + return data diff --git a/apps/api/afbcore/serializers/request/food_request_serializer.py b/apps/api/afbcore/serializers/food_request_serializer.py similarity index 52% rename from apps/api/afbcore/serializers/request/food_request_serializer.py rename to apps/api/afbcore/serializers/food_request_serializer.py index 4750296d..8f514939 100644 --- a/apps/api/afbcore/serializers/request/food_request_serializer.py +++ b/apps/api/afbcore/serializers/food_request_serializer.py @@ -1,16 +1,10 @@ from rest_framework import serializers -from ...models import FoodRequest, Pet - - -class PetSerializer(serializers.ModelSerializer): - class Meta: - model = Pet - fields = "__all__" +from ..models import FoodRequest, Pet +from .pet_serializer import PetSerializer class ClientPetsSerializer(serializers.Serializer): - which_pets = serializers.CharField() pets = PetSerializer(many=True) @@ -19,6 +13,10 @@ class DeliveryContactSerializer(serializers.Serializer): contact_name = serializers.CharField() preferred_method = serializers.CharField() contact_phone = serializers.CharField() + contact_email = serializers.CharField() + alt_contact_name = serializers.CharField() + alt_contact_phone = serializers.CharField() + alt_contact_email = serializers.CharField() class ConfirmationSerializer(serializers.Serializer): @@ -35,7 +33,7 @@ class AbstractFoodRequestSerializer(serializers.ModelSerializer): method_of_contact = serializers.CharField() delivery_contact = DeliveryContactSerializer() - contact_phone = serializers.CharField() + # contact_phone = serializers.CharField() client_pets = ClientPetsSerializer() confirmation = ConfirmationSerializer() @@ -44,12 +42,41 @@ class AbstractFoodRequestSerializer(serializers.ModelSerializer): class Meta: abstract = True model = FoodRequest - fields = [f.name for f in FoodRequest._meta.fields if f.name != "pets"] + fields = [ + "id", + "user", + "pets", + "branch", + "address_text", + "address_google_place_id", + "address_canadapost_id", + "address_latitude", + "address_longitude", + "address_buildingtype", + "address_details", + "ext_address_details", + "address_instructions", + "contact_phone", + "contact_email", + "contact_name", + "method_of_contact", + "safe_drop_agree", + "safe_drop_instructions", + "confirm_correct", + "accept_terms", + "flagged", + "date_requested", + "request_status", + "comments", + "details", + "created", + "modified", + ] class FoodRequestCreateSerializer(AbstractFoodRequestSerializer): class Meta(AbstractFoodRequestSerializer.Meta): - read_only_fields = ["id", "created_at", "updated_at"] + read_only_fields = ["id", "created", "modified"] class FoodRequestUpdateSerializer(AbstractFoodRequestSerializer): @@ -61,5 +88,9 @@ class Meta(AbstractFoodRequestSerializer.Meta): "created", "address_canadapost_id", "address_google_place_id", + "address_latitude", + "address_longitude", + "address_buildingtype", + "address_details", "request_status", ] diff --git a/apps/api/afbcore/serializers/mixins.py b/apps/api/afbcore/serializers/mixins.py index fa1881b1..57a5eaf2 100644 --- a/apps/api/afbcore/serializers/mixins.py +++ b/apps/api/afbcore/serializers/mixins.py @@ -2,24 +2,25 @@ class PhysicalLocationSerializerMixin(serializers.Serializer): - location_name = serializers.CharField(max_length=255) + location_name = serializers.CharField(max_length=50) address_line1 = serializers.CharField( - max_length=255, allow_null=True, required=False + max_length=100, allow_null=True, required=False ) address_line2 = serializers.CharField( - max_length=255, allow_null=True, required=False - ) - city = serializers.CharField( - max_length=255, allow_null=True, required=False + max_length=100, allow_null=True, required=False ) + city = serializers.CharField(max_length=50, allow_null=True, required=False) state_or_province = serializers.CharField( - max_length=255, allow_null=True, required=False + max_length=50, allow_null=True, required=False ) postal_code = serializers.CharField( - max_length=255, allow_null=True, required=False + max_length=16, allow_null=True, required=False ) country = serializers.CharField( - max_length=255, allow_null=True, required=False + max_length=50, allow_null=True, required=False + ) + instructions = serializers.CharField( + max_length=50, allow_null=True, required=False ) ext_id = serializers.CharField( max_length=255, allow_null=True, required=False diff --git a/apps/api/afbcore/serializers/pet_serializer.py b/apps/api/afbcore/serializers/pet_serializer.py new file mode 100644 index 00000000..b834ee2c --- /dev/null +++ b/apps/api/afbcore/serializers/pet_serializer.py @@ -0,0 +1,22 @@ +from rest_framework import serializers + +from ..models import Pet + + +class PetSerializer(serializers.ModelSerializer): + class Meta: + model = Pet + fields = [ + "id", + "pet_name", + "pet_type", + "pet_dob", + "profile", + "food_details", + "dog_details", + "animal_details", + "spay_or_neutered", + "created", + "modified", + ] + read_only_fields = ["created", "modified", "id"] diff --git a/apps/api/afbcore/serializers/profile/profile_serializer.py b/apps/api/afbcore/serializers/profile_serializer.py similarity index 68% rename from apps/api/afbcore/serializers/profile/profile_serializer.py rename to apps/api/afbcore/serializers/profile_serializer.py index 14619a25..75db4e5f 100644 --- a/apps/api/afbcore/serializers/profile/profile_serializer.py +++ b/apps/api/afbcore/serializers/profile_serializer.py @@ -1,13 +1,15 @@ from rest_framework import serializers -from ...models import Profile -from ..delivery_region_serializer import DeliveryRegionSerializer +from ..models import Profile +from .delivery_region_serializer import DeliveryRegionSerializer +from .pet_serializer import PetSerializer class ProfileSerializer(serializers.ModelSerializer): user = serializers.PrimaryKeyRelatedField(read_only=True) - # delivery_regions = serializers.ListField(required=False) - # delivery_regions = serializers.StringRelatedField(many=True) + + pets = PetSerializer(many=True, read_only=True, required=False) + delivery_regions = DeliveryRegionSerializer( many=True, read_only=True, required=False ) @@ -21,15 +23,21 @@ class Meta: fields = [ "id", "user", + "branch", "preferred_name", "phone_number", "address_verbatim", "address", + "address_details", + "ext_address_details", "role", + "pets", # pets are modified in their own serializer "delivery_regions", "validated_postal_code", "country", "status", + "created", + "modified", ] # depth = 1 read_only_fields = [ @@ -37,6 +45,8 @@ class Meta: "user", "status", "country", + "pets", "role", "delivery_regions", + "created", ] diff --git a/apps/api/afbcore/serializers/user/__init__.py b/apps/api/afbcore/serializers/user/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/afbcore/serializers/user/user_serializer.py b/apps/api/afbcore/serializers/user_serializer.py similarity index 97% rename from apps/api/afbcore/serializers/user/user_serializer.py rename to apps/api/afbcore/serializers/user_serializer.py index 93091405..ab72fc76 100644 --- a/apps/api/afbcore/serializers/user/user_serializer.py +++ b/apps/api/afbcore/serializers/user_serializer.py @@ -7,7 +7,7 @@ from django.contrib.auth.password_validation import validate_password from rest_framework import serializers -from ..profile.profile_serializer import ProfileSerializer +from .profile_serializer import ProfileSerializer logger = logging.getLogger(__name__) diff --git a/apps/api/afbcore/tests/models/test_delivery_region.py b/apps/api/afbcore/tests/models/test_delivery_region.py index a0cbb7c9..799f9ce5 100644 --- a/apps/api/afbcore/tests/models/test_delivery_region.py +++ b/apps/api/afbcore/tests/models/test_delivery_region.py @@ -1,4 +1,5 @@ from django.test import TestCase + from afbcore.models.delivery_region import DeliveryRegion """ diff --git a/apps/api/afbcore/tests/models/test_profile.py b/apps/api/afbcore/tests/models/test_profile.py index 7ac184db..0b5fe8ea 100644 --- a/apps/api/afbcore/tests/models/test_profile.py +++ b/apps/api/afbcore/tests/models/test_profile.py @@ -37,19 +37,18 @@ def setUpTestData(cls): validated_postal_code="12345", country="USA", status="active", + branch=cls.Branch, ) - cls.profile.branches.add(cls.Branch) cls.profile.delivery_regions.add(cls.DeliveryRegion) def test_profile_user_relation(self): profile = Profile.objects.get(id=self.profile.id) self.assertEqual(profile.user, self.user) - def test_profile_branches_relation(self): + def test_profile_branch_relation(self): profile = Profile.objects.get(id=self.profile.id) - self.assertEqual(profile.branches.count(), 1) - self.assertEqual(profile.branches.first(), self.Branch) + self.assertEqual(profile.branch, self.Branch) def test_profile_delivery_regions_relation(self): profile = Profile.objects.get(id=self.profile.id) diff --git a/apps/api/afbcore/tests/serializers/test_profile_serializer.py b/apps/api/afbcore/tests/serializers/test_profile_serializer.py index 8197e050..824aff13 100644 --- a/apps/api/afbcore/tests/serializers/test_profile_serializer.py +++ b/apps/api/afbcore/tests/serializers/test_profile_serializer.py @@ -4,7 +4,7 @@ DeliveryRegion, Profile, ) -from afbcore.models.mixins import HasDetails +from afbcore.models.mixins import HasDetailsMixin from afbcore.serializers import DeliveryRegionSerializer, ProfileSerializer from django.contrib.auth import get_user_model from django.test import override_settings diff --git a/apps/api/afbcore/tests/views/test_branch_viewset.py b/apps/api/afbcore/tests/views/test_branch_viewset.py new file mode 100644 index 00000000..82418dd7 --- /dev/null +++ b/apps/api/afbcore/tests/views/test_branch_viewset.py @@ -0,0 +1,67 @@ +import pytest +from afbcore.models import Branch +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +User = get_user_model() + +""" + How to run the tests: + - pnpm django:test apps/api/afbcore/tests/views/test_branch_viewset.py +""" + + +class TestBranchViewSet(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + email="testuser@afb.pet", name="testuser" + ) + self.client.force_authenticate(user=self.user) + + self.branch = Branch.objects.create( + display_name="Test Branch", hidden=False + ) + self.hidden_branch = Branch.objects.create( + display_name="Hidden Branch", hidden=True + ) + self.list_url = reverse("branch-list", kwargs={"version": "v1"}) + self.detail_url = reverse( + "branch-detail", kwargs={"version": "v1", "pk": self.branch.pk} + ) + + def test_retrieve_list_of_branches_with_default_filters(self): + response = self.client.get(self.list_url) + + # Assert that the response status code is 200 OK + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Assert that the response data contains the expected keys + expected_keys = ["count", "next", "previous", "results"] + for key in expected_keys: + self.assertIn( + key, response.data, f"'{key}' is missing from response data" + ) + + # Assert that 'results' contains the expected number of items + self.assertEqual( + len(response.data["results"]), 1 + ) # Only non-hidden branch should be returned + + # Assert that the first item in 'results' has the correct display_name + self.assertEqual( + response.data["results"][0]["display_name"], "Test Branch" + ) + + # Optionally, you can add more specific assertions about 'count', 'next', and 'previous' + self.assertEqual(response.data["count"], 1) + self.assertIsNone(response.data["next"]) + self.assertIsNone(response.data["previous"]) + + def test_retrieve_nonexistent_branch(self): + nonexistent_url = reverse( + "branch-detail", kwargs={"version": "v1", "pk": 9999} + ) + response = self.client.get(nonexistent_url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/apps/api/afbcore/tests/views/test_food_requests_viewset.py b/apps/api/afbcore/tests/views/test_food_requests_viewset.py index 6a3ce94d..13b337e9 100644 --- a/apps/api/afbcore/tests/views/test_food_requests_viewset.py +++ b/apps/api/afbcore/tests/views/test_food_requests_viewset.py @@ -1,7 +1,7 @@ # /apps/api/afbcore/tests/views/test__requests__view.py from afbcore.models import FoodRequest -from afbcore.views.requests import FoodRequestViewSet +from afbcore.views.food_request_view import FoodRequestViewSet from django.contrib.auth import get_user_model from django.test import TestCase from rest_framework import status @@ -63,7 +63,6 @@ def test_create_food_request(self): "pet_dob": "2020", "food_details": { "allergies": "Being picked up", - "usual_brands": "All of them", "foodtype": "Either", }, "dog_details": {"size": "10-20 lbs (Small)"}, diff --git a/apps/api/afbcore/tests/views/test_food_requests_viewset_api.py b/apps/api/afbcore/tests/views/test_food_requests_viewset_api.py index 617242cb..5acdacd4 100644 --- a/apps/api/afbcore/tests/views/test_food_requests_viewset_api.py +++ b/apps/api/afbcore/tests/views/test_food_requests_viewset_api.py @@ -3,7 +3,7 @@ FoodRequestCreateSerializer, FoodRequestUpdateSerializer, ) -from afbcore.views.requests import FoodRequestViewSet +from afbcore.views.food_request_view import FoodRequestViewSet from django.test import RequestFactory from rest_framework.test import APIRequestFactory, APITestCase diff --git a/apps/api/afbcore/tests/views/test_pet_viewset.py b/apps/api/afbcore/tests/views/test_pet_viewset.py new file mode 100644 index 00000000..7a11dffe --- /dev/null +++ b/apps/api/afbcore/tests/views/test_pet_viewset.py @@ -0,0 +1,198 @@ +from afbcore.models.pet import Pet +from afbcore.models.users.profile import Profile +from afbcore.serializers import PetSerializer +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.authtoken.models import Token +from rest_framework.test import APITestCase + +User = get_user_model() + +""" + How to run the tests: + - pnpm django:test apps/api/afbcore/tests/views/test_pet_viewset.py +""" + + +class PetViewSetTestCase(APITestCase): + def setUp(self): + self.user1 = User.objects.create_user(email="user1", name="name1") + self.user2 = User.objects.create_user(email="user2", name="name2") + self.profile1 = Profile.objects.create(user=self.user1) + self.profile2 = Profile.objects.create(user=self.user2) + self.pet1 = Pet.objects.create(pet_name="Pet1", profile=self.profile1) + self.pet2 = Pet.objects.create(pet_name="Pet2", profile=self.profile2) + + # The default client used in these tests is authenticated as user1 + self.token1 = Token.objects.create(user=self.user1) + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token1.key) + + self.list_url = reverse("pet-list", kwargs={"version": "v1"}) + self.detail_url = reverse( + "pet-detail", kwargs={"version": "v1", "pk": self.pet1.pk} + ) + + def tearDown(self): + self.pet1.delete() + self.pet2.delete() + self.profile1.delete() + self.profile2.delete() + self.user1.delete() + self.user2.delete() + + def test_get_pet_list(self): + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["results"][0]["pet_name"], "Pet1") + + def test_get_pet_detail(self): + response = self.client.get(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["pet_name"], "Pet1") + + def test_create_pet(self): + data = { + "pet_name": "NewPet", + "pet_type": "dog", + "pet_dob": "2020", + } + response = self.client.post(self.list_url, data) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Pet.objects.count(), 3) + self.assertEqual( + Pet.objects.get(pet_name="NewPet").profile, self.profile1 + ) + + def test_update_own_pet(self): + data = { + "pet_name": "UpdatedPet", + "pet_type": "dog", + "pet_dob": "2020", + } + response = self.client.put(self.detail_url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.pet1.refresh_from_db() + self.assertEqual(self.pet1.pet_name, "UpdatedPet") + + def test_update_other_user_pet(self): + data = {"pet_name": "UpdatedPet"} + other_pet_url = reverse( + "pet-detail", kwargs={"version": "v1", "pk": self.pet2.pk} + ) + response = self.client.put(other_pet_url, data) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_own_pet(self): + response = self.client.delete(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Pet.objects.count(), 1) + + def test_delete_other_user_pet(self): + other_pet_url = reverse( + "pet-detail", kwargs={"version": "v1", "pk": self.pet2.pk} + ) + response = self.client.delete(other_pet_url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(Pet.objects.count(), 2) + + def test_unauthenticated_access(self): + self.client.credentials() # Remove authentication + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_create_pet_missing_fields(self): + data = { + "pet_name": "NewPet", + "pet_type": "dog", + # Missing pet_dob field + } + response = self.client.post(self.list_url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_update_pet_food_details(self): + data = {"food_details": {"type": "dry", "brand": "Acme"}} + response = self.client.patch(self.detail_url, data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.pet1.refresh_from_db() + self.assertEqual( + self.pet1.food_details, {"type": "dry", "brand": "Acme"} + ) + + def test_user_with_multiple_profiles(self): + """Checks if a user can create and view pets across multiple profiles.""" + + # Create a second profile for user1 + second_profile = Profile.objects.create(user=self.user1) + + # Create a pet for the second profile + data = { + "pet_name": "SecondProfilePet", + "pet_type": "cat", + "pet_dob": "2019", + "profile": second_profile.id, + } + response = self.client.post(self.list_url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # Check that the pet was created and associated with the correct profile + created_pet = Pet.objects.get(pet_name="SecondProfilePet") + self.assertEqual(created_pet.profile, second_profile) + + # Check that the user can see both pets in the list + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 2) + + def test_create_pet_for_other_user_profile(self): + """Ensures a user can't create a pet for a profile that doesn't belong to them.""" + data = { + "pet_name": "OtherUserPet", + "pet_type": "bird", + "pet_dob": "2021", + "profile": self.profile2.id, # This profile belongs to user2 + } + response = self.client.post(self.list_url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_update_pet_to_different_profile_same_user(self): + """Verifies that a user can move a pet between their own profiles.""" + # Create a second profile for user1 + second_profile = Profile.objects.create(user=self.user1) + + data = {"pet_name": "UpdatedPet", "profile": second_profile.id} + response = self.client.patch(self.detail_url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.pet1.refresh_from_db() + self.assertEqual(self.pet1.profile, second_profile) + + def test_update_pet_to_different_user_profile(self): # fail + """Checks that a user can't move a pet to a profile belonging to another user.""" + data = { + "pet_name": "UpdatedPet", + "profile": self.profile2.id, # This profile belongs to user2 + } + response = self.client.patch(self.detail_url, data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_pets_from_all_profiles(self): + """Ensures that when listing pets, the user sees pets from all their profiles.""" + # Create a second profile for user1 and add a pet to it + second_profile = Profile.objects.create(user=self.user1) + Pet.objects.create( + pet_name="SecondProfilePet", + profile=second_profile, + pet_type="cat", + pet_dob="2019", + ) + + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + len(response.data["results"]), 2 + ) # Should see pets from both profiles diff --git a/apps/api/afbcore/tests/views/test_profile_viewset.py b/apps/api/afbcore/tests/views/test_profile_viewset.py new file mode 100644 index 00000000..77f424ba --- /dev/null +++ b/apps/api/afbcore/tests/views/test_profile_viewset.py @@ -0,0 +1,219 @@ +# apps/api/afbcore/tests/views/test_profile_viewset.py + +from afbcore.models import Pet, Profile +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework import status +from rest_framework.authtoken.models import Token +from rest_framework.test import APITestCase + +User = get_user_model() + +""" + How to run the tests: + - pnpm django:test apps/api/afbcore/tests/views/test_profile_viewset.py + - pytest apps/api/afbcore/tests/views/test_profile_viewset.py -v -k test_reconcile_pets_remove +""" + + +class ProfileViewSetTestCase(APITestCase): + def setUp(self): + self.user1 = User.objects.create_user( + email="user1@test.com", name="name123" + ) + self.user2 = User.objects.create_user( + email="user2@test.com", name="name123" + ) + # Create tokens for both users + self.token1 = Token.objects.create(user=self.user1) + self.token2 = Token.objects.create(user=self.user2) + + # Initially authenticate as user1 + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token1.key) + + # Profiles are created automatically when a user is created. + # See signals.py. + self.profile1 = self.user1.get_default_profile() + self.profile2 = self.user2.get_default_profile() + + # An extra profile for user2 + self.profile3 = Profile.objects.create(user=self.user2) + + self.pet1 = Pet.objects.create( + pet_name="Pet1", + profile=self.profile1, + pet_type="dog", + pet_dob="2020", + ) + + # Profile 1, user 1 is the default user for these tests + # self.client.force_authenticate(user=self.user1) + + self.list_url = reverse("profile-list", kwargs={"version": "v1"}) + self.detail_url = reverse( + "profile-detail", kwargs={"version": "v1", "pk": self.profile1.pk} + ) + self.reconcile_url = reverse( + "profile-reconcile-pets", + kwargs={"version": "v1", "pk": self.profile1.pk}, + ) + + def test_get_profile_list(self): + response = self.client.get(self.list_url) + count = response.data["count"] + results = response.data["results"] + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(count, 1) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], str(self.profile1.id)) + + def test_get_profile_list_as_user2(self): + # Clear previous credentials and set new ones for user2 + self.client.credentials() + self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token2.key) + response = self.client.get(self.list_url) + + # For debugging + print(f"Authenticated user: {response.wsgi_request.user}") + print(f"Response data: {response.data}") + + count = response.data["count"] + results = response.data["results"] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(count, 2) + self.assertEqual(len(results), 2) + self.assertEqual(results[0]["id"], str(self.profile2.id)) + self.client.credentials() # Clear credentials after the test + + def test_get_profile_list_multiple(self): + response = self.client.get(self.list_url) + count = response.data["count"] + results = response.data["results"] + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(count, 1) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], str(self.profile1.id)) + + def test_get_profile_detail(self): + response = self.client.get(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], str(self.profile1.id)) + + def test_update_own_profile(self): + data = {"preferred_name": "Updated Name"} + response = self.client.patch(self.detail_url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.profile1.refresh_from_db() + self.assertEqual(self.profile1.preferred_name, "Updated Name") + + def test_update_other_user_profile(self): + other_profile_url = reverse( + "profile-detail", kwargs={"version": "v1", "pk": self.profile2.pk} + ) + data = {"preferred_name": "Updated Name"} + response = self.client.patch(other_profile_url, data) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_profile_not_allowed(self): + response = self.client.delete(self.detail_url) + self.assertEqual( + response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED + ) + + def test_reconcile_pets_add(self): + data = { + "pets": [ + { + "id": str(self.pet1.id), + "pet_name": "Updated Pet1", + "pet_type": "dog", + "pet_dob": "2020", + }, + {"pet_name": "New Pet", "pet_type": "cat", "pet_dob": "2021"}, + ] + } + response = self.client.post(self.reconcile_url, data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Pet.objects.count(), 2) + self.assertTrue(Pet.objects.filter(pet_name="New Pet").exists()) + + def test_reconcile_pets_update(self): + data = { + "pets": [ + { + "id": str(self.pet1.id), + "pet_name": "Updated Pet1", + "pet_type": "cat", + "pet_dob": "2019", + } + ] + } + response = self.client.post(self.reconcile_url, data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.pet1.refresh_from_db() + self.assertEqual(self.pet1.pet_name, "Updated Pet1") + self.assertEqual(self.pet1.pet_type, "cat") + self.assertEqual(self.pet1.pet_dob, "2019") + + def test_reconcile_pets_remove(self): + Pet.objects.create( + pet_name="Pet2", + profile=self.profile1, + pet_type="cat", + pet_dob="2021", + ) + data = { + "pets": [ + { + "id": str(self.pet1.id), + "pet_name": "Pet1", + "pet_type": "dog", + "pet_dob": "2020", + } + ] + } + response = self.client.post(self.reconcile_url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Pet.objects.count(), 1) + self.assertFalse(Pet.objects.filter(pet_name="Pet2").exists()) + + def test_reconcile_pets_invalid_data(self): + data = {"pets": "not a list"} + response = self.client.post(self.reconcile_url, data, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_reconcile_pets_nonexistent_pet(self): + data = { + "pets": [ + { + "id": "614d1ca1-a0bc-4694-a2ec-6ac3b433802e", # "nonexistent-id", + "pet_name": "Nonexistent Pet", + "pet_type": "dog", + "pet_dob": "2020", + } + ] + } + response = self.client.post(self.reconcile_url, data, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_unauthenticated_access(self): + self.client.force_authenticate(user=None) + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_authenticated_access_different_user(self): + self.client.force_authenticate(user=self.user1) + reconcile_url2 = reverse( + "profile-reconcile-pets", + kwargs={ + "version": "v1", + "pk": self.profile2.pk, + }, # user 2's profile + ) + + response = self.client.get(reconcile_url2) + self.assertEqual( + response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED + ) diff --git a/apps/api/afbcore/tests/views/test_users.py b/apps/api/afbcore/tests/views/test_users.py index 0ff4708e..7368f572 100644 --- a/apps/api/afbcore/tests/views/test_users.py +++ b/apps/api/afbcore/tests/views/test_users.py @@ -2,7 +2,7 @@ import uuid from afbcore.serializers import UserSerializer -from afbcore.views.users import CurrentUserAPIView +from afbcore.views.user_view import CurrentUserAPIView from django.contrib.auth import get_user_model from django.test import RequestFactory from rest_framework import status @@ -10,8 +10,7 @@ """ How to run the tests: - - python manage.py test apps/api/afbcore/tests/views/test_users.py - - python manage.py test afbcore.tests.views.test_users.RegisterTestCase.test_register_valid_data + - pnpm django:test apps/api/afbcore/tests/views/test_users.py """ diff --git a/apps/api/afbcore/views/__init__.py b/apps/api/afbcore/views/__init__.py index 5f41917e..ca821dab 100644 --- a/apps/api/afbcore/views/__init__.py +++ b/apps/api/afbcore/views/__init__.py @@ -15,6 +15,8 @@ module. """ -from .profile import ProfileViewSet # noqa: F401 -from .requests import FoodRequestViewSet # noqa: F401 -from .users import CurrentUserAPIView # noqa: F401 +from .branch_viewset import BranchViewSet # noqa: F401 +from .food_request_view import FoodRequestViewSet # noqa: F401 +from .pet_viewset import PetViewSet # noqa: F401 +from .profile_viewset import ProfileViewSet # noqa: F401 +from .user_view import CurrentUserAPIView, RegisterUserAPIView # noqa: F401 diff --git a/apps/api/afbcore/serializers/profile/__init__.py b/apps/api/afbcore/views/admin/__init__.py similarity index 100% rename from apps/api/afbcore/serializers/profile/__init__.py rename to apps/api/afbcore/views/admin/__init__.py diff --git a/apps/api/afbcore/views/admin/branch_admin.py b/apps/api/afbcore/views/admin/branch_admin.py new file mode 100644 index 00000000..36730683 --- /dev/null +++ b/apps/api/afbcore/views/admin/branch_admin.py @@ -0,0 +1,110 @@ +from django.contrib import admin +from django.utils.html import format_html + +from ...models import Branch + + +@admin.register(Branch) +class BranchAdmin(admin.ModelAdmin): + list_display = ( + "display_name", + "location_name", + "city", + "state_or_province", + "country", + "operational", + "hidden", + ) + list_filter = ( + "operational", + "hidden", + "spay_neuter_requirement", + "delivery_type", + ) + search_fields = ( + "display_name", + "location_name", + "city", + "state_or_province", + "country", + ) + readonly_fields = ("id",) + + fieldsets = ( + ( + "Basic Information", + { + "fields": ( + "id", + "display_name", + "blurb", + "blurb_image", + "operational", + "hidden", + ) + }, + ), + ( + "Location", + { + "fields": ( + "location_name", + "address_line1", + "address_line2", + "city", + "state_or_province", + "postal_code", + "country", + "ext_id", + "latitude", + "longitude", + ) + }, + ), + ( + "Delivery Information", + { + "fields": ( + "delivery_regions", + "pickup_locations", + "delivery_type", + "delivery_pickup_details", + "delivery_radius", + "delivery_deadline_days", + ) + }, + ), + ( + "Branch Policies", + { + "fields": ( + "frequency_of_requests", + "spay_neuter_requirement", + "pets_per_household_max", + ) + }, + ), + ) + + def blurb_image_preview(self, obj): + if obj.blurb_image: + return format_html( + '', obj.blurb_image.url + ) + return "No Image" + + blurb_image_preview.short_description = "Blurb Image Preview" + + def get_readonly_fields(self, request, obj=None): + if obj: # editing an existing object + return self.readonly_fields + ("id",) + return self.readonly_fields + + filter_horizontal = ("delivery_regions",) + + def formfield_for_manytomany(self, db_field, request, **kwargs): + if db_field.name == "delivery_regions": + kwargs["widget"] = admin.widgets.FilteredSelectMultiple( + "Delivery Regions", is_stacked=False + ) + return super().formfield_for_manytomany(db_field, request, **kwargs) diff --git a/apps/api/afbcore/views/base.py b/apps/api/afbcore/views/base.py index 22730b67..8bb5823c 100644 --- a/apps/api/afbcore/views/base.py +++ b/apps/api/afbcore/views/base.py @@ -16,3 +16,26 @@ def get_queryset(self): """ user = self.request.user return super().get_queryset().filter(**{self.user_field: user}) + + +class ProfileFilterBaseViewSet(viewsets.ModelViewSet): + """ + Base viewset that filters the queryset based on a profile field. + The profile field should be specified in each subclass. + """ + + profile_field = ( + "profile" # default profile field, can be overridden in subclasses + ) + + def get_queryset(self): + """ + This view should return a list of all the objects + for the currently authenticated user's profile. + """ + user = self.request.user + return ( + super() + .get_queryset() + .filter(**{f"{self.profile_field}__user": user}) + ) diff --git a/apps/api/afbcore/views/branch_viewset.py b/apps/api/afbcore/views/branch_viewset.py new file mode 100644 index 00000000..deefce67 --- /dev/null +++ b/apps/api/afbcore/views/branch_viewset.py @@ -0,0 +1,54 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, viewsets +from rest_framework.permissions import IsAuthenticated + +from ..models import Branch +from ..serializers import BranchSerializer + + +class BranchViewSet(viewsets.ModelViewSet): + queryset = Branch.objects.all() + serializer_class = BranchSerializer + permission_classes = [IsAuthenticated] + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + filterset_fields = [ + "operational", + "hidden", + "delivery_type", + "spay_neuter_requirement", + ] + search_fields = [ + "display_name", + "location_name", + "city", + "state_or_province", + "country", + ] + ordering_fields = [ + "display_name", + "location_name", + "city", + "state_or_province", + "country", + ] + + def get_queryset(self): + """ + Optionally restricts the returned branches to non-hidden ones, + by filtering against a `show_hidden` query parameter in the URL. + """ + queryset = Branch.objects.all() + show_hidden = self.request.query_params.get("show_hidden", None) + show_hidden = show_hidden is not None and show_hidden.lower() == "true" + queryset = queryset.filter(hidden=show_hidden) + return queryset + + def perform_create(self, serializer): + serializer.save() + + def perform_update(self, serializer): + serializer.save() diff --git a/apps/api/afbcore/views/requests/food_request.py b/apps/api/afbcore/views/food_request_view.py similarity index 95% rename from apps/api/afbcore/views/requests/food_request.py rename to apps/api/afbcore/views/food_request_view.py index 7094c6c3..29cf5944 100644 --- a/apps/api/afbcore/views/requests/food_request.py +++ b/apps/api/afbcore/views/food_request_view.py @@ -1,8 +1,8 @@ from rest_framework import exceptions, status, viewsets from rest_framework.response import Response -from ...models import FoodRequest -from ...serializers import ( +from ..models import FoodRequest +from ..serializers import ( FoodRequestCreateSerializer, FoodRequestUpdateSerializer, ) diff --git a/apps/api/afbcore/views/authtoken.py b/apps/api/afbcore/views/logout_view.py similarity index 100% rename from apps/api/afbcore/views/authtoken.py rename to apps/api/afbcore/views/logout_view.py diff --git a/apps/api/afbcore/views/pet_viewset.py b/apps/api/afbcore/views/pet_viewset.py new file mode 100644 index 00000000..df81bcd4 --- /dev/null +++ b/apps/api/afbcore/views/pet_viewset.py @@ -0,0 +1,94 @@ +# apps/api/afbcore/views/pet_viewset.py + +import logging + +from django.core.exceptions import ObjectDoesNotExist +from rest_framework import permissions, viewsets +from rest_framework.authentication import TokenAuthentication +from rest_framework.exceptions import PermissionDenied, ValidationError + +from ..models import Pet, Profile +from ..serializers import PetSerializer +from .base import UserFilterBaseViewSet + +logger = logging.getLogger(__name__) + + +class PetViewSet(UserFilterBaseViewSet): + """ + API endpoint for Pet CRUD operations, limited to Pets + associated with the currently logged in user's profiles. + """ + + queryset = Pet.objects.order_by("-created") + serializer_class = PetSerializer + permission_classes = [permissions.IsAuthenticated] + authentication_classes = [TokenAuthentication] + + def get_queryset(self): + """ + This view should return a list of all pets + for the currently authenticated user's profiles. + """ + user = self.request.user + return Pet.objects.filter(profile__user=user).order_by("-created") + + def perform_create(self, serializer): + """ + When creating a new pet, ensure it's associated with one of the user's profiles. + """ + user = self.request.user + profile_id = self.request.data.get("profile") + + try: + if profile_id: + profile = Profile.objects.get(id=profile_id, user=user) + else: + profile = user.profiles.first() + + if profile is None: + raise ObjectDoesNotExist + serializer.save(profile=profile) + except ObjectDoesNotExist: + raise ValidationError( + "Invalid profile or user does not have any profiles. Please provide a valid profile or create one first." + ) + + def perform_update(self, serializer): + """ + Ensure that the user can only update pets associated with their profiles, + and can only move pets between their own profiles. + """ + instance = self.get_object() + user = self.request.user + new_profile_id = self.request.data.get("profile") + + try: + # Check if the pet's current profile belongs to the user + user.profiles.get(id=instance.profile.id) + + if new_profile_id: + # If a new profile is specified, check if it belongs to the user + new_profile = user.profiles.get(id=new_profile_id) + serializer.save(profile=new_profile) + else: + serializer.save() + except ObjectDoesNotExist: + raise PermissionDenied( + "You can only update pets associated with your profiles." + ) + + def perform_destroy(self, instance): + """ + Ensure that the user can only delete pets associated with their profiles. + """ + user = self.request.user + + try: + # Check if the pet's profile belongs to the user + user.profiles.get(id=instance.profile.id) + instance.delete() + except ObjectDoesNotExist: + raise PermissionDenied( + "You can only delete pets associated with your profiles." + ) diff --git a/apps/api/afbcore/views/profile/__init__.py b/apps/api/afbcore/views/profile/__init__.py deleted file mode 100644 index 00be1a74..00000000 --- a/apps/api/afbcore/views/profile/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -import logging - -from django.contrib.auth import get_user_model -from rest_framework import permissions, status, viewsets -from rest_framework.authentication import TokenAuthentication -from rest_framework.response import Response - -from ...models import Profile -from ...serializers import ProfileSerializer -from ..base import UserFilterBaseViewSet - -User = get_user_model() - -logger = logging.getLogger(__name__) - - -# Example of a viewset with custom actions. -# -class ProfileViewSet(UserFilterBaseViewSet): - """ - API endpoint for the Profile CRUD operations, limited to Profiles - associated with the currently logged in user. - """ - - queryset = Profile.objects.order_by("-created_at") - serializer_class = ProfileSerializer # must be a class, not string - permission_classes = [ - permissions.IsAuthenticated, - ] - authentication_classes = [TokenAuthentication] - - # def get(self, request, version=None, *args, **kwargs): - # """ - # Retrieve the current authenticated user. - # """ - # logger.debug("API Version: #{version}") - # serializer = self.get_serializer( - # request.user, context={"request": request} - # ) - # return Response(serializer.data) diff --git a/apps/api/afbcore/views/profile_viewset.py b/apps/api/afbcore/views/profile_viewset.py new file mode 100644 index 00000000..5726cfa0 --- /dev/null +++ b/apps/api/afbcore/views/profile_viewset.py @@ -0,0 +1,175 @@ +import logging + +from django.contrib.auth import get_user_model +from django.db import transaction +from rest_framework import permissions, status, viewsets +from rest_framework.authentication import TokenAuthentication +from rest_framework.decorators import action +from rest_framework.exceptions import MethodNotAllowed +from rest_framework.response import Response + +from ..models import Pet, Profile +from ..permissions import IsOwner +from ..serializers import PetSerializer, ProfileSerializer +from .base import UserFilterBaseViewSet + +User = get_user_model() + +logger = logging.getLogger(__name__) + + +""" + + The ProfileViewSet class is a viewset that provides CRUD operations + for the Profile model and is limited to profile(s) associated with + the currently logged in user. Even though multiple profiles can be + associated with a user, currently the application only supports one + profile per user (as of summer 2024). + + About permissions: + The ProfileViewSet uses the IsOwner permission class to limit + access to the profile(s) associated with the currently logged + in user. + + About updating pets: + The reconcile_pets method allows for adding, updating, and + removing pets in a single operation, which is useful for + synchronizing pet data with a client application. + + Example data in a POST request:: + { + "pets": [ + {"id": "existing-pet-id", "pet_name": "Updated Name", "pet_type": "dog", "pet_dob": "2020-01-01"}, + {"pet_name": "New Pet", "pet_type": "Cat", "pet_dob": "2021-05-15"} + ] + } + +""" + + +class ProfileViewSet(UserFilterBaseViewSet): + """ + API endpoint for the Profile CRUD operations, limited to Profiles + associated with the currently logged in user. + """ + + queryset = Profile.objects.order_by("-created") + serializer_class = ProfileSerializer # must be a class, not string + permission_classes = [ + permissions.IsAuthenticated, + IsOwner, + ] + authentication_classes = [TokenAuthentication] + + def get_queryset(self): + """ + Limit the available profiles to ones associated with the + currently authenticated user. + """ + return Profile.objects.filter(user=self.request.user) + + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed("DELETE") + + @action(detail=True, methods=["post"]) + @transaction.atomic + def reconcile_pets(self, request, pk=None, version=None): + """ + Reconcile the pets for a specific profile. + + Allows for adding, updating, and removing pets in a single + operation. This method will: + + 1. Add new pets that don't exist + 2. Update existing pets + 3. Remove pets that are not in the provided list + + It processes each pet in the list: + - If the pet has an ID, it updates the existing pet. + - If the pet doesn't have an ID, it creates a new pet. + - Removes any pets that were associated with the profile + but not included in the request data. + + Uses @transaction.atomic to ensure that all database operations + are performed in a single transaction. e.g. the changes are + either all saved or all rolled back in case of an error. + + """ + profile = self.get_object() + pets_data = request.data.get("pets", []) + + logger.info(f"Starting pet reconciliation for profile {profile.id}") + + if not isinstance(pets_data, list): + logger.warning( + f"Invalid pets data received for profile {profile.id}: not a list" + ) + return Response( + {"error": "Pets data must be a list"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Create a set of existing pet IDs for this profile + existing_pet_ids = set( + str(id) for id in profile.pets.values_list("id", flat=True) + ) + logger.info( + f"Existing pet IDs for profile {profile.id}: {existing_pet_ids}" + ) + + # Process each pet in the request + processed_pet_ids = set() + for pet_data in pets_data: + pet_id = pet_data.get("id") + pet_name = pet_data.get("pet_name") + + logger.info(f"Processing pet data: {pet_id} - {pet_name}") + + if pet_id: + # Update existing pet + logger.info(f"Checking pet {pet_id} for profile {profile.id}") + try: + pet = profile.pets.get(id=pet_id) + logger.info( + f"Updating existing pet {pet_id} for profile {profile.id}" + ) + serializer = PetSerializer(pet, data=pet_data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + processed_pet_ids.add(str(pet_id)) + logger.info(f"Updated pet {pet_id} with data: {pet_data}") + except Pet.DoesNotExist: + logger.error( + f"Pet with id {pet_id} does not exist for profile {profile.id}" + ) + return Response( + { + "error": f"Pet with id {pet_id} does not exist for this profile" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # Create new pet + logger.info(f"Creating new pet for profile {profile.id}") + serializer = PetSerializer(data=pet_data) + serializer.is_valid(raise_exception=True) + pet = serializer.save(profile=profile) + processed_pet_ids.add(str(pet.id)) + logger.info(f"Created new pet {pet.id} with data: {pet_data}") + + # Remove pets that were not in the request + pets_to_remove = existing_pet_ids - processed_pet_ids + if pets_to_remove: + logger.info( + f"Removing pets {pets_to_remove} from profile {profile.id}" + ) + profile.pets.filter(id__in=pets_to_remove).delete() + + # Return updated profile with reconciled pets + logger.info(f"Pet reconciliation completed for profile {profile.id}") + logger.info( + f"Final set of pet IDs for profile {profile.id}: {processed_pet_ids}" + ) + + serializer = self.get_serializer(profile) + return Response(serializer.data) diff --git a/apps/api/afbcore/views/requests/__init__.py b/apps/api/afbcore/views/requests/__init__.py deleted file mode 100644 index ee2a4a9f..00000000 --- a/apps/api/afbcore/views/requests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .food_request import FoodRequestViewSet # noqa: F401 diff --git a/apps/api/afbcore/views/users/__init__.py b/apps/api/afbcore/views/user_view.py similarity index 98% rename from apps/api/afbcore/views/users/__init__.py rename to apps/api/afbcore/views/user_view.py index f46cb0de..d7956895 100644 --- a/apps/api/afbcore/views/users/__init__.py +++ b/apps/api/afbcore/views/user_view.py @@ -9,7 +9,7 @@ from rest_framework.generics import CreateAPIView, RetrieveAPIView from rest_framework.response import Response -from ...serializers import UserSerializer +from ..serializers import UserSerializer User = get_user_model() diff --git a/apps/api/gunicorn.conf.py b/apps/api/gunicorn.conf.py new file mode 100644 index 00000000..b5ccdcf9 --- /dev/null +++ b/apps/api/gunicorn.conf.py @@ -0,0 +1,122 @@ +# Description: Gunicorn configuration file for the Django API +# +# +# +# Usage: +# +# $ gunicorn --config apps/api/gunicorn.conf.py afb.wsgi:application +# +# About Workers: +# +# Worker class determines the type of workers to use. The +# default synchronous workers assume that your application +# is resource-bound in terms of I/O operations. +# +# worker_class = "sync" # The default class. It can handle +# applications that are I/O-bound but not suitable for +# long-polling or other long-held socket connections. +# +# "gthread" - A threaded worker. It uses threads to handle +# requests. Good for I/O-bound applications and also can be +# used for long-polling. It's a good alternative to 'sync' +# with better concurrency for I/O-bound workloads. +# worker_class = "gthread" +# +# "gevent" - A worker class for handling a large number of +# connections. It uses greenlets to provide high-performance, +# network-based concurrency. Suitable for applications that +# require long-polling, WebSockets, or other long-lived +# connections. +# + +import os +import sys + +# Add the current directory to the beginning of the Python path +current_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, current_dir) + +# Set the Django settings module +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "afb.settings") + +# Server socket +bind = "0.0.0.0:8000" +backlog = 2048 + + +# Worker processes: 2-4 per core is a good start +workers = 4 + +# Choose one worker class by uncommenting: +# 1. Sync: Default, for fast responses, CPU-bound apps +# worker_class = "sync" +# +# 2. gthread: Threaded, good for mixed I/O and CPU workloads +worker_class = "gthread" +threads = 4 # Adjust based on your needs +# +# 3. gevent: For many concurrent connections, long-polling +# worker_class = "gevent" +# worker_connections = 1000 + +# Common settings +max_requests = 1000 +max_requests_jitter = 50 +timeout = 30 +keepalive = 2 + +# Security +limit_request_line = 4096 +limit_request_fields = 100 +limit_request_field_size = 8190 + +# Logging +accesslog = os.getenv("GUNICORN_ACCESS_LOG", "-") +errorlog = os.getenv("GUNICORN_ERROR_LOG", "-") +loglevel = os.getenv("GUNICORN_LOG_LEVEL", "info") + +# Process naming +proc_name = "gunicorn_myapp" + +# Server mechanics +daemon = False +pidfile = None +umask = 0 +user = None +group = None +tmp_upload_dir = None + +# SSL +# keyfile = '/path/to/key.pem' +# certfile = '/path/to/cert.pem' + + +# Hook functions +def on_starting(server): + print("Starting Gunicorn server...") + + +def on_reload(server): + print("Reloading Gunicorn server...") + + +def on_exit(server): + print("Shutting down Gunicorn server...") + + +# Server hooks +post_fork = lambda server, worker: server.log.info( + f"Worker spawned (pid: {worker.pid})" +) +pre_fork = lambda server, worker: server.log.info("Forking worker...") +pre_exec = lambda server: server.log.info("Forked child, re-executing...") + + +# Customize this based on your app's needs +def when_ready(server): + server.log.info("Server is ready. Spawning workers...") + + +# Error handling +def worker_abort(worker): + worker.log.info(f"worker {worker.pid} aborted") diff --git a/apps/ui/components/DeliveryInfoForm.vue b/apps/ui/components/DeliveryInfoForm.vue index 1cb36487..de494c94 100644 --- a/apps/ui/components/DeliveryInfoForm.vue +++ b/apps/ui/components/DeliveryInfoForm.vue @@ -1,112 +1,352 @@ + + + +const updateAutocomplete = (latitude: number, longitude: number) => { + if (autocomplete.value) { + const center = new google.maps.LatLng(latitude, longitude); + const bounds = new google.maps.LatLngBounds(center); + const boundaryDistance = 0.2; + const newBounds = { + north: center.lat() + boundaryDistance, + south: center.lat() - boundaryDistance, + east: center.lng() + boundaryDistance, + west: center.lng() - boundaryDistance, + }; + autocomplete.value.setBounds(newBounds); + autocomplete.value.setOptions({ + strictBounds: true, + location: center, + }); - + } +}; + +const deliveryAreaLink = computed(() => { + const branch = branchesMap.value.get(props.state.branch_selection); + if (branch) { + return `/requests/area/?lat=${branch.latitude}&lng=${branch.longitude}`; + } + return '/requests/area/'; +}); + + diff --git a/apps/ui/components/PetsForm.vue b/apps/ui/components/PetsForm.vue index 4f328d02..58bea470 100644 --- a/apps/ui/components/PetsForm.vue +++ b/apps/ui/components/PetsForm.vue @@ -5,31 +5,176 @@ size="lg" display-errors display-success + :endpoint="false" + @submit="handleOnSubmit" + @change="() => isSaveButtonDisabled = false" ref="form$" />