[birds] Move importer to scrobbles and webdav
Some checks failed
build & deploy / test (push) Failing after 1m32s
build & deploy / build-and-deploy (push) Has been skipped

This commit is contained in:
2026-05-23 17:30:25 -04:00
parent a4030e89ec
commit a171517f92
12 changed files with 284 additions and 8 deletions

View File

@ -1,8 +1,9 @@
from birds.models import Bird, BirdingCSVImport, BirdingLocation from birds.models import Bird, BirdingLocation
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.views import generic from django.views import generic
from scrobbles.models import EBirdCSVImport as BirdingCSVImport
from scrobbles.views import ( from scrobbles.views import (
ScrobbleableDetailView, ScrobbleableDetailView,
ScrobbleableListView, ScrobbleableListView,
@ -40,6 +41,9 @@ class BirdingCSVImportCreateView(
def form_valid(self, form): def form_valid(self, form):
self.object = form.save(commit=False) self.object = form.save(commit=False)
self.object.user = self.request.user self.object.user = self.request.user
self.object.original_filename = (
form.cleaned_data["csv_file"].name
)
self.object.save() self.object.save()
self.object.process() self.object.process()
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER")) return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
@ -55,5 +59,5 @@ class BirdingCSVImportDetailView(LoginRequiredMixin, generic.DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs) context_data = super().get_context_data(**kwargs)
context_data["title"] = "Birding CSV Import" context_data["title"] = "eBird CSV Import"
return context_data return context_data

View File

@ -3,6 +3,7 @@ from django.contrib import admin
from scrobbles.models import ( from scrobbles.models import (
AudioScrobblerTSVImport, AudioScrobblerTSVImport,
BGStatsImport, BGStatsImport,
EBirdCSVImport,
KoReaderImport, KoReaderImport,
LastFmImport, LastFmImport,
RetroarchImport, RetroarchImport,
@ -85,6 +86,11 @@ class BGStatsImportAdmin(ImportBaseAdmin):
... ...
@admin.register(EBirdCSVImport)
class EBirdCSVImportAdmin(ImportBaseAdmin):
...
@admin.register(ScaleCSVImport) @admin.register(ScaleCSVImport)
class ScaleCSVImportAdmin(ImportBaseAdmin): class ScaleCSVImportAdmin(ImportBaseAdmin):
... ...

View File

@ -17,6 +17,7 @@ DEFAULT_GPX_PATH = "var/gpx/"
DEFAULT_KOREADER_PATH = "var/koreader/" DEFAULT_KOREADER_PATH = "var/koreader/"
DEFAULT_RETROARCH_PATH = "var/retroarch/" DEFAULT_RETROARCH_PATH = "var/retroarch/"
DEFAULT_BGSTATS_PATH = "var/bgstats/" DEFAULT_BGSTATS_PATH = "var/bgstats/"
DEFAULT_EBIRD_PATH = "var/ebird/"
def import_from_webdav_for_all_users(restart=False): def import_from_webdav_for_all_users(restart=False):
@ -33,6 +34,7 @@ def import_from_webdav_for_all_users(restart=False):
gpx_count = 0 gpx_count = 0
retro_count = 0 retro_count = 0
bgstats_count = 0 bgstats_count = 0
ebird_count = 0
for user_id in webdav_enabled_user_ids: for user_id in webdav_enabled_user_ids:
client = get_webdav_client(user_id) client = get_webdav_client(user_id)
@ -40,6 +42,7 @@ def import_from_webdav_for_all_users(restart=False):
gpx_count += scan_webdav_for_gpx(client, user_id) gpx_count += scan_webdav_for_gpx(client, user_id)
retro_count += scan_webdav_for_retroarch(client, user_id) retro_count += scan_webdav_for_retroarch(client, user_id)
bgstats_count += scan_webdav_for_bgstats(client, user_id) bgstats_count += scan_webdav_for_bgstats(client, user_id)
ebird_count += scan_webdav_for_ebird(client, user_id)
logger.info( logger.info(
"WebDAV import complete", "WebDAV import complete",
@ -48,9 +51,10 @@ def import_from_webdav_for_all_users(restart=False):
"trail_gpx": gpx_count, "trail_gpx": gpx_count,
"retroarch": retro_count, "retroarch": retro_count,
"bgstats": bgstats_count, "bgstats": bgstats_count,
"ebird": ebird_count,
}, },
) )
return ko_count, gpx_count, retro_count, bgstats_count return ko_count, gpx_count, retro_count, bgstats_count, ebird_count
def scan_webdav_for_koreader(webdav_client, user_id, restart=False): def scan_webdav_for_koreader(webdav_client, user_id, restart=False):
@ -343,3 +347,60 @@ def scan_webdav_for_bgstats(webdav_client, user_id):
os.unlink(tmp.name) os.unlink(tmp.name)
return new_imports return new_imports
def scan_webdav_for_ebird(webdav_client, user_id):
"""Download .csv files from WebDAV var/ebird/ and queue imports for new files."""
from scrobbles.models import EBirdCSVImport
from scrobbles.tasks import process_ebird_csv_import
ebird_path = DEFAULT_EBIRD_PATH
try:
webdav_client.info(ebird_path)
except:
logger.info("No var/ebird/ directory on webdav", extra={"user_id": user_id})
return 0
try:
files = webdav_client.list(ebird_path)
except Exception as e:
logger.warning(
"Could not list var/ebird/",
extra={"user_id": user_id, "error": str(e)},
)
return 0
new_imports = 0
already_imported = set(
EBirdCSVImport.objects.filter(user_id=user_id).values_list(
"original_filename", flat=True
)
)
for fname in files:
fname = os.path.basename(fname)
if not fname.lower().endswith(".csv"):
continue
if fname in already_imported:
logger.debug(f"Skipping already-imported {fname}")
continue
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
try:
webdav_client.download_sync(
remote_path=f"{ebird_path}/{fname}", local_path=tmp.name
)
imp = EBirdCSVImport.objects.create(
user_id=user_id,
original_filename=fname,
)
with open(tmp.name, "rb") as f:
imp.csv_file.save(fname, f, save=True)
process_ebird_csv_import.delay(imp.id)
new_imports += 1
except Exception as e:
logger.error(f"Failed to import eBird CSV file {fname}: {e}")
finally:
os.unlink(tmp.name)
return new_imports

View File

@ -14,10 +14,11 @@ class Command(BaseCommand):
restart = False restart = False
if options["restart"]: if options["restart"]:
restart = True restart = True
ko_count, gpx_count, retro_count, bgstats_count = ( ko_count, gpx_count, retro_count, bgstats_count, ebird_count = (
webdav.import_from_webdav_for_all_users(restart=restart) webdav.import_from_webdav_for_all_users(restart=restart)
) )
print( print(
f"Started {ko_count} KOReader, {gpx_count} Trail GPX, " f"Started {ko_count} KOReader, {gpx_count} Trail GPX, "
f"{retro_count} Retroarch, {bgstats_count} BGStats WebDAV imports" f"{retro_count} Retroarch, {bgstats_count} BGStats, "
f"{ebird_count} eBird WebDAV imports"
) )

View File

@ -0,0 +1,75 @@
# Generated by Django 4.2.29 on 2026-05-23 21:24
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import scrobbles.models
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("scrobbles", "0081_bgstats_import_model"),
]
operations = [
migrations.CreateModel(
name="EBirdCSVImport",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
("uuid", models.UUIDField(default=uuid.uuid4, editable=False)),
("processing_started", models.DateTimeField(blank=True, null=True)),
("processed_finished", models.DateTimeField(blank=True, null=True)),
("process_log", models.TextField(blank=True, null=True)),
("process_count", models.IntegerField(blank=True, null=True)),
(
"csv_file",
models.FileField(
blank=True,
null=True,
upload_to=scrobbles.models.EBirdCSVImport.get_path,
),
),
(
"original_filename",
models.CharField(blank=True, max_length=255, null=True),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="scrobbles_ebirdcsvimport_set",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "eBird CSV Import",
},
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 4.2.29 on 2026-05-23 21:24
from django.db import migrations
def copy_birding_data(apps, schema_editor):
OldImport = apps.get_model("birds", "BirdingCSVImport")
NewImport = apps.get_model("scrobbles", "EBirdCSVImport")
db_alias = schema_editor.connection.alias
for old in OldImport.objects.using(db_alias).iterator():
NewImport.objects.using(db_alias).create(
id=old.id,
created=old.created,
modified=old.modified,
uuid=old.uuid,
user=old.user,
processing_started=old.processing_started,
processed_finished=old.processed_finished,
process_log=old.process_log,
process_count=old.process_count,
csv_file=old.csv_file,
original_filename=(
old.csv_file.name.rpartition("/")[-1] if old.csv_file else None
),
)
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0082_ebird_csv_import_model"),
]
operations = [
migrations.RunPython(copy_birding_data, migrations.RunPython.noop),
]

View File

@ -502,6 +502,53 @@ class BGStatsImport(BaseFileImportMixin):
self.mark_finished() self.mark_finished()
class EBirdCSVImport(BaseFileImportMixin):
class Meta:
verbose_name = "eBird CSV Import"
user = models.ForeignKey(
User,
on_delete=models.DO_NOTHING,
**BNULL,
related_name="scrobbles_ebirdcsvimport_set",
)
@property
def import_type(self) -> str:
return "eBird"
def get_absolute_url(self):
return reverse("scrobbles:ebird-csv-import-detail", kwargs={"slug": self.uuid})
def get_path(instance, filename):
extension = filename.split(".")[-1]
uuid = instance.uuid
return f"birding-csv-uploads/{uuid}.{extension}"
@property
def upload_file_path(self):
if getattr(settings, "USE_S3_STORAGE"):
path = self.csv_file.url
else:
path = self.csv_file.path
return path
csv_file = models.FileField(upload_to=get_path, **BNULL)
original_filename = models.CharField(max_length=255, **BNULL)
def process(self, force=False):
from birds.importer import import_birding_csv
if self.processed_finished and not force:
logger.info(f"{self} already processed on {self.processed_finished}")
return
self.mark_started()
scrobbles = import_birding_csv(self.upload_file_path, self.user_id)
self.record_log(scrobbles)
self.mark_finished()
class ScrobbleQuerySet(models.QuerySet): class ScrobbleQuerySet(models.QuerySet):
def with_related(self): def with_related(self):
return self.select_related("user").prefetch_related( return self.select_related("user").prefetch_related(

View File

@ -158,6 +158,16 @@ def process_trail_gpx_import(import_id):
trail_gpx_import.process() trail_gpx_import.process()
@shared_task
def process_ebird_csv_import(import_id):
EBirdCSVImport = apps.get_model("scrobbles", "EBirdCSVImport")
birding_import = EBirdCSVImport.objects.filter(id=import_id).first()
if not birding_import:
logger.warn(f"EBirdCSVImport not found with id {import_id}")
return
birding_import.process()
@shared_task @shared_task
def create_yesterdays_charts(): def create_yesterdays_charts():
"""Build/update charts for all users starting from last known record.""" """Build/update charts for all users starting from last known record."""

View File

@ -141,6 +141,11 @@ urlpatterns = [
views.ScrobbleTrailGPXImportDetailView.as_view(), views.ScrobbleTrailGPXImportDetailView.as_view(),
name="trail-gpx-import-detail", name="trail-gpx-import-detail",
), ),
path(
"imports/ebird-csv/<slug:slug>/",
views.ScrobbleBirdingCSVImportDetailView.as_view(),
name="ebird-csv-import-detail",
),
path( path(
"long-plays/", "long-plays/",
views.ScrobbleLongPlaysView.as_view(), views.ScrobbleLongPlaysView.as_view(),

View File

@ -70,6 +70,7 @@ from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
from scrobbles.models import ( from scrobbles.models import (
AudioScrobblerTSVImport, AudioScrobblerTSVImport,
BGStatsImport, BGStatsImport,
EBirdCSVImport,
KoReaderImport, KoReaderImport,
LastFmImport, LastFmImport,
RetroarchImport, RetroarchImport,
@ -464,9 +465,7 @@ class ScrobbleImportListView(TemplateView):
context_data["bgstats_imports"] = BGStatsImport.objects.filter( context_data["bgstats_imports"] = BGStatsImport.objects.filter(
user=self.request.user, user=self.request.user,
).order_by("-processing_started")[:10] ).order_by("-processing_started")[:10]
context_data["birding_csv_imports"] = apps.get_model( context_data["birding_csv_imports"] = EBirdCSVImport.objects.filter(
"birds", "BirdingCSVImport"
).objects.filter(
user=self.request.user, user=self.request.user,
).order_by("-processing_started")[:10] ).order_by("-processing_started")[:10]
context_data["scale_csv_imports"] = ScaleCSVImport.objects.filter( context_data["scale_csv_imports"] = ScaleCSVImport.objects.filter(
@ -498,6 +497,8 @@ class BaseScrobbleImportDetailView(DetailView):
title = "Retroarch Import" title = "Retroarch Import"
if self.model == BGStatsImport: if self.model == BGStatsImport:
title = "BG Stats Import" title = "BG Stats Import"
if self.model == EBirdCSVImport:
title = "eBird CSV Import"
if self.model == ScaleCSVImport: if self.model == ScaleCSVImport:
title = "Scale CSV Import" title = "Scale CSV Import"
if self.model == TrailGPXImport: if self.model == TrailGPXImport:
@ -534,6 +535,10 @@ class ScrobbleTrailGPXImportDetailView(BaseScrobbleImportDetailView):
model = TrailGPXImport model = TrailGPXImport
class ScrobbleBirdingCSVImportDetailView(BaseScrobbleImportDetailView):
model = EBirdCSVImport
class ManualScrobbleView(FormView): class ManualScrobbleView(FormView):
form_class = ScrobbleForm form_class = ScrobbleForm
template_name = "scrobbles/manual_form.html" template_name = "scrobbles/manual_form.html"

View File

@ -161,6 +161,7 @@ def import_retroarch_lrtl_files(playlog_path: str, user_id: int) -> List[dict]:
playback_position_seconds=playback_position_seconds, playback_position_seconds=playback_position_seconds,
played_to_completion=True, played_to_completion=True,
in_progress=False, in_progress=False,
timezone=timestamp.tzinfo.tzname,
long_play_seconds=game_data["runtime"], long_play_seconds=game_data["runtime"],
long_play_complete=long_play_complete, long_play_complete=long_play_complete,
user_id=user_id, user_id=user_id,

View File

@ -79,6 +79,31 @@
</div> </div>
{% endif %} {% endif %}
{% if birding_csv_imports %}
<div class="row">
<h3>eBird</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Started</th>
<th scope="col">Finished</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
{% for obj in birding_csv_imports %}
<tr>
<td><a href="{{obj.get_absolute_url}}">{{obj.human_start}}</a></td>
<td>{{obj.processed_finished}}</td>
<td>{{obj.process_count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if bgstats_imports %} {% if bgstats_imports %}
<div class="row"> <div class="row">
<h3>BG Stats</h3> <h3>BG Stats</h3>