Skip to content

Commit

Permalink
Add animals app with statistics and initial migration (#104)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
drikusroor authored Oct 12, 2024
1 parent 8e93bab commit 34646ea
Show file tree
Hide file tree
Showing 17 changed files with 694 additions and 6 deletions.
Empty file added src/animals/__init__.py
Empty file.
2 changes: 2 additions & 0 deletions src/animals/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@

# Register your models here.
6 changes: 6 additions & 0 deletions src/animals/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class AnimalsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'animals'
46 changes: 46 additions & 0 deletions src/animals/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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,
},
),
]
Empty file.
70 changes: 70 additions & 0 deletions src/animals/models.py
Original file line number Diff line number Diff line change
@@ -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)
Binary file added src/animals/static/images/capybara.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
269 changes: 269 additions & 0 deletions src/animals/templates/animals-statistics.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
{% extends "base.html" %}
{% load static wagtailimages_tags user_tags %}

{% block extra_css %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
{% endblock %}

{% block content %}
<div class="bg-green-700">
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8 text-white">Capybara Counter
<!-- {% static "capybara.jpg" max-64x64 alt="Capybara" class="w-12 h-12 inline-block" %} -->
<img src="{% static "/images/capybara.png" %}" alt="Capybara" class="w-8 h-8 inline-block" />
</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Animals per User -->
<div class="bg-white p-6 rounded-lg shadow-md">
<h2 class="text-xl font-semibold mb-4">Animals per User</h2>
<ul class="space-y-2">
{% for item in animals_per_user %}
{% if forloop.first %}
<li class="flex justify-between bg-amber-300 py-1 px-2 rounded-lg relative group">
{% else %}
<li class="flex justify-between p-2 relative group">
{% endif %}
{% user_avatar item "rounded-full w-8 h-8 object-cover overflow-hidden" 32 %}
<span>{% user_display_name item %}</span>
<span class="font-bold">{{ item.total_animals }}</span>

<ul class="space-y-1 absolute z-10 right-1 p-2 bg-blue-700 text-white rounded-lg drop-shadow hidden group-hover:block">
<li class="font-semibold">Animals Breakdown</li>
{% for animal in item.animal_breakdown %}
<li class="flex items-center gap-2">
{% if animal.image %}
{% image animal.image fill-32x32-c100 alt=animal.name class="rounded-full" %}
{% else %}
<div class="w-8 h-8 bg-gray-300 rounded-full"></div>
{% endif %}
{{ animal.name }}:
<span class="font-bold bg-blue-300 px-2 rounded-lg ml-auto text-gray-900">
{{ animal.count }}
</span>
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
</div>

<!-- Animals per Type -->
<div class="bg-white p-6 rounded-lg shadow-md">
<h2 class="text-xl font-semibold mb-4">Animals per Type</h2>
<ul class="space-y-2">
{% for item in animals_per_type %}
{% if forloop.first %}
<li class="flex justify-between bg-amber-300 py-1 px-2 rounded-lg group relative">
{% else %}
<li class="flex justify-between p-2 group relative">
{% endif %}
{% if item.image %}
{% image item.image fill-32x32-c100 alt=item.name class="rounded-full" %}
{% else %}
<div class="w-8 h-8"></div>
{% endif %}
<span>{{ item.name }}</span>
<span class="font-bold">{{ item.total_animals }}</span>

<ul class="space-y-1 absolute z-10 right-1 p-2 bg-blue-700 text-white rounded-lg drop-shadow hidden group-hover:block">
<li class="font-semibold">User Breakdown</li>
{% for user in item.animal_breakdown %}
<li class="flex items-center gap-2">
{% user_avatar user.user "rounded-full w-8 h-8 object-cover overflow-hidden" 32 %}
{{ user.name }}:
<span class="font-bold bg-blue-300 px-2 rounded-lg ml-auto text-gray-900">
{{ user.count }}
</span>
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
</div>

<!-- Animals per Day Chart -->
<div class="bg-white p-6 rounded-lg shadow-md col-span-full">
<h2 class="text-xl font-semibold mb-4">Animals per Day</h2>
<canvas id="animalsPerDayChart"></canvas>
</div>

<!-- Animals Map -->
<div class="bg-white p-6 rounded-lg shadow-md col-span-full">
<h2 class="text-xl font-semibold mb-4">Animal Locations</h2>
<div id="animalMap" style="height: 400px;"></div>
</div>

<!-- Animal Consumption Over Time and Location -->
<div class="bg-white p-6 rounded-lg shadow-md col-span-full">
<h2 class="text-xl font-semibold mb-4">Animal Consumption Over Time and Location</h2>
<canvas id="scatterChart"></canvas>
</div>

<!-- Average Animals per Day -->
<div class="bg-white p-6 rounded-lg shadow-md col-span-full">
<h2 class="text-xl font-semibold mb-4">Average Animals per Day</h2>
<ul class="space-y-2">
{% for item in avg_animals_per_day %}
{% if forloop.first %}
<li class="flex justify-between bg-amber-300 py-1 px-2 rounded-lg">
{% else %}
<li class="flex justify-between p-2">
{% endif %}
{% if item.image %}
{% image item.image fill-32x32-c100 alt=item.name class="rounded-full" %}
{% else %}
<div class="w-8 h-8"></div>
{% endif %}
<span>{{ item.name }}</span>
<span class="font-bold">{{ item.avg_per_day|floatformat:2 }}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>

<script>
// Animals per Day Chart
const ctx = document.getElementById('animalsPerDayChart').getContext('2d');
const animalsPerDay = JSON.parse('{{ animals_per_day|safe }}');

const animalTypes = [...new Set(animalsPerDay.map(item => item.animal_type__name))];
const dates = [...new Set(animalsPerDay.map(item => item.date_day.split(' ')[0]))]; // Extract only the date part

const datasets = animalTypes.map(type => ({
label: type,
data: dates.map(date => {
const entry = animalsPerDay.find(item => item.date_day.startsWith(date) && item.animal_type__name === type);
return entry ? entry.total : 0;
}),
backgroundColor: `rgba(${Math.random() * 255},${Math.random() * 255},${Math.random() * 255},0.5)`,
}));

new Chart(ctx, {
type: 'bar',
data: {
labels: dates,
datasets: datasets
},
options: {
scales: {
x: { stacked: true },
y: { stacked: true }
}
}
});

// Scatter Plot
const scatterData = JSON.parse('{{ scatter_data|safe }}');
const scatterCtx = document.getElementById('scatterChart').getContext('2d');

const scatterAnimalTypes = [...new Set(scatterData.map(item => item.animal_type__name))];
const colorMap = {};
scatterAnimalTypes.forEach((type, index) => {
colorMap[type] = `hsl(${index * 360 / scatterAnimalTypes.length}, 70%, 50%)`;
});

const scatterDatasets = scatterAnimalTypes.map(type => ({
label: type,
data: scatterData.filter(item => item.animal_type__name === type).map(item => {

const point = {
x: new Date(item.date),
y: item.location_coeff,
r: Math.sqrt(item.count) * 10, // Adjust the scaling factor as needed
amount: item.count,
image: item.animal_type__image__file
}

return point;
}),
backgroundColor: colorMap[type],
pointStyle: 'circle',
}));

new Chart(scatterCtx, {
type: 'bubble',
data: {
datasets: scatterDatasets
},
options: {
scales: {
x: {
type: 'time',
time: {
unit: 'day'
},
title: {
display: true,
text: 'Date'
}
},
y: {
title: {
display: true,
text: 'Location Coefficient'
}
}
},
plugins: {
tooltip: {
callbacks: {
label: (context) => {
const point = context.raw;
return `${context.dataset.label}: ${point.amount} at ${point.x.toString()}`;
}
}
}
}
}
});

// Animals Map
const map = L.map('animalMap').setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);

const animalsWithLocation = JSON.parse('{{ animals_with_location|safe }}');

animalsWithLocation.forEach(animal => {
const [lat, lng] = animal.location.split(',').map(parseFloat);
if (!isNaN(lat) && !isNaN(lng)) {

if (!animal.animal_type__image__file) {
L.marker([lat, lng]).addTo(map)
.bindPopup(`${animal.amount} ${animal.animal_type__name}(s)`);
return;
}

const icon = L.icon({
iconUrl: `/media/${animal.animal_type__image__file}`,
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32]
});

L.marker([lat, lng], { icon }).addTo(map)
.bindPopup(`${animal.amount} ${animal.animal_type__name}(s)`)
}
});

if (animalsWithLocation.length > 0) {
const bounds = animalsWithLocation.map(animal => {
const [lat, lng] = animal.location.split(',').map(parseFloat);
return [lat, lng];
}).filter(coord => !isNaN(coord[0]) && !isNaN(coord[1]));

if (bounds.length > 0) {
map.fitBounds(bounds);
}
}
</script>
{% endblock %}
Loading

0 comments on commit 34646ea

Please sign in to comment.