Coverage for app / security / tag_views.py: 50%

72 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-06 04:49 +0000

1""" 

2This module contains Flask-Admin views with custom logic to manage user-specific lists 

3and their related entities. 

4 

5Classes: 

6 UserListModelView: A customized Flask-Admin view to manage "lists" data, ensuring 

7 only the current user's lists are accessible and editable. 

8 

9 UserListBookModelView: A customized Flask-Admin view to manage relationships or 

10 entities linked to user-specific lists. 

11 

12These views restrict access and query data based on the currently authenticated user, 

13enforcing user-specific data visibility and preventing unauthorized modifications. 

14""" 

15from urllib.parse import quote 

16 

17from flask_admin.contrib.sqla import ModelView 

18from flask_admin.model.template import EndpointLinkRowAction 

19from flask_security import current_user 

20from flask import abort 

21from markupsafe import Markup 

22from sqlalchemy import func 

23from wtforms.fields.choices import SelectField 

24from wtforms.widgets import html_params 

25from wtforms.validators import Length, Regexp 

26 

27 

28from app.helpers.tag_colors import get_color_choices 

29 

30 

31def tag_pill_markup(text, color): 

32 """ 

33 Generates an HTML `span` element with bootstrap badge classes for a pill-styled badge. 

34 This utility method is typically used to generate markup for styled text with a given  

35 color as part of a UI or web application. 

36  

37 :param text: The text content that will appear inside the badge. 

38 :type text: str 

39 :param color: The color of the badge, determining the visual style. Assumed to 

40 be predefined so that badge-{color} forms a valid CSS class. 

41 :type color: str 

42 :return: A string containing the HTML for the pill badge element. 

43 :rtype: str 

44 """ 

45 return f"<span class='badge badge-pill badge-{color}'>{text}</span>" 

46 

47 

48# pylint: disable=too-few-public-methods 

49class BootstrapSelectWidget: 

50 """ 

51 Widget class for rendering a custom Bootstrap-styled <select> element. 

52 

53 This class provides a styled dropdown widget compatible with Bootstrap's 

54 `selectpicker` component. It ensures proper integration with Bootstrap's 

55 styling and additional optional attributes like `data-live-search`. The 

56 widget allows customization and renders options with badges for a better 

57 UI experience. 

58 """ 

59 def __call__(self, field, **kwargs): 

60 # Ensure the `selectpicker` class is merged into the final `class` attribute 

61 existing_classes = kwargs.get('class', '') 

62 kwargs['class'] = f"{existing_classes} selectpicker form-control".strip() 

63 

64 # Optionally add other attributes (e.g., data-live-search) 

65 kwargs.setdefault('data-live-search', 'true') 

66 

67 html = [f'<select {html_params(id=field.id, name=field.name, **kwargs)}>'] 

68 

69 # Loop through the 2-tuple (value, label) choices 

70 for value, label in field.choices: 

71 data_content = tag_pill_markup(text=label, color=value) 

72 

73 # Check if this choice is the current/selected value 

74 selected_html = 'selected' if field.data == value else '' 

75 

76 # Render the `<option>` tag with `data-content` and selection logic 

77 html.append( 

78 f'<option value="{value}" data-content="{data_content}" {selected_html}>' 

79 f'{label}</option>' 

80 ) 

81 

82 html.append('</select>') 

83 return ''.join(html) # Return the constructed HTML as a string 

84 

85 

86def _color_list_formatter(_view, _context, model, name): 

87 """ 

88 :param _view: current administrative view 

89 :param _context: instance of jinja2.runtime.Context 

90 :param model: model instance 

91 :param name: property name 

92 :return: rendering for the column in a list view 

93 """ 

94 if name == 'color' and model.color: 

95 return Markup( # nosec B704 

96 tag_pill_markup(text=model.color.title(), color=model.color) 

97 ) 

98 return '' 

99 

100 

101class SearchRowAction(EndpointLinkRowAction): 

102 """ 

103 Represents a row action for searching items with a specific tag. 

104 """ 

105 def __init__(self): 

106 super().__init__('search', 'Search') 

107 

108 @staticmethod 

109 def get_url(model): 

110 """ 

111 Generates a search URL based on the provided model's name. 

112 

113 The function takes a model object and constructs a URL-encoded query 

114 string using the lowercase name of the model. The generated URL is 

115 in the format of a search endpoint where the model's name becomes a 

116 filtering condition via its 'tag' parameter. 

117 

118 :param model: A model object containing the 'name' attribute used 

119 to generate the encoded query string. 

120 :type model: Any object with a string `name` attribute 

121 :return: A string representing the URL-encoded search query URL. 

122 :rtype: str 

123 """ 

124 return f"/search?title=*&tag={quote(model.name.lower())}" 

125 

126 def render(self, _context, _row_id, row): 

127 return Markup( # nosec B704 

128 f'<a href="{self.get_url(row)}" title="Search items with this tag" ' 

129 f'class="icon">' 

130 f'<span class="fa fa-binoculars"></span></a>' 

131 ) 

132 

133 

134class UserTagModelView(ModelView): 

135 """ 

136 A customized Flask-Admin view to manage "tags" data, ensuring only the current user's 

137 tags are accessible and editable. 

138 """ 

139 form_excluded_columns = ['owner', 'books'] 

140 column_exclude_list = ['owner', 'books'] 

141 

142 # Explicitly specify columns to display in the admin view 

143 column_list = ["name", "color"] # Columns to show in the list view 

144 form_columns = ["name", "color"] # Columns to show in creation/edit forms 

145 form_overrides = { 

146 'color': SelectField 

147 } 

148 form_args = { 

149 'color': { 

150 'choices': get_color_choices(), # Dynamically provide choices 

151 'widget': BootstrapSelectWidget() # Use the custom widget for badge-pill rendering 

152 }, 

153 'name': { 

154 'validators': [ 

155 Length(max=32, message='Tag name must be 32 characters or less'), 

156 Regexp(r'^[a-zA-Z0-9\s\-]+$', 

157 message='Tag name can only contain letters, numbers, spaces, and hyphens') 

158 ] 

159 } 

160 

161 } 

162 column_formatters = { 

163 'color': _color_list_formatter 

164 } 

165 # Add a custom action to the list of actions 

166 column_extra_row_actions = [SearchRowAction()] 

167 

168 can_edit = True # Allow editing lists 

169 can_delete = True # Allow deleting lists 

170 edit_template = 'tag_edit.html' 

171 create_template = 'tag_create.html' 

172 list_template = 'admin_model_list.html' 

173 

174 def on_form_prefill(self, form, _id): 

175 if hasattr(form, 'color'): 

176 form.color.choices = get_color_choices() 

177 

178 def validate_form(self, form): 

179 if hasattr(form, 'color'): 

180 form.color.choices = get_color_choices() 

181 return super().validate_form(form) 

182 

183 def is_accessible(self): 

184 """Ensure that only authenticated users can access this view.""" 

185 return current_user.is_authenticated 

186 

187 def inaccessible_callback(self, name, **kwargs): 

188 """Redirect or block access if not accessible.""" 

189 abort(403) 

190 

191 def get_query(self): 

192 """Restrict the data shown in the admin to only lists owned by the current user.""" 

193 return self.session.query(self.model).filter(self.model.owner_id == current_user.id) 

194 

195 def get_count_query(self): 

196 """Restrict the record count for pagination to only the current user's lists.""" 

197 return (self.session.query(func.count()) # pylint: disable=not-callable 

198 .filter(self.model.owner_id == current_user.id)) 

199 

200 def on_model_change(self, form, model, is_created): 

201 """ 

202 Ensure that the owner of the list is always the current user and 

203 convert the tag name to lowercase. 

204 """ 

205 if model.name: 

206 model.name = model.name.lower() 

207 

208 if is_created: 

209 model.owner_id = current_user.id 

210 super().on_model_change(form, model, is_created)