diff --git a/rattail/app.py b/rattail/app.py
index 9fd74077842bc181abd1ea62905942af5b473d0d..f168797e08a92376413e11df9b975902cce9c8af 100644
--- a/rattail/app.py
+++ b/rattail/app.py
@@ -104,6 +104,35 @@ class AppHandler(object):
"""
return self.config.app_title(default=default)
+ def get_class_prefix(self, default=None):
+ """
+ Returns the "class prefix" for the app, used when naming model
+ classes etc.
+ """
+ prefix = self.config.get('rattail', 'app_class_prefix',
+ default=default)
+ if prefix:
+ return prefix
+
+ title = self.get_title(default="Rattail")
+ prefix = title.replace(' ', '')
+ return prefix
+
+ def get_table_prefix(self, default=None):
+ """
+ Returns the "table prefix" for the app, used when naming
+ tables etc.
+ """
+ prefix = self.config.get('rattail', 'app_table_prefix',
+ default=default)
+ if prefix:
+ return prefix
+
+ title = self.get_title(default="Rattail")
+ prefix = title.lower()\
+ .replace(' ', '_')
+ return prefix
+
def get_timezone(self, key='default'):
"""
Returns a configured time zone.
@@ -595,6 +624,20 @@ class AppHandler(object):
self.datasync_handler = Handler(self.config, **kwargs)
return self.datasync_handler
+ def get_db_handler(self, **kwargs):
+ """
+ Get the configured "database" handler.
+
+ :returns: The :class:`~rattail.db.handler.DatabaseHandler`
+ instance for the app.
+ """
+ if not hasattr(self, 'db_handler'):
+ spec = self.config.get('rattail.db', 'handler',
+ default='rattail.db.handler:DatabaseHandler')
+ Handler = self.load_object(spec)
+ self.db_handler = Handler(self.config)
+ return self.db_handler
+
def get_employment_handler(self, **kwargs):
"""
Get the configured "employment" handler.
diff --git a/rattail/commands/core.py b/rattail/commands/core.py
index d21ddc32d62d5630dedc505ea3eec8954c8cbcc3..a0c6dc1f72ee20f1f40fb8e532277a90d9b2b8ce 100644
--- a/rattail/commands/core.py
+++ b/rattail/commands/core.py
@@ -438,6 +438,87 @@ class Subcommand(object):
"""
raise NotImplementedError
+ def require_prompt_toolkit(self):
+ try:
+ import prompt_toolkit
+ except ImportError:
+ value = input("\nprompt_toolkit is not installed. shall i install it? [Yn] ")
+ value = value.strip()
+ if value and not self.config.parse_bool(value):
+ self.stderr.write("prompt_toolkit is required; aborting\n")
+ sys.exit(1)
+
+ subprocess.check_call(['pip', 'install', 'prompt_toolkit'])
+
+ def require_rich(self):
+ try:
+ import rich
+ except ImportError:
+ value = input("\nrich is not installed. shall i install it? [Yn] ")
+ value = value.strip()
+ if value and not self.config.parse_bool(value):
+ self.stderr.write("rich is required; aborting\n")
+ sys.exit(1)
+
+ subprocess.check_call(['pip', 'install', 'rich'])
+
+ def rprint(self, *args, **kwargs):
+ self.require_rich()
+
+ from rich import print as rprint
+
+ return rprint(*args, **kwargs)
+
+ def basic_prompt(self, info, default=None, is_password=False, is_bool=False,
+ required=False):
+ self.require_prompt_toolkit()
+
+ from prompt_toolkit import prompt
+ from prompt_toolkit.styles import Style
+
+ # message formatting styles
+ style = Style.from_dict({
+ '': '',
+ 'bold': 'bold',
+ })
+
+ # build prompt message
+ message = [
+ ('', '\n'),
+ ('class:bold', info),
+ ]
+ if default is not None:
+ if is_bool:
+ message.append(('', ' [{}]: '.format('Y' if default else 'N')))
+ else:
+ message.append(('', ' [{}]: '.format(default)))
+ else:
+ message.append(('', ': '))
+
+ # prompt user for input
+ try:
+ text = prompt(message, style=style, is_password=is_password)
+ except (KeyboardInterrupt, EOFError):
+ self.rprint("\n\t[bold yellow]operation canceled by user[/bold yellow]\n",
+ file=self.stderr)
+ sys.exit(2)
+
+ if is_bool:
+ if text == '':
+ return default
+ elif text.upper() == 'Y':
+ return True
+ elif text.upper() == 'N':
+ return False
+ self.rprint("\n\t[bold yellow]ambiguous, please try again[/bold yellow]\n")
+ return self.basic_prompt(info, default, is_bool=True)
+
+ if required and not text and not default:
+ return self.basic_prompt(info, default, is_password=is_password,
+ required=True)
+
+ return text or default
+
class CheckDatabase(Subcommand):
"""
diff --git a/rattail/commands/projects.py b/rattail/commands/projects.py
new file mode 100644
index 0000000000000000000000000000000000000000..5aaac46b2dba366fba44f45a5aad308efbe6c3bf
--- /dev/null
+++ b/rattail/commands/projects.py
@@ -0,0 +1,137 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2022 Lance Edgar
+#
+# This file is part of Rattail.
+#
+# Rattail is free software: you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Rattail. If not, see .
+#
+################################################################################
+"""
+Project Commands
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+import os
+import subprocess
+import sys
+
+from rattail.commands.core import Subcommand
+from rattail.projects.handler import RattailProjectHandler
+
+
+class MakeProject(Subcommand):
+ """
+ Make a new app project
+ """
+ name = 'make-project'
+ description = __doc__.strip()
+
+ def add_parser_args(self, parser):
+ parser.add_argument('path',
+ help="Path to project folder")
+
+ def run(self, args):
+
+ path = os.path.abspath(args.path)
+ if os.path.exists(path):
+ self.stderr.write("path already exists: {}\n".format(path))
+ sys.exit(1)
+
+ # nb. technically these would be required and auto-installed
+ # as needed (later), but seems better to do this explicitly
+ # up-front before any other command output
+ self.require_prompt_toolkit()
+ self.require_rich()
+
+ # welcome, continue?
+ self.rprint("\n\t[blue]Welcome to Rattail[/blue]")
+ self.rprint("\n\tThis tool will generate source code for a new project.")
+ if not self.basic_prompt("continue?", True, is_bool=True):
+ self.rprint()
+ sys.exit(0)
+
+ # name
+ name = os.path.basename(path)
+
+ # app_table_prefix
+ prefix = name
+ prefix = prefix.replace('-', '_')
+ prefix = prefix.replace(' ', '_')
+ app_table_prefix = self.basic_prompt('app_table_prefix',
+ default=prefix)
+ app_table_prefix = app_table_prefix.rstrip('_')
+
+ # app_class_prefix
+ prefix = name
+ prefix = prefix.replace('-', '_')
+ prefix = prefix.replace('_', ' ')
+ app_class_prefix = ''.join([w.capitalize() for w in prefix.split()])
+ app_class_prefix = self.basic_prompt('app_class_prefix',
+ default=app_class_prefix)
+
+ # org_name
+ org_name = self.basic_prompt('org_name', required=True)
+
+ # pypi_name
+ pypi_name = name
+ pypi_name = pypi_name.replace('_', ' ')
+ pypi_name = pypi_name.replace('-', ' ')
+ pypi_name = '-'.join([w.capitalize()
+ for w in org_name.split() + pypi_name.split()])
+ pypi_name = self.basic_prompt('pypi_name', default=pypi_name)
+
+ # app_title
+ app_title = name
+ app_title = app_title.replace('-', ' ')
+ app_title = app_title.replace('_', ' ')
+ app_title = ' '.join([w.capitalize() for w in app_title.split()])
+
+ # generate project
+ project_handler = RattailProjectHandler(self.config)
+ options = {
+ 'name': app_title,
+ 'slug': name,
+ 'organization': org_name,
+ 'python_project_name': pypi_name,
+ 'python_name': app_table_prefix,
+ 'has_db': True,
+ 'extends_db': True,
+ 'has_web': True,
+
+ # TODO: these should not be needed..?
+ 'has_web_api': False,
+ 'has_datasync': False,
+ 'integrates_catapult': False,
+ 'integrates_corepos': False,
+ 'integrates_locsms': False,
+ 'uses_fabric': False,
+ }
+ project_handler.generate_project('rattail', name, options, path=path)
+ self.rprint("\n\tproject created at: [bold green]{}[/bold green]".format(
+ path))
+
+ # install pkg
+ if self.basic_prompt("install project package?", is_bool=True, default=True):
+ subprocess.check_call(['pip', 'install', '-e', path])
+ self.rprint("\n\tpackage installed: [bold green]{}[/bold green]".format(
+ pypi_name))
+
+ self.rprint("\n\tinstall and configure the app with:")
+ self.rprint("\n\t[blue]{} -n install[/blue]".format(name))
+
+ self.rprint()
diff --git a/rattail/data/config/web-complete.conf.mako b/rattail/data/config/web-complete.conf.mako
index dc671c35263767ec69fe848a48a390328599e007..88dafe00a770a5e1a522c261e21db06500bfd375 100644
--- a/rattail/data/config/web-complete.conf.mako
+++ b/rattail/data/config/web-complete.conf.mako
@@ -26,7 +26,11 @@ use = egg:${pyramid_egg}
pyramid.reload_templates = true
pyramid.debug_all = true
pyramid.default_locale_name = en
-# pyramid.includes = pyramid_debugtoolbar
+# TODO: you may want exclog only in production, not dev.
+# also you may want debugtoolbar in dev
+pyramid.includes =
+ pyramid_exclog
+ # pyramid_debugtoolbar
beaker.session.type = file
beaker.session.data_dir = %(here)s/cache/sessions/data
@@ -49,7 +53,7 @@ port = ${pyramid_port}
# NOTE: this is needed for local reverse proxy stuff to work with HTTPS
# https://docs.pylonsproject.org/projects/waitress/en/latest/reverse-proxy.html
# https://docs.pylonsproject.org/projects/waitress/en/latest/arguments.html
-# trusted_proxy = 127.0.0.1
+trusted_proxy = 127.0.0.1
# TODO: leave this empty if proxy serves as root site, e.g. http://rattail.example.com/
# url_prefix =
diff --git a/rattail/db/handler.py b/rattail/db/handler.py
new file mode 100644
index 0000000000000000000000000000000000000000..3877b842488a33f5f67d01683abf33b21b736a1b
--- /dev/null
+++ b/rattail/db/handler.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# Rattail -- Retail Software Framework
+# Copyright © 2010-2022 Lance Edgar
+#
+# This file is part of Rattail.
+#
+# Rattail is free software: you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation, either version 3 of the License, or (at your option) any later
+# version.
+#
+# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Rattail. If not, see .
+#
+################################################################################
+"""
+Database Handler
+"""
+
+from __future__ import unicode_literals, absolute_import
+
+from alembic.config import Config as AlembicConfig
+from alembic.script import ScriptDirectory
+
+from rattail.app import GenericHandler
+
+
+class DatabaseHandler(GenericHandler):
+ """
+ Base class and default implementation for the DB handler.
+ """
+
+ def get_alembic_branch_names(self, **kwargs):
+ """
+ Returns a list of Alembic branch names present in the default
+ database schema.
+ """
+ alembic_config = AlembicConfig()
+ alembic_config.set_main_option(
+ 'script_location',
+ self.config.get('alembic', 'script_location', usedb=False))
+ alembic_config.set_main_option(
+ 'version_locations',
+ self.config.get('alembic', 'version_locations', usedb=False))
+
+ script = ScriptDirectory.from_config(alembic_config)
+
+ branches = set()
+ for rev in script.get_revisions(script.get_heads()):
+ branches.update(rev.branch_labels)
+
+ return sorted(branches)
diff --git a/rattail/projects/handler.py b/rattail/projects/handler.py
index 7312026584b3989a6efb3b90e38d6ff44e0da6d5..b09718a201d3083930904fb496b28f217ab06b0f 100644
--- a/rattail/projects/handler.py
+++ b/rattail/projects/handler.py
@@ -28,6 +28,7 @@ from __future__ import unicode_literals, absolute_import
import os
import random
+import re
import shutil
import string
import subprocess
@@ -287,6 +288,9 @@ class RattailProjectHandler(ProjectHandler):
"""
And here we do some experimentation...
"""
+ from alembic.config import Config as AlembicConfig
+ from alembic.command import revision as alembic_revision
+
context = options
##############################
@@ -329,8 +333,6 @@ class RattailProjectHandler(ProjectHandler):
self.generate('rattail/package/emails.py.mako', os.path.join(package, 'emails.py'),
**context)
- self.generate('rattail/package/settings.py', os.path.join(package, 'settings.py'))
-
##############################
# data dir
##############################
@@ -380,17 +382,56 @@ class RattailProjectHandler(ProjectHandler):
alembic = os.path.join(db, 'alembic')
os.makedirs(alembic)
- self.generate('rattail/package/db/alembic/README', os.path.join(alembic, 'README'))
-
self.generate('rattail/package/db/alembic/env.py.mako', os.path.join(alembic, 'env.py'),
**context)
- self.generate('rattail/package/db/alembic/script.py.mako_', os.path.join(alembic, 'script.py.mako'))
-
versions = os.path.join(alembic, 'versions')
os.makedirs(versions)
- self.generate('rattail/package/db/alembic/versions/.keepme', os.path.join(versions, '.keepme'))
+ # make alembic config, aware of new project versions folder
+ alembic_config = AlembicConfig()
+ alembic_config.set_main_option('script_location',
+ 'rattail.db:alembic')
+ alembic_config.set_main_option('version_locations',
+ '{} rattail.db:alembic/versions'.format(
+ versions))
+
+ # generate first revision script for new project
+ script = alembic_revision(alembic_config,
+ version_path=versions,
+ head='rattail@head',
+ splice=True,
+ branch_label=context['python_name'],
+ message="add {} branch".format(context['python_name']))
+
+ # declare `down_revision = None` ..no way to tell alembic
+ # to do that apparently, so we must rewrite file
+ with open(script.path, 'rt') as f:
+ old_contents = f.read()
+ new_contents = []
+ for line in old_contents.split('\n'):
+ if line.startswith('down_revision ='):
+ line = re.sub(r"'\w+'", 'None', line)
+ new_contents.append(line)
+ with open(script.path, 'wt') as f:
+ f.write('\n'.join(new_contents))
+
+ ##############################
+ # templates
+ ##############################
+
+ templates = os.path.join(package, 'templates')
+ os.makedirs(templates)
+
+ installer = os.path.join(templates, 'installer')
+ os.makedirs(installer)
+
+ self.generate('rattail/package/templates/installer/rattail.conf.mako_tmpl',
+ os.path.join(installer, 'rattail.conf.mako'),
+ **context)
+
+ self.generate('rattail/package/templates/installer/upgrade.sh.mako_',
+ os.path.join(installer, 'upgrade.sh.mako'))
##############################
# web package dir
@@ -418,10 +459,11 @@ class RattailProjectHandler(ProjectHandler):
self.generate('rattail/package/web/static/__init__.py.mako', os.path.join(static, '__init__.py'),
**context)
- templates = os.path.join(web, 'templates')
- os.makedirs(templates)
+ web_templates = os.path.join(web, 'templates')
+ os.makedirs(web_templates)
- self.generate('rattail/package/web/templates/base_meta.mako_tmpl', os.path.join(templates, 'base_meta.mako'),
+ self.generate('rattail/package/web/templates/base_meta.mako_tmpl',
+ os.path.join(web_templates, 'base_meta.mako'),
**context)
views = os.path.join(web, 'views')
diff --git a/rattail/projects/rattail/gitignore.mako b/rattail/projects/rattail/gitignore.mako
index 5b4e2a850d70fdd53f6c09b922b06d7d6cbbb2e3..8029460421f27a5d160b438d837e63aa43c7e8d8 100644
--- a/rattail/projects/rattail/gitignore.mako
+++ b/rattail/projects/rattail/gitignore.mako
@@ -1,4 +1,6 @@
${egg_name}.egg-info/
+% if uses_fabric:
machines/*/.vagrant/
machines/*/fabenv.py
machines/*/fabric.yaml
+% endif
diff --git a/rattail/projects/rattail/package/commands.py.mako b/rattail/projects/rattail/package/commands.py.mako
index 9e5804b9e0315401e337b528c42e69033c859f12..99103f35941b4ab3bed58585709418e3432d2a69 100644
--- a/rattail/projects/rattail/package/commands.py.mako
+++ b/rattail/projects/rattail/package/commands.py.mako
@@ -3,9 +3,15 @@
${name} commands
"""
+import os
+import stat
+import subprocess
import sys
+from alembic.util.messaging import obfuscate_url_pw
+
from rattail import commands
+from rattail.files import resource_path
from ${python_name} import __version__
@@ -38,3 +44,222 @@ class HelloWorld(commands.Subcommand):
def run(self, args):
self.stdout.write("hello world!\n")
+
+
+class Install(commands.Subcommand):
+ """
+ Install the ${name} app
+ """
+ name = 'install'
+ description = __doc__.strip()
+
+ def run(self, args):
+
+ # nb. technically these would be required and auto-installed
+ # as needed (later), but seems better to do this explicitly
+ # up-front before any other command output
+ self.require_prompt_toolkit()
+ self.require_rich()
+
+ self.rprint("\n\t[blue]Welcome to ${name}![/blue]")
+ self.rprint("\n\tThis tool will install and configure a new app.")
+ self.rprint("\n\t[italic]NB. You should already have created a new database in PostgreSQL or MySQL.[/italic]")
+
+ # continue?
+ if not self.basic_prompt("continue?", True, is_bool=True):
+ self.rprint()
+ sys.exit(0)
+
+ # appdir must not yet exist
+ appdir = os.path.join(sys.prefix, 'app')
+ if os.path.exists(appdir):
+ self.rprint("\n\t[bold red]appdir already exists:[/bold red] {}\n".format(appdir))
+ sys.exit(1)
+
+ # get db info
+ dbtype = self.basic_prompt('db type', 'postgresql')
+ dbhost = self.basic_prompt('db host', 'localhost')
+ dbport = self.basic_prompt('db port', '3306' if dbtype == 'mysql' else '5432')
+ dbname = self.basic_prompt('db name', '${python_name}')
+ dbuser = self.basic_prompt('db user', 'rattail')
+
+ # get db password
+ dbpass = None
+ while not dbpass:
+ dbpass = self.basic_prompt('db pass', is_password=True)
+
+ # test db connection
+ self.rprint("\n\ttesting db connection... ", end='')
+ dburl = self.make_db_url(dbtype, dbhost, dbport, dbname, dbuser, dbpass)
+ error = self.test_db_connection(dburl)
+ if error:
+ self.rprint("[bold red]cannot connect![/bold red] ..error was:")
+ self.rprint("\n{}".format(error))
+ self.rprint("\n\t[bold yellow]aborting mission[/bold yellow]\n")
+ sys.exit(1)
+ self.rprint("[bold green]good[/bold green]")
+
+ # make the appdir
+ self.app.make_appdir(appdir)
+
+ # shared context for generated app files
+ context = {
+ 'envdir': sys.prefix,
+ 'app_package': '${python_name}',
+ 'app_title': "${name}",
+ 'pypi_name': '${python_project_name}',
+ 'appdir': appdir,
+ 'db_url': dburl,
+ 'pyramid_egg': '${egg_name}',
+ 'beaker_key': '${python_name}',
+ }
+
+ # make config files
+ rattail_conf = self.app.make_config_file(
+ 'rattail', os.path.join(appdir, 'rattail.conf'),
+ template_path=resource_path('${python_name}:templates/installer/rattail.conf.mako'),
+ **context)
+ quiet_conf = self.app.make_config_file('quiet', appdir)
+ web_conf = self.app.make_config_file(
+ 'web-complete', os.path.join(appdir, 'web.conf'),
+ **context)
+
+ # make upgrade script
+ path = os.path.join(appdir, 'upgrade.sh')
+ self.app.render_mako_template(
+ resource_path('${python_name}:templates/installer/upgrade.sh.mako'),
+ context, output_path=path)
+ os.chmod(path, stat.S_IRWXU
+ | stat.S_IRGRP
+ | stat.S_IXGRP
+ | stat.S_IROTH
+ | stat.S_IXOTH)
+
+ self.rprint("\n\tappdir created at: [bold green]{}[/bold green]".format(appdir))
+
+ bindir = os.path.join(sys.prefix, 'bin')
+
+ schema_installed = False
+ if self.basic_prompt("install db schema?", True, is_bool=True):
+ self.rprint()
+
+ # install db schema
+ alembic = os.path.join(bindir, 'alembic')
+ cmd = [alembic, '-c', rattail_conf, 'upgrade', 'heads']
+ subprocess.check_call(cmd)
+ schema_installed = True
+
+ rattail = os.path.join(bindir, 'rattail')
+
+ # set falafel theme
+ cmd = [rattail, '-c', quiet_conf, '--no-versioning',
+ 'setting-put', 'tailbone.theme', 'falafel']
+ subprocess.check_call(cmd)
+
+ # hide theme picker
+ cmd = [rattail, '-c', quiet_conf, '--no-versioning',
+ 'setting-put', 'tailbone.themes.expose_picker', 'false']
+ subprocess.check_call(cmd)
+
+ # set main image
+ cmd = [rattail, '-c', quiet_conf, '--no-versioning',
+ 'setting-put', 'tailbone.main_image_url', '/tailbone/img/home_logo.png']
+ subprocess.check_call(cmd)
+
+ # set header image
+ cmd = [rattail, '-c', quiet_conf, '--no-versioning',
+ 'setting-put', 'tailbone.header_image_url', '/tailbone/img/rattail.ico']
+ subprocess.check_call(cmd)
+
+ # set favicon image
+ cmd = [rattail, '-c', quiet_conf, '--no-versioning',
+ 'setting-put', 'tailbone.favicon_url', '/tailbone/img/rattail.ico']
+ subprocess.check_call(cmd)
+
+ # set vue version
+ cmd = [rattail, '-c', quiet_conf, '--no-versioning',
+ 'setting-put', 'tailbone.vue_version', '2.6.14']
+ subprocess.check_call(cmd)
+
+ # set buefy version
+ cmd = [rattail, '-c', quiet_conf, '--no-versioning',
+ 'setting-put', 'tailbone.buefy_version', 'latest']
+ subprocess.check_call(cmd)
+
+ # set default grid page size
+ cmd = [rattail, '-c', quiet_conf, '--no-versioning',
+ 'setting-put', 'tailbone.grid.default_pagesize', '20']
+ subprocess.check_call(cmd)
+
+ self.rprint("\n\tdb schema installed to: [bold green]{}[/bold green]".format(
+ obfuscate_url_pw(dburl)))
+
+ if self.basic_prompt("create admin user?", True, is_bool=True):
+
+ # get admin credentials
+ username = self.basic_prompt('admin username', 'admin')
+ password = None
+ while not password:
+ password = self.basic_prompt('admin password', is_password=True)
+ if password:
+ confirm = self.basic_prompt('confirm password', is_password=True)
+ if not confirm or confirm != password:
+ self.rprint("[bold yellow]passwords did not match[/bold yellow]")
+ password = None
+ fullname = self.basic_prompt('full name')
+
+ self.rprint()
+
+ # make admin user
+ rattail = os.path.join(bindir, 'rattail')
+ cmd = [rattail, '-c', quiet_conf, 'make-user', '-A', username,
+ '--password', password]
+ if fullname:
+ cmd.extend(['--full-name', fullname])
+ subprocess.check_call(cmd)
+
+ self.rprint("\n\tadmin user created: [bold green]{}[/bold green]".format(
+ username))
+
+ self.rprint("\n\t[bold green]initial setup is complete![/bold green]")
+
+ if schema_installed:
+ self.rprint("\n\tyou can run the web app with:")
+ self.rprint("\n\t[blue]cd {}[/blue]".format(sys.prefix))
+ self.rprint("\t[blue]bin/pserve file+ini:app/web.conf[/blue]")
+
+ self.rprint()
+
+ def make_db_url(self, dbtype, dbhost, dbport, dbname, dbuser, dbpass):
+ try:
+ # newer style
+ from sqlalchemy.engine import URL
+ factory = URL.create
+ except ImportError:
+ # older style
+ from sqlalchemy.engine.url import URL
+ factory = URL
+
+ if dbtype == 'mysql':
+ drivername = 'mysql+mysqlconnector'
+ else:
+ drivername = 'postgresql+psycopg2'
+
+ return factory(drivername=drivername,
+ username=dbuser,
+ password=dbpass,
+ host=dbhost,
+ port=dbport,
+ database=dbname)
+
+ def test_db_connection(self, url):
+ from sqlalchemy import create_engine
+
+ engine = create_engine(url)
+
+ # check for random table; does not matter if it exists, we
+ # just need to test interaction and this is a neutral way
+ try:
+ engine.has_table('whatever')
+ except Exception as error:
+ return str(error)
diff --git a/rattail/projects/rattail/package/config.py.mako b/rattail/projects/rattail/package/config.py.mako
index 6ab12ab4ccf399742ccbea3790fe65fb1615446f..6d8c3ea4796a85f90ebde5a7e968b80dd9a9f6ff 100644
--- a/rattail/projects/rattail/package/config.py.mako
+++ b/rattail/projects/rattail/package/config.py.mako
@@ -16,7 +16,6 @@ class ${studly_name}Config(ConfigExtension):
# set some default config values
config.setdefault('rattail.mail', 'emails', '${python_name}.emails')
- config.setdefault('rattail', 'settings', '${python_name}.settings')
config.setdefault('tailbone', 'menus', '${python_name}.web.menus')
config.setdefault('rattail.config', 'templates', '${python_name}:data/config rattail:data/config')
diff --git a/rattail/projects/rattail/package/db/alembic/README b/rattail/projects/rattail/package/db/alembic/README
deleted file mode 100644
index 98e4f9c44effe479ed38c66ba922e7bcc672916f..0000000000000000000000000000000000000000
--- a/rattail/projects/rattail/package/db/alembic/README
+++ /dev/null
@@ -1 +0,0 @@
-Generic single-database configuration.
\ No newline at end of file
diff --git a/rattail/projects/rattail/package/db/alembic/script.py.mako_ b/rattail/projects/rattail/package/db/alembic/script.py.mako_
deleted file mode 100644
index 70d02548df89ff3d52d96cb17fd4e289515954a2..0000000000000000000000000000000000000000
--- a/rattail/projects/rattail/package/db/alembic/script.py.mako_
+++ /dev/null
@@ -1,30 +0,0 @@
-# -*- coding: utf-8; mode: python; -*-
-# -*- coding: utf-8 -*-
-"""${message}
-
-Revision ID: ${up_revision}
-Revises: ${down_revision | comma,n}
-Create Date: ${create_date}
-
-"""
-
-from __future__ import unicode_literals, absolute_import
-
-# revision identifiers, used by Alembic.
-revision = ${repr(up_revision)}
-down_revision = ${repr(down_revision)}
-branch_labels = ${repr(branch_labels)}
-depends_on = ${repr(depends_on)}
-
-from alembic import op
-import sqlalchemy as sa
-import rattail.db.types
-${imports if imports else ""}
-
-
-def upgrade():
- ${upgrades if upgrades else "pass"}
-
-
-def downgrade():
- ${downgrades if downgrades else "pass"}
diff --git a/rattail/projects/rattail/package/db/alembic/versions/.keepme b/rattail/projects/rattail/package/db/alembic/versions/.keepme
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/rattail/projects/rattail/package/settings.py b/rattail/projects/rattail/package/settings.py
deleted file mode 100644
index 5cdeb943817c78a577ac8018d860ad0096999025..0000000000000000000000000000000000000000
--- a/rattail/projects/rattail/package/settings.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# -*- coding: utf-8; -*-
-"""
-App Settings
-"""
-
-from rattail.settings import Setting
-
-
-# bring in some common settings from rattail
-from rattail.settings import (
-
- # (General)
- rattail_app_title,
- tailbone_background_color,
-
- # Email
- rattail_mail_record_attempts,
-
- # Product
- rattail_product_key,
- rattail_product_key_title,
- tailbone_products_show_pod_image,
-
- # Purchasing / Receiving
- rattail_batch_purchase_allow_cases,
- rattail_batch_purchase_allow_expired_credits,
-)
diff --git a/rattail/projects/rattail/package/templates/installer/rattail.conf.mako_tmpl b/rattail/projects/rattail/package/templates/installer/rattail.conf.mako_tmpl
new file mode 100644
index 0000000000000000000000000000000000000000..09ac259c44174be76577beee0961bb9b30ee7d5f
--- /dev/null
+++ b/rattail/projects/rattail/package/templates/installer/rattail.conf.mako_tmpl
@@ -0,0 +1,147 @@
+## -*- mode: conf; -*-
+
+<%%text>############################################################%%text>
+#
+# ${app_title} core config
+#
+<%%text>############################################################%%text>
+
+
+<%%text>##############################%%text>
+# rattail
+<%%text>##############################%%text>
+
+[rattail]
+app_title = ${app_title}
+app_package = ${app_package}
+timezone.default = ${timezone}
+appdir = ${appdir}
+datadir = ${os.path.join(appdir, 'data')}
+batch.files = ${os.path.join(appdir, 'data', 'batch')}
+workdir = ${os.path.join(appdir, 'work')}
+export.files = ${os.path.join(appdir, 'data', 'exports')}
+
+[rattail.config]
+# require = /etc/rattail/rattail.conf
+configure_logging = true
+usedb = true
+preferdb = true
+
+[rattail.db]
+default.url = ${db_url}
+versioning.enabled = true
+
+[rattail.mail]
+
+# this is the global email shutoff switch
+#send_emails = false
+
+# recommended setup is to always talk to postfix on localhost and then
+# it can handle any need complexities, e.g. sending to relay
+smtp.server = localhost
+
+# by default only email templates from rattail proper are used
+templates = rattail:templates/mail
+
+# this is the "default" email profile, from which all others initially
+# inherit, but most/all profiles will override these values
+default.prefix = [${app_title}]
+default.from = rattail@localhost
+default.to = root@localhost
+# nb. in test environment it can be useful to disable by default, and
+# then selectively enable certain (e.g. feedback, upgrade) emails
+#default.enabled = false
+
+[rattail.upgrades]
+command = ${os.path.join(appdir, 'upgrade.sh')} --verbose
+files = ${os.path.join(appdir, 'data', 'upgrades')}
+
+
+<%%text>##############################%%text>
+# alembic
+<%%text>##############################%%text>
+
+[alembic]
+script_location = rattail.db:alembic
+version_locations = %(alembic_version_locations)s
+
+
+<%%text>##############################%%text>
+# logging
+<%%text>##############################%%text>
+
+[loggers]
+keys = root, exc_logger, beaker, txn, sqlalchemy, django_db, flufl_bounce, requests
+
+[handlers]
+keys = file, console, email
+
+[formatters]
+keys = generic, console
+
+[logger_root]
+handlers = file, console
+level = DEBUG
+
+[logger_exc_logger]
+qualname = exc_logger
+handlers = email
+level = ERROR
+
+[logger_beaker]
+qualname = beaker
+handlers =
+level = INFO
+
+[logger_txn]
+qualname = txn
+handlers =
+level = INFO
+
+[logger_sqlalchemy]
+qualname = sqlalchemy.engine
+handlers =
+# handlers = file
+# level = INFO
+
+[logger_django_db]
+qualname = django.db.backends
+handlers =
+level = INFO
+# level = DEBUG
+
+[logger_flufl_bounce]
+qualname = flufl.bounce
+handlers =
+level = WARNING
+
+[logger_requests]
+qualname = requests
+handlers =
+# level = WARNING
+
+[handler_file]
+class = handlers.RotatingFileHandler
+args = (${repr(os.path.join(appdir, 'log', 'rattail.log'))}, 'a', 1000000, 100, 'utf_8')
+formatter = generic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+formatter = console
+# formatter = generic
+# level = INFO
+# level = WARNING
+
+[handler_email]
+class = handlers.SMTPHandler
+args = ('localhost', 'rattail@localhost', ['root@localhost'], "[Rattail] Logging")
+formatter = generic
+level = ERROR
+
+[formatter_generic]
+format = %%(asctime)s %%(levelname)-5.5s [%%(name)s][%%(threadName)s] %%(funcName)s: %%(message)s
+datefmt = %%Y-%%m-%%d %%H:%%M:%%S
+
+[formatter_console]
+format = %%(levelname)-5.5s [%%(name)s][%%(threadName)s] %%(funcName)s: %%(message)s
diff --git a/rattail/projects/rattail/package/templates/installer/upgrade.sh.mako_ b/rattail/projects/rattail/package/templates/installer/upgrade.sh.mako_
new file mode 100755
index 0000000000000000000000000000000000000000..81c1d0a8050440b8b8f03e675aabb5945e10615e
--- /dev/null
+++ b/rattail/projects/rattail/package/templates/installer/upgrade.sh.mako_
@@ -0,0 +1,29 @@
+#!/bin/sh -e
+<%text>##################################################%text>
+#
+# upgrade script for ${app_title} app
+#
+<%text>##################################################%text>
+
+if [ "$1" = "--verbose" ]; then
+ VERBOSE='--verbose'
+ QUIET=
+else
+ VERBOSE=
+ QUIET='--quiet'
+fi
+
+cd ${envdir}
+
+PIP='bin/pip'
+ALEMBIC='bin/alembic'
+
+# upgrade pip and friends
+$PIP install $QUIET --disable-pip-version-check --upgrade pip
+$PIP install $QUIET --upgrade setuptools wheel
+
+# upgrade app proper
+$PIP install $QUIET --upgrade --upgrade-strategy eager ${pypi_name}
+
+# migrate schema
+$ALEMBIC -c app/rattail.conf upgrade heads
diff --git a/rattail/projects/rattail/package/web/views/__init__.py.mako b/rattail/projects/rattail/package/web/views/__init__.py.mako
index 05f07be49daebf6e6c5f378be0b2947ace2ebeea..ecc896d9c31a26864a38407d22993e87d661cd1f 100644
--- a/rattail/projects/rattail/package/web/views/__init__.py.mako
+++ b/rattail/projects/rattail/package/web/views/__init__.py.mako
@@ -70,5 +70,5 @@ def includeme(config):
config.include('tailbone.views.purchasing')
# batch views
- config.include('tailbone.views.handheld')
+ config.include('tailbone.views.batch.handheld')
config.include('tailbone.views.batch.inventory')
diff --git a/rattail/projects/rattail/setup.py.mako b/rattail/projects/rattail/setup.py.mako
index 31e5402ad004332a1c7a670bf995b63f6e0fb35d..bb47e325fd4880d0a4d7bdc5b2386cecffd9d17e 100644
--- a/rattail/projects/rattail/setup.py.mako
+++ b/rattail/projects/rattail/setup.py.mako
@@ -44,7 +44,9 @@ requires = [
'invoke', # 1.4.1
'rattail[auth,db,bouncer]', # 0.9.141
+ % if uses_fabric:
'rattail-fabric2', # 0.2.1
+ % endif
% if has_db:
'psycopg2', # 2.8.4
@@ -119,6 +121,7 @@ setup(
'${python_name}.commands': [
'hello = ${python_name}.commands:HelloWorld',
+ 'install = ${python_name}.commands:Install',
],
% if has_web:
diff --git a/rattail/settings.py b/rattail/settings.py
index 0030a4a12592e6e51fdac83dfdbb796c18072ba8..4fd4dc0257878f46862c6e6b641e853e3368c407 100644
--- a/rattail/settings.py
+++ b/rattail/settings.py
@@ -30,10 +30,16 @@ from __future__ import unicode_literals, absolute_import
class Setting(object):
"""
Base class for all setting definitions.
+
+ .. attribute:: core
+
+ Boolean indicating if this is a "core setting" - i.e. one which
+ should be exposed in the App Settings in most/all apps.
"""
group = "(General)"
namespace = None
name = None
+ core = False
data_type = str
choices = None
required = False
@@ -49,6 +55,29 @@ class rattail_app_title(Setting):
"""
namespace = 'rattail'
name = 'app_title'
+ core = True
+
+
+class rattail_app_class_prefix(Setting):
+ """
+ App-specific "capwords-style" prefix, used for naming model
+ classes and other things. E.g. with prefix of 'Poser', model
+ might be named 'PoserWidget'.
+ """
+ namespace = 'rattail'
+ name = 'app_class_prefix'
+ core = True
+
+
+class rattail_app_table_prefix(Setting):
+ """
+ App-specific "variable-style" prefix, used for naming tables and
+ other things. E.g. with prefix of 'poser', table might be named
+ 'poser_widget'.
+ """
+ namespace = 'rattail'
+ name = 'app_table_prefix'
+ core = True
class rattail_node_title(Setting):
@@ -68,6 +97,7 @@ class rattail_production(Setting):
namespace = 'rattail'
name = 'production'
data_type = bool
+ core = True
class tailbone_background_color(Setting):
@@ -87,6 +117,16 @@ class tailbone_buefy_version(Setting):
"""
namespace = 'tailbone'
name = 'buefy_version'
+ core = True
+
+
+class tailbone_favicon_url(Setting):
+ """
+ URL of favicon image.
+ """
+ namespace = 'tailbone'
+ name = 'favicon_url'
+ core = True
class tailbone_feedback_allows_reply(Setting):
@@ -106,6 +146,25 @@ class tailbone_grid_default_pagesize(Setting):
namespace = 'tailbone'
name = 'grid.default_pagesize'
data_type = int
+ core = True
+
+
+class tailbone_header_image_url(Setting):
+ """
+ URL of smaller logo image, shown in menu header.
+ """
+ namespace = 'tailbone'
+ name = 'header_image_url'
+ core = True
+
+
+class tailbone_main_image_url(Setting):
+ """
+ URL of main app logo image, e.g. on home/login page.
+ """
+ namespace = 'tailbone'
+ name = 'main_image_url'
+ core = True
class tailbone_sticky_headers(Setting):
@@ -128,6 +187,7 @@ class tailbone_vue_version(Setting):
"""
namespace = 'tailbone'
name = 'vue_version'
+ core = True
class rattail_single_store(Setting):
diff --git a/setup.py b/setup.py
index 908f55c662258f6d2e0cdbcaa66ef444e65f962d..3570c12232764ed8b68f8755bd84d49b5d1d53cd 100644
--- a/setup.py
+++ b/setup.py
@@ -225,6 +225,7 @@ setup(
'make-appdir = rattail.commands.core:MakeAppDir',
'make-batch = rattail.commands.batch:MakeBatch',
'make-config = rattail.commands.core:MakeConfig',
+ 'make-project = rattail.commands.projects:MakeProject',
'make-user = rattail.commands.core:MakeUser',
'make-uuid = rattail.commands.core:MakeUUID',
'mysql-chars = rattail.commands.mysql:MysqlChars',