Designed to hold a list of pages and page ranges for a book/magazine index.
A custom model field (and accompanying form field) that saves comma-separated pages and page ranges in human-readable string form. Includes some clean-up code, so that you can add a new page or range at the end of an existing entry, and it will put it in numeric order and combine runs into ranges. So this:
4-33, 43, 45, 60-65, 44, 59
becomes the tidy
4-33, 43-45, 59-65
NOTE: If you comment out the raising of the ValidationError in the form field's validate() method, it will actually clean up any extraneous characters for you (which could be dangerous, but for me is usually what I want), so even this horrible mess:
;4-33, 46a fads i44 ,p45o
gets cleaned to
4-33, 44-46
Updated 2026-06-26 — modernized for Django 3.2+ / 5.x + Python 3, and added a list-of-ints accessor.
- Removed the Python-2 / pre-1.10 leftovers:
__metaclass__ = SubfieldBase,super(Class, self), and_get_val_from_obj(nowvalue_from_objectinvalue_to_string). - Added
deconstruct()so the field works with migrations. - New
to_list(value)plus a generated<field>_listproperty on the model — the pages expanded to a list of ints, for matching (page in obj.pages_list). first_page()returns0on empty input instead of raisingIndexError.depack()now tolerates reversed ranges (53-51) and skips malformed parts (12-13-15, letters) instead of raising.- Centralized normalization in one helper; the form field rejects stray characters with a clear error while the model stays lenient.
- Dropped the obsolete
from_db_valuecontextarg (removed in Django 2.0); precompiled the validation regex.
The repack consecutive-run grouping (groupby on value − index) is unchanged.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 | """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 `<field>_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 `<name>_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) |
More like this
- find even number by Rajeev529 7 months ago
- Form field with fixed value by roam 7 months, 3 weeks ago
- New Snippet! by Antoliny0919 8 months ago
- Add Toggle Switch Widget to Django Forms by OgliariNatan 10 months, 2 weeks ago
- get_object_or_none by azwdevops 1 year, 2 months ago
Comments
Please login first before commenting.