Passed
Push — 2.x ( e14db6...c5cce7 )
by Jordi
10:55 queued 04:39
created

senaite.core.browser.viewlets.sampleheader   B

Complexity

Total Complexity 50

Size/Duplication

Total Lines 315
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 50
eloc 171
dl 0
loc 315
rs 8.4
c 0
b 0
f 0

21 Methods

Rating   Name   Duplication   Size   Complexity  
A SampleHeaderViewlet.grouper() 0 5 3
A SampleHeaderViewlet.render() 0 15 4
A SampleHeaderViewlet.get_configuration() 0 25 1
A SampleHeaderViewlet.get_field_info() 0 17 1
A SampleHeaderViewlet.is_at_field() 0 4 1
A SampleHeaderViewlet.is_edit_allowed() 0 4 1
B SampleHeaderViewlet.handle_form_submit() 0 39 6
A SampleHeaderViewlet.add_status_message() 0 4 1
A SampleHeaderViewlet.get_field_html() 0 13 3
A SampleHeaderViewlet.get_field_label() 0 5 1
A SampleHeaderViewlet.get_widget() 0 8 3
A SampleHeaderViewlet.is_field_required() 0 6 2
A SampleHeaderViewlet.fields() 0 5 1
A SampleHeaderViewlet.is_dx_field() 0 4 1
A SampleHeaderViewlet.render_widget() 0 4 1
B SampleHeaderViewlet.get_field_mode() 0 31 7
A SampleHeaderViewlet.is_primary_with_partitions() 0 5 1
B SampleHeaderViewlet.get_field_value() 0 28 6
A SampleHeaderViewlet.render_field_description() 0 5 1
A SampleHeaderViewlet.can_manage_sample_fields() 0 11 3
A SampleHeaderViewlet.is_primary_bound() 0 6 2

How to fix   Complexity   

Complexity

Complex classes like senaite.core.browser.viewlets.sampleheader often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
# -*- coding: utf-8 -*-
2
#
3
# This file is part of SENAITE.CORE.
4
#
5
# SENAITE.CORE is free software: you can redistribute it and/or modify it under
6
# the terms of the GNU General Public License as published by the Free Software
7
# Foundation, version 2.
8
#
9
# This program is distributed in the hope that it will be useful, but WITHOUT
10
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12
# details.
13
#
14
# You should have received a copy of the GNU General Public License along with
15
# this program; if not, write to the Free Software Foundation, Inc., 51
16
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
#
18
# Copyright 2018-2023 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
from itertools import islice
22
23
from bika.lims import api
24
from bika.lims import senaiteMessageFactory as _
25
from bika.lims.api.security import check_permission
26
from bika.lims.api.security import get_roles
27
from bika.lims.interfaces import IAnalysisRequestWithPartitions
28
from bika.lims.interfaces import IHeaderTableFieldRenderer
29
from plone.app.layout.viewlets import ViewletBase
30
from plone.memoize import view as viewcache
31
from plone.protect import PostOnly
32
from Products.Archetypes.event import ObjectEditedEvent
33
from Products.Archetypes.interfaces import IField as IATField
34
from Products.CMFCore.permissions import ModifyPortalContent
35
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
36
from senaite.core import logger
37
from senaite.core.interfaces import IDataManager
38
from zope import event
39
from zope.component import queryAdapter
40
from zope.schema.interfaces import IField as IDXField
41
42
_fieldname_not_in_form = object()
43
44
45
class SampleHeaderViewlet(ViewletBase):
46
    """Header table with editable sample fields
47
    """
48
    template = ViewPageTemplateFile("templates/sampleheader.pt")
49
50
    def render(self):
51
        """Renders the viewlet and handles form submission
52
        """
53
        request = self.request
54
        submitted = request.form.get("sampleheader_form_submitted", False)
55
        save = request.form.get("sampleheader_form_save", False)
56
        errors = {}
57
        if submitted and save:
58
            errors = self.handle_form_submit(request=self.request)
