Merge branch 'edge' of github.com:webnotes/wnframework into edge
This commit is contained in:
commit
269b69a69c
15 changed files with 182 additions and 107 deletions
|
|
@ -160,7 +160,7 @@ DROP TABLE IF EXISTS `tabSeries`;
|
|||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `tabSeries` (
|
||||
`name` varchar(40) DEFAULT NULL,
|
||||
`name` varchar(100) DEFAULT NULL,
|
||||
`current` int(10) DEFAULT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ import webnotes
|
|||
|
||||
sql = webnotes.conn.sql
|
||||
|
||||
test_records = []
|
||||
|
||||
class DocType:
|
||||
def __init__(self, doc, doclist=[]):
|
||||
self.doc = doc
|
||||
|
|
|
|||
1
core/doctype/letter_head/test_letter_head.py
Normal file
1
core/doctype/letter_head/test_letter_head.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
test_records = []
|
||||
|
|
@ -277,16 +277,4 @@ def get_perm_info(arg=None):
|
|||
def get_defaults(arg=None):
|
||||
return webnotes.conn.sql("""select defkey, defvalue from tabDefaultValue where
|
||||
parent=%s and parenttype = 'Profile'""", webnotes.form_dict['profile'])
|
||||
|
||||
test_records = [[{
|
||||
"doctype":"Profile",
|
||||
"email": "test@erpnext.com",
|
||||
"first_name": "_Test",
|
||||
"new_password": "testpassword"
|
||||
}],
|
||||
[{
|
||||
"doctype":"Profile",
|
||||
"email": "test1@erpnext.com",
|
||||
"first_name": "_Test1",
|
||||
"new_password": "testpassword"
|
||||
}]]
|
||||
|
||||
12
core/doctype/profile/test_profile.py
Normal file
12
core/doctype/profile/test_profile.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
test_records = [[{
|
||||
"doctype":"Profile",
|
||||
"email": "test@erpnext.com",
|
||||
"first_name": "_Test",
|
||||
"new_password": "testpassword"
|
||||
}],
|
||||
[{
|
||||
"doctype":"Profile",
|
||||
"email": "test1@erpnext.com",
|
||||
"first_name": "_Test1",
|
||||
"new_password": "testpassword"
|
||||
}]]
|
||||
1
core/doctype/workflow_state/test_workflow_state.py
Normal file
1
core/doctype/workflow_state/test_workflow_state.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
test_records = []
|
||||
|
|
@ -22,8 +22,6 @@
|
|||
from __future__ import unicode_literals
|
||||
import webnotes
|
||||
|
||||
test_records = []
|
||||
|
||||
class DocType:
|
||||
def __init__(self, d, dl):
|
||||
self.doc, self.doclist = d, dl
|
||||
|
|
@ -55,9 +55,17 @@ wn.views.CommunicationList = Class.extend({
|
|||
},
|
||||
|
||||
add_reply: function() {
|
||||
var subject = this.doc.subject;
|
||||
if(!subject && this.list.length) {
|
||||
// get subject from previous message
|
||||
subject = this.list[0].subject;
|
||||
if(strip(subject.toLowerCase().split(":")[0])!="re") {
|
||||
subject = "Re: " + subject;
|
||||
}
|
||||
}
|
||||
new wn.views.CommunicationComposer({
|
||||
doc: this.doc,
|
||||
subject: this.doc.subject,
|
||||
subject: subject,
|
||||
recipients: this.recipients
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ debug_log = []
|
|||
message_log = []
|
||||
mute_emails = False
|
||||
test_objects = {}
|
||||
|
||||
request_method = None
|
||||
print_messages = False
|
||||
user_lang = False
|
||||
lang = 'en'
|
||||
|
||||
|
|
@ -114,20 +115,20 @@ def getTraceback():
|
|||
return utils.getTraceback()
|
||||
|
||||
def errprint(msg):
|
||||
"""
|
||||
Append to the :data:`debug log`
|
||||
"""
|
||||
if not request_method:
|
||||
print repr(msg)
|
||||
|
||||
from utils import cstr
|
||||
debug_log.append(cstr(msg or ''))
|
||||
|
||||
def msgprint(msg, small=0, raise_exception=0, as_table=False):
|
||||
"""
|
||||
Append to the :data:`message_log`
|
||||
"""
|
||||
from utils import cstr
|
||||
if as_table and type(msg) in (list, tuple):
|
||||
msg = '<table border="1px" style="border-collapse: collapse" cellpadding="2px">' + ''.join(['<tr>'+''.join(['<td>%s</td>' % c for c in r])+'</tr>' for r in msg]) + '</table>'
|
||||
|
||||
if print_messages:
|
||||
print "Message: " + repr(msg)
|
||||
|
||||
message_log.append((small and '__small:' or '')+cstr(msg or ''))
|
||||
if raise_exception:
|
||||
import inspect
|
||||
|
|
@ -137,11 +138,7 @@ def msgprint(msg, small=0, raise_exception=0, as_table=False):
|
|||
raise ValidationError, msg
|
||||
|
||||
def create_folder(path):
|
||||
"""
|
||||
Wrapper function for os.makedirs (does not throw exception if directory exists)
|
||||
"""
|
||||
import os
|
||||
|
||||
try:
|
||||
os.makedirs(path)
|
||||
except OSError, e:
|
||||
|
|
@ -149,11 +146,7 @@ def create_folder(path):
|
|||
raise e
|
||||
|
||||
def create_symlink(source_path, link_path):
|
||||
"""
|
||||
Wrapper function for os.symlink (does not throw exception if directory exists)
|
||||
"""
|
||||
import os
|
||||
|
||||
try:
|
||||
os.symlink(source_path, link_path)
|
||||
except OSError, e:
|
||||
|
|
@ -161,11 +154,7 @@ def create_symlink(source_path, link_path):
|
|||
raise e
|
||||
|
||||
def remove_file(path):
|
||||
"""
|
||||
Wrapper function for os.remove (does not throw exception if file/symlink does not exists)
|
||||
"""
|
||||
import os
|
||||
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError, e:
|
||||
|
|
@ -173,9 +162,6 @@ def remove_file(path):
|
|||
raise e
|
||||
|
||||
def connect(db_name=None, password=None):
|
||||
"""
|
||||
Connect to this db (or db), if called from command prompt
|
||||
"""
|
||||
import webnotes.db
|
||||
global conn
|
||||
conn = webnotes.db.Database(user=db_name, password=password)
|
||||
|
|
@ -185,7 +171,7 @@ def connect(db_name=None, password=None):
|
|||
|
||||
import webnotes.profile
|
||||
global user
|
||||
user = webnotes.profile.Profile('Administrator')
|
||||
user = webnotes.profile.Profile('Administrator')
|
||||
|
||||
def get_env_vars(env_var):
|
||||
import os
|
||||
|
|
@ -391,4 +377,5 @@ def get_application_home_page(user='Guest'):
|
|||
if hpl:
|
||||
return hpl[0][0]
|
||||
else:
|
||||
return conn.get_value('Control Panel',None,'home_page') or 'Login Page'
|
||||
from startup import application_home_page
|
||||
return application_home_page
|
||||
|
|
|
|||
|
|
@ -97,7 +97,11 @@ class Database:
|
|||
if values!=():
|
||||
if isinstance(values, dict):
|
||||
values = dict(values)
|
||||
if debug: webnotes.errprint(query % values)
|
||||
if debug:
|
||||
try:
|
||||
webnotes.errprint(query % values)
|
||||
except TypeError:
|
||||
webnotes.errprint([query, values])
|
||||
self._cursor.execute(query, values)
|
||||
|
||||
else:
|
||||
|
|
@ -126,11 +130,15 @@ class Database:
|
|||
else:
|
||||
return self._cursor.fetchall()
|
||||
|
||||
def sql_list(self, query, values=()):
|
||||
return [r[0] for r in self.sql(query, values)]
|
||||
def sql_list(self, query, values=(), debug=False):
|
||||
return [r[0] for r in self.sql(query, values, debug=debug)]
|
||||
|
||||
def sql_ddl(self, query, values=()):
|
||||
self.commit()
|
||||
self.sql(query)
|
||||
|
||||
def check_transaction_status(self, query):
|
||||
if self.in_transaction and query and query.strip().split()[0].lower() in ['start', 'alter', 'drop', 'create']:
|
||||
if self.in_transaction and query and query.strip().split()[0].lower() in ['start', 'alter', 'drop', 'create', "begin"]:
|
||||
raise Exception, 'This statement can cause implicit commit'
|
||||
|
||||
if query and query.strip().lower()=='start transaction':
|
||||
|
|
@ -240,15 +248,23 @@ class Database:
|
|||
|
||||
return " and ".join(conditions), filters
|
||||
|
||||
def get(self, doctype, filters=None, as_dict=False):
|
||||
return self.get_value(doctype, filters, "*", as_dict=as_dict)
|
||||
|
||||
def get_value(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False):
|
||||
"""Get a single / multiple value from a record.
|
||||
For Single DocType, let filters be = None"""
|
||||
|
||||
if fieldname!="*" and isinstance(fieldname, basestring):
|
||||
fieldname = "`" + fieldname + "`"
|
||||
|
||||
if filters is not None and (filters!=doctype or filters=='DocType'):
|
||||
fl = isinstance(fieldname, basestring) and fieldname or "`, `".join(fieldname)
|
||||
fl = isinstance(fieldname, basestring) and fieldname or \
|
||||
("`" + "`, `".join(fieldname) + "`")
|
||||
conditions, filters = self.build_conditions(filters)
|
||||
|
||||
try:
|
||||
r = self.sql("select `%s` from `tab%s` where %s" % (fl, doctype,
|
||||
r = self.sql("select %s from `tab%s` where %s" % (fl, doctype,
|
||||
conditions), filters, as_dict)
|
||||
except Exception, e:
|
||||
if e.args[0]==1054 and ignore:
|
||||
|
|
@ -360,7 +376,8 @@ class Database:
|
|||
self.sql("start transaction")
|
||||
|
||||
def commit(self):
|
||||
self.sql("commit")
|
||||
if self.in_transaction:
|
||||
self.sql("commit")
|
||||
|
||||
def rollback(self):
|
||||
self.sql("ROLLBACK")
|
||||
|
|
|
|||
|
|
@ -134,11 +134,16 @@ def get_doctype_class(doctype, module):
|
|||
|
||||
return DocType
|
||||
|
||||
def get_module_name(doctype, module, prefix):
|
||||
from webnotes.modules import scrub
|
||||
_doctype, _module = scrub(doctype), scrub(module)
|
||||
return '%s.doctype.%s.%s%s' % (_module, _doctype, prefix, _doctype)
|
||||
|
||||
def load_doctype_module(doctype, module, prefix=""):
|
||||
from webnotes.modules import scrub
|
||||
_doctype, _module = scrub(doctype), scrub(module)
|
||||
try:
|
||||
module = __import__('%s.doctype.%s.%s%s' % (_module, _doctype, prefix, _doctype), fromlist=[''])
|
||||
module = __import__(get_module_name(doctype, module, prefix), fromlist=[''])
|
||||
return module
|
||||
except ImportError, e:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@
|
|||
from __future__ import unicode_literals
|
||||
import webnotes
|
||||
|
||||
#=================================================================================
|
||||
|
||||
def get_dt_values(doctype, fields, as_dict = 0):
|
||||
return webnotes.conn.sql('SELECT %s FROM tabDocType WHERE name="%s"' % (fields, doctype), as_dict = as_dict)
|
||||
|
||||
|
|
@ -39,21 +37,15 @@ def is_single(doctype):
|
|||
except IndexError, e:
|
||||
raise Exception, 'Cannot determine whether %s is single' % doctype
|
||||
|
||||
#=================================================================================
|
||||
|
||||
def get_parent_dt(dt):
|
||||
parent_dt = webnotes.conn.sql("""select parent from tabDocField
|
||||
where fieldtype="Table" and options="%s" and (parent not like "old_parent:%%")
|
||||
limit 1""" % dt)
|
||||
return parent_dt and parent_dt[0][0] or ''
|
||||
|
||||
#=================================================================================
|
||||
|
||||
def set_fieldname(field_id, fieldname):
|
||||
webnotes.conn.set_value('DocField', field_id, 'fieldname', fieldname)
|
||||
|
||||
#=================================================================================
|
||||
|
||||
def get_link_fields(doctype):
|
||||
"""
|
||||
Returns list of link fields for a doctype in tuple (fieldname, options, label)
|
||||
|
|
@ -71,8 +63,6 @@ def get_link_fields(doctype):
|
|||
)
|
||||
]
|
||||
|
||||
#=================================================================================
|
||||
|
||||
def get_table_fields(doctype):
|
||||
child_tables = [[d[0], d[1]] for d in webnotes.conn.sql("select options, fieldname from tabDocField \
|
||||
where parent='%s' and fieldtype='Table'" % doctype, as_list=1)]
|
||||
|
|
@ -81,3 +71,8 @@ def get_table_fields(doctype):
|
|||
where dt='%s' and fieldtype='Table'" % doctype, as_list=1)]
|
||||
|
||||
return child_tables + custom_child_tables
|
||||
|
||||
def has_field(doctype, fieldname):
|
||||
doclist = webnotes.model.doctype.get(doctype)
|
||||
return doclist.get({"parent":doctype, "doctype":"DocField", "fieldname":fieldname})
|
||||
|
||||
|
|
@ -106,6 +106,7 @@ class ModelWrapper:
|
|||
|
||||
from webnotes.model.code import get_obj
|
||||
self.obj = get_obj(doc=self.doc, doclist=self.doclist)
|
||||
self.controller = self.obj
|
||||
return self.obj
|
||||
|
||||
def to_dict(self):
|
||||
|
|
@ -195,6 +196,10 @@ class ModelWrapper:
|
|||
|
||||
self.set_doclist(self.obj.doclist)
|
||||
|
||||
def get_method(self, method):
|
||||
self.make_obj()
|
||||
return getattr(self.obj, method, None)
|
||||
|
||||
def save_main(self):
|
||||
try:
|
||||
self.doc.save(cint(self.doc.fields.get('__islocal')))
|
||||
|
|
|
|||
|
|
@ -5,65 +5,78 @@ if __name__=="__main__":
|
|||
sys.path.extend([".", "app", "lib"])
|
||||
|
||||
import webnotes
|
||||
from webnotes.model.meta import get_link_fields
|
||||
from webnotes.model.code import load_doctype_module
|
||||
import unittest
|
||||
|
||||
from webnotes.model.meta import get_link_fields, has_field
|
||||
from webnotes.model.code import load_doctype_module, get_module_name
|
||||
|
||||
|
||||
def make_test_records(doctype, verbose=0):
|
||||
webnotes.mute_emails = True
|
||||
if not webnotes.conn:
|
||||
webnotes.connect()
|
||||
|
||||
# also include doctype itself
|
||||
options_list = list(set([options for fieldname, options, label
|
||||
in get_link_fields(doctype)] + [doctype]))
|
||||
|
||||
for options in options_list:
|
||||
|
||||
for options in get_dependencies(doctype):
|
||||
|
||||
if options.startswith("link:"):
|
||||
options = options[5:]
|
||||
if options == "[Select]":
|
||||
continue
|
||||
|
||||
|
||||
if options not in webnotes.test_objects:
|
||||
webnotes.test_objects[options] = []
|
||||
make_test_records(options, verbose)
|
||||
|
||||
load_module_and_make_records(options, verbose)
|
||||
|
||||
def load_module_and_make_records(options, verbose=0):
|
||||
module = webnotes.conn.get_value("DocType", options, "module")
|
||||
make_test_records_for_doctype(options, verbose)
|
||||
|
||||
# get methods for either [doctype].py or test_[doctype].py
|
||||
doctype_module = load_doctype_module(options, module)
|
||||
test_module = load_doctype_module(options, module, "test_")
|
||||
def get_modules(doctype):
|
||||
module = webnotes.conn.get_value("DocType", doctype, "module")
|
||||
test_module = load_doctype_module(doctype, module, "test_")
|
||||
|
||||
return module, test_module
|
||||
|
||||
def get_dependencies(doctype):
|
||||
module, test_module = get_modules(doctype)
|
||||
|
||||
options_list = list(set([options for fieldname, options, label
|
||||
in get_link_fields(doctype)] + [doctype]))
|
||||
|
||||
if hasattr(test_module, "test_dependencies"):
|
||||
options_list += test_module.test_dependencies
|
||||
|
||||
if hasattr(test_module, "test_ignore"):
|
||||
for doctype_name in test_module.test_ignore:
|
||||
if doctype_name in options_list:
|
||||
options_list.remove(doctype_name)
|
||||
|
||||
return options_list
|
||||
|
||||
def make_test_records_for_doctype(doctype, verbose=0):
|
||||
module, test_module = get_modules(doctype)
|
||||
|
||||
if hasattr(test_module, "make_test_records"):
|
||||
webnotes.test_objects[options] += test_module.make_test_records(verbose)
|
||||
|
||||
elif hasattr(doctype_module, "make_test_records"):
|
||||
webnotes.test_objects[options] += doctype_module.make_test_records(verbose)
|
||||
webnotes.test_objects[doctype] += test_module.make_test_records(verbose)
|
||||
|
||||
elif hasattr(test_module, "test_records"):
|
||||
webnotes.test_objects[options] += make_test_objects(test_module)
|
||||
|
||||
elif hasattr(doctype_module, "test_records"):
|
||||
webnotes.test_objects[options] += make_test_objects(doctype_module)
|
||||
webnotes.test_objects[doctype] += make_test_objects(doctype, test_module.test_records)
|
||||
|
||||
elif verbose:
|
||||
print_mandatory_fields(options)
|
||||
print_mandatory_fields(doctype)
|
||||
|
||||
def make_test_objects(obj):
|
||||
if isinstance(obj, list):
|
||||
test_records = obj
|
||||
else:
|
||||
# obj is a module object
|
||||
test_records = obj.test_records
|
||||
|
||||
|
||||
def make_test_objects(doctype, test_records):
|
||||
records = []
|
||||
|
||||
for doclist in test_records:
|
||||
if not "doctype" in doclist[0]:
|
||||
doclist[0]["doctype"] = doctype
|
||||
d = webnotes.model_wrapper((webnotes.doclist(doclist)).copy())
|
||||
if webnotes.test_objects.get(d.doc.doctype):
|
||||
# do not create test records, if already exists
|
||||
return []
|
||||
if has_field(d.doc.doctype, "naming_series"):
|
||||
if not d.doc.naming_series:
|
||||
d.doc.naming_series = "_T-" + d.doc.doctype + "-"
|
||||
|
||||
d.insert()
|
||||
records.append(d.doc.name)
|
||||
return records
|
||||
|
|
@ -78,6 +91,52 @@ def print_mandatory_fields(doctype):
|
|||
print d.parent + ":" + d.fieldname + " | " + d.fieldtype + " | " + (d.options or "")
|
||||
print
|
||||
|
||||
if __name__=="__main__":
|
||||
make_test_records(sys.argv[1], verbose=1)
|
||||
def run_unittest(doctype):
|
||||
module = webnotes.conn.get_value("DocType", doctype, "module")
|
||||
test_module = get_module_name(doctype, module, "test_")
|
||||
make_test_records(args.doctype[0], verbose=True)
|
||||
|
||||
try:
|
||||
exec ('from %s import *' % test_module) in globals()
|
||||
del sys.argv[1:]
|
||||
unittest.main()
|
||||
|
||||
except ImportError, e:
|
||||
print "No test module."
|
||||
|
||||
def run_all_tests(verbose):
|
||||
import os, imp
|
||||
from webnotes.modules.utils import peval_doclist
|
||||
|
||||
for path, folders, files in os.walk("."):
|
||||
for filename in files:
|
||||
if filename.startswith("test_") and filename.endswith(".py"):
|
||||
test_suite = unittest.TestSuite()
|
||||
if os.path.basename(os.path.dirname(path))=="doctype":
|
||||
txt_file = os.path.join(path, filename[5:].replace(".py", ".txt"))
|
||||
with open(txt_file, 'r') as f:
|
||||
doctype_doclist = peval_doclist(f.read())
|
||||
doctype = doctype_doclist[0]["name"]
|
||||
make_test_records(doctype, verbose)
|
||||
|
||||
module = imp.load_source('test', os.path.join(path, filename))
|
||||
test_suite.addTest(unittest.TestLoader().loadTestsFromModule(module))
|
||||
unittest.TextTestRunner(verbosity=2).run(test_suite)
|
||||
|
||||
if __name__=="__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='Run tests.')
|
||||
parser.add_argument('-d', '--doctype', nargs=1, metavar = "DOCTYPE",
|
||||
help="test for doctype")
|
||||
parser.add_argument('-v', '--verbose', default=False, action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
webnotes.print_messages = args.verbose
|
||||
|
||||
if args.doctype:
|
||||
run_unittest(args.doctype[0])
|
||||
else:
|
||||
run_all_tests(args.verbose)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -30,13 +30,10 @@ def send(recipients=None, sender=None, doctype='Profile', email_field='email',
|
|||
subject='[No Subject]', message='[No Content]'):
|
||||
"""send bulk mail if not unsubscribed and within conf.bulk_mail_limit"""
|
||||
import webnotes
|
||||
|
||||
if webnotes.mute_emails:
|
||||
return
|
||||
|
||||
|
||||
def is_unsubscribed(rdata):
|
||||
if not rdata: return 1
|
||||
return rdata[0]['unsubscribed']
|
||||
return rdata.unsubscribed
|
||||
|
||||
def check_bulk_limit(new_mails):
|
||||
import conf, startup
|
||||
|
|
@ -53,20 +50,22 @@ def send(recipients=None, sender=None, doctype='Profile', email_field='email',
|
|||
webnotes.msgprint("""Monthly Bulk Mail Limit (%s) Crossed""" % monthly_bulk_mail_limit,
|
||||
raise_exception=BulkLimitCrossedError)
|
||||
|
||||
def add_unsubscribe_link(email):
|
||||
def update_message(doc):
|
||||
from webnotes.utils import get_request_site_address
|
||||
import urllib
|
||||
return message + """<div style="padding: 7px; border-top: 1px solid #aaa;
|
||||
updated = message + """<div style="padding: 7px; border-top: 1px solid #aaa;
|
||||
margin-top: 17px;">
|
||||
<small><a href="%s/server.py?%s">
|
||||
Unsubscribe</a> from this list.</small></div>""" % (get_request_site_address(),
|
||||
urllib.urlencode({
|
||||
"cmd": "webnotes.utils.email_lib.bulk.unsubscribe",
|
||||
"email": email,
|
||||
"email": doc.get(email_field),
|
||||
"type": doctype,
|
||||
"email_field": email_field
|
||||
}))
|
||||
|
||||
|
||||
return updated
|
||||
|
||||
if not recipients: recipients = []
|
||||
if not sender or sender == "Administrator":
|
||||
sender = webnotes.conn.get_value('Email Settings', None, 'auto_mail_id')
|
||||
|
|
@ -85,9 +84,11 @@ def send(recipients=None, sender=None, doctype='Profile', email_field='email',
|
|||
rdata = webnotes.conn.sql("""select * from `tab%s` where %s=%s""" % (doctype,
|
||||
email_field, '%s'), r, as_dict=1)
|
||||
|
||||
if not is_unsubscribed(rdata):
|
||||
doc = rdata and rdata[0] or {}
|
||||
|
||||
if not is_unsubscribed(doc):
|
||||
# add to queue
|
||||
add(r, sender, subject, add_unsubscribe_link(r), text_content)
|
||||
add(r, sender, subject, update_message(doc), text_content)
|
||||
|
||||
def add(email, sender, subject, message, text_content = None):
|
||||
"""add to bulk mail queue"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue