[birds] Add charts for birds
This commit is contained in:
@ -1,4 +1,4 @@
|
|||||||
from charts.views import ChartRecordView, SpotifyTracksView
|
from charts.views import BirdsChartView, ChartRecordView, SpotifyTracksView
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
app_name = "charts"
|
app_name = "charts"
|
||||||
@ -6,4 +6,5 @@ app_name = "charts"
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("charts/", ChartRecordView.as_view(), name="charts-home"),
|
path("charts/", ChartRecordView.as_view(), name="charts-home"),
|
||||||
path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"),
|
path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"),
|
||||||
|
path("charts/birds/", BirdsChartView.as_view(), name="birds-chart"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -415,6 +415,18 @@ class ChartRecordView(TemplateView):
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bird_data = self.get_bird_chart_data(
|
||||||
|
user,
|
||||||
|
year=context.get("year"),
|
||||||
|
month=context.get("month"),
|
||||||
|
)
|
||||||
|
context["birds_chart"] = bird_data["top_birds"]
|
||||||
|
context["bird_stats"] = {
|
||||||
|
"total_species": bird_data["total_species"],
|
||||||
|
"total_outings": bird_data["total_outings"],
|
||||||
|
"total_individuals": bird_data["total_individuals"],
|
||||||
|
}
|
||||||
|
|
||||||
context["chart_years"] = self.get_available_years(user)
|
context["chart_years"] = self.get_available_years(user)
|
||||||
context["period_type"] = self.get_period_type()
|
context["period_type"] = self.get_period_type()
|
||||||
context["prev_period"] = self.get_prev_period_url(user)
|
context["prev_period"] = self.get_prev_period_url(user)
|
||||||
@ -558,6 +570,52 @@ class ChartRecordView(TemplateView):
|
|||||||
return f"/charts/?date={year + 1}"
|
return f"/charts/?date={year + 1}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_bird_chart_data(self, user, year=None, month=None, limit=20):
|
||||||
|
from birds.models import Bird
|
||||||
|
|
||||||
|
filters = {
|
||||||
|
"user": user,
|
||||||
|
"media_type": Scrobble.MediaType.BIRDING_LOCATION,
|
||||||
|
}
|
||||||
|
if year:
|
||||||
|
filters["timestamp__year"] = year
|
||||||
|
if month:
|
||||||
|
filters["timestamp__month"] = month
|
||||||
|
|
||||||
|
scrobbles = Scrobble.objects.filter(**filters)
|
||||||
|
|
||||||
|
bird_counts = {}
|
||||||
|
outings = 0
|
||||||
|
for scrobble in scrobbles.iterator():
|
||||||
|
outings += 1
|
||||||
|
birds_data = scrobble.log.get("birds", []) if scrobble.log else []
|
||||||
|
for entry in birds_data:
|
||||||
|
bird_id = entry.get("bird_id")
|
||||||
|
quantity = entry.get("quantity", 1)
|
||||||
|
if bird_id:
|
||||||
|
bird_counts[bird_id] = bird_counts.get(bird_id, 0) + quantity
|
||||||
|
|
||||||
|
sorted_birds = sorted(
|
||||||
|
bird_counts.items(), key=lambda x: x[1], reverse=True
|
||||||
|
)[:limit]
|
||||||
|
|
||||||
|
bird_ids = [bid for bid, _ in sorted_birds]
|
||||||
|
birds = Bird.objects.filter(id__in=bird_ids)
|
||||||
|
bird_map = {b.id: b for b in birds}
|
||||||
|
|
||||||
|
top_birds = [
|
||||||
|
{"bird": bird_map[bid], "count": count}
|
||||||
|
for bid, count in sorted_birds
|
||||||
|
if bid in bird_map
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"top_birds": top_birds,
|
||||||
|
"total_outings": outings,
|
||||||
|
"total_species": len(bird_counts),
|
||||||
|
"total_individuals": sum(bird_counts.values()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SpotifyTracksView(TemplateView):
|
class SpotifyTracksView(TemplateView):
|
||||||
template_name = "charts/spotify_tracks.html"
|
template_name = "charts/spotify_tracks.html"
|
||||||
@ -594,3 +652,59 @@ class SpotifyTracksView(TemplateView):
|
|||||||
user = self.request.user
|
user = self.request.user
|
||||||
context["spotify_tracks"] = self.get_spotify_tracks(user)
|
context["spotify_tracks"] = self.get_spotify_tracks(user)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class BirdsChartView(TemplateView):
|
||||||
|
template_name = "charts/birds_chart.html"
|
||||||
|
|
||||||
|
def get_bird_data(self, user, limit=50):
|
||||||
|
from birds.models import Bird
|
||||||
|
|
||||||
|
scrobbles = Scrobble.objects.filter(
|
||||||
|
user=user,
|
||||||
|
media_type=Scrobble.MediaType.BIRDING_LOCATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
bird_counts = {}
|
||||||
|
outings = 0
|
||||||
|
for scrobble in scrobbles.iterator():
|
||||||
|
outings += 1
|
||||||
|
birds_data = scrobble.log.get("birds", []) if scrobble.log else []
|
||||||
|
for entry in birds_data:
|
||||||
|
bird_id = entry.get("bird_id")
|
||||||
|
quantity = entry.get("quantity", 1)
|
||||||
|
if bird_id:
|
||||||
|
bird_counts[bird_id] = bird_counts.get(bird_id, 0) + quantity
|
||||||
|
|
||||||
|
sorted_birds = sorted(
|
||||||
|
bird_counts.items(), key=lambda x: x[1], reverse=True
|
||||||
|
)[:limit]
|
||||||
|
|
||||||
|
bird_ids = [bid for bid, _ in sorted_birds]
|
||||||
|
birds = Bird.objects.filter(id__in=bird_ids)
|
||||||
|
bird_map = {b.id: b for b in birds}
|
||||||
|
|
||||||
|
top_birds = [
|
||||||
|
{"bird": bird_map[bid], "count": count}
|
||||||
|
for bid, count in sorted_birds
|
||||||
|
if bid in bird_map
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"top_birds": top_birds,
|
||||||
|
"total_outings": outings,
|
||||||
|
"total_species": len(bird_counts),
|
||||||
|
"total_individuals": sum(bird_counts.values()),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
user = self.request.user
|
||||||
|
bird_data = self.get_bird_data(user)
|
||||||
|
context["birds_chart"] = bird_data["top_birds"]
|
||||||
|
context["bird_stats"] = {
|
||||||
|
"total_species": bird_data["total_species"],
|
||||||
|
"total_outings": bird_data["total_outings"],
|
||||||
|
"total_individuals": bird_data["total_individuals"],
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
|||||||
63
vrobbler/templates/charts/birds_chart.html
Normal file
63
vrobbler/templates/charts/birds_chart.html
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
|
||||||
|
{% block title %}Top Birds{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
.container { margin-bottom: 100px; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2>🐦 Birding Charts</h2>
|
||||||
|
<p class="text-muted">All-time bird sightings based on scrobble log data.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if bird_stats %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 col-lg-3 mb-2">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{ bird_stats.total_species }}</h5>
|
||||||
|
<p class="card-text text-muted">Species seen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-lg-3 mb-2">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{ bird_stats.total_outings }}</h5>
|
||||||
|
<p class="card-text text-muted">Outings</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-lg-3 mb-2">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{ bird_stats.total_individuals }}</h5>
|
||||||
|
<p class="card-text text-muted">Total individuals</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-lg-4 chart-section">
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for item in birds_chart %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span class="me-2"><strong>#{{ forloop.counter }}</strong></span>
|
||||||
|
<a href="{{ item.bird.get_absolute_url }}">{{ item.bird.common_name }}</a>
|
||||||
|
<span class="badge bg-success rounded-pill">{{ item.count }}</span>
|
||||||
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li class="list-group-item">No bird sightings found.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -215,6 +215,26 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if birds_chart %}
|
||||||
|
<div class="col-md-6 col-lg-4 chart-section">
|
||||||
|
<h3>🐦 Top Birds</h3>
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for item in birds_chart %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span class="me-2"><strong>#{{ forloop.counter }}</strong></span>
|
||||||
|
<a href="{{ item.bird.get_absolute_url }}">{{ item.bird.common_name }}</a>
|
||||||
|
<span class="badge bg-success rounded-pill">{{ item.count }}</span>
|
||||||
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li class="list-group-item">No bird sightings this period.</li>
|
||||||
|
{% endfor %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<a href="{% url 'charts:birds-chart' %}">View all birds »</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not charts %}
|
{% if not charts %}
|
||||||
|
|||||||
Reference in New Issue
Block a user