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
« 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.
5Classes:
6 UserListModelView: A customized Flask-Admin view to manage "lists" data, ensuring
7 only the current user's lists are accessible and editable.
9 UserListBookModelView: A customized Flask-Admin view to manage relationships or
10 entities linked to user-specific lists.
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
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
28from app.helpers.tag_colors import get_color_choices
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.
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>"
48# pylint: disable=too-few-public-methods
49class BootstrapSelectWidget:
50 """
51 Widget class for rendering a custom Bootstrap-styled <select> element.
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()
64 # Optionally add other attributes (e.g., data-live-search)
65 kwargs.setdefault('data-live-search', 'true')
67 html = [f'<select {html_params(id=field.id, name=field.name, **kwargs)}>']
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)
73 # Check if this choice is the current/selected value
74 selected_html = 'selected' if field.data == value else ''
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 )
82 html.append('</select>')
83 return ''.join(html) # Return the constructed HTML as a string
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 ''
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')
108 @staticmethod
109 def get_url(model):
110 """
111 Generates a search URL based on the provided model's name.
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.
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())}"
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 )
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']
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 }
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()]
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'
174 def on_form_prefill(self, form, _id):
175 if hasattr(form, 'color'):
176 form.color.choices = get_color_choices()
178 def validate_form(self, form):
179 if hasattr(form, 'color'):
180 form.color.choices = get_color_choices()
181 return super().validate_form(form)
183 def is_accessible(self):
184 """Ensure that only authenticated users can access this view."""
185 return current_user.is_authenticated
187 def inaccessible_callback(self, name, **kwargs):
188 """Redirect or block access if not accessible."""
189 abort(403)
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)
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))
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()
208 if is_created:
209 model.owner_id = current_user.id
210 super().on_model_change(form, model, is_created)