[birds] Move importer to scrobbles and webdav
This commit is contained in:
@ -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
|
||||||
|
|||||||
@ -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):
|
||||||
...
|
...
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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),
|
||||||
|
]
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user