Coverage for utilities/forms/rendering.py: 82%

36 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import random 

2import re 

3import string 

4from functools import cached_property 

5 

6from django.conf import settings 

7 

8__all__ = ( 

9 'FieldSet', 

10 'InlineFields', 

11 'M2MAddRemoveFields', 

12 'ObjectAttribute', 

13 'TabbedGroups', 

14) 

15 

16 

17class FieldSet: 

18 """ 

19 A generic grouping of fields, with an optional name. Each item will be rendered 

20 on its own row under the provided heading (name), if any. The following types 

21 may be passed as items: 

22 

23 * Field name (string) 

24 * InlineFields instance 

25 * TabbedGroups instance 

26 * ObjectAttribute instance 

27 

28 Parameters: 

29 items: An iterable of items to be rendered (one per row) 

30 name: The fieldset's name, displayed as a heading (optional) 

31 html_id: An HTML id for the rendered fieldset div, enabling HTMX partial swaps (optional). 

32 Must be a valid CSS identifier: start with a letter, use only letters, digits, hyphens, underscores. 

33 """ 

34 def __init__(self, *items, name=None, html_id=None): 

35 if html_id is not None and settings.DEBUG: 35 ↛ 36line 35 didn't jump to line 36 because the condition on line 35 was never true

36 if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', html_id): 

37 raise ValueError(f"html_id {html_id!r} is not a valid CSS identifier") 

38 self.items = items 

39 self.name = name 

40 self.html_id = html_id 

41 

42 

43class InlineFields: 

44 """ 

45 A set of fields rendered inline (side-by-side) with a shared label. 

46 

47 Parameters: 

48 fields: An iterable of form field names 

49 label: The label text to render for the row (optional) 

50 help_text: Explanatory text rendered beneath the entire set of fields (optional) 

51 """ 

52 def __init__(self, *fields, label=None, help_text=None): 

53 self.fields = fields 

54 self.label = label 

55 self.help_text = help_text 

56 

57 

58class TabbedGroups: 

59 """ 

60 Two or more groups of fields (FieldSets) arranged under tabs among which the user can toggle. 

61 

62 Parameters: 

63 fieldsets: An iterable of FieldSet instances, one per tab. Each FieldSet *must* have a 

64 name assigned, which will be employed as the tab's label. 

65 """ 

66 def __init__(self, *fieldsets): 

67 for fs in fieldsets: 

68 if not fs.name: 68 ↛ 69line 68 didn't jump to line 69 because the condition on line 68 was never true

69 raise ValueError(f"Grouped fieldset {fs} must have a name.") 

70 self.groups = fieldsets 

71 

72 # Initialize a random ID for the group (for tab selection) 

73 self.id = ''.join( 

74 random.choice(string.ascii_lowercase + string.digits) for _ in range(8) 

75 ) 

76 

77 @cached_property 

78 def tabs(self): 

79 return [ 

80 { 

81 'id': f'{self.id}_{i}', 

82 'title': group.name, 

83 'fields': group.items, 

84 } for i, group in enumerate(self.groups, start=1) 

85 ] 

86 

87 

88class M2MAddRemoveFields: 

89 """ 

90 Represents a many-to-many relationship field on a form. It supports two rendering modes: 

91 

92 1. Simple mode: A single multi-select field pre-populated with current values. This is used 

93 for new objects or existing objects with fewer than THRESHOLD current assignments. 

94 2. Add/remove mode: Two separate fields for adding and removing relations. This avoids 

95 crashing the browser when an object has a very large number of current assignments. 

96 

97 The form must define three fields: '{name}', 'add_{name}', and 'remove_{name}'. The form's 

98 __init__() method determines the mode and removes the unused fields. 

99 

100 Parameters: 

101 name: The name of the M2M field on the model (e.g. 'asns'). 

102 """ 

103 THRESHOLD = 100 

104 

105 def __init__(self, name): 

106 self.name = name 

107 

108 

109class ObjectAttribute: 

110 """ 

111 Renders the value for a specific attribute on the form's instance. This may be used to 

112 display a read-only value and convey additional context to the user. If the attribute has 

113 a `get_absolute_url()` method, it will be rendered as a hyperlink. 

114 

115 Parameters: 

116 name: The name of the attribute to be displayed 

117 """ 

118 def __init__(self, name): 

119 self.name = name