Login

MultiRangeField and MultiRangeFormField

Author:
bobtiki
Posted:
June 1, 2014
Language:
Python
Version:
5.2
Score:
0 (after 0 ratings)

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 (now value_from_object in value_to_string).
  • Added deconstruct() so the field works with migrations.
  • New to_list(value) plus a generated <field>_list property on the model — the pages expanded to a list of ints, for matching (page in obj.pages_list).
  • first_page() returns 0 on empty input instead of raising IndexError.
  • 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_value context arg (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

  1. find even number by Rajeev529 7 months ago
  2. Form field with fixed value by roam 7 months, 3 weeks ago
  3. New Snippet! by Antoliny0919 8 months ago
  4. Add Toggle Switch Widget to Django Forms by OgliariNatan 10 months, 2 weeks ago
  5. get_object_or_none by azwdevops 1 year, 2 months ago

Comments

Please login first before commenting.