Projet

Général

Profil

0001-workflow-added-attachments-for-RegisterComment.patch

Michaël Bideau, 16 avril 2019 18:16

Télécharger (25,3 ko)

Voir les différences:

Subject: [PATCH] workflow: added attachments for RegisterComment          
 (new class WorkflowStatusItemWithAttachments created)

misc: updated evalutils.attachment() to handle content already encoded to base64
      and support varname parameter
 tests/test_misc.py         |  65 +++++++++++++
 tests/test_workflows.py    | 102 +++++++++++++++++++-
 wcs/qommon/evalutils.py    |  15 +--
 wcs/wf/register_comment.py |  16 +++-
 wcs/workflows.py           | 227 ++++++++++++++++++++++++++++++++-------------
 5 files changed, 349 insertions(+), 76 deletions(-)
tests/test_misc.py
440 440
    assert not '<ul' in html
441 441
    assert 'arabic simple' in html
442 442
    assert 'M. Francis Kuntz' in html
443

  
444
def test_attachment():
445
    hello_word_b64 = base64.encodestring('hello world')
446

  
447
    d = evalutils.attachment(varname='var1')
448
    assert d == {'varname'     : 'var1'
449
                ,'filename'    : ''
450
                ,'content_type': 'application/octet-stream'
451
                ,'b64_content' : ''}
452

  
453
    d = evalutils.attachment(varname ='var1'
454
                            ,filename='hello.txt')
455
    assert d == {'varname'     : 'var1'
456
                ,'filename'    : 'hello.txt'
457
                ,'content_type': 'application/octet-stream'
458
                ,'b64_content' : ''}
459

  
460
    d = evalutils.attachment(varname     ='var1'
461
                            ,content_type='text/plain')
462
    assert d == {'varname'     : 'var1'
463
                ,'filename'    : ''
464
                ,'content_type': 'text/plain'
465
                ,'b64_content' : ''}
466

  
467
    d = evalutils.attachment(varname='var1'
468
                            ,content='hello world')
469
    assert d == {'varname'     : 'var1'
470
                ,'filename'    : ''
471
                ,'content_type': 'application/octet-stream'
472
                ,'b64_content' : hello_word_b64}
473

  
474
    d = evalutils.attachment(varname    ='var1'
475
                            ,b64_content=hello_word_b64)
476
    assert d == {'varname'     : 'var1'
477
                ,'filename'    : ''
478
                ,'content_type': 'application/octet-stream'
479
                ,'b64_content' : hello_word_b64}
480

  
481
    d = evalutils.attachment(varname     ='var1'
482
                            ,filename    ='hello.txt'
483
                            ,content_type='text/plain'
484
                            ,content     ='hello world')
485
    assert d == {'varname'     : 'var1'
486
                ,'filename'    : 'hello.txt'
487
                ,'content_type': 'text/plain'
488
                ,'b64_content' : hello_word_b64}
489

  
490
    d = evalutils.attachment(varname     ='var1'
491
                            ,filename    ='hello.txt'
492
                            ,content_type='text/plain'
493
                            ,b64_content =hello_word_b64)
494
    assert d == {'varname'     : 'var1'
495
                ,'filename'    : 'hello.txt'
496
                ,'content_type': 'text/plain'
497
                ,'b64_content' : hello_word_b64}
498

  
499
    d = evalutils.attachment(varname     ='var1'
500
                            ,filename    ='hello.txt'
501
                            ,content_type='text/plain'
502
                            ,content     ='hello world'
503
                            ,b64_content =hello_word_b64)
504
    assert d == {'varname'     : 'var1'
505
                ,'filename'    : 'hello.txt'
506
                ,'content_type': 'text/plain'
507
                ,'b64_content' : hello_word_b64}
tests/test_workflows.py
37 37
from wcs.wf.jump import JumpWorkflowStatusItem, _apply_timeouts
38 38
from wcs.wf.timeout_jump import TimeoutWorkflowStatusItem
39 39
from wcs.wf.profile import UpdateUserProfileStatusItem
40
from wcs.wf.register_comment import RegisterCommenterWorkflowStatusItem
40
from wcs.wf.register_comment import RegisterCommenterWorkflowStatusItem, JournalEvolutionPart
41 41
from wcs.wf.remove import RemoveWorkflowStatusItem
42 42
from wcs.wf.roles import AddRoleWorkflowStatusItem, RemoveRoleWorkflowStatusItem
43 43
from wcs.wf.wscall import WebserviceCallStatusItem
......
851 851
    formdata.evolution[-1]._display_parts = None
852 852
    assert formdata.evolution[-1].display_parts()[-1] == '<div><p>hello</p></div>'
853 853

  
854
def test_register_comment_attachment(pub):
854
def test_register_comment_attachment_substitution(pub):
855 855
    pub.substitutions.feed(MockSubstitutionVariables())
856 856

  
857 857
    formdef = FormDef()
......
922 922
    assert url3 == url4
923 923

  
924 924

  
925
def test_register_comment_attachment(pub):
926
    wf = Workflow(name='comment with attachments')
927
    wf.backoffice_fields_formdef = WorkflowBackofficeFieldsFormDef(wf)
928
    wf.backoffice_fields_formdef.fields = [
929
        FileField(id='bo1', label='bo field 1', type='file', varname='backoffice_file1'),
930
    ]
931
    st1 = wf.add_status('Status1')
932
    wf.store()
933

  
934
    upload = PicklableUpload('test.jpeg', 'image/jpeg')
935
    jpg = open(os.path.join(os.path.dirname(__file__), 'image-with-gps-data.jpeg')).read()
936
    upload.receive([jpg])
937

  
938
    formdef = FormDef()
939
    formdef.name = 'baz'
940
    formdef.fields = [
941
        FileField(id='1', label='File', type='file', varname='frontoffice_file'),
942
    ]
943
    formdef.workflow_id = wf.id
944
    formdef.store()
945

  
946
    formdata = formdef.data_class()()
947
    formdata.data = {'1': upload}
948
    formdata.just_created()
949
    formdata.store()
950

  
951
    pub.substitutions.feed(formdata)
952

  
953
    setbo = SetBackofficeFieldsWorkflowStatusItem()
954
    setbo.parent = st1
955
    setbo.fields = [{'field_id': 'bo1', 'value': '=form_var_frontoffice_file_raw'}]
956
    setbo.perform(formdata)
957

  
958
    if os.path.exists(os.path.join(get_publisher().app_dir, 'attachments')):
959
        shutil.rmtree(os.path.join(get_publisher().app_dir, 'attachments'))
960

  
961
    item = RegisterCommenterWorkflowStatusItem()
962
    item.attachments = ['form_var_backoffice_file1_raw']
963
    item.comment = '<div>{{ attachments.%s.url }}</div>' % 'backoffice_file1'
964
    item.perform(formdata)
965

  
966
    assert len(os.listdir(os.path.join(get_publisher().app_dir, 'attachments'))) == 1
967
    for subdir in os.listdir(os.path.join(get_publisher().app_dir, 'attachments')):
968
        assert len(subdir) == 4
969
        assert len(os.listdir(os.path.join(get_publisher().app_dir, 'attachments', subdir))) == 1
970

  
971
    assert len(formdata.evolution[-1].parts) == 2
972
    assert isinstance(formdata.evolution[-1].parts[0], AttachmentEvolutionPart)
973
    assert formdata.evolution[-1].parts[0].varname == 'backoffice_file1'
974
    assert formdata.evolution[-1].parts[0].orig_filename == upload.orig_filename
975

  
976
    assert isinstance(formdata.evolution[-1].parts[1], JournalEvolutionPart)
977
    assert len(formdata.evolution[-1].parts[1].content) > 0
978
    comment_view = str(formdata.evolution[-1].parts[1].view())
979
    assert len(comment_view) > len('<div></div>')
980
    assert re.search('<div>[^<]+</div>', comment_view)
981

  
982
    if os.path.exists(os.path.join(get_publisher().app_dir, 'attachments')):
983
        shutil.rmtree(os.path.join(get_publisher().app_dir, 'attachments'))
984

  
985
    formdata.evolution[-1].parts = []
986
    formdata.store()
987

  
988
    ws_response_varname = 'ws_response_afile'
989
    wf_data = {'%s_filename'     % ws_response_varname : 'hello.txt'
990
              ,'%s_content_type' % ws_response_varname : 'text/plain'
991
              ,'%s_b64_content'  % ws_response_varname : base64.encodestring('hello world')}
992
    formdata.update_workflow_data(wf_data)
993
    formdata.store()
994
    assert hasattr(formdata, 'workflow_data')
995
    assert isinstance(formdata.workflow_data, dict)
996

  
997
    item = RegisterCommenterWorkflowStatusItem()
998
    item.attachments = ["utils.attachment(varname='{0}'"
999
                                        ",filename={1}_filename"
1000
                                        ",b64_content ={1}_b64_content"
1001
                                        ",content_type={1}_content_type)"
1002
                        "".format('afile', ws_response_varname)]
1003
    item.comment = '{{ attachments.%s.url }}' % 'afile'
1004
    item.perform(formdata)
1005

  
1006
    assert len(os.listdir(os.path.join(get_publisher().app_dir, 'attachments'))) == 1
1007
    for subdir in os.listdir(os.path.join(get_publisher().app_dir, 'attachments')):
1008
        assert len(subdir) == 4
1009
        assert len(os.listdir(os.path.join(get_publisher().app_dir, 'attachments', subdir))) == 1
1010

  
1011
    assert len(formdata.evolution[-1].parts) == 2
1012
    assert isinstance(formdata.evolution[-1].parts[0], AttachmentEvolutionPart)
1013
    assert formdata.evolution[-1].parts[0].varname == 'afile'
1014
    assert formdata.evolution[-1].parts[0].orig_filename == 'hello.txt'
1015

  
1016
    assert isinstance(formdata.evolution[-1].parts[1], JournalEvolutionPart)
1017
    assert len(formdata.evolution[-1].parts[1].content) > 0
1018
    comment_view = str(formdata.evolution[-1].parts[1].view())
1019
    assert len(comment_view) > len('<div></div>')
1020
    assert re.search('<div>[^<]+</div>', comment_view)
1021

  
1022

  
925 1023
def test_email(pub, emails):
926 1024
    pub.substitutions.feed(MockSubstitutionVariables())
927 1025

  
wcs/qommon/evalutils.py
127 127
    return make_date(date) + datetime.timedelta(days=count)
128 128

  
129 129

  
130
def attachment(content, filename='', content_type='application/octet-stream'):
130
def attachment(content=None, filename='', content_type='application/octet-stream', b64_content=None, varname=None):
131 131
    '''Serialize content as an attachment'''
132 132
    import base64
133 133

  
134
    return {
135
        'filename': filename,
136
        'content_type': content_type,
137
        'b64_content': base64.b64encode(content),
138
    }
134
    d = {'filename'    : filename if filename else ''
135
        ,'content_type': content_type if content_type else 'application/octet-stream'
136
        ,'b64_content' : b64_content if b64_content else \
137
                         base64.encodestring(content) if content else \
138
                         ''}
139
    if varname:
140
        d['varname'] = varname
141
    return d
wcs/wf/register_comment.py
21 21
from qommon.template import TemplateError
22 22
from qommon import get_logger
23 23

  
24
from wcs.workflows import WorkflowStatusItem, register_item_class, template_on_formdata
25

  
24
from wcs.workflows import WorkflowStatusItemWithAttachments, register_item_class, template_on_formdata
26 25

  
27 26
class JournalEvolutionPart: #pylint: disable=C1001
28 27
    content = None
......
69 68
        return d
70 69

  
71 70

  
72
class RegisterCommenterWorkflowStatusItem(WorkflowStatusItem):
71
class RegisterCommenterWorkflowStatusItem(WorkflowStatusItemWithAttachments):
73 72
    description = N_('History Message')
74 73
    key = 'register-comment'
75 74
    category = 'interaction'
......
84 83
                value=self.comment, cols=80, rows=10)
85 84

  
86 85
    def get_parameters(self):
87
        return ('comment', 'condition')
86
        return ('comment', 'attachments', 'condition')
88 87

  
89 88
    def perform(self, formdata):
90 89
        if not formdata.evolution:
91 90
            return
91

  
92
        # process attachments first, they might be used in the comment
93
        # (with substitution vars)
94
        if self.attachments:
95
            self.convert_attachments_to_uploads()
96
            self.attach_uploads_to_form(formdata)
97
            formdata.store()  # store and invalidate cache, so references can be used in the comment message.
98

  
99
        # the comment can use attachments done above
92 100
        try:
93 101
            formdata.evolution[-1].add_part(JournalEvolutionPart(formdata, self.comment))
94 102
            formdata.store()
wcs/workflows.py
45 45
from wcs.formdef import FormDef
46 46
from wcs.formdata import Evolution
47 47

  
48
from wcs.qommon.form import PicklableUpload as PUpload
49
from collections import OrderedDict
50

  
48 51
if not __name__.startswith('wcs.') and not __name__ == "__main__":
49 52
    raise ImportError('Import of workflows module must be absolute (import wcs.workflows)')
50 53

  
......
1843 1846
        return odict
1844 1847

  
1845 1848

  
1849
class WorkflowStatusItemWithAttachments(WorkflowStatusItem):
1850
    support_substitution_variables = True
1851
    attachments = None
1852
    uploads = OrderedDict()
1853

  
1854
    # copy-pasted from class SendmailWorkflowStatusItem
1855
    def attachments_init_with_xml(self, elem, charset, include_id=False):
1856
        if elem is None:
1857
            self.attachments = None
1858
        else:
1859
            self.attachments = [item.text.encode(charset) for item in elem.findall('attachment')]
1860

  
1861
    # copy-pasted from class SendmailWorkflowStatusItem
1862
    def get_attachments_options(self):
1863
        attachments_options = [(None, '---', None)]
1864
        varnameless = []
1865
        for field in self.parent.parent.get_backoffice_fields():
1866
            if field.key != 'file':
1867
                continue
1868
            if field.varname:
1869
                codename = 'form_var_%s_raw' % field.varname
1870
            else:
1871
                codename = 'form_f%s' % field.id  # = form_fbo<n>
1872
                varnameless.append(codename)
1873
            attachments_options.append((codename, field.label, codename))
1874
        # filter: do not consider removed fields without varname
1875
        attachments = [attachment for attachment in self.attachments or []
1876
                       if ((not attachment.startswith('form_fbo')) or
1877
                           (attachment in varnameless))]
1878
        return attachments_options, attachments
1879

  
1880

  
1881
    def add_parameters_widgets(self, form, parameters, prefix='', formdef=None):
1882
        super(WorkflowStatusItemWithAttachments, self).add_parameters_widgets(
1883
                form, parameters, prefix=prefix, formdef=formdef)
1884

  
1885
        # copy-pasted from class SendmailWorkflowStatusItem
1886
        if 'attachments' in parameters:
1887
            attachments_options, attachments = self.get_attachments_options()
1888
            if len(attachments_options) > 1:
1889
                form.add(WidgetList, '%sattachments' % prefix, title=_('Attachments'),
1890
                         element_type=SingleSelectWidgetWithOther,
1891
                         value=attachments,
1892
                         add_element_label=_('Add attachment'),
1893
                         element_kwargs={'render_br': False, 'options': attachments_options})
1894
            else:
1895
                form.add(WidgetList, '%sattachments' % prefix,
1896
                         title=_('Attachments (Python expressions)'),
1897
                         element_type=StringWidget,
1898
                         value=attachments,
1899
                         add_element_label=_('Add attachment'),
1900
                         element_kwargs={'render_br': False, 'size': 50},
1901
                         advanced=not(bool(attachments)))
1902

  
1903
    def get_parameters(self):
1904
        return ('attachments', 'condition',)
1905

  
1906
    def get_attachments(self, as_uploads=False):
1907
        if as_uploads:
1908
            return self.get_attachments_as_uploads()
1909
        return self.attachments
1910

  
1911
    def get_attachments_as_uploads(self):
1912
        return self.uploads
1913

  
1914
    def get_attachment_varname(self, attachment, upload=None):
1915
        varname = str(attachment)
1916
        if isinstance(attachment, str) and re.match(r'form_var_', attachment):
1917
            varname = attachment
1918
            m = re.search(r'^form_var_(\w+)$', attachment)
1919
            if m:
1920
                varname = re.sub(r'_raw$', '', m.group(1)) if re.search(r'_raw$', m.group(1)) else m.group(1)
1921
        elif upload:
1922
            if isinstance(upload, PUpload):
1923
                varname = str(upload)
1924
            elif isinstance(upload, dict) and 'varname' in upload:
1925
                varname = upload['varname']
1926
            elif hasattr(upload, varname):
1927
                varname = upload.varname
1928
        return varname
1929

  
1930
    def convert_attachments_to_uploads(self):
1931
        # forget previous attachment converted to uploads
1932
        # (file with same content and same varname, will not be duplicated
1933
        #  because the path of the file is made of the hash of the content)
1934
        self.uploads = OrderedDict()
1935

  
1936
        if self.attachments:
1937
            global_eval_dict = get_publisher().get_global_eval_dict()
1938
            local_eval_dict = get_publisher().substitutions.get_context_variables()
1939
            for attachment in self.attachments:
1940
                try:
1941
                    # execute any Python expression
1942
                    # and magically convert string like 'form_var_*_raw' to a PicklableUpload
1943
                    picklableupload = eval(attachment, global_eval_dict, local_eval_dict)
1944
                except:
1945
                    get_publisher().notify_of_exception(sys.exc_info(),
1946
                                                        context='[%s/attachments]' % self.__class__)
1947
                    continue
1948

  
1949
                if not picklableupload:
1950
                    continue
1951

  
1952
                varname = self.get_attachment_varname(attachment, picklableupload)
1953

  
1954
                try:
1955
                    # magically convert any value to a PicklableUpload
1956
                    # usualy a dict like one provided by qommon/evalutils:attachment()
1957
                    picklableupload = FileField.convert_value_from_anything(picklableupload)
1958
                except ValueError:
1959
                    get_publisher().notify_of_exception(sys.exc_info(),
1960
                                                        context='[%s/attachments]' % self.__class__)
1961
                    continue
1962

  
1963
                if varname in self.uploads:
1964
                    # exsiting attachments upload will be replaced by the new one (last one wins)
1965
                    # TODO remove the old one?
1966
                    pass
1967

  
1968
                self.uploads[varname] = picklableupload
1969

  
1970
        return self.uploads
1971

  
1972
    def form_has_attachment(self, formdata, varname=None, attachment=None):
1973
        if not formdata.evolution or not formdata.evolution[-1].parts:
1974
            return False
1975
        if not varname and hasattr(attachment, 'varname'):
1976
            varname = attachment.varname
1977
        for part in formdata.evolution[-1].parts:
1978
            if isinstance(part, AttachmentEvolutionPart):
1979
                if attachment:
1980
                    # not testing content here, only metadata
1981
                    if  part.orig_filename == attachment.orig_filename \
1982
                    and part.base_filename == attachment.base_filename \
1983
                    and  part.content_type == attachment.content_type  \
1984
                    and       part.charset == attachment.charset       \
1985
                    and (not varname or part.varname == varname):
1986
                        return True
1987
                elif varname and part.varname == varname:
1988
                    return True
1989
        return False
1990

  
1991
    def attach_uploads_to_form(self, formdata, convert_uploads=True):
1992
        if not formdata.evolution[-1].parts:
1993
            formdata.evolution[-1].parts = []
1994
        for varname, upload in self.get_attachments_as_uploads().items():
1995
            # attach to form only if new
1996
            if not self.form_has_attachment(formdata, varname=varname, attachment=upload):
1997
                try:
1998
                    # useless but required to restore upload.fp from serialized state, needed by 'AttachmentEvolutionPart.from_upload()
1999
                    fp = upload.get_file_pointer()
2000
                    formdata.evolution[-1].add_part(AttachmentEvolutionPart.from_upload(upload, varname=varname))
2001
                except:
2002
                    get_publisher().notify_of_exception(sys.exc_info(),
2003
                                                        context='[%s/attachments]' % self.__class__)
2004
                continue
2005

  
2006

  
1846 2007
class WorkflowStatusJumpItem(WorkflowStatusItem):
1847 2008
    status = None
1848 2009
    endpoint = False
......
2178 2339
register_item_class(JumpOnSubmitWorkflowStatusItem)
2179 2340

  
2180 2341

  
2181
class SendmailWorkflowStatusItem(WorkflowStatusItem):
2342
class SendmailWorkflowStatusItem(WorkflowStatusItemWithAttachments):
2182 2343
    description = N_('Email')
2183 2344
    key = 'sendmail'
2184 2345
    category = 'interaction'
2185
    support_substitution_variables = True
2186 2346

  
2187 2347
    to = []
2188 2348
    subject = None
2189 2349
    body = None
2190 2350
    custom_from = None
2191
    attachments = None
2192 2351

  
2193 2352
    comment = None
2194 2353

  
......
2204 2363
        return super(SendmailWorkflowStatusItem, self)._get_role_id_from_xml(
2205 2364
                elem, charset, include_id=include_id)
2206 2365

  
2207
    def attachments_init_with_xml(self, elem, charset, include_id=False):
2208
        if elem is None:
2209
            self.attachments = None
2210
        else:
2211
            self.attachments = [item.text.encode(charset) for item in elem.findall('attachment')]
2212

  
2213 2366
    def render_list_of_roles_or_emails(self, roles):
2214 2367
        t = []
2215 2368
        for r in roles:
......
2239 2392
    def fill_admin_form(self, form):
2240 2393
        self.add_parameters_widgets(form, self.get_parameters())
2241 2394

  
2242
    def get_attachments_options(self):
2243
        attachments_options = [(None, '---', None)]
2244
        varnameless = []
2245
        for field in self.parent.parent.get_backoffice_fields():
2246
            if field.key != 'file':
2247
                continue
2248
            if field.varname:
2249
                codename = 'form_var_%s_raw' % field.varname
2250
            else:
2251
                codename = 'form_f%s' % field.id  # = form_fbo<n>
2252
                varnameless.append(codename)
2253
            attachments_options.append((codename, field.label, codename))
2254
        # filter: do not consider removed fields without varname
2255
        attachments = [attachment for attachment in self.attachments or []
2256
                       if ((not attachment.startswith('form_fbo')) or
2257
                           (attachment in varnameless))]
2258
        return attachments_options, attachments
2259

  
2260 2395
    def add_parameters_widgets(self, form, parameters, prefix='', formdef=None):
2261 2396
        super(SendmailWorkflowStatusItem, self).add_parameters_widgets(
2262 2397
                form, parameters, prefix=prefix, formdef=formdef)
......
2277 2412
                     value=self.body, cols=80, rows=10,
2278 2413
                     validation_function=ComputedExpressionWidget.validate_template)
2279 2414

  
2280
        if 'attachments' in parameters:
2281
            attachments_options, attachments = self.get_attachments_options()
2282
            if len(attachments_options) > 1:
2283
                form.add(WidgetList, '%sattachments' % prefix, title=_('Attachments'),
2284
                         element_type=SingleSelectWidgetWithOther,
2285
                         value=attachments,
2286
                         add_element_label=_('Add attachment'),
2287
                         element_kwargs={'render_br': False, 'options': attachments_options})
2288
            else:
2289
                form.add(WidgetList, '%sattachments' % prefix,
2290
                         title=_('Attachments (Python expressions)'),
2291
                         element_type=StringWidget,
2292
                         value=attachments,
2293
                         add_element_label=_('Add attachment'),
2294
                         element_kwargs={'render_br': False, 'size': 50},
2295
                         advanced=not(bool(attachments)))
2296

  
2297 2415
        if 'custom_from' in parameters:
2298 2416
            form.add(ComputedExpressionWidget, '%scustom_from' % prefix,
2299 2417
                     title=_('Custom From Address'), value=self.custom_from,
......
2381 2499
        if self.custom_from:
2382 2500
            email_from = self.compute(self.custom_from)
2383 2501

  
2384
        attachments = []
2385
        if self.attachments:
2386
            global_eval_dict = get_publisher().get_global_eval_dict()
2387
            local_eval_dict = get_publisher().substitutions.get_context_variables()
2388
            for attachment in self.attachments:
2389
                try:
2390
                    picklableupload = eval(attachment, global_eval_dict, local_eval_dict)
2391
                except:
2392
                    get_publisher().notify_of_exception(sys.exc_info(),
2393
                                                        context='[Sendmail/attachments]')
2394
                    continue
2395
                if not picklableupload:
2396
                    continue
2397
                try:
2398
                    picklableupload = FileField.convert_value_from_anything(picklableupload)
2399
                except ValueError:
2400
                    get_publisher().notify_of_exception(sys.exc_info(),
2401
                                                        context='[Sendmail/attachments]')
2402
                    continue
2403
                attachments.append(picklableupload)
2502
        attachments = [x for _,x in self.convert_attachments_to_uploads().items()]
2404 2503

  
2405 2504
        if len(addresses) > 1:
2406 2505
            emails.email(mail_subject, mail_body, email_rcpt=None,
2407
-