Skip to content

Commit a52eefc

Browse files
committed
Add Speaker datetime and color fields with RGBA admin interface
Features added: - Added created/updated datetime fields to Speaker model with API filters - Added fill_color and border_color fields for Google Maps polygon styling - Created advanced RGBA color picker in admin with alpha transparency - Real-time color preview and map shape updates in admin interface - Updated API filters to support created__gte/lte and updated__gte/lte - Fixed Django warnings and floppyforms compatibility issues - Added comprehensive tests for new model fields and API functionality Technical changes: - Speaker model: created, updated, fill_color, border_color fields - SpeakerFilterSet: datetime range filters - SpeakerAdmin: enhanced form with color picker widgets - Custom JavaScript/CSS for RGBA color interface with map integration - Migration: 0041_add_speaker_datetime_and_color_fields - Tests: updated for new fields and API endpoints
1 parent 50cfffc commit a52eefc

File tree

10 files changed

+810
-9
lines changed

10 files changed

+810
-9
lines changed

roundware/api2/filters.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,11 +245,15 @@ class SpeakerFilterSet(django_filters.FilterSet):
245245
parent_ids_or = IntegerListFilter(field_name='parents', lookup_expr='in') # performs OR filtering
246246
parent_ids = IntegerListAndFilter(field_name='parents__id') # performs AND filtering
247247
children_ids_or = IntegerListFilter(field_name='children', lookup_expr='in') # performs OR filtering
248-
children_ids = IntegerListAndFilter(field_name='children__id') # performs AND filtering
248+
children_ids = IntegerListAndFilter(field_name='children__id') # performs AND filtering
249+
created__lte = django_filters.DateTimeFilter(field_name='created', lookup_expr='lte')
250+
created__gte = django_filters.DateTimeFilter(field_name='created', lookup_expr='gte')
251+
updated__lte = django_filters.DateTimeFilter(field_name='updated', lookup_expr='lte')
252+
updated__gte = django_filters.DateTimeFilter(field_name='updated', lookup_expr='gte')
249253

250254
class Meta:
251255
model = Speaker
252-
fields = ["activeyn", "project_id", "parents", "children"]
256+
fields = ["activeyn", "project_id", "parents", "children", "created", "updated"]
253257

254258

255259
class TagFilterSet(django_filters.FilterSet):

roundware/api2/tests/test_views.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from django.db import models
2525
from django.test.utils import override_settings
2626
from django.test.testcases import TransactionTestCase
27+
from django.urls import reverse
2728

2829
# Test fixtures
2930
@pytest.fixture
@@ -2278,4 +2279,60 @@ def get_exception_handler(self):
22782279
# Should keep custom status code and add auth header
22792280
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
22802281
assert response['WWW-Authenticate'] == 'Bearer realm="api"'
2281-
assert response.exception is True
2282+
assert response.exception is True
2283+
2284+
# Add this test to the existing SpeakerViewSet tests
2285+
def test_speaker_datetime_fields_in_api_response(self):
2286+
"""Test that created and updated fields are included in API responses"""
2287+
speaker = baker.make('rw.Speaker', project=self.project)
2288+
2289+
# Test GET endpoint includes datetime fields
2290+
url = reverse('speaker-detail', args=[speaker.id])
2291+
response = self.client.get(url)
2292+
2293+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2294+
data = response.json()
2295+
2296+
self.assertIn('created', data)
2297+
self.assertIn('updated', data)
2298+
self.assertIsNotNone(data['created'])
2299+
self.assertIsNotNone(data['updated'])
2300+
2301+
def test_speaker_color_fields_in_api_response(self):
2302+
"""Test that color fields are included in API responses"""
2303+
speaker = baker.make('rw.Speaker', project=self.project)
2304+
2305+
# Test GET endpoint includes color fields
2306+
url = reverse('speaker-detail', args=[speaker.id])
2307+
response = self.client.get(url)
2308+
2309+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2310+
data = response.json()
2311+
2312+
self.assertIn('fill_color', data)
2313+
self.assertIn('border_color', data)
2314+
self.assertEqual(data['fill_color'], '#0000FF80')
2315+
self.assertEqual(data['border_color'], '#0000FF')
2316+
2317+
def test_speaker_color_fields_can_be_updated(self):
2318+
"""Test that color fields can be updated via PATCH"""
2319+
speaker = baker.make('rw.Speaker', project=self.project)
2320+
2321+
# Test PATCH endpoint can update color fields
2322+
url = reverse('speaker-detail', args=[speaker.id])
2323+
patch_data = {
2324+
'fill_color': '#FF000080',
2325+
'border_color': '#FF0000'
2326+
}
2327+
response = self.client.patch(url, patch_data, content_type='application/json')
2328+
2329+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2330+
data = response.json()
2331+
2332+
self.assertEqual(data['fill_color'], '#FF000080')
2333+
self.assertEqual(data['border_color'], '#FF0000')
2334+
2335+
# Verify database was updated
2336+
speaker.refresh_from_db()
2337+
self.assertEqual(speaker.fill_color, '#FF000080')
2338+
self.assertEqual(speaker.border_color, '#FF0000')

roundware/rw/admin.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
from leaflet.admin import LeafletGeoAdmin
1414

15+
from roundware.rw.forms import SpeakerForm
16+
1517
class VoteInline(admin.TabularInline):
1618
model = Vote
1719
extra = 1
@@ -490,32 +492,47 @@ class Media:
490492

491493

492494
class SpeakerAdmin(LeafletGeoAdmin, ProjectProtectedModelAdmin):
493-
list_display = ('id', 'activeyn', 'code', 'project', 'maxvolume', 'minvolume', 'shape', 'uri')
494-
list_filter = ('project', 'activeyn')
495+
form = SpeakerForm
496+
list_display = ('id', 'activeyn', 'code', 'project', 'maxvolume', 'minvolume', 'shape', 'uri', 'created', 'updated')
497+
list_filter = ('project', 'activeyn', 'created')
495498
list_editable = ('activeyn', 'maxvolume', 'minvolume', 'shape')
496499
filter_horizontal = ('parents', )
497500
ordering = ['id']
498501
save_as = True
499502
save_on_top = True
500503
map_width = "400px"
504+
readonly_fields = ('created', 'updated')
501505

502506
fieldsets = (
503507
(None, {
504508
'fields': ('activeyn', 'code', 'project', 'maxvolume', 'minvolume', 'uri', 'parents' )
505509
}),
506510
('Geographical Data', {
507511
'fields': ('shape', 'attenuation_distance'),
512+
}),
513+
('Map Display Colors', {
514+
'fields': (
515+
('fill_color_rgb', 'fill_color_alpha'),
516+
('border_color_rgb', 'border_color_alpha'),
517+
('fill_color', 'border_color'), # Hidden fields for storage
518+
),
519+
'description': 'Set colors for this speaker\'s polygon on the map. Use RGB color pickers and sliders to control transparency (alpha).',
520+
}),
521+
('Timestamps', {
522+
'fields': ('created', 'updated'),
523+
'classes': ('collapse',)
508524
})
509525
)
510526

511527
class Media:
512528
css = {
513529
"all": (
514530
"http://code.jquery.com/ui/1.10.3/themes/smoothness/jquery-ui.css",
515-
"rw/css/speaker_admin.css"
531+
"rw/css/speaker_admin.css",
532+
"rw/css/speaker_color_admin.css"
516533
)
517534
}
518-
js = []
535+
js = ['rw/js/speaker_color_admin.js']
519536

520537

521538
class ListeningHistoryItemAdmin(ProjectProtectedThroughAssetModelAdmin):

roundware/rw/forms.py

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33

44
from __future__ import unicode_literals
55
from django.forms.models import BaseModelFormSet
6+
from django import forms as django_forms
67

78
import floppyforms as forms
89
from crispy_forms.helper import FormHelper
910
from guardian.shortcuts import get_objects_for_user
1011

1112
from django.conf import settings
1213

13-
from roundware.rw.models import Tag, UIGroup, UIItem
14+
from roundware.rw.models import Tag, UIGroup, UIItem, Speaker
1415
from roundware.rw import fields
1516
from roundware.rw.widgets import (NonAdminRelatedFieldWidgetWrapper,
1617
DummyWidgetWrapper,
@@ -221,3 +222,121 @@ class Meta:
221222
'index': 'Ordering',
222223
'header_text_loc': "Localized Header Text",
223224
}
225+
226+
227+
class SpeakerForm(django_forms.ModelForm):
228+
"""
229+
Custom form for Speaker model with color picker widgets that support alpha channel
230+
"""
231+
# Virtual fields for the color picker UI
232+
fill_color_rgb = django_forms.CharField(
233+
required=False,
234+
widget=django_forms.TextInput(attrs={
235+
'type': 'color',
236+
'title': 'Choose fill color (RGB)',
237+
'style': 'width: 60px; height: 30px; margin-right: 10px;'
238+
}),
239+
help_text="RGB color component"
240+
)
241+
242+
fill_color_alpha = django_forms.IntegerField(
243+
required=False,
244+
min_value=0,
245+
max_value=255,
246+
initial=128,
247+
widget=django_forms.NumberInput(attrs={
248+
'type': 'range',
249+
'min': '0',
250+
'max': '255',
251+
'step': '1',
252+
'style': 'width: 100px; margin-right: 10px;',
253+
'oninput': 'updateAlphaDisplay(this, "fill_alpha_display")'
254+
}),
255+
help_text="Alpha (transparency): 0=transparent, 255=opaque"
256+
)
257+
258+
border_color_rgb = django_forms.CharField(
259+
required=False,
260+
widget=django_forms.TextInput(attrs={
261+
'type': 'color',
262+
'title': 'Choose border color (RGB)',
263+
'style': 'width: 60px; height: 30px; margin-right: 10px;'
264+
}),
265+
help_text="RGB color component"
266+
)
267+
268+
border_color_alpha = django_forms.IntegerField(
269+
required=False,
270+
min_value=0,
271+
max_value=255,
272+
initial=255,
273+
widget=django_forms.NumberInput(attrs={
274+
'type': 'range',
275+
'min': '0',
276+
'max': '255',
277+
'step': '1',
278+
'style': 'width: 100px; margin-right: 10px;',
279+
'oninput': 'updateAlphaDisplay(this, "border_alpha_display")'
280+
}),
281+
help_text="Alpha (transparency): 0=transparent, 255=opaque"
282+
)
283+
284+
class Meta:
285+
model = Speaker
286+
fields = '__all__'
287+
widgets = {
288+
# Hide the actual hex fields since we use the RGB + alpha fields above
289+
'fill_color': django_forms.HiddenInput(),
290+
'border_color': django_forms.HiddenInput(),
291+
}
292+
293+
def __init__(self, *args, **kwargs):
294+
super(SpeakerForm, self).__init__(*args, **kwargs)
295+
296+
# Parse existing hex values into RGB + alpha components
297+
if self.instance and self.instance.pk:
298+
# Parse fill_color (e.g., "#FF0000AA" -> RGB="#FF0000", Alpha=170)
299+
if self.instance.fill_color:
300+
fill_hex = self.instance.fill_color
301+
if len(fill_hex) == 9: # #RRGGBBAA
302+
self.fields['fill_color_rgb'].initial = fill_hex[:7] # #RRGGBB
303+
self.fields['fill_color_alpha'].initial = int(fill_hex[7:9], 16) # AA
304+
elif len(fill_hex) == 7: # #RRGGBB
305+
self.fields['fill_color_rgb'].initial = fill_hex
306+
self.fields['fill_color_alpha'].initial = 255
307+
308+
# Parse border_color
309+
if self.instance.border_color:
310+
border_hex = self.instance.border_color
311+
if len(border_hex) == 9: # #RRGGBBAA
312+
self.fields['border_color_rgb'].initial = border_hex[:7] # #RRGGBB
313+
self.fields['border_color_alpha'].initial = int(border_hex[7:9], 16) # AA
314+
elif len(border_hex) == 7: # #RRGGBB
315+
self.fields['border_color_rgb'].initial = border_hex
316+
self.fields['border_color_alpha'].initial = 255
317+
318+
def clean(self):
319+
cleaned_data = super().clean()
320+
321+
# Combine RGB + alpha into 8-digit hex values
322+
fill_rgb = cleaned_data.get('fill_color_rgb', '#0000FF')
323+
fill_alpha = cleaned_data.get('fill_color_alpha', 128)
324+
if fill_rgb and fill_alpha is not None:
325+
# Convert alpha (0-255) to hex (00-FF)
326+
alpha_hex = format(fill_alpha, '02X')
327+
cleaned_data['fill_color'] = f"{fill_rgb}{alpha_hex}"
328+
329+
border_rgb = cleaned_data.get('border_color_rgb', '#0000FF')
330+
border_alpha = cleaned_data.get('border_color_alpha', 255)
331+
if border_rgb and border_alpha is not None:
332+
# Convert alpha (0-255) to hex (00-FF)
333+
alpha_hex = format(border_alpha, '02X')
334+
cleaned_data['border_color'] = f"{border_rgb}{alpha_hex}"
335+
336+
return cleaned_data
337+
338+
class Media:
339+
js = ('rw/js/speaker_color_admin.js',)
340+
css = {
341+
'all': ('rw/css/speaker_color_admin.css',)
342+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Generated by Django 4.2.23 on 2024-01-01 00:00
2+
3+
import datetime
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('rw', '0040_speaker_parents'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='speaker',
16+
name='created',
17+
field=models.DateTimeField(default=datetime.datetime.now),
18+
),
19+
migrations.AddField(
20+
model_name='speaker',
21+
name='updated',
22+
field=models.DateTimeField(auto_now=True),
23+
),
24+
migrations.AddField(
25+
model_name='speaker',
26+
name='fill_color',
27+
field=models.CharField(
28+
default='#0000FF80',
29+
help_text='Hex color for polygon fill (supports alpha: #RRGGBBAA)',
30+
max_length=9
31+
),
32+
),
33+
migrations.AddField(
34+
model_name='speaker',
35+
name='border_color',
36+
field=models.CharField(
37+
default='#0000FF',
38+
help_text='Hex color for polygon border (supports alpha: #RRGGBBAA)',
39+
max_length=9
40+
),
41+
),
42+
migrations.RunSQL(
43+
# Set initial created and updated times for existing speakers
44+
"UPDATE rw_speaker SET created=NOW(), updated=NOW() WHERE created IS NULL OR updated IS NULL;",
45+
reverse_sql="-- No reverse operation needed"
46+
),
47+
]

