From 34646ea662311b61ac960a5ec58960448b451e61 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Sat, 12 Oct 2024 09:46:29 -0300 Subject: [PATCH] Add animals app with statistics and initial migration (#104) * feat: Add animals app with models, admin, tests, and initial migration * feat: Update template names for statistics views and add animals URLs * feat: Add drinks statistics template with charts and maps for user and type breakdowns * feat: Update animals statistics template and add custom template tags for animal statistics * refactor: Remove unused imports from admin and tests modules --- src/animals/__init__.py | 0 src/animals/admin.py | 2 + src/animals/apps.py | 6 + src/animals/migrations/0001_initial.py | 46 +++ src/animals/migrations/__init__.py | 0 src/animals/models.py | 70 +++++ src/animals/static/images/capybara.png | Bin 0 -> 16438 bytes src/animals/templates/animals-statistics.html | 269 ++++++++++++++++++ src/animals/templatetags/animal_tags.py | 43 +++ src/animals/tests.py | 2 + src/animals/urls.py | 16 ++ src/animals/views.py | 233 +++++++++++++++ src/brazil_blog/settings/base.py | 1 + .../templates/includes/header.html | 3 +- src/brazil_blog/urls.py | 2 + ...statistics.html => drinks-statistics.html} | 5 +- src/drinks/views.py | 2 +- 17 files changed, 694 insertions(+), 6 deletions(-) create mode 100644 src/animals/__init__.py create mode 100644 src/animals/admin.py create mode 100644 src/animals/apps.py create mode 100644 src/animals/migrations/0001_initial.py create mode 100644 src/animals/migrations/__init__.py create mode 100644 src/animals/models.py create mode 100644 src/animals/static/images/capybara.png create mode 100644 src/animals/templates/animals-statistics.html create mode 100644 src/animals/templatetags/animal_tags.py create mode 100644 src/animals/tests.py create mode 100644 src/animals/urls.py create mode 100644 src/animals/views.py rename src/drinks/templates/{statistics.html => drinks-statistics.html} (98%) diff --git a/src/animals/__init__.py b/src/animals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/animals/admin.py b/src/animals/admin.py new file mode 100644 index 0000000..b97a94f --- /dev/null +++ b/src/animals/admin.py @@ -0,0 +1,2 @@ + +# Register your models here. diff --git a/src/animals/apps.py b/src/animals/apps.py new file mode 100644 index 0000000..2ce75f4 --- /dev/null +++ b/src/animals/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AnimalsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'animals' diff --git a/src/animals/migrations/0001_initial.py b/src/animals/migrations/0001_initial.py new file mode 100644 index 0000000..9a6878f --- /dev/null +++ b/src/animals/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.2 on 2024-10-12 12:13 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('wagtailimages', '0026_delete_uploadedimage'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AnimalType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('image', models.ForeignKey(blank=True, help_text='Optional image for the animal type', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='AnimalSpotting', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.TextField(blank=True)), + ('count', models.PositiveIntegerField(default=1)), + ('date', models.DateTimeField(default=django.utils.timezone.now)), + ('location', models.CharField(blank=True, max_length=255)), + ('spotter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='animal_spottings', to=settings.AUTH_USER_MODEL)), + ('animal_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='animal_spottings', to='animals.animaltype')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/animals/migrations/__init__.py b/src/animals/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/animals/models.py b/src/animals/models.py new file mode 100644 index 0000000..1c9b440 --- /dev/null +++ b/src/animals/models.py @@ -0,0 +1,70 @@ +from django.db import models +from django.contrib.auth import get_user_model +from django.utils import timezone +from wagtail.models import ClusterableModel +from wagtail.admin.panels import FieldPanel +from wagtail.snippets.models import register_snippet +from locations.forms import MapPickerWidget + +User = get_user_model() + + +class AnimalType(ClusterableModel): + name = models.CharField(max_length=255) + description = models.TextField(blank=True) + image = models.ForeignKey( + "wagtailimages.Image", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + help_text="Optional image for the animal type", + ) + + panels = [ + FieldPanel("name"), + FieldPanel("description"), + FieldPanel("image"), + ] + + def __str__(self): + return self.name + + class Meta: + ordering = ["name"] + + +class AnimalSpotting(ClusterableModel): + spotter = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="animal_spottings", + ) + + description = models.TextField(blank=True) + + animal_type = models.ForeignKey( + AnimalType, on_delete=models.CASCADE, related_name="animal_spottings" + ) + # default count is 1 + count = models.PositiveIntegerField(default=1) + # default is the current date + date = models.DateTimeField(default=timezone.now) + # location of spotting + location = models.CharField(max_length=255, blank=True) + + panels = [ + FieldPanel("spotter"), + FieldPanel("description"), + FieldPanel("animal_type"), + FieldPanel("count"), + FieldPanel("date"), + FieldPanel("location", widget=MapPickerWidget), + ] + + def __str__(self): + return f"{self.animal_type} - {self.date}" + + +register_snippet(AnimalType) +register_snippet(AnimalSpotting) diff --git a/src/animals/static/images/capybara.png b/src/animals/static/images/capybara.png new file mode 100644 index 0000000000000000000000000000000000000000..1faa15934377210702c64100a43e606ac10bbd62 GIT binary patch literal 16438 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 + + + +{% endblock %} + +{% block content %} +
+
+

Capybara Counter + + Capybara +

+
+ +
+

Animals per User

+
    + {% for item in animals_per_user %} + {% if forloop.first %} +
  • + {% else %} +
  • + {% endif %} + {% user_avatar item "rounded-full w-8 h-8 object-cover overflow-hidden" 32 %} + {% user_display_name item %} + {{ item.total_animals }} + + +
  • + {% endfor %} +
+
+ + +
+

Animals per Type

+
    + {% for item in animals_per_type %} + {% if forloop.first %} +
  • + {% else %} +
  • + {% endif %} + {% if item.image %} + {% image item.image fill-32x32-c100 alt=item.name class="rounded-full" %} + {% else %} +
    + {% endif %} + {{ item.name }} + {{ item.total_animals }} + + +
  • + {% endfor %} +
+
+ + +
+

Animals per Day

+ +
+ + +
+

Animal Locations

+
+
+ + +
+

Animal Consumption Over Time and Location

+ +
+ + +
+

Average Animals per Day

+
    + {% for item in avg_animals_per_day %} + {% if forloop.first %} +
  • + {% else %} +
  • + {% endif %} + {% if item.image %} + {% image item.image fill-32x32-c100 alt=item.name class="rounded-full" %} + {% else %} +
    + {% endif %} + {{ item.name }} + {{ item.avg_per_day|floatformat:2 }} +
  • + {% endfor %} +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/src/animals/templatetags/animal_tags.py b/src/animals/templatetags/animal_tags.py new file mode 100644 index 0000000..e63309a --- /dev/null +++ b/src/animals/templatetags/animal_tags.py @@ -0,0 +1,43 @@ +from django import template +from django.utils.safestring import mark_safe +from django.db.models import Sum +from ..models import AnimalType, AnimalSpotting +from django.urls import reverse + +register = template.Library() + + +@register.simple_tag +def quick_animal_add_buttons(): + animal_types = AnimalType.objects.all() + buttons_html = '
' + for animal_type in animal_types: + image_url = ( + animal_type.image.get_rendition("fill-50x50").url + if animal_type.image + else "" + ) + buttons_html += f""" + + """ + buttons_html += "
" + return mark_safe(buttons_html) + + +@register.simple_tag +def show_total_animals_spotted(): + total = AnimalSpotting.objects.aggregate(total=Sum("count"))["total"] + + statistics_url = reverse("animal_statistics") + + html = f""" + + Animal + {total} + + """ + + return mark_safe(html) diff --git a/src/animals/tests.py b/src/animals/tests.py new file mode 100644 index 0000000..4929020 --- /dev/null +++ b/src/animals/tests.py @@ -0,0 +1,2 @@ + +# Create your tests here. diff --git a/src/animals/urls.py b/src/animals/urls.py new file mode 100644 index 0000000..de4240f --- /dev/null +++ b/src/animals/urls.py @@ -0,0 +1,16 @@ +# games/urls.py +from rest_framework.routers import DefaultRouter +from django.urls import path, include + +from .views import AnimalViewSet, AnimalStatisticsView + +router = DefaultRouter() +router.register(r"animals", AnimalViewSet, basename="animals") + + +urlpatterns = [ + path("api/", include(router.urls)), + path( + "animal-statistics/", AnimalStatisticsView.as_view(), name="animal_statistics" + ), +] diff --git a/src/animals/views.py b/src/animals/views.py new file mode 100644 index 0000000..ed2759b --- /dev/null +++ b/src/animals/views.py @@ -0,0 +1,233 @@ +import json +from datetime import date +from rest_framework import viewsets +from rest_framework import serializers +from django.utils import timezone +from .models import AnimalSpotting, AnimalType +from locations.models import ItineraryStop +from rest_framework.permissions import IsAuthenticated, IsAdminUser +from rest_framework.decorators import permission_classes, action +from rest_framework.response import Response +from django.db.models import Sum +from django.views.generic import TemplateView +from django.db.models import Count +from django.db.models.functions import TruncDate +from django.core.serializers.json import DjangoJSONEncoder + +from django.contrib.auth.models import User + +from blog.templatetags.user_tags import user_display_name + + +class AnimalSerializer(serializers.ModelSerializer): + class Meta: + model = AnimalSpotting + fields = "__all__" + + def to_representation(self, instance): + data = super().to_representation(instance) + data["user"] = user_display_name(instance.spotter) + return data + + +class CustomJSONEncoder(DjangoJSONEncoder): + def default(self, obj): + if isinstance(obj, timezone.datetime): + return obj.strftime("%Y-%m-%d %H:%M:%S") + return super().default(obj) + + +class AnimalViewSet(viewsets.ModelViewSet): + queryset = AnimalSpotting.objects.all() + serializer_class = AnimalSerializer + + def get_queryset(self): + queryset = super().get_queryset() + user_id = self.request.query_params.get("user") + if user_id is not None: + queryset = queryset.filter(spotter_id=user_id) + return queryset + + @permission_classes([IsAuthenticated, IsAdminUser]) + def perform_create(self, serializer): + serializer.save(spotter=self.request.user) + + def get_serializer_context(self): + context = super().get_serializer_context() + context["request"] = self.request + return context + + @action(detail=False, methods=["get"]) + def get_animals_count(self, request): + animal_type = request.query_params.get("animal_type") + + if animal_type: + count = AnimalSpotting.objects.filter(animal_type=animal_type).aggregate( + count=Sum("count") + ) + return Response({"count": count}) + + count = AnimalSpotting.objects.aggregate(count=Sum("count")) + return Response({"count": count}) + + @action(detail=False, methods=["get"]) + def get_total_count_per_type(self, request): + total_count_per_type = ( + AnimalSpotting.objects.values("animal_type") + .annotate(total_count=Sum("count")) + .order_by("animal_type") + ) + return Response(total_count_per_type) + + @action(detail=False, methods=["post"]) + def quick_add_animal(self, request): + animal_type_id = request.data.get("animal_type") + count = request.data.get("count", 1) + location = request.data.get("location", "") + + if not location: + stop = ItineraryStop.objects.filter( + itinerary__stops__start_date__lte=timezone.now(), + itinerary__stops__end_date__gte=timezone.now(), + itinerary__stops__location__isnull=False, + ).first() + if stop: + location = stop.location + + AnimalSpotting.objects.create( + spotter=request.user, + animal_type_id=animal_type_id, + count=count, + location=location, + ) + + return Response({"status": "success"}) + + +class AnimalStatisticsView(TemplateView): + template_name = "animals-statistics.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + animals_per_user = ( + User.objects.filter(animal_spottings__isnull=False) + .distinct() + .annotate( + total_animals=Sum("animal_spottings__count"), + total_days=Count("animal_spottings__date", distinct=True), + ) + .order_by("-total_animals") + ) + + animal_types = AnimalType.objects.all() + for user in animals_per_user: + user.animal_breakdown = [] + for animal_type in animal_types: + count = ( + AnimalSpotting.objects.filter( + spotter=user, animal_type=animal_type + ).aggregate(total=Sum("count"))["total"] + or 0 + ) + if count > 0: + user.animal_breakdown.append( + { + "name": animal_type.name, + "count": count, + "image": animal_type.image, + } + ) + user.animal_breakdown.sort(key=lambda x: x["count"], reverse=True) + + context["animals_per_user"] = animals_per_user + + animals_per_type = ( + AnimalType.objects.filter(animal_spottings__isnull=False) + .distinct() + .annotate( + total_animals=Sum("animal_spottings__count"), + total_days=Count("animal_spottings__date", distinct=True), + ) + .order_by("-total_animals") + ) + + users = User.objects.all() + for animal_type in animals_per_type: + animal_type.animal_breakdown = [] + for user in users: + count = ( + AnimalSpotting.objects.filter( + spotter=user, animal_type=animal_type + ).aggregate(total=Sum("count"))["total"] + or 0 + ) + if count > 0: + animal_type.animal_breakdown.append( + { + "name": user_display_name(user), + "count": count, + "user": user, + } + ) + animal_type.animal_breakdown.sort(key=lambda x: x["count"], reverse=True) + + context["animals_per_type"] = animals_per_type + + animals_per_day = ( + AnimalSpotting.objects.annotate(date_day=TruncDate("date")) + .values("date_day", "animal_type__name") + .annotate(total=Sum("count")) + .order_by("date_day") + ) + context["animals_per_day"] = json.dumps( + list(animals_per_day), cls=CustomJSONEncoder + ) + + animals_with_location = list( + AnimalSpotting.objects.exclude(location="").values( + "location", "count", "animal_type__name", "animal_type__image__file" + ) + ) + context["animals_with_location"] = json.dumps( + animals_with_location, cls=CustomJSONEncoder + ) + + start_date = ( + AnimalSpotting.objects.order_by("date") + .annotate(date_day=TruncDate("date")) + .first() + .date_day + ) + today_date = date.today() + total_days_amount = (today_date - start_date).days + 1 + + avg_animals = ( + AnimalType.objects.all() + .annotate(avg_per_day=Sum("animal_spottings__count") / total_days_amount) + .order_by("-avg_per_day") + ) + context["avg_animals_per_day"] = list(avg_animals) + + scatter_data = ( + AnimalSpotting.objects.select_related("animal_type") + .values( + "date", + "count", + "location", + "animal_type__name", + "animal_type__image__file", + ) + .order_by("date") + ) + + for item in scatter_data: + if item["location"]: + lat, lon = map(float, item["location"].split(",")) + item["location_coeff"] = lat + lon + else: + item["location_coeff"] = 0 + + context["scatter_data"] = json.dumps(list(scatter_data), cls=CustomJSONEncoder) + + return context diff --git a/src/brazil_blog/settings/base.py b/src/brazil_blog/settings/base.py index 76c8cff..9de8cf8 100644 --- a/src/brazil_blog/settings/base.py +++ b/src/brazil_blog/settings/base.py @@ -32,6 +32,7 @@ "locations", "notifications", "drinks", + "animals", "rest_framework", "wagtail.contrib.forms", "wagtail.contrib.redirects", diff --git a/src/brazil_blog/templates/includes/header.html b/src/brazil_blog/templates/includes/header.html index b815342..9723091 100644 --- a/src/brazil_blog/templates/includes/header.html +++ b/src/brazil_blog/templates/includes/header.html @@ -1,5 +1,5 @@ {% load i18n static cache %} -{% load wagtailcore_tags wagtailimages_tags navigation_tags brazil_time user_tags drink_tags %} +{% load wagtailcore_tags wagtailimages_tags navigation_tags brazil_time user_tags drink_tags animal_tags %} {% get_site_root as site_root %} {% with site_name=settings.base.NavigationSettings.site_name site_logo=settings.base.NavigationSettings.site_logo %} @@ -18,6 +18,7 @@

{{ site_name }}

{% include "blog/includes/brazil-time.html" %} {% show_total_drinks_consumed %} + {% show_total_animals_spotted %}
diff --git a/src/brazil_blog/urls.py b/src/brazil_blog/urls.py index fc87b48..889079c 100644 --- a/src/brazil_blog/urls.py +++ b/src/brazil_blog/urls.py @@ -10,6 +10,7 @@ from search import views as search_views from blog import urls as blog_urls from drinks import urls as drinks_urls +from animals import urls as animals_urls urlpatterns = [ path("django-admin/", admin.site.urls), @@ -24,6 +25,7 @@ path("games/", include("games.urls")), path("locations/", include("locations.urls")), path("drinks/", include(drinks_urls)), + path("animals/", include(animals_urls)), ] diff --git a/src/drinks/templates/statistics.html b/src/drinks/templates/drinks-statistics.html similarity index 98% rename from src/drinks/templates/statistics.html rename to src/drinks/templates/drinks-statistics.html index ca4fe7a..8a40472 100644 --- a/src/drinks/templates/statistics.html +++ b/src/drinks/templates/drinks-statistics.html @@ -170,13 +170,10 @@

Average Drinks per Day

const scatterDatasets = scatterDrinkTypes.map(type => ({ label: type, data: scatterData.filter(item => item.drink_type__name === type).map(item => { - - console.log(new Date(item.date)); - return { x: new Date(item.date), y: item.location_coeff, - r: Math.sqrt(item.amount) * 5, // Adjust the scaling factor as needed + r: Math.sqrt(item.amount) * 10, // Adjust the scaling factor as needed amount: item.amount, image: item.drink_type__image__file } diff --git a/src/drinks/views.py b/src/drinks/views.py index c4ea609..0bb28d6 100644 --- a/src/drinks/views.py +++ b/src/drinks/views.py @@ -118,7 +118,7 @@ def quick_add_drink(self, request): class DrinkStatisticsView(TemplateView): - template_name = "statistics.html" + template_name = "drinks-statistics.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs)