diff options
| author | Étienne Loks <etienne.loks@peacefrogs.net> | 2011-06-24 14:37:16 +0200 | 
|---|---|---|
| committer | Étienne Loks <etienne.loks@peacefrogs.net> | 2011-06-24 14:37:16 +0200 | 
| commit | 05c6d94c9547377c9979e9d860c6618ee898ef6e (patch) | |
| tree | 6b4fc14f42da9d91ab2bb4b989ffeeb42947392f /ishtar/ishtar_base/forms.py | |
| parent | 2d008477cb66ec3e356fd9153afba7affede249c (diff) | |
| download | Ishtar-05c6d94c9547377c9979e9d860c6618ee898ef6e.tar.bz2 Ishtar-05c6d94c9547377c9979e9d860c6618ee898ef6e.zip  | |
Sources creation for Operation (refs #497) - restructuration (refs #57)
Diffstat (limited to 'ishtar/ishtar_base/forms.py')
| -rw-r--r-- | ishtar/ishtar_base/forms.py | 736 | 
1 files changed, 736 insertions, 0 deletions
diff --git a/ishtar/ishtar_base/forms.py b/ishtar/ishtar_base/forms.py new file mode 100644 index 000000000..29d9ab4d6 --- /dev/null +++ b/ishtar/ishtar_base/forms.py @@ -0,0 +1,736 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Copyright (C) 2010-2011  Étienne Loks  <etienne.loks_AT_peacefrogsDOTnet> + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +# See the file COPYING for details. + +""" +Forms definition +""" +import datetime +import re +from itertools import groupby + +from django.core.urlresolvers import reverse +from django.core import validators +from django.core.exceptions import ObjectDoesNotExist +from django.utils import formats +from django.utils.functional import lazy +from django.utils.translation import ugettext_lazy as _ +from django.shortcuts import render_to_response +from django.template import Context, RequestContext, loader +from django.db.models import Max +from django import forms +from django.core.mail import send_mail +from django.forms.formsets import formset_factory, BaseFormSet, \ +                                  DELETION_FIELD_NAME +from django.contrib.auth.models import User +from django.contrib.sites.models import Site + +from formwizard.forms import NamedUrlSessionFormWizard + +import models +import widgets +from ishtar import settings + +reverse_lazy = lazy(reverse, unicode) + +regexp_name = re.compile(r'^[\w\- ]+$', re.UNICODE) +name_validator = validators.RegexValidator(regexp_name, +_(u"Enter a valid name consisting of letters, spaces and hyphens."), 'invalid') + +class FloatField(forms.FloatField): +    """ +    Allow the use of comma for separating float fields +    """ +    def clean(self, value): +        if value: +            value = value.replace(',', '.').replace('%', '') +        return super(FloatField, self).clean(value) + +class FinalForm(forms.Form): +    final = True +    form_label = _(u"Confirm") + +class FormSet(BaseFormSet): +    def check_duplicate(self, key_names, error_msg=""): +        """Check for duplicate items in the formset""" +        if any(self.errors): +            return +        if not error_msg: +            error_msg = _("There are identical items.") +        items = [] +        for i in range(0, self.total_form_count()): +            form = self.forms[i] +            if not form.is_valid(): +                continue +            item = [key_name in form.cleaned_data and form.cleaned_data[key_name] +                    for key_name in key_names] +            if not [v for v in item if v]: +                continue +            if item in items: +                raise forms.ValidationError, error_msg +            items.append(item) + +    def add_fields(self, form, index): +        super(FormSet, self).add_fields(form, index) +        form.fields[DELETION_FIELD_NAME].label = '' +        form.fields[DELETION_FIELD_NAME].widget = widgets.DeleteWidget() + +class SearchWizard(NamedUrlSessionFormWizard): +    model = None + +    def get_wizard_name(self): +        """ +        As the class name can interfere when reused, use the url_name +        """ +        return self.url_name + +    def get_template(self, request, storage): +        templates = ['search.html'] +        return templates + +class Wizard(NamedUrlSessionFormWizard): +    model = None + +    def get_wizard_name(self): +        """ +        As the class name can interfere when reused, use the url_name +        """ +        return self.url_name + +    def get_template(self, request, storage): +        templates = ['default_wizard.html'] +        current_step = storage.get_current_step() or self.get_first_step( +                                                            request, storage) +        if current_step == self.get_last_step(request, storage): +            templates = ['confirm_wizard.html'] + templates +        return templates + +    def get_template_context(self, request, storage, form=None): +        """ +        Add previous and current steps to manage the wizard path +        """ +        context = super(Wizard, self).get_template_context(request, storage, +                                                           form) +        step = self.get_first_step(request, storage) +        current_step = storage.get_current_step() or self.get_first_step( +                                                            request, storage) +        context.update({'current_step':self.form_list[current_step]}) +        if step == current_step: +            return context +        previous_steps = [] +        while step: +            if step == current_step: +                break +            previous_steps.append(self.form_list[step]) +            step = self.get_next_step(request, storage, step) +        context.update({'previous_steps':previous_steps}) +        # not last step: validation +        if step != self.get_last_step(request, storage): +            return context +        final_form_list = [] +        for form_key in self.get_form_list(request, storage).keys(): +            form_obj = self.get_form(request, storage, step=form_key, +                    data=storage.get_step_data(form_key), +                    files=storage.get_step_files(form_key)) +            form_obj.is_valid() +            final_form_list.append(form_obj) +        last_form = final_form_list[-1] +        context.update({'datas':self.get_formated_datas(final_form_list)}) +        if hasattr(last_form, 'confirm_msg'): +            context.update({'confirm_msg':last_form.confirm_msg}) +        if hasattr(last_form, 'confirm_end_msg'): +            context.update({'confirm_end_msg':last_form.confirm_end_msg}) +        return context + +    def get_formated_datas(self, forms): +        """ +        Get the data to present in the last page +        """ +        datas = [] +        for form in forms: +            form_datas = [] +            base_form = hasattr(form, 'forms') and form.forms[0] or form +            associated_models = hasattr(base_form, 'associated_models') and \ +                                base_form.associated_models or {} +            if not hasattr(form, 'cleaned_data') and hasattr(form, 'forms'): +                cleaned_datas = [frm.cleaned_data for frm in form.forms +                                                  if frm.is_valid()] +                if not cleaned_datas: +                    continue +            elif not hasattr(form, 'cleaned_data'): +                continue +            else: +                cleaned_datas = type(form.cleaned_data) == list and \ +                                              form.cleaned_data \ +                            or [form.cleaned_data] +            for cleaned_data in cleaned_datas: +                if not cleaned_data: +                    continue +                if form_datas: +                    form_datas.append(("", "", "spacer")) +                items = hasattr(base_form, 'fields') and \ +                        base_form.fields.keyOrder or cleaned_data.keys() +                for key in items: +                    lbl = None +                    if key.startswith('hidden_'): +                        continue +                    if hasattr(base_form, 'fields') and key in base_form.fields: +                        lbl = base_form.fields[key].label +                    if not lbl: +                        continue +                    value = cleaned_data[key] +                    if not value and value != False: +                        continue +                    if type(value) == bool: +                        if value == True: +                            value = _("Yes") +                        elif value == False: +                            value = _("No") +                    elif key in associated_models: +                        item = associated_models[key].objects.get(pk=value) +                        if hasattr(item, 'short_label'): +                            value = item.short_label() +                        else: +                            value = unicode(item) +                    form_datas.append((lbl, value, '')) +            if form_datas: +                datas.append((form.form_label, form_datas)) +        return datas + +    def get_extra_model(self, dct, request, storage, form_list): +        dct['history_modifier'] = request.user +        return dct + +    def done(self, request, storage, form_list, return_object=False, **kwargs): +        """ +        Save to the model +        """ +        dct, m2m, whole_associated_models = {}, [], [] +        for form in form_list: +            if not form.is_valid(): +                return self.render(request, storage, form) +            base_form = hasattr(form, 'forms') and form.forms[0] or form +            associated_models = hasattr(base_form, 'associated_models') and \ +                                base_form.associated_models or {} +            if hasattr(form, 'forms'): +                multi = False +                if form.forms: +                    frm = form.forms[0] +                    if hasattr(frm, 'base_model') and frm.base_model: +                        whole_associated_models.append(frm.base_model) +                    else: +                        whole_associated_models += associated_models.keys() +                    fields = frm.fields.copy() +                    if 'DELETE' in fields: +                        fields.pop('DELETE') +                    multi = len(fields) > 1 +                    if multi: +                        assert hasattr(frm, 'base_model'), \ +                       u"Must define a base_model for " + unicode(frm.__class__) +                for frm in form.forms: +                    if not frm.is_valid(): +                        continue +                    vals = {} +                    if "DELETE" in frm.cleaned_data: +                        if frm.cleaned_data["DELETE"]: +                            continue +                        frm.cleaned_data.pop('DELETE') +                    for key in frm.cleaned_data: +                        value = frm.cleaned_data[key] +                        if not value and value != False: +                            continue +                        if key in associated_models: +                            value = associated_models[key].objects.get(pk=value) +                        if multi: +                            vals[key] = value +                        else: +                            m2m.append((key, value)) +                    if multi and vals: +                        m2m.append((frm.base_model, vals)) +            elif type(form.cleaned_data) == dict: +                for key in form.cleaned_data: +                    if key.startswith('hidden_'): +                        continue +                    value = form.cleaned_data[key] +                    if key in associated_models: +                        if value: +                            value = associated_models[key].objects.get(pk=value) +                        else: +                            value = None +                    dct[key] = value +        return self.save_model(dct, m2m, whole_associated_models, request, +                               storage, form_list, return_object) + +    def get_saved_model(self): +        """ +        Permit a distinguo when saved model is not the base selected model +        """ +        return self.model + +    def get_current_saved_object(self, request, storage): +        """ +        Permit a distinguo when saved model is not the base selected model +        """ +        return self.get_current_object(request, storage) + +    def save_model(self, dct, m2m, whole_associated_models, request, storage, +                   form_list, return_object): +        dct = self.get_extra_model(dct, request, storage, form_list) +        obj = self.get_current_saved_object(request, storage) + +        # manage dependant items +        other_objs = {} +        for k in dct.keys(): +            if '__' not in k: +                continue +            vals = k.split('__') +            assert len(vals) == 2, "Only one level of dependant item is managed" +            dependant_item, key = vals +            if dependant_item not in other_objs: +                other_objs[dependant_item] = {} +            other_objs[dependant_item][key] = dct.pop(k) +        if obj: +            for k in dct: +                if k.startswith('pk'): +                    continue +                setattr(obj, k, dct[k]) +            for dependant_item in other_objs: +                c_item = getattr(obj, dependant_item) +                # manage ManyToMany if only one associated +                if hasattr(c_item, "all"): +                    c_items = c_item.all() +                    if len(c_items) != 1: +                        continue +                    c_item = c_items[0] +                if c_item: +                    # to check # +                    for k in other_objs[dependant_item]: +                        setattr(c_item, k, other_objs[dependant_item][k]) +                    c_item.save() +                else: +                    m = getattr(self.model, dependant_item) +                    if hasattr(m, 'related'): +                        c_item = m.related.model(**other_objs[dependant_item]) +                        setattr(obj, dependant_item, c_item) +                    obj.save() +            obj.save() +        else: +            adds = {} +            for dependant_item in other_objs: +                m = getattr(self.model, dependant_item) +                model = m.field.rel.to +                c_dct = other_objs[dependant_item].copy() +                if hasattr(model, 'history'): +                    c_dct['history_modifier'] = request.user +                c_item = model(**c_dct) +                c_item.save() +                if hasattr(m, 'through'): +                    adds[dependant_item] = c_item +                elif hasattr(m, 'field'): +                    dct[dependant_item] = c_item +            if 'pk' in dct: +                dct.pop('pk') +            obj = self.get_saved_model()(**dct) +            obj.save() +            for k in adds: +                getattr(obj, k).add(adds[k]) +            # necessary to manage interaction between models like +            # material_index management for baseitems +            obj.save() +        m2m_items = {} +        for model in whole_associated_models: +            getattr(obj, model+'s').clear() +        for key, value in m2m: +            if key not in m2m_items: +                if type(key) == dict: +                    vals = [] +                    for item in getattr(obj, key+'s').all(): +                        v = {} +                        for k in value.keys(): +                            v[k] = getattr(item, k) +                        vals.append(v) +                    m2m_items[key] = vals +                else: +                    m2m_items[key] = getattr(obj, key+'s').all() +            if value not in m2m_items[key]: +                if type(value) == dict: +                    model = getattr(obj, key+'s').model +                    if issubclass(model, models.BaseHistorizedItem): +                        value['history_modifier'] = request.user +                    value = model.objects.create(**value) +                    value.save() +                getattr(obj, key+'s').add(value) +                # necessary to manage interaction between models like +                # material_index management for baseitems +                obj.save() +        res = render_to_response('wizard_done.html', {}, +                                  context_instance=RequestContext(request)) +        return return_object and (obj, res) or res + +    def get_deleted(self, keys): +        """ +        Get the deleted and non-deleted items in formsets +        """ +        not_to_delete, to_delete = set(), set() +        for key in keys: +            items = key.split('-') +            if len(items) < 2 or items[-2] in to_delete: +                continue +            idx = items[-2] +            try: +                int(idx) +            except: +                continue +            if items[-1] == u'DELETE': +                to_delete.add(idx) +                if idx in not_to_delete: +                    not_to_delete.remove(idx) +            elif idx not in not_to_delete: +                not_to_delete.add(idx) +        return (to_delete, not_to_delete) + +    def get_form(self, request, storage, step=None, data=None, files=None): +        """ +        Manage formset +        """ +        if data: +            data = data.copy() +            if not step: +                step = self.determine_step(request, storage) +            form = self.get_form_list(request, storage)[step] +            if hasattr(form, 'management_form'): +                # manage deletion +                to_delete, not_to_delete = self.get_deleted(data.keys()) +                # raz deleted fields +                for key in data.keys(): +                    items = key.split('-') +                    if len(items) < 2 or items[-2] not in to_delete: +                        continue +                    data.pop(key) +                if to_delete: +                    # reorganize +                    for idx, number in enumerate(sorted(not_to_delete)): +                        idx = unicode(idx) +                        if idx == number: +                            continue +                        for key in data.keys(): +                            items = key.split('-') +                            if len(items) > 2 and number == items[-2]: +                                items[-2] = unicode(idx) +                                k = u'-'.join(items) +                                data[k] = data.pop(key)[0] +                # get a form key +                base_key = form.form.base_fields.keys()[0] +                init = self.get_form_initial(request, storage, step) +                total_field = len([key for key in data.keys() +                                        if base_key in key.split('-') +                                           and data[key]]) +                if init and not to_delete: +                    total_field = max((total_field, len(init))) +                data[step + u'-INITIAL_FORMS'] = unicode(total_field) +                data[step + u'-TOTAL_FORMS'] = unicode(total_field + 1) +        data = data or None +        form = super(Wizard, self).get_form(request, storage, step, data, files) +        return form + +    def render_next_step(self, request, storage, form, **kwargs): +        """ +        Manage the modify or delete button in formset: next_step = current_step +        """ +        if request.POST.has_key('formset_modify') \ +           and request.POST['formset_modify'] \ +           or [key for key in request.POST.keys() +               if key.endswith('DELETE') and request.POST[key]]: +            return self.render(request, storage, form, **kwargs) +        return super(Wizard, self).render_next_step(request, storage, form, +                                                    **kwargs) + +    def process_post_request(self, request, storage, *args, **kwargs): +        """ +        Convert numerical step number to step name +        """ +        post_data = request.POST.copy() +        if request.POST.has_key('form_prev_step'): +            try: +                step_number = int(request.POST['form_prev_step']) +                post_data['form_prev_step'] = self.get_form_list(request, +                                                   storage).keys()[step_number] +            except ValueError: +                pass +        request.POST = post_data +        return super(Wizard, self).process_post_request(request, storage, *args, +                                                        **kwargs) +    @classmethod +    def session_has_key(cls, request, storage, form_key, key=None, multi=None): +        """ +        Check if the session has value of a specific form and (if provided) +        of a key +        """ +        test = storage.prefix in request.session \ +          and 'step_data' in request.session[storage.prefix] \ +          and form_key in request.session[storage.prefix]['step_data'] +        if not key or not test: +            return test +        key = key.startswith(form_key) and key or \ +              not multi and form_key + '-' + key or \ +              form_key + '-0-' + key #only check if the first field is available +        return key in request.session[storage.prefix]['step_data'][form_key] + +    @classmethod +    def session_get_value(cls, request, storage, form_key, key, multi=False): +        """ +        Get the value of a specific form +        """ +        if not cls.session_has_key(request, storage, form_key, key, multi): +            return +        if not multi: +            key = key.startswith(form_key) and key or form_key + '-' + key +            return request.session[storage.prefix]['step_data'][form_key][key] +        vals = [] +        for k in request.session[storage.prefix]['step_data'][form_key]: +            if k.startswith(form_key) and k.endswith(key) and \ +               request.session[storage.prefix]['step_data'][form_key][k]: +                vals.append(request.session[storage.prefix]['step_data']\ +                                           [form_key][k]) +        return vals + +    def get_current_object(self, request, storage): +        """ +        Get the current object for an instancied wizard +        """ +        current_obj = None +        main_form_key = 'selec-' + self.url_name +        try: +            idx = int(self.session_get_value(request, storage, main_form_key, +                                             'pk')) +            current_obj = self.model.objects.get(pk=idx) +        except(TypeError, ValueError, ObjectDoesNotExist): +            pass +        return current_obj + +    def get_form_initial(self, request, storage, step): +        current_obj = self.get_current_object(request, storage) +        current_step = storage.get_current_step() or self.get_first_step( +                                                            request, storage) +        if step.startswith('selec-') and step in self.form_list \ +           and 'pk' in self.form_list[step].associated_models: +            model_name = self.form_list[step].associated_models['pk' +                                              ].__name__.lower() +            if step == current_step: +                self.reset_wizard(request, storage) +            val = model_name in request.session and request.session[model_name] +            if val: +                return {'pk':val} +        elif current_obj: +            return self.get_instanced_init(current_obj, request, storage, +                                           step) +        current_form = self.form_list[current_step] +        if hasattr(current_form, 'currents'): +            initial = {} +            for key in current_form.currents: +                model_name = current_form.currents[key].__name__.lower() +                val = model_name in request.session and \ +                      request.session[model_name] +                if val: +                    initial[key] = val +            if initial: +                return initial +        return super(Wizard, self).get_form_initial(request, storage, step) + +    def get_instanced_init(self, obj, request, storage, step): +        """ +        Get initial data from an init +        """ +        current_step = storage.get_current_step() or self.get_first_step( +                                                            request, storage) +        c_form = self.form_list[current_step] +        # make the current object the default item for the session +        obj_name = obj.__class__.__name__.lower() +        # prefer a specialized name if available +        prefixes = storage.prefix.split('_') +        if len(prefixes) > 1 and prefixes[-2].startswith(obj_name): +            obj_name = prefixes[-2] +        request.session[obj_name] = unicode(obj.pk) +        initial = {} +        if request.POST or (step in request.session[storage.prefix] and\ +           request.session[storage.prefix]['step_data'][step]): +            return {} +        if hasattr(c_form, 'base_fields'): +            for base_field in c_form.base_fields.keys(): +                fields = base_field.split('__') +                value = obj +                for field in fields: +                    if not hasattr(value, field) or \ +                       getattr(value, field) == None: +                        value = obj +                        break +                    value = getattr(value, field) +                if value == obj: +                    continue +                if hasattr(value, 'pk'): +                    value = value.pk +                if value in (True, False): +                    initial[base_field] = value +                elif value != None: +                    initial[base_field] = unicode(value) +        elif hasattr(c_form, 'management_form'): +            initial = [] +            key = current_step.split('-')[0] +            if not hasattr(obj, key): +                return initial +            keys = c_form.form.base_fields.keys() +            for child_obj in getattr(obj, key).order_by('pk').all(): +                if not keys: +                    break +                vals = {} +                if len(keys) == 1: +                    # only one field: must be the id of the object +                    vals[keys[0]] = unicode(child_obj.pk) +                else: +                    for field in keys: +                        if hasattr(child_obj, field): +                            value = getattr(child_obj, field) +                            if hasattr(value, 'pk'): +                                value = value.pk +                            if value != None: +                                vals[field] = unicode(value) +                if vals: +                    initial.append(vals) +        return initial + +def get_now(): +    format = formats.get_format('DATE_INPUT_FORMATS')[0] +    value = datetime.datetime.now().strftime(format) +    return value + +class DeletionWizard(Wizard): +    def get_formated_datas(self, forms): +        datas = super(DeletionWizard, self).get_formated_datas(forms) +        self.current_obj = None +        for form in forms: +            if not hasattr(form, "cleaned_data"): +                continue +            for key in form.cleaned_data: +                if key == 'pk': +                    model = form.associated_models['pk'] +                    self.current_obj = model.objects.get(pk=form.cleaned_data['pk']) +        if not self.current_obj: +            return datas +        res = {} +        for field in self.model._meta.fields + self.model._meta.many_to_many: +            if field.name not in self.fields: +                continue +            value = getattr(self.current_obj, field.name) +            if not value: +                continue +            if hasattr(value, 'all'): +                value = ", ".join([unicode(item) for item in value.all()]) +                if not value: +                    continue +            else: +                value = unicode(value) +            res[field.name] = (field.verbose_name, value, '') +        if not datas and self.fields: +            datas = [['', []]] +        for field in self.fields: +            if field in res: +                datas[0][1].append(res[field]) +        return datas + +    def done(self, request, storage, form_list, **kwargs): +        obj = self.get_current_object(request, storage) +        obj.delete() +        return render_to_response('wizard_delete_done.html', {}, +                                  context_instance=RequestContext(request)) + +class ClosingWizard(Wizard): +    # "close" an item +    # to be define in the overloaded class +    model = None +    fields = [] + +    def get_formated_datas(self, forms): +        datas = super(ClosingWizard, self).get_formated_datas(forms) +        self.current_obj = None +        for form in forms: +            if not hasattr(form, "cleaned_data"): +                continue +            for key in form.cleaned_data: +                if key == 'pk': +                    model = form.associated_models['pk'] +                    self.current_obj = model.objects.get( +                                                  pk=form.cleaned_data['pk']) +        if not self.current_obj: +            return datas +        res = {} +        for field in self.model._meta.fields + self.model._meta.many_to_many: +            if field.name not in self.fields: +                continue +            value = getattr(self.current_obj, field.name) +            if not value: +                continue +            if hasattr(value, 'all'): +                value = ", ".join([unicode(item) for item in value.all()]) +                if not value: +                    continue +            else: +                value = unicode(value) +            res[field.name] = (field.verbose_name, value, '') +        if not datas and self.fields: +            datas = [['', []]] +        for field in self.fields: +            if field in res: +                datas[0][1].append(res[field]) +        return datas + +    def done(self, request, storage, form_list, **kwargs): +        obj = self.get_current_object(request, storage) +        for form in form_list: +            if form.is_valid(): +                if 'end_date' in form.cleaned_data and hasattr(obj, 'end_date'): +                    obj.end_date = form.cleaned_data['end_date'] +                    obj.save() +        return render_to_response('wizard_closing_done.html', {}, +                                  context_instance=RequestContext(request)) + +def get_form_selection(class_name, label, key, model, base_form, get_url, +                       not_selected_error=_(u"You should select an item.")): +    """ +    Generate a class selection form +        class_name -- name of the class +        label -- label of the form +        key -- model, +        base_form -- base form to select +        get_url -- url to get the item +        not_selected_error -- message displayed when no item is selected +    """ +    attrs = {'_main_key':key, +             '_not_selected_error':not_selected_error, +             'form_label':label, +             'associated_models':{key:model}, +             'currents':{key:model},} +    attrs[key] = forms.IntegerField(label="", required=False, +                       widget=widgets.JQueryJqGrid(reverse_lazy(get_url), +                       base_form(), model), validators=[models.valid_id(model)]) +    def clean(self): +        cleaned_data = self.cleaned_data +        if self._main_key not in cleaned_data \ +           or not cleaned_data[self._main_key]: +            raise forms.ValidationError(self._not_selected_error) +        return cleaned_data +    return type(class_name, (forms.Form,), attrs)  | 
