Merge github.com:web2py/web2py
This commit is contained in:
+264
-137
@@ -447,7 +447,7 @@ def pluralize(singular, rules=PLURALIZE_RULES):
|
||||
if plural: return plural
|
||||
|
||||
def hide_password(uri):
|
||||
return REGEX_PASSWORD.sub('://******:',uri)
|
||||
return REGEX_NOPASSWD.sub('******',uri)
|
||||
|
||||
def OR(a,b):
|
||||
return a|b
|
||||
@@ -5565,7 +5565,8 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
subject string
|
||||
mime string The mime header declaration
|
||||
email string The complete RFC822 message**
|
||||
attachments list:string Each non text decoded part as string
|
||||
attachments <type list> Each non text part as dict
|
||||
encoding string The main detected encoding
|
||||
|
||||
*At the application side it is measured as the length of the RFC822
|
||||
message string
|
||||
@@ -5623,11 +5624,22 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
# mapped names (which native
|
||||
# mailbox has what table name)
|
||||
|
||||
db.mailboxes <dict> # tablename, server native name pairs
|
||||
imapdb.mailboxes <dict> # tablename, server native name pairs
|
||||
|
||||
# To retrieve a table native mailbox name use:
|
||||
db.<table>.mailbox
|
||||
imapdb.<table>.mailbox
|
||||
|
||||
### New features v2.4.1:
|
||||
|
||||
# Declare mailboxes statically with tablename, name pairs
|
||||
# This avoids the extra server names retrieval
|
||||
|
||||
imapdb.define_tables({"inbox": "INBOX"})
|
||||
|
||||
# Selects without content/attachments/email columns will only
|
||||
# fetch header and flags
|
||||
|
||||
imapdb(q).select(imapdb.INBOX.sender, imapdb.INBOX.subject)
|
||||
"""
|
||||
|
||||
types = {
|
||||
@@ -5670,6 +5682,7 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
self.driver_args = driver_args
|
||||
self.adapter_args = adapter_args
|
||||
self.mailbox_size = None
|
||||
self.static_names = None
|
||||
self.charset = sys.getfilesystemencoding()
|
||||
# imap class
|
||||
self.imap4 = None
|
||||
@@ -5829,16 +5842,22 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def header_represent(f, r):
|
||||
from email.header import decode_header
|
||||
text, encoding = decode_header(f)[0]
|
||||
return text
|
||||
|
||||
def encode_text(self, text, charset, errors="replace"):
|
||||
""" convert text for mail to unicode"""
|
||||
if text is None:
|
||||
text = ""
|
||||
else:
|
||||
if isinstance(text, str):
|
||||
if charset is not None:
|
||||
text = unicode(text, charset, errors)
|
||||
else:
|
||||
if charset is None:
|
||||
text = unicode(text, "utf-8", errors)
|
||||
else:
|
||||
text = unicode(text, charset, errors)
|
||||
else:
|
||||
raise Exception("Unsupported mail text type %s" % type(text))
|
||||
return text.encode("utf-8")
|
||||
@@ -5847,12 +5866,13 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
charset = message.get_content_charset()
|
||||
return charset
|
||||
|
||||
def reset_mailboxes(self):
|
||||
self.connection.mailbox_names = None
|
||||
self.get_mailboxes()
|
||||
|
||||
def get_mailboxes(self):
|
||||
""" Query the mail database for mailbox names """
|
||||
if self.static_names:
|
||||
# statically defined mailbox names
|
||||
self.connection.mailbox_names = self.static_names
|
||||
return self.static_names.keys()
|
||||
|
||||
mailboxes_list = self.connection.list()
|
||||
self.connection.mailbox_names = dict()
|
||||
mailboxes = list()
|
||||
@@ -5864,7 +5884,8 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
sub_items = item.split("\"")
|
||||
sub_items = [sub_item for sub_item in sub_items \
|
||||
if len(sub_item.strip()) > 0]
|
||||
mailbox = sub_items[len(sub_items) - 1]
|
||||
# mailbox = sub_items[len(sub_items) -1]
|
||||
mailbox = sub_items[-1]
|
||||
# remove unwanted characters and store original names
|
||||
# Don't allow leading non alphabetic characters
|
||||
mailbox_name = re.sub('^[_0-9]*', '', re.sub('[^_\w]','',re.sub('[/ ]','_',mailbox)))
|
||||
@@ -5896,7 +5917,7 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
else:
|
||||
return False
|
||||
|
||||
def define_tables(self):
|
||||
def define_tables(self, mailbox_names=None):
|
||||
"""
|
||||
Auto create common IMAP fileds
|
||||
|
||||
@@ -5908,11 +5929,18 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
Returns a dictionary with tablename, server native mailbox name
|
||||
pairs.
|
||||
"""
|
||||
if mailbox_names:
|
||||
# optional statically declared mailboxes
|
||||
self.static_names = mailbox_names
|
||||
else:
|
||||
self.static_names = None
|
||||
if not isinstance(self.connection.mailbox_names, dict):
|
||||
self.get_mailboxes()
|
||||
mailboxes = self.connection.mailbox_names.keys()
|
||||
for mailbox_name in mailboxes:
|
||||
self.db.define_table("%s" % mailbox_name,
|
||||
|
||||
names = self.connection.mailbox_names.keys()
|
||||
|
||||
for name in names:
|
||||
self.db.define_table("%s" % name,
|
||||
Field("uid", "string", writable=False),
|
||||
Field("answered", "boolean"),
|
||||
Field("created", "datetime", writable=False),
|
||||
@@ -5930,13 +5958,19 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
Field("subject", "string", writable=False),
|
||||
Field("mime", "string", writable=False),
|
||||
Field("email", "string", writable=False, readable=False),
|
||||
Field("attachments", "list:string", writable=False, readable=False),
|
||||
Field("attachments", list, writable=False, readable=False),
|
||||
Field("encoding")
|
||||
)
|
||||
|
||||
# Set a special _mailbox attribute for storing
|
||||
# native mailbox names
|
||||
self.db[mailbox_name].mailbox = \
|
||||
self.connection.mailbox_names[mailbox_name]
|
||||
self.db[name].mailbox = \
|
||||
self.connection.mailbox_names[name]
|
||||
|
||||
# decode quoted printable
|
||||
self.db[name].to.represent = self.db[name].cc.represent = \
|
||||
self.db[name].bcc.represent = self.db[name].sender.represent = \
|
||||
self.db[name].subject.represent = self.header_represent
|
||||
|
||||
# Set the db instance mailbox collections
|
||||
self.db.mailboxes = self.connection.mailbox_names
|
||||
@@ -5952,7 +5986,7 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
query = self.common_filter(query, [self.get_query_mailbox(query),])
|
||||
return str(query)
|
||||
|
||||
def select(self,query,fields,attributes):
|
||||
def select(self, query, fields, attributes):
|
||||
""" Search and Fetch records and return web2py rows
|
||||
"""
|
||||
# move this statement elsewhere (upper-level)
|
||||
@@ -5960,19 +5994,22 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
query = self.common_filter(query, [self.get_query_mailbox(query),])
|
||||
|
||||
import email
|
||||
import email.header
|
||||
decode_header = email.header.decode_header
|
||||
# get records from imap server with search + fetch
|
||||
# convert results to a dictionary
|
||||
tablename = None
|
||||
fetch_results = list()
|
||||
|
||||
if isinstance(query, Query):
|
||||
tablename = self.get_table(query)
|
||||
mailbox = self.connection.mailbox_names.get(tablename, None)
|
||||
if mailbox is not None:
|
||||
if mailbox is None:
|
||||
raise ValueError("Mailbox name not found: %s" % mailbox)
|
||||
else:
|
||||
# select with readonly
|
||||
selected = self.connection.select(mailbox, True)
|
||||
self.mailbox_size = int(selected[1][0])
|
||||
result, selected = self.connection.select(mailbox, True)
|
||||
if result != "OK":
|
||||
raise Exception("IMAP error: %s" % selected)
|
||||
self.mailbox_size = int(selected[0])
|
||||
search_query = "(%s)" % str(query).strip()
|
||||
search_result = self.connection.uid("search", None, search_query)
|
||||
# Normal IMAP response OK is assumed (change this)
|
||||
@@ -5981,18 +6018,22 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
# ten records (change for non-experimental implementation)
|
||||
# However, light responses are not guaranteed with this
|
||||
# approach, just fewer messages.
|
||||
# TODO: change limitby single to 2-tuple argument
|
||||
limitby = attributes.get('limitby', None)
|
||||
messages_set = search_result[1][0].split()
|
||||
# descending order
|
||||
messages_set.reverse()
|
||||
if limitby is not None:
|
||||
# TODO: asc/desc attributes
|
||||
# TODO: orderby, asc/desc, limitby from complete message set
|
||||
messages_set = messages_set[int(limitby[0]):int(limitby[1])]
|
||||
# Partial fetches are not used since the email
|
||||
# library does not seem to support it (it converts
|
||||
# partial messages to mangled message instances)
|
||||
imap_fields = "(RFC822)"
|
||||
|
||||
# keep the requests small for header/flags
|
||||
if any([(field.name in ["content",
|
||||
"attachments", "email"]) for
|
||||
field in fields]):
|
||||
imap_fields = "(RFC822 FLAGS)"
|
||||
else:
|
||||
imap_fields = "(RFC822.HEADER FLAGS)"
|
||||
|
||||
if len(messages_set) > 0:
|
||||
# create fetch results object list
|
||||
# fetch each remote message and store it in memmory
|
||||
@@ -6008,16 +6049,13 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
"raw_message": data[0][1]}
|
||||
fr["multipart"] = fr["email"].is_multipart()
|
||||
# fetch flags for the message
|
||||
ftyp, fdata = self.connection.uid("fetch", uid, "(FLAGS)")
|
||||
if ftyp == "OK":
|
||||
fr["flags"] = self.driver.ParseFlags(fdata[0])
|
||||
fetch_results.append(fr)
|
||||
else:
|
||||
# error retrieving the flags for this message
|
||||
pass
|
||||
fr["flags"] = self.driver.ParseFlags(data[1])
|
||||
fetch_results.append(fr)
|
||||
else:
|
||||
# error retrieving the message body
|
||||
pass
|
||||
raise Exception("IMAP error retrieving the body: %s" % data)
|
||||
else:
|
||||
raise Exception("IMAP search error: %s" % search_result[1])
|
||||
elif isinstance(query, (Expression, basestring)):
|
||||
raise NotImplementedError()
|
||||
else:
|
||||
@@ -6033,11 +6071,11 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
else:
|
||||
allfields = False
|
||||
if allfields:
|
||||
fieldnames = ["%s.%s" % (tablename, field) for field in self.search_fields.keys()]
|
||||
colnames = ["%s.%s" % (tablename, field) for field in self.search_fields.keys()]
|
||||
else:
|
||||
fieldnames = ["%s.%s" % (tablename, field.name) for field in fields]
|
||||
colnames = ["%s.%s" % (tablename, field.name) for field in fields]
|
||||
|
||||
for k in fieldnames:
|
||||
for k in colnames:
|
||||
imapfields_dict[k] = k
|
||||
|
||||
imapqry_list = list()
|
||||
@@ -6061,62 +6099,58 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
# pending: search flags states trough the email message
|
||||
# instances for correct output
|
||||
|
||||
if "%s.id" % tablename in fieldnames:
|
||||
# preserve subject encoding (ASCII/quoted printable)
|
||||
|
||||
if "%s.id" % tablename in colnames:
|
||||
item_dict["%s.id" % tablename] = n
|
||||
if "%s.created" % tablename in fieldnames:
|
||||
if "%s.created" % tablename in colnames:
|
||||
item_dict["%s.created" % tablename] = self.convert_date(message["Date"])
|
||||
if "%s.uid" % tablename in fieldnames:
|
||||
if "%s.uid" % tablename in colnames:
|
||||
item_dict["%s.uid" % tablename] = uid
|
||||
if "%s.sender" % tablename in fieldnames:
|
||||
if "%s.sender" % tablename in colnames:
|
||||
# If there is no encoding found in the message header
|
||||
# force utf-8 replacing characters (change this to
|
||||
# module's defaults). Applies to .sender, .to, .cc and .bcc fields
|
||||
#############################################################################
|
||||
# TODO: External function to manage encoding and decoding of message strings
|
||||
#############################################################################
|
||||
item_dict["%s.sender" % tablename] = self.encode_text(message["From"], charset)
|
||||
if "%s.to" % tablename in fieldnames:
|
||||
item_dict["%s.to" % tablename] = self.encode_text(message["To"], charset)
|
||||
if "%s.cc" % tablename in fieldnames:
|
||||
item_dict["%s.sender" % tablename] = message["From"]
|
||||
if "%s.to" % tablename in colnames:
|
||||
item_dict["%s.to" % tablename] = message["To"]
|
||||
if "%s.cc" % tablename in colnames:
|
||||
if "Cc" in message.keys():
|
||||
item_dict["%s.cc" % tablename] = self.encode_text(message["Cc"], charset)
|
||||
item_dict["%s.cc" % tablename] = message["Cc"]
|
||||
else:
|
||||
item_dict["%s.cc" % tablename] = ""
|
||||
if "%s.bcc" % tablename in fieldnames:
|
||||
if "%s.bcc" % tablename in colnames:
|
||||
if "Bcc" in message.keys():
|
||||
item_dict["%s.bcc" % tablename] = self.encode_text(message["Bcc"], charset)
|
||||
item_dict["%s.bcc" % tablename] = message["Bcc"]
|
||||
else:
|
||||
item_dict["%s.bcc" % tablename] = ""
|
||||
if "%s.deleted" % tablename in fieldnames:
|
||||
if "%s.deleted" % tablename in colnames:
|
||||
item_dict["%s.deleted" % tablename] = "\\Deleted" in flags
|
||||
if "%s.draft" % tablename in fieldnames:
|
||||
if "%s.draft" % tablename in colnames:
|
||||
item_dict["%s.draft" % tablename] = "\\Draft" in flags
|
||||
if "%s.flagged" % tablename in fieldnames:
|
||||
if "%s.flagged" % tablename in colnames:
|
||||
item_dict["%s.flagged" % tablename] = "\\Flagged" in flags
|
||||
if "%s.recent" % tablename in fieldnames:
|
||||
if "%s.recent" % tablename in colnames:
|
||||
item_dict["%s.recent" % tablename] = "\\Recent" in flags
|
||||
if "%s.seen" % tablename in fieldnames:
|
||||
if "%s.seen" % tablename in colnames:
|
||||
item_dict["%s.seen" % tablename] = "\\Seen" in flags
|
||||
if "%s.subject" % tablename in fieldnames:
|
||||
subject = message["Subject"]
|
||||
decoded_subject = decode_header(subject)
|
||||
text = decoded_subject[0][0]
|
||||
encoding = decoded_subject[0][1]
|
||||
if encoding in (None, ""):
|
||||
encoding = charset
|
||||
item_dict["%s.subject" % tablename] = self.encode_text(text, encoding)
|
||||
if "%s.answered" % tablename in fieldnames:
|
||||
if "%s.subject" % tablename in colnames:
|
||||
item_dict["%s.subject" % tablename] = message["Subject"]
|
||||
if "%s.answered" % tablename in colnames:
|
||||
item_dict["%s.answered" % tablename] = "\\Answered" in flags
|
||||
if "%s.mime" % tablename in fieldnames:
|
||||
if "%s.mime" % tablename in colnames:
|
||||
item_dict["%s.mime" % tablename] = message.get_content_type()
|
||||
if "%s.encoding" % tablename in colnames:
|
||||
item_dict["%s.encoding" % tablename] = charset
|
||||
|
||||
# Here goes the whole RFC822 body as an email instance
|
||||
# for controller side custom processing
|
||||
# The message is stored as a raw string
|
||||
# >> email.message_from_string(raw string)
|
||||
# returns a Message object for enhanced object processing
|
||||
if "%s.email" % tablename in fieldnames:
|
||||
item_dict["%s.email" % tablename] = self.encode_text(raw_message, charset)
|
||||
if "%s.email" % tablename in colnames:
|
||||
# WARNING: no encoding performed (raw message)
|
||||
item_dict["%s.email" % tablename] = raw_message
|
||||
|
||||
# Size measure as suggested in a Velocity Reviews post
|
||||
# by Tim Williams: "how to get size of email attachment"
|
||||
@@ -6124,18 +6158,31 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
# To retrieve the server size for representation would add a new
|
||||
# fetch transaction to the process
|
||||
for part in message.walk():
|
||||
if "%s.attachments" % tablename in fieldnames:
|
||||
if not "text" in part.get_content_maintype():
|
||||
attachments.append(part.get_payload(decode=True))
|
||||
if "%s.content" % tablename in fieldnames:
|
||||
if "text" in part.get_content_maintype():
|
||||
payload = self.encode_text(part.get_payload(decode=True), charset)
|
||||
content.append(payload)
|
||||
if "%s.size" % tablename in fieldnames:
|
||||
maintype = part.get_content_maintype()
|
||||
if ("%s.attachments" % tablename in colnames) or \
|
||||
("%s.content" % tablename in colnames):
|
||||
if "%s.attachments" % tablename in colnames:
|
||||
if not ("text" in maintype):
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
attachment = {
|
||||
"payload": payload,
|
||||
"filename": part.get_filename(),
|
||||
"encoding": part.get_content_charset(),
|
||||
"mime": part.get_content_type(),
|
||||
"disposition": part["Content-Disposition"]}
|
||||
attachments.append(attachment)
|
||||
if "%s.content" % tablename in colnames:
|
||||
payload = part.get_payload(decode=True)
|
||||
part_charset = self.get_charset(part)
|
||||
if "text" in maintype:
|
||||
if payload:
|
||||
content.append(self.encode_text(payload, part_charset))
|
||||
if "%s.size" % tablename in colnames:
|
||||
if part is not None:
|
||||
size += len(str(part))
|
||||
item_dict["%s.content" % tablename] = bar_encode(content)
|
||||
item_dict["%s.attachments" % tablename] = bar_encode(attachments)
|
||||
item_dict["%s.attachments" % tablename] = attachments
|
||||
item_dict["%s.size" % tablename] = size
|
||||
imapqry_list.append(item_dict)
|
||||
|
||||
@@ -6143,12 +6190,12 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
# creation (sends an array or lists)
|
||||
for item_dict in imapqry_list:
|
||||
imapqry_array_item = list()
|
||||
for fieldname in fieldnames:
|
||||
for fieldname in colnames:
|
||||
imapqry_array_item.append(item_dict[fieldname])
|
||||
imapqry_array.append(imapqry_array_item)
|
||||
|
||||
# parse result and return a rows object
|
||||
colnames = fieldnames
|
||||
colnames = colnames
|
||||
processor = attributes.get('processor',self.parse)
|
||||
return processor(imapqry_array, fields, colnames)
|
||||
|
||||
@@ -6193,6 +6240,8 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
result, data = self.connection.store(*command)
|
||||
if result == "OK":
|
||||
rowcount += 1
|
||||
else:
|
||||
raise Exception("IMAP storing error: %s" % data)
|
||||
return rowcount
|
||||
|
||||
def _count(self, query, distinct=None):
|
||||
@@ -6224,6 +6273,8 @@ class IMAPAdapter(NoSQLAdapter):
|
||||
result, data = self.connection.store(number, "+FLAGS", "(\\Deleted)")
|
||||
if result == "OK":
|
||||
counter += 1
|
||||
else:
|
||||
raise Exception("IMAP store error: %s" % data)
|
||||
if counter > 0:
|
||||
result, data = self.connection.expunge()
|
||||
return counter
|
||||
@@ -6662,6 +6713,89 @@ class Row(object):
|
||||
del d[k]
|
||||
return d
|
||||
|
||||
def as_xml(self, row_name="row", colnames=None, indent=' '):
|
||||
def f(row,field,indent=' '):
|
||||
if isinstance(row,Row):
|
||||
spc = indent+' \n'
|
||||
items = [f(row[x],x,indent+' ') for x in row]
|
||||
return '%s<%s>\n%s\n%s</%s>' % (
|
||||
indent,
|
||||
field,
|
||||
spc.join(item for item in items if item),
|
||||
indent,
|
||||
field)
|
||||
elif not callable(row):
|
||||
if REGEX_ALPHANUMERIC.match(field):
|
||||
return '%s<%s>%s</%s>' % (indent,field,row,field)
|
||||
else:
|
||||
return '%s<extra name="%s">%s</extra>' % \
|
||||
(indent,field,row)
|
||||
else:
|
||||
return None
|
||||
return f(self, row_name, indent=indent)
|
||||
|
||||
def as_json(self, mode="object", default=None, colnames=None,
|
||||
serialize=True, **kwargs):
|
||||
"""
|
||||
serializes the table to a JSON list of objects
|
||||
kwargs are passed to .as_dict method
|
||||
only "object" mode supported for single row
|
||||
|
||||
serialize = False used by Rows.as_json
|
||||
TODO: return array mode with query column order
|
||||
"""
|
||||
|
||||
def inner_loop(record, col):
|
||||
(t, f) = col.split('.')
|
||||
res = None
|
||||
if not REGEX_TABLE_DOT_FIELD.match(col):
|
||||
key = col
|
||||
res = record._extra[col]
|
||||
else:
|
||||
key = f
|
||||
if isinstance(record.get(t, None), Row):
|
||||
res = record[t][f]
|
||||
else:
|
||||
res = record[f]
|
||||
if mode == 'object':
|
||||
return (key, res)
|
||||
else:
|
||||
return res
|
||||
|
||||
multi = any([isinstance(v, self.__class__) for v in self.values()])
|
||||
mode = mode.lower()
|
||||
if not mode in ['object', 'array']:
|
||||
raise SyntaxError('Invalid JSON serialization mode: %s' % mode)
|
||||
|
||||
if mode=='object' and colnames:
|
||||
item = dict([inner_loop(self, col) for col in colnames])
|
||||
elif colnames:
|
||||
item = [inner_loop(self, col) for col in colnames]
|
||||
else:
|
||||
if not mode == 'object':
|
||||
raise SyntaxError('Invalid JSON serialization mode: %s' % mode)
|
||||
|
||||
if multi:
|
||||
item = dict()
|
||||
[item.update(**v.as_dict(**kwargs)) for v in self.values()]
|
||||
else:
|
||||
item = self.as_dict(**kwargs)
|
||||
|
||||
if serialize:
|
||||
if have_serializers:
|
||||
return serializers.json(item,
|
||||
default=default or
|
||||
serializers.custom_json)
|
||||
else:
|
||||
try:
|
||||
import json as simplejson
|
||||
except ImportError:
|
||||
import gluon.contrib.simplejson as simplejson
|
||||
return simplejson.dumps(item)
|
||||
else:
|
||||
return item
|
||||
|
||||
|
||||
################################################################################
|
||||
# Everything below should be independent of the specifics of the database
|
||||
# and should work for RDBMs and some NoSQL databases
|
||||
@@ -6857,7 +6991,7 @@ class DAL(object):
|
||||
for db in db_group:
|
||||
if not db._uri:
|
||||
continue
|
||||
k = REGEX_NOPASSWD.sub('******',db._uri)
|
||||
k = hide_password(db._uri)
|
||||
infos[k] = dict(dbstats = [(row[0], row[1]) for row in db._timings],
|
||||
dbtables = {'defined':
|
||||
sorted(list(set(db.tables) -
|
||||
@@ -7163,6 +7297,8 @@ def index():
|
||||
i = 0
|
||||
while i<len(patterns):
|
||||
pattern = patterns[i]
|
||||
if not isinstance(pattern,str):
|
||||
pattern = pattern[0]
|
||||
tokens = pattern.split('/')
|
||||
if tokens[-1].startswith(':auto') and re2.match(tokens[-1]):
|
||||
new_patterns = auto_table(tokens[-1][tokens[-1].find('[')+1:-1],
|
||||
@@ -7239,15 +7375,21 @@ def index():
|
||||
ref = tag[tag.find('[')+1:-1]
|
||||
if '.' in ref and otable:
|
||||
table,field = ref.split('.')
|
||||
# print table,field
|
||||
selfld = '_id'
|
||||
if db[table][field].type.startswith('reference '):
|
||||
refs = [ x.name for x in db[otable] if x.type == db[table][field].type ]
|
||||
else:
|
||||
refs = [ x.name for x in db[table]._referenced_by if x.tablename==otable ]
|
||||
if refs:
|
||||
selfld = refs[0]
|
||||
if nested_select:
|
||||
try:
|
||||
dbset=db(db[table][field].belongs(dbset._select(db[otable]._id)))
|
||||
dbset=db(db[table][field].belongs(dbset._select(db[otable][selfld])))
|
||||
except ValueError:
|
||||
return Row({'status':400,'pattern':pattern,
|
||||
'error':'invalid path','response':None})
|
||||
else:
|
||||
items = [item.id for item in dbset.select(db[otable]._id)]
|
||||
items = [item.id for item in dbset.select(db[otable][selfld])]
|
||||
dbset=db(db[table][field].belongs(items))
|
||||
else:
|
||||
table = ref
|
||||
@@ -8062,7 +8204,7 @@ class Table(object):
|
||||
return fields
|
||||
|
||||
def _insert(self, **fields):
|
||||
fields = self._default(fields)
|
||||
fields = self._defaults(fields)
|
||||
return self._db._adapter._insert(self, self._listify(fields))
|
||||
|
||||
def insert(self, **fields):
|
||||
@@ -9461,6 +9603,22 @@ class Rows(object):
|
||||
:param storage_to_dict: when True returns a dict, otherwise a list(default True)
|
||||
:param datetime_to_str: convert datetime fields as strings (default True)
|
||||
"""
|
||||
|
||||
# test for multiple rows
|
||||
multi = False
|
||||
f = self.first()
|
||||
if f:
|
||||
multi = any([isinstance(v, f.__class__) for v in f.values()])
|
||||
if (not "." in key) and multi:
|
||||
# No key provided, default to int indices
|
||||
def new_key():
|
||||
i = 0
|
||||
while True:
|
||||
yield i
|
||||
i += 1
|
||||
key_generator = new_key()
|
||||
key = lambda r: key_generator.next()
|
||||
|
||||
rows = self.as_list(compact, storage_to_dict, datetime_to_str, custom_types)
|
||||
if isinstance(key,str) and key.count('.')==1:
|
||||
(table, field) = key.split('.')
|
||||
@@ -9537,64 +9695,30 @@ class Rows(object):
|
||||
"""
|
||||
serializes the table using sqlhtml.SQLTABLE (if present)
|
||||
"""
|
||||
|
||||
if strict:
|
||||
ncols = len(self.colnames)
|
||||
def f(row,field,indent=' '):
|
||||
if isinstance(row,Row):
|
||||
spc = indent+' \n'
|
||||
items = [f(row[x],x,indent+' ') for x in row]
|
||||
return '%s<%s>\n%s\n%s</%s>' % (
|
||||
indent,
|
||||
field,
|
||||
spc.join(item for item in items if item),
|
||||
indent,
|
||||
field)
|
||||
elif not callable(row):
|
||||
if REGEX_ALPHANUMERIC.match(field):
|
||||
return '%s<%s>%s</%s>' % (indent,field,row,field)
|
||||
else:
|
||||
return '%s<extra name="%s">%s</extra>' % \
|
||||
(indent,field,row)
|
||||
else:
|
||||
return None
|
||||
return '<%s>\n%s\n</%s>' % (
|
||||
rows_name,
|
||||
'\n'.join(f(row,row_name) for row in self),
|
||||
rows_name)
|
||||
return '<%s>\n%s\n</%s>' % (rows_name,
|
||||
'\n'.join(row.as_xml(row_name=row_name,
|
||||
colnames=self.colnames) for
|
||||
row in self), rows_name)
|
||||
|
||||
import sqlhtml
|
||||
return sqlhtml.SQLTABLE(self).xml()
|
||||
|
||||
def json(self, mode='object', default=None):
|
||||
def as_xml(self,row_name='row',rows_name='rows'):
|
||||
return self.xml(strict=True, row_name=row_name, rows_name=rows_name)
|
||||
|
||||
def as_json(self, mode='object', default=None):
|
||||
"""
|
||||
serializes the table to a JSON list of objects
|
||||
"""
|
||||
mode = mode.lower()
|
||||
if not mode in ['object', 'array']:
|
||||
raise SyntaxError('Invalid JSON serialization mode: %s' % mode)
|
||||
|
||||
def inner_loop(record, col):
|
||||
(t, f) = col.split('.')
|
||||
res = None
|
||||
if not REGEX_TABLE_DOT_FIELD.match(col):
|
||||
key = col
|
||||
res = record._extra[col]
|
||||
else:
|
||||
key = f
|
||||
if isinstance(record.get(t, None), Row):
|
||||
res = record[t][f]
|
||||
else:
|
||||
res = record[f]
|
||||
if mode == 'object':
|
||||
return (key, res)
|
||||
else:
|
||||
return res
|
||||
items = [record.as_json(mode=mode, default=default,
|
||||
serialize=False,
|
||||
colnames=self.colnames) for
|
||||
record in self]
|
||||
|
||||
if mode == 'object':
|
||||
items = [dict([inner_loop(record, col) for col in
|
||||
self.colnames]) for record in self]
|
||||
else:
|
||||
items = [[inner_loop(record, col) for col in self.colnames]
|
||||
for record in self]
|
||||
if have_serializers:
|
||||
return serializers.json(items,
|
||||
default=default or
|
||||
@@ -9606,6 +9730,9 @@ class Rows(object):
|
||||
import gluon.contrib.simplejson as simplejson
|
||||
return simplejson.dumps(items)
|
||||
|
||||
# for consistent naming yet backwards compatible
|
||||
as_csv = __str__
|
||||
json = as_json
|
||||
|
||||
################################################################################
|
||||
# dummy function used to define some doctests
|
||||
|
||||
+4
-2
@@ -375,7 +375,7 @@ class Response(Storage):
|
||||
wrapped = streamer(stream, chunk_size=chunk_size)
|
||||
return wrapped
|
||||
|
||||
def download(self, request, db, chunk_size=DEFAULT_CHUNK_SIZE, attachment=True):
|
||||
def download(self, request, db, chunk_size=DEFAULT_CHUNK_SIZE, attachment=True, download_filename=None):
|
||||
"""
|
||||
example of usage in controller::
|
||||
|
||||
@@ -403,9 +403,11 @@ class Response(Storage):
|
||||
raise HTTP(404)
|
||||
headers = self.headers
|
||||
headers['Content-Type'] = contenttype(name)
|
||||
if download_filename == None:
|
||||
download_filename = filename
|
||||
if attachment:
|
||||
headers['Content-Disposition'] = \
|
||||
'attachment; filename="%s"' % filename.replace('"','\"')
|
||||
'attachment; filename="%s"' % download_filename.replace('"','\"')
|
||||
return self.stream(stream, chunk_size=chunk_size, request=request)
|
||||
|
||||
def json(self, data, default=None):
|
||||
|
||||
+3
-2
@@ -556,7 +556,7 @@ class Scheduler(MetaScheduler):
|
||||
self.die()
|
||||
|
||||
def wrapped_assign_tasks(self, db):
|
||||
db.commit() # ?don't know if it's useful, let's be completely sure
|
||||
db.commit() #db.commit() only for Mysql
|
||||
x = 0
|
||||
while x < 10:
|
||||
try:
|
||||
@@ -571,6 +571,7 @@ class Scheduler(MetaScheduler):
|
||||
|
||||
def wrapped_pop_task(self):
|
||||
db = self.db
|
||||
db.commit() #another nifty db.commit() only for Mysql
|
||||
x = 0
|
||||
while x < 10:
|
||||
try:
|
||||
@@ -579,7 +580,7 @@ class Scheduler(MetaScheduler):
|
||||
break
|
||||
except:
|
||||
db.rollback()
|
||||
logger.error('TICKER(%s): error popping tasks', self.worker_name)
|
||||
logger.error('%s: error popping tasks', self.worker_name)
|
||||
x += 1
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
+25
-7
@@ -1623,7 +1623,7 @@ class SQLFORM(FORM):
|
||||
'integer': ['=', '!=', '<', '>', '<=', '>=', 'in', 'not in'],
|
||||
'double': ['=', '!=', '<', '>', '<=', '>='],
|
||||
'id': ['=', '!=', '<', '>', '<=', '>=', 'in', 'not in'],
|
||||
'reference': ['=', '!=', '<', '>', '<=', '>=', 'in', 'not in'],
|
||||
'reference': ['=', '!='],
|
||||
'boolean': ['=', '!=']}
|
||||
if fields[0]._db._adapter.dbengine == 'google:datastore':
|
||||
search_options['string'] = ['=', '!=', '<', '>', '<=', '>=']
|
||||
@@ -1641,15 +1641,33 @@ class SQLFORM(FORM):
|
||||
field.label, str) and T(field.label) or field.label
|
||||
selectfields.append(OPTION(label, _value=str(field)))
|
||||
operators = SELECT(*[OPTION(T(option), _value=option) for option in options])
|
||||
_id = "%s_%s" % (value_id,name)
|
||||
if field.type == 'boolean':
|
||||
value_input = SQLFORM.widgets.boolean.widget(field,field.default,_id=_id)
|
||||
elif field.type == 'double':
|
||||
value_input = SQLFORM.widgets.double.widget(field,field.default,_id=_id)
|
||||
elif field.type == 'time':
|
||||
value_input = SQLFORM.widgets.time.widget(field,field.default,_id=_id)
|
||||
elif field.type == 'date':
|
||||
value_input = SQLFORM.widgets.date.widget(field,field.default,_id=_id)
|
||||
elif field.type == 'datetime':
|
||||
value_input = SQLFORM.widgets.datetime.widget(field,field.default,_id=_id)
|
||||
elif (field.type.startswith('reference ') or
|
||||
field.type.startswith('list:reference ')) and \
|
||||
hasattr(field.requires,'options'):
|
||||
value_input = SELECT(
|
||||
OPTION(T("True"), _value="T"),
|
||||
OPTION(T("False"), _value="F"),
|
||||
_id="%s_%s" % (value_id,name))
|
||||
*[OPTION(v, _value=k)
|
||||
for k,v in field.requires.options()],
|
||||
**dict(_id=_id))
|
||||
elif field.type == 'integer' or \
|
||||
field.type.startswith('reference ') or \
|
||||
field.type.startswith('list:integer') or \
|
||||
field.type.startswith('list:reference '):
|
||||
value_input = SQLFORM.widgets.integer.widget(field,field.default,_id=_id)
|
||||
else:
|
||||
value_input = INPUT(_type='text',
|
||||
_id="%s_%s" % (value_id,name),
|
||||
_class=field.type)
|
||||
value_input = INPUT(
|
||||
_type='text', _id=_id, _class=field.type)
|
||||
|
||||
new_button = INPUT(
|
||||
_type="button", _value=T('New'), _class="btn",
|
||||
_onclick="%s_build_query('new','%s')" % (prefix,field))
|
||||
|
||||
+53
-46
@@ -1256,6 +1256,9 @@ class Auth(object):
|
||||
def navbar(self, prefix='Welcome', action=None,
|
||||
separators=(' [ ', ' | ', ' ] '), user_identifier=DEFAULT,
|
||||
referrer_actions=DEFAULT, mode='default'):
|
||||
def Anr(*a,**b):
|
||||
b['_rel']='nofollow'
|
||||
return A(*a,**b)
|
||||
referrer_actions = [] if not referrer_actions else referrer_actions
|
||||
request = current.request
|
||||
asdropdown = (mode == 'dropdown')
|
||||
@@ -1286,19 +1289,19 @@ class Auth(object):
|
||||
user_identifier = user_identifier % self.user
|
||||
if not user_identifier:
|
||||
user_identifier = ''
|
||||
logout = A(T('Logout'), _href='%s/logout?_next=%s' %
|
||||
logout = Anr(T('Logout'), _href='%s/logout?_next=%s' %
|
||||
(action, urllib.quote(self.settings.logout_next)))
|
||||
profile = A(T('Profile'), _href=href('profile'))
|
||||
password = A(T('Password'), _href=href('change_password'))
|
||||
profile = Anr(T('Profile'), _href=href('profile'))
|
||||
password = Anr(T('Password'), _href=href('change_password'))
|
||||
bar = SPAN(
|
||||
prefix, user_identifier, s1, logout, s3, _class='auth_navbar')
|
||||
|
||||
if asdropdown:
|
||||
logout = LI(A(I(_class='icon-off'), ' ' + T('Logout'), _href='%s/logout?_next=%s' %
|
||||
logout = LI(Anr(I(_class='icon-off'), ' ' + T('Logout'), _href='%s/logout?_next=%s' %
|
||||
(action, urllib.quote(self.settings.logout_next)))) # the space before T('Logout') is intentional. It creates a gap between icon and text
|
||||
profile = LI(A(I(_class='icon-user'), ' ' +
|
||||
profile = LI(Anr(I(_class='icon-user'), ' ' +
|
||||
T('Profile'), _href=href('profile')))
|
||||
password = LI(A(I(_class='icon-lock'), ' ' +
|
||||
password = LI(Anr(I(_class='icon-lock'), ' ' +
|
||||
T('Password'), _href=href('change_password')))
|
||||
bar = UL(logout, _class='dropdown-menu')
|
||||
# logout will be the last item in list
|
||||
@@ -1312,21 +1315,21 @@ class Auth(object):
|
||||
bar.insert(-1, s2)
|
||||
bar.insert(-1, password)
|
||||
else:
|
||||
login = A(T('Login'), _href=href('login'))
|
||||
register = A(T('Register'), _href=href('register'))
|
||||
retrieve_username = A(
|
||||
login = Anr(T('Login'), _href=href('login'))
|
||||
register = Anr(T('Register'), _href=href('register'))
|
||||
retrieve_username = Anr(
|
||||
T('Forgot username?'), _href=href('retrieve_username'))
|
||||
lost_password = A(
|
||||
lost_password = Anr(
|
||||
T('Lost password?'), _href=href('request_reset_password'))
|
||||
bar = SPAN(s1, login, s3, _class='auth_navbar')
|
||||
|
||||
if asdropdown:
|
||||
login = LI(A(I(_class='icon-off'), ' ' + T('Login'), _href=href('login'))) # the space before T('Login') is intentional. It creates a gap between icon and text
|
||||
register = LI(A(I(_class='icon-user'),
|
||||
login = LI(Anr(I(_class='icon-off'), ' ' + T('Login'), _href=href('login'))) # the space before T('Login') is intentional. It creates a gap between icon and text
|
||||
register = LI(Anr(I(_class='icon-user'),
|
||||
' ' + T('Register'), _href=href('register')))
|
||||
retrieve_username = LI(A(I(_class='icon-edit'), ' ' + T(
|
||||
retrieve_username = LI(Anr(I(_class='icon-edit'), ' ' + T(
|
||||
'Forgot username?'), _href=href('retrieve_username')))
|
||||
lost_password = LI(A(I(_class='icon-lock'), ' ' + T(
|
||||
lost_password = LI(Anr(I(_class='icon-lock'), ' ' + T(
|
||||
'Lost password?'), _href=href('request_reset_password')))
|
||||
bar = UL(login, _class='dropdown-menu')
|
||||
# login will be the last item in list
|
||||
@@ -1349,10 +1352,10 @@ class Auth(object):
|
||||
if asdropdown:
|
||||
bar.insert(-1, LI('', _class='divider'))
|
||||
if self.user_id:
|
||||
bar = LI(A(prefix, user_identifier, _href='#'),
|
||||
bar = LI(Anr(prefix, user_identifier, _href='#'),
|
||||
bar, _class='dropdown')
|
||||
else:
|
||||
bar = LI(A(T('Login'), _href='#'),
|
||||
bar = LI(Anr(T('Login'), _href='#'),
|
||||
bar, _class='dropdown')
|
||||
return bar
|
||||
|
||||
@@ -4761,7 +4764,7 @@ class Wiki(object):
|
||||
Field('slug',
|
||||
requires=[IS_SLUG(),
|
||||
IS_NOT_IN_DB(db, 'wiki_page.slug')],
|
||||
readable=False, writable=False),
|
||||
writable=False),
|
||||
Field('title', unique=True),
|
||||
Field('body', 'text', notnull=True),
|
||||
Field('tags', 'list:string'),
|
||||
@@ -4970,7 +4973,7 @@ class Wiki(object):
|
||||
slug.startswith(self.force_prefix)):
|
||||
current.session.flash = 'slug must have "%s" prefix' \
|
||||
% self.force_prefix
|
||||
redirect(URL(args=('_edit', self.force_prefix + slug)))
|
||||
redirect(URL(args=('_create')))
|
||||
db.wiki_page.can_read.default = [Wiki.everybody]
|
||||
db.wiki_page.can_edit.default = [auth.user_group_role()]
|
||||
db.wiki_page.title.default = title_guess
|
||||
@@ -4978,8 +4981,8 @@ class Wiki(object):
|
||||
if slug == 'wiki-menu':
|
||||
db.wiki_page.body.default = \
|
||||
'- Menu Item > @////index\n- - Submenu > http://web2py.com'
|
||||
else:
|
||||
db.wiki_page.body.default = db(db.wiki_page.id==from_template).select(db.wiki_page.body)[0].body if int(from_template) > 0 else '## %s\n\npage content' % title_guess
|
||||
#else:
|
||||
# db.wiki_page.body.default = db(db.wiki_page.id==from_template).select(db.wiki_page.body)[0].body if int(from_template) > 0 else '## %s\n\npage content' % title_guess
|
||||
vars = current.request.post_vars
|
||||
if vars.body:
|
||||
vars.body = vars.body.replace('://%s' % self.host, '://HOSTNAME')
|
||||
@@ -4993,20 +4996,19 @@ class Wiki(object):
|
||||
redirect(URL(args=slug))
|
||||
script = """
|
||||
$(function() {
|
||||
if (!$('#wiki_page_body').length) return;
|
||||
var pagecontent = $('#wiki_page_body');
|
||||
if (!jQuery('#wiki_page_body').length) return;
|
||||
var pagecontent = jQuery('#wiki_page_body');
|
||||
pagecontent.css('font-family',
|
||||
'Monaco,Menlo,Consolas,"Courier New",monospace');
|
||||
var prevbutton = $('<button class="btn nopreview">Preview</button>');
|
||||
var mediabutton = $('<button class="btn nopreview">Media</button>');
|
||||
var preview = $('<div id="preview"></div>').hide();
|
||||
var previewmedia = $('<div id="previewmedia"></div>');
|
||||
var table = $('form');
|
||||
var bodylabel = $('#wiki_page_body__label');
|
||||
preview.insertBefore(pagecontent);
|
||||
prevbutton.insertAfter(bodylabel);
|
||||
mediabutton.insertBefore(table);
|
||||
previewmedia.insertBefore(table);
|
||||
var prevbutton = jQuery('<button class="btn nopreview">Preview</button>');
|
||||
var mediabutton = jQuery('<button class="btn nopreview">Media</button>');
|
||||
var preview = jQuery('<div id="preview"></div>').hide();
|
||||
var previewmedia = jQuery('<div id="previewmedia"></div>');
|
||||
var form = pagecontent.closest('form');
|
||||
preview.insertBefore(form);
|
||||
prevbutton.insertBefore(form);
|
||||
mediabutton.insertBefore(form);
|
||||
previewmedia.insertBefore(form);
|
||||
mediabutton.toggle(function() {
|
||||
web2py_component('%(urlmedia)s', 'previewmedia');
|
||||
}, function() {
|
||||
@@ -5017,12 +5019,12 @@ class Wiki(object):
|
||||
if (prevbutton.hasClass('nopreview')) {
|
||||
prevbutton.addClass('preview').removeClass(
|
||||
'nopreview').html('Edit Source');
|
||||
web2py_ajax_page('post', '%(url)s', {body : $('#wiki_page_body').val()}, 'preview');
|
||||
pagecontent.fadeOut('fast', function() {preview.fadeIn()});
|
||||
web2py_ajax_page('post', '%(url)s', {body : jQuery('#wiki_page_body').val()}, 'preview');
|
||||
form.fadeOut('fast', function() {preview.fadeIn()});
|
||||
} else {
|
||||
prevbutton.addClass(
|
||||
'nopreview').removeClass('preview').html('Preview');
|
||||
preview.fadeOut('fast', function() {pagecontent.fadeIn()});
|
||||
preview.fadeOut('fast', function() {form.fadeIn()});
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -5046,7 +5048,7 @@ class Wiki(object):
|
||||
csv = True
|
||||
create = True
|
||||
if current.request.vars.embedded:
|
||||
script = "var c = $('#wiki_page_body'); c.val(c.val() + $('%s').text()); return false;"
|
||||
script = "var c = jQuery('#wiki_page_body'); c.val(c.val() + jQuery('%s').text()); return false;"
|
||||
fragment = self.auth.db.wiki_media.id.represent
|
||||
csv = False
|
||||
create = False
|
||||
@@ -5071,13 +5073,13 @@ class Wiki(object):
|
||||
slugs=db(db.wiki_page.id>0).select(db.wiki_page.id,db.wiki_page.slug)
|
||||
options=[OPTION(row.slug,_value=row.id) for row in slugs]
|
||||
options.insert(0, OPTION('',_value=''))
|
||||
form = SQLFORM.factory(Field("slug", default=current.request.args(1),
|
||||
form = SQLFORM.factory(Field("slug", default=current.request.args(1) or self.force_prefix,
|
||||
requires=(IS_SLUG(),
|
||||
IS_NOT_IN_DB(db,db.wiki_page.slug))),
|
||||
Field("from_template", "reference wiki_page",
|
||||
requires=IS_EMPTY_OR(IS_IN_DB(db, db.wiki_page, '%(slug)s')),
|
||||
comment=current.T("Choose Template or empty for new Page")),
|
||||
_class="well span6")
|
||||
#Field("from_template", "reference wiki_page",
|
||||
# requires=IS_EMPTY_OR(IS_IN_DB(db, db.wiki_page, '%(slug)s')),
|
||||
# comment=current.T("Choose Template or empty for new Page")),
|
||||
_class="well span6")
|
||||
form.element("[type=submit]").attributes["_value"] = current.T("Create Page from Slug")
|
||||
|
||||
if form.process().accepted:
|
||||
@@ -5088,21 +5090,26 @@ class Wiki(object):
|
||||
def pages(self):
|
||||
if not self.can_manage():
|
||||
return self.not_authorized()
|
||||
self.auth.db.wiki_page.id.represent = lambda id, row: SPAN(
|
||||
'@////%s' % row.slug)
|
||||
self.auth.db.wiki_page.slug.represent = lambda slug, row: SPAN(
|
||||
'@////%s' % slug)
|
||||
self.auth.db.wiki_page.title.represent = lambda title, row: \
|
||||
A(title, _href=URL(args=row.slug))
|
||||
wiki_table = self.auth.db.wiki_page
|
||||
content = SQLFORM.grid(
|
||||
self.auth.db.wiki_page,
|
||||
wiki_table,
|
||||
fields = [wiki_table.slug,
|
||||
wiki_table.title, wiki_table.tags,
|
||||
wiki_table.can_read, wiki_table.can_edit],
|
||||
links=[
|
||||
lambda row:
|
||||
A('edit', _href=URL(args=('_edit', row.slug))),
|
||||
A('edit', _href=URL(args=('_edit', row.slug)),_class='btn'),
|
||||
lambda row:
|
||||
A('media', _href=URL(args=('_editmedia', row.slug)))],
|
||||
A('media', _href=URL(args=('_editmedia', row.slug)),_class='btn')],
|
||||
details=False, editable=False, deletable=False, create=False,
|
||||
orderby=self.auth.db.wiki_page.title,
|
||||
args=['_pages'],
|
||||
user_signature=False)
|
||||
|
||||
return dict(content=content)
|
||||
|
||||
def media(self, id):
|
||||
|
||||
+1
-2
@@ -510,8 +510,7 @@ class web2pyDialog(object):
|
||||
|
||||
if not options.taskbar:
|
||||
thread.start_new_thread(start_browser,
|
||||
(get_url(ip, proto=proto, port=port),),
|
||||
dict(startup=True))
|
||||
(get_url(ip, proto=proto, port=port), True))
|
||||
|
||||
self.password.configure(state='readonly')
|
||||
[ip.configure(state='disabled') for ip in self.ips.values()]
|
||||
|
||||
Reference in New Issue
Block a user