-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
8e93bab
commit 34646ea
Showing
17 changed files
with
694 additions
and
6 deletions.
There are no files selected for viewing
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
|
||
# Register your models here. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 %} |
Oops, something went wrong.