From ae1042a3d0f7578711cf56a91ad9c71abbe77a8e Mon Sep 17 00:00:00 2001 From: tcely Date: Sat, 1 Feb 2025 20:57:27 -0500 Subject: [PATCH 1/3] 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. --- tubesync/sync/fields.py | 211 ++++++++++++------ ...27_alter_source_sponsorblock_categories.py | 2 +- tubesync/sync/models.py | 5 +- .../templates/widgets/checkbox_option.html | 4 +- 4 files changed, 148 insertions(+), 74 deletions(-) diff --git a/tubesync/sync/fields.py b/tubesync/sync/fields.py index 31889024..a18518a1 100644 --- a/tubesync/sync/fields.py +++ b/tubesync/sync/fields.py @@ -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] diff --git a/tubesync/sync/migrations/0027_alter_source_sponsorblock_categories.py b/tubesync/sync/migrations/0027_alter_source_sponsorblock_categories.py index 92fbc98a..c81b8e72 100644 --- a/tubesync/sync/migrations/0027_alter_source_sponsorblock_categories.py +++ b/tubesync/sync/migrations/0027_alter_source_sponsorblock_categories.py @@ -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=''), ), ] diff --git a/tubesync/sync/models.py b/tubesync/sync/models.py index 877b62e6..2daeb094 100644 --- a/tubesync/sync/models.py +++ b/tubesync/sync/models.py @@ -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'), diff --git a/tubesync/sync/templates/widgets/checkbox_option.html b/tubesync/sync/templates/widgets/checkbox_option.html index 06a6723e..db32a457 100644 --- a/tubesync/sync/templates/widgets/checkbox_option.html +++ b/tubesync/sync/templates/widgets/checkbox_option.html @@ -2,6 +2,6 @@ --> \ No newline at end of file + From a06bd29f88d46eec86de071c3495d49d932f1707 Mon Sep 17 00:00:00 2001 From: tcely Date: Sat, 1 Feb 2025 22:45:57 -0500 Subject: [PATCH 2/3] Do not `uppercase` the SponsorBlock categories Avoid the `text-transform` on `label` by simply not using that tag. Anyone who is better with CSS than I am, with an improved solution, is welcome to create a pull request. --- tubesync/sync/templates/widgets/checkbox_option.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tubesync/sync/templates/widgets/checkbox_option.html b/tubesync/sync/templates/widgets/checkbox_option.html index db32a457..739eb782 100644 --- a/tubesync/sync/templates/widgets/checkbox_option.html +++ b/tubesync/sync/templates/widgets/checkbox_option.html @@ -1,7 +1,7 @@ -