59
            # NOTE: we only redirect if no validation errors occured,
60
            #       because otherwise the fields are not error-indicated!
61
            if not errors:
62
                # redirect needed to show status message
63
                self.request.response.redirect(self.context.absolute_url())
64
        return self.template(errors=errors)
65
66
    def handle_form_submit(self, request=None):
67
        """Handle form submission
68
        """
69
        PostOnly(request)
70
71
        errors = {}
72
        field_values = {}
73
        form = request.form
74
75
        for name, field in self.fields.items():
76
            value = self.get_field_value(field, form)
77
78
            if value is _fieldname_not_in_form:
79
                continue
80
81
            # Keep track of field-values
82
            field_values.update({name: value})
83
84
            # Validate the field values
85
            error = field.validate(value, self.context)
86
            if error:
87
                errors.update({name: error})
88
89
        if errors:
90
            return errors
91
        else:
92
            # we want to set the fields with the data manager
93
            dm = IDataManager(self.context)
94
95
            # Store the field values
96
            for name, value in field_values.items():
97
                dm.set(name, value)
98
99
            message = _("Changes saved.")
100
            # reindex the object after save to update all catalog metadata
101
            self.context.reindexObject()
102
            # notify object edited event
103
            event.notify(ObjectEditedEvent(self.context))
104
            self.add_status_message(message, level="info")
105
106
    def get_configuration(self):
107
        """Return header configuration
108
109
        This method retrieves the customized field and column configuration from
110
        the management view directly.
111
112
        :returns: Field and columns configuration dictionary
113
        """
114
        mv = api.get_view(name="manage-sample-fields", context=self.context)
115
        settings = mv.get_configuration()
116
        visibility = settings.get("field_visibility")
117
118
        def is_visible(name):
119
            return visibility.get(name, True)
120
121
        # filter out fields that are configured as invisible
122
        prominent_fields = filter(is_visible, settings.get("prominent_fields"))
123
        standard_fields = filter(is_visible, settings.get("standard_fields"))
124
125
        config = {}
126
        config.update(settings)
127
        config["prominent_fields"] = prominent_fields
128
        config["standard_fields"] = standard_fields
129
130
        return config
131
132
    @property
133
    def fields(self):
134
        """Returns an ordered dict of all schema fields
135
        """
136
        return api.get_fields(self.context)
137
138
    def get_field_value(self, field, form):
139
        """Returns the submitted value for the given field
140
        """
141
        fieldname = field.getName()
142
        if fieldname not in form:
143
            return _fieldname_not_in_form
144
145
        fieldvalue = form[fieldname]
146
147
        # Handle (multiValued) reference fields
148
        # https://github.com/bikalims/bika.lims/issues/2270
149
        uid_fieldname = "{}_uid".format(fieldname)
150
        if uid_fieldname in form:
151
            # get the value from the corresponding `uid_<fieldname>` key
152
            value = form[uid_fieldname]
153
154
            # extract the assigned UIDs for multi-reference fields
155
            if field.multiValued:
156
                value = filter(None, value.split(","))
157
158
            # allow to flush single reference fields
159
            if not field.multiValued and not fieldvalue:
160
                value = ""
161
162
            return value
163
164
        # other fields
165
        return fieldvalue
166
167
    def grouper(self, iterable, n=3):
168
        """Splits an iterable into chunks of `n` items
169
        """
170
        for chunk in iter(lambda it=iter(iterable): list(islice(it, n)), []):
171
            yield chunk
172
173
    def get_field_info(self, name):
174
        """Return field information required for the template
175
        """
176
        field = self.fields.get(name)
177
        mode = self.get_field_mode(field)
178
        html = self.get_field_html(field, mode=mode)
179
        label = self.get_field_label(field, mode=mode)
180
        description = self.render_field_description(field, mode=mode)
181
        required = self.is_field_required(field, mode=mode)
182
        return {
183
            "name": name,
184
            "mode": mode,
185
            "html": html,
186
            "field": field,
187
            "label": label,
188
            "description": description,
189
            "required": required,
190
        }
