diff --git a/.gitignore b/.gitignore index 16597177..95b4e69b 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ HOWTO-web2py-devel *.sublime-workspace .idea/* site-packages/ +logs/ diff --git a/.travis.yml b/.travis.yml index 99bed1e8..bcc9ba01 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,6 +18,10 @@ before_script: - if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install --download-cache $HOME/.pip-cache unittest2; fi - if [[ $TRAVIS_PYTHON_VERSION == '2.7' ]]; then pip install --download-cache $HOME/.pip-cache coverage; fi; - if [[ $TRAVIS_PYTHON_VERSION == '2.7' ]]; then pip install --download-cache $HOME/.pip-cache codecov; fi + - mysql -e 'create database pydal;' + - psql -c 'create database pydal;' -U postgres + - psql -c 'create extension postgis;' -U postgres -d pydal; + - psql -c 'SHOW SERVER_VERSION' -U postgres script: export COVERAGE_PROCESS_START=gluon/tests/coverage.ini; ./web2py.py --run_system_tests --with_coverage @@ -28,3 +32,6 @@ after_success: notifications: email: true + +addons: + postgresql: "9.4" diff --git a/CHANGELOG b/CHANGELOG index a1efbb0a..f684e667 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,65 @@ +## trunk +- new JWT implementation (experimental) +- new gluon.contrib.redis_scheduler +- BREAKING: changes to gluon.contrib.redis_cache + BEFORE: + from gluon.contrib.redis_cache import RedisCache + cache.redis = RedisCache('localhost:6379',db=None, debug=True) + NOW: + from gluon.contrib.redis_utils import RConn + from gluon.contrib.redis_cache import RedisCache + rconn = RConn() + # or RConn(host='localhost', port=6379, + # db=0, password=None, socket_timeout=None, + # socket_connect_timeout=None, .....) + # exactly as a redis.StrictRedis instance + cache.redis = RedisCache(redis_conn=rconn, debug=True) +- BREAKING: changes to gluon.contrib.redis_session + BEFORE: + from gluon.contrib.redis_session import RedisSession + sessiondb = RedisSession('localhost:6379',db=0, session_expiry=False) + session.connect(request, response, db = sessiondb) + NOW: + from gluon.contrib.redis_utils import RConn + from gluon.contrib.redis_session import RedisSession + rconn = RConn() + sessiondb = RedisSession(redis_conn=rconn, session_expiry=False) + session.connect(request, response, db = sessiondb) + + +## 2.13.1-2 + +- fixed a security issue in request_reset_password +- added fabfile.py +- fixed oauth2 renew token, thanks dokime7 +- fixed add_membership, del_membership, add_membership IntegrityError (when auth.enable_record_versioning) +- allow passing unicode to template render +- allow IS_NOT_IN_DB to work with custom primarykey, thanks timmyborg +- allow HttpOnly cookies +- french pluralizaiton rules, thanks Mathieu Clabaut +- fixed bug in redirect to cas service, thanks Fernando González +- allow deploying to pythonanywhere from the web2py admin that you're running locally, thanks Leonel +- better tests +- many more bug fixes + +## 2.12.1-3 + +- security fix: Validate for open redirect everywhere, not just in login() +- allow to pack invidual apps and selected files as packed exe files +- allow bulk user registration with default bulk_register_enabled=False +- allow unsorted multiword query in grid search +- better MongoDB support with newer pyDAL +- enable /appadmin/manage/auth by default for user admin +- allow mail.settings.server='logging:filename' to log emails to a file +- better caching logic +- fixed order of confirm-password field +- TLS support in ldap +- prettydate can do UTC +- jquery 1.11.3 +- bootstrap 3.3.5 +- moved to codecov and enabled appveyor +- many bug fixes + ## 2.11.1 - Many small but significative improvements and bug fixes diff --git a/Makefile b/Makefile index e16e0967..c1c52395 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ clean: find ./ -name '*.rej' -exec rm -f {} \; find ./ -name '#*' -exec rm -f {} \; find ./ -name 'Thumbs.db' -exec rm -f {} \; - find ./gluon/ -name '.*' -exec rm -f {} \; + # find ./gluon/ -name '.*' -exec rm -f {} \; find ./gluon/ -name '*class' -exec rm -f {} \; find ./applications/admin/ -name '.*' -exec rm -f {} \; find ./applications/examples/ -name '.*' -exec rm -f {} \; @@ -32,7 +32,7 @@ update: echo "remember that pymysql was tweaked" src: ### Use semantic versioning - echo 'Version 2.11.2-stable+timestamp.'`date +%Y.%m.%d.%H.%M.%S` > VERSION + echo 'Version 2.13.4-stable+timestamp.'`date +%Y.%m.%d.%H.%M.%S` > VERSION ### rm -f all junk files make clean ### clean up baisc apps diff --git a/VERSION b/VERSION index 4b49a8a7..877e5261 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -Version 2.11.2-stable+timestamp.2015.05.30.11.29.46 +Version 2.13.3-stable+timestamp.2015.12.24.08.08.22 diff --git a/applications/admin/controllers/appadmin.py b/applications/admin/controllers/appadmin.py index 4d208944..2f780749 100644 --- a/applications/admin/controllers/appadmin.py +++ b/applications/admin/controllers/appadmin.py @@ -445,30 +445,31 @@ def ccache(): gae_stats['oldest'] = GetInHMS(time.time() - gae_stats['oldest_item_age']) total.update(gae_stats) else: + # get ram stats directly from the cache object + ram_stats = cache.ram.stats[request.application] + ram['hits'] = ram_stats['hit_total'] - ram_stats['misses'] + ram['misses'] = ram_stats['misses'] + try: + ram['ratio'] = ram['hits'] * 100 / ram_stats['hit_total'] + except (KeyError, ZeroDivisionError): + ram['ratio'] = 0 + for key, value in cache.ram.storage.iteritems(): - if isinstance(value, dict): - ram['hits'] = value['hit_total'] - value['misses'] - ram['misses'] = value['misses'] - try: - ram['ratio'] = ram['hits'] * 100 / value['hit_total'] - except (KeyError, ZeroDivisionError): - ram['ratio'] = 0 - else: - if hp: - ram['bytes'] += hp.iso(value[1]).size - ram['objects'] += hp.iso(value[1]).count - ram['entries'] += 1 - if value[0] < ram['oldest']: - ram['oldest'] = value[0] - ram['keys'].append((key, GetInHMS(time.time() - value[0]))) + if hp: + ram['bytes'] += hp.iso(value[1]).size + ram['objects'] += hp.iso(value[1]).count + ram['entries'] += 1 + if value[0] < ram['oldest']: + ram['oldest'] = value[0] + ram['keys'].append((key, GetInHMS(time.time() - value[0]))) for key in cache.disk.storage: value = cache.disk.storage[key] - if isinstance(value, dict): - disk['hits'] = value['hit_total'] - value['misses'] - disk['misses'] = value['misses'] + if isinstance(value[1], dict): + disk['hits'] = value[1]['hit_total'] - value[1]['misses'] + disk['misses'] = value[1]['misses'] try: - disk['ratio'] = disk['hits'] * 100 / value['hit_total'] + disk['ratio'] = disk['hits'] * 100 / value[1]['hit_total'] except (KeyError, ZeroDivisionError): disk['ratio'] = 0 else: @@ -485,7 +486,7 @@ def ccache(): ram_keys.remove('oldest') for key in ram_keys: total[key] = ram[key] + disk[key] - + try: total['ratio'] = total['hits'] * 100 / (total['hits'] + total['misses']) @@ -575,7 +576,7 @@ def bg_graph_model(): meta_graphmodel = dict(group=request.application, color='#ECECEC') group = meta_graphmodel['group'].replace(' ', '') - if not subgraphs.has_key(group): + if group not in subgraphs: subgraphs[group] = dict(meta=meta_graphmodel, tables=[]) subgraphs[group]['tables'].append(tablename) diff --git a/applications/admin/controllers/default.py b/applications/admin/controllers/default.py index 3e615c1e..264f757a 100644 --- a/applications/admin/controllers/default.py +++ b/applications/admin/controllers/default.py @@ -484,9 +484,15 @@ def cleanup(): def compile_app(): app = get_app() - c = app_compile(app, request) + c = app_compile(app, request, + skip_failed_views = (request.args(1) == 'skip_failed_views')) if not c: session.flash = T('application compiled') + elif isinstance(c, list): + session.flash = DIV(*[T('application compiled'), BR(), BR(), + T('WARNING: The following views could not be compiled:'), BR()] + + [CAT(BR(), view) for view in c] + + [BR(), BR(), T('DO NOT use the "Pack compiled" feature.')]) else: session.flash = DIV(T('Cannot compile: there are errors in your app:'), CODE(c)) diff --git a/applications/admin/controllers/pythonanywhere.py b/applications/admin/controllers/pythonanywhere.py new file mode 100644 index 00000000..e10e4020 --- /dev/null +++ b/applications/admin/controllers/pythonanywhere.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +import base64 +import os +import re +import gzip +import tarfile +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO +from xmlrpclib import ProtocolError +from gluon.contrib.simplejsonrpc import ServerProxy + + +def deploy(): + response.title = T('Deploy to pythonanywhere') + return {} + + +def create_account(): + """ Create a PythonAnywhere account """ + if not request.vars: + raise HTTP(400) + + if request.vars.username and request.vars.web2py_admin_password: + # Check if web2py is already there otherwise we get an error 500 too. + client = ServerProxy('https://%(username)s:%(web2py_admin_password)s@%(username)s.pythonanywhere.com/admin/webservices/call/jsonrpc' % request.vars) + try: + if client.login() is True: + return response.json({'status': 'ok'}) + except ProtocolError as error: + pass + + import urllib, urllib2 + url = 'https://www.pythonanywhere.com/api/web2py/create_account' + data = urllib.urlencode(request.vars) + req = urllib2.Request(url, data) + + try: + reply = urllib2.urlopen(req) + except urllib2.HTTPError as error: + if error.code == 400: + reply = error + elif error.code == 500: + return response.json({'status':'error', 'errors':{'username': ['An App other than web2py is installed in the domain %(username)s.pythonanywhere.com' % request.vars]}}) + else: + raise + response.headers['Content-Type'] = 'application/json' + return reply.read() + + +def list_apps(): + """ Get a list of apps both remote and local """ + if not request.vars.username or not request.vars.password: + raise HTTP(400) + client = ServerProxy('https://%(username)s:%(password)s@%(username)s.pythonanywhere.com/admin/webservices/call/jsonrpc' % request.vars) + regex = re.compile('^\w+$') + local = [f for f in os.listdir(apath(r=request)) if regex.match(f)] + try: + pythonanywhere = client.list_apps() + except ProtocolError as error: + raise HTTP(error.errcode) + return response.json({'local': local, 'pythonanywhere': pythonanywhere}) + + +def bulk_install(): + """ Install a list of apps """ + + def b64pack(app): + """ + Given an app's name, return the base64 representation of its packed version. + """ + folder = apath(app, r=request) + tmpfile = StringIO() + tar = tarfile.TarFile(fileobj=tmpfile, mode='w') + try: + filenames = listdir(folder, '^[\w\.\-]+$', add_dirs=True, + exclude_content_from=['cache', 'sessions', 'errors']) + for fname in filenames: + tar.add(os.path.join(folder, fname), fname, False) + finally: + tar.close() + tmpfile.seek(0) + gzfile = StringIO() + w2pfp = gzip.GzipFile(fileobj=gzfile, mode='wb') + w2pfp.write(tmpfile.read()) + w2pfp.close() + gzfile.seek(0) + return base64.b64encode(gzfile.read()) + + request.vars.apps = request.vars['apps[]'] + if not request.vars.apps or not request.vars.username or not request.vars.password: + raise HTTP(400) + if not isinstance(request.vars.apps, list): + request.vars.apps = [request.vars.apps] # Only one app selected + + client = ServerProxy('https://%(username)s:%(password)s@%(username)s.pythonanywhere.com/admin/webservices/call/jsonrpc' % request.vars) + + for app in request.vars.apps: + try: + client.install(app, app+'.w2p', b64pack(app)) + except ProtocolError as error: + raise HTTP(error.errcode) + + return response.json({'status': 'ok'}) diff --git a/applications/admin/languages/pt.py b/applications/admin/languages/pt.py index 3f850c3c..47e5506e 100644 --- a/applications/admin/languages/pt.py +++ b/applications/admin/languages/pt.py @@ -1,377 +1,408 @@ -# -*- coding: utf-8 -*- -{ -'!langcode!': 'pt', -'!langname!': 'Português', -'"update" is an optional expression like "field1=\'newvalue\'". You cannot update or delete the results of a JOIN': '"update" é uma expressão opcional como "campo1=\'novo_valor\'". Não é permitido atualizar ou apagar resultados de um JOIN', -'%s %%{row} deleted': '%s registros apagados', -'%s %%{row} updated': '%s registros atualizados', -'%Y-%m-%d': '%d/%m/%Y', -'%Y-%m-%d %H:%M:%S': '%d/%m/%Y %H:%M:%S', -'(requires internet access)': '(requer acesso à internet)', -'(requires internet access, experimental)': '(requer acesso à internet, experimental)', -'(something like "it-it")': '(algo como "it-it")', -'@markmin\x01(file **gluon/contrib/plural_rules/%s.py** is not found)': '(file **gluon/contrib/plural_rules/%s.py** is not found)', -'@markmin\x01An error occured, please [[reload %s]] the page': 'An error occured, please [[reload %s]] the page', -'@markmin\x01Searching: **%s** %%{file}': 'Searching: **%s** files', -'A new version of web2py is available': 'Está disponível uma nova versão do web2py', -'A new version of web2py is available: %s': 'Está disponível uma nova versão do web2py: %s', -'About': 'sobre', -'About application': 'Sobre a aplicação', -'additional code for your application': 'código adicional para sua aplicação', -'Additional code for your application': 'Código adicional para a sua aplicação', -'admin disabled because no admin password': ' admin desabilitado por falta de senha definida', -'admin disabled because not supported on google app engine': 'admin dehabilitado, não é soportado no GAE', -'admin disabled because unable to access password file': 'admin desabilitado, não foi possível ler o arquivo de senha', -'Admin is disabled because insecure channel': 'Admin desabilitado pois o canal não é seguro', -'Admin is disabled because unsecure channel': 'Admin desabilitado pois o canal não é seguro', -'Admin language': 'Linguagem do Admin', -'administrative interface': 'interface administrativa', -'Administrator Password:': 'Senha de administrador:', -'and rename it (required):': 'e renomeie (requerido):', -'and rename it:': ' e renomeie:', -'appadmin': 'appadmin', -'appadmin is disabled because insecure channel': 'admin desabilitado, canal inseguro', -'application "%s" uninstalled': 'aplicação "%s" desinstalada', -'application compiled': 'aplicação compilada', -'application is compiled and cannot be designed': 'A aplicação está compilada e não pode ser modificada', -'Application name:': 'Nome da aplicação:', -'are not used': 'não usadas', -'are not used yet': 'ainda não usadas', -'Are you sure you want to delete file "%s"?': 'Tem certeza que deseja apagar o arquivo "%s"?', -'Are you sure you want to delete plugin "%s"?': 'Tem certeza que deseja apagar o plugin "%s"?', -'Are you sure you want to delete this object?': 'Are you sure you want to delete this object?', -'Are you sure you want to uninstall application "%s"': 'Tem certeza que deseja apagar a aplicação "%s"?', -'Are you sure you want to uninstall application "%s"?': 'Tem certeza que deseja apagar a aplicação "%s"?', -'Are you sure you want to upgrade web2py now?': 'Tem certeza que deseja atualizar o web2py agora?', -'arguments': 'argumentos', -'ATTENTION: Login requires a secure (HTTPS) connection or running on localhost.': 'ATENÇÃO o login requer uma conexão segura (HTTPS) ou executar de localhost.', -'ATTENTION: TESTING IS NOT THREAD SAFE SO DO NOT PERFORM MULTIPLE TESTS CONCURRENTLY.': 'ATENÇÃO OS TESTES NÃO THREAD SAFE, NÃO EFETUE MÚLTIPLOS TESTES AO MESMO TEMPO.', -'ATTENTION: you cannot edit the running application!': 'ATENÇÃO: Não pode modificar a aplicação em execução!', -'Autocomplete Python Code': 'Autocompletar Código Python', -'Available databases and tables': 'Bancos de dados e tabelas disponíveis', -'back': 'voltar', -'browse': 'buscar', -'cache': 'cache', -'cache, errors and sessions cleaned': 'cache, erros e sessões eliminadas', -'can be a git repo': 'can be a git repo', -'Cannot be empty': 'Não pode ser vazio', -'Cannot compile: there are errors in your app. Debug it, correct errors and try again.': 'Não é possível compilar: Existem erros em sua aplicação. Depure, corrija os errros e tente novamente', -'Cannot compile: there are errors in your app:': 'Não é possível compilar: Existem erros em sua aplicação', -'cannot create file': 'Não é possível criar o arquivo', -'cannot upload file "%(filename)s"': 'não é possível fazer upload do arquivo "%(filename)s"', -'Change admin password': 'mudar senha de administrador', -'change editor settings': 'mudar definições do editor', -'Change Password': 'Trocar Senha', -'check all': 'marcar todos', -'Check for upgrades': 'checar por atualizações', -'Check to delete': 'Marque para apagar', -'Checking for upgrades...': 'Buscando atualizações...', -'Clean': 'limpar', -'click here for online examples': 'clique para ver exemplos online', -'click here for the administrative interface': 'Clique aqui para acessar a interface administrativa', -'Click row to expand traceback': 'Clique em uma coluna para expandir o log do erro', -'click to check for upgrades': 'clique aqui para checar por atualizações', -'click to open': 'clique para abrir', -'Client IP': 'IP do cliente', -'code': 'código', -'collapse/expand all': 'colapsar/expandir tudo', -'commit (mercurial)': 'commit (mercurial)', -'Compile': 'compilar', -'compiled application removed': 'aplicação compilada removida', -'Controllers': 'Controladores', -'controllers': 'controladores', -'Count': 'Contagem', -'Create': 'criar', -'create file with filename:': 'criar um arquivo com o nome:', -'Create new application using the Wizard': 'Criar nova aplicação utilizando o assistente', -'create new application:': 'nome da nova aplicação:', -'Create new simple application': 'Crie uma nova aplicação', -'Create/Upload': 'Create/Upload', -'created by': 'criado por', -'crontab': 'crontab', -'Current request': 'Requisição atual', -'Current response': 'Resposta atual', -'Current session': 'Sessão atual', -'currently running': 'Executando', -'currently saved or': 'Atualmente salvo ou', -'customize me!': 'Modifique-me', -'data uploaded': 'Dados enviados', -'database': 'banco de dados', -'database %s select': 'Seleção no banco de dados %s', -'database administration': 'administração de banco de dados', -'Date and Time': 'Data e Hora', -'db': 'db', -'Debug': 'Debug', -'defines tables': 'define as tabelas', -'Delete': 'Apague', -'delete': 'apagar', -'delete all checked': 'apagar marcados', -'delete plugin': 'apagar plugin', -'Delete this file (you will be asked to confirm deletion)': 'Delete this file (you will be asked to confirm deletion)', -'Delete:': 'Apague:', -'Deploy': 'publicar', -'Deploy on Google App Engine': 'Publicar no Google App Engine', -'Deploy to OpenShift': 'Deploy to OpenShift', -'Description': 'Descrição', -'design': 'modificar', -'DESIGN': 'Projeto', -'Design for': 'Projeto de', -'Detailed traceback description': 'Detailed traceback description', -'direction: ltr': 'direção: ltr', -'Disable': 'Disable', -'docs': 'docs', -'done!': 'feito!', -'download layouts': 'download layouts', -'Download layouts from repository': 'Download layouts from repository', -'download plugins': 'download plugins', -'Download plugins from repository': 'Download plugins from repository', -'E-mail': 'E-mail', -'EDIT': 'EDITAR', -'Edit': 'editar', -'Edit application': 'Editar aplicação', -'edit controller': 'editar controlador', -'Edit current record': 'Editar o registro atual', -'Edit Profile': 'Editar Perfil', -'edit views:': 'editar visões:', -'Editing %s': 'A Editar %s', -'Editing file': 'Editando arquivo', -'Editing file "%s"': 'Editando arquivo "%s"', -'Editing Language file': 'Editando arquivo de linguagem', -'Enterprise Web Framework': 'Framework web empresarial', -'Error': 'Erro', -'Error logs for "%(app)s"': 'Logs de erro para "%(app)s"', -'Error snapshot': 'Error snapshot', -'Error ticket': 'Error ticket', -'Errors': 'erros', -'Exception instance attributes': 'Atributos da instancia de excessão', -'Exit Fullscreen': 'Sair de Ecrã Inteiro', -'Expand Abbreviation (html files only)': 'Expandir Abreviação (só para ficheiros html)', -'export as csv file': 'exportar como arquivo CSV', -'exposes': 'expõe', -'extends': 'estende', -'failed to reload module': 'Falha ao recarregar o módulo', -'failed to reload module because:': 'falha ao recarregar o módulo por:', -'File': 'Arquivo', -'file "%(filename)s" created': 'arquivo "%(filename)s" criado', -'file "%(filename)s" deleted': 'arquivo "%(filename)s" apagado', -'file "%(filename)s" uploaded': 'arquivo "%(filename)s" enviado', -'file "%(filename)s" was not deleted': 'arquivo "%(filename)s" não foi apagado', -'file "%s" of %s restored': 'arquivo "%s" de %s restaurado', -'file changed on disk': 'arquivo modificado no disco', -'file does not exist': 'arquivo não existe', -'file saved on %(time)s': 'arquivo salvo em %(time)s', -'file saved on %s': 'arquivo salvo em %s', -'filter': 'filtro', -'Find Next': 'Localizar Seguinte', -'Find Previous': 'Localizar Anterior', -'First name': 'Nome', -'Frames': 'Frames', -'Functions with no doctests will result in [passed] tests.': 'Funções sem doctests resultarão em testes [aceitos].', -'graph model': 'graph model', -'Group ID': 'ID do Grupo', -'Hello World': 'Olá Mundo', -'Help': 'ajuda', -'Hide/Show Translated strings': '', -'htmledit': 'htmledit', -'If the report above contains a ticket number it indicates a failure in executing the controller, before any attempt to execute the doctests. This is usually due to an indentation error or an error outside function code.\nA green title indicates that all tests (if defined) passed. In this case test results are not shown.': 'Se o relatório acima contém um número de ticket, isso indica uma falha no controlador em execução, antes de tantar executar os doctests. Isto acontece geralmente por erro de endentação ou erro fora do código da função.\r\nO titulo em verde indica que os testes (se definidos) passaram. Neste caso os testes não são mostrados.', -'Import/Export': 'Importar/Exportar', -'includes': 'inclui', -'insert new': 'inserir novo', -'insert new %s': 'inserir novo %s', -'inspect attributes': 'inspecionar atributos', -'Install': 'instalar', -'Installed applications': 'Aplicações instaladas', -'internal error': 'erro interno', -'Internal State': 'Estado Interno', -'Invalid action': 'Ação inválida', -'Invalid email': 'E-mail inválido', -'invalid password': 'senha inválida', -'Invalid Query': 'Consulta inválida', -'invalid request': 'solicitação inválida', -'invalid ticket': 'ticket inválido', -'Keyboard shortcuts': 'Atalhos de teclado', -'language file "%(filename)s" created/updated': 'arquivo de linguagem "%(filename)s" criado/atualizado', -'Language files (static strings) updated': 'Arquivos de linguagem (textos estáticos) atualizados', -'languages': 'linguagens', -'Languages': 'Linguagens', -'languages updated': 'linguagens atualizadas', -'Last name': 'Sobrenome', -'Last saved on:': 'Salvo em:', -'License for': 'Licença para', -'loading...': 'carregando...', -'locals': 'locals', -'Login': 'Entrar', -'login': 'inicio de sessão', -'Login to the Administrative Interface': 'Entrar na interface adminitrativa', -'Logout': 'finalizar sessão', -'Lost Password': 'Senha perdida', -'Manage': 'Manage', -'manage': 'gerenciar', -'merge': 'juntar', -'Models': 'Modelos', -'models': 'modelos', -'Modules': 'Módulos', -'modules': 'módulos', -'Name': 'Nome', -'new application "%s" created': 'nova aplicação "%s" criada', -'New application wizard': 'Assistente para novas aplicações ', -'new plugin installed': 'novo plugin instalado', -'New Record': 'Novo registro', -'new record inserted': 'novo registro inserido', -'New simple application': 'Nova aplicação básica', -'next 100 rows': 'próximos 100 registros', -'NO': 'NÃO', -'No databases in this application': 'Não existem bancos de dados nesta aplicação', -'no match': 'não encontrado', -'no package selected': 'nenhum pacote selecionado', -'online designer': 'online designer', -'or alternatively': 'or alternatively', -'Or Get from URL:': 'Ou Obtenha do URL:', -'or import from csv file': 'ou importar de um arquivo CSV', -'or provide app url:': 'ou forneça a url de uma aplicação:', -'or provide application url:': 'ou forneça a url de uma aplicação:', -'Origin': 'Origem', -'Original/Translation': 'Original/Tradução', -'Overwrite installed app': 'sobrescrever aplicação instalada', -'Pack all': 'criar pacote', -'Pack compiled': 'criar pacote compilado', -'Pack custom': 'Pack custom', -'pack plugin': 'empacotar plugin', -'PAM authenticated user, cannot change password here': 'usuario autenticado por PAM, não pode alterar a senha por aqui', -'Password': 'Senha', -'password changed': 'senha alterada', -'Peeking at file': 'Visualizando arquivo', -'plugin "%(plugin)s" deleted': 'plugin "%(plugin)s" eliminado', -'Plugin "%s" in application': 'Plugin "%s" na aplicação', -'plugins': 'plugins', -'Plugins': 'Plugins', -'Plural-Forms:': 'Plural-Forms:', -'Powered by': 'Este site utiliza', -'previous 100 rows': '100 registros anteriores', -'Private files': 'Private files', -'private files': 'private files', -'Query:': 'Consulta:', -'Rapid Search': 'Rapid Search', -'record': 'registro', -'record does not exist': 'o registro não existe', -'record id': 'id do registro', -'Record ID': 'ID do Registro', -'Register': 'Registrar-se', -'Registration key': 'Chave de registro', -'Reload routes': 'Reload routes', -'Remove compiled': 'eliminar compilados', -'Replace': 'Substituir', -'Replace All': 'Substituir Tudo', -'request': 'request', -'Resolve Conflict file': 'Arquivo de resolução de conflito', -'response': 'response', -'restore': 'restaurar', -'revert': 'reverter', -'Role': 'Papel', -'Rows in table': 'Registros na tabela', -'Rows selected': 'Registros selecionados', -'rules are not defined': 'rules are not defined', -"Run tests in this file (to run all files, you may also use the button labelled 'test')": "Run tests in this file (to run all files, you may also use the button labelled 'test')", -'Running on %s': 'A correr em %s', -'Save': 'Save', -'save': 'salvar', -'Save file:': 'Gravar ficheiro:', -'Save file: %s': 'Gravar ficheiro: %s', -'Save via Ajax': 'Gravar via Ajax', -'Saved file hash:': 'Hash do arquivo salvo:', -'selected': 'selecionado(s)', -'session': 'session', -'session expired': 'sessão expirada', -'shell': 'Terminal', -'Site': 'site', -'some files could not be removed': 'alguns arquicos não puderam ser removidos', -'Start searching': 'Start searching', -'Start wizard': 'iniciar assistente', -'state': 'estado', -'Static': 'Static', -'static': 'estáticos', -'Static files': 'Arquivos estáticos', -'Submit': 'Submit', -'submit': 'enviar', -'Sure you want to delete this object?': 'Tem certeza que deseja apaagr este objeto?', -'table': 'tabela', -'Table name': 'Nome da tabela', -'test': 'testar', -'Testing application': 'Testando a aplicação', -'The "query" is a condition like "db.table1.field1==\'value\'". Something like "db.table1.field1==db.table2.field2" results in a SQL JOIN.': 'A "consulta" é uma condição como "db.tabela.campo1==\'valor\'". Algo como "db.tabela1.campo1==db.tabela2.campo2" resulta em um JOIN SQL.', -'the application logic, each URL path is mapped in one exposed function in the controller': 'A lógica da aplicação, cada URL é mapeada para uma função exposta pelo controlador', -'The application logic, each URL path is mapped in one exposed function in the controller': 'The application logic, each URL path is mapped in one exposed function in the controller', -'the data representation, define database tables and sets': 'A representação dos dadps, define tabelas e estruturas de dados', -'The data representation, define database tables and sets': 'The data representation, define database tables and sets', -'The presentations layer, views are also known as templates': 'The presentations layer, views are also known as templates', -'the presentations layer, views are also known as templates': 'A camada de apresentação, As visões também são chamadas de templates', -'There are no controllers': 'Não existem controllers', -'There are no models': 'Não existem modelos', -'There are no modules': 'Não existem módulos', -'There are no plugins': 'There are no plugins', -'There are no private files': '', -'There are no static files': 'Não existem arquicos estáticos', -'There are no translators, only default language is supported': 'Não há traduções, somente a linguagem padrão é suportada', -'There are no views': 'Não existem visões', -'These files are not served, they are only available from within your app': 'These files are not served, they are only available from within your app', -'These files are served without processing, your images go here': 'These files are served without processing, your images go here', -'these files are served without processing, your images go here': 'Estes arquivos são servidos sem processamento, suas imagens ficam aqui', -'This is the %(filename)s template': 'Este é o template %(filename)s', -'Ticket': 'Ticket', -'Ticket ID': 'Ticket ID', -'Timestamp': 'Data Atual', -'TM': 'MR', -'to previous version.': 'para a versão anterior.', -'To create a plugin, name a file/folder plugin_[name]': 'Para criar um plugin, nomeio um arquivo/pasta como plugin_[nome]', -'toggle breakpoint': 'toggle breakpoint', -'Toggle comment': 'Toggle comment', -'Toggle Fullscreen': 'Toggle Fullscreen', -'Traceback': 'Traceback', -'translation strings for the application': 'textos traduzidos para a aplicação', -'Translation strings for the application': 'Translation strings for the application', -'try': 'tente', -'try something like': 'tente algo como', -'Try the mobile interface': 'Try the mobile interface', -'Unable to check for upgrades': 'Não é possível checar as atualizações', -'unable to create application "%s"': 'não é possível criar a aplicação "%s"', -'unable to delete file "%(filename)s"': 'não é possível criar o arquico "%(filename)s"', -'unable to delete file plugin "%(plugin)s"': 'não é possível criar o plugin "%(plugin)s"', -'Unable to download': 'Não é possível efetuar o download', -'Unable to download app': 'Não é possível baixar a aplicação', -'Unable to download app because:': 'Não é possível baixar a aplicação porque:', -'Unable to download because': 'Não é possível baixar porque', -'unable to parse csv file': 'não é possível analisar o arquivo CSV', -'unable to uninstall "%s"': 'não é possível instalar "%s"', -'unable to upgrade because "%s"': 'não é possível atualizar porque "%s"', -'uncheck all': 'desmarcar todos', -'Uninstall': 'desinstalar', -'update': 'atualizar', -'update all languages': 'atualizar todas as linguagens', -'Update:': 'Atualizar:', -'upgrade web2py now': 'atualize o web2py agora', -'upload': 'upload', -'Upload': 'Upload', -'Upload & install packed application': 'Faça upload e instale uma aplicação empacotada', -'Upload a package:': 'Faça upload de um pacote:', -'Upload and install packed application': 'Upload and install packed application', -'upload application:': 'Fazer upload de uma aplicação:', -'Upload existing application': 'Faça upload de uma aplicação existente', -'upload file:': 'Enviar arquivo:', -'upload plugin file:': 'Enviar arquivo de plugin:', -'Use (...)&(...) for AND, (...)|(...) for OR, and ~(...) for NOT to build more complex queries.': 'Use (...)&(...) para AND, (...)|(...) para OR, y ~(...) para NOT, para criar consultas mais complexas.', -'Use an url:': 'Use uma url:', -'User ID': 'ID do Usuario', -'variables': 'variáveis', -'Version': 'Versão', -'versioning': 'versionamento', -'Versioning': 'Versioning', -'view': 'visão', -'Views': 'Visões', -'views': 'visões', -'Web Framework': 'Web Framework', -'web2py is up to date': 'web2py está atualizado', -'web2py Recent Tweets': 'Tweets Recentes de @web2py', -'web2py upgraded; please restart it': 'web2py atualizado; favor reiniciar', -'Welcome to web2py': 'Bem-vindo ao web2py', -'YES': 'SIM', -} +# -*- coding: utf-8 -*- +{ +'!langcode!': 'pt', +'!langname!': 'Português', +'"update" is an optional expression like "field1=\'newvalue\'". You cannot update or delete the results of a JOIN': '"update" é uma expressão opcional como "campo1=\'novo_valor\'". Não é permitido atualizar ou apagar resultados de um JOIN', +'%s %%{row} deleted': '%s registros apagados', +'%s %%{row} updated': '%s registros atualizados', +'%Y-%m-%d': '%d/%m/%Y', +'%Y-%m-%d %H:%M:%S': '%d/%m/%Y %H:%M:%S', +'(requires internet access)': '(requer acesso à internet)', +'(requires internet access, experimental)': '(requer acesso à internet, experimental)', +'(something like "it-it")': '(algo como "it-it")', +'@markmin\x01(file **gluon/contrib/plural_rules/%s.py** is not found)': '(file **gluon/contrib/plural_rules/%s.py** is not found)', +'@markmin\x01An error occured, please [[reload %s]] the page': 'An error occured, please [[reload %s]] the page', +'@markmin\x01Searching: **%s** %%{file}': 'Searching: **%s** files', +'A new version of web2py is available': 'Está disponível uma nova versão do web2py', +'A new version of web2py is available: %s': 'Está disponível uma nova versão do web2py: %s', +'About': 'sobre', +'About application': 'Sobre a aplicação', +'Accept Terms': 'Accept Terms', +'additional code for your application': 'código adicional para sua aplicação', +'Additional code for your application': 'Código adicional para a sua aplicação', +'admin disabled because no admin password': ' admin desabilitado por falta de senha definida', +'admin disabled because not supported on google app engine': 'admin dehabilitado, não é soportado no GAE', +'admin disabled because unable to access password file': 'admin desabilitado, não foi possível ler o arquivo de senha', +'Admin is disabled because insecure channel': 'Admin desabilitado pois o canal não é seguro', +'Admin is disabled because unsecure channel': 'Admin desabilitado pois o canal não é seguro', +'Admin language': 'Linguagem do Admin', +'administrative interface': 'interface administrativa', +'Administrator Password:': 'Senha de administrador:', +'and rename it (required):': 'e renomeie (requerido):', +'and rename it:': ' e renomeie:', +'appadmin': 'appadmin', +'appadmin is disabled because insecure channel': 'admin desabilitado, canal inseguro', +'application "%s" uninstalled': 'aplicação "%s" desinstalada', +'application compiled': 'aplicação compilada', +'application is compiled and cannot be designed': 'A aplicação está compilada e não pode ser modificada', +'Application name:': 'Nome da aplicação:', +'are not used': 'não usadas', +'are not used yet': 'ainda não usadas', +'Are you sure you want to delete file "%s"?': 'Tem certeza que deseja apagar o arquivo "%s"?', +'Are you sure you want to delete plugin "%s"?': 'Tem certeza que deseja apagar o plugin "%s"?', +'Are you sure you want to delete this object?': 'Are you sure you want to delete this object?', +'Are you sure you want to uninstall application "%s"': 'Tem certeza que deseja apagar a aplicação "%s"?', +'Are you sure you want to uninstall application "%s"?': 'Tem certeza que deseja apagar a aplicação "%s"?', +'Are you sure you want to upgrade web2py now?': 'Tem certeza que deseja atualizar o web2py agora?', +'arguments': 'argumentos', +'ATTENTION: Login requires a secure (HTTPS) connection or running on localhost.': 'ATENÇÃO o login requer uma conexão segura (HTTPS) ou executar de localhost.', +'ATTENTION: TESTING IS NOT THREAD SAFE SO DO NOT PERFORM MULTIPLE TESTS CONCURRENTLY.': 'ATENÇÃO OS TESTES NÃO THREAD SAFE, NÃO EFETUE MÚLTIPLOS TESTES AO MESMO TEMPO.', +'ATTENTION: you cannot edit the running application!': 'ATENÇÃO: Não pode modificar a aplicação em execução!', +'Autocomplete Python Code': 'Autocompletar Código Python', +'Available databases and tables': 'Bancos de dados e tabelas disponíveis', +'back': 'voltar', +'Begin': 'Begin', +'browse': 'buscar', +'cache': 'cache', +'cache, errors and sessions cleaned': 'cache, erros e sessões eliminadas', +'can be a git repo': 'can be a git repo', +'Cannot be empty': 'Não pode ser vazio', +'Cannot compile: there are errors in your app. Debug it, correct errors and try again.': 'Não é possível compilar: Existem erros em sua aplicação. Depure, corrija os errros e tente novamente', +'Cannot compile: there are errors in your app:': 'Não é possível compilar: Existem erros em sua aplicação', +'cannot create file': 'Não é possível criar o arquivo', +'cannot upload file "%(filename)s"': 'não é possível fazer upload do arquivo "%(filename)s"', +'Change admin password': 'mudar senha de administrador', +'change editor settings': 'mudar definições do editor', +'Change Password': 'Trocar Senha', +'check all': 'marcar todos', +'Check for upgrades': 'checar por atualizações', +'Check to delete': 'Marque para apagar', +'Checking for upgrades...': 'Buscando atualizações...', +'Clean': 'limpar', +'click here for online examples': 'clique para ver exemplos online', +'click here for the administrative interface': 'Clique aqui para acessar a interface administrativa', +'Click row to expand traceback': 'Clique em uma coluna para expandir o log do erro', +'click to check for upgrades': 'clique aqui para checar por atualizações', +'click to open': 'clique para abrir', +'Client IP': 'IP do cliente', +'code': 'código', +'collapse/expand all': 'colapsar/expandir tudo', +'commit (mercurial)': 'commit (mercurial)', +'Compile': 'compilar', +'compiled application removed': 'aplicação compilada removida', +'Controllers': 'Controladores', +'controllers': 'controladores', +'Count': 'Contagem', +'Create': 'criar', +'create file with filename:': 'criar um arquivo com o nome:', +'Create new application using the Wizard': 'Criar nova aplicação utilizando o assistente', +'create new application:': 'nome da nova aplicação:', +'Create new simple application': 'Crie uma nova aplicação', +'Create/Upload': 'Create/Upload', +'created by': 'criado por', +'crontab': 'crontab', +'Current request': 'Requisição atual', +'Current response': 'Resposta atual', +'Current session': 'Sessão atual', +'currently running': 'Executando', +'currently saved or': 'Atualmente salvo ou', +'customize me!': 'Modifique-me', +'data uploaded': 'Dados enviados', +'database': 'banco de dados', +'database %s select': 'Seleção no banco de dados %s', +'database administration': 'administração de banco de dados', +'Date and Time': 'Data e Hora', +'db': 'db', +'Debug': 'Debug', +'defines tables': 'define as tabelas', +'Delete': 'Apague', +'delete': 'apagar', +'delete all checked': 'apagar marcados', +'delete plugin': 'apagar plugin', +'Delete this file (you will be asked to confirm deletion)': 'Delete this file (you will be asked to confirm deletion)', +'Delete:': 'Apague:', +'Deploy': 'publicar', +'Deploy on Google App Engine': 'Publicar no Google App Engine', +'Deploy to OpenShift': 'Deploy to OpenShift', +'Deploy to pythonanywhere': 'Deploy to pythonanywhere', +'Deploy to PythonAnywhere': 'Deploy to PythonAnywhere', +'Deployment Interface': 'Deployment Interface', +'Description': 'Descrição', +'design': 'modificar', +'DESIGN': 'Projeto', +'Design for': 'Projeto de', +'Detailed traceback description': 'Detailed traceback description', +'details': 'details', +'direction: ltr': 'direção: ltr', +'Disable': 'Disable', +'docs': 'docs', +'done!': 'feito!', +'download layouts': 'download layouts', +'Download layouts from repository': 'Download layouts from repository', +'download plugins': 'download plugins', +'Download plugins from repository': 'Download plugins from repository', +'E-mail': 'E-mail', +'EDIT': 'EDITAR', +'Edit': 'editar', +'Edit application': 'Editar aplicação', +'edit controller': 'editar controlador', +'Edit current record': 'Editar o registro atual', +'Edit Profile': 'Editar Perfil', +'edit views:': 'editar visões:', +'Editing %s': 'A Editar %s', +'Editing file': 'Editando arquivo', +'Editing file "%s"': 'Editando arquivo "%s"', +'Editing Language file': 'Editando arquivo de linguagem', +'Email Address': 'Email Address', +'Enterprise Web Framework': 'Framework web empresarial', +'Error': 'Erro', +'Error logs for "%(app)s"': 'Logs de erro para "%(app)s"', +'Error snapshot': 'Error snapshot', +'Error ticket': 'Error ticket', +'Errors': 'erros', +'Exception instance attributes': 'Atributos da instancia de excessão', +'Exit Fullscreen': 'Sair de Ecrã Inteiro', +'Expand Abbreviation (html files only)': 'Expandir Abreviação (só para ficheiros html)', +'export as csv file': 'exportar como arquivo CSV', +'exposes': 'expõe', +'exposes:': 'exposes:', +'extends': 'estende', +'failed to reload module': 'Falha ao recarregar o módulo', +'failed to reload module because:': 'falha ao recarregar o módulo por:', +'File': 'Arquivo', +'file "%(filename)s" created': 'arquivo "%(filename)s" criado', +'file "%(filename)s" deleted': 'arquivo "%(filename)s" apagado', +'file "%(filename)s" uploaded': 'arquivo "%(filename)s" enviado', +'file "%(filename)s" was not deleted': 'arquivo "%(filename)s" não foi apagado', +'file "%s" of %s restored': 'arquivo "%s" de %s restaurado', +'file changed on disk': 'arquivo modificado no disco', +'file does not exist': 'arquivo não existe', +'file saved on %(time)s': 'arquivo salvo em %(time)s', +'file saved on %s': 'arquivo salvo em %s', +'filter': 'filtro', +'Find Next': 'Localizar Seguinte', +'Find Previous': 'Localizar Anterior', +'First name': 'Nome', +'Form has errors': 'Form has errors', +'Frames': 'Frames', +'Functions with no doctests will result in [passed] tests.': 'Funções sem doctests resultarão em testes [aceitos].', +'graph model': 'graph model', +'Group ID': 'ID do Grupo', +'Hello World': 'Olá Mundo', +'Help': 'ajuda', +'Hide/Show Translated strings': '', +'htmledit': 'htmledit', +'If the report above contains a ticket number it indicates a failure in executing the controller, before any attempt to execute the doctests. This is usually due to an indentation error or an error outside function code.\nA green title indicates that all tests (if defined) passed. In this case test results are not shown.': 'Se o relatório acima contém um número de ticket, isso indica uma falha no controlador em execução, antes de tantar executar os doctests. Isto acontece geralmente por erro de endentação ou erro fora do código da função.\r\nO titulo em verde indica que os testes (se definidos) passaram. Neste caso os testes não são mostrados.', +'if your application uses a database other than sqlite you will then have to configure its DAL in pythonanywhere.': 'if your application uses a database other than sqlite you will then have to configure its DAL in pythonanywhere.', +'Import/Export': 'Importar/Exportar', +'includes': 'inclui', +'insert new': 'inserir novo', +'insert new %s': 'inserir novo %s', +'inspect attributes': 'inspecionar atributos', +'Install': 'instalar', +'Installed applications': 'Aplicações instaladas', +'internal error': 'erro interno', +'Internal State': 'Estado Interno', +'Invalid action': 'Ação inválida', +'Invalid email': 'E-mail inválido', +'invalid password': 'senha inválida', +'Invalid Query': 'Consulta inválida', +'invalid request': 'solicitação inválida', +'invalid ticket': 'ticket inválido', +'Keyboard shortcuts': 'Atalhos de teclado', +'language file "%(filename)s" created/updated': 'arquivo de linguagem "%(filename)s" criado/atualizado', +'Language files (static strings) updated': 'Arquivos de linguagem (textos estáticos) atualizados', +'languages': 'linguagens', +'Languages': 'Linguagens', +'languages updated': 'linguagens atualizadas', +'Last name': 'Sobrenome', +'Last saved on:': 'Salvo em:', +'License for': 'Licença para', +'lists by ticket': 'lists by ticket', +'Loading...': 'Loading...', +'loading...': 'carregando...', +'Local Apps': 'Local Apps', +'locals': 'locals', +'Login': 'Entrar', +'login': 'inicio de sessão', +'Login successful': 'Login successful', +'Login to the Administrative Interface': 'Entrar na interface adminitrativa', +'Login/Register': 'Login/Register', +'Logout': 'finalizar sessão', +'Lost Password': 'Senha perdida', +'manage': 'gerenciar', +'Manage': 'Manage', +'merge': 'juntar', +'models': 'modelos', +'Models': 'Modelos', +'Modules': 'Módulos', +'modules': 'módulos', +'Name': 'Nome', +'new application "%s" created': 'nova aplicação "%s" criada', +'New Application Wizard': 'New Application Wizard', +'New application wizard': 'Assistente para novas aplicações ', +'new plugin installed': 'novo plugin instalado', +'New Record': 'Novo registro', +'new record inserted': 'novo registro inserido', +'New simple application': 'Nova aplicação básica', +'next 100 rows': 'próximos 100 registros', +'NO': 'NÃO', +'No databases in this application': 'Não existem bancos de dados nesta aplicação', +'no match': 'não encontrado', +'no package selected': 'nenhum pacote selecionado', +'No ticket_storage.txt found under /private folder': 'No ticket_storage.txt found under /private folder', +'online designer': 'online designer', +'or alternatively': 'or alternatively', +'Or Get from URL:': 'Ou Obtenha do URL:', +'or import from csv file': 'ou importar de um arquivo CSV', +'or provide app url:': 'ou forneça a url de uma aplicação:', +'or provide application url:': 'ou forneça a url de uma aplicação:', +'Origin': 'Origem', +'Original/Translation': 'Original/Tradução', +'Overwrite installed app': 'sobrescrever aplicação instalada', +'Pack all': 'criar pacote', +'Pack compiled': 'criar pacote compilado', +'Pack custom': 'Pack custom', +'pack plugin': 'empacotar plugin', +'PAM authenticated user, cannot change password here': 'usuario autenticado por PAM, não pode alterar a senha por aqui', +'Password': 'Senha', +'password changed': 'senha alterada', +'Peeking at file': 'Visualizando arquivo', +'Please wait, giving pythonanywhere a moment...': 'Please wait, giving pythonanywhere a moment...', +'plugin "%(plugin)s" deleted': 'plugin "%(plugin)s" eliminado', +'Plugin "%s" in application': 'Plugin "%s" na aplicação', +'plugins': 'plugins', +'Plugins': 'Plugins', +'Plural-Forms:': 'Plural-Forms:', +'Powered by': 'Este site utiliza', +'previous 100 rows': '100 registros anteriores', +'Private files': 'Private files', +'private files': 'private files', +'PythonAnywhere Apps': 'PythonAnywhere Apps', +'PythonAnywhere Password': 'PythonAnywhere Password', +'Query:': 'Consulta:', +'Rapid Search': 'Rapid Search', +'Read': 'Read', +'record': 'registro', +'record does not exist': 'o registro não existe', +'record id': 'id do registro', +'Record ID': 'ID do Registro', +'Register': 'Registrar-se', +'Registration key': 'Chave de registro', +'Reload routes': 'Reload routes', +'Remove compiled': 'eliminar compilados', +'Replace': 'Substituir', +'Replace All': 'Substituir Tudo', +'request': 'request', +'requires python-git, but not installed': 'requires python-git, but not installed', +'Resolve Conflict file': 'Arquivo de resolução de conflito', +'response': 'response', +'restore': 'restaurar', +'revert': 'reverter', +'Role': 'Papel', +'Rows in table': 'Registros na tabela', +'Rows selected': 'Registros selecionados', +'rules are not defined': 'rules are not defined', +"Run tests in this file (to run all files, you may also use the button labelled 'test')": "Run tests in this file (to run all files, you may also use the button labelled 'test')", +'Running on %s': 'A correr em %s', +'Save': 'Save', +'save': 'salvar', +'Save file:': 'Gravar ficheiro:', +'Save file: %s': 'Gravar ficheiro: %s', +'Save via Ajax': 'Gravar via Ajax', +'Saved file hash:': 'Hash do arquivo salvo:', +'selected': 'selecionado(s)', +'session': 'session', +'session expired': 'sessão expirada', +'shell': 'Terminal', +'Site': 'site', +'some files could not be removed': 'alguns arquicos não puderam ser removidos', +'Something went wrong please wait a few minutes before retrying': 'Something went wrong please wait a few minutes before retrying', +'source : filesystem': 'source : filesystem', +'Start a new app': 'Start a new app', +'Start searching': 'Start searching', +'Start wizard': 'iniciar assistente', +'state': 'estado', +'Static': 'Static', +'static': 'estáticos', +'Static files': 'Arquivos estáticos', +'Submit': 'Submit', +'submit': 'enviar', +'Sure you want to delete this object?': 'Tem certeza que deseja apaagr este objeto?', +'switch to : db': 'switch to : db', +'table': 'tabela', +'Table name': 'Nome da tabela', +'test': 'testar', +'Testing application': 'Testando a aplicação', +'The "query" is a condition like "db.table1.field1==\'value\'". Something like "db.table1.field1==db.table2.field2" results in a SQL JOIN.': 'A "consulta" é uma condição como "db.tabela.campo1==\'valor\'". Algo como "db.tabela1.campo1==db.tabela2.campo2" resulta em um JOIN SQL.', +'the application logic, each URL path is mapped in one exposed function in the controller': 'A lógica da aplicação, cada URL é mapeada para uma função exposta pelo controlador', +'The application logic, each URL path is mapped in one exposed function in the controller': 'The application logic, each URL path is mapped in one exposed function in the controller', +'the data representation, define database tables and sets': 'A representação dos dadps, define tabelas e estruturas de dados', +'The data representation, define database tables and sets': 'The data representation, define database tables and sets', +'The presentations layer, views are also known as templates': 'The presentations layer, views are also known as templates', +'the presentations layer, views are also known as templates': 'A camada de apresentação, As visões também são chamadas de templates', +'There are no controllers': 'Não existem controllers', +'There are no models': 'Não existem modelos', +'There are no modules': 'Não existem módulos', +'There are no plugins': 'There are no plugins', +'There are no private files': '', +'There are no static files': 'Não existem arquicos estáticos', +'There are no translators, only default language is supported': 'Não há traduções, somente a linguagem padrão é suportada', +'There are no views': 'Não existem visões', +'These files are not served, they are only available from within your app': 'These files are not served, they are only available from within your app', +'These files are served without processing, your images go here': 'These files are served without processing, your images go here', +'these files are served without processing, your images go here': 'Estes arquivos são servidos sem processamento, suas imagens ficam aqui', +'This is the %(filename)s template': 'Este é o template %(filename)s', +'Ticket': 'Ticket', +'Ticket ID': 'Ticket ID', +'Timestamp': 'Data Atual', +'TM': 'MR', +'to previous version.': 'para a versão anterior.', +'To create a plugin, name a file/folder plugin_[name]': 'Para criar um plugin, nomeio um arquivo/pasta como plugin_[nome]', +'toggle breakpoint': 'toggle breakpoint', +'Toggle comment': 'Toggle comment', +'Toggle Fullscreen': 'Toggle Fullscreen', +'Traceback': 'Traceback', +'translation strings for the application': 'textos traduzidos para a aplicação', +'Translation strings for the application': 'Translation strings for the application', +'try': 'tente', +'try something like': 'tente algo como', +'Try the mobile interface': 'Try the mobile interface', +'Unable to check for upgrades': 'Não é possível checar as atualizações', +'unable to create application "%s"': 'não é possível criar a aplicação "%s"', +'unable to delete file "%(filename)s"': 'não é possível criar o arquico "%(filename)s"', +'unable to delete file plugin "%(plugin)s"': 'não é possível criar o plugin "%(plugin)s"', +'Unable to download': 'Não é possível efetuar o download', +'Unable to download app': 'Não é possível baixar a aplicação', +'Unable to download app because:': 'Não é possível baixar a aplicação porque:', +'Unable to download because': 'Não é possível baixar porque', +'unable to parse csv file': 'não é possível analisar o arquivo CSV', +'unable to uninstall "%s"': 'não é possível instalar "%s"', +'unable to upgrade because "%s"': 'não é possível atualizar porque "%s"', +'uncheck all': 'desmarcar todos', +'Uninstall': 'desinstalar', +'update': 'atualizar', +'update all languages': 'atualizar todas as linguagens', +'Update:': 'Atualizar:', +'upgrade now to %s': 'upgrade now to %s', +'upgrade web2py now': 'atualize o web2py agora', +'upload': 'upload', +'Upload': 'Upload', +'Upload & install packed application': 'Faça upload e instale uma aplicação empacotada', +'Upload a package:': 'Faça upload de um pacote:', +'Upload and install packed application': 'Upload and install packed application', +'upload application:': 'Fazer upload de uma aplicação:', +'Upload existing application': 'Faça upload de uma aplicação existente', +'upload file:': 'Enviar arquivo:', +'upload plugin file:': 'Enviar arquivo de plugin:', +'Use (...)&(...) for AND, (...)|(...) for OR, and ~(...) for NOT to build more complex queries.': 'Use (...)&(...) para AND, (...)|(...) para OR, y ~(...) para NOT, para criar consultas mais complexas.', +'Use an url:': 'Use uma url:', +'User ID': 'ID do Usuario', +'Username': 'Username', +'variables': 'variáveis', +'Version': 'Versão', +'versioning': 'versionamento', +'Versioning': 'Versioning', +'view': 'visão', +'Views': 'Visões', +'views': 'visões', +'Warning!': 'Warning!', +'Web Framework': 'Web Framework', +'web2py Admin Password': 'web2py Admin Password', +'web2py is up to date': 'web2py está atualizado', +'web2py Recent Tweets': 'Tweets Recentes de @web2py', +'web2py upgraded; please restart it': 'web2py atualizado; favor reiniciar', +'Welcome to web2py': 'Bem-vindo ao web2py', +'YES': 'SIM', +'You only need these if you have already registered': 'You only need these if you have already registered', +} diff --git a/applications/admin/static/css/bootstrap_adapters.css b/applications/admin/static/css/bootstrap_adapters.css deleted file mode 100644 index 9ac8ad5d..00000000 --- a/applications/admin/static/css/bootstrap_adapters.css +++ /dev/null @@ -1,579 +0,0 @@ -/*============================================================= - GENERAL -==============================================================*/ -html,body{height:auto;background:transparent;} -/*============================================================= - CONTROLS -==============================================================*/ -label, -input, -button, -select, -textarea, -button.btn -{ -font-size:13px; -font-weight:normal; -line-height:18px; -} -textarea, -select -{ -margin-bottom:9px; -} -select, -/*textarea,*/ -input[type="text"], -input[type="password"], -input[type="datetime"], -input[type="datetime-local"], -input[type="date"], -input[type="month"], -input[type="time"], -input[type="week"], -input[type="number"], -input[type="email"], -input[type="url"], -input[type="search"], -input[type="tel"], -input[type="color"], -.uneditable-input, -a.btn-lnk -{ -height:18px; -padding:4px; -font-size:13px; -line-height:18px; -} -.design h3, -.plugin h3 -{ -background-position:0 2px; -} - -select, -input[type="file"] -{ -height:28px; -line-height:28px; -} -input[type="submit"], -input[type="button"] -{ -font-size:13px; -height:28px; -line-height:18px; -padding:4px 10px; -} -input[type="radio"], -input[type="checkbox"] -{ -margin-top:2px; -} -.button.btn -{ -line-height:1.25em; -font-size:inherit; -border:none; -text-shadow:none; -margin-bottom:0px; --webkit-border-radius:0px; - -moz-border-radius:0px; - border-radius:0px; --webkit-box-shadow:none; - -moz-box-shadow:none; - box-shadow:none); -} -.button.btn:hover -{ -background-color:transparent; --webkit-transition: background-position 0s linear; - -moz-transition: background-position 0s linear; - -o-transition: background-position 0s linear; - transition: background-position 0s linear; -} -form label -{ -font-weight:bold; -} -.help -{ -border-color:transparent; -} -/* tree menu */ -.folder -{ -border:none; -} -.folder>i -{ -display:none; -} -.celled -{ -padding-top: 2px; -} -.celled-one -{ -padding-top: 1px; -} - -.test h3 -{ -border:0; -padding-left:18px; -} -/*============================================================= - FLASH MESSAGEBOX -==============================================================*/ -.flash -{ -position:fixed; -width:50%; -top:49px; -left:25%; -right:25%; -cursor:default; -text-align:center; -padding:8px 35px 8px 14px; -z-index:5620; -} -.flash>.close -{ -color:inherit; -opacity:0.7; -} -.flash>.close:hover -{ -opacity:0.9; -} -/*============================================================= - NAVBAR -==============================================================*/ -.navbar-fixed-top .navbar-inner, -.navbar-static-top .navbar-inner -{ -/* in place of shadow image */ --webkit-box-shadow:0px 10px 20px rgba(195,195,195,1.0); --moz-box-shadow: 0px 10px 20px rgba(195,195,195,1.0); -box-shadow: 0px 10px 20px rgba(195,195,195,1.0); -//zoom:1; /* IE6-9 */ -filter:progid:DXImageTransform.Microsoft.DropShadow(OffX=0, OffY=10, Color=#000000); /* IE6-9 */ -padding:0; -} -.navbar-inverse .navbar-inner -{ -min-height:33px; /* required - override */ -height:33px; -filter:progid:DXImageTransform.Microsoft.gradient(enabled=false); /* IE6-9 */ -background:#292929 url(../images/header_bg.png) repeat-x; -border:none; -} -#header -{ -background:transparent; -} -#header.navbar -{ -overflow:visible; -} -.navbar-inverse .nav > li > a -{ -padding:0; -line-height:1.25; -text-shadow:none; -} -.navbar .btn-navbar -{ -padding:4px; -margin:5px 5px 0 5px; -} -#menu{margin-right:-7px;} -/*============================================================= - FOOTER -==============================================================*/ -#footer -{ -padding-bottom:0; -} -/*============================================================= - MAIN -==============================================================*/ -#main -{ -position:static; -padding-top:0; -padding-bottom:0; -} -/*============================================================= - SIDEBAR -==============================================================*/ -.sidebar_inner -{ -background:transparent; -padding:0; -min-width:auto; -} -.sidebar .box { -border-top:1px solid #EEE; -} -/*============================================================= - WIZARD -==============================================================*/ -.step div.help li -{ -line-height:inherit; -} -.ms-container .ms-selectable li.ms-elem-selectable, -.ms-container .ms-selection li.ms-elem-selected -{ -font-size:13px; -} -.input-append a.btn -{ -padding:4px; -height:18px; -font-size:13px; -line-height:18px; -} -/*============================================================= - ERRORS TABLE -==============================================================*/ -.errors .table th -{ -filter:progid:DXImageTransform.Microsoft.gradient(enabled=false); /* IE6-9 */ -} - -.tablebar span.help -{ -font-weight:normal; -line-height:1.25em; -text-shadow:none; -width:auto; -} -/*============================================================= - TOOLTIP -==============================================================*/ -.tooltip.in -{ -opacity:1; -filter:alpha(opacity=100); -} -.tooltip-inner -{ -opacity:1; -text-align:left; -background:#9fb364; -color:#eef1d9; -border:1px solid #eef1d9; -font-style:italic; -padding:0.3em; --moz-border-radius:0.5em; -border-radius:0.5em; -font-size:13px; -text-transform:none; -} -.tooltip.right .tooltip-arrow, -.tooltip.left .tooltip-arrow -{ -border-color:transparent; -} - -/*============================================================= - THE GRID -==============================================================*/ -.w2p_grid_bottom_bar .w2p_export_menu -{ -line-height:18px; -margin-left:0; -} -.w2p_export_menu .dropdown-toggle -{ -cursor:pointer; -margin:0; -padding:0; -background-image: -webkit-gradient(linear, 0 0, 0 100%, from(white), to(#E6E6E6)); -background-image: -webkit-linear-gradient(top, white, #E6E6E6); -background-image: -o-linear-gradient(top, white, #E6E6E6); -background-image: linear-gradient(to bottom, white, #E6E6E6); -background-image: -moz-linear-gradient(top, white, #E6E6E6); -} -.w2p_export_menu ul -{ -margin-top:2px; -display:none; -} -.w2p_export_menu li -{ -display:list-item; -margin:0; -} -div.web2py_grid -{ -font-size:13px; -line-height:18px; -} -.web2py_grid a.btn -{ -font-size:13px; -line-height:18px; -padding:4px 10px; -margin-left:0; -margin-right:4px; - -background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), to(#e6e6e6)); -background-image: -webkit-linear-gradient(top, #ffffff, #e6e6e6); -background-image: -o-linear-gradient(top, #ffffff, #e6e6e6); -background-image: linear-gradient(to bottom, #ffffff, #e6e6e6); -background-image: -moz-linear-gradient(top, #ffffff, #e6e6e6); -} -.web2py_grid .input-append .btn -{ -padding:4px 10px; -margin-right:0; -font-family:inherit; -color:#333; -text-shadow:0 1px 1px rgba(255, 255, 255, 0.75); -border:1px solid #c5c5c5; -} -.web2py_grid select:focus -{ -border-color:rgba(232,149,60,0.8); -outline:0; --webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075),0 0 8px rgba(232, 149, 60, 0.6); --moz-box-shadow: inset 0 1px 1px rgba(0,0,0,0.075),0 0 8px rgba(232,149,60,0.6); -box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075),0 0 8px rgba(232, 149, 60, 0.6); -} -.web2py_console input[type="button"], -.web2py_grid .row_buttons a.btn -{ -color:#333; -line-height:18px; -padding:4px 10px; -text-shadow:rgba(255, 255, 255, 0.74902) 0px 1px 1px; -border-color:rgba(0, 0, 0, 0.15) rgba(0, 0, 0, 0.15) rgba(0, 0, 0, 0.25); --webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; -} -.web2py_console input[type="button"]:hover, -.web2py_grid .row_buttons a.btn:hover -{ -color:#333; -border-color:rgba(0, 0, 0, 0.15) rgba(0, 0, 0, 0.15) rgba(0, 0, 0, 0.25); -background:#E6E6E6; -background-position: 0 -15px !important; --webkit-transition: background-position .1s linear; --moz-transition: background-position .1s linear; --o-transition: background-position .1s linear; -transition: background-position .1s linear; -} -.web2py_table -{ -border:none; -} -.web2py_table table -{ -/*table-layout:fixed;*/ -margin-bottom:4px; -} -.web2py_table table td -{ -/*word-wrap:break-word;*/ /*uncomment when "table-layout:fixed" is applied */ -} - -.web2py_grid thead th -{ -background-color:transparent; -padding:4px 5px; -line-height:18px; -vertical-align:bottom; -border-right:0; -border-bottom:0; -word-wrap:break-word; -} -.web2py_grid .btn-group > .dropdown-menu -{ -font-size:13px; -} -.web2py_grid .dropdown-menu li > a:hover, -.web2py_grid .dropdown-menu li > a:focus -{ -filter:progid:DXImageTransform.Microsoft.gradient(enabled=false); /* IE6-9 */ -background-image:none; -background-color:#E8953C; -} -.pagination -{ -margin:0; -height:30px; -} -.pagination ul > li > a -{ -line-height:28px; -} - -#w2p_grid_addbtn:focus, -#w2p_search-form :focus, -.btn:focus -{ -outline:none; -} -.web2py_console input[type="button"]:focus, -.web2py_grid .row_buttons a.btn:focus - { -box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15) inset, 0 1px 2px rgba(0, 0, 0, 0.05); -} -div.web2py_counter.span6 -{ -min-height:20px; -} -.web2py_paginator -{ -border:0; -margin:0; -padding:0; -background-color:transparent; -} -.web2py_paginator ul li a -{ -margin-right:0; -padding:0 14px; -border:1px solid #DDD; -border-left-width:0; -color:#E8953C; -} -.web2py_paginator ul li a:hover -{ -background: whiteSmoke; -border: 1px solid #DDD; -border-left-width:0; -color:#e2821b; -} -.web2py_paginator ul li:first-child a, -.web2py_paginator ul li:first-child a:hover -{ -border-left-width:1px; -} -.web2py_paginator .current -{ -font-weight:normal; -} -.web2py_paginator ul li.current a:hover -{ -color:#999; -} - - - - - -.editor-bar-column a[name="save"] -{ -background-color: whiteSmoke; -background-image: -webkit-gradient(linear,0 0,0 100%,from(white),to(#E6E6E6)); -background-image: -webkit-linear-gradient(top,white,#E6E6E6); -background-image: -o-linear-gradient(top,white,#E6E6E6); -background-image: linear-gradient(to bottom,white,#E6E6E6); -background-image: -moz-linear-gradient(top,white,#E6E6E6); -background-repeat: repeat-x; -padding:2px 6px; -font-size:11px; -line-height:17px; -margin:0; -} -.editor-bar-column a[name="save"]:hover -{ -background-color: #E6E6E6; -background-position: 0 -15px; --webkit-transition: background-position .1s linear; --moz-transition: background-position .1s linear; --o-transition: background-position .1s linear; -transition: background-position .1s linear; -} -.keybindings -{ -padding:0 18px 10px; -} -.keybindings li -{ -margin-bottom:0; -} - -/*----- translate page ---*/ - -.languageform input -{ -margin-bottom:0; -} -.languageform div -{ -margin-bottom:9px; -} -.languageform input.untranslated -{ -background-color:#FC0; -} - -.step #wizard_nav .first-box -{ -padding-top:0; -} - -/*============================================================= - MEDIA QUERIES -==============================================================*/ -@media (max-width: 979px) -{ -/*----------------------------------- - Navbar --------------------------------------*/ - #header .navbar-inner - { - padding:0; - } - /*collapsed menu*/ - .navbar .nav-collapse .nav - { - background:#222; - padding:8px 2px 8px 8px; - -webkit-border-bottom-right-radius:8px; - -webkit-border-bottom-left-radius:8px; - -moz-border-radius-bottomright:8px; - -moz-border-radius-bottomleft:8px; - border-bottom-right-radius:8px; - border-bottom-left-radius:8px; - } - #menu - { - margin-right:0; - } - #menu li - { - float:none; - } - #menu a.button, - #menu a.button span - { - background-image:url(../images/menu_responsive.png); - } - #menu a.button - { - padding:0 1em 0 0; - } -} -@media(max-width:632px) -{ -/*----------------------------------- - footer --------------------------------------*/ - #footer - { - height:auto; - } - - #footer select - { - margin-top:8px; - } -} \ No newline at end of file diff --git a/applications/admin/static/css/bootstrap_essentials.css b/applications/admin/static/css/bootstrap_essentials.css index d4126f51..4343a844 100644 --- a/applications/admin/static/css/bootstrap_essentials.css +++ b/applications/admin/static/css/bootstrap_essentials.css @@ -476,7 +476,7 @@ h4.editableapp { background: #fff url(../images/folder.png) no-repeat; } h4.currentapp { background: #fff url(../images/folder_locked.png) no-repeat; } -.flash { position:fixed; width:50%; top:49px; left:25%; right:25%; cursor:default; text-align:center; z-index:5620; } +.w2p_flash { position:fixed; width:50%; top:49px; left:25%; right:25%; cursor:default; text-align:center; z-index:5620; } span#closeflash {position:absolute; top:1px; right:-1px; font-size:150%; border:1px solid black; border-color: transparent transparent #fbeed5 #fbeed5; border-radius: 0 0 0 4px; width:22px; } span#closeflash:hover {font-weight:bold; cursor:pointer; } diff --git a/applications/admin/static/css/web2py.css b/applications/admin/static/css/web2py.css deleted file mode 100644 index 9816e36c..00000000 --- a/applications/admin/static/css/web2py.css +++ /dev/null @@ -1,322 +0,0 @@ -/** these MUST stay **/ -a {text-decoration:none; white-space:nowrap} -a:hover {text-decoration:underline} -a.button {text-decoration:none} -h1,h2,h3,h4,h5,h6 {margin:0.5em 0 0.25em 0; display:block; - font-family:Helvetica} -h1 {font-size:4.00em} -h2 {font-size:3.00em} -h3 {font-size:2.00em} -h4 {font-size:1.50em} -h5 {font-size:1.25em} -h6 {font-size:1.12em} -th,label {font-weight:bold; white-space:nowrap;} -td,th {text-align:left; padding:2px 5px 2px 5px} -th {vertical-align:middle; border-right:1px solid white} -td {vertical-align:top} -form table tr td label {text-align:left} -p,table,ol,ul {padding:0; margin: 0.75em 0} -p {text-align:justify} -ol, ul {list-style-position:outside; margin-left:2em} -li {margin-bottom:0.5em} -span,input,select,textarea,button,label,a {display:inline} -img {border:0} -blockquote,blockquote p,p blockquote { - font-style:italic; margin:0.5em 30px 0.5em 30px; font-size:0.9em} -i,em {font-style:italic} -strong {font-weight:bold} -small {font-size:0.8em} -code {font-family:Courier} -textarea {width:100%} -video {width:400px} -audio {width:200px} -[type="text"], [type="password"], select { - margin-right: 5px; width: 300px; -} -.hidden {display:none;visibility:visible} -.right {float:right; text-align:right} -.left {float:left; text-align:left} -.center {width:100%; text-align:center; vertical-align:middle} -/** end **/ - -/* Sticky footer begin */ - -.main { - padding:20px 0 50px 0; -} - -.footer,.push { - height:6em; - padding:1em 0; - clear:both; -} - -.footer-content {position:relative; bottom:-4em; width:100%} - -.auth_navbar { - white-space:nowrap; -} - -/* Sticky footer end */ - -.footer { - border-top:1px #DEDEDE solid; -} -.header { - /* background:; */ -} - - -fieldset {padding:16px; border-top:1px #DEDEDE solid} -fieldset legend {text-transform:uppercase; font-weight:bold; padding:4px 16px 4px 16px; background:#f1f1f1} - -/* fix ie problem with menu */ - -td.w2p_fw {padding-bottom:1px} -td.w2p_fl,td.w2p_fw,td.w2p_fc {vertical-align:top} -td.w2p_fl {text-align:left} -td.w2p_fl, td.w2p_fw {padding-right:7px} -td.w2p_fl,td.w2p_fc {padding-top:4px} -div.w2p_export_menu {margin:5px 0} -div.w2p_export_menu a, div.w2p_wiki_tags a, div.w2p_cloud a {margin-left:5px; padding:2px 5px; background-color:#f1f1f1; border-radius:5px; -moz-border-radius:5px; -webkit-border-radius:5px;} - -/* tr#submit_record__row {border-top:1px solid #E5E5E5} */ -#submit_record__row td {padding-top:.5em} - -/* Fix */ -#auth_user_remember__row label {display:inline} -#web2py_user_form td {vertical-align:top} - -/*********** web2py specific ***********/ -div.flash { - font-weight:bold; - display:none; - position:fixed; - padding:10px; - top:48px; - right:250px; - min-width:280px; - opacity:0.95; - margin:0px 0px 10px 10px; - vertical-align:middle; - cursor:pointer; - color:#fff; - background-color:#000; - border:2px solid #fff; - border-radius:8px; - -o-border-radius: 8px; - -moz-border-radius:8px; - -webkit-border-radius:8px; - background-image: -webkit-linear-gradient(top,#222,#000); - background-image: -o-linear-gradient(top,#222,#000); - background-image: -moz-linear-gradient(90deg, #222, #000); - background-image: linear-gradient(top,#222,#000); - background-repeat: repeat-x; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - -webkit-font-smoothing: antialiased; - z-index:2000; -} - -div.flash #closeflash{color:inherit; float:right; margin-left:15px;} -.ie-lte7 div.flash #closeflash -{color:expression(this.parentNode.currentStyle['color']);float:none;position:absolute;right:4px;} - -div.flash:hover { opacity:0.25; } - -div.error_wrapper {display:block} -div.error { - width: 298px; - background:red; - border: 2px solid #d00; - color:white; - padding:5px; - display:inline-block; - background-image: -webkit-linear-gradient(left,#f00,#fdd); - background-image: -o-linear-gradient(left,#f00,#fdd); - background-image: -moz-linear-gradient(0deg, #f00, #fdd); - background-image: linear-gradient(left,#f00,#fdd); - background-repeat: repeat-y; -} - -.topbar { - padding:10px 0; - width:100%; - color:#959595; - vertical-align:middle; - padding:auto; - background-image:-khtml-gradient(linear,left top,left bottom,from(#333333),to(#222222)); - background-image:-moz-linear-gradient(top,#333333,#222222); - background-image:-ms-linear-gradient(top,#333333,#222222); - background-image:-webkit-gradient(linear,left top,left bottom,color-stop(0%,#333333),color-stop(100%,#222222)); - background-image:-webkit-linear-gradient(top,#333333,#222222); - background-image:-o-linear-gradient(top,#333333,#222222); - background-image:linear-gradient(top,#333333,#222222); - filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#333333',endColorstr='#222222',GradientType=0); - -webkit-box-shadow:0 1px 3px rgba(0,0,0,0.25),inset 0 -1px 0 rgba(0,0,0,0.1); - -moz-box-shadow:0 1px 3px rgba(0,0,0,0.25),inset 0 -1px 0 rgba(0,0,0,0.1); - box-shadow:0 1px 3px rgba(0,0,0,0.25),inset 0 -1px 0 rgba(0,0,0,0.1); -} - -.topbar a { - color:#e1e1e1; -} - -#navbar {float:right; padding:5px; /* same as superfish */} - -.statusbar { - background-color:#F5F5F5; - margin-top:1em; - margin-bottom:1em; - padding:.5em 1em; - border:1px solid #ddd; - border-radius:5px; - -moz-border-radius:5px; - -webkit-border-radius:5px; -} - -.breadcrumbs {float:left} - -.copyright {float:left} -#poweredBy {float:right} - -/* #MEDIA QUERIES SECTION */ - -/* -*Grid -* -* The default style for SQLFORM.grid even using jquery-iu or another ui framework -* will look better with the declarations below -* if needed to remove base.css consider keeping these following lines in some css file. -*/ -/* .web2py_table {border:1px solid #ccc} */ -.web2py_paginator {} -.web2py_grid {width:100%} -.web2py_grid table {width:100%} -.web2py_grid tbody td {padding:2px 5px 2px 5px; vertical-align: middle;} -.web2py_grid .web2py_form td {vertical-align: top;} - -.web2py_grid thead th,.web2py_grid tfoot td { - background-color:#EAEAEA; - padding:10px 5px 10px 5px; -} - -.web2py_grid tr.odd {background-color:#F9F9F9} -.web2py_grid tr:hover {background-color:#F5F5F5} - -/* -.web2py_breadcrumbs a { - line-height:20px; margin-right:5px; display:inline-block; - padding:3px 5px 3px 5px; - font-family:'lucida grande',tahoma,verdana,arial,sans-serif; - color:#3C3C3D; - text-shadow:1px 1px 0 #FFFFFF; - white-space:nowrap; overflow:visible; cursor:pointer; - background:#ECECEC; - border:1px solid #CACACA; - -webkit-border-radius:2px; -moz-border-radius:2px; - -webkit-background-clip:padding-box; border-radius:2px; - outline:none; position:relative; zoom:1; *display:inline; -} -*/ - -.web2py_console form { - width: 100%; - display: inline; - vertical-align: middle; - margin: 0 0 0 5px; -} - -.web2py_console form select { - margin:0; -} - -.web2py_search_actions { - float:left; - text-align:left; -} - -.web2py_grid .row_buttons { - min-height:25px; - vertical-align:middle; -} -.web2py_grid .row_buttons a { - margin:3px; -} - -.web2py_search_actions { - width:100%; -} - -.web2py_grid .row_buttons a, -.web2py_paginator ul li a, -.web2py_search_actions a, -.web2py_console input[type=submit], -.web2py_console input[type=button], -.web2py_console button { - line-height:20px; - margin-right:2px; display:inline-block; - padding:3px 5px 3px 5px; -} - -.web2py_counter { - margin-top:5px; - margin-right:2px; - width:35%; - float:right; - text-align:right; -} - -/*Fix firefox problem*/ -.web2py_table {clear:both; display:block} - -.web2py_paginator { - padding:5px; - text-align:right; - background-color:#f2f2f2; - -} -.web2py_paginator ul { - list-style-type:none; - margin:0px; - padding:0px; -} - -.web2py_paginator ul li { - display:inline; -} - -.web2py_paginator .current { - font-weight:bold; -} - -.web2py_breadcrumbs ul { - list-style:none; - margin-bottom:18px; -} - -li.w2p_grid_breadcrumb_elem { - display:inline-block; -} - -.web2py_console form { vertical-align: middle; } -.web2py_console input, .web2py_console select, -.web2py_console a { margin: 2px; } - -.web2py_htmltable { - width: 100%; - overflow-x: auto; - -ms-overflow-x:scroll; -} - -#wiki_page_body { - width: 600px; - height: auto; - min-height: 400px; -} - -/* fix some IE problems */ - -.ie-lte7 .topbar .container {z-index:2} -.ie-lte8 div.flash{ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#222222', endColorstr='#000000', GradientType=0 ); } -.ie-lte8 div.flash:hover {filter:alpha(opacity=25);} -.ie9 #w2p_query_panel {padding-bottom:2px} diff --git a/applications/admin/static/css/web2py_bootstrap.css b/applications/admin/static/css/web2py_bootstrap.css deleted file mode 100644 index a91465f1..00000000 --- a/applications/admin/static/css/web2py_bootstrap.css +++ /dev/null @@ -1,264 +0,0 @@ -/*============================================================= - CUSTOM RULES -==============================================================*/ - -body{height:auto;} /* to avoid vertical scroll bar */ - -a{} -a:visited{} -a:hover{} -a:focus{} -a:active{} - -h1{} -h2{} -h3{} -h4{} -h5{} -h6{} - -div.flash.flash-center{left:25%;right:25%;} -div.flash.flash-top,div.flash.flash-top:hover{ - position:relative; - display:block; - margin:0; - padding:1em; - top:0; - left:0; - width:100%; - text-align:center; - text-shadow:0 1px 0 rgba(255, 255, 255, 0.5); - color:#865100; - background:#feea9a; - border:1px solid; - border-top:0px; - border-left:0px; - border-right:0px; - border-radius:0; - opacity:1; -} -#header{margin-top:60px;} -.mastheader h1 { - margin-bottom:9px; - font-size:81px; - font-weight:bold; - letter-spacing:-1px; - line-height:1; - font-size:54px; -} -.mastheader small { - font-size:20px; - font-weight:300; -} -/* auth navbar - primitive style */ -.auth_navbar,.auth_navbar a{color:inherit;} -.navbar-inner {-webkit-border-radius:0;-moz-border-radius:0;border-radius:0} -.ie-lte7 .auth_navbar,.auth_navbar a{color:expression(this.parentNode.currentStyle['color']); /* ie7 doesn't support inherit */} -.auth_navbar a{white-space:nowrap;} /* to avoid the nav split on more lines */ -.auth_navbar a:hover{color:white;text-decoration:none;} -ul#navbar>.auth_navbar{ - display:inline-block; - padding:5px; -} -/* form errors message box customization */ -div.error_wrapper{margin-bottom:9px;} -div.error_wrapper .error{ - border-radius: 4px; - -o-border-radius: 4px; - -moz-border-radius: 4px; - -webkit-border-radius: 4px; -} -/* below rules are only for formstyle = bootstrap -trying to make errors look like bootstrap ones */ -div.controls .error_wrapper{ - display:inline-block; - margin-bottom:0; - vertical-align:middle; -} -div.controls .error{ - min-width:5px; - background:inherit; - color:#B94A48; - border:none; - padding:0; - margin:0; - /*display:inline;*/ /* uncommenting this, the animation effect is lost */ -} -div.controls .help-inline{color:#3A87AD;} -div.controls .error_wrapper +.help-inline {margin-left:-99999px;} -div.controls select +.error_wrapper {margin-left:5px;} -.ie-lte7 div.error{color:#fff;} - -/* beautify brand */ -.navbar {margin-bottom:0} -.navbar-inverse .brand{color:#c6cecc;} -.navbar-inverse .brand b{display:inline-block;margin-top:-1px;} -.navbar-inverse .brand b>span{font-size:22px;color:white} -.navbar-inverse .brand:hover b>span{color:white} -/* beautify web2py link in navbar */ -span.highlighted{color:#d8d800;} -.open span.highlighted{color:#ffff00;} - -/*============================================================= - OVERRIDING WEB2PY.CSS RULES -==============================================================*/ - -/* reset to default */ -a{white-space:normal;} -li{margin-bottom:0;} -textarea,button{display:block;} -/*reset ul padding */ -ul#navbar{padding:0;} -/* label aligned to related input */ -td.w2p_fl,td.w2p_fc {padding:0;} -#web2py_user_form td{vertical-align:middle;} - -/*============================================================= - OVERRIDING BOOTSTRAP.CSS RULES -==============================================================*/ - -/* because web2py handles this via js */ -textarea { width:90%} -.hidden{visibility:visible;} -/* right folder for bootstrap black images/icons */ -[class^="icon-"],[class*=" icon-"]{ - background-image:url("../images/glyphicons-halflings.png") -} -/* right folder for bootstrap white images/icons */ -.icon-white, -.nav-tabs > .active > a > [class^="icon-"], -.nav-tabs > .active > a > [class*=" icon-"], -.nav-pills > .active > a > [class^="icon-"], -.nav-pills > .active > a > [class*=" icon-"], -.nav-list > .active > a > [class^="icon-"], -.nav-list > .active > a > [class*=" icon-"], -.navbar-inverse .nav > .active > a > [class^="icon-"], -.navbar-inverse .nav > .active > a > [class*=" icon-"], -.dropdown-menu > li > a:hover > [class^="icon-"], -.dropdown-menu > li > a:hover > [class*=" icon-"], -.dropdown-menu > .active > a > [class^="icon-"], -.dropdown-menu > .active > a > [class*=" icon-"] { - background-image:url("../images/glyphicons-halflings-white.png"); -} -/* bootstrap has a label as input's wrapper while web2py has a div */ -div>input[type="radio"],div>input[type="checkbox"]{margin:0;} -/* bootstrap has button instead of input */ -input[type="button"], input[type="submit"]{margin-right:8px;} - -/* web2py radio widget adjustment */ -.generic-widget input[type='radio'] {margin:-1px 0 0 0; vertical-align: middle;} -.generic-widget input[type='radio'] + label {display:inline-block; margin:0 0 0 6px; vertical-align: middle;} - -/*============================================================= -RULES FOR SOLVING CONFLICTS BETWEEN WEB2PY.CSS AND BOOTSTRAP.CSS -==============================================================*/ - -/*when formstyle=table3cols*/ -tr#auth_user_remember__row>td.w2p_fw>div{padding-bottom:8px;} -td.w2p_fw div>label{vertical-align:middle;} -td.w2p_fc {padding-bottom:5px;} -/*when formstyle=divs*/ -div#auth_user_remember__row{margin-top:4px;} -div#auth_user_remember__row>.w2p_fl{display:none;} -div#auth_user_remember__row>.w2p_fw{min-height:39px;} -div.w2p_fw,div.w2p_fc{ - display:inline-block; - vertical-align:middle; - margin-bottom:0; -} -div.w2p_fc{ - padding-left:5px; - margin-top:-8px; -} -/*when formstyle=ul*/ -form>ul{ - list-style:none; - margin:0; -} -li#auth_user_remember__row{margin-top:4px;} -li#auth_user_remember__row>.w2p_fl{display:none;} -li#auth_user_remember__row>.w2p_fw{min-height:39px;} -/*when formstyle=bootstrap*/ -#auth_user_remember__row label.checkbox{display:block;} -span.inline-help{display:inline-block;} -input[type="text"].input-xlarge,input[type="password"].input-xlarge{width:270px;} -/*when recaptcha is used*/ -#recaptcha{min-height:30px;display:inline-block;margin-bottom:0;line-height:30px;vertical-align:middle;} -td>#recaptcha{margin-bottom:6px;} -div>#recaptcha{margin-bottom:9px;} -div.control-group.error{ -width:auto; -background:transparent; -border:0; -color:inherit; -padding:0; -background-repeat:repeat; -} - -/*============================================================= - OTHER RULES -==============================================================*/ - -/* Massimo Di Pierro fixed alignment in forms with list:string */ -form table tr{margin-bottom:9px;} -td.w2p_fw ul{margin-left:0px;} - -/* web2py_console in grid and smartgrid */ -.hidden{visibility:visible;} -.web2py_console input{ - display: inline-block; - margin-bottom: 0; - vertical-align: middle; -} -.web2py_console input[type="submit"], -.web2py_console input[type="button"], -.web2py_console button{ - padding-top:4px; - padding-bottom:4px; - margin:3px 0 0 2px; -} -.web2py_console a, -.web2py_console select, -.web2py_console input -{ - margin:3px 0 0 2px; -} -.web2py_grid form table{width:auto;} -/* auth_user_remember checkbox extrapadding in IE fix */ -.ie-lte9 input#auth_user_remember.checkbox {padding-left:0;} - -div.controls .error { - width: auto; -} - -/*============================================================= - MEDIA QUERIES -==============================================================*/ - -@media only screen and (max-width:979px){ - body{padding-top:0px;} - #navbar{/*top:5px;*/} - div.flash{right:5px;} - .dropdown-menu ul{visibility:visible;} -} - -@media only screen and (max-width:479px){ - body{ - padding-left:10px; - padding-right:10px; - } - .navbar-fixed-top,.navbar-fixed-bottom { - margin-left:-10px; - margin-right:-10px; - } - input[type="text"],input[type="password"],select{ - width:95%; - } -} - -@media (max-width: 767px) { - .navbar { - margin-right: -20px; - margin-left: -20px; - } -} diff --git a/applications/admin/static/css/web2py_bootstrap_nojs.css b/applications/admin/static/css/web2py_bootstrap_nojs.css deleted file mode 100644 index 0ec7312f..00000000 --- a/applications/admin/static/css/web2py_bootstrap_nojs.css +++ /dev/null @@ -1,122 +0,0 @@ -/*============================================================= - BOOTSTRAP DROPDOWN MENU -==============================================================*/ - -.dropdown-menu ul{ - left:100%; - position:absolute; - top:0; - visibility:hidden; - margin-top:-1px; -} -.dropdown-menu li:hover ul{visibility:visible;} -.navbar .dropdown-menu ul:before{ - border-bottom:7px solid transparent; - border-left:none; - border-right:7px solid rgba(0, 0, 0, 0.2); - border-top:7px solid transparent; - left:-7px; - top:5px; -} -.nav > li.dropdown > a:after { - border-left: 4px solid transparent; - border-right: 4px solid transparent; - border-top: 4px solid #000000; - content: ""; - display: inline-block; - height: 0; - opacity: 0.7; - vertical-align: top; - width: 0; - - margin-left: 2px; - margin-top: 8px; - - border-bottom-color: #FFFFFF; - border-top-color: #FFFFFF; -} -.dropdown-menu span{display:inline-block;} -ul.dropdown-menu li.dropdown > a:after { - border-left: 4px solid #000; - border-right: 4px solid transparent; - border-bottom: 4px solid transparent; - border-top: 4px solid transparent; - content: ""; - display: inline-block; - height: 0; - opacity: 0.7; - vertical-align: top; - width: 0; - - margin-left: 8px; - margin-top: 6px; -} - -ul.nav li.dropdown:hover ul.dropdown-menu { - display: block; -} - -.open >.dropdown-menu ul{display:block;} /* fix menu issue when BS2.0.4 is applied */ - -/*============================================================= - BOOTSTRAP SUBMIT BUTTON -==============================================================*/ - -input[type='submit']:not(.btn) { -display: inline-block; -padding: 4px 14px; -margin-bottom: 0; -font-size: 14px; -line-height: 20px; -color: #333; -text-align: center; -text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75); -vertical-align: middle; -cursor: pointer; -background-color: whiteSmoke; -background-image: -webkit-gradient(linear,0 0,0 100%,from(white),to(#E6E6E6)); -background-image: -webkit-linear-gradient(top,white,#E6E6E6); -background-image: -o-linear-gradient(top,white,#E6E6E6); -background-image: linear-gradient(to bottom,white,#E6E6E6); -background-image: -moz-linear-gradient(top,white,#E6E6E6); -background-repeat: repeat-x; -border: 1px solid #BBB; -border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); -border-bottom-color: #A2A2A2; --webkit-border-radius: 4px; --moz-border-radius: 4px; -border-radius: 4px; -filter: progid:dximagetransform.microsoft.gradient(startColorstr='#ffffffff',endColorstr='#ffe6e6e6',GradientType=0); -filter: progid:dximagetransform.microsoft.gradient(enabled=false); --webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05); --moz-box-shadow: inset 0 1px 0 rgba(255,255,255,0.2),0 1px 2px rgba(0,0,0,0.05); -box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05); -} - -input[type='submit']:not(.btn):hover { -color: #333; -text-decoration: none; -background-color: #E6E6E6; -background-position: 0 -15px; --webkit-transition: background-position .1s linear; --moz-transition: background-position .1s linear; --o-transition: background-position .1s linear; -transition: background-position .1s linear; -} - -input[type='submit']:not(.btn).active, input[type='submit']:not(.btn):active { -background-color: #E6E6E6; -background-color: #D9D9D9 9; -background-image: none; -outline: 0; --webkit-box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05); --moz-box-shadow: inset 0 2px 4px rgba(0,0,0,0.15),0 1px 2px rgba(0,0,0,0.05); -box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05); -} - -/*============================================================= - OTHER -==============================================================*/ - -.ie-lte8 .navbar-fixed-top {position:static;} - diff --git a/applications/admin/static/js/ajax_editor.js b/applications/admin/static/js/ajax_editor.js index 54f74625..d7521838 100644 --- a/applications/admin/static/js/ajax_editor.js +++ b/applications/admin/static/js/ajax_editor.js @@ -77,10 +77,10 @@ function doClickSave() { t.attr('disabled', ''); var flash = xhr.getResponseHeader('web2py-component-flash'); if(flash) { - $('.flash').html(decodeURIComponent(flash)) + $('.w2p_flash').html(decodeURIComponent(flash)) .append('×') .slideDown(); - } else $('.flash').hide(); + } else $('.w2p_flash').hide(); try { if(json.error) { window.location.href = json.redirect; @@ -158,10 +158,10 @@ function doToggleBreakpoint(filename, url, sel) { // show flash message (if any) var flash = xhr.getResponseHeader('web2py-component-flash'); if(flash) { - $('.flash').html(decodeURIComponent(flash)) + $('.w2p_flash').html(decodeURIComponent(flash)) .append('×') .slideDown(); - } else $('.flash').hide(); + } else $('.w2p_flash').hide(); try { if(json.error) { window.location.href = json.redirect; diff --git a/applications/admin/static/js/jquery.js b/applications/admin/static/js/jquery.js index e6a051d0..0f60b7bd 100644 --- a/applications/admin/static/js/jquery.js +++ b/applications/admin/static/js/jquery.js @@ -1,4 +1,5 @@ -/*! jQuery v1.11.2 | (c) 2005, 2014 jQuery Foundation, Inc. | jquery.org/license */ -!function(a,b){"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){var c=[],d=c.slice,e=c.concat,f=c.push,g=c.indexOf,h={},i=h.toString,j=h.hasOwnProperty,k={},l="1.11.2",m=function(a,b){return new m.fn.init(a,b)},n=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,o=/^-ms-/,p=/-([\da-z])/gi,q=function(a,b){return b.toUpperCase()};m.fn=m.prototype={jquery:l,constructor:m,selector:"",length:0,toArray:function(){return d.call(this)},get:function(a){return null!=a?0>a?this[a+this.length]:this[a]:d.call(this)},pushStack:function(a){var b=m.merge(this.constructor(),a);return b.prevObject=this,b.context=this.context,b},each:function(a,b){return m.each(this,a,b)},map:function(a){return this.pushStack(m.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(d.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(0>a?b:0);return this.pushStack(c>=0&&b>c?[this[c]]:[])},end:function(){return this.prevObject||this.constructor(null)},push:f,sort:c.sort,splice:c.splice},m.extend=m.fn.extend=function(){var a,b,c,d,e,f,g=arguments[0]||{},h=1,i=arguments.length,j=!1;for("boolean"==typeof g&&(j=g,g=arguments[h]||{},h++),"object"==typeof g||m.isFunction(g)||(g={}),h===i&&(g=this,h--);i>h;h++)if(null!=(e=arguments[h]))for(d in e)a=g[d],c=e[d],g!==c&&(j&&c&&(m.isPlainObject(c)||(b=m.isArray(c)))?(b?(b=!1,f=a&&m.isArray(a)?a:[]):f=a&&m.isPlainObject(a)?a:{},g[d]=m.extend(j,f,c)):void 0!==c&&(g[d]=c));return g},m.extend({expando:"jQuery"+(l+Math.random()).replace(/\D/g,""),isReady:!0,error:function(a){throw new Error(a)},noop:function(){},isFunction:function(a){return"function"===m.type(a)},isArray:Array.isArray||function(a){return"array"===m.type(a)},isWindow:function(a){return null!=a&&a==a.window},isNumeric:function(a){return!m.isArray(a)&&a-parseFloat(a)+1>=0},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},isPlainObject:function(a){var b;if(!a||"object"!==m.type(a)||a.nodeType||m.isWindow(a))return!1;try{if(a.constructor&&!j.call(a,"constructor")&&!j.call(a.constructor.prototype,"isPrototypeOf"))return!1}catch(c){return!1}if(k.ownLast)for(b in a)return j.call(a,b);for(b in a);return void 0===b||j.call(a,b)},type:function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?h[i.call(a)]||"object":typeof a},globalEval:function(b){b&&m.trim(b)&&(a.execScript||function(b){a.eval.call(a,b)})(b)},camelCase:function(a){return a.replace(o,"ms-").replace(p,q)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()},each:function(a,b,c){var d,e=0,f=a.length,g=r(a);if(c){if(g){for(;f>e;e++)if(d=b.apply(a[e],c),d===!1)break}else for(e in a)if(d=b.apply(a[e],c),d===!1)break}else if(g){for(;f>e;e++)if(d=b.call(a[e],e,a[e]),d===!1)break}else for(e in a)if(d=b.call(a[e],e,a[e]),d===!1)break;return a},trim:function(a){return null==a?"":(a+"").replace(n,"")},makeArray:function(a,b){var c=b||[];return null!=a&&(r(Object(a))?m.merge(c,"string"==typeof a?[a]:a):f.call(c,a)),c},inArray:function(a,b,c){var d;if(b){if(g)return g.call(b,a,c);for(d=b.length,c=c?0>c?Math.max(0,d+c):c:0;d>c;c++)if(c in b&&b[c]===a)return c}return-1},merge:function(a,b){var c=+b.length,d=0,e=a.length;while(c>d)a[e++]=b[d++];if(c!==c)while(void 0!==b[d])a[e++]=b[d++];return a.length=e,a},grep:function(a,b,c){for(var d,e=[],f=0,g=a.length,h=!c;g>f;f++)d=!b(a[f],f),d!==h&&e.push(a[f]);return e},map:function(a,b,c){var d,f=0,g=a.length,h=r(a),i=[];if(h)for(;g>f;f++)d=b(a[f],f,c),null!=d&&i.push(d);else for(f in a)d=b(a[f],f,c),null!=d&&i.push(d);return e.apply([],i)},guid:1,proxy:function(a,b){var c,e,f;return"string"==typeof b&&(f=a[b],b=a,a=f),m.isFunction(a)?(c=d.call(arguments,2),e=function(){return a.apply(b||this,c.concat(d.call(arguments)))},e.guid=a.guid=a.guid||m.guid++,e):void 0},now:function(){return+new Date},support:k}),m.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(a,b){h["[object "+b+"]"]=b.toLowerCase()});function r(a){var b=a.length,c=m.type(a);return"function"===c||m.isWindow(a)?!1:1===a.nodeType&&b?!0:"array"===c||0===b||"number"==typeof b&&b>0&&b-1 in a}var s=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=hb(),z=hb(),A=hb(),B=function(a,b){return a===b&&(l=!0),0},C=1<<31,D={}.hasOwnProperty,E=[],F=E.pop,G=E.push,H=E.push,I=E.slice,J=function(a,b){for(var c=0,d=a.length;d>c;c++)if(a[c]===b)return c;return-1},K="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",L="[\\x20\\t\\r\\n\\f]",M="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",N=M.replace("w","w#"),O="\\["+L+"*("+M+")(?:"+L+"*([*^$|!~]?=)"+L+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+N+"))|)"+L+"*\\]",P=":("+M+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+O+")*)|.*)\\)|)",Q=new RegExp(L+"+","g"),R=new RegExp("^"+L+"+|((?:^|[^\\\\])(?:\\\\.)*)"+L+"+$","g"),S=new RegExp("^"+L+"*,"+L+"*"),T=new RegExp("^"+L+"*([>+~]|"+L+")"+L+"*"),U=new RegExp("="+L+"*([^\\]'\"]*?)"+L+"*\\]","g"),V=new RegExp(P),W=new RegExp("^"+N+"$"),X={ID:new RegExp("^#("+M+")"),CLASS:new RegExp("^\\.("+M+")"),TAG:new RegExp("^("+M.replace("w","w*")+")"),ATTR:new RegExp("^"+O),PSEUDO:new RegExp("^"+P),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+L+"*(even|odd|(([+-]|)(\\d*)n|)"+L+"*(?:([+-]|)"+L+"*(\\d+)|))"+L+"*\\)|)","i"),bool:new RegExp("^(?:"+K+")$","i"),needsContext:new RegExp("^"+L+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+L+"*((?:-\\d)?\\d*)"+L+"*\\)|)(?=[^-]|$)","i")},Y=/^(?:input|select|textarea|button)$/i,Z=/^h\d$/i,$=/^[^{]+\{\s*\[native \w/,_=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ab=/[+~]/,bb=/'|\\/g,cb=new RegExp("\\\\([\\da-f]{1,6}"+L+"?|("+L+")|.)","ig"),db=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:0>d?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},eb=function(){m()};try{H.apply(E=I.call(v.childNodes),v.childNodes),E[v.childNodes.length].nodeType}catch(fb){H={apply:E.length?function(a,b){G.apply(a,I.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function gb(a,b,d,e){var f,h,j,k,l,o,r,s,w,x;if((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,d=d||[],k=b.nodeType,"string"!=typeof a||!a||1!==k&&9!==k&&11!==k)return d;if(!e&&p){if(11!==k&&(f=_.exec(a)))if(j=f[1]){if(9===k){if(h=b.getElementById(j),!h||!h.parentNode)return d;if(h.id===j)return d.push(h),d}else if(b.ownerDocument&&(h=b.ownerDocument.getElementById(j))&&t(b,h)&&h.id===j)return d.push(h),d}else{if(f[2])return H.apply(d,b.getElementsByTagName(a)),d;if((j=f[3])&&c.getElementsByClassName)return H.apply(d,b.getElementsByClassName(j)),d}if(c.qsa&&(!q||!q.test(a))){if(s=r=u,w=b,x=1!==k&&a,1===k&&"object"!==b.nodeName.toLowerCase()){o=g(a),(r=b.getAttribute("id"))?s=r.replace(bb,"\\$&"):b.setAttribute("id",s),s="[id='"+s+"'] ",l=o.length;while(l--)o[l]=s+rb(o[l]);w=ab.test(a)&&pb(b.parentNode)||b,x=o.join(",")}if(x)try{return H.apply(d,w.querySelectorAll(x)),d}catch(y){}finally{r||b.removeAttribute("id")}}}return i(a.replace(R,"$1"),b,d,e)}function hb(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ib(a){return a[u]=!0,a}function jb(a){var b=n.createElement("div");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function kb(a,b){var c=a.split("|"),e=a.length;while(e--)d.attrHandle[c[e]]=b}function lb(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&(~b.sourceIndex||C)-(~a.sourceIndex||C);if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function mb(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function nb(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function ob(a){return ib(function(b){return b=+b,ib(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function pb(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=gb.support={},f=gb.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return b?"HTML"!==b.nodeName:!1},m=gb.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=g.documentElement,e=g.defaultView,e&&e!==e.top&&(e.addEventListener?e.addEventListener("unload",eb,!1):e.attachEvent&&e.attachEvent("onunload",eb)),p=!f(g),c.attributes=jb(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=jb(function(a){return a.appendChild(g.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=$.test(g.getElementsByClassName),c.getById=jb(function(a){return o.appendChild(a).id=u,!g.getElementsByName||!g.getElementsByName(u).length}),c.getById?(d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c&&c.parentNode?[c]:[]}},d.filter.ID=function(a){var b=a.replace(cb,db);return function(a){return a.getAttribute("id")===b}}):(delete d.find.ID,d.filter.ID=function(a){var b=a.replace(cb,db);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){return p?b.getElementsByClassName(a):void 0},r=[],q=[],(c.qsa=$.test(g.querySelectorAll))&&(jb(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+L+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+L+"*(?:value|"+K+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),jb(function(a){var b=g.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+L+"*[*^$|!~]?="),a.querySelectorAll(":enabled").length||q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=$.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&jb(function(a){c.disconnectedMatch=s.call(a,"div"),s.call(a,"[s!='']:x"),r.push("!=",P)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=$.test(o.compareDocumentPosition),t=b||$.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===g||a.ownerDocument===v&&t(v,a)?-1:b===g||b.ownerDocument===v&&t(v,b)?1:k?J(k,a)-J(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,h=[a],i=[b];if(!e||!f)return a===g?-1:b===g?1:e?-1:f?1:k?J(k,a)-J(k,b):0;if(e===f)return lb(a,b);c=a;while(c=c.parentNode)h.unshift(c);c=b;while(c=c.parentNode)i.unshift(c);while(h[d]===i[d])d++;return d?lb(h[d],i[d]):h[d]===v?-1:i[d]===v?1:0},g):n},gb.matches=function(a,b){return gb(a,null,null,b)},gb.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(U,"='$1']"),!(!c.matchesSelector||!p||r&&r.test(b)||q&&q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return gb(b,n,null,[a]).length>0},gb.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},gb.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&D.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},gb.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},gb.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=gb.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=gb.selectors={cacheLength:50,createPseudo:ib,match:X,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(cb,db),a[3]=(a[3]||a[4]||a[5]||"").replace(cb,db),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||gb.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&gb.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return X.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&V.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(cb,db).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+L+")"+a+"("+L+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=gb.attr(d,a);return null==e?"!="===b:b?(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(Q," ")+" ").indexOf(c)>-1:"|="===b?e===c||e.slice(0,c.length+1)===c+"-":!1):!0}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h;if(q){if(f){while(p){l=b;while(l=l[p])if(h?l.nodeName.toLowerCase()===r:1===l.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){k=q[u]||(q[u]={}),j=k[a]||[],n=j[0]===w&&j[1],m=j[0]===w&&j[2],l=n&&q.childNodes[n];while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if(1===l.nodeType&&++m&&l===b){k[a]=[w,n,m];break}}else if(s&&(j=(b[u]||(b[u]={}))[a])&&j[0]===w)m=j[1];else while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if((h?l.nodeName.toLowerCase()===r:1===l.nodeType)&&++m&&(s&&((l[u]||(l[u]={}))[a]=[w,m]),l===b))break;return m-=e,m===d||m%d===0&&m/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||gb.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ib(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=J(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ib(function(a){var b=[],c=[],d=h(a.replace(R,"$1"));return d[u]?ib(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ib(function(a){return function(b){return gb(a,b).length>0}}),contains:ib(function(a){return a=a.replace(cb,db),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ib(function(a){return W.test(a||"")||gb.error("unsupported lang: "+a),a=a.replace(cb,db).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:function(a){return a.disabled===!1},disabled:function(a){return a.disabled===!0},checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return Z.test(a.nodeName)},input:function(a){return Y.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:ob(function(){return[0]}),last:ob(function(a,b){return[b-1]}),eq:ob(function(a,b,c){return[0>c?c+b:c]}),even:ob(function(a,b){for(var c=0;b>c;c+=2)a.push(c);return a}),odd:ob(function(a,b){for(var c=1;b>c;c+=2)a.push(c);return a}),lt:ob(function(a,b,c){for(var d=0>c?c+b:c;--d>=0;)a.push(d);return a}),gt:ob(function(a,b,c){for(var d=0>c?c+b:c;++db;b++)d+=a[b].value;return d}function sb(a,b,c){var d=b.dir,e=c&&"parentNode"===d,f=x++;return b.first?function(b,c,f){while(b=b[d])if(1===b.nodeType||e)return a(b,c,f)}:function(b,c,g){var h,i,j=[w,f];if(g){while(b=b[d])if((1===b.nodeType||e)&&a(b,c,g))return!0}else while(b=b[d])if(1===b.nodeType||e){if(i=b[u]||(b[u]={}),(h=i[d])&&h[0]===w&&h[1]===f)return j[2]=h[2];if(i[d]=j,j[2]=a(b,c,g))return!0}}}function tb(a){return a.length>1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function ub(a,b,c){for(var d=0,e=b.length;e>d;d++)gb(a,b[d],c);return c}function vb(a,b,c,d,e){for(var f,g=[],h=0,i=a.length,j=null!=b;i>h;h++)(f=a[h])&&(!c||c(f,d,e))&&(g.push(f),j&&b.push(h));return g}function wb(a,b,c,d,e,f){return d&&!d[u]&&(d=wb(d)),e&&!e[u]&&(e=wb(e,f)),ib(function(f,g,h,i){var j,k,l,m=[],n=[],o=g.length,p=f||ub(b||"*",h.nodeType?[h]:h,[]),q=!a||!f&&b?p:vb(p,m,a,h,i),r=c?e||(f?a:o||d)?[]:g:q;if(c&&c(q,r,h,i),d){j=vb(r,n),d(j,[],h,i),k=j.length;while(k--)(l=j[k])&&(r[n[k]]=!(q[n[k]]=l))}if(f){if(e||a){if(e){j=[],k=r.length;while(k--)(l=r[k])&&j.push(q[k]=l);e(null,r=[],j,i)}k=r.length;while(k--)(l=r[k])&&(j=e?J(f,l):m[k])>-1&&(f[j]=!(g[j]=l))}}else r=vb(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):H.apply(g,r)})}function xb(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=sb(function(a){return a===b},h,!0),l=sb(function(a){return J(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];f>i;i++)if(c=d.relative[a[i].type])m=[sb(tb(m),c)];else{if(c=d.filter[a[i].type].apply(null,a[i].matches),c[u]){for(e=++i;f>e;e++)if(d.relative[a[e].type])break;return wb(i>1&&tb(m),i>1&&rb(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(R,"$1"),c,e>i&&xb(a.slice(i,e)),f>e&&xb(a=a.slice(e)),f>e&&rb(a))}m.push(c)}return tb(m)}function yb(a,b){var c=b.length>0,e=a.length>0,f=function(f,g,h,i,k){var l,m,o,p=0,q="0",r=f&&[],s=[],t=j,u=f||e&&d.find.TAG("*",k),v=w+=null==t?1:Math.random()||.1,x=u.length;for(k&&(j=g!==n&&g);q!==x&&null!=(l=u[q]);q++){if(e&&l){m=0;while(o=a[m++])if(o(l,g,h)){i.push(l);break}k&&(w=v)}c&&((l=!o&&l)&&p--,f&&r.push(l))}if(p+=q,c&&q!==p){m=0;while(o=b[m++])o(r,s,g,h);if(f){if(p>0)while(q--)r[q]||s[q]||(s[q]=F.call(i));s=vb(s)}H.apply(i,s),k&&!f&&s.length>0&&p+b.length>1&&gb.uniqueSort(i)}return k&&(w=v,j=t),r};return c?ib(f):f}return h=gb.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=xb(b[c]),f[u]?d.push(f):e.push(f);f=A(a,yb(e,d)),f.selector=a}return f},i=gb.select=function(a,b,e,f){var i,j,k,l,m,n="function"==typeof a&&a,o=!f&&g(a=n.selector||a);if(e=e||[],1===o.length){if(j=o[0]=o[0].slice(0),j.length>2&&"ID"===(k=j[0]).type&&c.getById&&9===b.nodeType&&p&&d.relative[j[1].type]){if(b=(d.find.ID(k.matches[0].replace(cb,db),b)||[])[0],!b)return e;n&&(b=b.parentNode),a=a.slice(j.shift().value.length)}i=X.needsContext.test(a)?0:j.length;while(i--){if(k=j[i],d.relative[l=k.type])break;if((m=d.find[l])&&(f=m(k.matches[0].replace(cb,db),ab.test(j[0].type)&&pb(b.parentNode)||b))){if(j.splice(i,1),a=f.length&&rb(j),!a)return H.apply(e,f),e;break}}}return(n||h(a,o))(f,b,!p,e,ab.test(a)&&pb(b.parentNode)||b),e},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=jb(function(a){return 1&a.compareDocumentPosition(n.createElement("div"))}),jb(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||kb("type|href|height|width",function(a,b,c){return c?void 0:a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&jb(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||kb("value",function(a,b,c){return c||"input"!==a.nodeName.toLowerCase()?void 0:a.defaultValue}),jb(function(a){return null==a.getAttribute("disabled")})||kb(K,function(a,b,c){var d;return c?void 0:a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),gb}(a);m.find=s,m.expr=s.selectors,m.expr[":"]=m.expr.pseudos,m.unique=s.uniqueSort,m.text=s.getText,m.isXMLDoc=s.isXML,m.contains=s.contains;var t=m.expr.match.needsContext,u=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,v=/^.[^:#\[\.,]*$/;function w(a,b,c){if(m.isFunction(b))return m.grep(a,function(a,d){return!!b.call(a,d,a)!==c});if(b.nodeType)return m.grep(a,function(a){return a===b!==c});if("string"==typeof b){if(v.test(b))return m.filter(b,a,c);b=m.filter(b,a)}return m.grep(a,function(a){return m.inArray(a,b)>=0!==c})}m.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?m.find.matchesSelector(d,a)?[d]:[]:m.find.matches(a,m.grep(b,function(a){return 1===a.nodeType}))},m.fn.extend({find:function(a){var b,c=[],d=this,e=d.length;if("string"!=typeof a)return this.pushStack(m(a).filter(function(){for(b=0;e>b;b++)if(m.contains(d[b],this))return!0}));for(b=0;e>b;b++)m.find(a,d[b],c);return c=this.pushStack(e>1?m.unique(c):c),c.selector=this.selector?this.selector+" "+a:a,c},filter:function(a){return this.pushStack(w(this,a||[],!1))},not:function(a){return this.pushStack(w(this,a||[],!0))},is:function(a){return!!w(this,"string"==typeof a&&t.test(a)?m(a):a||[],!1).length}});var x,y=a.document,z=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,A=m.fn.init=function(a,b){var c,d;if(!a)return this;if("string"==typeof a){if(c="<"===a.charAt(0)&&">"===a.charAt(a.length-1)&&a.length>=3?[null,a,null]:z.exec(a),!c||!c[1]&&b)return!b||b.jquery?(b||x).find(a):this.constructor(b).find(a);if(c[1]){if(b=b instanceof m?b[0]:b,m.merge(this,m.parseHTML(c[1],b&&b.nodeType?b.ownerDocument||b:y,!0)),u.test(c[1])&&m.isPlainObject(b))for(c in b)m.isFunction(this[c])?this[c](b[c]):this.attr(c,b[c]);return this}if(d=y.getElementById(c[2]),d&&d.parentNode){if(d.id!==c[2])return x.find(a);this.length=1,this[0]=d}return this.context=y,this.selector=a,this}return a.nodeType?(this.context=this[0]=a,this.length=1,this):m.isFunction(a)?"undefined"!=typeof x.ready?x.ready(a):a(m):(void 0!==a.selector&&(this.selector=a.selector,this.context=a.context),m.makeArray(a,this))};A.prototype=m.fn,x=m(y);var B=/^(?:parents|prev(?:Until|All))/,C={children:!0,contents:!0,next:!0,prev:!0};m.extend({dir:function(a,b,c){var d=[],e=a[b];while(e&&9!==e.nodeType&&(void 0===c||1!==e.nodeType||!m(e).is(c)))1===e.nodeType&&d.push(e),e=e[b];return d},sibling:function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c}}),m.fn.extend({has:function(a){var b,c=m(a,this),d=c.length;return this.filter(function(){for(b=0;d>b;b++)if(m.contains(this,c[b]))return!0})},closest:function(a,b){for(var c,d=0,e=this.length,f=[],g=t.test(a)||"string"!=typeof a?m(a,b||this.context):0;e>d;d++)for(c=this[d];c&&c!==b;c=c.parentNode)if(c.nodeType<11&&(g?g.index(c)>-1:1===c.nodeType&&m.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?m.unique(f):f)},index:function(a){return a?"string"==typeof a?m.inArray(this[0],m(a)):m.inArray(a.jquery?a[0]:a,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(m.unique(m.merge(this.get(),m(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function D(a,b){do a=a[b];while(a&&1!==a.nodeType);return a}m.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return m.dir(a,"parentNode")},parentsUntil:function(a,b,c){return m.dir(a,"parentNode",c)},next:function(a){return D(a,"nextSibling")},prev:function(a){return D(a,"previousSibling")},nextAll:function(a){return m.dir(a,"nextSibling")},prevAll:function(a){return m.dir(a,"previousSibling")},nextUntil:function(a,b,c){return m.dir(a,"nextSibling",c)},prevUntil:function(a,b,c){return m.dir(a,"previousSibling",c)},siblings:function(a){return m.sibling((a.parentNode||{}).firstChild,a)},children:function(a){return m.sibling(a.firstChild)},contents:function(a){return m.nodeName(a,"iframe")?a.contentDocument||a.contentWindow.document:m.merge([],a.childNodes)}},function(a,b){m.fn[a]=function(c,d){var e=m.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=m.filter(d,e)),this.length>1&&(C[a]||(e=m.unique(e)),B.test(a)&&(e=e.reverse())),this.pushStack(e)}});var E=/\S+/g,F={};function G(a){var b=F[a]={};return m.each(a.match(E)||[],function(a,c){b[c]=!0}),b}m.Callbacks=function(a){a="string"==typeof a?F[a]||G(a):m.extend({},a);var b,c,d,e,f,g,h=[],i=!a.once&&[],j=function(l){for(c=a.memory&&l,d=!0,f=g||0,g=0,e=h.length,b=!0;h&&e>f;f++)if(h[f].apply(l[0],l[1])===!1&&a.stopOnFalse){c=!1;break}b=!1,h&&(i?i.length&&j(i.shift()):c?h=[]:k.disable())},k={add:function(){if(h){var d=h.length;!function f(b){m.each(b,function(b,c){var d=m.type(c);"function"===d?a.unique&&k.has(c)||h.push(c):c&&c.length&&"string"!==d&&f(c)})}(arguments),b?e=h.length:c&&(g=d,j(c))}return this},remove:function(){return h&&m.each(arguments,function(a,c){var d;while((d=m.inArray(c,h,d))>-1)h.splice(d,1),b&&(e>=d&&e--,f>=d&&f--)}),this},has:function(a){return a?m.inArray(a,h)>-1:!(!h||!h.length)},empty:function(){return h=[],e=0,this},disable:function(){return h=i=c=void 0,this},disabled:function(){return!h},lock:function(){return i=void 0,c||k.disable(),this},locked:function(){return!i},fireWith:function(a,c){return!h||d&&!i||(c=c||[],c=[a,c.slice?c.slice():c],b?i.push(c):j(c)),this},fire:function(){return k.fireWith(this,arguments),this},fired:function(){return!!d}};return k},m.extend({Deferred:function(a){var b=[["resolve","done",m.Callbacks("once memory"),"resolved"],["reject","fail",m.Callbacks("once memory"),"rejected"],["notify","progress",m.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return m.Deferred(function(c){m.each(b,function(b,f){var g=m.isFunction(a[b])&&a[b];e[f[1]](function(){var a=g&&g.apply(this,arguments);a&&m.isFunction(a.promise)?a.promise().done(c.resolve).fail(c.reject).progress(c.notify):c[f[0]+"With"](this===d?c.promise():this,g?[a]:arguments)})}),a=null}).promise()},promise:function(a){return null!=a?m.extend(a,d):d}},e={};return d.pipe=d.then,m.each(b,function(a,f){var g=f[2],h=f[3];d[f[1]]=g.add,h&&g.add(function(){c=h},b[1^a][2].disable,b[2][2].lock),e[f[0]]=function(){return e[f[0]+"With"](this===e?d:this,arguments),this},e[f[0]+"With"]=g.fireWith}),d.promise(e),a&&a.call(e,e),e},when:function(a){var b=0,c=d.call(arguments),e=c.length,f=1!==e||a&&m.isFunction(a.promise)?e:0,g=1===f?a:m.Deferred(),h=function(a,b,c){return function(e){b[a]=this,c[a]=arguments.length>1?d.call(arguments):e,c===i?g.notifyWith(b,c):--f||g.resolveWith(b,c)}},i,j,k;if(e>1)for(i=new Array(e),j=new Array(e),k=new Array(e);e>b;b++)c[b]&&m.isFunction(c[b].promise)?c[b].promise().done(h(b,k,c)).fail(g.reject).progress(h(b,j,i)):--f;return f||g.resolveWith(k,c),g.promise()}});var H;m.fn.ready=function(a){return m.ready.promise().done(a),this},m.extend({isReady:!1,readyWait:1,holdReady:function(a){a?m.readyWait++:m.ready(!0)},ready:function(a){if(a===!0?!--m.readyWait:!m.isReady){if(!y.body)return setTimeout(m.ready);m.isReady=!0,a!==!0&&--m.readyWait>0||(H.resolveWith(y,[m]),m.fn.triggerHandler&&(m(y).triggerHandler("ready"),m(y).off("ready")))}}});function I(){y.addEventListener?(y.removeEventListener("DOMContentLoaded",J,!1),a.removeEventListener("load",J,!1)):(y.detachEvent("onreadystatechange",J),a.detachEvent("onload",J))}function J(){(y.addEventListener||"load"===event.type||"complete"===y.readyState)&&(I(),m.ready())}m.ready.promise=function(b){if(!H)if(H=m.Deferred(),"complete"===y.readyState)setTimeout(m.ready);else if(y.addEventListener)y.addEventListener("DOMContentLoaded",J,!1),a.addEventListener("load",J,!1);else{y.attachEvent("onreadystatechange",J),a.attachEvent("onload",J);var c=!1;try{c=null==a.frameElement&&y.documentElement}catch(d){}c&&c.doScroll&&!function e(){if(!m.isReady){try{c.doScroll("left")}catch(a){return setTimeout(e,50)}I(),m.ready()}}()}return H.promise(b)};var K="undefined",L;for(L in m(k))break;k.ownLast="0"!==L,k.inlineBlockNeedsLayout=!1,m(function(){var a,b,c,d;c=y.getElementsByTagName("body")[0],c&&c.style&&(b=y.createElement("div"),d=y.createElement("div"),d.style.cssText="position:absolute;border:0;width:0;height:0;top:0;left:-9999px",c.appendChild(d).appendChild(b),typeof b.style.zoom!==K&&(b.style.cssText="display:inline;margin:0;border:0;padding:1px;width:1px;zoom:1",k.inlineBlockNeedsLayout=a=3===b.offsetWidth,a&&(c.style.zoom=1)),c.removeChild(d))}),function(){var a=y.createElement("div");if(null==k.deleteExpando){k.deleteExpando=!0;try{delete a.test}catch(b){k.deleteExpando=!1}}a=null}(),m.acceptData=function(a){var b=m.noData[(a.nodeName+" ").toLowerCase()],c=+a.nodeType||1;return 1!==c&&9!==c?!1:!b||b!==!0&&a.getAttribute("classid")===b};var M=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,N=/([A-Z])/g;function O(a,b,c){if(void 0===c&&1===a.nodeType){var d="data-"+b.replace(N,"-$1").toLowerCase();if(c=a.getAttribute(d),"string"==typeof c){try{c="true"===c?!0:"false"===c?!1:"null"===c?null:+c+""===c?+c:M.test(c)?m.parseJSON(c):c}catch(e){}m.data(a,b,c)}else c=void 0}return c}function P(a){var b;for(b in a)if(("data"!==b||!m.isEmptyObject(a[b]))&&"toJSON"!==b)return!1; -return!0}function Q(a,b,d,e){if(m.acceptData(a)){var f,g,h=m.expando,i=a.nodeType,j=i?m.cache:a,k=i?a[h]:a[h]&&h;if(k&&j[k]&&(e||j[k].data)||void 0!==d||"string"!=typeof b)return k||(k=i?a[h]=c.pop()||m.guid++:h),j[k]||(j[k]=i?{}:{toJSON:m.noop}),("object"==typeof b||"function"==typeof b)&&(e?j[k]=m.extend(j[k],b):j[k].data=m.extend(j[k].data,b)),g=j[k],e||(g.data||(g.data={}),g=g.data),void 0!==d&&(g[m.camelCase(b)]=d),"string"==typeof b?(f=g[b],null==f&&(f=g[m.camelCase(b)])):f=g,f}}function R(a,b,c){if(m.acceptData(a)){var d,e,f=a.nodeType,g=f?m.cache:a,h=f?a[m.expando]:m.expando;if(g[h]){if(b&&(d=c?g[h]:g[h].data)){m.isArray(b)?b=b.concat(m.map(b,m.camelCase)):b in d?b=[b]:(b=m.camelCase(b),b=b in d?[b]:b.split(" ")),e=b.length;while(e--)delete d[b[e]];if(c?!P(d):!m.isEmptyObject(d))return}(c||(delete g[h].data,P(g[h])))&&(f?m.cleanData([a],!0):k.deleteExpando||g!=g.window?delete g[h]:g[h]=null)}}}m.extend({cache:{},noData:{"applet ":!0,"embed ":!0,"object ":"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000"},hasData:function(a){return a=a.nodeType?m.cache[a[m.expando]]:a[m.expando],!!a&&!P(a)},data:function(a,b,c){return Q(a,b,c)},removeData:function(a,b){return R(a,b)},_data:function(a,b,c){return Q(a,b,c,!0)},_removeData:function(a,b){return R(a,b,!0)}}),m.fn.extend({data:function(a,b){var c,d,e,f=this[0],g=f&&f.attributes;if(void 0===a){if(this.length&&(e=m.data(f),1===f.nodeType&&!m._data(f,"parsedAttrs"))){c=g.length;while(c--)g[c]&&(d=g[c].name,0===d.indexOf("data-")&&(d=m.camelCase(d.slice(5)),O(f,d,e[d])));m._data(f,"parsedAttrs",!0)}return e}return"object"==typeof a?this.each(function(){m.data(this,a)}):arguments.length>1?this.each(function(){m.data(this,a,b)}):f?O(f,a,m.data(f,a)):void 0},removeData:function(a){return this.each(function(){m.removeData(this,a)})}}),m.extend({queue:function(a,b,c){var d;return a?(b=(b||"fx")+"queue",d=m._data(a,b),c&&(!d||m.isArray(c)?d=m._data(a,b,m.makeArray(c)):d.push(c)),d||[]):void 0},dequeue:function(a,b){b=b||"fx";var c=m.queue(a,b),d=c.length,e=c.shift(),f=m._queueHooks(a,b),g=function(){m.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return m._data(a,c)||m._data(a,c,{empty:m.Callbacks("once memory").add(function(){m._removeData(a,b+"queue"),m._removeData(a,c)})})}}),m.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.lengthh;h++)b(a[h],c,g?d:d.call(a[h],h,b(a[h],c)));return e?a:j?b.call(a):i?b(a[0],c):f},W=/^(?:checkbox|radio)$/i;!function(){var a=y.createElement("input"),b=y.createElement("div"),c=y.createDocumentFragment();if(b.innerHTML="
a",k.leadingWhitespace=3===b.firstChild.nodeType,k.tbody=!b.getElementsByTagName("tbody").length,k.htmlSerialize=!!b.getElementsByTagName("link").length,k.html5Clone="<:nav>"!==y.createElement("nav").cloneNode(!0).outerHTML,a.type="checkbox",a.checked=!0,c.appendChild(a),k.appendChecked=a.checked,b.innerHTML="",k.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue,c.appendChild(b),b.innerHTML="",k.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,k.noCloneEvent=!0,b.attachEvent&&(b.attachEvent("onclick",function(){k.noCloneEvent=!1}),b.cloneNode(!0).click()),null==k.deleteExpando){k.deleteExpando=!0;try{delete b.test}catch(d){k.deleteExpando=!1}}}(),function(){var b,c,d=y.createElement("div");for(b in{submit:!0,change:!0,focusin:!0})c="on"+b,(k[b+"Bubbles"]=c in a)||(d.setAttribute(c,"t"),k[b+"Bubbles"]=d.attributes[c].expando===!1);d=null}();var X=/^(?:input|select|textarea)$/i,Y=/^key/,Z=/^(?:mouse|pointer|contextmenu)|click/,$=/^(?:focusinfocus|focusoutblur)$/,_=/^([^.]*)(?:\.(.+)|)$/;function ab(){return!0}function bb(){return!1}function cb(){try{return y.activeElement}catch(a){}}m.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,n,o,p,q,r=m._data(a);if(r){c.handler&&(i=c,c=i.handler,e=i.selector),c.guid||(c.guid=m.guid++),(g=r.events)||(g=r.events={}),(k=r.handle)||(k=r.handle=function(a){return typeof m===K||a&&m.event.triggered===a.type?void 0:m.event.dispatch.apply(k.elem,arguments)},k.elem=a),b=(b||"").match(E)||[""],h=b.length;while(h--)f=_.exec(b[h])||[],o=q=f[1],p=(f[2]||"").split(".").sort(),o&&(j=m.event.special[o]||{},o=(e?j.delegateType:j.bindType)||o,j=m.event.special[o]||{},l=m.extend({type:o,origType:q,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&m.expr.match.needsContext.test(e),namespace:p.join(".")},i),(n=g[o])||(n=g[o]=[],n.delegateCount=0,j.setup&&j.setup.call(a,d,p,k)!==!1||(a.addEventListener?a.addEventListener(o,k,!1):a.attachEvent&&a.attachEvent("on"+o,k))),j.add&&(j.add.call(a,l),l.handler.guid||(l.handler.guid=c.guid)),e?n.splice(n.delegateCount++,0,l):n.push(l),m.event.global[o]=!0);a=null}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,n,o,p,q,r=m.hasData(a)&&m._data(a);if(r&&(k=r.events)){b=(b||"").match(E)||[""],j=b.length;while(j--)if(h=_.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o){l=m.event.special[o]||{},o=(d?l.delegateType:l.bindType)||o,n=k[o]||[],h=h[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),i=f=n.length;while(f--)g=n[f],!e&&q!==g.origType||c&&c.guid!==g.guid||h&&!h.test(g.namespace)||d&&d!==g.selector&&("**"!==d||!g.selector)||(n.splice(f,1),g.selector&&n.delegateCount--,l.remove&&l.remove.call(a,g));i&&!n.length&&(l.teardown&&l.teardown.call(a,p,r.handle)!==!1||m.removeEvent(a,o,r.handle),delete k[o])}else for(o in k)m.event.remove(a,o+b[j],c,d,!0);m.isEmptyObject(k)&&(delete r.handle,m._removeData(a,"events"))}},trigger:function(b,c,d,e){var f,g,h,i,k,l,n,o=[d||y],p=j.call(b,"type")?b.type:b,q=j.call(b,"namespace")?b.namespace.split("."):[];if(h=l=d=d||y,3!==d.nodeType&&8!==d.nodeType&&!$.test(p+m.event.triggered)&&(p.indexOf(".")>=0&&(q=p.split("."),p=q.shift(),q.sort()),g=p.indexOf(":")<0&&"on"+p,b=b[m.expando]?b:new m.Event(p,"object"==typeof b&&b),b.isTrigger=e?2:3,b.namespace=q.join("."),b.namespace_re=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=d),c=null==c?[b]:m.makeArray(c,[b]),k=m.event.special[p]||{},e||!k.trigger||k.trigger.apply(d,c)!==!1)){if(!e&&!k.noBubble&&!m.isWindow(d)){for(i=k.delegateType||p,$.test(i+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),l=h;l===(d.ownerDocument||y)&&o.push(l.defaultView||l.parentWindow||a)}n=0;while((h=o[n++])&&!b.isPropagationStopped())b.type=n>1?i:k.bindType||p,f=(m._data(h,"events")||{})[b.type]&&m._data(h,"handle"),f&&f.apply(h,c),f=g&&h[g],f&&f.apply&&m.acceptData(h)&&(b.result=f.apply(h,c),b.result===!1&&b.preventDefault());if(b.type=p,!e&&!b.isDefaultPrevented()&&(!k._default||k._default.apply(o.pop(),c)===!1)&&m.acceptData(d)&&g&&d[p]&&!m.isWindow(d)){l=d[g],l&&(d[g]=null),m.event.triggered=p;try{d[p]()}catch(r){}m.event.triggered=void 0,l&&(d[g]=l)}return b.result}},dispatch:function(a){a=m.event.fix(a);var b,c,e,f,g,h=[],i=d.call(arguments),j=(m._data(this,"events")||{})[a.type]||[],k=m.event.special[a.type]||{};if(i[0]=a,a.delegateTarget=this,!k.preDispatch||k.preDispatch.call(this,a)!==!1){h=m.event.handlers.call(this,a,j),b=0;while((f=h[b++])&&!a.isPropagationStopped()){a.currentTarget=f.elem,g=0;while((e=f.handlers[g++])&&!a.isImmediatePropagationStopped())(!a.namespace_re||a.namespace_re.test(e.namespace))&&(a.handleObj=e,a.data=e.data,c=((m.event.special[e.origType]||{}).handle||e.handler).apply(f.elem,i),void 0!==c&&(a.result=c)===!1&&(a.preventDefault(),a.stopPropagation()))}return k.postDispatch&&k.postDispatch.call(this,a),a.result}},handlers:function(a,b){var c,d,e,f,g=[],h=b.delegateCount,i=a.target;if(h&&i.nodeType&&(!a.button||"click"!==a.type))for(;i!=this;i=i.parentNode||this)if(1===i.nodeType&&(i.disabled!==!0||"click"!==a.type)){for(e=[],f=0;h>f;f++)d=b[f],c=d.selector+" ",void 0===e[c]&&(e[c]=d.needsContext?m(c,this).index(i)>=0:m.find(c,this,null,[i]).length),e[c]&&e.push(d);e.length&&g.push({elem:i,handlers:e})}return h]","i"),hb=/^\s+/,ib=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,jb=/<([\w:]+)/,kb=/\s*$/g,rb={option:[1,""],legend:[1,"
","
"],area:[1,"",""],param:[1,"",""],thead:[1,"","
"],tr:[2,"","
"],col:[2,"","
"],td:[3,"","
"],_default:k.htmlSerialize?[0,"",""]:[1,"X
","
"]},sb=db(y),tb=sb.appendChild(y.createElement("div"));rb.optgroup=rb.option,rb.tbody=rb.tfoot=rb.colgroup=rb.caption=rb.thead,rb.th=rb.td;function ub(a,b){var c,d,e=0,f=typeof a.getElementsByTagName!==K?a.getElementsByTagName(b||"*"):typeof a.querySelectorAll!==K?a.querySelectorAll(b||"*"):void 0;if(!f)for(f=[],c=a.childNodes||a;null!=(d=c[e]);e++)!b||m.nodeName(d,b)?f.push(d):m.merge(f,ub(d,b));return void 0===b||b&&m.nodeName(a,b)?m.merge([a],f):f}function vb(a){W.test(a.type)&&(a.defaultChecked=a.checked)}function wb(a,b){return m.nodeName(a,"table")&&m.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function xb(a){return a.type=(null!==m.find.attr(a,"type"))+"/"+a.type,a}function yb(a){var b=pb.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function zb(a,b){for(var c,d=0;null!=(c=a[d]);d++)m._data(c,"globalEval",!b||m._data(b[d],"globalEval"))}function Ab(a,b){if(1===b.nodeType&&m.hasData(a)){var c,d,e,f=m._data(a),g=m._data(b,f),h=f.events;if(h){delete g.handle,g.events={};for(c in h)for(d=0,e=h[c].length;e>d;d++)m.event.add(b,c,h[c][d])}g.data&&(g.data=m.extend({},g.data))}}function Bb(a,b){var c,d,e;if(1===b.nodeType){if(c=b.nodeName.toLowerCase(),!k.noCloneEvent&&b[m.expando]){e=m._data(b);for(d in e.events)m.removeEvent(b,d,e.handle);b.removeAttribute(m.expando)}"script"===c&&b.text!==a.text?(xb(b).text=a.text,yb(b)):"object"===c?(b.parentNode&&(b.outerHTML=a.outerHTML),k.html5Clone&&a.innerHTML&&!m.trim(b.innerHTML)&&(b.innerHTML=a.innerHTML)):"input"===c&&W.test(a.type)?(b.defaultChecked=b.checked=a.checked,b.value!==a.value&&(b.value=a.value)):"option"===c?b.defaultSelected=b.selected=a.defaultSelected:("input"===c||"textarea"===c)&&(b.defaultValue=a.defaultValue)}}m.extend({clone:function(a,b,c){var d,e,f,g,h,i=m.contains(a.ownerDocument,a);if(k.html5Clone||m.isXMLDoc(a)||!gb.test("<"+a.nodeName+">")?f=a.cloneNode(!0):(tb.innerHTML=a.outerHTML,tb.removeChild(f=tb.firstChild)),!(k.noCloneEvent&&k.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||m.isXMLDoc(a)))for(d=ub(f),h=ub(a),g=0;null!=(e=h[g]);++g)d[g]&&Bb(e,d[g]);if(b)if(c)for(h=h||ub(a),d=d||ub(f),g=0;null!=(e=h[g]);g++)Ab(e,d[g]);else Ab(a,f);return d=ub(f,"script"),d.length>0&&zb(d,!i&&ub(a,"script")),d=h=e=null,f},buildFragment:function(a,b,c,d){for(var e,f,g,h,i,j,l,n=a.length,o=db(b),p=[],q=0;n>q;q++)if(f=a[q],f||0===f)if("object"===m.type(f))m.merge(p,f.nodeType?[f]:f);else if(lb.test(f)){h=h||o.appendChild(b.createElement("div")),i=(jb.exec(f)||["",""])[1].toLowerCase(),l=rb[i]||rb._default,h.innerHTML=l[1]+f.replace(ib,"<$1>")+l[2],e=l[0];while(e--)h=h.lastChild;if(!k.leadingWhitespace&&hb.test(f)&&p.push(b.createTextNode(hb.exec(f)[0])),!k.tbody){f="table"!==i||kb.test(f)?""!==l[1]||kb.test(f)?0:h:h.firstChild,e=f&&f.childNodes.length;while(e--)m.nodeName(j=f.childNodes[e],"tbody")&&!j.childNodes.length&&f.removeChild(j)}m.merge(p,h.childNodes),h.textContent="";while(h.firstChild)h.removeChild(h.firstChild);h=o.lastChild}else p.push(b.createTextNode(f));h&&o.removeChild(h),k.appendChecked||m.grep(ub(p,"input"),vb),q=0;while(f=p[q++])if((!d||-1===m.inArray(f,d))&&(g=m.contains(f.ownerDocument,f),h=ub(o.appendChild(f),"script"),g&&zb(h),c)){e=0;while(f=h[e++])ob.test(f.type||"")&&c.push(f)}return h=null,o},cleanData:function(a,b){for(var d,e,f,g,h=0,i=m.expando,j=m.cache,l=k.deleteExpando,n=m.event.special;null!=(d=a[h]);h++)if((b||m.acceptData(d))&&(f=d[i],g=f&&j[f])){if(g.events)for(e in g.events)n[e]?m.event.remove(d,e):m.removeEvent(d,e,g.handle);j[f]&&(delete j[f],l?delete d[i]:typeof d.removeAttribute!==K?d.removeAttribute(i):d[i]=null,c.push(f))}}}),m.fn.extend({text:function(a){return V(this,function(a){return void 0===a?m.text(this):this.empty().append((this[0]&&this[0].ownerDocument||y).createTextNode(a))},null,a,arguments.length)},append:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=wb(this,a);b.appendChild(a)}})},prepend:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=wb(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},remove:function(a,b){for(var c,d=a?m.filter(a,this):this,e=0;null!=(c=d[e]);e++)b||1!==c.nodeType||m.cleanData(ub(c)),c.parentNode&&(b&&m.contains(c.ownerDocument,c)&&zb(ub(c,"script")),c.parentNode.removeChild(c));return this},empty:function(){for(var a,b=0;null!=(a=this[b]);b++){1===a.nodeType&&m.cleanData(ub(a,!1));while(a.firstChild)a.removeChild(a.firstChild);a.options&&m.nodeName(a,"select")&&(a.options.length=0)}return this},clone:function(a,b){return a=null==a?!1:a,b=null==b?a:b,this.map(function(){return m.clone(this,a,b)})},html:function(a){return V(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a)return 1===b.nodeType?b.innerHTML.replace(fb,""):void 0;if(!("string"!=typeof a||mb.test(a)||!k.htmlSerialize&&gb.test(a)||!k.leadingWhitespace&&hb.test(a)||rb[(jb.exec(a)||["",""])[1].toLowerCase()])){a=a.replace(ib,"<$1>");try{for(;d>c;c++)b=this[c]||{},1===b.nodeType&&(m.cleanData(ub(b,!1)),b.innerHTML=a);b=0}catch(e){}}b&&this.empty().append(a)},null,a,arguments.length)},replaceWith:function(){var a=arguments[0];return this.domManip(arguments,function(b){a=this.parentNode,m.cleanData(ub(this)),a&&a.replaceChild(b,this)}),a&&(a.length||a.nodeType)?this:this.remove()},detach:function(a){return this.remove(a,!0)},domManip:function(a,b){a=e.apply([],a);var c,d,f,g,h,i,j=0,l=this.length,n=this,o=l-1,p=a[0],q=m.isFunction(p);if(q||l>1&&"string"==typeof p&&!k.checkClone&&nb.test(p))return this.each(function(c){var d=n.eq(c);q&&(a[0]=p.call(this,c,d.html())),d.domManip(a,b)});if(l&&(i=m.buildFragment(a,this[0].ownerDocument,!1,this),c=i.firstChild,1===i.childNodes.length&&(i=c),c)){for(g=m.map(ub(i,"script"),xb),f=g.length;l>j;j++)d=i,j!==o&&(d=m.clone(d,!0,!0),f&&m.merge(g,ub(d,"script"))),b.call(this[j],d,j);if(f)for(h=g[g.length-1].ownerDocument,m.map(g,yb),j=0;f>j;j++)d=g[j],ob.test(d.type||"")&&!m._data(d,"globalEval")&&m.contains(h,d)&&(d.src?m._evalUrl&&m._evalUrl(d.src):m.globalEval((d.text||d.textContent||d.innerHTML||"").replace(qb,"")));i=c=null}return this}}),m.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){m.fn[a]=function(a){for(var c,d=0,e=[],g=m(a),h=g.length-1;h>=d;d++)c=d===h?this:this.clone(!0),m(g[d])[b](c),f.apply(e,c.get());return this.pushStack(e)}});var Cb,Db={};function Eb(b,c){var d,e=m(c.createElement(b)).appendTo(c.body),f=a.getDefaultComputedStyle&&(d=a.getDefaultComputedStyle(e[0]))?d.display:m.css(e[0],"display");return e.detach(),f}function Fb(a){var b=y,c=Db[a];return c||(c=Eb(a,b),"none"!==c&&c||(Cb=(Cb||m("' % urllib.quote(url) + return '' % urllib.quote(url) def web2py_component(url): diff --git a/gluon/contrib/imageutils.py b/gluon/contrib/imageutils.py index ec40760d..1dd19795 100644 --- a/gluon/contrib/imageutils.py +++ b/gluon/contrib/imageutils.py @@ -6,15 +6,17 @@ # # Given the model # -# db.define_table("table_name", Field("picture", "upload"), Field("thumbnail", "upload")) +# db.define_table("table_name", Field("picture", "upload"), +# Field("thumbnail", "upload")) # -# # to resize the picture on upload +# to resize the picture on upload # # from images import RESIZE # # db.table_name.picture.requires = RESIZE(200, 200) # -# # to store original image in picture and create a thumbnail in 'thumbnail' field +# to store original image in picture and create a thumbnail +# in 'thumbnail' field # # from images import THUMB # db.table_name.thumbnail.compute = lambda row: THUMB(row.picture, 200, 200) @@ -24,8 +26,11 @@ from gluon import current class RESIZE(object): - def __init__(self, nx=160, ny=80, error_message=' image resize'): - (self.nx, self.ny, self.error_message) = (nx, ny, error_message) + + def __init__(self, nx=160, ny=80, quality=100, + error_message=' image resize'): + (self.nx, self.ny, self.quality, self.error_message) = ( + nx, ny, quality, error_message) def __call__(self, value): if isinstance(value, str) and len(value) == 0: @@ -36,7 +41,7 @@ class RESIZE(object): img = Image.open(value.file) img.thumbnail((self.nx, self.ny), Image.ANTIALIAS) s = cStringIO.StringIO() - img.save(s, 'JPEG', quality=100) + img.save(s, 'JPEG', quality=self.quality) s.seek(0) value.file = s except: @@ -51,7 +56,7 @@ def THUMB(image, nx=120, ny=120, gae=False, name='thumb'): request = current.request from PIL import Image import os - img = Image.open(os.path.join(request.folder,'uploads',image)) + img = Image.open(os.path.join(request.folder, 'uploads', image)) img.thumbnail((nx, ny), Image.ANTIALIAS) root, ext = os.path.splitext(image) thumb = '%s_%s%s' % (root, name, ext) diff --git a/gluon/contrib/login_methods/ldap_auth.py b/gluon/contrib/login_methods/ldap_auth.py index 4f64cedb..a4659618 100644 --- a/gluon/contrib/login_methods/ldap_auth.py +++ b/gluon/contrib/login_methods/ldap_auth.py @@ -14,12 +14,20 @@ except Exception, e: raise e -def ldap_auth(server='ldap', port=None, +def ldap_auth(server='ldap', + port=None, base_dn='ou=users,dc=domain,dc=com', - mode='uid', secure=False, - cert_path=None, cert_file=None, - cacert_path=None, cacert_file=None, key_file=None, - bind_dn=None, bind_pw=None, filterstr='objectClass=*', + mode='uid', + secure=False, + self_signed_certificate=None, # See NOTE below + cert_path=None, + cert_file=None, + cacert_path=None, + cacert_file=None, + key_file=None, + bind_dn=None, + bind_pw=None, + filterstr='objectClass=*', username_attrib='uid', custom_scope='subtree', allowed_groups=None, @@ -33,6 +41,7 @@ def ldap_auth(server='ldap', port=None, group_name_attrib='cn', group_member_attrib='memberUid', group_filterstr='objectClass=*', + tls=False, logging_level='error'): """ @@ -80,6 +89,13 @@ def ldap_auth(server='ldap', port=None, If ldap is using GnuTLS then you need cert_file="..." instead cert_path because cert_path isn't implemented in GnuTLS :( + To enable TLS, set tls=True: + + auth.settings.login_methods.append(ldap_auth( + server='my.ldap.server', + base_dn='ou=Users,dc=domain,dc=com', + tls=True)) + If you need to bind to the directory with an admin account in order to search it then specify bind_dn & bind_pw to use for this. - currently only implemented for Active Directory @@ -151,6 +167,14 @@ def ldap_auth(server='ldap', port=None, You can set the logging level with the "logging_level" parameter, default is "error" and can be set to error, warning, info, debug. """ + + if self_signed_certificate: + # NOTE : If you have a self-signed SSL Certificate pointing over "port=686" and "secure=True" alone + # will not work, you need also to set "self_signed_certificate=True". + # Ref1: https://onemoretech.wordpress.com/2015/06/25/connecting-to-ldap-over-self-signed-tls-with-python/ + # Ref2: http://bneijt.nl/blog/post/connecting-to-ldaps-with-self-signed-cert-using-python/ + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) + logger = logging.getLogger('web2py.auth.ldap_auth') if logging_level == 'error': logger.setLevel(logging.ERROR) @@ -188,8 +212,7 @@ def ldap_auth(server='ldap', port=None, logger.warning('blank password not allowed') return False logger.debug('mode: [%s] manage_user: [%s] custom_scope: [%s]' - ' manage_groups: [%s]' % (str(mode), str(manage_user), - str(custom_scope), str(manage_groups))) + ' manage_groups: [%s]' % (str(mode), str(manage_user), str(custom_scope), str(manage_groups))) if manage_user: if user_firstname_attrib.count(':') > 0: (user_firstname_attrib, @@ -238,14 +261,10 @@ def ldap_auth(server='ldap', port=None, # in the ldap_basedn requested_attrs = ['sAMAccountName'] if manage_user: - requested_attrs.extend([user_firstname_attrib, - user_lastname_attrib, - user_mail_attrib]) + requested_attrs.extend([user_firstname_attrib, user_lastname_attrib, user_mail_attrib]) result = con.search_ext_s( ldap_basedn, ldap.SCOPE_SUBTREE, - "(&(sAMAccountName=%s)(%s))" % ( - ldap.filter.escape_filter_chars(username_bare), - filterstr), + "(&(sAMAccountName=%s)(%s))" % (ldap.filter.escape_filter_chars(username_bare), filterstr), requested_attrs)[0][1] if not isinstance(result, dict): # result should be a dict in the form @@ -278,25 +297,21 @@ def ldap_auth(server='ldap', port=None, if manage_user: result = con.search_s(dn, ldap.SCOPE_BASE, "(objectClass=*)", - [user_firstname_attrib, - user_lastname_attrib, - user_mail_attrib])[0][1] + [user_firstname_attrib, user_lastname_attrib, user_mail_attrib])[0][1] if ldap_mode == 'uid': # OpenLDAP (UID) if ldap_binddn and ldap_bindpw: con.simple_bind_s(ldap_binddn, ldap_bindpw) dn = "uid=" + username + "," + ldap_basedn - dn = con.search_s(ldap_basedn, ldap.SCOPE_SUBTREE, "(uid=%s)"%username, [''])[0][0] + dn = con.search_s(ldap_basedn, ldap.SCOPE_SUBTREE, "(uid=%s)" % username, [''])[0][0] else: dn = "uid=" + username + "," + ldap_basedn con.simple_bind_s(dn, password) if manage_user: result = con.search_s(dn, ldap.SCOPE_BASE, "(objectClass=*)", - [user_firstname_attrib, - user_lastname_attrib, - user_mail_attrib])[0][1] + [user_firstname_attrib, user_lastname_attrib, user_mail_attrib])[0][1] if ldap_mode == 'company': # no DNs or password needed to search directory @@ -311,9 +326,7 @@ def ldap_auth(server='ldap', port=None, # find the uid attrs = ['uid'] if manage_user: - attrs.extend([user_firstname_attrib, - user_lastname_attrib, - user_mail_attrib]) + attrs.extend([user_firstname_attrib, user_lastname_attrib, user_mail_attrib]) # perform the actual search company_search_result = con.search_s(ldap_basedn, ldap.SCOPE_SUBTREE, @@ -329,13 +342,11 @@ def ldap_auth(server='ldap', port=None, basedns = ldap_basedn else: basedns = [ldap_basedn] - filter = '(&(uid=%s)(%s))' % ( - ldap.filter.escape_filter_chars(username), filterstr) + filter = '(&(uid=%s)(%s))' % (ldap.filter.escape_filter_chars(username), filterstr) found = False for basedn in basedns: try: - result = con.search_s(basedn, ldap.SCOPE_SUBTREE, - filter) + result = con.search_s(basedn, ldap.SCOPE_SUBTREE, filter) if result: user_dn = result[0][0] # Check the password @@ -344,9 +355,10 @@ def ldap_auth(server='ldap', port=None, break except ldap.LDAPError, detail: (exc_type, exc_value) = sys.exc_info()[:2] - logger.warning( - "ldap_auth: searching %s for %s resulted in %s: %s\n" % - (basedn, filter, exc_type, exc_value) + logger.warning("ldap_auth: searching %s for %s resulted in %s: %s\n" % (basedn, + filter, + exc_type, + exc_value) ) if not found: logger.warning('User [%s] not found!' % username) @@ -359,10 +371,7 @@ def ldap_auth(server='ldap', port=None, basedns = ldap_basedn else: basedns = [ldap_basedn] - filter = '(&(%s=%s)(%s))' % (username_attrib, - ldap.filter.escape_filter_chars( - username), - filterstr) + filter = '(&(%s=%s)(%s))' % (username_attrib, ldap.filter.escape_filter_chars(username), filterstr) if custom_scope == 'subtree': ldap_scope = ldap.SCOPE_SUBTREE elif custom_scope == 'base': @@ -381,9 +390,10 @@ def ldap_auth(server='ldap', port=None, break except ldap.LDAPError, detail: (exc_type, exc_value) = sys.exc_info()[:2] - logger.warning( - "ldap_auth: searching %s for %s resulted in %s: %s\n" % - (basedn, filter, exc_type, exc_value) + logger.warning("ldap_auth: searching %s for %s resulted in %s: %s\n" % (basedn, + filter, + exc_type, + exc_value) ) if not found: logger.warning('User [%s] not found!' % username) @@ -393,16 +403,14 @@ def ldap_auth(server='ldap', port=None, logger.info('[%s] Manage user data' % str(username)) try: if user_firstname_part is not None: - store_user_firstname = result[user_firstname_attrib][ - 0].split(' ', 1)[user_firstname_part] + store_user_firstname = result[user_firstname_attrib][0].split(' ', 1)[user_firstname_part] else: store_user_firstname = result[user_firstname_attrib][0] except KeyError, e: store_user_firstname = None try: if user_lastname_part is not None: - store_user_lastname = result[user_lastname_attrib][ - 0].split(' ', 1)[user_lastname_part] + store_user_lastname = result[user_lastname_attrib][0].split(' ', 1)[user_lastname_part] else: store_user_lastname = result[user_lastname_attrib][0] except KeyError, e: @@ -411,32 +419,27 @@ def ldap_auth(server='ldap', port=None, store_user_mail = result[user_mail_attrib][0] except KeyError, e: store_user_mail = None - try: - # + update_or_insert_values = {'first_name': store_user_firstname, + 'last_name': store_user_lastname, + 'email': store_user_mail} + if '@' not in username: # user as username - # ################# + # ################ + fields = ['first_name', 'last_name', 'email'] user_in_db = db(db.auth_user.username == username) - if user_in_db.count() > 0: - user_in_db.update(first_name=store_user_firstname, - last_name=store_user_lastname, - email=store_user_mail) - else: - db.auth_user.insert(first_name=store_user_firstname, - last_name=store_user_lastname, - email=store_user_mail, - username=username) - except: - # + elif '@' in username: # user as email - # ############## + # ############# + fields = ['first_name', 'last_name'] user_in_db = db(db.auth_user.email == username) - if user_in_db.count() > 0: - user_in_db.update(first_name=store_user_firstname, - last_name=store_user_lastname) - else: - db.auth_user.insert(first_name=store_user_firstname, - last_name=store_user_lastname, - email=username) + update_or_insert_values = dict(((f, update_or_insert_values[f]) for f in fields)) + + if user_in_db.count() > 0: + actual_values = user_in_db.select(*[db.auth_user[f] for f in fields]).first().as_dict() + if update_or_insert_values != actual_values: # We don't update record if values are the same + user_in_db.update(**update_or_insert_values) + else: + db.auth_user.insert(**update_or_insert_values) con.unbind() if manage_groups: @@ -478,9 +481,7 @@ def ldap_auth(server='ldap', port=None, # No match return False - def do_manage_groups(username, - password=None, - db=db): + def do_manage_groups(username, password=None, db=db): """ Manage user groups @@ -500,23 +501,19 @@ def ldap_auth(server='ldap', port=None, # Get all group name where the user is in actually in local db # ############################################################# try: - db_user_id = db(db.auth_user.username == username).select( - db.auth_user.id).first().id + db_user_id = db(db.auth_user.username == username).select(db.auth_user.id).first().id except: try: - db_user_id = db(db.auth_user.email == username).select( - db.auth_user.id).first().id + db_user_id = db(db.auth_user.email == username).select(db.auth_user.id).first().id except AttributeError, e: # # There is no user in local db # We create one # ############################## try: - db_user_id = db.auth_user.insert(username=username, - first_name=username) + db_user_id = db.auth_user.insert(username=username, first_name=username) except AttributeError, e: - db_user_id = db.auth_user.insert(email=username, - first_name=username) + db_user_id = db.auth_user.insert(email=username, first_name=username) if not db_user_id: logging.error( 'There is no username or email for %s!' % username) @@ -524,27 +521,23 @@ def ldap_auth(server='ldap', port=None, # if old pydal version, assume this is a relational database which can do joins db_can_join = db.can_join() if hasattr(db, 'can_join') else True if db_can_join: - db_group_search = db( - (db.auth_membership.user_id == db_user_id) & - (db.auth_user.id == db.auth_membership.user_id) & - (db.auth_group.id == db.auth_membership.group_id)) + db_group_search = \ + db((db.auth_membership.user_id == db_user_id) & + (db.auth_user.id == db.auth_membership.user_id) & + (db.auth_group.id == db.auth_membership.group_id)) else: # no joins on NoSQL databases, perform two queries db_group_search = db(db.auth_membership.user_id == db_user_id) - group_ids = [x.group_id for x in db_group_search.select( - db.auth_membership.group_id, distinct=True)] + group_ids = [x.group_id for x in db_group_search.select(db.auth_membership.group_id, distinct=True)] db_group_search = db(db.auth_group.id.belongs(group_ids)) db_groups_of_the_user = list() db_group_id = dict() if db_group_search.count() > 0: - for group in db_group_search.select(db.auth_group.id, - db.auth_group.role, - distinct=True): + for group in db_group_search.select(db.auth_group.id, db.auth_group.role, distinct=True): db_group_id[group.role] = group.id db_groups_of_the_user.append(group.role) - logging.debug('db groups of user %s: %s' % - (username, str(db_groups_of_the_user))) + logging.debug('db groups of user %s: %s' % (username, str(db_groups_of_the_user))) # # Delete user membership from groups where user is not anymore @@ -552,8 +545,7 @@ def ldap_auth(server='ldap', port=None, for group_to_del in db_groups_of_the_user: if ldap_groups_of_the_user.count(group_to_del) == 0: db((db.auth_membership.user_id == db_user_id) & - (db.auth_membership.group_id == \ - db_group_id[group_to_del])).delete() + (db.auth_membership.group_id == db_group_id[group_to_del])).delete() # # Create user membership in groups where user is not in already @@ -561,16 +553,12 @@ def ldap_auth(server='ldap', port=None, for group_to_add in ldap_groups_of_the_user: if db_groups_of_the_user.count(group_to_add) == 0: if db(db.auth_group.role == group_to_add).count() == 0: - gid = db.auth_group.insert(role=group_to_add, - description='Generated from LDAP') + gid = db.auth_group.insert(role=group_to_add, description='Generated from LDAP') else: - gid = db(db.auth_group.role == group_to_add).select( - db.auth_group.id).first().id - db.auth_membership.insert(user_id=db_user_id, - group_id=gid) + gid = db(db.auth_group.role == group_to_add).select(db.auth_group.id).first().id + db.auth_membership.insert(user_id=db_user_id, group_id=gid) except: - logger.warning("[%s] Groups are not managed successfully!" % - str(username)) + logger.warning("[%s] Groups are not managed successfully!" % str(username)) import traceback logger.debug(traceback.format_exc()) return False @@ -610,6 +598,8 @@ def ldap_auth(server='ldap', port=None, ldap_port = 389 con = ldap.initialize( "ldap://" + ldap_server + ":" + str(ldap_port)) + if tls: + con.start_tls_s() return con def get_user_groups_from_ldap(username, @@ -659,10 +649,12 @@ def ldap_auth(server='ldap', port=None, con.simple_bind_s(username, password) logger.debug('Ldap username connect...') # We have to use the full string - username = con.search_ext_s(base_dn, ldap.SCOPE_SUBTREE, - "(&(sAMAccountName=%s)(%s))" % - (ldap.filter.escape_filter_chars(username_bare), - filterstr), ["cn"])[0][0] + username = \ + con.search_ext_s(base_dn, + ldap.SCOPE_SUBTREE, + "(&(sAMAccountName=%s)(%s))" % (ldap.filter.escape_filter_chars(username_bare), + filterstr), + ["cn"])[0][0] else: if ldap_binddn: # need to search directory with an bind_dn account 1st @@ -675,18 +667,14 @@ def ldap_auth(server='ldap', port=None, if username is None: return list() # search for groups where user is in - filter = '(&(%s=%s)(%s))' % (ldap.filter.escape_filter_chars( - group_member_attrib - ), + filter = '(&(%s=%s)(%s))' % (ldap.filter.escape_filter_chars(group_member_attrib), ldap.filter.escape_filter_chars(username), group_filterstr) - group_search_result = con.search_s(group_dn, - ldap.SCOPE_SUBTREE, - filter, [group_name_attrib]) + group_search_result = con.search_s(group_dn, ldap.SCOPE_SUBTREE, filter, [group_name_attrib]) ldap_groups_of_the_user = list() for group_row in group_search_result: group = group_row[1] - if type(group) == dict and group.has_key(group_name_attrib): + if type(group) == dict and group_name_attrib in group: ldap_groups_of_the_user.extend(group[group_name_attrib]) con.unbind() diff --git a/gluon/contrib/login_methods/oauth20_account.py b/gluon/contrib/login_methods/oauth20_account.py index 632244d9..fbeca5b6 100644 --- a/gluon/contrib/login_methods/oauth20_account.py +++ b/gluon/contrib/login_methods/oauth20_account.py @@ -139,24 +139,36 @@ server for requests. It can be used for the optional"scope" parameters for Face Return the access token generated by the authenticating server. If token is already in the session that one will be used. + If token has expired refresh_token is used to get another token. Otherwise the token is fetched from the auth server. - """ + refresh_token = None if current.session.token and 'expires' in current.session.token: expires = current.session.token['expires'] # reuse token until expiration if expires == 0 or expires > time.time(): - return current.session.token['access_token'] + return current.session.token['access_token'] + if 'refresh_token' in current.session.token: + refresh_token = current.session.token['refresh_token'] code = current.request.vars.code - if code: - data = dict(client_id=self.client_id, - client_secret=self.client_secret, - redirect_uri=current.session.redirect_uri, - code=code, - grant_type='authorization_code' - ) + if code or refresh_token: + data = dict( + client_id=self.client_id, + client_secret=self.client_secret, + ) + if code: + data.update( + redirect_uri=current.session.redirect_uri, + code=code, + grant_type='authorization_code' + ) + elif refresh_token: + data.update( + refresh_token=refresh_token, + grant_type='refresh_token' + ) open_url = None opener = self.__build_url_opener(self.token_url) diff --git a/gluon/contrib/login_methods/saml2_auth.py b/gluon/contrib/login_methods/saml2_auth.py index 8e9909ad..bf782f1c 100644 --- a/gluon/contrib/login_methods/saml2_auth.py +++ b/gluon/contrib/login_methods/saml2_auth.py @@ -13,6 +13,7 @@ Include in your model (eg db.py):: auth.define_tables(username=True) from gluon.contrib.login_methods.saml2_auth import Saml2Auth + import os auth.settings.login_form=Saml2Auth( config_file = os.path.join(request.folder,'private','sp_conf'), maps=dict( @@ -20,10 +21,59 @@ Include in your model (eg db.py):: email=lambda v: v['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn'][0], user_id=lambda v: v['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn'][0])) -you must have private/sp_conf.py, the pysaml2 sp configuration file +you must have private/sp_conf.py, the pysaml2 sp configuration file. For example: + + + #!/usr/bin/env python + # -*- coding: utf-8 -*- + + from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT + import os.path + import requests + import tempfile + + BASEDIR = os.path.abspath(os.path.dirname(__file__)) + + # Web2py SP url and application name + HOST = 'http://127.0.0.1:8000' + APP = 'sp' + + # To load the IDP metadata... + IDP_METADATA = 'http://127.0.0.1:8088/metadata' + + def full_path(local_file): + return os.path.join(BASEDIR, local_file) + + CONFIG = { + # your entity id, usually your subdomain plus the url to the metadata view. + 'entityid': '%s/%s/default/metadata' % (HOST, APP), + 'service': { + 'sp' : { + 'name': 'MYSP', + 'endpoints': { + 'assertion_consumer_service': [ + ('%s/%s/default/user/login' % (HOST, APP), BINDING_HTTP_REDIRECT), + ('%s/%s/default/user/login' % (HOST, APP), BINDING_HTTP_POST), + ], + }, + }, + }, + # Your private and public key. + 'key_file': full_path('pki/mykey.pem'), + 'cert_file': full_path('pki/mycert.pem'), + + # where the remote metadata is stored + 'metadata': { + "remote": [{ + "url": IDP_METADATA, + "cert":full_path('pki/mycert.pem') + }] + }, + } + """ -from saml2 import BINDING_HTTP_REDIRECT +from saml2 import BINDING_HTTP_REDIRECT, BINDING_HTTP_POST from saml2.client import Saml2Client from gluon.utils import web2py_uuid from gluon import current, redirect, URL @@ -59,10 +109,13 @@ def saml2_handler(session, request, config_filename = None): client = Saml2Client(config_file = config_filename) idps = client.metadata.with_descriptor("idpsso") entityid = idps.keys()[0] - bindings = [BINDING_HTTP_REDIRECT] + bindings = [BINDING_HTTP_REDIRECT, BINDING_HTTP_POST] binding, destination = client.pick_binding( "single_sign_on_service", bindings, "idpsso", entity_id=entityid) - binding = BINDING_HTTP_REDIRECT + if request.env.request_method == 'GET': + binding = BINDING_HTTP_REDIRECT + elif request.env.request_method == 'POST': + binding = BINDING_HTTP_POST if not request.vars.SAMLResponse: req_id, req = client.create_authn_request(destination, binding=binding) relay_state = web2py_uuid().replace('-','') diff --git a/gluon/contrib/plural_rules/fr.py b/gluon/contrib/plural_rules/fr.py index 4237c0e3..590f11c6 100644 --- a/gluon/contrib/plural_rules/fr.py +++ b/gluon/contrib/plural_rules/fr.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# -*- coding: utf8 -*- +# -*- coding: utf-8 -*- # Plural-Forms for fr (French)) nplurals=2 # French language has 2 forms: @@ -15,3 +15,55 @@ get_plural_id = lambda n: int(n != 1) # for words (or phrases) not found in plural_dict dictionary # construct_plural_form = lambda word, plural_id: (word + 'suffix') +irregular={ + 'aïeul': 'aïeux', + 'bonhomme': 'bonshommes', + 'ciel': 'cieux', + 'oeil': 'yeux', + 'œil': 'yeux', + 'madame': 'mesdames', + 'mademoiselle': 'mesdemoiselles', + 'monsieur': 'messieurs', + 'bijou': 'bijoux', + 'caillou': 'cailloux', + 'chou': 'choux', + 'genou': 'genoux', + 'hibou': 'hiboux', + 'joujou': 'joujoux', + 'pou': 'poux', + 'corail': ' coraux', + 'émail': 'émaux', + 'travail': 'travaux', + 'vitrail': 'vitraux', + 'soupirail': 'soupiraux', + 'bail': 'baux', + 'fermail': 'fermaux', + 'ventail': 'ventaux', + 'bleu': 'bleus', + 'pneu': 'pneus', + 'émeu': 'émeus', + 'enfeu': 'enfeus', + #'lieu': 'lieus', # poisson + +} + +def construct_plural_form(word, plural_id): + u""" + >>> [construct_plural_form(x, 1) for x in \ + [ 'bleu', 'nez', 'sex', 'bas', 'gruau', 'jeu', 'journal',\ + 'chose' ]] + ['bleus', 'nez', 'sex', 'bas', 'gruaux', 'jeux', 'journaux', 'choses'] + """ + if word in irregular: + return irregular[word] + if word[-1:] in ('s', 'x', 'z'): + return word + if word[-2:] in ('au', 'eu'): + return word + 'x' + if word[-2:] == 'al': + return word[0:-2] + 'aux' + return word + 's' + +if __name__ == '__main__': + import doctest + doctest.testmod() diff --git a/gluon/contrib/redis_cache.py b/gluon/contrib/redis_cache.py index 0f35395a..47f30959 100644 --- a/gluon/contrib/redis_cache.py +++ b/gluon/contrib/redis_cache.py @@ -2,20 +2,20 @@ Developed by niphlod@gmail.com Released under web2py license because includes gluon/cache.py source code """ -import redis -from redis.exceptions import ConnectionError -from gluon import current -from gluon.cache import CacheAbstract + try: - import cPickle as pickle + import cPickle as pickle except: - import pickle + import pickle import time import re import logging import thread import random - +from gluon import current +from gluon.cache import CacheAbstract +from gluon.contrib.redis_utils import acquire_lock, release_lock +from gluon.contrib.redis_utils import register_release_lock, RConnectionError logger = logging.getLogger("web2py.cache.redis") @@ -24,17 +24,22 @@ locker = thread.allocate_lock() def RedisCache(*args, **vars): """ - Usage example: put in models + Usage example: put in models:: - from gluon.contrib.redis_cache import RedisCache - cache.redis = RedisCache('localhost:6379',db=None, debug=True, with_lock=True, password=None) + from gluon.contrib.redis_utils import RConn + rconn = RConn() + from gluon.contrib.redis_cache import RedisCache + cache.redis = RedisCache(redis_conn=rconn, debug=True, with_lock=True) - :param db: redis db to use (0..16) - :param debug: if True adds to stats() the total_hits and misses - :param with_lock: sets the default locking mode for creating new keys. + Args: + redis_conn: a redis-like connection object + debug: if True adds to stats() the total_hits and misses + with_lock: sets the default locking mode for creating new keys. By default is False (usualy when you choose Redis you do it for performances reason) When True, only one thread/process can set a value concurrently + fail_gracefully: if redis is unavailable, returns the value computing it + instead of raising an exception When you use cache.redis directly you can use value = cache.redis('mykey', lambda: time.time(), with_lock=True) @@ -81,22 +86,19 @@ class RedisClient(object): MAX_RETRIES = 5 RETRIES = 0 - def __init__(self, server='localhost:6379', db=None, debug=False, with_lock=False, password=None): - self.server = server - self.password = password - self.db = db or 0 - host, port = (self.server.split(':') + ['6379'])[:2] - port = int(port) + def __init__(self, redis_conn=None, debug=False, + with_lock=False, fail_gracefully=False): self.request = current.request self.debug = debug self.with_lock = with_lock - self.prefix = "w2p:%s:" % (self.request.application) + self.fail_gracefully = fail_gracefully + self.prefix = "w2p:cache:%s:" % (self.request.application) if self.request: app = self.request.application else: app = '' - if not app in self.meta_storage: + if app not in self.meta_storage: self.storage = self.meta_storage[app] = { CacheAbstract.cache_stats_name: { 'hit_total': 0, @@ -107,7 +109,8 @@ class RedisClient(object): self.cache_set_key = 'w2p:%s:___cache_set' % (self.request.application) - self.r_server = redis.Redis(host=host, port=port, db=self.db, password=self.password) + self.r_server = redis_conn + self._release_script = register_release_lock(self.r_server) def initialize(self): pass @@ -140,26 +143,20 @@ class RedisClient(object): #naive distributed locking if with_lock: lock_key = '%s:__lock' % newKey - try: - while True: - lock = self.r_server.setnx(lock_key, 1) - if lock: - value = self.cache_it(newKey, f, time_expire) - break - else: - time.sleep(0.2) - #did someone else create it in the meanwhile ? - obj = self.r_server.get(newKey) - if obj: - value = pickle.loads(obj) - break - finally: - self.r_server.delete(lock_key) + randomvalue = time.time() + al = acquire_lock(self.r_server, lock_key, randomvalue) + #someone may have computed it + obj = self.r_server.get(newKey) + if obj is None: + value = self.cache_it(newKey, f, time_expire) + else: + value = pickle.loads(obj) + release_lock(self, lock_key, al) else: #without distributed locking value = self.cache_it(newKey, f, time_expire) return value - except ConnectionError: + except RConnectionError: return self.retry_call(key, f, time_expire, with_lock) def cache_it(self, key, f, time_expire): @@ -172,39 +169,42 @@ class RedisClient(object): value_ = pickle.dumps(value, pickle.HIGHEST_PROTOCOL) if time_expire == 0: time_expire = 1 - self.r_server.setex(key, value_, time_expire) + self.r_server.setex(key, time_expire, value_) #print '%s will expire on %s: it goes in bucket %s' % (key, time.ctime(expireat)) #print 'that will expire on %s' % (bucket_key, time.ctime(((expireat/60) + 1)*60)) p = self.r_server.pipeline() #add bucket to the fixed set p.sadd(cache_set_key, bucket_key) #sets the key - p.setex(key, value_, time_expire) + p.setex(key, time_expire, value_) #add the key to the bucket p.sadd(bucket_key, key) #expire the bucket properly - p.expireat(bucket_key, ((expireat/60) + 1)*60) + p.expireat(bucket_key, ((expireat / 60) + 1) * 60) p.execute() return value - def retry_call(self, key, f, time_expire, with_locking): + def retry_call(self, key, f, time_expire, with_lock): self.RETRIES += 1 if self.RETRIES <= self.MAX_RETRIES: logger.error("sleeping %s seconds before reconnecting" % (2 * self.RETRIES)) time.sleep(2 * self.RETRIES) - self.__init__(self.server, self.db, self.debug, self.with_lock) - return self.__call__(key, f, time_expire, with_locking) + if self.fail_gracefully: + self.RETRIES = 0 + return f() + return self.__call__(key, f, time_expire, with_lock) else: self.RETRIES = 0 - raise ConnectionError('Redis instance is unavailable at %s' % ( - self.server)) + if self.fail_gracefully: + return f + raise RConnectionError('Redis instance is unavailable') def increment(self, key, value=1): try: newKey = self.__keyFormat__(key) return self.r_server.incr(newKey, value) - except ConnectionError: + except RConnectionError: return self.retry_increment(key, value) def retry_increment(self, key, value): @@ -212,12 +212,10 @@ class RedisClient(object): if self.RETRIES <= self.MAX_RETRIES: logger.error("sleeping some seconds before reconnecting") time.sleep(2 * self.RETRIES) - self.__init__(self.server, self.db, self.debug, self.with_lock) return self.increment(key, value) else: self.RETRIES = 0 - raise ConnectionError('Redis instance is unavailable at %s' % ( - self.server)) + raise RConnectionError('Redis instance is unavailable') def clear(self, regex): """ @@ -225,9 +223,9 @@ class RedisClient(object): clear cache entries """ r = re.compile(regex) - #get all buckets + # get all buckets buckets = self.r_server.smembers(self.cache_set_key) - #get all keys in buckets + # get all keys in buckets if buckets: keys = self.r_server.sunion(buckets) else: @@ -237,8 +235,8 @@ class RedisClient(object): for a in keys: if r.match(str(a).replace(prefix, '', 1)): pipe.delete(a) - if random.randrange(0,100) < 10: - #do this just once in a while (10% chance) + if random.randrange(0, 100) < 10: + # do this just once in a while (10% chance) self.clear_buckets(buckets) pipe.execute() diff --git a/gluon/contrib/redis_scheduler.py b/gluon/contrib/redis_scheduler.py new file mode 100644 index 00000000..109ff071 --- /dev/null +++ b/gluon/contrib/redis_scheduler.py @@ -0,0 +1,785 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +| This file is part of the web2py Web Framework +| Created by niphlod@gmail.com +| License: LGPLv3 (http://www.gnu.org/licenses/lgpl.html) + +Scheduler with redis backend +--------------------------------- +""" + +USAGE = """ +## Example + +For any existing app + +Create File: app/models/scheduler.py ====== +from gluon.contrib.redis_utils import RConn +from gluon.contrib.redis_scheduler import RScheduler + +def demo1(*args,**vars): + print 'you passed args=%s and vars=%s' % (args, vars) + return 'done!' + +def demo2(): + 1/0 + +rconn = RConn() +mysched = RScheduler(db, dict(demo1=demo1,demo2=demo2), ...., redis_conn=rconn) + +## run worker nodes with: + + cd web2py + python web2py.py -K app + +""" + +import os +import time +import socket +import datetime +import logging + +path = os.getcwd() + +if 'WEB2PY_PATH' not in os.environ: + os.environ['WEB2PY_PATH'] = path + +try: + from gluon.contrib.simplejson import loads, dumps +except: + from simplejson import loads, dumps + +IDENTIFIER = "%s#%s" % (socket.gethostname(), os.getpid()) + +logger = logging.getLogger('web2py.rscheduler.%s' % IDENTIFIER) + +from gluon.utils import web2py_uuid +from gluon.storage import Storage +from gluon.scheduler import * +from gluon.scheduler import _decode_dict +from gluon.contrib.redis_utils import RWatchError + + +POLLING = 'POLLING' + + +class RScheduler(Scheduler): + + def __init__(self, db, tasks=None, migrate=True, + worker_name=None, group_names=None, heartbeat=HEARTBEAT, + max_empty_runs=0, discard_results=False, utc_time=False, + redis_conn=None, mode=1): + + """ + Highly-experimental coordination with redis + Takes all args from Scheduler except redis_conn which + must be something closer to a StrictRedis instance. + + My only regret - and the reason why I kept this under the hood for a + while - is that it's hard to hook up in web2py to something happening + right after the commit to a table, which will enable this version of the + scheduler to process "immediate" tasks right away instead of waiting a + few seconds (see FIXME in queue_task()) + + mode is reserved for future usage patterns. + Right now it moves the coordination (which is the most intensive + routine in the scheduler in matters of IPC) of workers to redis. + I'd like to have incrementally redis-backed modes of operations, + such as e.g.: + - 1: IPC through redis (which is the current implementation) + - 2: Store task results in redis (which will relieve further pressure + from the db leaving the scheduler_run table empty and possibly + keep things smooth as tasks results can be set to expire + after a bit of time) + - 3: Move all the logic for storing and queueing tasks to redis + itself - which means no scheduler_task usage too - and use + the database only as an historical record-bookkeeping + (e.g. for reporting) + + As usual, I'm eager to see your comments. + """ + + Scheduler.__init__(self, db, tasks=tasks, migrate=migrate, + worker_name=worker_name, group_names=group_names, + heartbeat=heartbeat, max_empty_runs=max_empty_runs, + discard_results=discard_results, utc_time=utc_time) + + self.r_server = redis_conn + from gluon import current + self._application = current.request.application or 'appname' + + def _nkey(self, key): + """Helper to restrict all keys to a namespace + and track them""" + prefix = 'w2p:rsched:%s' % self._application + allkeys = '%s:allkeys' % prefix + newkey = "%s:%s" % (prefix, key) + self.r_server.sadd(allkeys, newkey) + return newkey + + def prune_all(self): + """ + Just to be fair and implement a method + that does housekeeping + """ + all_keys = self._nkey('allkeys') + with self.r_server.pipeline() as pipe: + while True: + try: + pipe.watch('PRUNE_ALL') + while True: + k = pipe.spop(all_keys) + if k is None: + break + pipe.delete(k) + pipe.execute() + break + except RWatchError: + time.sleep(0.1) + continue + + def dt2str(self, value): + return value.strftime('%Y-%m-%d %H:%M:%S') + + def str2date(self, value): + return datetime.datetime.strptime(value, '%Y-%m-%d %H:%M:%S') + + def send_heartbeat(self, counter): + """ + workers coordination has evolved into something is not that + easy. Here we try to do what we need in a single transaction, + and retry that transaction if something goes wrong + """ + with self.r_server.pipeline() as pipe: + while True: + try: + pipe.watch('SEND_HEARTBEAT') + self.inner_send_heartbeat(counter, pipe) + pipe.execute() + self.adj_hibernation() + self.sleep() + break + except RWatchError: + time.sleep(0.1) + continue + + def inner_send_heartbeat(self, counter, pipe): + """ + Does a few things: + - registers the workers + - accepts commands sent to workers (KILL, TERMINATE, PICK, DISABLED, etc) + - adjusts sleep + - saves stats + - elects master + - does "housecleaning" for dead workers + - triggers tasks assignment + """ + r_server = pipe + status_keyset = self._nkey('worker_statuses') + status_key = self._nkey('worker_status:%s' % (self.worker_name)) + now = self.now() + mybackedstatus = r_server.hgetall(status_key) + if not mybackedstatus: + r_server.hmset( + status_key, + dict( + status=ACTIVE, worker_name=self.worker_name, + first_heartbeat=self.dt2str(now), + last_heartbeat=self.dt2str(now), + group_names=dumps(self.group_names), is_ticker=False, + worker_stats=dumps(self.w_stats)) + ) + r_server.sadd(status_keyset, status_key) + if not self.w_stats.status == POLLING: + self.w_stats.status = ACTIVE + self.w_stats.sleep = self.heartbeat + mybackedstatus = ACTIVE + else: + mybackedstatus = mybackedstatus['status'] + if mybackedstatus == DISABLED: + # keep sleeping + self.w_stats.status = DISABLED + r_server.hmset( + status_key, + dict(last_heartbeat=self.dt2str(now), + worker_stats=dumps(self.w_stats)) + ) + elif mybackedstatus == TERMINATE: + self.w_stats.status = TERMINATE + logger.debug("Waiting to terminate the current task") + self.give_up() + elif mybackedstatus == KILL: + self.w_stats.status = KILL + self.die() + else: + if mybackedstatus == STOP_TASK: + logger.info('Asked to kill the current task') + self.terminate_process() + logger.info('........recording heartbeat (%s)', + self.w_stats.status) + r_server.hmset( + status_key, + dict( + last_heartbeat=self.dt2str(now), status=ACTIVE, + worker_stats=dumps(self.w_stats) + ) + ) + # newroutine + r_server.expire(status_key, self.heartbeat * 3 * 15) + self.w_stats.sleep = self.heartbeat # re-activating the process + if self.w_stats.status not in (RUNNING, POLLING): + self.w_stats.status = ACTIVE + + self.do_assign_tasks = False + if counter % 5 == 0 or mybackedstatus == PICK: + try: + logger.info( + ' freeing workers that have not sent heartbeat') + registered_workers = r_server.smembers(status_keyset) + allkeys = self._nkey('allkeys') + for worker in registered_workers: + w = r_server.hgetall(worker) + w = Storage(w) + if not w: + r_server.srem(status_keyset, worker) + logger.info('removing %s from %s', worker, allkeys) + r_server.srem(allkeys, worker) + continue + try: + self.is_a_ticker = self.being_a_ticker(pipe) + except: + pass + if self.w_stats.status in (ACTIVE, POLLING): + self.do_assign_tasks = True + if self.is_a_ticker and self.do_assign_tasks: + # I'm a ticker, and 5 loops passed without reassigning tasks, + # let's do that and loop again + if not self.db_thread: + logger.debug('thread building own DAL object') + self.db_thread = DAL( + self.db._uri, folder=self.db._adapter.folder) + self.define_tables(self.db_thread, migrate=False) + db = self.db_thread + self.wrapped_assign_tasks(db) + return None + except: + logger.error('Error assigning tasks') + + def being_a_ticker(self, pipe): + """ + This is slightly more convoluted than the original + but if far more efficient + """ + r_server = pipe + status_keyset = self._nkey('worker_statuses') + registered_workers = r_server.smembers(status_keyset) + ticker = None + all_active = [] + all_workers = [] + for worker in registered_workers: + w = r_server.hgetall(worker) + if w['worker_name'] != self.worker_name and w['status'] == ACTIVE: + all_active.append(w) + if w['is_ticker'] == 'True' and ticker is None: + ticker = w + all_workers.append(w) + not_busy = self.w_stats.status in (ACTIVE, POLLING) + if not ticker: + if not_busy: + # only if this worker isn't busy, otherwise wait for a free one + for worker in all_workers: + key = self._nkey('worker_status:%s' % worker['worker_name']) + if worker['worker_name'] == self.worker_name: + r_server.hset(key, 'is_ticker', True) + else: + r_server.hset(key, 'is_ticker', False) + logger.info("TICKER: I'm a ticker") + else: + # giving up, only if I'm not alone + if len(all_active) > 1: + key = self._nkey('worker_status:%s' % (self.worker_name)) + r_server.hset(key, 'is_ticker', False) + else: + not_busy = True + return not_busy + else: + logger.info( + "%s is a ticker, I'm a poor worker" % ticker['worker_name']) + return False + + def assign_tasks(self, db): + """ + The real beauty. We don't need to ASSIGN tasks, we just put + them into the relevant queue + """ + st, sd = db.scheduler_task, db.scheduler_task_deps + r_server = self.r_server + now = self.now() + status_keyset = self._nkey('worker_statuses') + with r_server.pipeline() as pipe: + while 1: + try: + # making sure we're the only one doing the job + pipe.watch('ASSIGN_TASKS') + registered_workers = pipe.smembers(status_keyset) + all_workers = [] + for worker in registered_workers: + w = pipe.hgetall(worker) + if w['status'] == ACTIVE: + all_workers.append(Storage(w)) + pipe.execute() + break + except RWatchError: + time.sleep(0.1) + continue + + # build workers as dict of groups + wkgroups = {} + for w in all_workers: + group_names = loads(w.group_names) + for gname in group_names: + if gname not in wkgroups: + wkgroups[gname] = dict( + workers=[{'name': w.worker_name, 'c': 0}]) + else: + wkgroups[gname]['workers'].append( + {'name': w.worker_name, 'c': 0}) + # set queued tasks that expired between "runs" (i.e., you turned off + # the scheduler): then it wasn't expired, but now it is + db( + (st.status.belongs((QUEUED, ASSIGNED))) & + (st.stop_time < now) + ).update(status=EXPIRED) + + # calculate dependencies + deps_with_no_deps = db( + (sd.can_visit == False) & + (~sd.task_child.belongs( + db(sd.can_visit == False)._select(sd.task_parent) + ) + ) + )._select(sd.task_child) + no_deps = db( + (st.status.belongs((QUEUED, ASSIGNED))) & + ( + (sd.id == None) | (st.id.belongs(deps_with_no_deps)) + + ) + )._select(st.id, distinct=True, left=sd.on( + (st.id == sd.task_parent) & + (sd.can_visit == False) + ) + ) + + all_available = db( + (st.status.belongs((QUEUED, ASSIGNED))) & + ((st.times_run < st.repeats) | (st.repeats == 0)) & + (st.start_time <= now) & + ((st.stop_time == None) | (st.stop_time > now)) & + (st.next_run_time <= now) & + (st.enabled == True) & + (st.id.belongs(no_deps)) + ) + + limit = len(all_workers) * (50 / (len(wkgroups) or 1)) + + # let's freeze it up + db.commit() + x = 0 + r_server = self.r_server + for group in wkgroups.keys(): + queued_list = self._nkey('queued:%s' % group) + queued_set = self._nkey('queued_set:%s' % group) + # if are running, let's don't assign them again + running_list = self._nkey('running:%s' % group) + while True: + # the joys for rpoplpush! + t = r_server.rpoplpush(running_list, queued_list) + if not t: + # no more + break + r_server.sadd(queued_set, t) + + tasks = all_available(st.group_name == group).select( + limitby=(0, limit), orderby = st.next_run_time) + + # put tasks in the processing list + + for task in tasks: + x += 1 + gname = task.group_name + + if r_server.sismember(queued_set, task.id): + # already queued, we don't put on the list + continue + r_server.sadd(queued_set, task.id) + r_server.lpush(queued_list, task.id) + d = dict(status=QUEUED) + if not task.task_name: + d['task_name'] = task.function_name + db( + (st.id == task.id) & + (st.status.belongs((QUEUED, ASSIGNED))) + ).update(**d) + db.commit() + # I didn't report tasks but I'm working nonetheless!!!! + if x > 0: + self.w_stats.empty_runs = 0 + self.w_stats.queue = x + self.w_stats.distribution = wkgroups + self.w_stats.workers = len(all_workers) + # I'll be greedy only if tasks queued are equal to the limit + # (meaning there could be others ready to be queued) + self.greedy = x >= limit + logger.info('TICKER: workers are %s', len(all_workers)) + logger.info('TICKER: tasks are %s', x) + + def pop_task(self, db): + r_server = self.r_server + st = self.db.scheduler_task + task = None + # ready to process something + for group in self.group_names: + queued_set = self._nkey('queued_set:%s' % group) + queued_list = self._nkey('queued:%s' % group) + running_list = self._nkey('running:%s' % group) + running_dict = self._nkey('running_dict:%s' % group) + self.w_stats.status = POLLING + # polling for 1 minute in total. If more groups are in, + # polling is 1 minute in total + logger.debug(' polling on %s' , group) + task_id = r_server.brpoplpush(queued_list, running_list, timeout=60/len(self.group_names)) + logger.debug(' finished polling') + self.w_stats.status = ACTIVE + if task_id: + r_server.hset(running_dict, task_id, self.worker_name) + r_server.srem(queued_set, task_id) + task = db( + (st.id == task_id) & + (st.status == QUEUED) + ).select().first() + if not task: + r_server.lrem(running_list, 0, task_id) + r_server.hdel(running_dict, task_id) + r_server.lrem(queued_list, 0, task_id) + logger.error("we received a task that isn't there (%s)" % task_id) + return None + break + now = self.now() + if task: + task.update_record(status=RUNNING, last_run_time=now) + # noone will touch my task! + db.commit() + logger.debug(' work to do %s', task.id) + else: + logger.info('nothing to do (%s)' % self.w_stats.status) + return None + times_run = task.times_run + 1 + if not task.prevent_drift: + next_run_time = task.last_run_time + datetime.timedelta( + seconds=task.period + ) + else: + next_run_time = task.start_time + datetime.timedelta( + seconds=task.period * times_run + ) + if times_run < task.repeats or task.repeats == 0: + # need to run (repeating task) + run_again = True + else: + # no need to run again + run_again = False + run_id = 0 + while True and not self.discard_results: + logger.debug(' new scheduler_run record') + try: + run_id = db.scheduler_run.insert( + task_id=task.id, + status=RUNNING, + start_time=now, + worker_name=self.worker_name) + db.commit() + break + except: + time.sleep(0.5) + db.rollback() + logger.info('new task %(id)s "%(task_name)s"' + ' %(application_name)s.%(function_name)s' % task) + return Task( + app=task.application_name, + function=task.function_name, + timeout=task.timeout, + args=task.args, # in json + vars=task.vars, # in json + task_id=task.id, + run_id=run_id, + run_again=run_again, + next_run_time=next_run_time, + times_run=times_run, + stop_time=task.stop_time, + retry_failed=task.retry_failed, + times_failed=task.times_failed, + sync_output=task.sync_output, + uuid=task.uuid, + group_name=task.group_name) + + def report_task(self, task, task_report): + """ + Needs overwriting only because we need to pop from the + running tasks + """ + r_server = self.r_server + db = self.db + now = self.now() + st = db.scheduler_task + sr = db.scheduler_run + if not self.discard_results: + if task_report.result != 'null' or task_report.tb: + # result is 'null' as a string if task completed + # if it's stopped it's None as NoneType, so we record + # the STOPPED "run" anyway + logger.debug(' recording task report in db (%s)', + task_report.status) + db(sr.id == task.run_id).update( + status=task_report.status, + stop_time=now, + run_result=task_report.result, + run_output=task_report.output, + traceback=task_report.tb) + else: + logger.debug(' deleting task report in db because of no result') + db(sr.id == task.run_id).delete() + # if there is a stop_time and the following run would exceed it + is_expired = (task.stop_time + and task.next_run_time > task.stop_time + and True or False) + status = (task.run_again and is_expired and EXPIRED + or task.run_again and not is_expired + and QUEUED or COMPLETED) + if task_report.status == COMPLETED: + # assigned calculations + d = dict(status=status, + next_run_time=task.next_run_time, + times_run=task.times_run, + times_failed=0, + assigned_worker_name=self.worker_name + ) + db(st.id == task.task_id).update(**d) + if status == COMPLETED: + self.update_dependencies(db, task.task_id) + else: + st_mapping = {'FAILED': 'FAILED', + 'TIMEOUT': 'TIMEOUT', + 'STOPPED': 'FAILED'}[task_report.status] + status = (task.retry_failed + and task.times_failed < task.retry_failed + and QUEUED or task.retry_failed == -1 + and QUEUED or st_mapping) + db(st.id == task.task_id).update( + times_failed=db.scheduler_task.times_failed + 1, + next_run_time=task.next_run_time, + status=status, + assigned_worker_name=self.worker_name + ) + logger.info('task completed (%s)', task_report.status) + running_list = self._nkey('running:%s' % task.group_name) + running_dict = self._nkey('running_dict:%s' % task.group_name) + r_server.lrem(running_list, 0, task.task_id) + r_server.hdel(running_dict, task.task_id) + + def wrapped_pop_task(self): + """Commodity function to call `pop_task` and trap exceptions + If an exception is raised, assume it happened because of database + contention and retries `pop_task` after 0.5 seconds + """ + db = self.db + db.commit() # another nifty db.commit() only for Mysql + x = 0 + while x < 10: + try: + rtn = self.pop_task(db) + return rtn + break + # this is here to "interrupt" any blrpoplpush op easily + except KeyboardInterrupt: + self.give_up() + break + except: + self.w_stats.errors += 1 + db.rollback() + logger.error(' error popping tasks') + x += 1 + time.sleep(0.5) + + def get_workers(self, only_ticker=False): + """ Returns a dict holding worker_name : {**columns} + representing all "registered" workers + only_ticker returns only the worker running as a TICKER, + if there is any + """ + r_server = self.r_server + status_keyset = self._nkey('worker_statuses') + registered_workers = r_server.smembers(status_keyset) + all_workers = {} + for worker in registered_workers: + w = r_server.hgetall(worker) + w = Storage(w) + if not w: + continue + all_workers[w.worker_name] = Storage( + status=w.status, + first_heartbeat=self.str2date(w.first_heartbeat), + last_heartbeat=self.str2date(w.last_heartbeat), + group_names=loads(w.group_names, object_hook=_decode_dict), + is_ticker=w.is_ticker == 'True' and True or False, + worker_stats=loads(w.worker_stats, object_hook=_decode_dict) + ) + if only_ticker: + for k, v in all_workers.iteritems(): + if v['is_ticker']: + return {k: v} + return {} + return all_workers + + def set_worker_status(self, group_names=None, action=ACTIVE, + exclude=None, limit=None, worker_name=None): + """Internal function to set worker's status""" + r_server = self.r_server + all_workers = self.get_workers() + if not group_names: + group_names = self.group_names + elif isinstance(group_names, str): + group_names = [group_names] + exclusion = exclude and exclude.append(action) or [action] + workers = [] + if worker_name is not None: + if worker_name in all_workers.keys(): + workers = [worker_name] + else: + for k, v in all_workers.iteritems(): + if v.status not in exclusion and set(group_names) & set(v.group_names): + workers.append(k) + if limit and worker_name is None: + workers = workers[:limit] + if workers: + with r_server.pipeline() as pipe: + while True: + try: + pipe.watch('SET_WORKER_STATUS') + for w in workers: + worker_key = self._nkey('worker_status:%s' % w) + pipe.hset(worker_key, 'status', action) + pipe.execute() + break + except RWatchError: + time.sleep(0.1) + continue + + def queue_task(self, function, pargs=[], pvars={}, **kwargs): + """ + FIXME: immediate should put item in queue. The hard part is + that currently there are no hooks happening at post-commit time + Queue tasks. This takes care of handling the validation of all + parameters + + Args: + function: the function (anything callable with a __name__) + pargs: "raw" args to be passed to the function. Automatically + jsonified. + pvars: "raw" kwargs to be passed to the function. Automatically + jsonified + kwargs: all the parameters available (basically, every + `scheduler_task` column). If args and vars are here, they should + be jsonified already, and they will override pargs and pvars + + Returns: + a dict just as a normal validate_and_insert(), plus a uuid key + holding the uuid of the queued task. If validation is not passed + ( i.e. some parameters are invalid) both id and uuid will be None, + and you'll get an "error" dict holding the errors found. + """ + if hasattr(function, '__name__'): + function = function.__name__ + targs = 'args' in kwargs and kwargs.pop('args') or dumps(pargs) + tvars = 'vars' in kwargs and kwargs.pop('vars') or dumps(pvars) + tuuid = 'uuid' in kwargs and kwargs.pop('uuid') or web2py_uuid() + tname = 'task_name' in kwargs and kwargs.pop('task_name') or function + immediate = 'immediate' in kwargs and kwargs.pop('immediate') or None + rtn = self.db.scheduler_task.validate_and_insert( + function_name=function, + task_name=tname, + args=targs, + vars=tvars, + uuid=tuuid, + **kwargs) + if not rtn.errors: + rtn.uuid = tuuid + if immediate: + r_server = self.r_server + ticker = self.get_workers(only_ticker=True) + if ticker.keys(): + ticker = ticker.keys()[0] + with r_server.pipeline() as pipe: + while True: + try: + pipe.watch('SET_WORKER_STATUS') + worker_key = self._nkey('worker_status:%s' % ticker) + pipe.hset(worker_key, 'status', 'PICK') + pipe.execute() + break + except RWatchError: + time.sleep(0.1) + continue + else: + rtn.uuid = None + return rtn + + def stop_task(self, ref): + """Shortcut for task termination. + + If the task is RUNNING it will terminate it, meaning that status + will be set as FAILED. + + If the task is QUEUED, its stop_time will be set as to "now", + the enabled flag will be set to False, and the status to STOPPED + + Args: + ref: can be + + - an integer : lookup will be done by scheduler_task.id + - a string : lookup will be done by scheduler_task.uuid + + Returns: + - 1 if task was stopped (meaning an update has been done) + - None if task was not found, or if task was not RUNNING or QUEUED + + Note: + Experimental + """ + r_server = self.r_server + st = self.db.scheduler_task + if isinstance(ref, int): + q = st.id == ref + elif isinstance(ref, str): + q = st.uuid == ref + else: + raise SyntaxError( + "You can retrieve results only by id or uuid") + task = self.db(q).select(st.id, st.status, st.group_name) + task = task.first() + rtn = None + if not task: + return rtn + running_dict = self._nkey('running_dict:%s' % task.group_name) + if task.status == 'RUNNING': + worker_key = r_server.hget(running_dict, task.id) + worker_key = self._nkey('worker_status:%s' % (worker_key)) + r_server.hset(worker_key, 'status', STOP_TASK) + elif task.status == 'QUEUED': + rtn = self.db(q).update( + stop_time=self.now(), + enabled=False, + status=STOPPED) + return rtn diff --git a/gluon/contrib/redis_session.py b/gluon/contrib/redis_session.py index f9e2fa29..c987e64e 100644 --- a/gluon/contrib/redis_session.py +++ b/gluon/contrib/redis_session.py @@ -1,13 +1,18 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- """ Developed by niphlod@gmail.com +License MIT/BSD/GPL + +Redis-backed sessions """ -import redis -from gluon import current -from gluon.storage import Storage -import time import logging import thread +from gluon import current +from gluon.storage import Storage +from gluon.contrib.redis_utils import acquire_lock, release_lock +from gluon.contrib.redis_utils import register_release_lock logger = logging.getLogger("web2py.session.redis") @@ -16,10 +21,20 @@ locker = thread.allocate_lock() def RedisSession(*args, **vars): """ - Usage example: put in models - from gluon.contrib.redis_session import RedisSession - sessiondb = RedisSession('localhost:6379',db=0, session_expiry=False, password=None) - session.connect(request, response, db = sessiondb) + Usage example: put in models:: + + from gluon.contrib.redis_utils import RConn + rconn = RConn() + from gluon.contrib.redis_session + sessiondb = RedisSession(redis_conn=rconn, with_lock=True, session_expiry=False) + session.connect(request, response, db = sessiondb) + + Args: + redis_conn: a redis-like connection object + with_lock: prevent concurrent modifications to the same session + session_expiry: delete automatically sessions after n seconds + (still need to run sessions2trash.py every 1M sessions + or so) Simple slip-in storage for session """ @@ -36,30 +51,9 @@ def RedisSession(*args, **vars): class RedisClient(object): - meta_storage = {} - MAX_RETRIES = 5 - RETRIES = 0 - _release_script = None - - def __init__(self, server='localhost:6379', db=None, debug=False, - session_expiry=False, with_lock=False, password=None): - """session_expiry can be an integer, in seconds, to set the default expiration - of sessions. The corresponding record will be deleted from the redis instance, - and there's virtually no need to run sessions2trash.py - """ - self.server = server - self.password = password - self.db = db or 0 - host, port = (self.server.split(':') + ['6379'])[:2] - port = int(port) - self.debug = debug - if current and current.request: - self.app = current.request.application - else: - self.app = '' - self.r_server = redis.Redis(host=host, port=port, db=self.db, password=self.password) - if with_lock: - RedisClient._release_script = self.r_server.register_script(_LUA_RELEASE_LOCK) + def __init__(self, redis_conn, session_expiry=False, with_lock=False): + self.r_server = redis_conn + self._release_script = register_release_lock(self.r_server) self.tablename = None self.session_expiry = session_expiry self.with_lock = with_lock @@ -93,12 +87,11 @@ class RedisClient(object): class MockTable(object): def __init__(self, db, r_server, tablename, session_expiry, with_lock=False): + # here self.db is the RedisClient instance self.db = db - self.r_server = r_server self.tablename = tablename # set the namespace for sessions of this app - self.keyprefix = 'w2p:sess:%s' % tablename.replace( - 'web2py_session_', '') + self.keyprefix = 'w2p:sess:%s' % tablename.replace('web2py_session_', '') # fast auto-increment id (needed for session handling) self.serial = "%s:serial" % self.keyprefix # index of all the session keys of this app @@ -126,7 +119,7 @@ class MockTable(object): if key == 'id': # return a fake query. We need to query it just by id for normal operations self.query = MockQuery( - field='id', db=self.r_server, + field='id', db=self.db, prefix=self.keyprefix, session_expiry=self.session_expiry, with_lock=self.with_lock, unique_key=self.unique_key ) @@ -140,12 +133,12 @@ class MockTable(object): # 'locked', 'client_ip','created_datetime','modified_datetime' # 'unique_key', 'session_data' # retrieve a new key - newid = str(self.r_server.incr(self.serial)) + newid = str(self.db.r_server.incr(self.serial)) key = self.keyprefix + ':' + newid if self.with_lock: key_lock = key + ':lock' - acquire_lock(self.r_server, key_lock, newid) - with self.r_server.pipeline() as pipe: + acquire_lock(self.db.r_server, key_lock, newid) + with self.db.r_server.pipeline() as pipe: # add it to the index pipe.sadd(self.id_idx, key) # set a hash key with the Storage @@ -154,7 +147,7 @@ class MockTable(object): pipe.expire(key, self.session_expiry) pipe.execute() if self.with_lock: - release_lock(self.r_server, key_lock, newid) + release_lock(self.db, key_lock, newid) return newid @@ -186,8 +179,8 @@ class MockQuery(object): # means that someone wants to retrieve the key self.value key = self.keyprefix + ':' + str(self.value) if self.with_lock: - acquire_lock(self.db, key + ':lock', self.value) - rtn = self.db.hgetall(key) + acquire_lock(self.db.r_server, key + ':lock', self.value, 2) + rtn = self.db.r_server.hgetall(key) if rtn: if self.unique_key: # make sure the id and unique_key are correct @@ -201,13 +194,13 @@ class MockQuery(object): rtn = [] id_idx = "%s:id_idx" % self.keyprefix # find all session keys of this app - allkeys = self.db.smembers(id_idx) + allkeys = self.db.r_server.smembers(id_idx) for sess in allkeys: - val = self.db.hgetall(sess) + val = self.db.r_server.hgetall(sess) if not val: if self.session_expiry: # clean up the idx, because the key expired - self.db.srem(id_idx, sess) + self.db.r_server.srem(id_idx, sess) continue val = Storage(val) # add a delete_record method (necessary for sessions2trash.py) @@ -222,9 +215,9 @@ class MockQuery(object): # means that the session has been found and needs an update if self.op == 'eq' and self.field == 'id' and self.value: key = self.keyprefix + ':' + str(self.value) - if not self.db.exists(key): + if not self.db.r_server.exists(key): return None - with self.db.pipeline() as pipe: + with self.db.r_server.pipeline() as pipe: pipe.hmset(key, kwargs) if self.session_expiry: pipe.expire(key, self.session_expiry) @@ -238,7 +231,7 @@ class MockQuery(object): if self.op == 'eq' and self.field == 'id' and self.value: id_idx = "%s:id_idx" % self.keyprefix key = self.keyprefix + ':' + str(self.value) - with self.db.pipeline() as pipe: + with self.db.r_server.pipeline() as pipe: pipe.delete(key) pipe.srem(id_idx, key) rtn = pipe.execute() @@ -254,29 +247,6 @@ class RecordDeleter(object): def __call__(self): id_idx = "%s:id_idx" % self.keyprefix # remove from the index - self.db.srem(id_idx, self.key) + self.db.r_server.srem(id_idx, self.key) # remove the key itself - self.db.delete(self.key) - - -def acquire_lock(conn, lockname, identifier, ltime=10): - while True: - if conn.set(lockname, identifier, ex=ltime, nx=True): - return identifier - time.sleep(.01) - - -_LUA_RELEASE_LOCK = """ -if redis.call("get", KEYS[1]) == ARGV[1] -then - return redis.call("del", KEYS[1]) -else - return 0 -end -""" - - -def release_lock(conn, lockname, identifier): - return RedisClient._release_script( - keys=[lockname], args=[identifier], - client=conn) + self.db.r_server.delete(self.key) diff --git a/gluon/contrib/redis_utils.py b/gluon/contrib/redis_utils.py new file mode 100644 index 00000000..217ca952 --- /dev/null +++ b/gluon/contrib/redis_utils.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Developed by niphlod@gmail.com +License MIT/BSD/GPL + +Serves as base to implement Redis connection object and various utils +for redis_cache, redis_session and redis_scheduler in the future +Should-could be overriden in case redis doesn't keep up (e.g. cluster support) +to ensure compatibility with another - similar - library +""" + +import logging +import thread +import time +from gluon import current + +logger = logging.getLogger("web2py.redis_utils") + +try: + import redis + from redis.exceptions import WatchError as RWatchError + from redis.exceptions import ConnectionError as RConnectionError +except ImportError: + logger.error("Needs redis library to work") + raise RuntimeError('Needs redis library to work') + + +locker = thread.allocate_lock() + + +def RConn(*args, **vars): + """ + Istantiates a StrictRedis connection with parameters, at the first time + only + """ + locker.acquire() + try: + instance_name = 'redis_conn_' + current.request.application + if not hasattr(RConn, instance_name): + setattr(RConn, instance_name, redis.StrictRedis(*args, **vars)) + return getattr(RConn, instance_name) + finally: + locker.release() + +def acquire_lock(conn, lockname, identifier, ltime=10): + while True: + if conn.set(lockname, identifier, ex=ltime, nx=True): + return identifier + time.sleep(.01) + + +_LUA_RELEASE_LOCK = """ +if redis.call("get", KEYS[1]) == ARGV[1] +then + return redis.call("del", KEYS[1]) +else + return 0 +end +""" + + +def release_lock(instance, lockname, identifier): + return instance._release_script( + keys=[lockname], args=[identifier]) + + +def register_release_lock(conn): + rtn = conn.register_script(_LUA_RELEASE_LOCK) + return rtn diff --git a/gluon/contrib/simplejsonrpc.py b/gluon/contrib/simplejsonrpc.py index 6a68da0a..de040eec 100644 --- a/gluon/contrib/simplejsonrpc.py +++ b/gluon/contrib/simplejsonrpc.py @@ -33,7 +33,7 @@ except ImportError: class JSONRPCError(RuntimeError): "Error object for remote procedure call fail" - def __init__(self, code, message, data=None): + def __init__(self, code, message, data=''): value = "%s: %s\n%s" % (code, message, '\n'.join(data)) RuntimeError.__init__(self, value) self.code = code diff --git a/gluon/contrib/spreadsheet.py b/gluon/contrib/spreadsheet.py index 40663d81..3f3f9377 100644 --- a/gluon/contrib/spreadsheet.py +++ b/gluon/contrib/spreadsheet.py @@ -16,7 +16,7 @@ def quote(text): class Node: def __init__(self, name, value, url='.', readonly=False, active=True, - onchange=None, **kwarg): + onchange=None, select=False, size=4, **kwarg): self.url = url self.name = name self.value = str(value) @@ -26,11 +26,21 @@ class Node: self.readonly = readonly self.active = active self.onchange = onchange - self.size = 4 + self.size = size self.locked = False + self.select = value if select and not isinstance(value, str) else False def xml(self): - return """ @@ -391,7 +401,8 @@ class Sheet: def __init__(self, rows, cols, url='.', readonly=False, active=True, onchange=None, value=None, data=None, - headers=None, update_button="", **kwarg): + headers=None, update_button="", c_headers=None, + r_headers=None, **kwarg): """ Arguments: @@ -425,6 +436,9 @@ class Sheet: self.tr_attributes = {} self.td_attributes = {} + self.c_headers = c_headers + self.r_headers = r_headers + self.data = data self.readonly = readonly @@ -505,7 +519,7 @@ class Sheet: self.environment[name] = obj def cell(self, key, value, readonly=False, active=True, - onchange=None, **kwarg): + onchange=None, select=False, **kwarg): """ key is the name of the cell value is the initial value of the cell. It can be a formula "=1+3" @@ -528,7 +542,7 @@ class Sheet: value = value(r, c) node = Node(key, value, self.url, readonly, active, - onchange, **kwarg) + onchange, select=select, **kwarg) self.nodes[key] = node self[key] = value @@ -781,11 +795,19 @@ class Sheet: gluon.html.TH, gluon.html.BR, gluon.html.SCRIPT) regex = re.compile('r\d+c\d+') - header = TR(TH(), *[TH('c%s' % c) + if not self.c_headers: + header = TR(TH(), *[TH('c%s' % c) for c in range(self.cols)]) + else: + header = TR(TH(), *[TH('%s' % c) + for c in self.c_headers]) + rows = [] for r in range(self.rows): - tds = [TH('r%s' % r), ] + if not self.r_headers: + tds = [TH('r%s' % r), ] + else: + tds = [TH('%s' % self.r_headers[r]), ] for c in range(self.cols): key = 'r%sc%s' % (r, c) attributes = {"_class": "w2p_spreadsheet_col_%s" % diff --git a/gluon/contrib/websocket_messaging.py b/gluon/contrib/websocket_messaging.py index 14b14a15..d732e07e 100644 --- a/gluon/contrib/websocket_messaging.py +++ b/gluon/contrib/websocket_messaging.py @@ -146,8 +146,8 @@ class TokenHandler(tornado.web.RequestHandler): class DistributeHandler(tornado.websocket.WebSocketHandler): - def check_origin(self, origin): - return True + def check_origin(self, origin): + return True def open(self, params): group, token, name = params.split('/') + [None, None] diff --git a/gluon/custom_import.py b/gluon/custom_import.py index f7bd91a2..cf677fda 100644 --- a/gluon/custom_import.py +++ b/gluon/custom_import.py @@ -41,7 +41,7 @@ class CustomImportException(ImportError): def custom_importer(name, globals=None, locals=None, fromlist=None, level=-1): """ - web2py's custom importer. It behaves like the standard Python importer but + web2py's custom importer. It behaves like the standard Python importer but it tries to transform import statements as something like "import applications.app_name.modules.x". If the import fails, it falls back on naive_importer @@ -80,7 +80,7 @@ def custom_importer(name, globals=None, locals=None, fromlist=None, level=-1): if not fromlist: # import like "import x" or "import x.y" result = None - for itemname in name.split("."): + for itemname in name.split("."): new_mod = base_importer( modules_prefix, globals, locals, [itemname], level) try: diff --git a/gluon/globals.py b/gluon/globals.py index 644a6b83..5581ade8 100644 --- a/gluon/globals.py +++ b/gluon/globals.py @@ -208,7 +208,7 @@ class Request(Storage): def parse_get_vars(self): """Takes the QUERY_STRING and unpacks it to get_vars """ - query_string = self.env.get('QUERY_STRING', '') + query_string = self.env.get('query_string', '') dget = urlparse.parse_qs(query_string, keep_blank_values=1) # Ref: https://docs.python.org/2/library/cgi.html#cgi.parse_qs get_vars = self._get_vars = Storage(dget) for (key, value) in get_vars.iteritems(): @@ -362,20 +362,30 @@ class Request(Storage): redirect(URL(scheme='https', args=self.args, vars=self.vars)) def restful(self): - def wrapper(action, self=self): - def f(_action=action, _self=self, *a, **b): - self.is_restful = True - method = _self.env.request_method - if len(_self.args) and '.' in _self.args[-1]: - _self.args[-1], _, self.extension = self.args[-1].rpartition('.') + def wrapper(action, request=self): + def f(_action=action, *a, **b): + request.is_restful = True + env = request.env + is_json = env.content_type=='application/json' + method = env.request_method + if len(request.args) and '.' in request.args[-1]: + request.args[-1], _, request.extension = request.args[-1].rpartition('.') current.response.headers['Content-Type'] = \ - contenttype('.' + _self.extension.lower()) + contenttype('.' + request.extension.lower()) rest_action = _action().get(method, None) if not (rest_action and method == method.upper() and callable(rest_action)): raise HTTP(405, "method not allowed") try: - return rest_action(*_self.args, **getattr(_self, 'vars', {})) + vars = request.vars + if method == 'POST' and is_json: + body = request.body.read() + if len(body): + vars = sj.loads(body) + res = rest_action(*request.args, **vars) + if is_json and not isinstance(res, str): + res = json(res) + return res except TypeError, e: exc_type, exc_value, exc_traceback = sys.exc_info() if len(traceback.extract_tb(exc_traceback)) == 1: @@ -1023,10 +1033,16 @@ class Session(Storage): def _fixup_before_save(self): response = current.response rcookies = response.cookies - if self._forget and response.session_id_name in rcookies: + scookies = rcookies.get(response.session_id_name) + if not scookies: + return + if self._forget: del rcookies[response.session_id_name] - elif self._secure and response.session_id_name in rcookies: - rcookies[response.session_id_name]['secure'] = True + return + if self.get('httponly_cookies',True): + scookies['HttpOnly'] = True + if self._secure: + scookies['secure'] = True def clear_session_cookies(self): request = current.request @@ -1074,6 +1090,7 @@ class Session(Storage): if response.session_storage_type == 'file': target = recfile.generate(response.session_filename) try: + self._close(response) os.unlink(target) except: pass diff --git a/gluon/html.py b/gluon/html.py index 7177a743..b6fc7c0c 100644 --- a/gluon/html.py +++ b/gluon/html.py @@ -668,7 +668,7 @@ class XML(XmlComponent): def XML_unpickle(data): - return marshal.loads(data) + return XML(marshal.loads(data)) def XML_pickle(data): @@ -784,6 +784,9 @@ class DIV(XmlComponent): else: return self.components[i] + def get(self, i): + return self.attributes.get(i) + def __setitem__(self, i, value): """ Sets attribute with name 'i' or component #i. @@ -1135,7 +1138,7 @@ class DIV(XmlComponent): for (key, value) in kargs.iteritems(): if key not in ['first_only', 'replace', 'find_text']: if isinstance(value, (str, int)): - if self[key] != str(value): + if str(self[key]) != str(value): check = False elif key in self.attributes: if not value.search(str(self[key])): @@ -1856,6 +1859,8 @@ class INPUT(DIV): try: (value, errors) = validator(value) except: + import traceback + print traceback.format_exc() msg = "Validation error, field:%s %s" % (name,validator) raise Exception(msg) if not errors is None: @@ -2643,7 +2648,7 @@ def test(): >>> form=FORM(INPUT(value="Hello World", _name="var", requires=IS_MATCH('^\w+$'))) >>> isinstance(form.as_dict(), dict) True - >>> form.as_dict(flat=True).has_key("vars") + >>> "vars" in form.as_dict(flat=True) True >>> isinstance(form.as_json(), basestring) and len(form.as_json(sanitize=False)) > 0 True diff --git a/gluon/main.py b/gluon/main.py index ee3600a2..3c6a77e5 100644 --- a/gluon/main.py +++ b/gluon/main.py @@ -370,8 +370,8 @@ def wsgibase(environ, responder): cid = env.http_web2py_component_element, is_local = (env.remote_addr in local_hosts and client == env.remote_addr), - is_shell = cmd_opts and cmd_opts.shell, - is_sheduler = cmd_opts and cmd_opts.scheduler, + is_shell = False, + is_scheduler = False, is_https = env.wsgi_url_scheme in HTTPS_SCHEMES or \ request.env.http_x_forwarded_proto in HTTPS_SCHEMES \ or env.https == 'on' @@ -423,10 +423,13 @@ def wsgibase(environ, responder): # ################################################## if env.http_cookie: - try: - request.cookies.load(env.http_cookie) - except Cookie.CookieError, e: - pass # invalid cookies + for single_cookie in env.http_cookie.split(';'): + single_cookie = single_cookie.strip() + if single_cookie: + try: + request.cookies.load(single_cookie) + except Cookie.CookieError: + pass # single invalid cookie ignore # ################################################## # try load session or create new session file diff --git a/gluon/packages/dal b/gluon/packages/dal index 6ea8659a..598b2e99 160000 --- a/gluon/packages/dal +++ b/gluon/packages/dal @@ -1 +1 @@ -Subproject commit 6ea8659adc02e1ccd6075550d3ed095542332967 +Subproject commit 598b2e999f4e32af71f4deb90ced21e5a40c5f9c diff --git a/gluon/portalocker.py b/gluon/portalocker.py index de584323..d9a88d4e 100644 --- a/gluon/portalocker.py +++ b/gluon/portalocker.py @@ -53,8 +53,8 @@ except: except: try: import win32con - import win32file import pywintypes + import win32file os_locking = 'windows' except: pass diff --git a/gluon/recfile.py b/gluon/recfile.py index 180466e3..d5d772e5 100755 --- a/gluon/recfile.py +++ b/gluon/recfile.py @@ -9,7 +9,7 @@ Generates names for cache and session files -------------------------------------------- """ -import os, uuid +import os def generate(filename, depth=2, base=512): @@ -17,10 +17,10 @@ def generate(filename, depth=2, base=512): path, filename = os.path.split(filename) else: path = None - dummyhash = sum(ord(c)*256**(i % 4) for i, c in enumerate(filename)) % base**depth + dummyhash = sum(ord(c) * 256 ** (i % 4) for i, c in enumerate(filename)) % base ** depth folders = [] - for level in range(depth-1, -1, -1): - code, dummyhash = divmod(dummyhash, base**level) + for level in range(depth - 1, -1, -1): + code, dummyhash = divmod(dummyhash, base ** level) folders.append("%03x" % code) folders.append(filename) if path: @@ -63,17 +63,3 @@ def open(filename, mode="r", path=None): if mode.startswith('w') and not os.path.exists(os.path.dirname(fullfilename)): os.makedirs(os.path.dirname(fullfilename)) return file(fullfilename, mode) - - -def test(): - if not os.path.exists('tests'): - os.mkdir('tests') - for k in range(20): - filename = os.path.join('tests', str(uuid.uuid4()) + '.test') - open(filename, "w").write('test') - assert open(filename, "r").read() == 'test' - if exists(filename): - remove(filename) - -if __name__ == '__main__': - test() diff --git a/gluon/scheduler.py b/gluon/scheduler.py index dae66a5f..e89b82fa 100644 --- a/gluon/scheduler.py +++ b/gluon/scheduler.py @@ -391,7 +391,6 @@ class MetaScheduler(threading.Thread): except: p.terminate() p.join() - self.have_heartbeat = False logger.debug(' task stopped by general exception') tr = TaskReport(STOPPED) else: @@ -406,7 +405,6 @@ class MetaScheduler(threading.Thread): except Queue.Empty: tr = TaskReport(TIMEOUT) elif queue.empty(): - self.have_heartbeat = False logger.debug(' task stopped') tr = TaskReport(STOPPED) else: @@ -922,7 +920,7 @@ class Scheduler(MetaScheduler): else: st_mapping = {'FAILED': 'FAILED', 'TIMEOUT': 'TIMEOUT', - 'STOPPED': 'QUEUED'}[task_report.status] + 'STOPPED': 'FAILED'}[task_report.status] status = (task.retry_failed and task.times_failed < task.retry_failed and QUEUED or task.retry_failed == -1 diff --git a/gluon/shell.py b/gluon/shell.py index 1cac24e2..a37c9de1 100644 --- a/gluon/shell.py +++ b/gluon/shell.py @@ -129,6 +129,8 @@ def env( if global_settings.cmd_options: ip = global_settings.cmd_options.ip port = global_settings.cmd_options.port + request.is_shell = global_settings.cmd_options.shell is not None + request.is_scheduler = global_settings.cmd_options.scheduler is not None else: ip, port = '127.0.0.1', '8000' request.env.http_host = '%s:%s' % (ip, port) diff --git a/gluon/sqlhtml.py b/gluon/sqlhtml.py index 20d62f09..cff80b93 100644 --- a/gluon/sqlhtml.py +++ b/gluon/sqlhtml.py @@ -29,7 +29,7 @@ from gluon.html import URL, FIELDSET, P, DEFAULT_PASSWORD_DISPLAY from pydal.base import DEFAULT from pydal.objects import Table, Row, Expression, Field from pydal.adapters.base import CALLABLETYPES -from pydal.helpers.methods import smart_query, bar_encode +from pydal.helpers.methods import smart_query, bar_encode, _repr_ref from pydal.helpers.classes import Reference, SQLCustomType from gluon.storage import Storage from gluon.utils import md5_hash @@ -71,6 +71,26 @@ def represent(field, value, record): else: raise RuntimeError("field representation must take 1 or 2 args") +class CacheRepresenter(object): + def __init__(self): + self.cache = {} + def __call__(self, field, value, row): + cache = self.cache + if field not in cache: + cache[field] = {} + try: + nvalue = cache[field][value] + except KeyError: + try: + nvalue = field.represent(value, row) + except KeyError: + try: + nvalue = field.represent(value, row[field.tablename]) + except KeyError: + nvalue = None + if isinstance(field, _repr_ref): + cache[field][value] = nvalue + return nvalue def safe_int(x): try: @@ -626,13 +646,12 @@ class AutocompleteWidget(object): def __init__(self, request, field, id_field=None, db=None, orderby=None, limitby=(0, 10), distinct=False, keyword='_autocomplete_%(tablename)s_%(fieldname)s', - min_length=2, help_fields=None, help_string=None): + min_length=2, help_fields=None, help_string=None, at_beginning = True): self.help_fields = help_fields or [] self.help_string = help_string if self.help_fields and not self.help_string: - self.help_string = ' '.join('%%(%s)s' % f.name - for f in self.help_fields) + self.help_string = ' '.join('%%(%s)s' % f.name for f in self.help_fields) self.request = request self.keyword = keyword % dict(tablename=field.tablename, @@ -642,6 +661,7 @@ class AutocompleteWidget(object): self.limitby = limitby self.distinct = distinct self.min_length = min_length + self.at_beginning = at_beginning self.fields = [field] if id_field: self.is_reference = True @@ -659,8 +679,10 @@ class AutocompleteWidget(object): field = self.fields[0] if settings and settings.global_settings.web2py_runtime_gae: rows = self.db(field.__ge__(self.request.vars[self.keyword]) & field.__lt__(self.request.vars[self.keyword] + u'\ufffd')).select(orderby=self.orderby, limitby=self.limitby, *(self.fields+self.help_fields)) - else: + elif self.at_beginning: rows = self.db(field.like(self.request.vars[self.keyword] + '%', case_sensitive=False)).select(orderby=self.orderby, limitby=self.limitby, distinct=self.distinct, *(self.fields+self.help_fields)) + else: + rows = self.db(field.contains(self.request.vars[self.keyword], case_sensitive=False)).select(orderby=self.orderby, limitby=self.limitby, distinct=self.distinct, *(self.fields+self.help_fields)) if rows: if self.is_reference: id_field = self.fields[1] @@ -714,7 +736,7 @@ class AutocompleteWidget(object): name=name, div_id=div_id, u='F' + self.keyword) if self.min_length == 0: attr['_onfocus'] = attr['_onkeyup'] - return CAT(INPUT(**attr), + return CAT(INPUT(**attr), INPUT(_type='hidden', _id=key3, _value=value, _name=name, requires=field.requires), DIV(_id=div_id, _style='position:absolute;')) @@ -727,7 +749,7 @@ class AutocompleteWidget(object): key=self.keyword, id=attr['_id'], div_id=div_id, u='F' + self.keyword) if self.min_length == 0: attr['_onfocus'] = attr['_onkeyup'] - return CAT(INPUT(**attr), + return CAT(INPUT(**attr), DIV(_id=div_id, _style='position:absolute;')) @@ -818,7 +840,7 @@ def formstyle_bootstrap(form, fields): controls.add_class('span4') if isinstance(label, LABEL): - label['_class'] = 'control-label' + label['_class'] = add_class(label.get('_class'),'control-label') if _submit: # submit button has unwrapped label and controls, different class @@ -868,7 +890,7 @@ def formstyle_bootstrap3_stacked(form, fields): e.add_class('form-control') if isinstance(label, LABEL): - label['_class'] = 'control-label' + label['_class'] = add_class(label.get('_class'),'control-label') parent.append(DIV(label, _controls, _class='form-group', _id=id)) return parent @@ -916,8 +938,10 @@ def formstyle_bootstrap3_inline_factory(col_label_size=3): elif isinstance(controls, UL): for e in controls.elements("input"): e.add_class('form-control') + elif controls is None or isinstance(controls, basestring): + _controls = P(controls, _class="form-control-static %s" % col_class) if isinstance(label, LABEL): - label['_class'] = 'control-label %s' % label_col_class + label['_class'] = add_class(label.get('_class'),'control-label %s' % label_col_class) parent.append(DIV(label, _controls, _class='form-group', _id=id)) return parent @@ -1100,10 +1124,12 @@ class SQLFORM(FORM): raise HTTP(404, "Object not found") self.record = record - self.record_id = record_id if keyed: self.record_id = dict([(k, record and str(record[k]) or None) for k in table._primarykey]) + else: + self.record_id = record_id + self.field_parent = {} xfields = [] self.fields = fields @@ -1126,7 +1152,8 @@ class SQLFORM(FORM): extra_fields = extra_fields or [] self.extra_fields = {} for extra_field in extra_fields: - self.fields.append(extra_field.name) + if not extra_field.name in self.fields: + self.fields.append(extra_field.name) self.extra_fields[extra_field.name] = extra_field extra_field.db = table._db extra_field.table = table @@ -1160,6 +1187,14 @@ class SQLFORM(FORM): label = LABEL(label, label and sep, _for=field_id, _id=field_id + SQLFORM.ID_LABEL_SUFFIX) + cond = readonly or \ + (not ignore_rw and not field.writable and field.readable) + + if cond: + label['_class'] = 'readonly' + else: + label['_class'] = '' + row_id = field_id + SQLFORM.ID_ROW_SUFFIX if field.type == 'id': self.custom.dspval.id = nbsp @@ -1188,8 +1223,6 @@ class SQLFORM(FORM): default = field.default if isinstance(default, CALLABLETYPES): default = default() - cond = readonly or \ - (not ignore_rw and not field.writable and field.readable) if default is not None and not cond: default = field.formatter(default) @@ -1471,13 +1504,12 @@ class SQLFORM(FORM): hideerror=hideerror, **kwargs ) - - self.deleted = \ - request_vars.get(self.FIELDNAME_REQUEST_DELETE, False) + + self.deleted = request_vars.get(self.FIELDNAME_REQUEST_DELETE, False) self.custom.end = CAT(self.hidden_fields(), self.custom.end) - auch = record_id and self.errors and self.deleted + delete_exception = self.record_id and self.errors and self.deleted if self.record_changed and self.detect_record_change: message_onchange = \ @@ -1489,8 +1521,9 @@ class SQLFORM(FORM): if message_onchange is not None: current.response.flash = message_onchange return ret - elif (not ret) and (not auch): - # auch is true when user tries to delete a record + + elif (not ret) and (not delete_exception): + # delete_exception is true when user tries to delete a record # that does not pass validation, yet it should be deleted for fieldname in self.fields: @@ -1520,9 +1553,10 @@ class SQLFORM(FORM): self.accepted = ret return ret - if record_id and str(record_id) != str(self.record_id): - raise SyntaxError('user is tampering with form\'s record_id: ' - '%s != %s' % (record_id, self.record_id)) + if self.record_id: + if str(record_id) != str(self.record_id): + raise SyntaxError('user is tampering with form\'s record_id: ' + '%s != %s' % (record_id, self.record_id)) if record_id and dbio and not keyed: self.vars.id = self.record[self.id_field_name] @@ -1686,6 +1720,7 @@ class SQLFORM(FORM): self.id_field_name]).update(**fields) else: self.vars.id = self.table.insert(**fields) + self.accepted = ret return ret @@ -2048,7 +2083,7 @@ class SQLFORM(FORM): ## if it's not an integer if cache_count is None or isinstance(cache_count, tuple): if groupby: - c = 'count(*)' + c = 'count(*) AS count_all' nrows = db.executesql( 'select count(*) from (%s) _tmp;' % dbset._select(c, left=left, cacheable=True, @@ -2083,7 +2118,7 @@ class SQLFORM(FORM): elif isinstance(orderby, Field) and orderby is not field_id: # here we're with an ASC order on a field stored as orderby orderby = orderby | field_id - elif (isinstance(orderby, Expression) and + elif (isinstance(orderby, Expression) and orderby.first and orderby.first is not field_id): # here we're with a DESC order on a field stored as orderby.first orderby = orderby | field_id @@ -2668,7 +2703,7 @@ class SQLFORM(FORM): htmltable = TABLE(COLGROUP(*cols), THEAD(head)) tbody = TBODY() numrec = 0 - repr_cache = {} + repr_cache = CacheRepresenter() for row in rows: trcols = [] id = row[field_id] @@ -2688,27 +2723,13 @@ class SQLFORM(FORM): maxlength = maxtextlengths.get(str(field), maxtextlength) if field.represent: if field.type.startswith('reference'): - if field not in repr_cache: - repr_cache[field] = {} - try: - nvalue = repr_cache[field][value] - except KeyError: - try: - nvalue = field.represent(value, row) - except KeyError: - try: - nvalue = field.represent( - value, row[field.tablename]) - except KeyError: - nvalue = None - repr_cache[field][value] = nvalue + nvalue = repr_cache(field, value, row) else: try: nvalue = field.represent(value, row) except KeyError: try: - nvalue = field.represent( - value, row[field.tablename]) + nvalue = field.represent(value, row[field.tablename]) except KeyError: nvalue = None value = nvalue diff --git a/gluon/streamer.py b/gluon/streamer.py index 3dbc386d..d6b49dc8 100644 --- a/gluon/streamer.py +++ b/gluon/streamer.py @@ -25,7 +25,6 @@ regex_stop_range = re.compile('(?<=\-)\d+') DEFAULT_CHUNK_SIZE = 64 * 1024 - def streamer(stream, chunk_size=DEFAULT_CHUNK_SIZE, bytes=None): offset = 0 while bytes is None or offset < bytes: @@ -51,11 +50,12 @@ def stream_file_or_304_or_206( status=200, error_message=None ): - if error_message is None: - error_message = rewrite.THREAD_LOCAL.routes.error_message % 'invalid request' + # FIX THIS + # if error_message is None: + # error_message = rewrite.THREAD_LOCAL.routes.error_message % 'invalid request' try: open = file # this makes no sense but without it GAE cannot open files - fp = open(static_file) + fp = open(static_file,'rb') except IOError, e: if e[0] == errno.EISDIR: raise HTTP(403, error_message, web2py_error='file is a directory') diff --git a/gluon/template.py b/gluon/template.py index 34b0d415..0e297e92 100644 --- a/gluon/template.py +++ b/gluon/template.py @@ -898,6 +898,9 @@ def render(content="hello world", if not 'NOESCAPE' in context: context['NOESCAPE'] = NOESCAPE + if isinstance(content, unicode): + content = content.encode('utf8') + # save current response class if context and 'response' in context: old_response_body = context['response'].body diff --git a/gluon/tests/__init__.py b/gluon/tests/__init__.py index 64a104d3..436621b7 100644 --- a/gluon/tests/__init__.py +++ b/gluon/tests/__init__.py @@ -3,12 +3,14 @@ import sys from test_http import * from test_cache import * from test_contenttype import * +from test_compileapp import * from test_fileutils import * from test_globals import * from test_html import * from test_is_url import * from test_languages import * from test_router import * +from test_recfile import * from test_routes import * from test_storage import * from test_serializers import * diff --git a/gluon/tests/test_compileapp.py b/gluon/tests/test_compileapp.py new file mode 100644 index 00000000..a57b00c6 --- /dev/null +++ b/gluon/tests/test_compileapp.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" Unit tests for utils.py """ + +import unittest +from fix_path import fix_sys_path + +fix_sys_path(__file__) + +from compileapp import compile_application, remove_compiled_application +from gluon.fileutils import w2p_pack, w2p_unpack +import os + + +class TestPack(unittest.TestCase): + """ Tests the compileapp.py module """ + + def test_compile(self): + #apps = ['welcome', 'admin', 'examples'] + apps = ['welcome'] + for appname in apps: + appname_path = os.path.join(os.getcwd(), 'applications', appname) + compile_application(appname_path) + remove_compiled_application(appname_path) + test_path = os.path.join(os.getcwd(), "%s.w2p" % appname) + unpack_path = os.path.join(os.getcwd(), 'unpack', appname) + w2p_pack(test_path, appname_path, compiled=True, filenames=None) + w2p_pack(test_path, appname_path, compiled=False, filenames=None) + w2p_unpack(test_path, unpack_path) + return + + +if __name__ == '__main__': + unittest.main() diff --git a/gluon/tests/test_dal.py b/gluon/tests/test_dal.py index 472750a9..63d8041a 100644 --- a/gluon/tests/test_dal.py +++ b/gluon/tests/test_dal.py @@ -4,15 +4,16 @@ Unit tests for gluon.dal """ +import sys import os import unittest from fix_path import fix_sys_path fix_sys_path(__file__) - from gluon.dal import DAL, Field + def tearDownModule(): try: os.unlink('dummy.db') @@ -50,6 +51,73 @@ class TestDefaultValidators(unittest.TestCase): pass """ + +def _prepare_exec_for_file(filename): + module = [] + if filename.endswith('.py'): + filename = filename[:-3] + elif os.path.split(filename)[1] == '__init__.py': + filename = os.path.dirname(filename) + else: + raise 'The file provided (%s) does is not a valid Python file.' + filename = os.path.realpath(filename) + dirpath = filename + while 1: + dirpath, extra = os.path.split(dirpath) + module.append(extra) + if not os.path.isfile(os.path.join(dirpath, '__init__.py')): + break + sys.path.insert(0, dirpath) + return '.'.join(module[::-1]) + + +def load_pydal_tests_module(): + path = os.path.dirname(os.path.abspath(__file__)) + if not os.path.isfile(os.path.join(path, 'web2py.py')): + i = 0 + while i < 10: + i += 1 + if os.path.exists(os.path.join(path, 'web2py.py')): + break + path = os.path.abspath(os.path.join(path, '..')) + pydal_test_path = os.path.join( + path, "gluon", "packages", "dal", "tests", "__init__.py") + mname = _prepare_exec_for_file(pydal_test_path) + mod = __import__(mname) + return mod + + +def pydal_suite(): + mod = load_pydal_tests_module() + suite = unittest.TestSuite() + tlist = [ + getattr(mod, el) for el in mod.__dict__.keys() if el.startswith("Test") + ] + for t in tlist: + suite.addTest(unittest.makeSuite(t)) + return suite + + +class TestDALAdapters(unittest.TestCase): + def _run_tests(self): + suite = pydal_suite() + return unittest.TextTestRunner(verbosity=2).run(suite) + + def test_mysql(self): + if os.environ.get('APPVEYOR'): + return + os.environ["DB"] = "mysql://root:@localhost/pydal" + result = self._run_tests() + self.assertTrue(result) + + def test_pg8000(self): + if os.environ.get('APPVEYOR'): + return + os.environ["DB"] = "postgres:pg8000://postgres:@localhost/pydal" + result = self._run_tests() + self.assertTrue(result) + + if __name__ == '__main__': unittest.main() tearDownModule() diff --git a/gluon/tests/test_globals.py b/gluon/tests/test_globals.py index 5f9d5526..b6040ac2 100644 --- a/gluon/tests/test_globals.py +++ b/gluon/tests/test_globals.py @@ -6,17 +6,43 @@ """ +import re import unittest from fix_path import fix_sys_path fix_sys_path(__file__) -from gluon.globals import Response +from gluon.globals import Request, Response, Session from gluon import URL +def setup_clean_session(): + request = Request(env={}) + request.application = 'a' + request.controller = 'c' + request.function = 'f' + request.folder = 'applications/admin' + response = Response() + session = Session() + session.connect(request, response) + from gluon.globals import current + current.request = request + current.response = response + current.session = session + return current class testResponse(unittest.TestCase): + #port from python 2.7, needed for 2.5 and 2.6 tests + def assertRegexpMatches(self, text, expected_regexp, msg=None): + """Fail the test unless the text matches the regular expression.""" + if isinstance(expected_regexp, basestring): + expected_regexp = re.compile(expected_regexp) + if not expected_regexp.search(text): + msg = msg or "Regexp didn't match" + msg = '%s: %r not found in %r' % ( + msg, expected_regexp.pattern, text) + raise self.failureException(msg) + def test_include_files(self): def return_includes(response, extensions=None): @@ -120,5 +146,43 @@ class testResponse(unittest.TestCase): content = return_includes(response) self.assertEqual(content, '') + def test_cookies(self): + current = setup_clean_session() + cookie = str(current.response.cookies) + session_key='%s=%s'%(current.response.session_id_name,current.response.session_id) + self.assertRegexpMatches(cookie, r'^Set-Cookie: ') + self.assertTrue(session_key in cookie) + self.assertTrue('Path=/' in cookie) + + def test_cookies_secure(self): + current = setup_clean_session() + current.session._fixup_before_save() + cookie = str(current.response.cookies) + self.assertTrue('secure' not in cookie) + + current = setup_clean_session() + current.session.secure() + current.session._fixup_before_save() + cookie = str(current.response.cookies) + self.assertTrue('secure' in cookie) + + def test_cookies_httponly(self): + current = setup_clean_session() + current.session._fixup_before_save() + cookie = str(current.response.cookies) + self.assertTrue('httponly' in cookie) + + current = setup_clean_session() + current.session.httponly_cookies = True + current.session._fixup_before_save() + cookie = str(current.response.cookies) + self.assertTrue('httponly' in cookie) + + current = setup_clean_session() + current.session.httponly_cookies = False + current.session._fixup_before_save() + cookie = str(current.response.cookies) + self.assertTrue('httponly' not in cookie) + if __name__ == '__main__': unittest.main() diff --git a/gluon/tests/test_html.py b/gluon/tests/test_html.py index 27c46cbe..d426ea9c 100644 --- a/gluon/tests/test_html.py +++ b/gluon/tests/test_html.py @@ -309,7 +309,7 @@ class TestBareHelpers(unittest.TestCase): self.assertEqual(XML('

HelloWorld

', sanitize=True), XML('

HelloWorld

')) #bug check for the sanitizer for closing no-close tags - self.assertEqual(XML('

Test


Test


', sanitize=True), + self.assertEqual(XML('

Test


Test


', sanitize=True), XML('

Test


Test


')) def testTAG(self): diff --git a/gluon/tests/test_recfile.py b/gluon/tests/test_recfile.py new file mode 100644 index 00000000..86f6e917 --- /dev/null +++ b/gluon/tests/test_recfile.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" + Unit tests for gluon.recfile +""" +import unittest +import os +import shutil +import uuid +from fix_path import fix_sys_path + +fix_sys_path(__file__) + +from gluon import recfile + + +class TestRecfile(unittest.TestCase): + + def setUp(self): + os.mkdir('tests') + + def tearDown(self): + shutil.rmtree('tests') + + def testgeneration(self): + for k in range(20): + teststring = 'test%s' % k + filename = os.path.join('tests', str(uuid.uuid4()) + '.test') + with recfile.open(filename, "w") as g: + g.write(teststring) + self.assertEqual(recfile.open(filename, "r").read(), teststring) + is_there = recfile.exists(filename) + self.assertTrue(is_there) + recfile.remove(filename) + is_there = recfile.exists(filename) + self.assertFalse(is_there) + +if __name__ == '__main__': + unittest.main() diff --git a/gluon/tests/test_storage.py b/gluon/tests/test_storage.py index ca047e95..86dcf231 100644 --- a/gluon/tests/test_storage.py +++ b/gluon/tests/test_storage.py @@ -146,7 +146,7 @@ class TestList(unittest.TestCase): 'something') # except if default is especified self.assertEqual(b(0, default=0, otherwise=lambda: 'something'), 0) - + def test_listgetitem(self): '''Mantains list behaviour.''' a = List((1, 2, 3)) diff --git a/gluon/tests/test_utils.py b/gluon/tests/test_utils.py index bccf6ce8..1ad22556 100644 --- a/gluon/tests/test_utils.py +++ b/gluon/tests/test_utils.py @@ -13,7 +13,7 @@ from utils import compare import hashlib from hashlib import md5, sha1, sha224, sha256, sha384, sha512 -from utils import simple_hash, get_digest +from utils import simple_hash, get_digest, secure_dumps, secure_loads class TestUtils(unittest.TestCase): @@ -24,68 +24,77 @@ class TestUtils(unittest.TestCase): data = md5_hash("web2py rocks") self.assertEqual(data, '79509f3246a2824dee64635303e99204') - + def test_compare(self): """ Tests the compare funciton """ - + a, b = 'test123', 'test123' compare_result_true = compare(a, b) self.assertTrue(compare_result_true) - + a, b = 'test123', 'test456' compare_result_false = compare(a, b) self.assertFalse(compare_result_false) - + def test_simple_hash(self): """ Tests the simple_hash function """ - + # no key, no salt, md5 data_md5 = simple_hash('web2py rocks!', key='', salt='', digest_alg='md5') self.assertEqual(data_md5, '37d95defba6c8834cb8cae86ee888568') - + # no key, no salt, sha1 data_sha1 = simple_hash('web2py rocks!', key='', salt='', digest_alg='sha1') self.assertEqual(data_sha1, '00489a46753d8db260c71542611cdef80652c4b7') - + # no key, no salt, sha224 data_sha224 = simple_hash('web2py rocks!', key='', salt='', digest_alg='sha224') self.assertEqual(data_sha224, '84d7054271842c2c17983baa2b1447e0289d101140a8c002d49d60da') - + # no key, no salt, sha256 data_sha256 = simple_hash('web2py rocks!', key='', salt='', digest_alg='sha256') self.assertEqual(data_sha256, '0849f224d8deb267e4598702aaec1bd749e6caec90832469891012a4be24af08') - + # no key, no salt, sha384 data_sha384 = simple_hash('web2py rocks!', key='', salt='', digest_alg='sha384') - self.assertEqual(data_sha384, + self.assertEqual(data_sha384, '3cffaf39371adbe84eb10f588d2718207d8e965e9172a27a278321b86977351376ae79f92e91d8c58cad86c491282d5f') - + # no key, no salt, sha512 data_sha512 = simple_hash('web2py rocks!', key='', salt='', digest_alg='sha512') self.assertEqual(data_sha512, 'fa3237f594743e1d7b6c800bb134b3255cf4a98ab8b01e2ec23256328c9f8059' '64fdef25a038d6cc3fda1b2fb45d66461eeed5c4669e506ec8bdfee71348db7e') + def test_secure_dumps_and_loads(self): + """ Tests secure_dumps and secure_loads""" + testobj = {'a': 1, 'b': 2} + testkey = 'mysecret' + secured = secure_dumps(testobj, testkey) + original = secure_loads(secured, testkey) + self.assertEqual(testobj, original) + self.assertTrue(isinstance(secured, basestring)) + self.assertTrue(':' in secured) + large_testobj = [x for x in range(1000)] + secured_comp = secure_dumps(large_testobj, testkey, compression_level=9) + original_comp = secure_loads(secured_comp, testkey, compression_level=9) + self.assertEqual(large_testobj, original_comp) + secured = secure_dumps(large_testobj, testkey) + self.assertTrue(len(secured_comp) < len(secured)) -class TestPack(unittest.TestCase): - """ Tests the compileapp.py module """ + testhash = 'myhash' + secured = secure_dumps(testobj, testkey, testhash) + original = secure_loads(secured, testkey, testhash) + self.assertEqual(testobj, original) - def test_compile(self): - from compileapp import compile_application, remove_compiled_application - from gluon.fileutils import w2p_pack, w2p_unpack - import os - #apps = ['welcome', 'admin', 'examples'] - apps = ['welcome'] - for appname in apps: - appname_path = os.path.join(os.getcwd(), 'applications', appname) - compile_application(appname_path) - remove_compiled_application(appname_path) - test_path = os.path.join(os.getcwd(), "%s.w2p" % appname) - unpack_path = os.path.join(os.getcwd(), 'unpack', appname) - w2p_pack(test_path, appname_path, compiled=True, filenames=None) - w2p_pack(test_path, appname_path, compiled=False, filenames=None) - w2p_unpack(test_path, unpack_path) - return + wrong1 = secure_loads(secured, testkey, 'wronghash') + self.assertEqual(wrong1, None) + wrong2 = secure_loads(secured, 'wrongkey', testhash) + self.assertEqual(wrong2, None) + wrong3 = secure_loads(secured, 'wrongkey', 'wronghash') + self.assertEqual(wrong3, None) + wrong4 = secure_loads('abc', 'a', 'b') + self.assertEqual(wrong4, None) if __name__ == '__main__': unittest.main() diff --git a/gluon/tests/test_validators.py b/gluon/tests/test_validators.py index 0935cfe2..8a18bd10 100644 --- a/gluon/tests/test_validators.py +++ b/gluon/tests/test_validators.py @@ -618,6 +618,9 @@ class TestValidators(unittest.TestCase): self.assertEqual(rtn, (u'hell', None)) rtn = IS_MATCH('hell', is_unicode=True)(u'hell') self.assertEqual(rtn, (u'hell', None)) + # regr test for #1044 + rtn = IS_MATCH('hello')(u'\xff') + self.assertEqual(rtn, (u'\xff', 'Invalid expression')) def test_IS_EQUAL_TO(self): diff --git a/gluon/tests/tests_markmin.py b/gluon/tests/tests_markmin.py deleted file mode 100644 index 8b137891..00000000 --- a/gluon/tests/tests_markmin.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/gluon/tools.py b/gluon/tools.py index 9d90b9a5..f0a09850 100644 --- a/gluon/tools.py +++ b/gluon/tools.py @@ -32,11 +32,14 @@ import cStringIO import ConfigParser import email.utils import random +import hmac +import hashlib from email import MIMEBase, MIMEMultipart, MIMEText, Encoders, Header, message_from_string, Charset +from gluon.serializers import json_parser from gluon.contenttype import contenttype from gluon.storage import Storage, StorageList, Settings, Messages -from gluon.utils import web2py_uuid +from gluon.utils import web2py_uuid, compare from gluon.fileutils import read_file, check_credentials from gluon import * from gluon.contrib.autolinks import expand_one @@ -49,17 +52,6 @@ import gluon.serializers as serializers Table = DAL.Table Field = DAL.Field -try: - # try stdlib (Python 2.6) - import json as json_parser -except ImportError: - try: - # try external module - import simplejson as json_parser - except: - # fallback to pure-Python module - import gluon.contrib.simplejson as json_parser - __all__ = ['Mail', 'Auth', 'Recaptcha', 'Recaptcha2', 'Crud', 'Service', 'Wiki', 'PluginManager', 'fetch', 'geocode', 'reverse_geocode', 'prettydate'] @@ -261,7 +253,7 @@ class Mail(object): MIMEBase.MIMEBase.__init__(self, *content_type.split('/', 1)) self.set_payload(payload) self['Content-Disposition'] = 'attachment; filename="%s"' % filename - if not content_id is None: + if content_id is not None: self['Content-Id'] = '<%s>' % content_id.encode(encoding) Encoders.encode_base64(self) @@ -272,7 +264,7 @@ class Mail(object): settings.sender = sender settings.login = login settings.tls = tls - settings.timeout = 60 # seconds + settings.timeout = 60 # seconds settings.hostname = None settings.ssl = False settings.cipher_type = None @@ -478,16 +470,16 @@ class Mail(object): text = message html = None - if (not text is None or not html is None) and (not raw): + if (text is not None or html is not None) and (not raw): - if not text is None: + if text is not None: if not isinstance(text, basestring): text = text.read() if isinstance(text, unicode): text = text.encode('utf-8') elif not encoding == 'utf-8': text = text.decode(encoding).encode('utf-8') - if not html is None: + if html is not None: if not isinstance(html, basestring): html = html.read() if isinstance(html, unicode): @@ -523,14 +515,15 @@ class Mail(object): payload_in.attach(attachment) else: payload_in.attach(attachments) + attachments = [attachments] ####################################################### # CIPHER # ####################################################### cipher_type = cipher_type or self.settings.cipher_type - sign = sign if sign != None else self.settings.sign + sign = sign if sign is not None else self.settings.sign sign_passphrase = sign_passphrase or self.settings.sign_passphrase - encrypt = encrypt if encrypt != None else self.settings.encrypt + encrypt = encrypt if encrypt is not None else self.settings.encrypt ####################################################### # GPGME # ####################################################### @@ -571,12 +564,12 @@ class Mail(object): c.op_sign(plain, sig, mode.DETACH) sig.seek(0, 0) # make it part of the email - payload = MIMEMultipart.MIMEMultipart('signed', - boundary=None, - _subparts=None, - **dict( - micalg="pgp-sha1", - protocol="application/pgp-signature")) + payload = \ + MIMEMultipart.MIMEMultipart('signed', + boundary=None, + _subparts=None, + **dict(micalg="pgp-sha1", + protocol="application/pgp-signature")) # insert the origin payload payload.attach(payload_in) # insert the detached signature @@ -637,20 +630,15 @@ class Mail(object): self.error = "No sign and no encrypt is set but cipher type to x509" return False import os - x509_sign_keyfile = x509_sign_keyfile or\ - self.settings.x509_sign_keyfile + x509_sign_keyfile = x509_sign_keyfile or self.settings.x509_sign_keyfile - x509_sign_chainfile = x509_sign_chainfile or\ - self.settings.x509_sign_chainfile + x509_sign_chainfile = x509_sign_chainfile or self.settings.x509_sign_chainfile - x509_sign_certfile = x509_sign_certfile or\ - self.settings.x509_sign_certfile or\ - x509_sign_keyfile or\ - self.settings.x509_sign_certfile + x509_sign_certfile = x509_sign_certfile or self.settings.x509_sign_certfile or \ + x509_sign_keyfile or self.settings.x509_sign_certfile # crypt certfiles could be a string or a list - x509_crypt_certfiles = x509_crypt_certfiles or\ - self.settings.x509_crypt_certfiles + x509_crypt_certfiles = x509_crypt_certfiles or self.settings.x509_crypt_certfiles x509_nocerts = x509_nocerts or\ self.settings.x509_nocerts @@ -784,8 +772,8 @@ class Mail(object): attachments = attachments and [mail.Attachment( a.my_filename, a.my_payload, - contebt_id='' % k - ) for k,a in enumerate(attachments) if not raw] + content_id='' % k + ) for k, a in enumerate(attachments) if not raw] if attachments: result = mail.send_mail( sender=sender, to=origTo, @@ -938,7 +926,7 @@ class Recaptcha(DIV): _type='hidden', _name='recaptcha_response_field', _value='manual_challenge')), _id='recaptcha') - else: #use Google's ajax interface, needed for LOADed components + else: # use Google's ajax interface, needed for LOADed components url_recaptcha_js = "%s/js/recaptcha_ajax.js" % server RecaptchaOptions = "var RecaptchaOptions = {%s}" % self.options @@ -1140,6 +1128,310 @@ def addrow(form, a, b, c, style, _id, position=-1): TD(c, _class='w2p_fc'), _id=_id)) +class AuthJWT(object): + + """ + Experimental! + + Args: + - secret_key: the secret. Without salting, an attacker knowing this can impersonate + any user + - algorithm : uses as they are in the JWT specs, HS256, HS384 or HS512 basically means + signing with HMAC with a 256, 284 or 512bit hash + - verify_expiration : verifies the expiration checking the exp claim + - leeway: allow n seconds of skew when checking for token expiration + - expiration : how many seconds a token may be valid + - allow_refresh: enable the machinery to get a refreshed token passing a not-already-expired + token + - refresh_expiration_delta: to avoid continous refresh of the token + - header_prefix : self-explanatory. "JWT" and "Bearer" seems to be the emerging standards + - jwt_add_header: a dict holding additional mappings to the header. by default only alg and typ are filled + - user_param: the name of the parameter holding the username when requesting a token. Can be useful, e.g, for + email-based authentication, with "email" as a parameter + - pass_param: same as above, but for the password + - realm: self-explanatory + - salt: can be static or a function that takes the payload as an argument. + Example: + def mysalt(payload): + return payload['hmac_key'].split('-')[0] + - additional_payload: can be a dict to merge with the payload or a function that takes + the payload as input and returns the modified payload + Example: + def myadditional_payload(payload): + payload['my_name_is'] = 'bond,james bond' + return payload + - before_authorization: can be a callable that takes the deserialized token (a dict) as input. + Gets called right after signature verification but before the actual + authorization takes place. It may be use to cast + the extra auth_user fields to their actual types. + You can raise with HTTP a proper error message + Example: + def mybefore_authorization(tokend): + if not tokend['my_name_is'] == 'bond,james bond': + raise HTTP(400, u'Invalid JWT my_name_is claim') + - max_header_length: check max length to avoid load()ing unusually large tokens (could mean crafted, e.g. in a DDoS.) + + Basic Usage: + in models (or the controller needing it) + + myjwt = AuthJWT(auth, secret_key='secret') + + in the controller issuing tokens + + def login_and_take_token(): + return myjwt.jwt_token_manager() + + A call then to /app/controller/login_and_take_token with username and password returns the token + A call to /app/controller/login_and_take_token with the original token returns the refreshed token + + To protect a function with JWT + + @myjwt.allows_jwt() + @auth.requires_login() + def protected(): + return '%s$%s' % (request.now, auth.user_id) + + """ + + def __init__(self, + auth, + secret_key, + algorithm='HS256', + verify_expiration=True, + leeway=30, + expiration=60 * 5, + allow_refresh=True, + refresh_expiration_delta=60 * 60, + header_prefix='Bearer', + jwt_add_header=None, + user_param='username', + pass_param='password', + realm='Login required', + salt=None, + additional_payload=None, + before_authorization=None, + max_header_length=4*1024, + ): + self.secret_key = secret_key + self.auth = auth + self.algorithm = algorithm + if self.algorithm not in ('HS256', 'HS384', 'HS512'): + raise NotImplementedError('Algoritm %s not allowed' % algorithm) + self.verify_expiration = verify_expiration + self.leeway = leeway + self.expiration = expiration + self.allow_refresh = allow_refresh + self.refresh_expiration_delta = refresh_expiration_delta + self.header_prefix = header_prefix + self.jwt_add_header = jwt_add_header or {} + base_header = {'alg': self.algorithm, 'typ': 'JWT'} + for k, v in self.jwt_add_header.iteritems(): + base_header[k] = v + self.cached_b64h = self.jwt_b64e(json_parser.dumps(base_header)) + digestmod_mapping = { + 'HS256': hashlib.sha256, + 'HS384': hashlib.sha384, + 'HS512': hashlib.sha512 + } + self.digestmod = digestmod_mapping[algorithm] + self.user_param = user_param + self.pass_param = pass_param + self.realm = realm + self.salt = salt + self.additional_payload = additional_payload + self.before_authorization = before_authorization + self.max_header_length = max_header_length + + @staticmethod + def jwt_b64e(string): + if isinstance(string, unicode): + string = string.encode('utf-8', 'strict') + return base64.urlsafe_b64encode(string).strip(b'=') + + @staticmethod + def jwt_b64d(string): + """base64 decodes a single bytestring (and is tolerant to getting + called with a unicode string). + The result is also a bytestring. + """ + if isinstance(string, unicode): + string = string.encode('ascii', 'ignore') + return base64.urlsafe_b64decode(string + '=' * (-len(string) % 4)) + + def generate_token(self, payload): + secret = self.secret_key + if self.salt: + if callable(self.salt): + secret = "%s$%s" % (secret, self.salt(payload)) + else: + secret = "%s$%s" % (secret, self.salt) + if isinstance(secret, unicode): + secret = secret.encode('ascii', 'ignore') + b64h = self.cached_b64h + b64p = self.jwt_b64e(serializers.json(payload)) + jbody = b64h + '.' + b64p + mauth = hmac.new(key=secret, msg=jbody, digestmod=self.digestmod) + jsign = self.jwt_b64e(mauth.digest()) + return jbody + '.' + jsign + + def verify_signature(self, body, signature, secret): + mauth = hmac.new(key=secret, msg=body, digestmod=self.digestmod) + return compare(self.jwt_b64e(mauth.digest()), signature) + + def load_token(self, token): + if isinstance(token, unicode): + token = token.encode('utf-8', 'strict') + body, sig = token.rsplit('.', 1) + b64h, b64b = body.split('.', 1) + if b64h != self.cached_b64h: + # header not the same + raise HTTP(400, u'Invalid JWT Header') + secret = self.secret_key + tokend = serializers.loads_json(self.jwt_b64d(b64b)) + if self.salt: + if callable(self.salt): + secret = "%s$%s" % (secret, self.salt(tokend)) + else: + secret = "%s$%s" % (secret, self.salt) + if isinstance(secret, unicode): + secret = secret.encode('ascii', 'ignore') + if not self.verify_signature(body, sig, secret): + # signature verification failed + raise HTTP(400, u'Token signature is invalid') + if self.verify_expiration: + now = time.mktime(datetime.datetime.utcnow().timetuple()) + if tokend['exp'] + self.leeway < now: + raise HTTP(400, u'Token is expired') + if callable(self.before_authorization): + self.before_authorization(tokend) + return tokend + + def serialize_auth_session(self, session_auth): + """ + As bad as it sounds, as long as this is rarely used (vs using the token) + this is the faster method, even if we ditch session in jwt_token_manager(). + We (mis)use the heavy default auth mechanism to avoid any further computation, + while sticking to a somewhat-stable Auth API. + """ + ## is the following safe or should we use + ## calendar.timegm(datetime.datetime.utcnow().timetuple()) + ## result seem to be the same (seconds since epoch, in UTC) + now = time.mktime(datetime.datetime.now().timetuple()) + expires = now + self.expiration + payload = dict( + hmac_key=session_auth['hmac_key'], + user_groups=session_auth['user_groups'], + user=session_auth['user'].as_dict(), + iat=now, + exp=expires + ) + return payload + + def refresh_token(self, orig_payload): + now = time.mktime(datetime.datetime.now().timetuple()) + if self.verify_expiration: + orig_exp = orig_payload['exp'] + if orig_exp + self.leeway < now: + # token already expired, can't be used for refresh + raise HTTP(400, u'Token already expired') + orig_iat = orig_payload.get('orig_iat') or orig_payload['iat'] + if orig_iat + self.refresh_expiration_delta < now: + # refreshed too long ago + raise HTTP(400, u'Token issued too long ago') + expires = now + self.expiration + orig_payload.update( + orig_iat=orig_iat, + iat=now, + exp=expires, + hmac_key=web2py_uuid() + ) + self.alter_payload(orig_payload) + return orig_payload + + def alter_payload(self, payload): + if self.additional_payload: + if callable(self.additional_payload): + payload = self.additional_payload(payload) + elif isinstance(self.additional_payload, dict): + payload.update(self.additional_payload) + return payload + + def jwt_token_manager(self): + """ + The part that issues (and refreshes) tokens. + Used in a controller, given myjwt is the istantiated class, as + + def api_auth(): + return myjwt.jwt_token_manager() + + Then, a call to /app/c/api_auth with username and password + returns a token, while /app/c/api_auth with the current token + issues another token + """ + request = current.request + response = current.response + session = current.session + # forget and unlock response + session.forget(response) + valid_user = None + ret = None + if request.vars.token: + if not self.allow_refresh: + raise HTTP(403, u'Refreshing token is not allowed') + token = request.vars.token + tokend = self.load_token(token) + # verification can fail here + refreshed = self.refresh_token(tokend) + ret = {'token': self.generate_token(refreshed)} + elif self.user_param in request.vars and self.pass_param in request.vars: + username = request.vars[self.user_param] + password = request.vars[self.pass_param] + valid_user = self.auth.login_bare(username, password) + else: + valid_user = self.auth.user + if valid_user: + payload = self.serialize_auth_session(current.session.auth) + self.alter_payload(payload) + ret = {'token': self.generate_token(payload)} + elif ret is None: + raise HTTP( + 401, u'Not Authorized - need to be logged in, to pass a token for refresh or username and password for login', + **{'WWW-Authenticate': u'JWT realm="%s"' % self.realm}) + response.headers['Content-Type'] = 'application/json' + return serializers.json(ret) + + def inject_token(self, tokend): + """ + The real deal, not touching the db but still logging-in the user + """ + self.auth.user = Storage(tokend['user']) + self.auth.user_groups = tokend['user_groups'] + self.auth.hmac_key = tokend['hmac_key'] + + def allows_jwt(self, otherwise=None): + """ + The validator that checks for the header or the + _token var + """ + request = current.request + token_in_header = request.env.http_authorization + if token_in_header: + parts = token_in_header.split() + if parts[0].lower() != self.header_prefix.lower(): + raise HTTP(400, u'Invalid JWT header') + elif len(parts) == 1: + raise HTTP(400, u'Invalid JWT header, missing token') + elif len(parts) > 2: + raise HTTP(400, 'Invalid JWT header, token contains spaces') + token = parts[1] + else: + token = request.vars._token + if token and len(token) < self.max_header_length: + tokend = self.load_token(token) + self.inject_token(tokend) + return self.auth.requires(True, otherwise=otherwise) + + class Auth(object): default_settings = dict( @@ -1158,6 +1450,8 @@ class Auth(object): manager_actions={}, auth_manager_role=None, two_factor_authentication_group = None, + auth_two_factor_enabled = False, + auth_two_factor_tries_left = 3, login_captcha=None, register_captcha=None, pre_registration_div=None, @@ -1239,6 +1533,7 @@ class Auth(object): invalid_login='Invalid login', invalid_user='Invalid user', invalid_password='Invalid password', + invalid_two_factor_code = 'Incorrect code. {0} more attempt(s) remaining.', is_empty="Cannot be empty", mismatched_password="Password fields don't match", verify_email='Welcome %(username)s! Click on the link %(link)s to verify your email', @@ -1252,7 +1547,9 @@ class Auth(object): retrieve_password_subject='Password retrieve', reset_password='Click on the link %(link)s to reset your password', reset_password_subject='Password reset', - bulk_invite_subject='Invitation to join%(site)s', + bulk_invite_subject='Invitation to join %(site)s', + retrieve_two_factor_code='Your temporary login code is {0}', + retrieve_two_factor_code_subject='Two-step Login Authentication Code', bulk_invite_body='You have been invited to join %(site)s, click %(link)s to complete the process', invalid_reset_password='Invalid reset password', profile_updated='Profile updated', @@ -1297,6 +1594,8 @@ class Auth(object): label_client_ip='Client IP', label_origin='Origin', label_remember_me="Remember me (for 30 days)", + label_two_factor='Authentication code', + two_factor_comment = 'This code was emailed to you and is required for login.', verify_password_comment='please input your password again', ) @@ -1421,7 +1720,7 @@ class Auth(object): hmac_key=None, controller='default', function='user', cas_provider=None, signature=True, secure=False, csrf_prevention=True, propagate_extension=None, - url_index=None): + url_index=None, jwt=None): ## next two lines for backward compatibility if not db and environment and isinstance(environment, DAL): @@ -1482,38 +1781,40 @@ class Auth(object): logged_url=URL(controller, function, args='profile'), download_url=URL(controller, 'download'), mailer=(mailer is True) and Mail() or mailer, - on_failed_authorization = URL(controller, function, args='not_authorized'), - login_next = url_index, - login_onvalidation = [], - login_onaccept = [], - login_onfail = [], - login_methods = [self], - login_form = self, - logout_next = url_index, - logout_onlogout = None, - register_next = url_index, - register_onvalidation = [], - register_onaccept = [], - verify_email_next = url_login, - verify_email_onaccept = [], - profile_next = url_index, - profile_onvalidation = [], - profile_onaccept = [], - retrieve_username_next = url_index, - retrieve_password_next = url_index, - request_reset_password_next = url_login, - reset_password_next = url_index, - change_password_next = url_index, - change_password_onvalidation = [], - change_password_onaccept = [], - retrieve_password_onvalidation = [], - request_reset_password_onvalidation = [], - request_reset_password_onaccept = [], - reset_password_onvalidation = [], - reset_password_onaccept = [], - hmac_key = hmac_key, - formstyle = current.response.formstyle, - label_separator = current.response.form_label_separator + on_failed_authorization=URL(controller, function, args='not_authorized'), + login_next=url_index, + login_onvalidation=[], + login_onaccept=[], + login_onfail=[], + login_methods=[self], + login_form=self, + logout_next=url_index, + logout_onlogout=None, + register_next=url_index, + register_onvalidation=[], + register_onaccept=[], + verify_email_next=url_login, + verify_email_onaccept=[], + profile_next=url_index, + profile_onvalidation=[], + profile_onaccept=[], + retrieve_username_next=url_index, + retrieve_password_next=url_index, + request_reset_password_next=url_login, + reset_password_next=url_index, + change_password_next=url_index, + change_password_onvalidation=[], + change_password_onaccept=[], + retrieve_password_onvalidation=[], + request_reset_password_onvalidation=[], + request_reset_password_onaccept=[], + reset_password_onvalidation=[], + reset_password_onaccept=[], + hmac_key=hmac_key, + formstyle=current.response.formstyle, + label_separator=current.response.form_label_separator, + two_factor_methods = [], + two_factor_onvalidation = [], ) settings.lock_keys = True # ## these are messages that can be customized @@ -1539,6 +1840,7 @@ class Auth(object): self.define_signature() else: self.signature = None + self.jwt_handler = jwt and AuthJWT(self, **jwt) def get_vars_next(self): next = current.request.vars._next @@ -1549,7 +1851,7 @@ class Auth(object): # _next variable in the request. items = next.split('/') if '//' in next and items[2] != current.request.env.http_host: - next = None + next = None return next def _get_user_id(self): @@ -1606,8 +1908,8 @@ class Auth(object): 'retrieve_username', 'retrieve_password', 'reset_password', 'request_reset_password', 'change_password', 'profile', 'groups', - 'impersonate', 'not_authorized', 'confirm_registration', - 'bulk_register','manage_tokens'): + 'impersonate', 'not_authorized', 'confirm_registration', + 'bulk_register','manage_tokens','jwt'): if len(request.args) >= 2 and args[0] == 'impersonate': return getattr(self, args[0])(request.args[1]) else: @@ -1669,10 +1971,10 @@ class Auth(object): urllib.quote( logout_next)), 'icon': 'icon-off'}) - if not 'profile' in self.settings.actions_disabled: + if 'profile' not in self.settings.actions_disabled: items.append({'name': T('Profile'), 'href': href('profile'), 'icon': 'icon-user'}) - if not 'change_password' in self.settings.actions_disabled: + if 'change_password' not in self.settings.actions_disabled: items.append({'name': T('Password'), 'href': href('change_password'), 'icon': 'icon-lock'}) @@ -1690,10 +1992,10 @@ class Auth(object): else: # User is not logged in items.append({'name': T('Log In'), 'href': href('login'), 'icon': 'icon-off'}) - if not 'register' in self.settings.actions_disabled: + if 'register' not in self.settings.actions_disabled: items.append({'name': T('Sign Up'), 'href': href('register'), 'icon': 'icon-user'}) - if not 'request_reset_password' in self.settings.actions_disabled: + if 'request_reset_password' not in self.settings.actions_disabled: items.append({'name': T('Lost password?'), 'href': href('request_reset_password'), 'icon': 'icon-lock'}) @@ -1787,10 +2089,7 @@ class Auth(object): custom.navbar(auth.navbar(mode='bare')) or ''}} """ - bare = {} - - bare['prefix'] = prefix - bare['user'] = user_identifier if self.user_id else None + bare = {'prefix': prefix, 'user': user_identifier if self.user_id else None} for i in items: if i['name'] == T('Log In'): @@ -1964,7 +2263,7 @@ class Auth(object): settings.enable_tokens = enable_tokens if not self.signature: self.define_signature() - if signature == True: + if signature: signature_list = [self.signature] elif not signature: signature_list = [] @@ -1981,7 +2280,7 @@ class Auth(object): error_message=self.messages.email_taken)] if not settings.email_case_sensitive: is_unique_email.insert(1, IS_LOWER()) - if not settings.table_user_name in db.tables: + if settings.table_user_name not in db.tables: passfield = settings.password_field extra_fields = settings.extra_fields.get( settings.table_user_name, []) + signature_list @@ -2056,7 +2355,7 @@ class Auth(object): fake_migrate=fake_migrate, format='%(first_name)s %(last_name)s (%(id)s)')) reference_table_user = 'reference %s' % settings.table_user_name - if not settings.table_group_name in db.tables: + if settings.table_group_name not in db.tables: extra_fields = settings.extra_fields.get( settings.table_group_name, []) + signature_list db.define_table( @@ -2073,7 +2372,7 @@ class Auth(object): fake_migrate=fake_migrate, format='%(role)s (%(id)s)')) reference_table_group = 'reference %s' % settings.table_group_name - if not settings.table_membership_name in db.tables: + if settings.table_membership_name not in db.tables: extra_fields = settings.extra_fields.get( settings.table_membership_name, []) + signature_list db.define_table( @@ -2087,7 +2386,7 @@ class Auth(object): migrate=self.__get_migrate( settings.table_membership_name, migrate), fake_migrate=fake_migrate)) - if not settings.table_permission_name in db.tables: + if settings.table_permission_name not in db.tables: extra_fields = settings.extra_fields.get( settings.table_permission_name, []) + signature_list db.define_table( @@ -2107,7 +2406,7 @@ class Auth(object): migrate=self.__get_migrate( settings.table_permission_name, migrate), fake_migrate=fake_migrate)) - if not settings.table_event_name in db.tables: + if settings.table_event_name not in db.tables: db.define_table( settings.table_event_name, Field('time_stamp', 'datetime', @@ -2131,7 +2430,7 @@ class Auth(object): fake_migrate=fake_migrate)) now = current.request.now if settings.cas_domains: - if not settings.table_cas_name in db.tables: + if settings.table_cas_name not in db.tables: db.define_table( settings.table_cas_name, Field('user_id', reference_table_user, default=None, @@ -2148,13 +2447,13 @@ class Auth(object): if settings.enable_tokens: extra_fields = settings.extra_fields.get( settings.table_token_name, []) + signature_list - if not settings.table_token_name in db.tables: + if settings.table_token_name not in db.tables: db.define_table( settings.table_token_name, Field('user_id', reference_table_user, default=None, label=self.messages.label_user_id), Field('expires_on', 'datetime', default=datetime.datetime(2999,12,31)), - Field('token',writable=False,default=web2py_uuid(),unique=True), + Field('token',writable=False,default=web2py_uuid,unique=True), *extra_fields, **dict( migrate=self.__get_migrate( @@ -2235,7 +2534,7 @@ class Auth(object): break if not checks: return None - if not 'registration_id' in keys: + if 'registration_id' not in keys: keys['registration_id'] = keys[checks[0]] # if we think we found the user but registration_id does not match, # make new user @@ -2257,7 +2556,8 @@ class Auth(object): if not 'first_name' in keys and 'first_name' in table_user.fields: guess = keys.get('email', 'anonymous').split('@')[0] keys['first_name'] = keys.get('username', guess) - user_id = table_user.insert(**table_user._filter_fields(keys)) + form = table_user._filter_fields(keys) + user_id = table_user.insert(**form) user = table_user[user_id] if self.settings.create_user_groups: group_id = self.add_group( @@ -2267,6 +2567,8 @@ class Auth(object): self.add_membership(self.settings.everybody_group_id, user_id) if login: self.user = user + if self.settings.register_onaccept: + callback(self.settings.register_onaccept, form) return user def basic(self, basic_auth_realm=False): @@ -2336,17 +2638,16 @@ class Auth(object): userfield = self.settings.login_userfield or 'username' \ if 'username' in table_user.fields else 'email' passfield = self.settings.password_field - return Storage({"table_user": table_user, - "userfield": userfield, - "passfield": passfield}) + return Storage({'table_user': table_user, + 'userfield': userfield, + 'passfield': passfield}) def login_bare(self, username, password): """ Logins user as specified by username (or email) and password """ settings = self._get_login_settings() - user = settings.table_user(**{settings.userfield: \ - username}) + user = settings.table_user(**{settings.userfield: username}) if user and user.get(settings.passfield, False): password = settings.table_user[ settings.passfield].validate(password)[0] @@ -2369,15 +2670,15 @@ class Auth(object): and a raw password. """ settings = self._get_login_settings() - # users can register_bare even if no password is provided, + # users can register_bare even if no password is provided, # in this case they will have to reset their password to login if fields.get(settings.passfield): fields[settings.passfield] = \ settings.table_user[settings.passfield].validate(fields[settings.passfield])[0] if not fields.get(settings.userfield): - raise ValueError("register_bare: " + - "userfield not provided or invalid") - user = self.get_or_create_user(fields, login=False, get=False, + raise ValueError('register_bare: ' + + 'userfield not provided or invalid') + user = self.get_or_create_user(fields, login=False, get=False, update_fields=self.settings.update_fields) if not user: # get or create did not create a user (it ignores duplicate records) @@ -2396,7 +2697,7 @@ class Auth(object): session = current.session db, table = self.db, self.table_cas() session._cas_service = request.vars.service or session._cas_service - if not request.env.http_host in self.settings.cas_domains or \ + if request.env.http_host not in self.settings.cas_domains or \ not session._cas_service: raise HTTP(403, 'not authorized') @@ -2424,7 +2725,7 @@ class Auth(object): if self.is_logged_in() and not 'renew' in request.vars: return allow_access() elif not self.is_logged_in() and 'gateway' in request.vars: - redirect(service) + redirect(session._cas_service) def cas_onaccept(form, onaccept=onaccept): if not onaccept is DEFAULT: @@ -2478,15 +2779,27 @@ class Auth(object): raise HTTP(200, message) def _reset_two_factor_auth(self, session): - """When two-step authentication is enabled, this function is used to + """ + When two-step authentication is enabled, this function is used to clear the session after successfully completing second challenge or when the maximum number of tries allowed has expired. """ session.auth_two_factor_user = None session.auth_two_factor = None session.auth_two_factor_enabled = False - # Allow up to 4 attempts (the 1st one plus 3 more) - session.auth_two_factor_tries_left = 3 + # Set the number of attempts. It should be more than 1. + session.auth_two_factor_tries_left = self.settings.auth_two_factor_tries_left + + def when_is_logged_in_bypass_next_in_url(self, next, session): + """ + This function should be use when someone want to avoid asking for user + credentials when loaded page contains "user/login?_next=NEXT_COMPONENT" + in the URL is refresh but user is already authenticated. + """ + if self.is_logged_in(): + if next == session._auth_next: + del session._auth_next + redirect(next, client_side=self.settings.client_side) def login(self, next=DEFAULT, @@ -2497,29 +2810,11 @@ class Auth(object): """ Returns a login form """ - - table_user = self.table_user() settings = self.settings - if 'username' in table_user.fields or \ - not settings.login_email_validate: - tmpvalidator = IS_NOT_EMPTY(error_message=self.messages.is_empty) - if not settings.username_case_sensitive: - tmpvalidator = [IS_LOWER(), tmpvalidator] - else: - tmpvalidator = IS_EMAIL(error_message=self.messages.invalid_email) - if not settings.email_case_sensitive: - tmpvalidator = [IS_LOWER(), tmpvalidator] - request = current.request response = current.response session = current.session - passfield = settings.password_field - try: - table_user[passfield].requires[-1].min_length = 0 - except: - pass - ### use session for federated login snext = self.get_vars_next() @@ -2543,6 +2838,27 @@ class Auth(object): next = user_next else: next = user_next + # Avoid asking unnecessary user credentials when user is logged in + self.when_is_logged_in_bypass_next_in_url(next=next, session=session) + + # Moved here to avoid unnecessary execution in case of redirection to next in case of logged in user + table_user = self.table_user() + if 'username' in table_user.fields or \ + not settings.login_email_validate: + tmpvalidator = IS_NOT_EMPTY(error_message=self.messages.is_empty) + if not settings.username_case_sensitive: + tmpvalidator = [IS_LOWER(), tmpvalidator] + else: + tmpvalidator = IS_EMAIL(error_message=self.messages.invalid_email) + if not settings.email_case_sensitive: + tmpvalidator = [IS_LOWER(), tmpvalidator] + + passfield = settings.password_field + try: + table_user[passfield].requires[-1].min_length = 0 + except: + pass + if onvalidation is DEFAULT: onvalidation = settings.login_onvalidation if onaccept is DEFAULT: @@ -2554,8 +2870,7 @@ class Auth(object): user = None # default - - #Setup the default field used for the form + # Setup the default field used for the form multi_login = False if self.settings.login_userfield: username = self.settings.login_userfield @@ -2595,24 +2910,22 @@ class Auth(object): if settings.remember_me_form: extra_fields = [ Field('remember_me', 'boolean', default=False, - label = self.messages.label_remember_me)] + label=self.messages.label_remember_me)] else: extra_fields = [] # do we use our own login form, or from a central source? if settings.login_form == self: - form = SQLFORM( - table_user, - fields=[username, passfield], - hidden=dict(_next=next), - showid=settings.showid, - submit_button=self.messages.login_button, - delete_label=self.messages.delete_label, - formstyle=settings.formstyle, - separator=settings.label_separator, - extra_fields = extra_fields, - ) - + form = SQLFORM(table_user, + fields=[username, passfield], + hidden=dict(_next=next), + showid=settings.showid, + submit_button=self.messages.login_button, + delete_label=self.messages.delete_label, + formstyle=settings.formstyle, + separator=settings.label_separator, + extra_fields=extra_fields, + ) captcha = settings.login_captcha or \ (settings.login_captcha != False and settings.captcha) @@ -2631,19 +2944,19 @@ class Auth(object): entered_username = form.vars[username] if multi_login and '@' in entered_username: # if '@' in username check for email, not username - user = table_user(email = entered_username) + user = table_user(email=entered_username) else: user = table_user(**{username: entered_username}) if user: # user in db, check if registration pending or disabled temp_user = user - if temp_user.registration_key == 'pending': + if (temp_user.registration_key or '').startswith('pending'): response.flash = self.messages.registration_pending return form elif temp_user.registration_key in ('disabled', 'blocked'): response.flash = self.messages.login_disabled return form - elif (not temp_user.registration_key is None + elif (temp_user.registration_key is not None and temp_user.registration_key.strip()): response.flash = \ self.messages.registration_verifying @@ -2655,7 +2968,7 @@ class Auth(object): if login_method != self and \ login_method(request.vars[username], request.vars[passfield]): - if not self in settings.login_methods: + if self not in settings.login_methods: # do not store password in db form.vars[passfield] = None user = self.get_or_create_user( @@ -2676,7 +2989,7 @@ class Auth(object): if login_method != self and \ login_method(request.vars[username], request.vars[passfield]): - if not self in settings.login_methods: + if self not in settings.login_methods: # do not store password in db form.vars[passfield] = None user = self.get_or_create_user( @@ -2692,7 +3005,7 @@ class Auth(object): self.url(args=request.args, vars=request.get_vars), client_side=settings.client_side) - else: # use a central authentication server + else: # use a central authentication server cas = settings.login_form cas_user = cas.get_user() @@ -2715,17 +3028,21 @@ class Auth(object): # authentication step was successful (i.e. user provided correct # username and password at the first challenge). # Check if this user is signed up for two-factor authentication - # Default rule is that the user must be part of a group that is called - # auth.settings.two_factor_authentication_group - if user and self.settings.two_factor_authentication_group: + # If auth.settings.auth_two_factor_enabled it will enable two factor + # for all the app. Another way to anble two factor is that the user + # must be part of a group that is called auth.settings.two_factor_authentication_group + if user and self.settings.auth_two_factor_enabled == True: + session.auth_two_factor_enabled = True + elif user and self.settings.two_factor_authentication_group: role = self.settings.two_factor_authentication_group session.auth_two_factor_enabled = self.has_membership(user_id=user.id, role=role) # challenge if session.auth_two_factor_enabled: form = SQLFORM.factory( Field('authentication_code', + label=self.messages.label_two_factor, required=True, - comment='This code was emailed to you and is required for login.'), + comment=self.messages.two_factor_comment), hidden=dict(_next=next), formstyle=settings.formstyle, separator=settings.label_separator @@ -2738,19 +3055,83 @@ class Auth(object): # form successfully, and the password has been validated, but # the two-factor form has not been displayed or validated yet. if session.auth_two_factor_user is None and user is not None: - session.auth_two_factor_user = user # store the validated user and associate with this session + session.auth_two_factor_user = user # store the validated user and associate with this session session.auth_two_factor = random.randint(100000, 999999) - session.auth_two_factor_tries_left = 3 # Allow user to try up to 4 times - # TODO: Add some error checking to handle cases where email cannot be sent - self.settings.mailer.send( - to=user.email, - subject="Two-step Login Authentication Code", - message="Your temporary login code is {0}".format(session.auth_two_factor)) + session.auth_two_factor_tries_left = self.settings.auth_two_factor_tries_left + # Set the way we generate the code or we send the code. For example using SMS... + two_factor_methods = self.settings.two_factor_methods + + if two_factor_methods == []: + # TODO: Add some error checking to handle cases where email cannot be sent + self.settings.mailer.send( + to=user.email, + subject=self.messages.retrieve_two_factor_code_subject, + message=self.messages.retrieve_two_factor_code.format(session.auth_two_factor)) + else: + #Check for all method. It is possible to have multiples + for two_factor_method in two_factor_methods: + try: + # By default we use session.auth_two_factor generated before. + session.auth_two_factor = two_factor_method(user, session.auth_two_factor) + except: + pass + else: + break + if form.accepts(request, session if self.csrf_prevention else None, formname='login', dbio=False, onvalidation=onvalidation, hideerror=settings.hideerror): accepted_form = True + + accepted_form = True + + ''' + The lists is executed after form validation for each of the corresponding action. + For example, in your model: + + In your models copy and paste: + + #Before define tables, we add some extra field to auth_user + auth.settings.extra_fields['auth_user'] = [ + Field('motp_secret', 'password', length=512, default='', label='MOTP Secret'), + Field('motp_pin', 'string', length=128, default='', label='MOTP PIN')] + + OFFSET = 60 #Be sure is the same in your OTP Client + + #Set session.auth_two_factor to None. Because the code is generated by external app. + # This will avoid to use the default setting and send a code by email. + def _set_two_factor(user, auth_two_factor): + return None + + def verify_otp(user, otp): + import time + from hashlib import md5 + epoch_time = int(time.time()) + time_start = int(str(epoch_time - OFFSET)[:-1]) + time_end = int(str(epoch_time + OFFSET)[:-1]) + for t in range(time_start - 1, time_end + 1): + to_hash = str(t) + user.motp_secret + user.motp_pin + hash = md5(to_hash).hexdigest()[:6] + if otp == hash: + return hash + + auth.settings.auth_two_factor_enabled = True + auth.messages.two_factor_comment = "Verify your OTP Client for the code." + auth.settings.two_factor_methods = [lambda user, auth_two_factor: _set_two_factor(user, auth_two_factor)] + auth.settings.two_factor_onvalidation = [lambda user, otp: verify_otp(user, otp)] + + ''' + if self.settings.two_factor_onvalidation != []: + + for two_factor_onvalidation in self.settings.two_factor_onvalidation: + try: + session.auth_two_factor = two_factor_onvalidation(session.auth_two_factor_user, form.vars['authentication_code']) + except: + pass + else: + break + if form.vars['authentication_code'] == str(session.auth_two_factor): # Handle the case when the two-factor form has been successfully validated # and the user was previously stored (the current user should be None because @@ -2771,9 +3152,20 @@ class Auth(object): # session usernamem will still exist self._reset_two_factor_auth(session) else: - # TODO: Limit the number of retries allowed. - response.flash = 'Incorrect code. {0} more attempt(s) remaining.'.format(session.auth_two_factor_tries_left) session.auth_two_factor_tries_left -= 1 + # If the number of retries are higher than auth_two_factor_tries_left + # Require user to enter username and password again. + if session.auth_two_factor_enabled and session.auth_two_factor_tries_left < 1: + # Exceeded maximum allowed tries for this code. Require user to enter + # username and password again. + user = None + accepted_form = False + self._reset_two_factor_auth(session) + # Redirect to the default 'next' page without logging + # in. If that page requires login, user will be redirected + # back to the main login form + redirect(next, client_side=settings.client_side) + response.flash = self.messages.invalid_two_factor_code.format(session.auth_two_factor_tries_left) return form else: return form @@ -2840,7 +3232,7 @@ class Auth(object): if self.settings.renew_session_onlogout: current.session.renew(clear_session=not self.settings.keep_session_onlogout) current.session.flash = self.messages.logged_out - if not next is None: + if next is not None: redirect(next) def register(self, @@ -2894,12 +3286,21 @@ class Auth(object): passfield = self.settings.password_field formstyle = self.settings.formstyle + try: # Make sure we have our original minimum length as other auth forms change it + table_user[passfield].requires[-1].min_length = self.settings.password_min_length + except: + pass + if self.settings.register_verify_password: + if self.settings.register_fields is None: + self.settings.register_fields = [f.name for f in table_user if f.writable] + k = self.settings.register_fields.index("password") + self.settings.register_fields.insert(k+1, "password_two") extra_fields = [ - Field("password_two", "password", requires=IS_EQUAL_TO( - request.post_vars.get(passfield, None), - error_message=self.messages.mismatched_password), - label=current.T("Confirm Password"))] + Field("password_two", "password", + requires=IS_EQUAL_TO(request.post_vars.get(passfield, None), + error_message=self.messages.mismatched_password), + label=current.T("Confirm Password"))] else: extra_fields = [] form = SQLFORM(table_user, @@ -2910,7 +3311,7 @@ class Auth(object): delete_label=self.messages.delete_label, formstyle=formstyle, separator=self.settings.label_separator, - extra_fields = extra_fields + extra_fields=extra_fields ) captcha = self.settings.register_captcha or self.settings.captcha @@ -2918,13 +3319,17 @@ class Auth(object): addrow(form, captcha.label, captcha, captcha.comment, self.settings.formstyle, 'captcha__row') - #Add a message if specified + # Add a message if specified if self.settings.pre_registration_div: addrow(form, '', DIV(_id="pre-reg", *self.settings.pre_registration_div), '', formstyle, '') - table_user.registration_key.default = key = web2py_uuid() + key = web2py_uuid() + if self.settings.registration_requires_approval: + key = 'pending-'+key + + table_user.registration_key.default = key if form.accepts(request, session if self.csrf_prevention else None, formname='register', onvalidation=onvalidation, @@ -3027,13 +3432,13 @@ class Auth(object): """ table_user = self.table_user() - if not 'username' in table_user.fields: + if 'username' not in table_user.fields: raise HTTP(404) request = current.request response = current.response session = current.session captcha = self.settings.retrieve_username_captcha or \ - (self.settings.retrieve_username_captcha != False and self.settings.captcha) + (self.settings.retrieve_username_captcha != False and self.settings.captcha) if not self.settings.mailer: response.flash = self.messages.function_disabled return '' @@ -3047,7 +3452,7 @@ class Auth(object): log = self.messages['retrieve_username_log'] old_requires = table_user.email.requires table_user.email.requires = [IS_IN_DB(self.db, table_user.email, - error_message=self.messages.invalid_email)] + error_message=self.messages.invalid_email)] form = SQLFORM(table_user, fields=['email'], hidden=dict(_next=next), @@ -3064,7 +3469,7 @@ class Auth(object): if form.accepts(request, session if self.csrf_prevention else None, formname='retrieve_username', dbio=False, onvalidation=onvalidation, hideerror=self.settings.hideerror): - users = table_user._db(table_user.email==form.vars.email).select() + users = table_user._db(table_user.email == form.vars.email).select() if not users: current.session.flash = \ self.messages.invalid_email @@ -3124,7 +3529,7 @@ class Auth(object): log = self.messages['retrieve_password_log'] old_requires = table_user.email.requires table_user.email.requires = [IS_IN_DB(self.db, table_user.email, - error_message=self.messages.invalid_email)] + error_message=self.messages.invalid_email)] form = SQLFORM(table_user, fields=['email'], hidden=dict(_next=next), @@ -3138,11 +3543,12 @@ class Auth(object): formname='retrieve_password', dbio=False, onvalidation=onvalidation, hideerror=self.settings.hideerror): user = table_user(email=form.vars.email) + key = user.registration_key if not user: current.session.flash = \ self.messages.invalid_email redirect(self.url(args=request.args)) - elif user.registration_key in ('pending', 'disabled', 'blocked'): + elif key in ('pending', 'disabled', 'blocked') or (key or '').startswith('pending'): current.session.flash = \ self.messages.registration_pending redirect(self.url(args=request.args)) @@ -3170,13 +3576,12 @@ class Auth(object): table_user.email.requires = old_requires return form - def confirm_registration( - self, - next=DEFAULT, - onvalidation=DEFAULT, - onaccept=DEFAULT, - log=DEFAULT, - ): + def confirm_registration(self, + next=DEFAULT, + onvalidation=DEFAULT, + onaccept=DEFAULT, + log=DEFAULT, + ): """ Returns a form to confirm user registration """ @@ -3224,9 +3629,8 @@ class Auth(object): requires=self.table_user()[passfield].requires), Field('new_password2', 'password', label=self.messages.verify_password, - requires=[IS_EXPR( - 'value==%s' % repr(request.vars.new_password), - self.messages.mismatched_password)]), + requires=[IS_EXPR('value==%s' % repr(request.vars.new_password), + self.messages.mismatched_password)]), submit_button='Confirm Registration', hidden=dict(_next=next), formstyle=self.settings.formstyle, @@ -3258,7 +3662,7 @@ class Auth(object): if self.settings.mailer and self.settings.mailer.send( to=user.email, subject=subject % d, - message=body % d): + message=body % d): user.update_record(reset_password_key=reset_password_key) return True return False @@ -3269,18 +3673,18 @@ class Auth(object): """ if not self.user: redirect(self.settings.login_url) - if not self.setting.bulk_register_enabled: + if not self.settings.bulk_register_enabled: return HTTP(404) form = SQLFORM.factory( - Field('subject','string',default=self.messages.bulk_invite_subject,requires=IS_NOT_EMPTY()), - Field('emails','text',requires=IS_NOT_EMPTY()), - Field('message','text',default=self.messages.bulk_invite_body,requires=IS_NOT_EMPTY()), + Field('subject', 'string', default=self.messages.bulk_invite_subject, requires=IS_NOT_EMPTY()), + Field('emails', 'text', requires=IS_NOT_EMPTY()), + Field('message', 'text', default=self.messages.bulk_invite_body, requires=IS_NOT_EMPTY()), formstyle=self.settings.formstyle) if form.process().accepted: emails = re.compile('[^\s\'"@<>,;:]+\@[^\s\'"@<>,;:]+').findall(form.vars.emails) - # send the invitations + # send the invitations emails_sent = [] emails_fail = [] emails_exist = [] @@ -3294,14 +3698,14 @@ class Auth(object): else: emails_fail.append(email) emails_fail += emails[max_emails:] - form = DIV(H4('Emails sent'),UL(*[A(x,_href='mailto:'+x) for x in emails_sent]), - H4('Emails failed'),UL(*[A(x,_href='mailto:'+x) for x in emails_fail]), - H4('Emails existing'),UL(*[A(x,_href='mailto:'+x) for x in emails_exist])) + form = DIV(H4('Emails sent'), UL(*[A(x, _href='mailto:'+x) for x in emails_sent]), + H4('Emails failed'), UL(*[A(x, _href='mailto:'+x) for x in emails_fail]), + H4('Emails existing'), UL(*[A(x, _href='mailto:'+x) for x in emails_exist])) return form def manage_tokens(self): if not self.user: - redirect(self.settings.login_url) + redirect(self.settings.login_url) table_token =self.table_token() table_token.user_id.writable = False table_token.user_id.default = self.user.id @@ -3349,6 +3753,11 @@ class Auth(object): session.flash = self.messages.invalid_reset_password redirect(next, client_side=self.settings.client_side) + key = user.registration_key + if key in ('pending', 'disabled', 'blocked') or (key or '').startswith('pending'): + session.flash = self.messages.registration_pending + redirect(next, client_side=self.settings.client_side) + if onvalidation is DEFAULT: onvalidation = self.settings.reset_password_onvalidation if onaccept is DEFAULT: @@ -3442,11 +3851,12 @@ class Auth(object): onvalidation=onvalidation, hideerror=self.settings.hideerror): user = table_user(**{userfield:form.vars.get(userfield)}) + key = user.registration_key if not user: session.flash = self.messages['invalid_%s' % userfield] redirect(self.url(args=request.args), client_side=self.settings.client_side) - elif user.registration_key in ('pending', 'disabled', 'blocked'): + elif key in ('pending', 'disabled', 'blocked') or (key or '').startswith('pending'): session.flash = self.messages.registration_pending redirect(self.url(args=request.args), client_side=self.settings.client_side) @@ -3474,7 +3884,7 @@ class Auth(object): if self.settings.mailer and self.settings.mailer.send( to=user.email, subject=self.messages.reset_password_subject, - message=self.messages.reset_password % d): + message=self.messages.reset_password % d): user.update_record(reset_password_key=reset_password_key) return True return False @@ -3526,15 +3936,14 @@ class Auth(object): requires[0].min_length = 0 form = SQLFORM.factory( Field('old_password', 'password', requires=requires, - label=self.messages.old_password), + label=self.messages.old_password), Field('new_password', 'password', - label=self.messages.new_password, - requires=table_user[passfield].requires), + label=self.messages.new_password, + requires=table_user[passfield].requires), Field('new_password2', 'password', - label=self.messages.verify_password, - requires=[IS_EXPR( - 'value==%s' % repr(request.vars.new_password), - self.messages.mismatched_password)]), + label=self.messages.verify_password, + requires=[IS_EXPR('value==%s' % repr(request.vars.new_password), + self.messages.mismatched_password)]), submit_button=self.messages.password_change_button, hidden=dict(_next=next), formstyle=self.settings.formstyle, @@ -3626,6 +4035,50 @@ class Auth(object): for callback in onaccept: callback(form) + def jwt(self): + """ + To use JWT authentication: + 1) instantiate auth with:: + + auth = Auth(db, jwt = {'secret_key':'secret'}) + + where 'secret' is your own secret string. + + 2) Decorate functions that require login but should accept the JWT token credentials:: + + @auth.allows_jwt() + @auth.requires_login() + def myapi(): return 'hello %s' % auth.user.email + + Notice jwt is allowed but not required. if user is logged in, myapi is accessible. + + 3) Use it! + + Now API users can obtain a token with + + http://.../app/default/user/jwt?username=...&password=.... + + (returns json object with a token attribute) + API users can refresh an existing token with + + http://.../app/default/user/jwt?token=... + + they can authenticate themselves when calling http:/.../myapi by injecting a header + + Authorization: Bearer + + Any additional attributes in the jwt argument of Auth() below:: + + auth = Auth(db, jwt = {...}) + + are passed to the constructor of class AuthJWT. Look there for documentation. + """ + if not self.jwt_handler: + raise HTTP(400, "Not authorized") + else: + rtn = self.jwt_handler.jwt_token_manager() + raise HTTP(200, rtn, cookies=None, **current.response.headers) + def is_impersonating(self): return self.is_logged_in() and 'impersonator' in current.session.auth @@ -3724,6 +4177,12 @@ class Auth(object): raise HTTP(403, 'ACCESS DENIED') return self.messages.access_denied + def allows_jwt(self, otherwise=None): + if not self.jwt_handler: + raise HTTP(400, "Not authorized") + else: + return self.jwt_handler.allows_jwt(otherwise=otherwise) + def requires(self, condition, requires_login=True, otherwise=None): """ Decorator that prevents access to action if not logged in @@ -3735,11 +4194,15 @@ class Auth(object): basic_allowed, basic_accepted, user = self.basic() user = user or self.user - if requires_login: + login_required = requires_login + if callable(login_required): + login_required = login_required() + + if login_required: if not user: if current.request.ajax: raise HTTP(401, self.messages.ajax_failed_authentication) - elif not otherwise is None: + elif otherwise is not None: if callable(otherwise): return otherwise() redirect(otherwise) @@ -3777,7 +4240,7 @@ class Auth(object): return self.requires(True, otherwise=otherwise) def requires_login_or_token(self, otherwise=None): - if self.settings.enable_tokens == True: + if self.settings.enable_tokens is True: user = None request = current.request token = request.env.http_web2py_user_token or request.vars._token @@ -3789,7 +4252,7 @@ class Auth(object): if row: user = table_user(row.user_id) else: - row = self.db(table_token.token==token)(table_user.id==table_token.user_id).select().first() + row = self.db(table_token.token == token)(table_user.id == table_token.user_id).select().first() if row: user = row[table_user._tablename] if user: @@ -3911,8 +4374,13 @@ class Auth(object): if not user_id and self.user: user_id = self.user.id membership = self.table_membership() - record = membership(user_id=user_id, group_id=group_id) + db = membership._db + record = db((membership.user_id==user_id)& + (membership.group_id==group_id), + ignore_common_filters=True).select().first() if record: + if hasattr(record, 'is_active') and not record.is_active: + record.update_record(is_active=True) return record.id else: id = membership.insert(group_id=group_id, user_id=user_id) @@ -3968,7 +4436,7 @@ class Auth(object): rows = self.db(membership.user_id == user_id).select(membership.group_id) groups = set([row.group_id for row in rows]) - if group_id and not group_id in groups: + if group_id and group_id not in groups: return False else: groups = set([group_id]) @@ -4007,9 +4475,15 @@ class Auth(object): permission = self.table_permission() if group_id == 0: group_id = self.user_group() - record = self.db(permission.group_id == group_id)(permission.name == name)(permission.table_name == str(table_name))( - permission.record_id == long(record_id)).select(limitby=(0, 1), orderby_on_limitby=False).first() + record = self.db((permission.group_id == group_id)& + (permission.name == name)& + (permission.table_name == str(table_name))& + (permission.record_id == long(record_id)), + ignore_common_filters=True).select( + limitby=(0, 1), orderby_on_limitby=False).first() if record: + if hasattr(record, 'is_active') and not record.is_ctive: + record.update_record(is_active=True) id = record.id else: id = permission.insert(group_id=group_id, name=name, @@ -4202,6 +4676,7 @@ class Auth(object): function=function, groups=groups) else: + self._wiki.settings.extra = extra or {} self._wiki.env.update(env or {}) # if resolve is set to True, process request as wiki call @@ -4210,7 +4685,7 @@ class Auth(object): if resolve: if slug: wiki = self._wiki.read(slug, force_render) - if isinstance(wiki, dict) and wiki.has_key('content'): # FIXME: .has_key() is deprecated + if isinstance(wiki, dict) and 'content' in wiki: # We don't want to return a dict object, just the wiki wiki = wiki['content'] else: @@ -4386,7 +4861,7 @@ class Crud(object): deletable = self.settings.update_deletable if message is DEFAULT: message = self.messages.record_updated - if not 'hidden' in attributes: + if 'hidden' not in attributes: attributes['hidden'] = {} attributes['hidden']['_next'] = next form = SQLFORM( @@ -4411,7 +4886,7 @@ class Crud(object): if not record and captcha: addrow(form, captcha.label, captcha, captcha.comment, self.settings.formstyle, 'captcha__row') - if not request.extension in ('html', 'load'): + if request.extension not in ('html', 'load'): (_session, _formname) = (None, None) else: (_session, _formname) = ( @@ -4437,7 +4912,7 @@ class Crud(object): callback(ondelete, form, table._tablename) response.flash = message callback(onaccept, form, table._tablename) - if not request.extension in ('html', 'load'): + if request.extension not in ('html', 'load'): raise HTTP(200, 'RECORD CREATED/UPDATED') if isinstance(next, (list, tuple)): # fix issue with 2.6 next = next[0] @@ -4445,7 +4920,7 @@ class Crud(object): next = replace_id(next, form) session.flash = response.flash redirect(next) - elif not request.extension in ('html', 'load'): + elif request.extension not in ('html', 'load'): raise HTTP(401, serializers.json(dict(errors=form.errors))) return form @@ -4470,18 +4945,17 @@ class Crud(object): log = self.messages['create_log'] if message is DEFAULT: message = self.messages.record_created - return self.update( - table, - None, - next=next, - onvalidation=onvalidation, - onaccept=onaccept, - log=log, - message=message, - deletable=False, - formname=formname, - **attributes - ) + return self.update(table, + None, + next=next, + onvalidation=onvalidation, + onaccept=onaccept, + log=log, + message=message, + deletable=False, + formname=formname, + **attributes + ) def read(self, table, record): if not (isinstance(table, Table) or table in self.db.tables) \ @@ -4501,7 +4975,7 @@ class Crud(object): formstyle=self.settings.formstyle, separator=self.settings.label_separator ) - if not current.request.extension in ('html', 'load'): + if current.request.extension not in ('html', 'load'): return table._filter_fields(form.record, id=True) return form @@ -4533,19 +5007,18 @@ class Crud(object): session.flash = message redirect(next) - def rows( - self, - table, - query=None, - fields=None, - orderby=None, - limitby=None, - ): + def rows(self, + table, + query=None, + fields=None, + orderby=None, + limitby=None, + ): if not (isinstance(table, Table) or table in self.db.tables): raise HTTP(404) if not self.has_permission('select', table): redirect(self.settings.auth.settings.on_failed_authorization) - #if record_id and not self.has_permission('select', table): + # if record_id and not self.has_permission('select', table): # redirect(self.settings.auth.settings.on_failed_authorization) if not isinstance(table, Table): table = self.db[table] @@ -4556,7 +5029,7 @@ class Crud(object): else: fields = [table[f] if isinstance(f, str) else f for f in fields] rows = self.db(query).select(*fields, **dict(orderby=orderby, - limitby=limitby)) + limitby=limitby)) return rows def select(self, @@ -4572,9 +5045,9 @@ class Crud(object): rows = self.rows(table, query, fields, orderby, limitby) if not rows: return None # Nicer than an empty table. - if not 'upload' in attr: + if 'upload' not in attr: attr['upload'] = self.url('download') - if not current.request.extension in ('html', 'load'): + if current.request.extension not in ('html', 'load'): return rows.as_list() if not headers: if isinstance(table, str): @@ -4766,8 +5239,7 @@ regex_geocode = \ def geocode(address): try: a = urllib.quote(address) - txt = fetch('http://maps.googleapis.com/maps/api/geocode/xml?sensor=false&address=%s' - % a) + txt = fetch('http://maps.googleapis.com/maps/api/geocode/xml?sensor=false&address=%s' % a) item = regex_geocode.search(txt) (la, lo) = (float(item.group('la')), float(item.group('lo'))) return (la, lo) @@ -5168,7 +5640,8 @@ class Service(object): # jsonrpc 2.0 error types. records the following structure {code: (message,meaning)} jsonrpc_errors = { - -32700: ("Parse error. Invalid JSON was received by the server.", "An error occurred on the server while parsing the JSON text."), + -32700: ("Parse error. Invalid JSON was received by the server.", + "An error occurred on the server while parsing the JSON text."), -32600: ("Invalid Request", "The JSON sent is not a valid Request object."), -32601: ("Method not found", "The method does not exist / is not available."), -32602: ("Invalid params", "Invalid method parameter(s)."), @@ -5177,8 +5650,7 @@ class Service(object): def serve_jsonrpc(self): def return_response(id, result): - return serializers.json({'version': '1.1', - 'id': id, 'result': result, 'error': None}) + return serializers.json({'version': '1.1', 'id': id, 'result': result, 'error': None}) def return_error(id, code, message, data=None): error = {'name': 'JSONRPCError', @@ -5230,7 +5702,7 @@ class Service(object): def return_error(id, code, message=None, data=None): error = {'code': code} - if Service.jsonrpc_errors.has_key(code): + if code in Service.jsonrpc_errors: error['message'] = Service.jsonrpc_errors[code][0] error['data'] = Service.jsonrpc_errors[code][1] if message is not None: @@ -5266,7 +5738,7 @@ class Service(object): if data['jsonrpc'] != '2.0': raise Service.JsonRpcException(-32603, 'Unsupported jsonrpc version "%s"' % data['jsonrpc']) if 'id' not in iparms: - return False + return False return True @@ -5363,10 +5835,9 @@ class Service(object): response = current.response procedures = self.soap_procedures - location = "%s://%s%s" % ( - request.env.wsgi_url_scheme, - request.env.http_host, - URL(r=request, f="call/soap", vars={})) + location = "%s://%s%s" % (request.env.wsgi_url_scheme, + request.env.http_host, + URL(r=request, f="call/soap", vars={})) namespace = 'namespace' in response and response.namespace or location documentation = response.description or '' dispatcher = SoapDispatcher( @@ -5420,7 +5891,7 @@ class Service(object): P(response.description), P("The following operations are available"), A("See WSDL for webservice description", - _href=URL(r=request, f="call/soap", vars={"WSDL":None})), + _href=URL(r=request, f="call/soap", vars={"WSDL": None})), UL([LI(A("%s: %s" % (method, doc or ''), _href=URL(r=request, f="call/soap", vars={'op': method}))) for method, doc in dispatcher.list_methods()]), @@ -5519,11 +5990,12 @@ def completion(callback): return _completion -def prettydate(d, T=lambda x: x): +def prettydate(d, T=lambda x: x, utc=False): + now = datetime.datetime.utcnow() if utc else datetime.datetime.now() if isinstance(d, datetime.datetime): - dt = datetime.datetime.now() - d + dt = now - d elif isinstance(d, datetime.date): - dt = datetime.date.today() - d + dt = now.date() - d elif not d: return '' else: @@ -5664,10 +6136,10 @@ class PluginManager(object): settings = self.__getattr__(plugin) settings.installed = True settings.update( - (k, v) for k, v in defaults.items() if not k in settings) + (k, v) for k, v in defaults.items() if k not in settings) def __getattr__(self, key): - if not key in self.__dict__: + if key not in self.__dict__: self.__dict__[key] = Storage() return self.__dict__[key] @@ -5718,9 +6190,9 @@ class Expose(object): raise HTTP(200, open(filename, 'rb'), **current.response.headers) self.path = path = os.path.join(filename, '*') self.folders = [f[len(path) - 1:] for f in sorted(glob.glob(path)) - if os.path.isdir(f) and not self.isprivate(f)] + if os.path.isdir(f) and not self.isprivate(f)] self.filenames = [f[len(path) - 1:] for f in sorted(glob.glob(path)) - if not os.path.isdir(f) and not self.isprivate(f)] + if not os.path.isdir(f) and not self.isprivate(f)] if 'README' in self.filenames: readme = open(os.path.join(filename, 'README')).read() self.paragraph = MARKMIN(readme) @@ -5742,10 +6214,9 @@ class Expose(object): def table_folders(self): if self.folders: - return SPAN(H3('Folders'), TABLE( - *[TR(TD(A(folder, _href=URL(args=self.args + [folder])))) - for folder in self.folders], - **dict(_class="table"))) + return SPAN(H3('Folders'), + TABLE(*[TR(TD(A(folder, _href=URL(args=self.args + [folder])))) + for folder in self.folders], **dict(_class="table"))) return '' @staticmethod @@ -5764,8 +6235,7 @@ class Expose(object): TD(IMG(_src=URL(args=self.args + [f]), _style='max-width:%spx' % width) if width and self.isimage(f) else '')) - for f in self.filenames], - **dict(_class="table"))) + for f in self.filenames], **dict(_class="table"))) return '' def xml(self): @@ -5936,7 +6406,7 @@ class Wiki(object): # define only non-existent tables for key, value in table_definitions: args = [] - if not key in db.tables(): + if key not in db.tables(): # look for wiki_ extra fields in auth.settings extra_fields = auth.settings.extra_fields if extra_fields: @@ -5970,8 +6440,8 @@ class Wiki(object): if (auth.user and check_credentials(current.request, gae_login=False) and - not 'wiki_editor' in auth.user_groups.values() and - self.settings.groups == auth.user_groups.values()): + 'wiki_editor' not in auth.user_groups.values() and + self.settings.groups == auth.user_groups.values()): group = db.auth_group(role='wiki_editor') gid = group.id if group else db.auth_group.insert( role='wiki_editor') @@ -5985,8 +6455,7 @@ class Wiki(object): raise HTTP(401) def can_read(self, page): - if 'everybody' in page.can_read or not \ - self.settings.manage_permissions: + if 'everybody' in page.can_read or not self.settings.manage_permissions: return True elif self.auth.user: groups = self.settings.groups @@ -6029,9 +6498,7 @@ class Wiki(object): def automenu(self): """adds the menu if not present""" - if (not self.wiki_menu_items and - self.settings.controller and - self.settings.function): + if (not self.wiki_menu_items and self.settings.controller and self.settings.function): self.wiki_menu_items = self.menu(self.settings.controller, self.settings.function) current.response.menu += self.wiki_menu_items @@ -6075,8 +6542,7 @@ class Wiki(object): def first_paragraph(self, page): if not self.can_read(page): mm = (page.body or '').replace('\r', '') - ps = [p for p in mm.split('\n\n') - if not p.startswith('#') and p.strip()] + ps = [p for p in mm.split('\n\n') if not p.startswith('#') and p.strip()] if ps: return ps[0] return '' @@ -6222,10 +6688,7 @@ class Wiki(object): fragment = self.auth.db.wiki_media.id.represent csv = False create = False - links= [ - lambda row: - A('copy into source', _href='#', _onclick=script % (fragment(row.id, row))) - ] + links = [lambda row: A('copy into source', _href='#', _onclick=script % (fragment(row.id, row)))] content = SQLFORM.grid( self.auth.db.wiki_media.wiki_page == page.id, orderby=self.auth.db.wiki_media.title, @@ -6394,7 +6857,7 @@ class Wiki(object): count = db.wiki_tag.wiki_page.count() fields = [db.wiki_page.id, db.wiki_page.slug, db.wiki_page.title, db.wiki_page.tags, - db.wiki_page.can_read] + db.wiki_page.can_read, db.wiki_page.can_edit] if preview: fields.append(db.wiki_page.body) if query is None: @@ -6462,7 +6925,7 @@ class Wiki(object): request = current.request # FIXME: This is an ugly hack to ensure a default render # engine if not specified (with multiple render engines) - if not "render" in request.post_vars: + if 'render' not in request.post_vars: request.post_vars.render = None return render(request.post_vars) @@ -6478,7 +6941,7 @@ class Config(object): self.config.read(filename) if not self.config.has_section(section): self.config.add_section(section) - self.section = section + self.section = section self.filename = filename def read(self): diff --git a/gluon/utils.py b/gluon/utils.py index 4df0cbc3..9e0f6679 100644 --- a/gluon/utils.py +++ b/gluon/utils.py @@ -64,6 +64,10 @@ else: except (ImportError, ValueError): HAVE_PBKDF2 = False +HAVE_COMPARE_DIGEST = False +if hasattr(hmac, 'compare_digest'): + HAVE_COMPARE_DIGEST = True + logger = logging.getLogger("web2py") @@ -77,6 +81,8 @@ def AES_new(key, IV=None): def compare(a, b): """ Compares two strings and not vulnerable to timing attacks """ + if HAVE_COMPARE_DIGEST: + return hmac.compare_digest(a, b) if len(a) != len(b): return False result = 0 @@ -143,6 +149,7 @@ DIGEST_ALG_BY_SIZE = { 512 / 4: 'sha512', } + def get_callable_argspec(fn): if inspect.isfunction(fn) or inspect.ismethod(fn): inspectable = fn @@ -154,6 +161,7 @@ def get_callable_argspec(fn): inspectable = fn return inspect.getargspec(inspectable) + def pad(s, n=32, padchar=' '): return s + (32 - len(s) % 32) * padchar @@ -172,7 +180,7 @@ def secure_dumps(data, encryption_key, hash_key=None, compression_level=None): def secure_loads(data, encryption_key, hash_key=None, compression_level=None): - if not ':' in data: + if ':' not in data: return None if not hash_key: hash_key = sha1(encryption_key).hexdigest() diff --git a/gluon/validators.py b/gluon/validators.py index 2afeedc4..1ca9ee49 100644 --- a/gluon/validators.py +++ b/gluon/validators.py @@ -22,7 +22,7 @@ import decimal import unicodedata from cStringIO import StringIO from gluon.utils import simple_hash, web2py_uuid, DIGEST_ALG_BY_SIZE -from pydal.objects import FieldVirtual, FieldMethod +from pydal.objects import Field, FieldVirtual, FieldMethod regex_isint = re.compile('^[+-]?\d+$') @@ -201,12 +201,15 @@ class IS_MATCH(Validator): def __call__(self, value): if self.is_unicode: - if isinstance(value,unicode): - match = self.regex.search(value) - else: + if not isinstance(value, unicode): match = self.regex.search(str(value).decode('utf8')) + else: + match = self.regex.search(value) else: - match = self.regex.search(str(value)) + if not isinstance(value, unicode): + match = self.regex.search(str(value)) + else: + match = self.regex.search(value.encode('utf8')) if match is not None: return (self.extract and match.group() or value, None) return (value, translate(self.error_message)) @@ -509,34 +512,44 @@ class IS_IN_DB(Validator): zero='', sort=False, _and=None, - left=None + left=None, + delimiter=None, + auto_add=False, ): from pydal.objects import Table - if isinstance(field, Table): - field = field._id - if hasattr(dbset, 'define_table'): self.dbset = dbset() else: self.dbset = dbset + + if isinstance(field, Table): + field = field._id + elif isinstance(field, str): + items = field.split('.') + if len(items)==1: items+=['id'] + field = self.dbset.db[items[0]][items[1]] + (ktable, kfield) = str(field).split('.') if not label: label = '%%(%s)s' % kfield if isinstance(label, str): if regex1.match(str(label)): label = '%%(%s)s' % str(label).split('.')[-1] - ks = regex2.findall(label) - if kfield not in ks: - ks += [kfield] - fields = ks + fieldnames = regex2.findall(label) + if kfield not in fieldnames: + fieldnames.append(kfield) # kfield must be last + elif isinstance(label, Field): + fieldnames = [label.name, kfield] # kfield must be last + label = '%%(%s)s' % label.name + elif callable(label): + fieldnames = '*' else: - ks = [kfield] - fields = 'all' - self.fields = fields + raise NotImplementedError + self.field = field # the lookup field + self.fieldnames = fieldnames # fields requires to build the formatting self.label = label self.ktable = ktable self.kfield = kfield - self.ks = ks self.error_message = error_message self.theset = None self.orderby = orderby @@ -548,6 +561,8 @@ class IS_IN_DB(Validator): self.sort = sort self._and = _and self.left = left + self.delimiter = delimiter + self.auto_add = auto_add def set_self_id(self, id): if self._and: @@ -555,10 +570,10 @@ class IS_IN_DB(Validator): def build_set(self): table = self.dbset.db[self.ktable] - if self.fields == 'all': + if self.fieldnames == '*': fields = [f for f in table] else: - fields = [table[k] for k in self.fields] + fields = [table[k] for k in self.fieldnames] ignore = (FieldVirtual, FieldMethod) fields = filter(lambda f: not isinstance(f, ignore), fields) if self.dbset.db._dbname != 'gae': @@ -591,18 +606,42 @@ class IS_IN_DB(Validator): items.insert(0, ('', self.zero)) return items + def maybe_add(self, table, fieldname, value): + d = {fieldname: value} + record = table(**d) + if record: + return record.id + else: + return table.insert(**d) + def __call__(self, value): table = self.dbset.db[self.ktable] field = table[self.kfield] + if self.multiple: if self._and: raise NotImplementedError if isinstance(value, list): values = value + elif self.delimiter: + values = value.split(self.delimiter) # because of autocomplete elif value: values = [value] else: values = [] + + if self.field.type in ('id','integer'): + new_values = [] + for value in values: + if isinstance(value,(int,long)) or value.isdigit(): + value = int(value) + elif self.auto_add: + value = self.maybe_add(table, self.fieldnames[0], value) + else: + return (values, translate(self.error_message)) + new_values.append(value) + values = new_values + if isinstance(self.multiple, (tuple, list)) and \ not self.multiple[0] <= len(values) < self.multiple[1]: return (values, translate(self.error_message)) @@ -621,18 +660,32 @@ class IS_IN_DB(Validator): return (values, None) elif count(values) == len(values): return (values, None) - elif self.theset: - if str(value) in self.theset: - if self._and: - return self._and(value) - else: - return (value, None) else: - if self.dbset(field == value).count(): - if self._and: - return self._and(value) + if self.field.type in ('id','integer'): + if isinstance(value,(int,long)) or value.isdigit(): + value = int(value) + elif self.auto_add: + value = self.maybe_add(table, self.fieldnames[0], value) else: - return (value, None) + return (value, translate(self.error_message)) + + try: + value = int(value) + except TypeError: + return (values, translate(self.error_message)) + + if self.theset: + if str(value) in self.theset: + if self._and: + return self._and(value) + else: + return (value, None) + else: + if self.dbset(field == value).count(): + if self._and: + return self._and(value) + else: + return (value, None) return (value, translate(self.error_message)) @@ -694,7 +747,7 @@ class IS_NOT_IN_DB(Validator): return (value, translate(self.error_message)) else: row = subset.select(table._id, field, limitby=(0, 1), orderby_on_limitby=False).first() - if row and str(row.id) != str(id): + if row and str(row[table._id]) != str(id): return (value, translate(self.error_message)) return (value, None) @@ -2165,29 +2218,22 @@ class IS_DATE(Validator): INPUT(_type='text', _name='name', requires=IS_DATE()) date has to be in the ISO8960 format YYYY-MM-DD - timezome must be None or a pytz.timezone("America/Chicago") object """ def __init__(self, format='%Y-%m-%d', - error_message='Enter date as %(format)s', - timezone=None): + error_message='Enter date as %(format)s'): self.format = translate(format) self.error_message = str(error_message) - self.timezone = timezone self.extremes = {} def __call__(self, value): ovalue = value if isinstance(value, datetime.date): - if self.timezone is not None: - value = value - datetime.timedelta(seconds=self.timezone*3600) return (value, None) try: (y, m, d, hh, mm, ss, t0, t1, t2) = \ time.strptime(value, str(self.format)) value = datetime.date(y, m, d) - if self.timezone is not None: - value = self.timezone.localize(value).astimezone(utc) return (value, None) except: self.extremes.update(IS_DATETIME.nice(self.format)) @@ -2203,11 +2249,7 @@ class IS_DATE(Validator): format = format.replace('%Y', y) if year < 1900: year = 2000 - if self.timezone is not None: - d = datetime.datetime(year, value.month, value.day) - d = d.replace(tzinfo=utc).astimezone(self.timezone) - else: - d = datetime.date(year, value.month, value.day) + d = datetime.date(year, value.month, value.day) return d.strftime(format) @@ -2258,7 +2300,8 @@ class IS_DATETIME(Validator): time.strptime(value, str(self.format)) value = datetime.datetime(y, m, d, hh, mm, ss) if self.timezone is not None: - value = self.timezone.localize(value).astimezone(utc) + # TODO: https://github.com/web2py/web2py/issues/1094 (temporary solution) + value = self.timezone.localize(value).astimezone(utc).replace(tzinfo=None) return (value, None) except: self.extremes.update(IS_DATETIME.nice(self.format)) @@ -2307,8 +2350,7 @@ class IS_DATE_IN_RANGE(IS_DATE): minimum=None, maximum=None, format='%Y-%m-%d', - error_message=None, - timezone=None): + error_message=None): self.minimum = minimum self.maximum = maximum if error_message is None: @@ -2320,8 +2362,7 @@ class IS_DATE_IN_RANGE(IS_DATE): error_message = "Enter date in range %(min)s %(max)s" IS_DATE.__init__(self, format=format, - error_message=error_message, - timezone=timezone) + error_message=error_message) self.extremes = dict(min=self.formatter(minimum), max=self.formatter(maximum)) @@ -2847,9 +2888,11 @@ class CRYPT(object): self.salt = salt def __call__(self, value): - value = value and value[:self.max_length] - if len(value) < self.min_length: + v = value and str(value)[:self.max_length] + if not v or len(v) < self.min_length: return ('', translate(self.error_message)) + if isinstance(value, LazyCrypt): + return (value, None) return (LazyCrypt(self, value), None) # entropy calculator for IS_STRONG @@ -3377,7 +3420,8 @@ class IS_IPV4(Validator): (number == self.localhost)): ok = False if not (self.is_private is None or self.is_private == - (sum([number[0] <= number <= number[1] for number in self.private]) > 0)): + (sum([private_number[0] <= number <= private_number[1] + for private_number in self.private]) > 0)): ok = False if not (self.is_automatic is None or self.is_automatic == (self.automatic[0] <= number <= self.automatic[1])): @@ -3482,7 +3526,7 @@ class IS_IPV6(Validator): from gluon.contrib import ipaddr as ipaddress try: - ip = ipaddress.IPv6Address(value) + ip = ipaddress.IPv6Address(value.decode('utf-8')) ok = True except ipaddress.AddressValueError: return (value, translate(self.error_message)) @@ -3494,7 +3538,7 @@ class IS_IPV6(Validator): self.subnets = [self.subnets] for network in self.subnets: try: - ipnet = ipaddress.IPv6Network(network) + ipnet = ipaddress.IPv6Network(network.decode('utf-8')) except (ipaddress.NetmaskValueError, ipaddress.AddressValueError): return (value, translate('invalid subnet provided')) if ip in ipnet: @@ -3703,20 +3747,22 @@ class IS_IPADDRESS(Validator): def __call__(self, value): try: - import ipaddress + from ipaddress import ip_address as IPAddress + from ipaddress import IPv6Address, IPv4Address except ImportError: - from gluon.contrib import ipaddr as ipaddress + from gluon.contrib.ipaddr import (IPAddress, IPv4Address, + IPv6Address) try: - ip = ipaddress.IPAddress(value) - except ValueError, e: + ip = IPAddress(value.decode('utf-8')) + except ValueError: return (value, translate(self.error_message)) - if self.is_ipv4 and isinstance(ip, ipaddress.IPv6Address): + if self.is_ipv4 and isinstance(ip, IPv6Address): retval = (value, translate(self.error_message)) - elif self.is_ipv6 and isinstance(ip, ipaddress.IPv4Address): + elif self.is_ipv6 and isinstance(ip, IPv4Address): retval = (value, translate(self.error_message)) - elif self.is_ipv4 or isinstance(ip, ipaddress.IPv4Address): + elif self.is_ipv4 or isinstance(ip, IPv4Address): retval = IS_IPV4( minip=self.minip, maxip=self.maxip, @@ -3726,7 +3772,7 @@ class IS_IPADDRESS(Validator): is_automatic=self.is_automatic, error_message=self.error_message )(value) - elif self.is_ipv6 or isinstance(ip, ipaddress.IPv6Address): + elif self.is_ipv6 or isinstance(ip, IPv6Address): retval = IS_IPV6( is_private=self.is_private, is_link_local=self.is_link_local, diff --git a/gluon/widget.py b/gluon/widget.py index 64720bba..a5551052 100644 --- a/gluon/widget.py +++ b/gluon/widget.py @@ -40,8 +40,8 @@ ProgramInfo = '''%s %s %s''' % (ProgramName, ProgramAuthor, ProgramVersion) -if not sys.version[:3] in ['2.5', '2.6', '2.7']: - msg = 'Warning: web2py requires Python 2.5, 2.6 or 2.7 but you are running:\n%s' +if not sys.version[:3] in ['2.6', '2.7']: + msg = 'Warning: web2py requires Python 2.6 or 2.7 but you are running:\n%s' msg = msg % sys.version sys.stderr.write(msg) @@ -56,8 +56,8 @@ def run_system_tests(options): major_version = sys.version_info[0] minor_version = sys.version_info[1] if major_version == 2: - if minor_version in (5, 6): - sys.stderr.write("Python 2.5 or 2.6\n") + if minor_version in (6,): + sys.stderr.write('Python 2.6\n') ret = subprocess.call(['unit2', '-v', 'gluon.tests']) elif minor_version in (7,): call_args = [sys.executable, '-m', 'unittest', '-v', 'gluon.tests'] @@ -150,7 +150,7 @@ class web2pyDialog(object): self.scheduler_processes = {} self.menu = Tkinter.Menu(self.root) servermenu = Tkinter.Menu(self.menu, tearoff=0) - httplog = os.path.join(self.options.folder, 'httpserver.log') + httplog = os.path.join(self.options.folder, self.options.log_filename) iconphoto = os.path.join('extras', 'icons', 'web2py.gif') if os.path.exists(iconphoto): img = Tkinter.PhotoImage(file=iconphoto) @@ -225,9 +225,9 @@ class web2pyDialog(object): text=str(ProgramVersion + "\n" + ProgramAuthor), font=('Helvetica', 11), justify=Tkinter.CENTER, foreground='#195866', background=bg_color, - height=3).pack( side='top', - fill='both', - expand='yes') + height=3).pack(side='top', + fill='both', + expand='yes') self.bannerarea.after(1000, self.update_canvas) @@ -322,11 +322,15 @@ class web2pyDialog(object): self.tb = None def update_schedulers(self, start=False): + applications_folder = os.path.join(self.options.folder, 'applications') apps = [] - available_apps = [arq for arq in os.listdir('applications/')] - available_apps = [arq for arq in available_apps - if os.path.exists( - 'applications/%s/models/scheduler.py' % arq)] + ##FIXME - can't start scheduler in the correct dir from Tk + if self.options.folder: + return + available_apps = [ + arq for arq in os.listdir(applications_folder) + if os.path.exists(os.path.join(applications_folder, arq, 'models', 'scheduler.py')) + ] if start: # the widget takes care of starting the scheduler if self.options.scheduler and self.options.with_scheduler: @@ -414,9 +418,11 @@ class web2pyDialog(object): def connect_pages(self): """ Connects pages """ # reset the menu - available_apps = [arq for arq in os.listdir('applications/') - if os.path.exists( - 'applications/%s/__init__.py' % arq)] + applications_folder = os.path.join(self.options.folder, 'applications') + available_apps = [ + arq for arq in os.listdir(applications_folder) + if os.path.exists(os.path.join(applications_folder, arq, '__init__.py')) + ] self.pagesmenu.delete(0, len(available_apps)) for arq in available_apps: url = self.url + arq @@ -552,14 +558,15 @@ class web2pyDialog(object): def update_canvas(self): """ Updates canvas """ + httplog = os.path.join(self.options.folder, self.options.log_filename) try: - t1 = os.path.getsize('httpserver.log') + t1 = os.path.getsize(httplog) except: self.canvas.after(1000, self.update_canvas) return try: - fp = open('httpserver.log', 'r') + fp = open(httplog, 'r') fp.seek(self.t0) data = fp.read(t1 - self.t0) fp.close() @@ -1051,6 +1058,8 @@ def start_schedulers(options): apps = options.scheduler_groups code = "from gluon import current;current._scheduler.loop()" logging.getLogger().setLevel(options.debuglevel) + if options.folder: + os.chdir(options.folder) if len(apps) == 1 and not options.with_scheduler: app_, code = get_code_for_scheduler(apps[0], options) if not app_: @@ -1117,11 +1126,12 @@ def start(cron=True): if hasattr(options, key): setattr(options, key, getattr(options2, key)) - logfile0 = os.path.join('extras', 'examples', 'logging.example.conf') - if not os.path.exists('logging.conf') and os.path.exists(logfile0): + logfile0 = os.path.join('examples', 'logging.example.conf') + logfile1 = os.path.join(options.folder, 'logging.conf') + if not os.path.exists(logfile1) and os.path.exists(logfile0): import shutil sys.stdout.write("Copying logging.conf.example to logging.conf ... ") - shutil.copyfile('logging.example.conf', logfile0) + shutil.copyfile(logfile0, logfile1) sys.stdout.write("OK\n") # ## if -T run doctests (no cron) diff --git a/scripts/service/service.py b/scripts/service/service.py index 0a2982b5..247a7f14 100644 --- a/scripts/service/service.py +++ b/scripts/service/service.py @@ -74,7 +74,7 @@ class ServiceBase(Base): key = config['https_key'] cert = config['https_cert'] if key != '' and cert != '': - interfaces.append('%s:%s:%s:%s' % (ip, port, cert, key)) + interfaces.append('%s:%s:%s:%s' % (ip, port, key, cert)) ports.append(ports) if len(interfaces) == 0: sys.exit('Configuration error. Must have settings for http and/or https') @@ -92,7 +92,7 @@ class ServiceBase(Base): interfaces = ';'.join(interfaces) args.append('--interfaces=%s' % interfaces) - if 'log_filename' in config.key(): + if 'log_filename' in config.keys(): log_filename = config['log_filename'] args.append('--log_filename=%s' % log_filename) diff --git a/scripts/setup-web2py-centos7.sh b/scripts/setup-web2py-centos7.sh index 4d28ac06..6b74400a 100644 --- a/scripts/setup-web2py-centos7.sh +++ b/scripts/setup-web2py-centos7.sh @@ -195,7 +195,7 @@ NameVirtualHost *:80 NameVirtualHost *:443 - WSGIDaemonProcess web2py user=apache group=apache processes=1 threads=1 + WSGIDaemonProcess web2py user=apache group=apache WSGIProcessGroup web2py WSGIScriptAlias / /opt/web-apps/web2py/wsgihandler.py WSGIPassAuthorization On diff --git a/scripts/setup-web2py-fedora-ami.sh b/scripts/setup-web2py-fedora-ami.sh index f7382d0e..5ef2acaf 100755 --- a/scripts/setup-web2py-fedora-ami.sh +++ b/scripts/setup-web2py-fedora-ami.sh @@ -299,7 +299,7 @@ NameVirtualHost *:80 NameVirtualHost *:443 - WSGIDaemonProcess web2py user=apache group=apache processes=1 threads=1 + WSGIDaemonProcess web2py user=apache group=apache WSGIProcessGroup web2py WSGIScriptAlias / /opt/web-apps/web2py/wsgihandler.py diff --git a/scripts/setup-web2py-fedora.sh b/scripts/setup-web2py-fedora.sh index 5566e95d..d7d6d01c 100644 --- a/scripts/setup-web2py-fedora.sh +++ b/scripts/setup-web2py-fedora.sh @@ -1,3 +1,4 @@ +#!/bin/bash echo "This script will: 1) Install modules needed to run web2py on Fedora and CentOS/RHEL 2) Install Python 2.6 to /opt and recompile wsgi if not provided @@ -27,7 +28,7 @@ Press ENTER to continue...[ctrl+C to abort]" read CONFIRM -#!/bin/bash + ### ### Phase 0 - This may get messy. Lets work from a temporary directory @@ -301,7 +302,7 @@ NameVirtualHost *:80 NameVirtualHost *:443 - WSGIDaemonProcess web2py user=apache group=apache processes=1 threads=1 + WSGIDaemonProcess web2py user=apache group=apache WSGIProcessGroup web2py WSGIScriptAlias / /opt/web-apps/web2py/wsgihandler.py WSGIPassAuthorization On diff --git a/scripts/setup-web2py-ubuntu.sh b/scripts/setup-web2py-ubuntu.sh index 01682d1a..8de3050a 100644 --- a/scripts/setup-web2py-ubuntu.sh +++ b/scripts/setup-web2py-ubuntu.sh @@ -1,3 +1,4 @@ +#!/bin/bash echo "This script will: 1) install all modules need to run web2py on Ubuntu 14.04 2) install web2py in /home/www-data/ @@ -12,7 +13,7 @@ Press a key to continue...[ctrl+C to abort]" read CONFIRM -#!/bin/bash + # optional # dpkg-reconfigure console-setup # dpkg-reconfigure timezoneconf @@ -84,13 +85,31 @@ openssl x509 -noout -fingerprint -text < /etc/apache2/ssl/self_signed.cert > /et echo "rewriting your apache config file to use mod_wsgi" echo "=================================================" echo ' -WSGIDaemonProcess web2py user=www-data group=www-data processes=1 threads=1 +WSGIDaemonProcess web2py user=www-data group=www-data - RewriteEngine On - RewriteCond %{HTTPS} !=on - RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R,L] + WSGIProcessGroup web2py + WSGIScriptAlias / /home/www-data/web2py/wsgihandler.py + WSGIPassAuthorization On + + + AllowOverride None + Require all denied + + Require all granted + + + + AliasMatch ^/([^/]+)/static/(?:_[\d]+.[\d]+.[\d]+/)?(.*) \ + /home/www-data/web2py/applications/$1/static/$2 + + + Options -Indexes + ExpiresActive On + ExpiresDefault "access plus 1 hour" + Require all granted + CustomLog /var/log/apache2/access.log common ErrorLog /var/log/apache2/error.log diff --git a/web2py.py b/web2py.py index 42ec1c22..516427f7 100755 --- a/web2py.py +++ b/web2py.py @@ -3,6 +3,8 @@ import os import sys +from multiprocessing import freeze_support +# import gluon.import_all ##### This should be uncommented for py2exe.py if hasattr(sys, 'frozen'): path = os.path.dirname(os.path.abspath(sys.executable)) # for py2exe @@ -14,17 +16,14 @@ os.chdir(path) sys.path = [path] + [p for p in sys.path if not p == path] -# import gluon.import_all ##### This should be uncommented for py2exe.py +# important that this import is after the os.chdir + import gluon.widget # Start Web2py and Web2py cron service! if __name__ == '__main__': - try: - from multiprocessing import freeze_support - freeze_support() - except: - sys.stderr.write('Sorry, -K only supported for python 2.6-2.7\n') - if os.environ.has_key("COVERAGE_PROCESS_START"): + freeze_support() + if 'COVERAGE_PROCESS_START' in os.environ: try: import coverage coverage.process_startup()