roundware/rw/models.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,22 @@ def __init__(self, *args, **kwargs):
548548

549549
parents = models.ManyToManyField('Speaker', related_name='children', symmetrical=False, blank=True)
550550

551+
# Datetime tracking fields (like Asset model)
552+
created = models.DateTimeField(default=datetime.now)
553+
updated = models.DateTimeField(auto_now=True)
554+
555+
# Map display styling fields
556+
fill_color = models.CharField(
557+
max_length=9,
558+
default='#0000FF80', # Semi-transparent blue
559+
help_text='Hex color for polygon fill (supports alpha: #RRGGBBAA)'
560+
)
561+
border_color = models.CharField(
562+
max_length=9,
563+
default='#0000FF', # Solid blue
564+
help_text='Hex color for polygon border (supports alpha: #RRGGBBAA)'
565+
)
566+
551567
objects = GeoManager()
552568

553569
def __str__(self):
@@ -613,8 +629,9 @@ class Tag(models.Model):
613629
location = models.MultiPolygonField(geography=True, null=True, blank=True)
614630

615631
# DEPRECATED: Used in API/1; could be generated from API/2 data
632+
# Note: removed related_name since it has no effect on symmetrical relationships
616633
relationships_old = models.ManyToManyField(
617-
'self', symmetrical=True, related_name='related_to', blank=True)
634+
'self', symmetrical=True, blank=True)
618635

619636
@mark_safe
620637
def get_loc(self):

0 commit comments

Comments
 (0)