"""Django MultiRangeField — a CharField for page numbers and ranges. Stores a normalized, comma-separated string like "30, 41, 51-57, 68", and exposes the expanded list of ints (handy for matching) two ways: - field.to_list(value), and - a generated `_list` property on any model that uses it, e.g. `page in obj.pages_list`. Modernized for Django 3.2+ / 5.x and Python 3 (the original relied on the long-removed SubfieldBase metaclass and _get_val_from_obj). """ __author__ = "Mark Boszko" import re from itertools import groupby from django import forms from django.core.exceptions import ValidationError from django.db import models # Page-range strings may only contain digits, commas, hyphens, and whitespace. _VALID_CHARS = re.compile(r"^[0-9,\s-]*$") def depack(value): """Expand a page-range string into an ascending list of ints. "30, 41, 51-53" -> [30, 41, 51, 52, 53]. Reversed ranges ("53-51") are tolerated; malformed parts ("12-13-15", letters) are skipped, never raised. """ pages = [] for part in re.sub(r"\s", "", value or "").split(","): if "-" in part: lo, _, hi = part.partition("-") # split on the first hyphen only if lo.isdigit() and hi.isdigit(): a, b = int(lo), int(hi) pages.extend(range(min(a, b), max(a, b) + 1)) elif part.isdigit(): pages.append(int(part)) return pages def repack(pages): """Collapse a list of ints into a sorted, de-duplicated range string. [3, 1, 2, 6, 8] -> "1-3, 6, 8". Consecutive runs group together because, in a sorted list, (value - position) stays constant across a run. """ chunks = [] for _, run in groupby( enumerate(sorted(set(pages))), key=lambda pos_val: pos_val[1] - pos_val[0] ): nums = [val for _, val in run] chunks.append(f"{nums[0]}-{nums[-1]}" if len(nums) > 1 else str(nums[0])) return ", ".join(chunks) def first_page(value): """The lowest page number in the range, or 0 if there are none.""" pages = depack(value) return pages[0] if pages else 0 def _normalize(value): """Round-trip a page-range string into canonical form (sorted, ranges).""" return repack(depack(value)) if value else "" class MultiRangeField(models.CharField): description = "Page numbers and ranges, e.g. '30, 41, 51-57, 68'" def __init__(self, *args, **kwargs): kwargs["max_length"] = 1000 kwargs.setdefault("help_text", "Comma-separated pages and page ranges.") super().__init__(*args, **kwargs) def deconstruct(self): # Drop the values __init__ hardcodes so migrations don't pin them. name, path, args, kwargs = super().deconstruct() kwargs.pop("max_length", None) kwargs.pop("help_text", None) return name, path, args, kwargs def from_db_value(self, value, expression, connection): # Normalize on read too, so legacy/imported rows come back canonical. return _normalize(value) def to_python(self, value): return _normalize(value) def get_prep_value(self, value): return _normalize(value) def value_to_string(self, obj): # value_from_object replaced the _get_val_from_obj removed in Django 1.10. return _normalize(self.value_from_object(obj)) def to_list(self, value): """The pages as an ascending list of ints, for matching.""" return depack(value) def contribute_to_class(self, cls, name, **kwargs): super().contribute_to_class(cls, name, **kwargs) # Expose `_list` on the model: the pages expanded to ints. setattr(cls, f"{name}_list", property(lambda obj, n=name: depack(getattr(obj, n)))) def formfield(self, **kwargs): return super().formfield(**{"form_class": MultiRangeFormField, **kwargs}) class MultiRangeFormField(forms.CharField): """Form field that rejects stray characters (strict, for human input).""" def clean(self, value): value = super().clean(value) if value and not _VALID_CHARS.match(value): raise ValidationError("Only numbers, commas, hyphens, and spaces are allowed.") return _normalize(value)