• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hasgeek / baseframe / 16218997531

11 Jul 2025 11:32AM UTC coverage: 67.889% (+1.0%) from 66.866%
16218997531

push

github

web-flow
Fix linter issues; fix ValidCoordinates; add OptionalCoordinates (#511)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

120 of 149 new or added lines in 14 files covered. (80.54%)

3 existing lines in 2 files now uncovered.

1592 of 2345 relevant lines covered (67.89%)

2.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

54.67
/src/baseframe/forms/fields.py
1
"""Form fields."""
2
# ruff: noqa: ARG002
3
# pylint: disable=attribute-defined-outside-init,unused-argument
4

5
from __future__ import annotations
4✔
6

7
import itertools
4✔
8
from collections.abc import Iterable, Mapping
4✔
9
from datetime import datetime, tzinfo
4✔
10
from decimal import Decimal, InvalidOperation as DecimalError
4✔
11
from typing import Any, Callable, Optional, Protocol, Union, cast, runtime_checkable
4✔
12
from urllib.parse import urljoin
4✔
13

14
import bleach
4✔
15
import wtforms
4✔
16
from dateutil import parser
4✔
17
from flask import current_app, json
4✔
18
from flask_wtf import RecaptchaField as RecaptchaFieldBase
4✔
19
from pytz import timezone as pytz_timezone, utc
4✔
20
from werkzeug.datastructures import MultiDict
4✔
21
from wtforms import Form as WTForm
4✔
22
from wtforms.fields import (
4✔
23
    DateTimeField as DateTimeFieldBase,
24
    Field,
25
    FieldList,
26
    FileField,
27
    Label,
28
    SelectField as SelectFieldBase,
29
    SelectMultipleField,
30
    SubmitField,
31
    TextAreaField as TextAreaFieldBase,
32
)
33
from wtforms.utils import unset_value
4✔
34
from wtforms.widgets import Select as OriginalSelectWidget
4✔
35

36
from ..extensions import _, __, get_timezone
4✔
37
from ..utils import request_timestamp
4✔
38
from .parsleyjs import StringField, TextAreaField, URLField
4✔
39
from .typing import ReturnIterChoices, ValidatorList
4✔
40
from .validators import Recaptcha, StopValidation, ValidationError
4✔
41
from .widgets import (
4✔
42
    CoordinatesInput,
43
    DateTimeInput,
44
    ImgeeWidget,
45
    RadioMatrixInput,
46
    Select2Widget,
47
    SelectWidget,
48
    TinyMce4,
49
)
50

51
__all__ = [
4✔
52
    'SANITIZE_ATTRIBUTES',
53
    'SANITIZE_TAGS',
54
    'AnnotatedTextField',
55
    'AutocompleteField',
56
    'AutocompleteMultipleField',
57
    'CoordinatesField',
58
    'DateTimeField',
59
    'EnumSelectField',
60
    'Field',
61
    'FieldList',
62
    'FileField',
63
    'FormField',
64
    'GeonameSelectField',
65
    'GeonameSelectMultiField',
66
    'ImgeeField',
67
    'JsonField',
68
    'Label',
69
    'MarkdownField',
70
    'RadioMatrixField',
71
    'RecaptchaField',
72
    'SelectField',
73
    'SelectMultipleField',
74
    'StylesheetField',
75
    'SubmitField',
76
    'TextListField',
77
    'TinyMce4Field',
78
    'UserSelectField',
79
    'UserSelectMultiField',
80
]
81

82
# Default tags and attributes to allow in HTML sanitization
83
SANITIZE_TAGS = [
4✔
84
    'p',
85
    'br',
86
    'strong',
87
    'em',
88
    'sup',
89
    'sub',
90
    'h3',
91
    'h4',
92
    'h5',
93
    'h6',
94
    'ul',
95
    'ol',
96
    'li',
97
    'a',
98
    'blockquote',
99
    'code',
100
]
101
SANITIZE_ATTRIBUTES = {'a': ['href', 'title', 'target']}
4✔
102

103

104
@runtime_checkable
4✔
105
class GeonameidProtocol(Protocol):
4✔
106
    geonameid: str
4✔
107

108

109
class RecaptchaField(RecaptchaFieldBase):
4✔
110
    """RecaptchaField with an improved validator."""
111

112
    def __init__(
4✔
113
        self,
114
        label: str = '',
115
        validators: Optional[ValidatorList] = None,
116
        **kwargs: Any,
117
    ) -> None:
118
        validators = validators or [Recaptcha()]
×
119
        super().__init__(label, validators, **kwargs)
×
120

121

122
# This class borrowed from https://github.com/industrydive/wtforms_extended_selectfield
123
class SelectField(SelectFieldBase):
4✔
124
    """
125
    Add support of ``optgroup`` grouping to the default ``SelectField`` from WTForms.
126

127
    Here is an example of how the data is laid out::
128

129
        (
130
            ("Fruits", (('apple', "Apple"), ('peach', "Peach"), ('pear', "Pear"))),
131
            (
132
                "Vegetables",
133
                (
134
                    ('cucumber', "Cucumber"),
135
                    ('potato', "Potato"),
136
                    ('tomato', "Tomato"),
137
                ),
138
            ),
139
            ('other', "None of the above"),
140
        )
141

142
    It's a little strange that the tuples are (value, label) except for groups which are
143
    (Group Label, list of tuples) but this is how Django does it too.
144
    https://docs.djangoproject.com/en/dev/ref/models/fields/#choices
145
    """
146

147
    widget = SelectWidget()
4✔
148

149
    def pre_validate(self, form: WTForm) -> None:
4✔
150
        """Don't forget to also validate values from embedded lists."""
151
        for item1, item2 in self.choices:
×
152
            if isinstance(item2, (list, tuple)):
×
153
                # group_label = item1
154
                group_items = item2
×
155
                for val, _label in group_items:
×
156
                    if val == self.data:
×
157
                        return
×
158
            else:
159
                val = item1
×
160
                # label = item2
161
                if val == self.data:
×
162
                    return
×
163
        raise StopValidation(_("Not a valid choice"))
×
164

165

166
class TinyMce4Field(TextAreaField):
4✔
167
    """Rich text field using TinyMCE 4."""
168

169
    data: Optional[str]
4✔
170
    widget = TinyMce4()
4✔
171

172
    def __init__(
4✔
173
        self,
174
        *args: Any,
175
        content_css: Optional[Union[str, Callable[[], str]]] = None,
176
        linkify: bool = True,
177
        nofollow: bool = True,
178
        tinymce_options: Optional[dict[str, Any]] = None,
179
        sanitize_tags: Optional[list[str]] = None,
180
        sanitize_attributes: Optional[dict[str, list[str]]] = None,
181
        **kwargs: Any,
182
    ) -> None:
183
        super().__init__(*args, **kwargs)
×
184

185
        # Clone the dict to preserve local edits
NEW
186
        tinymce_options = {} if tinymce_options is None else dict(tinymce_options)
×
187

188
        # Set defaults for TinyMCE
189
        tinymce_options.setdefault(
×
190
            'plugins', 'autolink autoresize link lists paste searchreplace'
191
        )
192
        tinymce_options.setdefault(
×
193
            'toolbar',
194
            'bold italic | bullist numlist | link unlink | searchreplace undo redo',
195
        )
196
        tinymce_options.setdefault('width', '100%')
×
197
        tinymce_options.setdefault('height', '200')
×
198
        tinymce_options.setdefault('autoresize_min_height', '159')
×
199
        tinymce_options.setdefault('autoresize_max_height', '200')
×
200
        tinymce_options.setdefault(
×
201
            'valid_elements',
202
            'p,br,strong/b,em/i,sup,sub,h3,h4,h5,h6,ul,ol,li,a[!href|title|target]'
203
            ',blockquote,code',
204
        )
205
        tinymce_options.setdefault('statusbar', False)
×
206
        tinymce_options.setdefault('menubar', False)
×
207
        tinymce_options.setdefault('resize', True)
×
208
        tinymce_options.setdefault('relative_urls', False)
×
209
        tinymce_options.setdefault('remove_script_host', False)
×
210

211
        # Remove options that cannot be set by callers
212
        tinymce_options.pop('content_css', None)
×
213
        tinymce_options.pop('script_url', None)
×
214
        tinymce_options.pop('setup', None)
×
215

216
        if sanitize_tags is None:
×
217
            sanitize_tags = SANITIZE_TAGS
×
218
        if sanitize_attributes is None:
×
219
            sanitize_attributes = SANITIZE_ATTRIBUTES
×
220

221
        self.linkify = linkify
×
222
        self.nofollow = nofollow
×
223
        self.tinymce_options = tinymce_options
×
224

225
        self._content_css = content_css
×
226
        self.sanitize_tags = sanitize_tags
×
227
        self.sanitize_attributes = sanitize_attributes
×
228

229
    @property
4✔
230
    def content_css(self) -> Optional[str]:
4✔
231
        if callable(self._content_css):
×
232
            return self._content_css()
×
233
        return self._content_css
×
234

235
    def process_formdata(self, valuelist: list[str]) -> None:
4✔
236
        """Process incoming data from request form."""
237
        super().process_formdata(valuelist)
×
238
        # Sanitize data
239
        self.data = bleach.clean(
×
240
            self.data or '',
241
            strip=True,
242
            tags=self.sanitize_tags,
243
            attributes=self.sanitize_attributes,
244
        )
245
        if self.linkify:
×
246
            if self.nofollow:
×
247
                self.data = bleach.linkify(self.data or '')
×
248
            else:
249
                self.data = bleach.linkify(self.data or '', callbacks=[])
×
250

251

252
class DateTimeField(DateTimeFieldBase):
4✔
253
    """
254
    A text field which stores a `datetime.datetime` matching a format.
255

256
    This field only handles UTC timestamps, but renders to UI in the user's timezone,
257
    as specified in the timezone parameter. If not specified, the timezone is guessed
258
    from the runtime environment.
259

260
    :param str label: Label to display against the field
261
    :param list validators: List of validators
262
    :param str display_format: Datetime format string
263
    :param str timezone: Timezone used for user input
264
    :param str message: Message for when the date/time could not be parsed
265
    :param bool naive: If `True` (default), timezone info is stripped from the return
266
        data
267
    """
268

269
    widget = DateTimeInput()
4✔
270
    data: Optional[datetime]
4✔
271
    default_message = __("This date/time could not be recognized")
4✔
272
    _timezone: tzinfo
4✔
273

274
    def __init__(
4✔
275
        self,
276
        *args: Any,
277
        display_format: str = '%Y-%m-%dT%H:%M',
278
        timezone: Union[str, tzinfo, None] = None,
279
        message: Optional[str] = None,
280
        naive: bool = True,
281
        **kwargs: Any,
282
    ) -> None:
283
        super().__init__(*args, **kwargs)
4✔
284
        self.display_format = display_format
4✔
285
        self.timezone = timezone  # type: ignore[assignment]
4✔
286
        self.message = message if message is not None else self.default_message
4✔
287
        self.naive = naive
4✔
288

289
    @property
4✔
290
    def timezone(self) -> tzinfo:
4✔
291
        return self._timezone
4✔
292

293
    @timezone.setter
4✔
294
    def timezone(self, value: Union[str, tzinfo, None]) -> None:
4✔
295
        if value is None:
4✔
296
            value = get_timezone()
4✔
297
        if isinstance(value, str):
4✔
298
            self._timezone = pytz_timezone(value)
4✔
299
        else:
300
            self._timezone = value
4✔
301

302
        # A note on DST:
303

304
        # During a DST transition, the clock is set back by an hour. A naive timestamp
305
        # within this hour will be ambiguous about whether it is referring to the time
306
        # pre-transition or post-transition. pytz expects us to clarify using the is_dst
307
        # flag. Ideally, we should ask the user, but this is tricky: the question only
308
        # applies for time within that hour, so the front-end should detect it and then
309
        # prompt the user. Transitions happen late at night and it is very unlikely in
310
        # our use cases that a user will want to select a time during that period.
311

312
        # Therefore, we simply assume that whatever is the current zone when the widget
313
        # is rendered is also the zone in which ambiguous time is specified.
314

315
        # Related: now.tzname() will return 'IST' all year for timezone 'Asia/Kolkata',
316
        # while for 'America/New_York' it will be 'EST' or 'EDT'. We will be showing the
317
        # user the current name even though they may be inputting a future date that is
318
        # in the other zone. OTOH, Indian users will recognise 'IST' but not
319
        # 'Asia/Kolkata', since India does not have multiple timezones and a user may be
320
        # left wondering why they are specifying time in a distant city.
321

322
        # Using 'tzname' instead of 'zone' optimises for Indian users, but we will have
323
        # to revisit this as we expand to a global footprint.
324

325
        now = request_timestamp().astimezone(self.timezone)
4✔
326
        self.tzname = now.tzname()
4✔
327
        self.is_dst = bool(now.dst())
4✔
328

329
    def _value(self) -> str:
4✔
330
        if self.data:
4✔
331
            if self.data.tzinfo is None:
4✔
332
                # We got a naive datetime from the calling app. Assume UTC
333
                data = utc.localize(self.data).astimezone(self.timezone)
4✔
334
            else:
335
                # We got a tz-aware datetime. Cast into the required timezone
336
                data = self.data.astimezone(self.timezone)
4✔
337
            value = data.strftime(self.display_format)
4✔
338
        else:
339
            value = ''
4✔
340
        return value
4✔
341

342
    def process_formdata(self, valuelist: list[str]) -> None:
4✔
343
        """Process incoming data from request form."""
344
        if valuelist:
4✔
345
            # We received a timestamp from the browser. Parse and save it
346
            data: Optional[datetime] = None
4✔
347
            # `valuelist` will contain `date` and `time` as two separate values
348
            # if the widget is rendered as two parts. If so, parse each at a time
349
            # and use it as a default to replace values from the next value.  If the
350
            # front-end renders a single widget, the entire content will be parsed once.
351
            for value in valuelist:
4✔
352
                if value.strip():
4✔
353
                    try:
4✔
354
                        # `dateutil` cannot handle ISO and European-style dates at the
355
                        # same time, so `dayfirst` MUST be False. Setting it to True
356
                        # will interpret YYYY-DD-MM instead of YYYY-MM-DD. Bug report:
357
                        # https://github.com/dateutil/dateutil/issues/402
358
                        data = parser.parse(
4✔
359
                            value, default=data, ignoretz=False, dayfirst=False
360
                        )
361
                    except (ValueError, OverflowError, TypeError):
4✔
362
                        # TypeError is not a documented error for `parser.parse`, but
363
                        # the DateTimeField implementation in `wtforms_dateutil` says
364
                        # it can happen due to a known bug
365
                        raise ValidationError(self.message) from None
4✔
366
            if data is not None:
4✔
367
                if data.tzinfo is None:
4✔
368
                    # NOTE: localize is implemented separately in the sub-classes of
369
                    # tzinfo: in UTC, StaticTzInfo and DstTzInfo. We've told mypy
370
                    # we take the base type, so we need to ask it to ignore the missing
371
                    # function there
372
                    data = self.timezone.localize(  # type: ignore[attr-defined]
4✔
373
                        data, is_dst=self.is_dst
374
                    ).astimezone(utc)
375
                else:
376
                    data = data.astimezone(utc)
4✔
377
                # If the app wanted a naive datetime, strip the timezone info
378
                if self.naive:
4✔
379
                    # XXX: cast required because mypy misses the `not None` test above
380
                    data = cast(datetime, data).replace(tzinfo=None)
4✔
381
            self.data = data
4✔
382
        else:
383
            self.data = None
4✔
384

385

386
class TextListField(TextAreaFieldBase):
4✔
387
    """A list field that renders as a textarea with one line per list item."""
388

389
    def _value(self) -> str:
4✔
390
        if self.data:
×
391
            return '\r\n'.join(self.data)
×
392
        return ''
×
393

394
    def process_formdata(self, valuelist: list[str]) -> None:
4✔
395
        """Process incoming data from request form."""
396
        if valuelist and valuelist[0]:
4✔
397
            self.data = (
4✔
398
                valuelist[0].replace('\r\n', '\n').replace('\r', '\n').split('\n')
399
            )
400
        else:
401
            self.data = []
×
402

403

404
class UserSelectFieldBase:
4✔
405
    """Select a user."""
406

407
    data: Optional[list[Any]]
4✔
408

409
    def __init__(self, *args: Any, **kwargs: Any) -> None:
4✔
410
        self.lastuser = kwargs.pop('lastuser', None)
×
411
        if self.lastuser is None:
×
412
            if hasattr(current_app, 'login_manager'):
×
413
                self.lastuser = current_app.login_manager
×
414
            else:
415
                raise RuntimeError("App does not have Lastuser as .login_manager")
×
416

417
        self.usermodel = kwargs.pop(
×
418
            'usermodel', self.lastuser.usermanager.usermodel if self.lastuser else None
419
        )
420
        self.separator = kwargs.pop('separator', ',')
×
421
        if self.lastuser:
×
422
            self.autocomplete_endpoint = self.lastuser.autocomplete_endpoint
×
423
            self.getuser_endpoint = self.lastuser.getuser_endpoint
×
424
        else:
425
            self.autocomplete_endpoint = kwargs.pop('autocomplete_endpoint')()
×
426
            self.getuser_endpoint = kwargs.pop('getuser_endpoint')()
×
427
        super().__init__(*args, **kwargs)
×
428

429
    def iter_choices(self) -> ReturnIterChoices:
4✔
430
        """Iterate over choices."""
431
        if self.data:
×
432
            for user in self.data:
×
433
                yield (user.userid, user.pickername, True, {})
×
434

435
    def process_formdata(self, valuelist: list[str]) -> None:
4✔
436
        """Process incoming data from request form."""
437
        super().process_formdata(valuelist)  # type: ignore[misc]
×
438
        userids = valuelist
×
439
        # Convert strings in userids into User objects
440
        users = []
×
441
        if userids:
×
442
            if self.lastuser and not getattr(
×
443
                self.lastuser, 'is_master_data_source', False
444
            ):
445
                usersdata = self.lastuser.getuser_by_userids(userids)
×
446
                # TODO: Move all of this inside the getuser method with user=True,
447
                # create=True
448
                for userinfo in usersdata:
×
449
                    if userinfo['type'] == 'user':
×
450
                        user = self.usermodel.query.filter_by(
×
451
                            userid=userinfo['buid']
452
                        ).first()
453
                        if not user:
×
454
                            # New user in this app. Don't set username right now. It's
455
                            # not relevant until first login and we don't want to deal
456
                            # with conflicts. We don't add this user to the session. The
457
                            # view is responsible for that (using SQLAlchemy cascades
458
                            # when assigning users to a collection).
459
                            user = self.usermodel(
×
460
                                userid=userinfo['buid'], fullname=userinfo['title']
461
                            )
462
                        users.append(user)
×
463
            else:
464
                users = self.usermodel.all(buids=userids)
×
465
        self.data = users
×
466

467

468
class UserSelectField(UserSelectFieldBase, StringField):
4✔
469
    """Render a user select field that allows one user to be selected."""
470

471
    data: Optional[Any]
4✔
472
    multiple = False
4✔
473
    widget = Select2Widget()
4✔
474
    widget_autocomplete = True
4✔
475

476
    def _value(self) -> str:
4✔
477
        """Render value for HTML."""
478
        if self.data:
×
479
            return self.data.userid
×
480
        return ''
×
481

482
    def iter_choices(self) -> ReturnIterChoices:
4✔
483
        """Iterate over choices."""
484
        if self.data:
×
485
            yield (self.data.userid, self.data.pickername, True, {})
×
486

487
    def process_formdata(self, valuelist: list[str]) -> None:
4✔
488
        """Process incoming data from request form."""
489
        super().process_formdata(valuelist)
×
490
        if self.data:
×
491
            self.data = self.data[0]
×
492
        else:
493
            self.data = None
×
494

495

496
class UserSelectMultiField(UserSelectFieldBase, StringField):
4✔
497
    """Render a user select field that allows multiple users to be selected."""
498

499
    data = list[type]
4✔
500
    multiple = True
4✔
501
    widget = Select2Widget()
4✔
502
    widget_autocomplete = True
4✔
503

504

505
class AutocompleteFieldBase:
4✔
506
    """Autocomplete a field."""
507

508
    data: Optional[Union[str, list[str]]]
4✔
509

510
    def __init__(
4✔
511
        self,
512
        *args: Any,
513
        autocomplete_endpoint: str,
514
        results_key: str = 'results',
515
        separator: str = ',',
516
        **kwargs: Any,
517
    ) -> None:
518
        super().__init__(*args, **kwargs)
×
519
        self.autocomplete_endpoint = autocomplete_endpoint
×
520
        self.results_key = results_key
×
521
        self.separator = separator
×
522
        self.choices = ()  # Disregard server-side choices
×
523

524
    def iter_choices(self) -> ReturnIterChoices:
4✔
525
        """Iterate over choices."""
526
        if self.data:
×
527
            for user in self.data:
×
528
                yield (str(user), str(user), True, {})
×
529

530
    def process_formdata(self, valuelist: list[str]) -> None:
4✔
531
        """Process incoming data from request form."""
532
        super().process_formdata(valuelist)  # type: ignore[misc]
×
533
        # Convert strings into Tag objects
534
        self.data = valuelist
×
535

536
    def pre_validate(self, form: WTForm) -> None:
4✔
537
        """Do not validate data."""
538
        return
×
539

540

541
class AutocompleteField(AutocompleteFieldBase, StringField):
4✔
542
    """
543
    Select field that sources choices from a JSON API endpoint.
544

545
    Does not validate choices server-side.
546
    """
547

548
    data: Optional[str]
4✔
549
    multiple = False
4✔
550
    widget = Select2Widget()
4✔
551
    widget_autocomplete = True
4✔
552

553
    def _value(self) -> str:
4✔
554
        if self.data:
×
555
            return self.data
×
556
        return ''
×
557

558
    def process_formdata(self, valuelist: list[str]) -> None:
4✔
559
        """Process incoming data from request form."""
560
        super().process_formdata(valuelist)
×
561
        if self.data:
×
562
            self.data = self.data[0]
×
563
        else:
564
            self.data = None
×
565

566

567
class AutocompleteMultipleField(AutocompleteFieldBase, StringField):
4✔
568
    """
569
    Multiple select field that sources choices from a JSON API endpoint.
570

571
    Does not validate choices server-side.
572
    """
573

574
    data: Optional[list[str]]
4✔
575
    multiple = True
4✔
576
    widget = Select2Widget()
4✔
577
    widget_autocomplete = True
4✔
578

579

580
class GeonameSelectFieldBase:
4✔
581
    """Select a geoname location."""
582

583
    data: Optional[Union[str, list[str], GeonameidProtocol, list[GeonameidProtocol]]]
4✔
584

585
    def __init__(self, *args: Any, separator: str = ',', **kwargs: Any) -> None:
4✔
586
        super().__init__(*args, **kwargs)
×
587
        self.separator = separator
×
588
        server = current_app.config.get('HASCORE_SERVER', 'https://hasgeek.com/api')
×
589
        self.autocomplete_endpoint = urljoin(server, '/1/geo/autocomplete')
×
590
        self.getname_endpoint = urljoin(server, '/1/geo/get_by_names')
×
591

592
    def iter_choices(self) -> ReturnIterChoices:
4✔
593
        """Iterate over choices."""
594
        if self.data:
×
595
            if isinstance(self.data, (str, GeonameidProtocol)):
×
596
                yield (str(self.data), str(self.data), True, {})
×
597
            else:
598
                for item in self.data:
×
599
                    yield (str(item), str(item), True, {})
×
600

601
    def process_formdata(self, valuelist: list[str]) -> None:
4✔
602
        """Process incoming data from request form."""
603
        super().process_formdata(valuelist)  # type: ignore[misc]
×
604
        # TODO: Convert strings into GeoName objects
605
        self.data = valuelist
×
606

607

608
class GeonameSelectField(GeonameSelectFieldBase, StringField):
4✔
609
    """Render a geoname select field that allows one geoname to be selected."""
610

611
    data: Optional[Union[str, GeonameidProtocol]]
4✔
612
    multiple = False
4✔
613
    widget = Select2Widget()
4✔
614
    widget_autocomplete = True
4✔
615

616
    def _value(self) -> str:
4✔
617
        if self.data:
×
618
            if isinstance(self.data, GeonameidProtocol):
×
619
                return self.data.geonameid
×
620
            return self.data
×
621
        return ''
×
622

623
    def process_formdata(self, valuelist: list[str]) -> None:
4✔
624
        """Process incoming data from request form."""
625
        super().process_formdata(valuelist)
×
626
        if self.data:
×
627
            if isinstance(self.data, (list, tuple)):
×
628
                self.data = self.data[0]
×
629
        else:
630
            self.data = None
×
631

632

633
class GeonameSelectMultiField(GeonameSelectFieldBase, StringField):
4✔
634
    """Render a geoname select field that allows multiple geonames to be selected."""
635

636
    data: Optional[Union[list[str], list[GeonameidProtocol]]]
4✔
637
    multiple = True
4✔
638
    widget = Select2Widget()
4✔
639
    widget_autocomplete = True
4✔
640

641

642
class AnnotatedTextField(StringField):
4✔
643
    """Text field with prefix and suffix annotations."""
644

645
    def __init__(
4✔
646
        self,
647
        *args: Any,
648
        prefix: Optional[str] = None,
649
        suffix: Optional[str] = None,
650
        **kwargs: Any,
651
    ) -> None:
652
        super().__init__(*args, **kwargs)
×
653
        self.prefix = prefix
×
654
        self.suffix = suffix
×
655

656

657
class MarkdownField(TextAreaField):
4✔
658
    """TextArea field which has class='markdown'."""
659

660
    def __call__(self, *args: Any, **kwargs: Any) -> str:
4✔
661
        c = kwargs.pop('class', '') or kwargs.pop('class_', '')
×
662
        kwargs['class'] = (c + ' markdown').strip()
×
663
        return super().__call__(*args, **kwargs)
×
664

665

666
class StylesheetField(wtforms.TextAreaField):
4✔
667
    """TextArea field which has class='stylesheet'."""
668

669
    def __call__(self, *args: Any, **kwargs: Any) -> str:
4✔
670
        c = kwargs.pop('class', '') or kwargs.pop('class_', '')
×
671
        kwargs['class'] = (c + ' stylesheet').strip()
×
672
        return super().__call__(*args, **kwargs)
×
673

674

675
class ImgeeField(URLField):
4✔
676
    """
677
    A URLField which lets the user choose an image from Imgee and returns the URL.
678

679
    Example usage::
680

681
        image = ImgeeField(
682
            label=__("Logo"),
683
            description=__("Your company logo here"),
684
            validators=[validators.DataRequired()],
685
            profile='foo',
686
            img_label='logos',
687
            img_size='100x75',
688
        )
689
    """
690

691
    widget = ImgeeWidget()
4✔
692

693
    def __init__(
4✔
694
        self,
695
        *args: Any,
696
        profile: Optional[Union[str, Callable[[], str]]] = None,
697
        img_label: Optional[str] = None,
698
        img_size: Optional[str] = None,
699
        **kwargs: Any,
700
    ) -> None:
701
        super().__init__(*args, **kwargs)
×
702
        self.profile = profile
×
703
        self.img_label = img_label
×
704
        self.img_size = img_size
×
705

706
    def __call__(self, *args: Any, **kwargs: Any) -> str:
4✔
707
        c = kwargs.pop('class', '') or kwargs.pop('class_', '')
×
708
        kwargs['class'] = (c + ' imgee__url-holder').strip()
×
709
        if self.profile:
×
710
            kwargs['data-profile'] = (
×
711
                self.profile() if callable(self.profile) else self.profile
712
            )
713
        if self.img_label:
×
714
            kwargs['data-img-label'] = self.img_label
×
715
        if self.img_size:
×
716
            kwargs['data-img-size'] = self.img_size
×
717
        return super().__call__(*args, **kwargs)
×
718

719

720
class FormField(wtforms.FormField):
4✔
721
    """FormField that removes CSRF in sub-forms."""
722

723
    def process(self, *args: Any, **kwargs: Any) -> None:
4✔
724
        super().process(*args, **kwargs)
×
725
        if hasattr(self.form, 'csrf_token'):
×
726
            del self.form.csrf_token
×
727

728

729
class CoordinatesField(wtforms.Field):
4✔
730
    """Adds latitude and longitude fields and returns them as a tuple."""
731

732
    data: Optional[tuple[Optional[Decimal], Optional[Decimal]]]
4✔
733
    widget = CoordinatesInput()
4✔
734

735
    def process_formdata(self, valuelist: list[str]) -> None:
4✔
736
        """Process incoming data from request form."""
737
        latitude: Optional[Decimal]
738
        longitude: Optional[Decimal]
739

740
        if valuelist and len(valuelist) == 2:
4✔
741
            try:
4✔
742
                latitude = Decimal(valuelist[0])
4✔
743
            except (DecimalError, TypeError):
4✔
744
                latitude = None
4✔
745
            try:
4✔
746
                longitude = Decimal(valuelist[1])
4✔
747
            except (DecimalError, TypeError):
4✔
748
                longitude = None
4✔
749

750
            self.data = latitude, longitude
4✔
751
        else:
752
            self.data = None, None
4✔
753

754
    def _value(self) -> tuple[str, str]:
4✔
755
        """Return HTML render value."""
NEW
756
        if self.data is not None:
×
NEW
757
            return (
×
758
                str(self.data[0]) if self.data[0] is not None else '',
759
                str(self.data[1]) if self.data[1] is not None else '',
760
            )
UNCOV
761
        return '', ''
×
762

763

764
class RadioMatrixField(wtforms.Field):
4✔
765
    """
766
    Presents a matrix of questions (rows) and choices (columns).
767

768
    Saves each row as either an attr or a dict key on the target field in the object.
769
    """
770

771
    data: dict[str, Any]
4✔
772
    widget = RadioMatrixInput()
4✔
773

774
    def __init__(
4✔
775
        self,
776
        *args: Any,
777
        coerce: Callable[[Any], Any] = str,
778
        fields: Iterable[tuple[str, str]] = (),
779
        choices: Iterable[tuple[str, str]] = (),
780
        **kwargs: Any,
781
    ) -> None:
782
        super().__init__(*args, **kwargs)
×
783
        self.coerce = coerce
×
784
        self.fields = fields
×
785
        self.choices = choices
×
786

787
    def process(
4✔
788
        self,
789
        formdata: MultiDict,
790
        data: Any = unset_value,
791
        extra_filters: Optional[Iterable[Callable[[Any], Any]]] = None,
792
    ) -> None:
793
        self.process_errors = []
×
794
        if data is unset_value:
×
795
            data = self.default() if callable(self.default) else self.default
×
796

797
        self.object_data = data
×
798

799
        try:
×
800
            self.process_data(data)
×
801
        except ValueError as exc:
×
802
            self.process_errors.append(exc.args[0])
×
803

804
        if formdata:
×
805
            raw_data = {}
×
806
            for fname, _ftitle in self.fields:
×
807
                if fname in formdata:
×
808
                    raw_data[fname] = formdata[fname]
×
809
            self.raw_data = raw_data
×
810
            self.process_formdata(raw_data)
×
811

812
        try:
×
813
            for filt in itertools.chain(self.filters, extra_filters or []):
×
814
                self.data = filt(self.data)
×
815
        except ValueError as exc:
×
816
            self.process_errors.append(exc.args[0])
×
817

818
    def process_data(self, value: Any) -> None:
4✔
819
        """Process incoming data from Python."""
820
        if value:
×
821
            self.data = {fname: getattr(value, fname) for fname, _ftitle in self.fields}
×
822
        else:
823
            self.data = {}
×
824

825
    def process_formdata(self, valuelist: dict[str, Any]) -> None:
4✔
826
        """Process incoming data from request form."""
827
        self.data = {key: self.coerce(value) for key, value in valuelist.items()}
×
828

829
    def populate_obj(self, obj: Any, name: str) -> None:
4✔
830
        # 'name' is the name of this field in the form. Ignore it for RadioMatrixField
831

832
        for fname, _ftitle in self.fields:
×
833
            if fname in self.data:
×
834
                setattr(obj, fname, self.data[fname])
×
835

836

837
_invalid_marker = object()
4✔
838

839

840
class EnumSelectField(SelectField):
4✔
841
    """
842
    SelectField that populates choices from a LabeledEnum.
843

844
    The LabeledEnum must use (value, name, title) tuples for all elements in the enum.
845
    Only name and title are exposed to the form, keeping value private.
846

847
    Takes a ``lenum`` argument instead of ``choices``::
848

849
        class MyForm(forms.Form):
850
            field = forms.EnumSelectField(
851
                __("My Field"), lenum=MY_ENUM, default=MY_ENUM.CHOICE
852
            )
853
    """
854

855
    widget = OriginalSelectWidget()
4✔
856

857
    def __init__(self, *args: Any, **kwargs: Any) -> None:
4✔
858
        self.lenum = kwargs.pop('lenum')
4✔
859
        kwargs['choices'] = self.lenum.nametitles()
4✔
860

861
        super().__init__(*args, **kwargs)
4✔
862

863
    def iter_choices(self) -> ReturnIterChoices:
4✔
864
        """Iterate over choices."""
865
        selected_name = self.lenum[self.data].name if self.data is not None else None
4✔
866
        for name, title in self.choices:
4✔
867
            yield (name, title, name == selected_name, {})
4✔
868

869
    def process_data(self, value: Any) -> None:
4✔
870
        """Process incoming data from Python."""
871
        if value is None:
4✔
872
            self.data = None
4✔
873
        elif value in self.lenum:
4✔
874
            self.data = value
4✔
875
        else:
876
            raise KeyError(_("Value not in LabeledEnum"))
×
877

878
    def process_formdata(self, valuelist: list[str]) -> None:
4✔
879
        """Process incoming data from request form."""
880
        if valuelist:
4✔
881
            try:
4✔
882
                value = self.lenum.value_for(self.coerce(valuelist[0]))
4✔
883
                if value is None:
4✔
884
                    value = _invalid_marker
4✔
885
                self.data = value
4✔
886
            except ValueError as exc:
×
887
                raise ValueError(
×
888
                    self.gettext('Invalid Choice: could not coerce')
889
                ) from exc
890

891
    def pre_validate(self, form: WTForm) -> None:
4✔
892
        if self.data is _invalid_marker:
4✔
893
            raise StopValidation(self.gettext('Not a valid choice'))
4✔
894

895

896
class JsonField(wtforms.TextAreaField):
4✔
897
    """
898
    A field to accept JSON input, stored internally as a Python-native type.
899

900
    ::
901

902
        class MyForm(forms.Form):
903
            field = forms.JsonField(__("My Field"), default={})
904

905
    The standard WTForms field arguments are accepted. Additionally:
906

907
    :param require_dict: Require a dict as the data value (default `True`)
908
    :param encode_kwargs: Additional arguments for :meth:`json.dumps` (default
909
        ``{'sort_keys': True, 'indent': 2}``)
910
    :param decode_kwargs: Additional arguments for :meth:`json.loads` (default ``{}``)
911
    """
912

913
    default_encode_kwargs: dict[str, Any] = {'sort_keys': True, 'indent': 2}
4✔
914
    default_decode_kwargs: dict[str, Any] = {}
4✔
915

916
    def __init__(
4✔
917
        self,
918
        *args: Any,
919
        require_dict: bool = True,
920
        encode_kwargs: Optional[dict[str, Any]] = None,
921
        decode_kwargs: Optional[dict[str, Any]] = None,
922
        **kwargs: Any,
923
    ) -> None:
924
        super().__init__(*args, **kwargs)
4✔
925
        self.require_dict = require_dict
4✔
926
        self.encode_args = (
4✔
927
            encode_kwargs if encode_kwargs is not None else self.default_encode_kwargs
928
        )
929
        self.decode_args = (
4✔
930
            decode_kwargs if decode_kwargs is not None else self.default_decode_kwargs
931
        )
932

933
    def __call__(self, *args: Any, **kwargs: Any) -> str:
4✔
934
        c = kwargs.pop('class', '') or kwargs.pop('class_', '')
×
935
        kwargs['class'] = (c + ' json').strip()
×
936
        return super().__call__(*args, **kwargs)
×
937

938
    def _value(self) -> str:
4✔
939
        """
940
        Render the internal Python value as a JSON string.
941

942
        Special-case `None` to return an empty string instead of a JSON ``null``.
943
        """
944
        if self.raw_data:
4✔
945
            # If we've received data from a form, render it as is. This allows
946
            # invalid JSON to be presented back to the user for correction.
947
            return self.raw_data[0]
×
948
        if self.data is not None:
4✔
949
            # Use Flask's JSON module to apply any custom JSON implementation as used
950
            # by the app
951
            return json.dumps(self.data, ensure_ascii=False, **self.encode_args)
4✔
952
        return ''
×
953

954
    def process_data(self, value: Any) -> None:
4✔
955
        """Process incoming data from Python."""
956
        if value is not None and self.require_dict and not isinstance(value, Mapping):
4✔
957
            raise ValueError(_("Field value must be a dictionary"))
×
958

959
        # TODO: Confirm this value can be rendered as JSON.
960
        # We're ignoring it for now because :meth:`_value` will trip on it
961
        # when the form is rendered, and the likelihood of this field being
962
        # used without being rendered is low.
963

964
        self.data = value
4✔
965

966
    def process_formdata(self, valuelist: list[str]) -> None:
4✔
967
        """Process incoming data from request form."""
968
        if valuelist:
4✔
969
            value = valuelist[0]
4✔
970
            if not value:
4✔
971
                self.data = self.default
4✔
972
                return
4✔
973
            # ValueError raised by this method will be captured as a field validator
974
            # error by WTForms
975
            try:
4✔
976
                data = json.loads(value, **self.decode_args)
4✔
977
            except ValueError as exc:
4✔
978
                raise ValueError(
4✔
979
                    _("Invalid JSON: {message}").format(message=exc.args[0])
980
                ) from None
981
            if self.require_dict and not isinstance(data, Mapping):
4✔
982
                raise ValueError(
4✔
983
                    _("The JSON data must be a hash object enclosed in {}")
984
                )
985
            self.data = data
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc