Skip to content
2 changes: 2 additions & 0 deletions bats_ai/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
RecordingRouter,
RecordingTagRouter,
SpeciesRouter,
VettingRouter,
)
from bats_ai.core.views.nabat import NABatConfigurationRouter, NABatRecordingRouter

Expand Down Expand Up @@ -46,3 +47,4 @@ def global_auth(request):
api.add_router('/recording-tag/', RecordingTagRouter)
api.add_router('/nabat/recording/', NABatRecordingRouter)
api.add_router('/nabat/configuration/', NABatConfigurationRouter)
api.add_router('/vetting/', VettingRouter)
2 changes: 2 additions & 0 deletions bats_ai/core/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .species import SpeciesAdmin
from .spectrogram import SpectrogramAdmin
from .spectrogram_image import SpectrogramImageAdmin
from .vetting_details import VettingDetailsAdmin

__all__ = [
'AnnotationsAdmin',
Expand All @@ -32,6 +33,7 @@
'ConfigurationAdmin',
'ExportedAnnotationFileAdmin',
'SpectrogramImageAdmin',
'VettingDetailsAdmin',
# NABat Models
'NABatRecordingAnnotationAdmin',
'NABatCompressedSpectrogramAdmin',
Expand Down
1 change: 1 addition & 0 deletions bats_ai/core/admin/recording_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class RecordingAnnotationAdmin(admin.ModelAdmin):
'additional_data',
'comments',
'model',
'submitted',
]
list_select_related = True
filter_horizontal = ('species',) # or filter_vertical
Expand Down
13 changes: 13 additions & 0 deletions bats_ai/core/admin/vetting_details.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.contrib import admin

from bats_ai.core.models import VettingDetails


@admin.register(VettingDetails)
class VettingDetailsAdmin(admin.ModelAdmin):
list_display = [
'pk',
'user',
# 'reference_materials',
]
search_fields = ('user',)
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Generated by Django 4.2.23 on 2026-01-15 18:06

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('core', '0024_delete_image'),
]

