Rework sponsorblock_categories and CommaSepChoiceField (#222)

This is a good checkpoint.

* Loading from the database and saving back to the database both work.

* The categories are displayed in the web user interface appropriately.

* Editing the `Source` from the web user interface is working.
This commit is contained in:
tcely 2025-02-01 20:57:27 -05:00 committed by GitHub
parent 5e1da4e58e
commit ae1042a3d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 148 additions and 74 deletions

View File

@ -1,6 +1,8 @@
from django.forms import MultipleChoiceField, CheckboxSelectMultiple, Field, TypedMultipleChoiceField
from django.db import models
from typing import Any, Optional, Dict
from collections import namedtuple
from functools import lru_cache
from typing import Any, Dict
from django import forms
from django.db import connection, models
from django.utils.translation import gettext_lazy as _
@ -22,103 +24,174 @@ class SponsorBlock_Category(models.TextChoices):
MUSIC_OFFTOPIC = 'music_offtopic', _( 'Non-Music Section' )
CommaSepChoice = namedtuple(
'CommaSepChoice', [
'allow_all',
'all_choice',
'all_label',
'possible_choices',
'selected_choices',
'separator',
],
defaults = (
False,
None,
'All',
list(),
list(),
',',
),
)
# this is a form field!
class CustomCheckboxSelectMultiple(CheckboxSelectMultiple):
class CustomCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
template_name = 'widgets/checkbox_select.html'
option_template_name = 'widgets/checkbox_option.html'
# perhaps set the 'selected' attribute too?
# checked_attribute = {'checked': True, 'selected': True}
def get_context(self, name: str, value: Any, attrs) -> Dict[str, Any]:
ctx = super().get_context(name, value, attrs)['widget']
ctx["multipleChoiceProperties"] = []
for _group, options, _index in ctx["optgroups"]:
for option in options:
if not isinstance(value,str) and not isinstance(value,list) and ( option["value"] in value.selected_choices or ( value.allow_all and value.all_choice in value.selected_choices ) ):
checked = True
else:
checked = False
data = value
select_all = False
if isinstance(data, CommaSepChoice):
select_all = (data.allow_all and data.all_choice in data.selected_choices)
value = list(data.selected_choices)
context = super().get_context(name, value, attrs)
widget = context['widget']
options = widget['optgroups']
# This is a new key in widget
widget['multipleChoiceProperties'] = list()
for _group, single_option_list, _index in options:
for option in single_option_list:
option['selected'] |= select_all
widget['multipleChoiceProperties'].append(option)
ctx["multipleChoiceProperties"].append({
"template_name": option["template_name"],
"type": option["type"],
"value": option["value"],
"label": option["label"],
"name": option["name"],
"checked": checked})
return { 'widget': widget }
return { 'widget': ctx }
# this is a database field!
class CommaSepChoiceField(models.Field):
"Implements comma-separated storage of lists"
class CommaSepChoiceField(models.CharField):
'''
Implements comma-separated storage of lists
'''
# If 'text' isn't correct add the vendor override here.
_DB_TYPES = {}
form_class = forms.MultipleChoiceField
widget = CustomCheckboxSelectMultiple
from common.logger import log
def __init__(self, *args, separator=",", possible_choices=(("","")), all_choice="", all_label="All", allow_all=False, **kwargs):
super().__init__(*args, **kwargs)
kwargs.setdefault('max_length', 128)
self.separator = str(separator)
self.possible_choices = possible_choices
self.selected_choices = []
self.possible_choices = possible_choices or choices
self.selected_choices = list()
self.allow_all = allow_all
self.all_label = all_label
self.all_choice = all_choice
self.choices = self.get_all_choices()
super().__init__(*args, **kwargs)
self.validators.clear()
# Override these functions to prevent unwanted behaviors
def to_python(self, value):
return value
def get_internal_type(self):
return super().get_internal_type()
# standard functions for this class
def deconstruct(self):
# set it back to the default for models.Field
# this way it is never in the returned values
self.choices = None
name, path, args, kwargs = super().deconstruct()
self.choices = self.get_all_choices()
if ',' != self.separator:
kwargs['separator'] = self.separator
kwargs['possible_choices'] = self.possible_choices
return name, path, args, kwargs
def db_type(self, connection):
value = self._DB_TYPES.get(connection.vendor, None)
return value if value is not None else 'text'
def get_my_choices(self):
choiceArray = []
if self.possible_choices is None:
return choiceArray
if self.allow_all:
choiceArray.append((self.all_choice, _(self.all_label)))
for t in self.possible_choices:
choiceArray.append(t)
return choiceArray
kwargs['allow_all'] = self.allow_all
if self.all_choice:
kwargs['all_choice'] = self.all_choice
if 'All' != self.all_label:
kwargs['all_label'] = self.all_label
return name, path, args, kwargs
def formfield(self, **kwargs):
# This is a fairly standard way to set up some defaults
# while letting the caller override them.
defaults = {'form_class': MultipleChoiceField,
'choices': self.get_my_choices,
'widget': CustomCheckboxSelectMultiple,
'label': '',
'required': False}
defaults = {
'form_class': self.form_class,
# 'choices_form_class': self.form_class,
'widget': self.widget,
# use a callable for choices
'choices': self.get_all_choices,
'label': '',
'required': False,
}
# Keep the part from CharField we want,
# then call Field to skip the 'max_length' entry.
db_empty_string_as_null = connection.features.interprets_empty_strings_as_nulls
if self.null and not db_empty_string_as_null:
defaults['empty_value'] = None
defaults.update(kwargs)
return super().formfield(**defaults)
return models.Field.formfield(self, **defaults)
# This is a more compact way to do the same thing
# return super().formfield(**{
# 'form_class': self.form_class,
# **kwargs,
# })
def from_db_value(self, value, expr, conn):
if 0 == len(value) or value is None:
self.selected_choices = []
else:
self.selected_choices = value.split(self.separator)
@lru_cache(maxsize=10)
def from_db_value(self, value, expression, connection):
'''
Create a data structure to be used in Python code.
return self
This is called quite often with the same input,
because the database value doesn't change often.
So, it's being cached to prevent excessive logging.
'''
self.log.debug(f'fdbv:1: {type(value)} {repr(value)}')
if isinstance(value, str) and len(value) > 0:
value = value.split(self.separator)
if not isinstance(value, list):
value = list()
self.selected_choices = value
args_dict = {key: self.__dict__[key] for key in CommaSepChoice._fields}
return CommaSepChoice(**args_dict)
def get_prep_value(self, value):
if value is None:
return ""
if not isinstance(value,list):
return ""
'''
Create a value to be stored in the database.
'''
data = value
if not isinstance(data, CommaSepChoice):
# The data was lost; we can regenerate it.
args_dict = {key: self.__dict__[key] for key in CommaSepChoice._fields}
args_dict['selected_choices'] = list(value)
data = CommaSepChoice(**args_dict)
value = data.selected_choices
s_value = super().get_prep_value(value)
if set(s_value) != set(value):
self.log.warn(f'CommaSepChoiceField:get_prep_value: values did not match. '
f'CommaSepChoiceField({value}) versus CharField({s_value})')
if not isinstance(value, list):
return ''
if data.all_choice in value:
return data.all_choice
return data.separator.join(value)
if self.all_choice not in value:
return self.separator.join(value)
else:
return self.all_choice
# extra functions not used by any parent classes
def get_all_choices(self):
choice_list = list()
if self.possible_choices is None:
return choice_list
if self.allow_all:
choice_list.append((self.all_choice, _(self.all_label)))
for choice in self.possible_choices:
choice_list.append(choice)
return choice_list
def get_text_for_value(self, val):
fval = [i for i in self.possible_choices if i[0] == val]
if len(fval) <= 0:
return []
else:
return fval[0][1]

View File

@ -14,6 +14,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='source',
name='sponsorblock_categories',
field=sync.fields.CommaSepChoiceField(default='all', help_text='Select the sponsorblocks you want to enforce', possible_choices=[('sponsor', 'Sponsor'), ('intro', 'Intermission/Intro Animation'), ('outro', 'Endcards/Credits'), ('selfpromo', 'Unpaid/Self Promotion'), ('preview', 'Preview/Recap'), ('filler', 'Filler Tangent'), ('interaction', 'Interaction Reminder'), ('music_offtopic', 'Non-Music Section')], verbose_name=''),
field=sync.fields.CommaSepChoiceField(all_choice='all', all_label='(All Categories)', allow_all=True, default='all', help_text='Select the SponsorBlock categories that you wish to be removed from downloaded videos.', max_length=128, possible_choices=[('sponsor', 'Sponsor'), ('intro', 'Intermission/Intro Animation'), ('outro', 'Endcards/Credits'), ('selfpromo', 'Unpaid/Self Promotion'), ('preview', 'Preview/Recap'), ('filler', 'Filler Tangent'), ('interaction', 'Interaction Reminder'), ('music_offtopic', 'Non-Music Section')], verbose_name=''),
),
]

View File

@ -118,12 +118,13 @@ class Source(models.Model):
sponsorblock_categories = CommaSepChoiceField(
_(''),
max_length=128,
possible_choices=SponsorBlock_Category.choices,
all_choice='all',
allow_all=True,
all_label='(all options)',
all_label='(All Categories)',
default='all',
help_text=_('Select the sponsorblocks you want to enforce')
help_text=_('Select the SponsorBlock categories that you wish to be removed from downloaded videos.')
)
embed_metadata = models.BooleanField(
_('embed metadata'),

View File

@ -2,6 +2,6 @@
<label for="{{ option.value }}">{{option.label}}</label>-->
<label>
<input type="{{ option.type }}" name="{{ option.name }}" value="{{ option.value }}" id="{{ option.value }}" {% if option.checked %}checked{% endif %}>
<input type="{{ option.type }}" name="{{ option.name }}" value="{{ option.value }}" id="{{ option.value }}" {% if option.selected %}checked{% endif %}>
<span>{{option.label}}</span>
</label>
</label>