191
192
    def get_field_html(self, field, mode="view"):
193
        """Render field HTML
194
        """
195
        if mode == "view":
196
            # Lookup custom view adapter
197
            adapter = queryAdapter(self.context,
198
                                   interface=IHeaderTableFieldRenderer,
199
                                   name=field.getName())
200
            # return immediately if we have an adapter
201
            if adapter is not None:
202
                return adapter(field)
203
204
        return None
205
206
    def get_field_label(self, field, mode="view"):
207
        """Renders the field label
208
        """
209
        widget = self.get_widget(field)
210
        return getattr(widget, "label", "")
211
212
    def render_field_description(self, field, mode="view"):
213
        """Renders the field description
214
        """
215
        widget = self.get_widget(field)
216
        return getattr(widget, "description", "")
217
218
    def render_widget(self, field, mode="view"):
219
        """Render the field widget
220
        """
221
        return self.context.widget(field.getName(), mode=mode)
222
223
    def get_field_mode(self, field, default="hidden"):
224
        """Returns the field mode in the header
225
226
        Possible values are:
227
228
          - edit: field is rendered in edit mode
229
          - view: field is rendered in view mode
230
        """
231
        mode = "view"
232
        if field.checkPermission("edit", self.context):
233
            mode = "edit"
234
            if not self.is_edit_allowed():
235
                logger.warn("Permission '{}' granted for the edition of '{}', "
236
                            "but 'Modify portal content' not granted"
237
                            .format(field.write_permission, field.getName()))
238
        elif field.checkPermission("view", self.context):
239
            mode = "view"
240
241
        widget = self.get_widget(field)
242
        mode_vis = widget.isVisible(self.context, mode=mode, field=field)
243
        if mode_vis != "visible":
244
            if mode == "view":
245
                return default
246
            # The field cannot be rendered in edit mode, but maybe can be
247
            # rendered in view mode.
248
            mode = "view"
249
            view_vis = widget.isVisible(self.context, mode=mode, field=field)
250
            if view_vis != "visible":
251
                return default
252
253
        return mode
254
255
    def get_widget(self, field):
256
        """Returns the widget of the field
257
        """
258
        if self.is_at_field(field):
259
            return field.widget
260
        elif self.is_dx_field(field):
261
            raise NotImplementedError("DX widgets not yet needed")
262
        raise TypeError("Field %r is neither a DX nor an AT field")
263
264
    def add_status_message(self, message, level="info"):
265
        """Set a portal status message
266
        """
267
        return self.context.plone_utils.addPortalMessage(message, level)
268
269
    @viewcache.memoize
270
    def is_primary_with_partitions(self):
271
        """Check if the Sample is a primary with partitions
272
        """
273
        return IAnalysisRequestWithPartitions.providedBy(self.context)
274
275
    def is_primary_bound(self, field):
276
        """Checks if the field is primary bound
277
        """
278
        if not self.is_primary_with_partitions():
279
            return False
280
        return getattr(field, "primary_bound", False)
281
282
    def is_edit_allowed(self):
283
        """Check permission 'ModifyPortalContent' on the context
284
        """
285
        return check_permission(ModifyPortalContent, self.context)
286
287
    def is_field_required(self, field, mode="edit"):
288
        """Check if the field is required
289
        """
290
        if mode == "view":
291
            return False
292
        return field.required
293
294
    def is_at_field(self, field):
295
        """Check if the field is an AT field
296
        """
297
        return IATField.providedBy(field)
298
299
    def is_dx_field(self, field):
300
        """Check if the field is an DX field
301
        """
302
        return IDXField.providedBy(field)
303
304
    def can_manage_sample_fields(self):
305
        """Checks if the user is allowed to manage the sample fields
306
307
        TODO: Better use custom permission (same as used for view)
308
        """
309
        roles = get_roles()
310
        if "Manager" in roles:
311
            return True
312
        elif "LabManager" in roles:
313
            return True
314
        return False
315