operations = [
migrations.AddField(
model_name='configuration',
name='mark_annotations_completed_enabled',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='configuration',
name='non_admin_upload_enabled',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='recordingannotation',
name='submitted',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='VettingDetails',
fields=[
(
'id',
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
),
),
('reference_materials', models.TextField(blank=True)),
(
'user',
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
),
migrations.AddConstraint(
model_name='vettingdetails',
constraint=models.CheckConstraint(
check=models.Q(('reference_materials__length__lte', 2000)),
name='reference_materials_max_2000',
),
),
]
2 changes: 2 additions & 0 deletions bats_ai/core/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .species import Species
from .spectrogram import Spectrogram
from .spectrogram_image import SpectrogramImage
from .vetting_details import VettingDetails

__all__ = [
'Annotations',
Expand All @@ -28,4 +29,5 @@
'ProcessingTaskType',
'ExportedAnnotationFile',
'SpectrogramImage',
'VettingDetails',
]
4 changes: 4 additions & 0 deletions bats_ai/core/models/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ class AvailableColorScheme(models.TextChoices):
# 18 characters is just enough for "rgb(255, 255, 255)"
default_spectrogram_background_color = models.CharField(max_length=18, default='rgb(0, 0, 0)')

# Fields used for community vetting focused deployment of BatAI
non_admin_upload_enabled = models.BooleanField(default=True)
mark_annotations_completed_enabled = models.BooleanField(default=False)

def save(self, *args, **kwargs):
# Ensure only one instance of Configuration exists
if not Configuration.objects.exists() and not self.pk:
Expand Down
4 changes: 3 additions & 1 deletion bats_ai/core/models/recording_annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ class RecordingAnnotation(TimeStampedModel, models.Model):
owner = models.ForeignKey(User, on_delete=models.CASCADE)
species = models.ManyToManyField(Species)
comments = models.TextField(blank=True, null=True)
model = models.TextField(blank=True, null=True) # AI Model information if inference used
# AI Model information if inference used, else "User Defined"
model = models.TextField(blank=True, null=True)
confidence = models.FloatField(
default=1.0,
validators=[
Expand All @@ -24,3 +25,4 @@ class RecordingAnnotation(TimeStampedModel, models.Model):
additional_data = models.JSONField(
blank=True, null=True, help_text='Additional information about the models/data'
)
submitted = models.BooleanField(default=False)
20 changes: 20 additions & 0 deletions bats_ai/core/models/vetting_details.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from django.contrib.auth.models import User
from django.db import models
from django.db.models import Q
from django.db.models.functions import Length

models.TextField.register_lookup(Length, 'length')


class VettingDetails(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
reference_materials = models.TextField(blank=True)

class Meta:
constraints = [
models.CheckConstraint(
# TODO change to 'condition' in Django v6
check=Q(reference_materials__length__lte=2000),
name='reference_materials_max_2000',
)
]
14 changes: 13 additions & 1 deletion bats_ai/core/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
from django.test import Client
import pytest

from .factories import SuperuserFactory, UserFactory
from bats_ai.core.models import VettingDetails

from .factories import SuperuserFactory, UserFactory, VettingDetailsFactory


@pytest.fixture
Expand Down Expand Up @@ -32,3 +34,13 @@ def authorized_client(superuser: User) -> Client:
client = Client()
client.force_login(user=superuser)
return client


@pytest.fixture
def vetting_details(user: User) -> VettingDetails:
return VettingDetailsFactory(user=user)


@pytest.fixture
def random_user_vetting_details() -> VettingDetails:
return VettingDetailsFactory(user=UserFactory())
11 changes: 11 additions & 0 deletions bats_ai/core/tests/factories.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from django.contrib.auth.models import User
import factory.django

from bats_ai.core.models import VettingDetails


class UserFactory(factory.django.DjangoModelFactory[User]):
class Meta:
Expand Down Expand Up @@ -28,3 +30,12 @@ class Meta:
def _create(cls, model_class, *args, **kwargs):
manager = cls._get_manager(model_class)
return manager.create_superuser(*args, **kwargs)


class VettingDetailsFactory(factory.django.DjangoModelFactory[VettingDetails]):

class Meta:
model = VettingDetails

user = factory.SubFactory(UserFactory)
reference_materials = factory.Faker('paragraph', nb_sentences=3)
114 changes: 114 additions & 0 deletions bats_ai/core/tests/test_vetting_details.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import pytest

from .factories import UserFactory, VettingDetailsFactory


@pytest.mark.parametrize(
'client_fixture,status_code',
[
('client', 401),
('authenticated_client', 200),
('authorized_client', 200),
],
)
@pytest.mark.django_db
def test_get_vetting_details(client_fixture, status_code, user, vetting_details, request):
api_client = request.getfixturevalue(client_fixture)
resp = api_client.get(f'/api/v1/vetting/user/{user.id}')
assert resp.status_code == status_code
if status_code == 200:
assert resp.json()['reference_materials'] == vetting_details.reference_materials


@pytest.mark.django_db
def test_get_vetting_details_other_user(authenticated_client):
other_user = UserFactory()
VettingDetailsFactory(user=other_user)
resp = authenticated_client.get(f'/api/v1/vetting/user/{other_user.id}')
assert resp.status_code == 404


@pytest.mark.django_db
def test_create_vetting_details(client):
test_text = 'foo'
data = {'reference_materials': test_text}
test_user = UserFactory()
client.force_login(user=test_user)
resp = client.post(
f'/api/v1/vetting/user/{test_user.id}', data=data, content_type='application/json'
)
assert resp.status_code == 200
assert resp.json()['user_id'] == test_user.id


@pytest.mark.parametrize(
'client_fixture,status_code',
[
('authenticated_client', 404),
('authorized_client', 200),
],
)
@pytest.mark.django_db
def test_create_vetting_details_other_user(client_fixture, status_code, request):
api_client = request.getfixturevalue(client_fixture)
test_text = 'foo'
data = {'reference_materials': test_text}
other_user = UserFactory()
resp = api_client.post(
f'/api/v1/vetting/user/{other_user.id}', data=data, content_type='application/json'
)
assert resp.status_code == status_code
if status_code == 200:
assert resp.json()['reference_materials'] == test_text


@pytest.mark.django_db
def test_update_vetting_details(client):
test_text = 'bar'
data = {'reference_materials': 'bar'}
test_user = UserFactory()
VettingDetailsFactory(user=test_user, reference_materials='foo')
client.force_login(test_user)

initial_resp = client.get(f'/api/v1/vetting/user/{test_user.id}')
assert initial_resp.status_code == 200

resp = client.post(
f'/api/v1/vetting/user/{test_user.id}', data=data, content_type='application/json'
)
assert resp.status_code == 200

new_details_response = client.get(f'/api/v1/vetting/user/{test_user.id}')
assert new_details_response.status_code == 200
assert new_details_response.json()['reference_materials'] == test_text


@pytest.mark.parametrize(
'client_fixture,status_code',
[
('authenticated_client', 404),
('authorized_client', 200),
],
)
@pytest.mark.django_db
def test_update_vetting_details_other_user(
client_fixture, status_code, random_user_vetting_details, request
):
api_client = request.getfixturevalue(client_fixture)
resp = api_client.post(
f'/api/v1/vetting/user/{random_user_vetting_details.user.id}',
data={'reference_materials': 'foo'},
content_type='application/json',
)
assert resp.status_code == status_code


@pytest.mark.django_db
def test_update_vetting_details_length_constraint(authorized_client, random_user_vetting_details):
data = {'reference_materials': 'a' * 2001}
resp = authorized_client.post(
f'/api/v1/vetting/user/{random_user_vetting_details.user.id}',
data=data,
content_type='application/json',
)
assert resp.status_code == 400
2 changes: 2 additions & 0 deletions bats_ai/core/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .recording_tag import router as RecordingTagRouter
from .sequence_annotations import router as SequenceAnnotationRouter
from .species import router as SpeciesRouter
from .vetting_details import router as VettingRouter

__all__ = [
'RecordingRouter',
Expand All @@ -22,4 +23,5 @@
'ProcessingTaskRouter',
'ExportAnnotationRouter',
'RecordingTagRouter',
'VettingRouter',
]
15 changes: 15 additions & 0 deletions bats_ai/core/views/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class ConfigurationSchema(Schema):
spectrogram_view: Configuration.SpectrogramViewMode
default_color_scheme: Configuration.AvailableColorScheme
default_spectrogram_background_color: str
non_admin_upload_enabled: bool
mark_annotations_completed_enabled: bool


# Endpoint to retrieve the configuration status
Expand All @@ -38,6 +40,8 @@ def get_configuration(request):
spectrogram_view=config.spectrogram_view,
default_color_scheme=config.default_color_scheme,
default_spectrogram_background_color=config.default_spectrogram_background_color,
non_admin_upload_enabled=config.non_admin_upload_enabled,
mark_annotations_completed_enabled=config.mark_annotations_completed_enabled,
is_admin=request.user.is_authenticated and request.user.is_superuser,
)

Expand All @@ -61,3 +65,14 @@ def check_is_admin(request):
if request.user.is_authenticated:
return {'is_admin': request.user.is_superuser}
return {'is_admin': False}


@router.get('/me')
def get_current_user(request):
if request.user.is_authenticated:
return {
'email': request.user.email,
'name': request.user.username,
'id': request.user.id,
}
return {'email': '', 'name': ''}
Loading