Compare commits

...
Sign in to create a new pull request.

37 commits
master ... v0.4

Author SHA1 Message Date
Lance Edgar
742924596d add initdb command 2012-12-26 16:27:16 -08:00
Lance Edgar
ba2b6db75d convenience commit 2012-12-26 10:08:11 -08:00
Lance Edgar
d2725c2fb3 fix filemon service 2012-12-18 09:26:20 -08:00
Lance Edgar
b0ac9bc7eb add config inheritance; update tests etc. 2012-12-18 08:40:36 -08:00
Lance Edgar
c91d98a609 more stuff from edbob 2012-12-15 10:06:37 -08:00
Lance Edgar
5bf93ef57c remove edbob dependency 2012-12-15 09:52:32 -08:00
Lance Edgar
594c58065c Merge branch '0.3a4'
Conflicts:
	CHANGES.txt
	rattail/_version.py
	rattail/db/extension/model.py
2012-12-15 08:32:42 -08:00
Lance Edgar
9c764ea240 add purge-batches command, etc. 2012-12-14 09:05:17 -08:00
Lance Edgar
1d238f3575 more batch stuff, and tests (savepoint) 2012-12-13 15:13:08 -08:00
Lance Edgar
9dd8e11a79 start of new batch framework, lots more tests (savepoint) 2012-12-13 13:39:08 -08:00
Lance Edgar
8168c06192 savepoint 2012-12-10 21:39:48 -08:00
Lance Edgar
3a21fa23bf overhaul commands, db.load (plus tests/docs) 2012-12-08 12:05:16 -08:00
Lance Edgar
2f63fed30a bump version 2012-12-08 08:13:43 -08:00
Lance Edgar
4e93f3b93a massive db commit (refactor/tests/docs) 2012-12-07 23:54:29 -08:00
Lance Edgar
8bf44f3ded add initialization mod/tests/docs 2012-12-06 21:12:42 -08:00
Lance Edgar
c4072c7ac0 add logging config 2012-12-06 15:21:31 -08:00
Lance Edgar
ade17c5f90 add modules mod/tests/docs 2012-12-06 14:13:56 -08:00
Lance Edgar
8007343b3a add db.types mod/tests/docs 2012-12-05 15:48:42 -08:00
Lance Edgar
405d78fb40 add sil.writer tests/docs 2012-12-05 14:17:22 -08:00
Lance Edgar
9624cb8fe4 add pricing tests/docs 2012-12-05 12:12:27 -08:00
Lance Edgar
c07438837e add sil.batches tests/docs 2012-12-05 11:40:00 -08:00
Lance Edgar
ff0eb3cf80 add configuration mod/tests/docs 2012-12-05 11:23:20 -08:00
Lance Edgar
0471180ee9 add sil.columns docs, tests 2012-12-03 10:46:32 -08:00
Lance Edgar
6e43e03b95 add gpc deprecation, tests 2012-12-03 08:47:55 -08:00
Lance Edgar
5eae6aa7ec add db.util 2012-12-02 16:57:28 -08:00
Lance Edgar
09ba2a7c3c add init tests (not very many/good though) 2012-12-02 07:35:21 -08:00
Lance Edgar
717496d58a add files module 2012-12-02 07:18:43 -08:00
Lance Edgar
f32bd6946c finish barcode tests 2012-12-01 14:19:27 -08:00
Lance Edgar
d1df2aa368 add initial tests 2012-12-01 12:28:07 -08:00
Lance Edgar
36ab26dfaf add initial docs 2012-12-01 12:18:37 -08:00
Lance Edgar
6be40fd4e3 refactor barcodes in prep for docs/tests 2012-12-01 12:05:10 -08:00
Lance Edgar
25096dc70d add Department.subdepartments relationship 2012-11-28 15:45:50 -08:00
Lance Edgar
7106f42461 update changelog 2012-09-18 11:40:41 -07:00
Lance Edgar
07e3b4afb8 merge 2012-09-18 11:23:34 -07:00
Lance Edgar
3f337e8608 bump version 2012-09-18 11:11:23 -07:00
Lance Edgar
ad9777adb6 add delete-orphan to Vendor.contacts relationship
(cherry picked from commit 3fd6c9212e)
2012-09-18 11:10:14 -07:00
Lance Edgar
affd0acf4c add .gitignore
(cherry picked from commit 36584bf919)
2012-09-18 11:10:00 -07:00
113 changed files with 12798 additions and 1571 deletions

View file

@ -1 +1,9 @@
include *.txt
recursive-include rattail/templates *.mako
include docs/Makefile
include docs/make.bat
include docs/_static/.dummy
recursive-include docs *.rst
prune docs/_build

153
docs/Makefile Normal file
View file

@ -0,0 +1,153 @@
# Makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
PAPER =
BUILDDIR = _build
# Internal variables.
PAPEROPT_a4 = -D latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
# the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
help:
@echo "Please use \`make <target>' where <target> is one of"
@echo " html to make standalone HTML files"
@echo " dirhtml to make HTML files named index.html in directories"
@echo " singlehtml to make a single large HTML file"
@echo " pickle to make pickle files"
@echo " json to make JSON files"
@echo " htmlhelp to make HTML files and a HTML help project"
@echo " qthelp to make HTML files and a qthelp project"
@echo " devhelp to make HTML files and a Devhelp project"
@echo " epub to make an epub"
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
@echo " latexpdf to make LaTeX files and run them through pdflatex"
@echo " text to make text files"
@echo " man to make manual pages"
@echo " texinfo to make Texinfo files"
@echo " info to make Texinfo files and run them through makeinfo"
@echo " gettext to make PO message catalogs"
@echo " changes to make an overview of all changed/added/deprecated items"
@echo " linkcheck to check all external links for integrity"
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
clean:
-rm -rf $(BUILDDIR)/*
html:
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
singlehtml:
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
@echo
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
pickle:
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
@echo
@echo "Build finished; now you can process the pickle files."
json:
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
@echo
@echo "Build finished; now you can process the JSON files."
htmlhelp:
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
@echo
@echo "Build finished; now you can run HTML Help Workshop with the" \
".hhp project file in $(BUILDDIR)/htmlhelp."
qthelp:
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
@echo
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/rattail.qhcp"
@echo "To view the help file:"
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/rattail.qhc"
devhelp:
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
@echo
@echo "Build finished."
@echo "To view the help file:"
@echo "# mkdir -p $$HOME/.local/share/devhelp/rattail"
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/rattail"
@echo "# devhelp"
epub:
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
@echo
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
latex:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
@echo "Run \`make' in that directory to run these through (pdf)latex" \
"(use \`make latexpdf' here to do that automatically)."
latexpdf:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through pdflatex..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
text:
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
@echo
@echo "Build finished. The text files are in $(BUILDDIR)/text."
man:
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
@echo
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
texinfo:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
@echo "Run \`make' in that directory to run these through makeinfo" \
"(use \`make info' here to do that automatically)."
info:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo "Running Texinfo files through makeinfo..."
make -C $(BUILDDIR)/texinfo info
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
gettext:
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
@echo
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
changes:
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
@echo
@echo "The overview file is in $(BUILDDIR)/changes."
linkcheck:
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
@echo
@echo "Link check complete; look for any errors in the above output " \
"or in $(BUILDDIR)/linkcheck/output.txt."
doctest:
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
@echo "Testing of doctests in the sources finished, look at the " \
"results in $(BUILDDIR)/doctest/output.txt."

0
docs/_static/.dummy vendored Normal file
View file

21
docs/barcodes.rst Normal file
View file

@ -0,0 +1,21 @@
``rattail.barcodes``
====================
.. automodule:: rattail.barcodes
Check Digit Calculation
-----------------------
.. autofunction:: upc_check_digit
.. autofunction:: price_check_digit
.. autofunction:: luhn_check_digit
Barcode Conversion
------------------
.. autofunction:: upce_to_upca

243
docs/conf.py Normal file
View file

@ -0,0 +1,243 @@
# -*- coding: utf-8 -*-
#
# rattail documentation build configuration file, created by
# sphinx-quickstart on Sat Dec 01 11:14:03 2012.
#
# This file is execfile()d with the current directory set to its containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
import sys, os
import rattail
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.'))
# -- General configuration -----------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode']
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'rattail'
copyright = u'2012, Lance Edgar'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '0.3'
# The full version, including alpha/beta/rc tags.
release = rattail.__version__
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# -- Options for HTML output ---------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'default'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'rattaildoc'
# -- Options for LaTeX output --------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('index', 'rattail.tex', u'rattail Documentation',
u'Lance Edgar', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output --------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'rattail', u'rattail Documentation',
[u'Lance Edgar'], 1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
# -- Options for Texinfo output ------------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'rattail', u'rattail Documentation',
u'Lance Edgar', 'rattail', 'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'

37
docs/configuration.rst Normal file
View file

@ -0,0 +1,37 @@
``rattaill.configuration``
==========================
.. automodule:: rattail.configuration
Storage API
-----------
.. autoclass:: Storage
:members:
.. autoclass:: UserConfigFileStorage
.. automethod:: __init__
Configuration Parser
--------------------
.. autoclass:: RattailConfigParser
:members:
Default File Locations
----------------------
.. autofunction:: default_system_paths
.. autofunction:: default_user_paths
Logging
-------
.. autofunction:: basic_logging

22
docs/core.rst Normal file
View file

@ -0,0 +1,22 @@
``rattail``
===========
.. automodule:: rattail
Initialization
--------------
.. autofunction:: init
.. autofunction:: init_modules
Core Classes
------------
.. autoclass:: GPC
.. automethod:: __init__

519
docs/db_model.rst Normal file
View file

@ -0,0 +1,519 @@
``rattail.db.model``
====================
.. automodule:: rattail.db.model
Model Classes
-------------
.. autoclass:: Batch
Attributes:
.. autoattribute:: id
.. autoattribute:: source
.. autoattribute:: destination
.. autoattribute:: action_type
.. autoattribute:: description
.. autoattribute:: purge
.. autoattribute:: executed
.. autoattribute:: columns
.. autoattribute:: rowclass
.. autoattribute:: rowcount
.. autoattribute:: rows
Methods:
.. automethod:: create_table
.. automethod:: drop_table
.. automethod:: add_column
.. automethod:: add_row
.. autoclass:: BatchColumn
Attributes:
.. autoattribute:: batch
.. autoattribute:: ordinal
.. autoattribute:: name
.. autoattribute:: sil_name
.. autoattribute:: data_type
.. autoattribute:: display_name
.. autoattribute:: description
.. autoattribute:: visible
.. autoclass:: BatchRow
.. autoclass:: Brand
Attributes:
.. autoattribute:: name
.. autoclass:: Category
Attributes:
.. autoattribute:: number
.. autoattribute:: name
.. autoattribute:: department
.. autoclass:: Change
Attributes:
.. autoattribute:: class_name
.. autoattribute:: deleted
.. autoclass:: Customer
Attributes:
.. autoattribute:: id
.. autoattribute:: name
.. autoattribute:: email_preference
.. autoattribute:: phones
.. autoattribute:: phone
.. autoattribute:: emails
.. autoattribute:: email
.. autoattribute:: people
.. autoattribute:: _people
.. autoattribute:: person
.. autoattribute:: _person
.. autoattribute:: groups
.. autoattribute:: _groups
Methods:
.. automethod:: add_email_address
.. automethod:: add_phone_number
.. autoclass:: CustomerEmailAddress
Attributes:
.. autoattribute:: type
.. autoattribute:: address
.. autoattribute:: preference
.. autoclass:: CustomerGroup
Attributes:
.. autoattribute:: id
.. autoattribute:: name
.. autoclass:: CustomerGroupAssignment
Attributes:
.. autoattribute:: customer
.. autoattribute:: group
.. autoclass:: CustomerPerson
Attributes:
.. autoattribute:: customer
.. autoattribute:: person
.. autoattribute:: ordinal
.. autoclass:: CustomerPhoneNumber
Attributes:
.. autoattribute:: type
.. autoattribute:: number
.. autoattribute:: preference
.. autoclass:: Department
Attributes:
.. autoattribute:: number
.. autoattribute:: name
.. autoattribute:: subdepartments
.. autoclass:: Employee
Attributes:
.. autoattribute:: id
.. autoattribute:: person
.. autoattribute:: first_name
.. autoattribute:: last_name
.. autoattribute:: phones
.. autoattribute:: phone
.. autoattribute:: emails
.. autoattribute:: email
Methods:
.. automethod:: add_email_address
.. automethod:: add_phone_number
.. autoclass:: EmployeeEmailAddress
Attributes:
.. autoattribute:: type
.. autoattribute:: address
.. autoattribute:: preference
.. autoclass:: EmployeePhoneNumber
Attributes:
.. autoattribute:: type
.. autoattribute:: number
.. autoattribute:: preference
.. autoclass:: LabelProfile
Attributes:
.. autoattribute:: code
.. autoattribute:: description
.. autoattribute:: printer_spec
.. autoattribute:: formatter_spec
.. autoattribute:: format
.. autoattribute:: visible
.. autoattribute:: ordinal
.. autoclass:: Person
Attributes:
.. autoattribute:: first_name
.. autoattribute:: last_name
.. autoattribute:: display_name
.. autoattribute:: phones
.. autoattribute:: phone
.. autoattribute:: emails
.. autoattribute:: email
Methods:
.. automethod:: add_email_address
.. automethod:: add_phone_number
.. autoclass:: PersonEmailAddress
Attributes:
.. autoattribute:: type
.. autoattribute:: address
.. autoattribute:: preference
.. autoclass:: PersonPhoneNumber
Attributes:
.. autoattribute:: type
.. autoattribute:: number
.. autoattribute:: preference
.. autoclass:: Product
Attributes:
.. autoattribute:: upc
.. autoattribute:: department
.. autoattribute:: subdepartment
.. autoattribute:: category
.. autoattribute:: brand
.. autoattribute:: description
.. autoattribute:: description2
.. autoattribute:: size
.. autoattribute:: unit_of_measure
.. autoattribute:: prices
.. autoattribute:: regular_price
.. autoattribute:: current_price
.. autoattribute:: costs
.. autoattribute:: cost
.. autoattribute:: vendor
.. autoclass:: ProductCost
Attributes:
.. autoattribute:: product
.. autoattribute:: vendor
.. autoattribute:: code
.. autoattribute:: case_size
.. autoattribute:: case_cost
.. autoattribute:: pack_size
.. autoattribute:: pack_cost
.. autoattribute:: unit_cost
.. autoattribute:: effective
.. autoattribute:: preference
.. autoclass:: ProductPrice
Attributes:
.. autoattribute:: product
.. autoattribute:: type
.. autoattribute:: level
.. autoattribute:: price
.. autoattribute:: multiple
.. autoattribute:: pack_price
.. autoattribute:: pack_multiple
.. autoattribute:: starts
.. autoattribute:: ends
.. autoclass:: Store
Attributes:
.. autoattribute:: id
.. autoattribute:: name
.. autoattribute:: phones
.. autoattribute:: phone
.. autoattribute:: emails
.. autoattribute:: email
Methods:
.. automethod:: add_email_address
.. automethod:: add_phone_number
.. autoclass:: StoreEmailAddress
Attributes:
.. autoattribute:: type
.. autoattribute:: address
.. autoattribute:: preference
.. autoclass:: StorePhoneNumber
Attributes:
.. autoattribute:: type
.. autoattribute:: number
.. autoattribute:: preference
.. autoclass:: Subdepartment
Attributes:
.. autoattribute:: number
.. autoattribute:: name
.. autoattribute:: department
.. autoclass:: Vendor
Attributes:
.. autoattribute:: id
.. autoattribute:: name
.. autoattribute:: special_discount
.. autoattribute:: phones
.. autoattribute:: phone
.. autoattribute:: emails
.. autoattribute:: email
.. autoattribute:: contacts
.. autoattribute:: contact
Methods:
.. automethod:: add_email_address
.. automethod:: add_phone_number
.. automethod:: add_contact
.. autoclass:: VendorContact
Attributes:
.. autoattribute:: vendor
.. autoattribute:: person
.. autoattribute:: preference
.. autoclass:: VendorEmailAddress
Attributes:
.. autoattribute:: type
.. autoattribute:: address
.. autoattribute:: preference
.. autoclass:: VendorPhoneNumber
Attributes:
.. autoattribute:: type
.. autoattribute:: number
.. autoattribute:: preference
Utilities
---------
.. autofunction:: uuid_column

7
docs/db_types.rst Normal file
View file

@ -0,0 +1,7 @@
``rattail.db.types``
====================
.. automodule:: rattail.db.types
.. autoclass:: GPCType

7
docs/db_util.rst Normal file
View file

@ -0,0 +1,7 @@
``rattail.db.util``
===================
.. automodule:: rattail.db.util
.. autofunction:: install_core_schema

21
docs/exceptions.rst Normal file
View file

@ -0,0 +1,21 @@
``rattail.exceptions``
======================
.. automodule:: rattail.exceptions
.. autoclass:: RattailError
.. autoclass:: InitializationError
.. autoclass:: ModuleHasNoInit
.. autoclass:: ConfigurationError
.. autoclass:: NoDefaultDatabase
.. autoclass:: SILError
.. autoclass:: SILColumnNotFound
.. autoclass:: LabelPrintingError

15
docs/files.rst Normal file
View file

@ -0,0 +1,15 @@
``rattail.files``
=================
.. automodule:: rattail.files
.. autofunction:: count_lines
.. autofunction:: creation_time
.. autofunction:: locking_copy
.. autofunction:: resource_path
.. autofunction:: temp_path

68
docs/index.rst Normal file
View file

@ -0,0 +1,68 @@
``rattail`` Package
===================
The ``rattail`` package is the "core" of the Rattail software framework within
the context of Python. This package serves a few distinct purposes, which are
outlined below.
Namespace
---------
First and foremost, ``rattail`` is a namespace package. This means that
everything provided by Rattail will fall under the ``rattail`` namespace. For
example, the pieces which integrate with `LOC Store Management Suite
<http://www.locsoftware.com/>`_ exist in the subpackage ``rattail.sw.locsms``
(which is distributed separately).
In practice, most folks may not *need* to know that ``rattail`` is a namespace
package; however it is pointed out here for the sake of completeness.
Library API
-----------
Secondly, and perhaps more usefully, ``rattail`` provides a library of various
utilities. These are "benign" in that they do not rely on the framework
aspects of Rattail at all, and may be used simply by importing the needed
function (etc.) and invoking it.
The following outlines the portions of ``rattail`` which are meant to be
generally available, and which incur no framework overhead:
.. toctree::
:maxdepth: 1
core
barcodes
configuration
exceptions
files
modules
pricing
sil
util
Database API
------------
Of course the data is really what a retail shop is interested in. Rattail
provides a generic database schema. In addition to the data classes, there are
a handful of tools used to create and interact with the data structures:
.. toctree::
:maxdepth: 1
db_model
db_types
db_util
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

190
docs/make.bat Normal file
View file

@ -0,0 +1,190 @@
@ECHO OFF
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set BUILDDIR=_build
set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
set I18NSPHINXOPTS=%SPHINXOPTS% .
if NOT "%PAPER%" == "" (
set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
)
if "%1" == "" goto help
if "%1" == "help" (
:help
echo.Please use `make ^<target^>` where ^<target^> is one of
echo. html to make standalone HTML files
echo. dirhtml to make HTML files named index.html in directories
echo. singlehtml to make a single large HTML file
echo. pickle to make pickle files
echo. json to make JSON files
echo. htmlhelp to make HTML files and a HTML help project
echo. qthelp to make HTML files and a qthelp project
echo. devhelp to make HTML files and a Devhelp project
echo. epub to make an epub
echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
echo. text to make text files
echo. man to make manual pages
echo. texinfo to make Texinfo files
echo. gettext to make PO message catalogs
echo. changes to make an overview over all changed/added/deprecated items
echo. linkcheck to check all external links for integrity
echo. doctest to run all doctests embedded in the documentation if enabled
goto end
)
if "%1" == "clean" (
for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
del /q /s %BUILDDIR%\*
goto end
)
if "%1" == "html" (
%SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/html.
goto end
)
if "%1" == "dirhtml" (
%SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
goto end
)
if "%1" == "singlehtml" (
%SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
goto end
)
if "%1" == "pickle" (
%SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can process the pickle files.
goto end
)
if "%1" == "json" (
%SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can process the JSON files.
goto end
)
if "%1" == "htmlhelp" (
%SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can run HTML Help Workshop with the ^
.hhp project file in %BUILDDIR%/htmlhelp.
goto end
)
if "%1" == "qthelp" (
%SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can run "qcollectiongenerator" with the ^
.qhcp project file in %BUILDDIR%/qthelp, like this:
echo.^> qcollectiongenerator %BUILDDIR%\qthelp\rattail.qhcp
echo.To view the help file:
echo.^> assistant -collectionFile %BUILDDIR%\qthelp\rattail.ghc
goto end
)
if "%1" == "devhelp" (
%SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished.
goto end
)
if "%1" == "epub" (
%SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The epub file is in %BUILDDIR%/epub.
goto end
)
if "%1" == "latex" (
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
if errorlevel 1 exit /b 1
echo.
echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
goto end
)
if "%1" == "text" (
%SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The text files are in %BUILDDIR%/text.
goto end
)
if "%1" == "man" (
%SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The manual pages are in %BUILDDIR%/man.
goto end
)
if "%1" == "texinfo" (
%SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
goto end
)
if "%1" == "gettext" (
%SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
goto end
)
if "%1" == "changes" (
%SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
if errorlevel 1 exit /b 1
echo.
echo.The overview file is in %BUILDDIR%/changes.
goto end
)
if "%1" == "linkcheck" (
%SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
if errorlevel 1 exit /b 1
echo.
echo.Link check complete; look for any errors in the above output ^
or in %BUILDDIR%/linkcheck/output.txt.
goto end
)
if "%1" == "doctest" (
%SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
if errorlevel 1 exit /b 1
echo.
echo.Testing of doctests in the sources finished, look at the ^
results in %BUILDDIR%/doctest/output.txt.
goto end
)
:end

9
docs/modules.rst Normal file
View file

@ -0,0 +1,9 @@
``rattail.modules``
===================
.. automodule:: rattail.modules
.. autofunction:: graft
.. autofunction:: prune

7
docs/pricing.rst Normal file
View file

@ -0,0 +1,7 @@
``rattail.pricing``
===================
.. automodule:: rattail.pricing
.. autofunction:: gross_margin

43
docs/sil.rst Normal file
View file

@ -0,0 +1,43 @@
``rattail.sil``
===============
.. automodule:: rattail.sil
Column API
----------
.. autoclass:: Column
.. autofunction:: get_column
Batch API
---------
.. autoclass:: Writer
.. automethod:: __init__
.. automethod:: get_fileobj
.. automethod:: close
.. automethod:: write
.. automethod:: val
.. automethod:: write_row
.. automethod:: write_rows
.. automethod:: write_batch_header_raw
.. automethod:: write_batch_header
.. automethod:: write_create_header
.. automethod:: write_maintenance_header
.. autofunction:: consume_batch_id

9
docs/util.rst Normal file
View file

@ -0,0 +1,9 @@
``rattail.util``
================
.. automodule:: rattail.util
.. autoclass:: Object
.. autofunction:: get_uuid

View file

@ -29,4 +29,7 @@
__import__('pkg_resources').declare_namespace(__name__)
from rattail._version import __version__
from rattail.util import Object, get_uuid
from rattail.barcodes import GPC
from rattail.initialization import *
from rattail.enum import *

View file

@ -1 +1 @@
__version__ = '0.3a25'
__version__ = '0.4a1'

View file

@ -24,17 +24,104 @@
"""
``rattail.barcodes`` -- Barcode Utilities
This module contains utility functions for handling barcode conversions, check
digit calculations, etc.
See also the :class:`rattail.GPC` class, which may be used to represent most
barcode data.
"""
# import re
class GPC(object):
"""
Class to abstract the representation of `Global Product Classification
<http://www.gs1.org/gdsn/gpc>`_ data. Examples of this would be UPC or EAN
barcodes.
The initial motivation for this class was to provide better SIL support.
To that end, the instances are assumed to always be comprised of only
numeric digits, and must include a check digit. If you do not know the
check digit, provide a ``calc_check_digit`` value to the constructor.
"""
def __init__(self, value, calc_check_digit=False):
"""
Constructor.
:param value: The barcode data. This must be either a value of numeric
type, or a string containing only digits.
:type value: integer or string
:param calc_check_digit: If this is ``False``, then the ``value`` parameter
is assumed to include the check digit.
If ``value`` does not include a check digit and one needs to be
calculated, then this paramter should be a keyword signifying the
algorithm to be used.
Currently the only check digit algorithm keyword supported is
``'upc'``. As that is likely to always be the default, setting this
parameter to ``True`` will also be interpreted as ``'upc'``.
:type calc_check_digit: boolean or string
"""
value = str(value)
if calc_check_digit is True or calc_check_digit == 'upc':
value += str(upc_check_digit(value))
self.value = int(value)
def __eq__(self, other):
return int(self) == int(other)
def __ne__(self, other):
return int(self) != int(other)
def __gt__(self, other):
return int(self) > int(other)
def __ge__(self, other):
return int(self) >= int(other)
def __lt__(self, other):
return int(self) < int(other)
def __le__(self, other):
return int(self) <= int(other)
def __hash__(self):
return hash(self.value)
def __int__(self):
return int(self.value)
def __long__(self):
return long(self.value)
def __repr__(self):
return "GPC('%014d')" % self.value
def __str__(self):
return '%014d' % self.value
def __unicode__(self):
return u'%014d' % self.value
def upc_check_digit(data):
"""
Calculates the check digit for ``data``, according to the standard
`UPC algorithm <http://en.wikipedia.org/wiki/Check_digit#UPC>`_. The check
digit will be returned in integer form.
Calculates a check digit according to the standard `UPC algorithm
<http://en.wikipedia.org/wiki/Universal_Product_Code#Check_digits>`_.
:param data: The barcode data, without check digit.
:type data: string
:returns: The calculated check digit.
:rtype: integer
"""
sum_ = 0
for i in range(len(data) - 1, -1, -2):
sum_ += int(data[i]) * 3
@ -48,10 +135,16 @@ def upc_check_digit(data):
def luhn_check_digit(data):
"""
Calculate the check digit for ``data`` according to the
`Luhn algorithm <http://en.wikipedia.org/wiki/Luhn_algorithm>`_. The check
digit will be returned in integer form.
Calculates a check digit according to the `Luhn algorithm
<http://en.wikipedia.org/wiki/Luhn_algorithm>`_.
:param data: The barcode data, without check digit.
:type data: string
:returns: The calculated check digit.
:rtype: integer
"""
reverse_data = ''.join([x for x in reversed(data)])
sum_ = 0
for i in range(len(reverse_data)):
@ -68,17 +161,18 @@ def luhn_check_digit(data):
def price_check_digit(data):
"""
Returns the "price check digit" for ``data``, which must be a
four-character string of digits. The check digit is returned in integer
form.
Calculates a check digit according to the `Price Check Digit algorithm
<http://barcodes.gs1us.org/GS1%20US%20BarCodes%20and%20eCom%20-%20The%20Global%20Language%20of%20Business.htm#PCD>`_
This typically would be used to validate a random weight UPC. See GS1's
`Price Check Digit (North American POS Product Sold by Weight/Measure)
<http://www.gs1us.org/GS1%20US%20BarCodes%20and%20eCom%20-%20The%20Global%20Language%20of%20Business.htm#PCD>`_
for more information.
:param data: The price data, without check digit. The length of the data
string must be exactly 4 characters.
:type data: string
:returns: The calculated check digit.
:rtype: integer
"""
if not isinstance(data, basestring) and len(data) == 4:
if not isinstance(data, basestring) or len(data) != 4:
raise ValueError("'data' must be 4-character string; got: %s" % str(data))
map1 = {0:0, 1:2, 2:4, 3:6, 4:8, 5:9, 6:1, 7:3, 8:5, 9:7}
@ -90,42 +184,57 @@ def price_check_digit(data):
return int(str(3 * sum_)[-1])
def upce_to_upca(upce, include_check_digit=False):
def upce_to_upca(data, include_check_digit=False):
"""
Expands ``upce`` (which is assumed to be a valid UPC-E barcode) into its
full UPC-A equivalent. The return value will have either 11 or 12 digits,
depending on ``include_check_digit``.
Converts a `UPC-E
<http://en.wikipedia.org/wiki/Universal_Product_Code#UPC-E>`_
(zero-compressed) barcode into its expanded UPC-A equivalent.
:param data: The UPC-E barcode data. The length of the data string must be
either 6 or 8 characters.
:type data: string
:param include_check_digit: Whether or not to include the check digit in
the return value.
:type include_check_digit: boolean
:returns: The expanded UPC-A barcode data. The length of the data string
will be 11 or 12 characters, depending on the ``include_check_digit``
parameter.
:rtype: string
"""
if len(upce) == 8:
upce = upce[1:7]
assert len(upce) == 6
assert upce.isdigit()
if len(data) == 8:
data = data[1:7]
assert len(data) == 6
assert data.isdigit()
upce = data
last_digit = int(upce[-1])
if last_digit == 0:
upca = "0%02u00000%03u" % (int(upce[0:2]), int(upce[2:5]))
upca = '0%02u00000%03u' % (int(upce[0:2]), int(upce[2:5]))
elif last_digit == 1:
upca = "0%02u10000%03u" % (int(upce[0:2]), int(upce[2:5]))
upca = '0%02u10000%03u' % (int(upce[0:2]), int(upce[2:5]))
elif last_digit == 2:
upca = "0%02u20000%03u" % (int(upce[0:2]), int(upce[2:5]))
upca = '0%02u20000%03u' % (int(upce[0:2]), int(upce[2:5]))
elif last_digit == 3:
upca = "0%03u00000%02u" % (int(upce[0:3]), int(upce[3:5]))
upca = '0%03u00000%02u' % (int(upce[0:3]), int(upce[3:5]))
elif last_digit == 4:
upca = "0%04u00000%01u" % (int(upce[0:4]), int(upce[4]))
upca = '0%04u00000%01u' % (int(upce[0:4]), int(upce[4]))
elif last_digit == 5:
upca = "0%05u00005" % int(upce[0:5])
upca = '0%05u00005' % int(upce[0:5])
elif last_digit == 6:
upca = "0%05u00006" % int(upce[0:5])
upca = '0%05u00006' % int(upce[0:5])
elif last_digit == 7:
upca = "0%05u00007" % int(upce[0:5])
upca = '0%05u00007' % int(upce[0:5])
elif last_digit == 8:
upca = "0%05u00008" % int(upce[0:5])
upca = '0%05u00008' % int(upce[0:5])
elif last_digit == 9:
upca = "0%05u00009" % int(upce[0:5])
upca = '0%05u00009' % int(upce[0:5])
if include_check_digit:
upca += str(calculate_check_digit(upca))
upca += str(upc_check_digit(upca))
return upca

View file

@ -26,24 +26,4 @@
``rattail.batches`` -- Batch System
"""
from edbob.util import entry_point_map
from rattail.batches.exceptions import *
from rattail.batches.providers import *
def get_provider(name):
providers = get_providers()
provider = providers.get(name)
if not provider:
raise BatchProviderNotFound(name)
return provider()
def get_providers():
return entry_point_map('rattail.batches.providers')
def iter_providers():
providers = get_providers()
return sorted(providers.itervalues(), key=lambda x: x.description)

View file

@ -27,17 +27,18 @@
"""
import datetime
import edbob
import pkg_resources
import rattail
from rattail import sil
from rattail.exceptions import BatchProviderNotFound
from rattail.db.model import Batch
__all__ = ['BatchProvider']
__all__ = ['BatchProvider', 'iter_providers', 'get_provider']
class BatchProvider(edbob.Object):
class BatchProvider(rattail.Object):
name = None
description = None
@ -49,7 +50,7 @@ class BatchProvider(edbob.Object):
session = None
def add_columns(self, batch):
pass
raise NotImplementedError
def add_rows_begin(self, batch, data):
pass
@ -63,7 +64,7 @@ class BatchProvider(edbob.Object):
prog = None
if progress:
prog = progress("Adding rows to batch \"%s\"" % batch.description,
data.count())
len(data))
cancel = False
for i, datum in enumerate(data, 1):
self.add_row(batch, datum)
@ -86,17 +87,20 @@ class BatchProvider(edbob.Object):
def execute(self, batch, progress=None):
raise NotImplementedError
def make_batch(self, session, data, progress=None):
def make_batch(self, session, data, progress=None, **kwargs):
self.session = session
batch = rattail.Batch()
batch.provider = self.name
batch.source = self.source
batch.id = sil.consume_batch_id(batch.source)
batch.destination = self.destination
batch.description = self.description
batch.action_type = self.action_type
self.set_purge_date(batch)
kwargs.setdefault('provider', self.name)
kwargs.setdefault('source', self.source)
kwargs.setdefault('destination', self.destination)
kwargs.setdefault('description', self.description)
kwargs.setdefault('action_type', self.action_type)
batch = Batch(**kwargs)
if batch.id is None: # pragma: no cover
batch.id = sil.consume_batch_id(batch.source)
if batch.purge is None:
self.set_purge_date(batch)
session.add(batch)
session.flush()
@ -108,9 +112,29 @@ class BatchProvider(edbob.Object):
return batch
def set_purge_date(self, batch):
today = edbob.utc_time(naive=True).date()
today = datetime.date.today()
purge_offset = datetime.timedelta(days=self.purge_date_offset)
batch.purge = today + purge_offset
def set_params(self, session, **params):
pass
def get_providers():
providers = {}
for entrypoint in pkg_resources.iter_entry_points('rattail.batches.providers'):
providers[entrypoint.name] = entrypoint.load()
return providers
def iter_providers():
providers = get_providers()
return sorted(providers.itervalues(), key=lambda x: x.description)
def get_provider(name):
providers = get_providers()
provider = providers.get(name)
if not provider:
raise BatchProviderNotFound(name)
return provider()

View file

@ -1,13 +1,35 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``dtail.batches.products.labels`` -- Print Labels Batch
``rattail.batches.providers.labels`` -- Print Labels Batch
"""
from sqlalchemy.orm import object_session
import rattail
from rattail.batches.providers import BatchProvider
from rattail.db.model import Product, LabelProfile
class PrintLabels(BatchProvider):
@ -29,8 +51,8 @@ class PrintLabels(BatchProvider):
def add_rows_begin(self, batch, data):
session = object_session(batch)
if not self.default_profile:
q = session.query(rattail.LabelProfile)
q = q.order_by(rattail.LabelProfile.ordinal)
q = session.query(LabelProfile)
q = q.order_by(LabelProfile.ordinal)
self.default_profile = q.first()
assert self.default_profile
else:
@ -56,18 +78,18 @@ class PrintLabels(BatchProvider):
profiles = {}
cancel = False
for i, row in enumerate(batch.iter_rows(), 1):
for i, row in enumerate(batch.rows, 1):
profile = profiles.get(row.F95)
if not profile:
q = session.query(rattail.LabelProfile)
q = q.filter(rattail.LabelProfile.code == row.F95)
q = session.query(LabelProfile)
q = q.filter(LabelProfile.code == row.F95)
profile = q.one()
profile.labels = []
profiles[row.F95] = profile
q = session.query(rattail.Product)
q = q.filter(rattail.Product.upc == row.F01)
q = session.query(Product)
q = q.filter(Product.upc == row.F01)
product = q.one()
profile.labels.append((product, row.F94))
@ -90,8 +112,8 @@ class PrintLabels(BatchProvider):
def set_params(self, session, **params):
profile = params.get('profile')
if profile:
q = session.query(rattail.LabelProfile)
q = q.filter(rattail.LabelProfile.code == profile)
q = session.query(LabelProfile)
q = q.filter(LabelProfile.code == profile)
self.default_profile = q.one()
quantity = params.get('quantity')

View file

@ -27,23 +27,38 @@
"""
import sys
import edbob
from edbob import commands
import argparse
import pkg_resources
import logging
import rattail
from rattail.configuration import basic_logging
class Command(commands.Command):
class ArgumentParser(argparse.ArgumentParser):
"""
The primary command for Rattail.
Customized version of ``argparse.ArgumentParser``, which overrides some of
the argument parsing logic. This is necessary for the primary command
(:class:`Command`); but is not used with :class:`Subcommand` derivatives.
"""
def parse_args(self, args=None, namespace=None):
args, argv = self.parse_known_args(args, namespace)
args.argv = argv
return args
class Command(rattail.Object):
"""
The primary console command for Rattail.
"""
name = 'rattail'
version = rattail.__version__
description = "Retail Software Framework"
description = "Pythonic Retail Software Framework"
long_description = """
Rattail is a retail software framework.
Rattail is a Pythonic retail software framework.
Copyright (c) 2010-2012 Lance Edgar <lance@edbob.org>
@ -52,8 +67,179 @@ and you are welcome to redistribute it under certain conditions.
See the file COPYING.txt for more information.
"""
def __init__(self, **kwargs):
subcommands = {}
for entrypoint in pkg_resources.iter_entry_points('%s.commands' % self.name):
subcommands[entrypoint.name] = entrypoint.load()
kwargs.setdefault('subcommands', subcommands)
kwargs.setdefault('output_stream', None)
super(Command, self).__init__(**kwargs)
class DatabaseSyncCommand(commands.Subcommand):
if self.output_stream is None: # pragma: no cover
self.output_stream = sys.stdout
def __repr__(self):
return '<Command: %s>' % self.name
def __unicode__(self):
return unicode(self.name)
def iter_subcommands(self):
"""
Generator which yields associated :class:`Subcommand` classes, ordered
by :attr:`Subcommand.name`.
"""
for name in sorted(self.subcommands):
yield self.subcommands[name]
def print_help(self):
"""
Prints help text for the primary command, including a list of available
subcommands.
"""
stream = self.output_stream
stream.write("""%(description)s
Usage: %(name)s [options] <command> [command-options]
Options:
-c PATH, --config=PATH
Config path (may be specified more than once)
-n, --no-init Don't load config before executing command
-d, --debug Increase logging level to DEBUG
-v, --verbose Increase logging level to INFO
-V, --version Display program version and exit
Commands:
""" % self)
for cmd in self.iter_subcommands():
stream.write(' %-16s %s\n' % (cmd.name, cmd.description))
stream.write("""
Try '%(name)s help <command>' for more help.
""" % self)
def run(self, *args):
"""
Parses ``args`` and executes the appropriate subcommand action
accordingly (or displays help text).
"""
parser = ArgumentParser(
prog=self.name,
description=self.description,
add_help=False,
)
parser.add_argument('-c', '--config', action='append', dest='config_paths',
metavar='PATH')
parser.add_argument('-d', '--debug', action='store_true', dest='debug')
parser.add_argument('-n', '--no-init', action='store_true', default=False)
parser.add_argument('-v', '--verbose', action='store_true', dest='verbose')
parser.add_argument('-V', '--version', action='version',
version="%%(prog)s %s" % self.version)
parser.add_argument('command', nargs='*')
# Parse args and determind subcommand.
args = parser.parse_args(list(args))
if not args or not args.command:
self.print_help()
return
# Show (sub)command help if so instructed, or unknown subcommand.
cmd = args.command.pop(0)
if cmd == 'help':
if len(args.command) != 1:
self.print_help()
return
cmd = args.command[0]
if cmd not in self.subcommands:
self.print_help()
return
cmd = self.subcommands[cmd](parent=self, output_stream=self.output_stream)
cmd.print_help()
return
elif cmd not in self.subcommands:
self.print_help()
return
# Initialize everything...
if not args.no_init:
# Basic logging should be established before init()ing.
basic_logging()
log = logging.getLogger()
if args.verbose: # pragma: no cover
log.setLevel(logging.INFO)
if args.debug: # pragma: no cover
log.setLevel(logging.DEBUG)
rattail.init(*(args.config_paths or []))
# Command line logging flags should override config.
if args.verbose: # pragma: no cover
log.setLevel(logging.INFO)
if args.debug: # pragma: no cover
log.setLevel(logging.DEBUG)
# And finally, do something of real value...
cmd = self.subcommands[cmd](parent=self, output_stream=self.output_stream)
cmd._run(*(args.command + args.argv))
class Subcommand(rattail.Object):
"""
Base class for application "subcommands." You'll want to derive from this
class as needed to implement most of your application's command logic.
"""
name = None
description = None
def __init__(self, **kwargs):
kwargs.setdefault('parent', None)
kwargs.setdefault('output_stream', None)
super(Subcommand, self).__init__(**kwargs)
self.parser = argparse.ArgumentParser(
prog='%s %s' % (self.parent, self.name),
description=self.description,
)
self.add_parser_args(self.parser)
def __repr__(self):
return '<Subcommand: %s>' % self.name
def add_parser_args(self, parser):
"""
If your subcommand accepts optional (or positional) arguments, you
should override this and add the possible arguments directly to
``parser`` (which is a ``argparse.ArgumentParser`` instance) via its
``add_argument()`` method.
"""
def print_help(self):
self.parser.print_help(self.output_stream)
def _run(self, *args):
args = self.parser.parse_args(list(args))
return self.run(args)
def run(self, args):
"""
Runs the subcommand. You must override this method within your
subclass. ``args`` will be a ``argparse.Namespace`` object containing
all parsed arguments found on the original command line executed by the
user.
"""
raise NotImplementedError
class DatabaseSyncCommand(Subcommand):
"""
Interacts with the database synchronization service; called as ``rattail
dbsync``.
@ -70,7 +256,7 @@ class DatabaseSyncCommand(commands.Subcommand):
stop = subparsers.add_parser('stop', help="Stop service")
stop.set_defaults(subcommand='stop')
def run(self, args):
def run(self, args): # pragma: win32 no cover
from rattail.db.sync import linux as dbsync
if args.subcommand == 'start':
@ -80,25 +266,104 @@ class DatabaseSyncCommand(commands.Subcommand):
dbsync.stop_daemon()
class FileMonitorCommand(commands.FileMonitorCommand):
class FileMonitorCommand(Subcommand):
"""
Interacts with the file monitor service; called as ``rattail filemon``.
This command expects a subcommand; one of the following:
See :class:`edbob.commands.FileMonitorCommand` for more information.
* ``rattail filemon start``
* ``rattail filemon stop``
On Windows platforms, the following additional subcommands are available:
* ``rattail filemon install``
* ``rattail filemon uninstall``
.. note::
The Windows Vista family of operating systems requires you to launch
``cmd.exe`` as an Administrator in order to have sufficient rights to
run the above commands.
"""
appname = 'rattail'
name = 'filemon'
description = "Manage the file monitor service"
def get_win32_module(self):
from rattail import filemon
return filemon
def add_parser_args(self, parser):
subparsers = parser.add_subparsers(title='subcommands')
def get_win32_service(self):
from rattail.filemon import RattailFileMonitor
return RattailFileMonitor
start = subparsers.add_parser('start', help="Start service")
start.set_defaults(subcommand='start')
stop = subparsers.add_parser('stop', help="Stop service")
stop.set_defaults(subcommand='stop')
if sys.platform == 'win32':
install = subparsers.add_parser('install', help="Install service")
install.set_defaults(subcommand='install')
install.add_argument('-a', '--auto-start', action='store_true',
help="Configure service to start automatically")
remove = subparsers.add_parser('remove', help="Uninstall (remove) service")
remove.set_defaults(subcommand='remove')
uninstall = subparsers.add_parser('uninstall', help="Uninstall (remove) service")
uninstall.set_defaults(subcommand='remove')
else: # pragma: win32 no cover
parser.add_argument('-D', '--dont-daemonize',
action='store_false', dest='daemonize',
help="Don't daemonize when starting")
def run(self, args):
if sys.platform == 'linux2': # pragma: win32 no cover
from rattail.filemon import linux as filemon
if args.subcommand == 'start':
filemon.start_daemon(daemonize=args.daemonize)
elif args.subcommand == 'stop':
filemon.stop_daemon()
elif sys.platform == 'win32': # pragma: no cover
from rattail import win32
from rattail.filemon import win32 as filemon
# Execute typical service command.
options = []
if args.subcommand == 'install' and args.auto_start:
options = ['--startup', 'auto']
win32.execute_service_command(filemon, args.subcommand, *options)
# If installing auto-start service on Windows 7, we should update
# its startup type to be "Automatic (Delayed Start)".
if args.subcommand == 'install' and args.auto_start:
if platform.release() == '7':
win32.delayed_auto_start_service('RattailFileMonitor')
class LoadHostDataCommand(commands.Subcommand):
class InitDatabaseCommand(Subcommand):
"""
Initializes a database.
"""
name = 'initdb'
description = "Initialize a Rattail database"
def run(self, args):
from rattail import db
from rattail.db.util import core_schema_installed, install_core_schema
rattail.init_modules(['rattail.db'])
if core_schema_installed():
self.output_stream.write("Database has already been initialized:\n")
self.output_stream.write(" %s\n" % str(db.engine.url))
return
install_core_schema()
self.output_stream.write("Initialized database:\n")
self.output_stream.write(" %s\n" % str(db.engine.url))
class LoadHostDataCommand(Subcommand):
"""
Loads data from the Rattail host database, if one is configured.
"""
@ -107,28 +372,71 @@ class LoadHostDataCommand(commands.Subcommand):
description = "Load data from host database"
def run(self, args):
from edbob.console import Progress
from rattail import db
from rattail.db import load
from rattail.console import Progress
edbob.init_modules(['edbob.db'])
rattail.init_modules(['rattail.db'])
host_engine = db.engines.get('host')
if 'host' not in edbob.engines:
print "Host engine URL not configured."
if not host_engine:
self.output_stream.write("Host engine URL not configured.\n")
return
proc = load.LoadProcessor()
proc.load_all_data(edbob.engines['host'], Progress)
proc.load_all_data(host_engine, Progress)
def main(*args):
class PurgeBatchesCommand(Subcommand):
"""
.. highlight:: sh
Purges stale batches from the database; called as::
rattail purge-batches
"""
name = 'purge-batches'
description = "Purge stale batches from the database"
def run(self, args):
from rattail import db
from rattail.db.batches.util import purge_batches
rattail.init_modules(['rattail.db'])
session = db.Session()
self.output_stream.write("Purging batches from database:\n")
self.output_stream.write(" %s\n" % str(db.engine.url))
purged = purge_batches(session)
session.commit()
session.close()
self.output_stream.write("\nPurged %d batches\n" % purged)
class UUIDCommand(Subcommand):
"""
Command for generating an UUID; called as ``rattail uuid``.
"""
name = 'uuid'
description = "Generate an universally-unique identifier"
def run(self, args):
self.output_stream.write(rattail.get_uuid())
def main(*args, **kwargs):
"""
The primary entry point for the Rattail command system.
"""
if args:
args = list(args)
else:
else: # pragma: no cover
args = sys.argv[1:]
cmd = Command()
cmd = Command(**kwargs)
cmd.run(*args)

638
rattail/configuration.py Normal file
View file

@ -0,0 +1,638 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.configuration`` -- Configuration Framework
This module provides a configuration storage interface, as well as some utility
functions for locating default configuration file paths.
"""
import os
import os.path
import sys
import re
import ConfigParser
import logging
import logging.config
from rattail.files import temp_path
from rattail.exceptions import MissingConfiguration
__all__ = ['RattailConfigParser', 'default_system_paths', 'default_user_paths']
log = logging.getLogger(__name__)
class RattailConfigParser(ConfigParser.SafeConfigParser):
"""
Subclass of ``ConfigParser.SafeConfigParser`` which adds some customization
specific to Rattail, mostly for the sake of convenience.
"""
def __init__(self, *args, **kwargs):
ConfigParser.SafeConfigParser.__init__(self, *args, **kwargs)
self.paths_attempted = []
self.paths_loaded = []
def configure_logging(self):
"""
Saves the current (possibly cascaded) configuration to a temporary
file, and passes that to ``logging.config.fileConfig()``.
:returns: ``None``
"""
if self.getboolean('rattail', 'basic_logging', default=False):
basic_logging()
if self.getboolean('rattail', 'configure_logging', default=False): # pragma: no cover
path = temp_path('.conf')
self.save(path)
try:
logging.config.fileConfig(path, disable_existing_loggers=False)
except ConfigParser.NoSectionError:
pass
os.remove(path)
if not self.getboolean('rattail', 'testing', default=False): # pragma: no cover
log.debug("RattailConfigParser.configure_logging: configured logging")
def get(self, section, option, raw=None, vars=None, default=None):
"""
Customized version of ``ConfigParser.SafeConfigParser.get()``. This
one adds the ``default`` keyword parameter and will return it instead
of raising an error when ``option`` doesn't exist.
:param section: Section in which the option is expected to exist.
:type section: string
:param option: Option whose value should be returned.
:type option: string
:param raw: Whether or not to expand ``'%'`` interpolations in the
resulting value.
:type raw: boolean
:param vars: Optional dictionary in which to look for the value. If
not provided, the configuration held in memory by the parser will be
consulted instead.
:type vars: dictionary
:param default: Default value to be returned, if the option cannot be
found.
:type default: string
:returns: The value held in memory (if found), or the value specified
by the ``default`` parameter.
:rtype: string, or ``None``
"""
if self.has_option(section, option):
return ConfigParser.SafeConfigParser.get(self, section, option, raw, vars)
return default
def getboolean(self, section, option, default=None):
"""
Overriddes ``ConfigParser.SafeConfigParser.getboolean()``, to allow for
a default.
:param section: Section in which the option is expected to exist.
:type section: string
:param option: Option whose value should be returned.
:type option: string
:param default: Default value to be returned, if the option cannot be
found.
:type default: boolean
:returns: Boolean value, or ``None`` if none could be found and no
default is provided.
:rtype: boolean, or ``None``
"""
try:
return ConfigParser.SafeConfigParser.getboolean(self, section, option)
except AttributeError:
return default
def get_user_dir(self, create=False, last_segment='rattail'):
"""
Returns the path to the "preferred" user-level configuration folder.
The folder may be created if so desired.
:param create: Whether or not the folder should be created, if it
doesn't already exist.
:type create: boolean
:param last_segment: The final path segment for the folder. This
parameter exists primarily for the sake of tests.
:type last_segment: string
:returns: Path to the configuration folder.
:rtype: string
"""
if sys.platform == 'win32':
from win32com.shell import shell, shellcon
path = os.path.join(
shell.SHGetSpecialFolderPath(0, shellcon.CSIDL_APPDATA),
last_segment)
else: # pragma: win32 no cover
path = os.path.expanduser('~/.%s' % last_segment)
if create and not os.path.exists(path):
os.mkdir(path)
return path
def get_user_file(self, filename='rattail.conf', create=False):
"""
Returns the full path to a user-level config file location. The path
is obtained by joining the result of :meth:`get_user_dir()` with
``filename``.
:param filename: Base name for the file, including extension.
:type filename: string
:param create: Whether or not the configuration folder should be
created. This parameter is passed directly to
:meth:`get_user_dir()`.
:type create: boolean
:returns: Full path to the file.
:rtype: string
"""
return os.path.join(self.get_user_dir(create=create), filename)
def options(self, section):
"""
Overriddes ``ConfigParser.SafeConfigParser.options()``. This
implementation returns an empty list instead of raising an exception,
in the event the section requested doesn't exist.
:param section: Section whose options are to be returned.
:type section: string
:returns: List of option names found within the section.
:rtype: list
"""
if self.has_section(section):
return ConfigParser.SafeConfigParser.options(self, section)
return []
def read(self, paths, recurse=True):
r"""
.. highlight:: ini
Overriddes ``ConfigParser.SafeConfigParser.read()`` by adding the
following logic:
Prior to actually reading the contents of the specified file(s) into
the current config instance, a recursive algorithm will inspect the
config found in the file(s) to see if additional config file(s) are to
be included. All config files, whether specified directly or
indirectly via configuration, are finally read into the current config
instance in the proper order so that cascading works as expected.
If you pass ``recurse=False`` to this method then none of the magical
inclusion logic will happen at all.
Note that when a config file indicates that another file(s) is to be
included, the referenced file will be read into this config instance
*before* the original (primary) file is read into it. A convenient
setup then could be to maintain a "site-wide" config file, shared on
the network, including something like this::
# //file-server/rattail/site.conf
#
# This file contains settings relevant to all machines on the
# network. Mail and logging configuration at least would be good
# candidates for inclusion here.
[rattail.mail]
smtp.server = mail.example.com
# smtp.username = user
# smtp.password = pass
sender.default = noreply@example.com
recipients.default = ['tech-support@example.com']
[loggers]
keys = root, rattail
# ...etc. The bulk of logging configuration would go here.
Then a config file local to a particular machine could look something
like this::
# C:\ProgramData\rattail\rattail.conf
#
# This file contains settings specific to the local machine.
[rattail]
include_config = [r'\\file-server\rattail\site.conf']
# Add any local app config here, e.g. connection details for a
# database, etc.
# All logging config is inherited from the site-wide file, except
# we'll override the output file location so that it remains local.
# And maybe we need the level bumped up while we troubleshoot
# something.
[handler_file]
args = (r'C:\ProgramData\rattail\log\rattail.log', 'a')
[logger_rattail]
level = DEBUG
There is no right way to do this of course; the need should drive the
method. Since recursion is used, there is also no real limit to how
you go about it. A config file specific to a particular app on a
particular machine can further include a config file specific to the
local user on that machine, which in turn can include a file specific
to the local machine generally, which could then include one or more
site-wide files, etc. Or the "most specific" (initially read; primary)
config file could indicate which other files to include for every level
of that, in which case recursion would be less necessary (though still
technically used).
:param paths: One or more configuration file paths to be read.
:type paths: sequence or string
:param recurse: Flag indicating whether recursion should be used when
reading configuration file(s).
:type recurse: boolean
:returns: List of file paths which were successfully read into the
configuration instance. Note that this list may include paths which
were not passed to the method, in the case of recursion.
:rtype: list
"""
if isinstance(paths, basestring):
paths = [paths]
for path in paths:
self.read_path(path, recurse=recurse)
return self.paths_loaded
def read_path(self, path, recurse=True):
r"""
.. highlight:: ini
Reads a "single" config file into the instance. If ``recurse`` is
``True``, *and* the config file references other "parent" config
file(s), then the parent(s) are read also in recursive fashion.
Parent config file paths may be specified in this way::
[rattail]
include_config = [
r'\\file-server\rattail\site.conf',
r'C:\ProgramData\rattail\special-stuff.conf',
]
See :meth:`read()` for more information.
:param path: Path to the configuration file to be read.
:type path: string
:param recurse: Flag indicating whether recursion should be used.
:type recurse: boolean
:returns: ``None``
"""
path = os.path.abspath(path)
if path in self.paths_attempted:
return
self.paths_attempted.append(path)
# if not self.getboolean('rattail', 'testing', default=False): # pragma: no cover
# log.debug("RattailConfigParser.read_path: reading config file: %s" % path)
if not os.path.exists(path):
if not self.getboolean('rattail', 'testing', default=False): # pragma: no cover
log.info("RattailConfigParser.read_path: file doesn't exist: %s" % path)
return
here = os.path.abspath(os.path.dirname(path))
config = ConfigParser.SafeConfigParser({'here':here})
if not config.read(path):
if not self.getboolean('rattail', 'testing', default=False): # pragma: no cover
log.warning("RattailConfigParser.read_path: read failed for file: %s" % path)
return
include = None
if recurse:
if (config.has_section('rattail') and
config.has_option('rattail', 'include_config')):
include = config.get('rattail', 'include_config')
if include:
for p in eval(include):
self.read_path(p)
ConfigParser.SafeConfigParser.read(self, path)
if include:
self.remove_option('rattail', 'include_config')
self.paths_loaded.append(path)
if not self.getboolean('rattail', 'testing', default=False): # pragma: no cover
log.info("RattailConfigParser.read_path: successfully read file: %s" % path)
def read_service(self, service, paths):
"""
"Special" version of :meth:`read()` which will first inspect the
file(s) for a service-specific directive, the presence of which
indicates the *true* config file to be used for the service.
This method is pretty much a hack to get around certain limitations of
Windows service implementations; it is not used otherwise.
"""
if isinstance(paths, basestring):
paths = [paths]
final_paths = list(paths)
for path in paths:
here = os.path.abspath(os.path.dirname(path))
config = ConfigParser.SafeConfigParser({'here':here})
if config.read(path):
if (config.has_section('rattail.service_config')
and config.has_option('rattail.service_config', service)):
final_paths = config.get('rattail.service_config', service)
if isinstance(final_paths, basestring):
final_paths = eval(final_paths)
self.read(final_paths, recurse=True)
def require(self, section, option, raw=None, vars=None):
"""
Convenience method which is equivalent to :meth:`get()`, except that it
will raise an error if the option value is not found.
:param section: Section in which the option is expected to exist.
:type section: string
:param option: Option whose value should be returned.
:type option: string
:param raw: Whether or not to expand ``'%'`` interpolations in the
resulting value.
:type raw: boolean
:param vars: Optional dictionary in which to look for the value. If
not provided, the configuration held in memory by the parser will be
consulted instead.
:type vars: dictionary
:raises: :class:`rattail.exceptions.MissingConfiguration`, if the
option value does not exist within the current configuration.
:returns: The value held in memory, if it is found.
:rtype: string, or ``None``
"""
value = self.get(section, option, raw, vars)
if value:
return value
raise MissingConfiguration(section, option)
def save(self, path):
"""
Saves the current config contents to a file.
:param path: Path to the output file.
:type path: string
:returns: ``None``
"""
f = open(path, 'w')
self.write(f)
f.close()
def set(self, section, option, value):
"""
Customized version of ``ConfigParser.SafeConfigParser.set()``. This
one automatically creates the section if it doesn't already exist,
instead of raising an error.
:param section: Section in which to put the option value.
:type section: string
:param option: Option whose value should be set.
:type option: string
:param value: Value for the option.
:type value: string
:returns: ``None``
"""
if not self.has_section(section):
self.add_section(section)
ConfigParser.SafeConfigParser.set(self, section, option, value)
class Storage(object):
"""
Base class for all configuration storage engines.
This class is designed to be a simple wrapper around a
``ConfigParser.RawConfigParser`` (or derived,
e.g. :class:`RattailConfigParser`) instance, hence the use of sections,
options and values. However other implementations may exist, provided they
adhere to the interface specified by this class.
"""
def get(self, section, option, default=None):
"""
Retrieves a configuration value from storage.
:param section: The "section" from which the value should be retrieved.
:type section: string
:param option: The "option" whose value should be retrieved.
:type option: string
:param default: Optional default value to be returned, should the
requested value not be found in storage.
:type default: string
:returns: The configuration value as found in storage, or the value
specified by the ``default`` parameter.
:rtype: string, or ``None``
"""
raise NotImplementedError
def put(self, section, option, value):
"""
Writes a configuration value to storage.
:param section: The "section" under which the value should be written.
:type section: string
:param option: The "option" whose value should be written.
:type option: string
:param value: The value to be written.
:type value: string
:returns: ``None``
"""
raise NotImplementedError
class UserConfigFileStorage(Storage):
"""
:class:`Storage` implementation which (by default) uses a configuration
file local to the current user's "home" folder.
"""
def __init__(self, config_path=None, **kwargs):
"""
Constructor.
:param config_path: Optional path to the configuration file to be used.
If this is not specified, a default will be provided which exists in
the user's home folder. :meth:`RattailConfigParser.get_user_file()`
will be invoked to determine the default path.
:type config_path: string
:param \*\*kwargs: Additional keyword arguments are currently used only
for testing purposes.
"""
self.config_parser = RattailConfigParser()
if kwargs.get('testing'):
self.config_parser.set('rattail', 'testing', 'True')
self.config_path = config_path
if not self.config_path:
self.config_path = self.config_parser.get_user_file('rattail.conf')
self.config_parser.read(self.config_path)
def get(self, section, option, default=None):
return self.config_parser.get(section, option, default=default)
def put(self, section, option, value):
self.config_parser.set(section, option, value)
self.config_parser.save(self.config_path)
def basic_logging():
"""
Does some basic configuration on the root logger.
:returns: ``None``
.. note::
This only enables console output at this point; it is assumed that if
you intend to "truly" configure logging that you will be using a proper
config file and calling :func:`rattail.init()`.
"""
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(
'%(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s'))
root = logging.getLogger()
root.addHandler(handler)
def default_system_paths():
r"""
Returns a list of default system-level config file paths.
:returns: List of strings, each of which is a potential config file path.
:rtype: list
The following paths will be returned, according to ``sys.platform``.
``win32``:
* ``<COMMON_APPDATA>\rattail.conf``
* ``<COMMON_APPDATA>\rattail\rattail.conf``
Any other platform:
* ``/etc/rattail.conf``
* ``/etc/rattail/rattail.conf``
* ``/usr/local/etc/rattail.conf``
* ``/usr/local/etc/rattail/rattail.conf``
"""
if sys.platform == 'win32':
from win32com.shell import shell, shellcon
paths = [
os.path.join(shell.SHGetSpecialFolderPath(
0, shellcon.CSIDL_COMMON_APPDATA), 'rattail.conf'),
os.path.join(shell.SHGetSpecialFolderPath(
0, shellcon.CSIDL_COMMON_APPDATA), 'rattail', 'rattail.conf'),
]
else: # pragma: win32 no cover
paths = [
'/etc/rattail.conf',
'/etc/rattail/rattail.conf',
'/usr/local/etc/rattail.conf',
'/usr/local/etc/rattail/rattail.conf',
]
return paths
def default_user_paths():
r"""
Returns a list of default user-level config file paths.
:returns: List of strings, each of which is a potential config file path.
:rtype: list
The following paths will be returned, according to ``sys.platform``:
``win32``:
* ``<APPDATA>\rattail.conf``
* ``<APPDATA>\rattail\rattail.conf``
Any other platform:
* ``~/.rattail.conf``
* ``~/.rattail/rattail.conf``
"""
if sys.platform == 'win32':
from win32com.shell import shell, shellcon
paths = [
os.path.join(shell.SHGetSpecialFolderPath(
0, shellcon.CSIDL_APPDATA), 'rattail.conf'),
os.path.join(shell.SHGetSpecialFolderPath(
0, shellcon.CSIDL_APPDATA), 'rattail', 'rattail.conf'),
]
else: # pragma: win32 no cover
paths = [
os.path.expanduser('~/.rattail.conf'),
os.path.expanduser('~/.rattail/rattail.conf'),
]
return paths

View file

@ -23,29 +23,26 @@
################################################################################
"""
``rattail.batches.exceptions`` -- Batch Exceptions
``rattail.console`` -- Console-Specific Stuff
"""
class BatchError(Exception):
pass
import sys
import progressbar
class BatchProviderNotFound(BatchError):
class Progress(object):
"""
Provides a console-based progress bar.
"""
def __init__(self, name):
self.name = name
def __init__(self, message, maximum):
sys.stderr.write("\n%s...(%u total)\n" % (message, maximum))
widgets = [progressbar.Percentage(), ' ', progressbar.Bar(), ' ', progressbar.ETA()]
self.progress = progressbar.ProgressBar(maxval=maximum, widgets=widgets).start()
def __str__(self):
return "Batch provider not found: %s" % self.name
def update(self, value):
self.progress.update(value)
return True
class BatchDestinationNotSupported(BatchError):
def __init__(self, batch):
self.batch = batch
def __str__(self):
return "Destination '%s' not supported for batch: %s" % (
self.batch.destination, self.batch.description)
def destroy(self):
sys.stderr.write("\n")

135
rattail/daemon.py Normal file
View file

@ -0,0 +1,135 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import
# This code was stolen from:
# http://www.jejik.com/articles/2007/02/a_simple_unix_linux_daemon_in_python/
import sys, os, time, atexit
from signal import SIGTERM
class Daemon:
"""
A generic daemon class.
Usage: subclass the Daemon class and override the run() method
"""
def __init__(self, pidfile, stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'):
self.stdin = stdin
self.stdout = stdout
self.stderr = stderr
self.pidfile = pidfile
def daemonize(self):
"""
do the UNIX double-fork magic, see Stevens' "Advanced
Programming in the UNIX Environment" for details (ISBN 0201563177)
http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16
"""
try:
pid = os.fork()
if pid > 0:
# exit first parent
sys.exit(0)
except OSError, e:
sys.stderr.write("fork #1 failed: %d (%s)\n" % (e.errno, e.strerror))
sys.exit(1)
# decouple from parent environment
os.chdir("/")
os.setsid()
os.umask(0)
# do second fork
try:
pid = os.fork()
if pid > 0:
# exit from second parent
sys.exit(0)
except OSError, e:
sys.stderr.write("fork #2 failed: %d (%s)\n" % (e.errno, e.strerror))
sys.exit(1)
# redirect standard file descriptors
sys.stdout.flush()
sys.stderr.flush()
si = file(self.stdin, 'r')
so = file(self.stdout, 'a+')
se = file(self.stderr, 'a+', 0)
os.dup2(si.fileno(), sys.stdin.fileno())
os.dup2(so.fileno(), sys.stdout.fileno())
os.dup2(se.fileno(), sys.stderr.fileno())
# write pidfile
atexit.register(self.delpid)
pid = str(os.getpid())
file(self.pidfile,'w+').write("%s\n" % pid)
def delpid(self):
os.remove(self.pidfile)
def start(self):
"""
Start the daemon
"""
# Check for a pidfile to see if the daemon already runs
try:
pf = file(self.pidfile,'r')
pid = int(pf.read().strip())
pf.close()
except IOError:
pid = None
if pid:
message = "pidfile %s already exist. Daemon already running?\n"
sys.stderr.write(message % self.pidfile)
sys.exit(1)
# Start the daemon
self.daemonize()
self.run()
def stop(self):
"""
Stop the daemon
"""
# Get the pid from the pidfile
try:
pf = file(self.pidfile,'r')
pid = int(pf.read().strip())
pf.close()
except IOError:
pid = None
if not pid:
message = "pidfile %s does not exist. Daemon not running?\n"
sys.stderr.write(message % self.pidfile)
return # not an error in a restart
# Try killing the daemon process
try:
while 1:
os.kill(pid, SIGTERM)
time.sleep(0.1)
except OSError, err:
err = str(err)
if err.find("No such process") > 0:
if os.path.exists(self.pidfile):
os.remove(self.pidfile)
else:
print str(err)
sys.exit(1)
def restart(self):
"""
Restart the daemon
"""
self.stop()
self.start()
def run(self):
"""
You should override this method when you subclass Daemon. It will be called after the process has been
daemonized by start() or restart().
"""

View file

@ -26,122 +26,20 @@
``rattail.db`` -- Database Stuff
"""
import logging
import warnings
from sqlalchemy import types
from sqlalchemy.event import listen
from sqlalchemy.orm import object_mapper, RelationshipProperty
import edbob
from sqlalchemy import engine_from_config
from sqlalchemy.orm import sessionmaker
import rattail
from rattail.gpc import GPC
from rattail.db import changes
from rattail.exceptions import NoDefaultDatabase
from rattail.modules import graft
ignore_role_changes = None
__all__ = ['engines', 'engine', 'Session']
log = logging.getLogger(__name__)
class GPCType(types.TypeDecorator):
"""
SQLAlchemy type engine for GPC data.
"""
impl = types.BigInteger
def process_bind_param(self, value, dialect):
if value is None:
return None
return int(value)
def process_result_value(self, value, dialect):
if value is None:
return None
return GPC(value)
def before_flush(session, flush_context, instances):
"""
Listens for session flush events. This function is responsible for adding
stub records to the 'changes' table, which will in turn be used by the
database synchronizer.
"""
def record_change(instance, deleted=False):
# No need to record changes for Change. :)
if isinstance(instance, rattail.Change):
return
# No need to record changes for batch data.
if isinstance(instance, (rattail.Batch,
rattail.BatchColumn,
rattail.BatchRow)):
return
# Ignore instances which don't use UUID.
if not hasattr(instance, 'uuid'):
return
# Ignore Role instances, if so configured.
if ignore_role_changes:
if isinstance(instance, (edbob.Role, edbob.UserRole)):
return
# Provide an UUID value, if necessary.
if not instance.uuid:
# If the 'uuid' column is actually a foreign key to another
# table...well, then we can't just generate a new value for it.
# Instead we must traverse the relationship and fetch the existing
# foreign key value...
mapper = object_mapper(instance)
uuid = mapper.columns['uuid']
if uuid.foreign_keys:
for prop in mapper.iterate_properties:
if (isinstance(prop, RelationshipProperty)
and len(prop.remote_side) == 1
and prop.remote_side[0].key == 'uuid'):
instance.uuid = getattr(instance, prop.key).uuid
break
assert instance.uuid
# ...but if there is no foreign key, just generate a new UUID.
else:
instance.uuid = edbob.get_uuid()
# Record the change.
change = session.query(rattail.Change).get(
(instance.__class__.__name__, instance.uuid))
if not change:
change = rattail.Change(
class_name=instance.__class__.__name__,
uuid=instance.uuid)
session.add(change)
change.deleted = deleted
log.debug("before_flush: recorded change: %s" % repr(change))
for instance in session.deleted:
log.debug("before_flush: deleted instance: %s" % repr(instance))
record_change(instance, deleted=True)
for instance in session.new:
log.debug("before_flush: new instance: %s" % repr(instance))
record_change(instance)
for instance in session.dirty:
if session.is_modified(instance, passive=True):
log.debug("before_flush: dirty instance: %s" % repr(instance))
record_change(instance)
def record_changes(session):
listen(session, 'before_flush', before_flush)
engines = {}
engine = None
Session = sessionmaker()
def init(config):
@ -149,18 +47,38 @@ def init(config):
Initialize the Rattail database framework.
"""
from rattail.db.extension import model
edbob.graft(rattail, model)
global engine
global ignore_role_changes
ignore_role_changes = config.getboolean(
keys = config.require('rattail.db', 'engines')
keys = keys.split(',')
keys = [x.strip() for x in keys]
# Determine the default database.
if 'default' in keys:
default = 'default'
elif len(keys) == 1:
default = keys[0]
else:
raise NoDefaultDatabase()
# Instantiate all database engines.
cfg = dict(config.items('rattail.db'))
for key in keys:
engines[key] = engine_from_config(cfg, '%s.' % key)
# Use the default database.
engine = engines[default]
Session.configure(bind=engine)
# Bring all model classes into the root namespace.
from rattail.db import model
graft(rattail, model)
# from edbob.db.extensions import extend_framework
# extend_framework()
# Maybe record data changes.
changes.ignore_role_changes = config.getboolean(
'rattail.db', 'changes.ignore_roles', default=True)
if config.getboolean('rattail.db', 'changes.record'):
record_changes(edbob.Session)
elif config.getboolean('rattail.db', 'record_changes'):
warnings.warn("Config setting 'record_changes' in section [rattail.db] "
"is deprecated; please use 'changes.record' instead.",
DeprecationWarning)
record_changes(edbob.Session)
changes.record_changes(Session)

160
rattail/db/auth.py Normal file
View file

@ -0,0 +1,160 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.db.auth`` -- Authentication & Authorization
"""
import sys
import bcrypt
from sqlalchemy.orm import object_session
from rattail.db.model import Role, User
class BcryptAuthenticator(object):
"""
Authentication with py-bcrypt (Blowfish).
"""
def populate_user(self, user, password):
user.salt = bcrypt.gensalt()
user.password = bcrypt.hashpw(password, user.salt)
def authenticate_user(self, user, password):
return bcrypt.hashpw(password, user.salt) == user.password
def authenticate_user(session, username, password):
"""
Attempts to authenticate with ``username`` and ``password``. If
successful, returns the :class:`rattail.db.model.User` instance; otherwise
returns ``None``.
"""
q = session.query(User)
q = q.filter(User.username == username)
user = q.first()
if user:
auth = BcryptAuthenticator()
if auth.authenticate_user(user, password):
return user
def administrator_role(session):
"""
Returns the "Administrator" :class:`rattail.db.model.Role` instance,
attached to the given ``session``.
"""
uuid = 'd937fa8a965611dfa0dd001143047286'
admin = session.query(Role).get(uuid)
if admin:
return admin
admin = Role(uuid=uuid, name='Administrator')
session.add(admin)
return admin
def guest_role(session):
"""
Returns the "Guest" :class:`rattail.db.model.Role` instance, attached to
the given ``session``.
"""
uuid = 'f8a27c98965a11dfaff7001143047286'
guest = session.query(Role).get(uuid)
if guest:
return guest
guest = Role(uuid=uuid, name='Guest')
session.add(guest)
return guest
def grant_permission(session, role, permission):
"""
Grants ``permission`` to ``role``.
"""
if permission not in role.permissions:
role.permissions.append(permission)
def has_permission(session, obj, perm, include_guest=True):
"""
Checks the given ``obj`` (which may be either a
:class:`rattail.db.model.User`` or :class:`rattail.db.model.Role`
instance), and returns a boolean indicating whether or not the object is
allowed the given permission. ``perm`` should be a fully-qualified
permission name, e.g. ``'users.create'``.
"""
if isinstance(obj, User):
roles = list(obj.roles)
elif isinstance(obj, Role):
roles = [obj]
elif obj is None:
roles = []
else:
raise TypeError("You must pass either a User or Role for 'obj'; got: %s" % repr(obj))
if include_guest:
roles.append(guest_role(session))
admin = administrator_role(session)
for role in roles:
if role is admin:
return True
for permission in role.permissions:
if permission == perm:
return True
return False
def init_database(session, output_stream=None):
"""
Initialize the auth system within a Rattail database.
Currently this only creates an :class:`rattail.db.model.User` instance with
username ``'admin'`` (and password the same), and assigns the user to the
built-in administrative role (see :func:`administrator_role()`).
"""
if output_stream is None: # pragma: no cover
output_stream = sys.stdout
admin = User(username='admin')
set_user_password(admin, 'admin')
admin.roles.append(administrator_role(session))
session.add(admin)
session.flush()
output_stream.write("Created 'admin' user with password 'admin'\n")
def set_user_password(user, password):
"""
Sets the password for the given :class:`rattail.db.model.User` instance.
"""
auth = BcryptAuthenticator()
auth.populate_user(user, password)

View file

@ -23,19 +23,10 @@
################################################################################
"""
``rattail.sil.exceptions`` -- SIL Exceptions
``rattail.db.batches`` -- Batch API
"""
class SILError(Exception):
pass
class SILColumnNotFound(SILError):
def __init__(self, name):
self.name = name
def __str__(self):
return "SIL column not found: %s" % self.name
from rattail.db.batches.types import *
from rattail.db.batches.data import *
from rattail.db.batches.makers import *
from rattail.db.batches.executors import *

View file

@ -0,0 +1,71 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.db.batches.data`` -- Batch Data Providers
"""
class BatchDataProvider(object):
def __len__(self):
raise NotImplementedError
def __iter__(self):
raise NotImplementedError
class QueryDataProvider(BatchDataProvider):
def __init__(self, query):
self.query = query
def __len__(self):
return self.query.count()
class ProductDataProxy(object):
def __init__(self, product):
self.product = product
def __getattr__(self, name):
if name == 'F01':
return self.product.upc
if name == 'F02':
return self.product.description
if name == 'F22':
return self.product.size
if name == 'F155':
if self.product.brand:
return self.product.brand.name
return ''
raise AttributeError("Product has no attribute '%s'" % name)
class ProductQueryDataProvider(QueryDataProvider):
def __iter__(self):
for product in self.query:
yield ProductDataProxy(product)

View file

@ -0,0 +1,93 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.db.batches.executors`` -- Batch Executors
"""
from sqlalchemy.orm import object_session
from rattail.db.model import LabelProfile, Product
from rattail.exceptions import BatchTypeNotSupported
__all__ = ['BatchExecutor']
class BatchExecutor(object):
batch_type = None
def execute(self, batch, progress=None):
if batch.type != self.batch_type:
raise BatchTypeNotSupported(self, batch.type)
session = object_session(batch)
return self.execute_batch(session, batch, progress)
def execute_batch(self, session, batch, progress=None):
raise NotImplementedError
class LabelsBatchExecutor(BatchExecutor):
batch_type = 'labels'
def execute_batch(self, session, batch, progress=None):
prog = None
if progress:
prog = progress("Loading product data", batch.rowcount)
profiles = {}
cancel = False
for i, row in enumerate(batch.rows, 1):
profile = profiles.get(row.F95)
if not profile:
q = session.query(LabelProfile)
q = q.filter(LabelProfile.code == row.F95)
profile = q.one()
profile.labels = []
profiles[row.F95] = profile
q = session.query(Product)
q = q.filter(Product.upc == row.F01)
product = q.one()
profile.labels.append((product, row.F94))
if prog and not prog.update(i):
cancel = True
break
if not cancel:
for profile in profiles.itervalues():
printer = profile.get_printer()
assert printer
if not printer.print_labels(profile.labels):
cancel = True
break
if prog:
prog.destroy()
return not cancel

View file

@ -0,0 +1,120 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.db.batches.makers`` -- Batch Makers
"""
from rattail.db.batches.types import get_batch_type
from rattail.db.batches.data import BatchDataProvider
from rattail.db.model import Batch, LabelProfile
class BatchMaker(object):
progress_message = "Making batch(es)"
def __init__(self, session):
self.session = session
self.batches = {}
def get_batch(self, name):
if name not in self.batches:
self.batches[name] = self.make_batch(name)
return self.batches[name]
def make_batch(self, name):
if hasattr(self, 'make_batch_%s' % name):
batch = getattr(self, 'make_batch_%s' % name)()
else:
batch_type = get_batch_type(name)
batch = Batch()
batch_type.initialize(batch)
self.session.add(batch)
self.session.flush()
batch.create_table()
return batch
def make_batches_begin(self, data):
pass
def make_batches(self, data, progress=None):
if not isinstance(data, BatchDataProvider):
raise TypeError("Sorry, you must pass a BatchDataProvider instance")
result = self.make_batches_begin(data)
if result is not None and not result:
return False
prog = None
if progress and len(data):
prog = progress(self.progress_message, len(data))
cancel = False
for i, data_row in enumerate(data, 1):
self.process_data_row(data_row)
if prog and not prog.update(i):
cancel = True
break
self.session.flush()
if prog:
prog.destroy()
if not cancel:
result = self.make_batches_end()
if result is not None:
cancel = not result
return not cancel
def make_batches_end(self):
pass
def process_data_row(self, data_row):
raise NotImplementedError
class LabelsBatchMaker(BatchMaker):
default_profile = None
default_quantity = 1
def make_batches_begin(self, data):
if not self.default_profile:
q = self.session.query(LabelProfile)
q = q.order_by(LabelProfile.ordinal)
self.default_profile = q.first()
assert self.default_profile
def process_data_row(self, data_row):
batch = self.get_batch('labels')
row = batch.rowclass()
row.F01 = data_row.F01
row.F155 = data_row.F155
row.F02 = data_row.F02
row.F22 = data_row.F22
row.F95 = self.default_profile.code
row.F94 = self.default_quantity
batch.add_row(row)

View file

@ -0,0 +1,89 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.db.batches.types`` -- Batch Types
"""
import datetime
import pkg_resources
from rattail.time import local_time
from rattail.exceptions import BatchTypeNotFound
batch_types = None
def get_batch_type(name):
global batch_types
if batch_types is None:
batch_types = {}
for entrypoint in pkg_resources.iter_entry_points('rattail.batches.types'):
batch_types[entrypoint.name] = entrypoint.load()
if name in batch_types:
return batch_types[name]()
raise BatchTypeNotFound(name)
class BatchType(object):
name = None
description = None
source = None
destination = None
action_type = None
purge_date_offset = None
def initialize(self, batch):
batch.description = self.description
batch.source = self.source
batch.destination = self.destination
batch.action_type = self.action_type
self.set_purge_date(batch)
self.add_columns(batch)
def set_purge_date(self, batch):
if self.purge_date_offset is not None:
today = local_time().date()
purge_offset = datetime.timedelta(days=self.purge_date_offset)
batch.purge = today + purge_offset
def add_columns(self, batch):
raise NotImplementedError
class LabelsBatchType(BatchType):
name = 'labels'
description = "Print Labels"
def add_columns(self, batch):
batch.add_column('F01')
batch.add_column('F155')
batch.add_column('F02')
batch.add_column('F22', display_name="Size")
batch.add_column('F95', display_name="Label")
batch.add_column('F94', display_name="Quantity")

View file

@ -0,0 +1,91 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.db.batches.util`` -- Batch Utilities
"""
import re
from sqlalchemy import MetaData
import rattail
from rattail.db.model import Batch
from rattail.time import local_time
def purge_batches(session, effective_date=None):
"""
Purge batches from the database which are considered old.
:param session: Active database session.
:type session: ``sqlalchemy.orm.Session`` instance
:param effective_date: Date against which comparisons should be made when
determining if a batch is "old" (based on its
:attr:`rattail.db.model.Batch.purge` attribute). If no value is
provided, then :func:`rattail.time.local_time()` is called to determine
the current date.
:type effective_date: ``datetime.date`` instance, or ``None``
:returns: ``None``
"""
if effective_date is None:
rattail.init_modules(['rattail.time'])
effective_date = local_time().date()
purged = 0
q = session.query(Batch)
q = q.filter(Batch.purge != None)
q = q.filter(Batch.purge < effective_date)
for batch in q:
batch.drop_table()
session.delete(batch)
session.flush()
purged += 1
# This should theoretically not be necessary, if/when the batch processing
# is cleaning up after itself properly. For now though, it seems that
# orphaned data tables are sometimes being left behind.
batch_pattern = re.compile(r'^batch\.[0-9a-f]{32}$')
current_batches = []
for batch in session.query(Batch):
current_batches.append('batch.%s' % batch.uuid)
def orphaned_batches(name, metadata):
if batch_pattern.match(name):
if name not in current_batches:
return True
return False
metadata = MetaData(session.bind)
metadata.reflect(only=orphaned_batches)
for table in reversed(metadata.sorted_tables):
table.drop()
return purged

View file

@ -26,21 +26,18 @@
``rattail.db.cache`` -- Cache Helpers
"""
import edbob
from edbob.util import requires_impl
import rattail
from rattail.util import Object
from rattail.db import model
class DataCacher(edbob.Object):
class DataCacher(Object):
def __init__(self, session=None, **kwargs):
super(DataCacher, self).__init__(session=session, **kwargs)
@property
@requires_impl(is_property=True)
def class_(self):
pass
raise NotImplementedError
@property
def name(self):
@ -76,7 +73,7 @@ class DataCacher(edbob.Object):
class DepartmentCacher(DataCacher):
class_ = rattail.Department
class_ = model.Department
def cache_instance(self, dept):
self.instances[dept.number] = dept
@ -84,7 +81,7 @@ class DepartmentCacher(DataCacher):
class SubdepartmentCacher(DataCacher):
class_ = rattail.Subdepartment
class_ = model.Subdepartment
def cache_instance(self, subdept):
self.instances[subdept.number] = subdept
@ -92,7 +89,7 @@ class SubdepartmentCacher(DataCacher):
class BrandCacher(DataCacher):
class_ = rattail.Brand
class_ = model.Brand
def cache_instance(self, brand):
self.instances[brand.name] = brand
@ -100,7 +97,7 @@ class BrandCacher(DataCacher):
class VendorCacher(DataCacher):
class_ = rattail.Vendor
class_ = model.Vendor
def cache_instance(self, vend):
self.instances[vend.id] = vend
@ -108,7 +105,7 @@ class VendorCacher(DataCacher):
class ProductCacher(DataCacher):
class_ = rattail.Product
class_ = model.Product
def cache_instance(self, prod):
self.instances[prod.upc] = prod
@ -116,7 +113,7 @@ class ProductCacher(DataCacher):
class CustomerGroupCacher(DataCacher):
class_ = rattail.CustomerGroup
class_ = model.CustomerGroup
def cache_instance(self, group):
self.instances[group.id] = group
@ -124,7 +121,7 @@ class CustomerGroupCacher(DataCacher):
class CustomerCacher(DataCacher):
class_ = rattail.Customer
class_ = model.Customer
def cache_instance(self, customer):
self.instances[customer.id] = customer

124
rattail/db/changes.py Normal file
View file

@ -0,0 +1,124 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.db.changes`` -- Recording Changes
"""
import logging
from sqlalchemy.event import listen
from sqlalchemy.orm import object_mapper, RelationshipProperty
import rattail
from rattail.db.model import (
Change, Batch, BatchColumn, BatchRow, Role, UserRole)
__all__ = ['record_changes']
ignore_role_changes = None
log = logging.getLogger(__name__)
def before_flush(session, flush_context, instances):
"""
Listens for session flush events. This function is responsible for adding
stub records to the 'changes' table, which will in turn be used by the
database synchronizer.
"""
def record_change(instance, deleted=False):
# No need to record changes for Change. :)
if isinstance(instance, Change):
return
# No need to record changes for batch data.
if isinstance(instance, (Batch, BatchColumn, BatchRow)):
return
# Ignore instances which don't use UUID.
if not hasattr(instance, 'uuid'):
return
# Ignore Role instances, if so configured.
if ignore_role_changes:
if isinstance(instance, (Role, UserRole)):
return
# Provide an UUID value, if necessary.
if not instance.uuid:
# If the 'uuid' column is actually a foreign key to another
# table...well, then we can't just generate a new value for it.
# Instead we must traverse the relationship and fetch the existing
# foreign key value...
mapper = object_mapper(instance)
uuid = mapper.columns['uuid']
if uuid.foreign_keys:
for prop in mapper.iterate_properties:
if (isinstance(prop, RelationshipProperty)
and len(prop.remote_side) == 1
and prop.remote_side[0].key == 'uuid'):
instance.uuid = getattr(instance, prop.key).uuid
break
assert instance.uuid
# ...but if there is no foreign key, just generate a new UUID.
else:
instance.uuid = rattail.get_uuid()
# Record the change.
change = session.query(Change).get(
(instance.__class__.__name__, instance.uuid))
if not change:
change = Change(
class_name=instance.__class__.__name__,
uuid=instance.uuid)
session.add(change)
change.deleted = deleted
log.debug("before_flush: recorded change: %s" % repr(change))
for instance in session.deleted:
log.debug("before_flush: deleted instance: %s" % repr(instance))
record_change(instance, deleted=True)
for instance in session.new:
log.debug("before_flush: new instance: %s" % repr(instance))
record_change(instance)
for instance in session.dirty:
if session.is_modified(instance, passive=True):
log.debug("before_flush: dirty instance: %s" % repr(instance))
record_change(instance)
def record_changes(session):
listen(session, 'before_flush', before_flush)

View file

@ -1,955 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.db.extension.model`` -- Schema Definition
"""
import logging
from sqlalchemy import Column, ForeignKey
from sqlalchemy import String, Integer, DateTime, Date, Boolean, Numeric, Text
from sqlalchemy import types
from sqlalchemy import and_
from sqlalchemy.orm import relationship, object_session
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.orderinglist import ordering_list
import edbob
from edbob.db.model import Base, uuid_column
from edbob.db.extensions.contact import Person, EmailAddress, PhoneNumber
from edbob.exceptions import LoadSpecError
from edbob.sqlalchemy import getset_factory
from rattail import sil
from rattail import batches
from rattail.db import GPCType
__all__ = ['Change', 'Store', 'StoreEmailAddress', 'StorePhoneNumber',
'Department', 'Subdepartment', 'Brand', 'Category', 'Vendor',
'VendorContact', 'VendorPhoneNumber', 'VendorEmailAddress',
'Product', 'ProductCost', 'ProductPrice', 'Customer',
'CustomerEmailAddress', 'CustomerPhoneNumber', 'CustomerGroup',
'CustomerGroupAssignment', 'CustomerPerson', 'Employee',
'EmployeeEmailAddress', 'EmployeePhoneNumber',
'BatchColumn', 'Batch', 'BatchRow', 'LabelProfile']
log = logging.getLogger(__name__)
class Change(Base):
"""
Represents a changed (or deleted) record, which is pending synchronization
to another database.
"""
__tablename__ = 'changes'
class_name = Column(String(25), primary_key=True)
uuid = Column(String(32), primary_key=True)
deleted = Column(Boolean)
def __repr__(self):
status = 'deleted' if self.deleted else 'new/changed'
return "<Change: %s, %s, %s>" % (self.class_name, self.uuid, status)
class BatchColumn(Base):
"""
Represents a :class:`SilColumn` associated with a :class:`Batch`.
"""
__tablename__ = 'batch_columns'
uuid = uuid_column()
batch_uuid = Column(String(32), ForeignKey('batches.uuid'))
ordinal = Column(Integer, nullable=False)
name = Column(String(20))
display_name = Column(String(50))
sil_name = Column(String(10))
data_type = Column(String(15))
description = Column(String(50))
visible = Column(Boolean, default=True)
def __init__(self, sil_name=None, **kwargs):
if sil_name:
kwargs['sil_name'] = sil_name
sil_column = sil.get_column(sil_name)
kwargs.setdefault('name', sil_name)
kwargs.setdefault('data_type', sil_column.data_type)
kwargs.setdefault('description', sil_column.description)
kwargs.setdefault('display_name', sil_column.display_name)
super(BatchColumn, self).__init__(**kwargs)
def __repr__(self):
return "<BatchColumn: %s>" % self.name
def __unicode__(self):
return unicode(self.display_name or '')
class BatchRow(edbob.Object):
"""
Superclass of batch row objects.
"""
def __unicode__(self):
return u"Row %d" % self.ordinal
class Batch(Base):
"""
Represents a SIL-compliant batch of data.
"""
__tablename__ = 'batches'
uuid = uuid_column()
provider = Column(String(50))
id = Column(String(8))
source = Column(String(6))
destination = Column(String(6))
action_type = Column(String(6))
description = Column(String(50))
rowcount = Column(Integer, default=0)
executed = Column(DateTime)
purge = Column(Date)
columns = relationship(BatchColumn, backref='batch',
collection_class=ordering_list('ordinal'),
order_by=BatchColumn.ordinal,
cascade='save-update, merge, delete, delete-orphan')
_rowclasses = {}
def __repr__(self):
return "<Batch: %s>" % self.description
def __unicode__(self):
return unicode(self.description or '')
@property
def rowclass(self):
"""
Returns the mapped class for the underlying row (data) table.
"""
if not self.uuid:
object_session(self).flush()
assert self.uuid
if self.uuid not in self._rowclasses:
kwargs = {
'__tablename__': 'batch.%s' % self.uuid,
'uuid': uuid_column(),
'ordinal': Column(Integer, nullable=False),
}
for column in self.columns:
data_type = sil.get_sqlalchemy_type(column.data_type)
kwargs[column.name] = Column(data_type)
rowclass = type('BatchRow_%s' % str(self.uuid), (Base, BatchRow), kwargs)
batch_uuid = self.uuid
def batch(self):
return object_session(self).query(Batch).get(batch_uuid)
rowclass.batch = property(batch)
self._rowclasses[self.uuid] = rowclass
return self._rowclasses[self.uuid]
def add_column(self, sil_name=None, **kwargs):
column = BatchColumn(sil_name, **kwargs)
self.columns.append(column)
def add_row(self, row, **kwargs):
"""
Adds a row to the batch data table.
"""
session = object_session(self)
# FIXME: This probably needs to use a func.max() query.
row.ordinal = self.rowcount + 1
session.add(row)
self.rowcount += 1
session.flush()
def create_table(self):
"""
Creates the batch's data table within the database.
"""
session = object_session(self)
self.rowclass.__table__.create(session.bind)
def drop_table(self):
"""
Drops the batch's data table from the database.
"""
log.debug("Batch.drop_table: Dropping table for batch: %s, %s (%s)"
% (self.id, self.description, self.uuid))
session = object_session(self)
self.rowclass.__table__.drop(session.bind)
def execute(self, progress=None):
provider = self.get_provider()
assert provider
if not provider.execute(self, progress):
return False
self.executed = edbob.utc_time(naive=True)
object_session(self).flush()
return True
def get_provider(self):
assert self.provider
return batches.get_provider(self.provider)
def iter_rows(self):
session = object_session(self)
q = session.query(self.rowclass)
q = q.order_by(self.rowclass.ordinal)
return q
class StoreEmailAddress(EmailAddress):
"""
Represents an email address associated with a :class:`Store`.
"""
__mapper_args__ = {'polymorphic_identity': 'Store'}
class StorePhoneNumber(PhoneNumber):
"""
Represents a phone (or fax) number associated with a :class:`Store`.
"""
__mapper_args__ = {'polymorphic_identity': 'Store'}
class Store(Base):
"""
Represents a store (physical or otherwise) within the organization.
"""
__tablename__ = 'stores'
uuid = uuid_column()
id = Column(String(10))
name = Column(String(100))
def __repr__(self):
return "<Store: %s, %s>" % (self.id, self.name)
def __unicode__(self):
return unicode(self.name or '')
def add_email_address(self, address, type='Info'):
email = StoreEmailAddress(address=address, type=type)
self.emails.append(email)
def add_phone_number(self, number, type='Voice'):
phone = StorePhoneNumber(number=number, type=type)
self.phones.append(phone)
Store.emails = relationship(
StoreEmailAddress,
backref='store',
primaryjoin=StoreEmailAddress.parent_uuid == Store.uuid,
foreign_keys=[StoreEmailAddress.parent_uuid],
collection_class=ordering_list('preference', count_from=1),
order_by=StoreEmailAddress.preference,
cascade='save-update, merge, delete, delete-orphan')
Store.email = relationship(
StoreEmailAddress,
primaryjoin=and_(
StoreEmailAddress.parent_uuid == Store.uuid,
StoreEmailAddress.preference == 1),
foreign_keys=[StoreEmailAddress.parent_uuid],
uselist=False,
viewonly=True)
Store.phones = relationship(
StorePhoneNumber,
backref='store',
primaryjoin=StorePhoneNumber.parent_uuid == Store.uuid,
foreign_keys=[StorePhoneNumber.parent_uuid],
collection_class=ordering_list('preference', count_from=1),
order_by=StorePhoneNumber.preference,
cascade='save-update, merge, delete, delete-orphan')
Store.phone = relationship(
StorePhoneNumber,
primaryjoin=and_(
StorePhoneNumber.parent_uuid == Store.uuid,
StorePhoneNumber.preference == 1),
foreign_keys=[StorePhoneNumber.parent_uuid],
uselist=False,
viewonly=True)
class Brand(Base):
"""
Represents a brand or similar product line.
"""
__tablename__ = 'brands'
uuid = uuid_column()
name = Column(String(100))
def __repr__(self):
return "<Brand: %s>" % self.name
def __unicode__(self):
return unicode(self.name or '')
class Department(Base):
"""
Represents an organizational department.
"""
__tablename__ = 'departments'
uuid = uuid_column()
number = Column(Integer)
name = Column(String(30))
def __repr__(self):
return "<Department: %s>" % self.name
def __unicode__(self):
return unicode(self.name or '')
class Subdepartment(Base):
"""
Represents an organizational subdepartment.
"""
__tablename__ = 'subdepartments'
uuid = uuid_column()
number = Column(Integer)
name = Column(String(30))
department_uuid = Column(String(32), ForeignKey('departments.uuid'))
department = relationship(Department)
def __repr__(self):
return "<Subdepartment: %s>" % self.name
def __unicode__(self):
return unicode(self.name or '')
class Category(Base):
"""
Represents an organizational category for products.
"""
__tablename__ = 'categories'
uuid = uuid_column()
number = Column(Integer)
name = Column(String(50))
department_uuid = Column(String(32), ForeignKey('departments.uuid'))
department = relationship(Department)
def __repr__(self):
return "<Category: %s, %s>" % (self.number, self.name)
def __unicode__(self):
return unicode(self.name or '')
class VendorEmailAddress(EmailAddress):
"""
Represents an email address associated with a :class:`Vendor`.
"""
__mapper_args__ = {'polymorphic_identity': 'Vendor'}
class VendorPhoneNumber(PhoneNumber):
"""
Represents a phone (or fax) number associated with a :class:`Vendor`.
"""
__mapper_args__ = {'polymorphic_identity': 'Vendor'}
class VendorContact(Base):
"""
Represents a point of contact (e.g. salesperson) for a vendor, from the
retailer's perspective.
"""
__tablename__ = 'vendor_contacts'
uuid = uuid_column()
vendor_uuid = Column(String(32), ForeignKey('vendors.uuid'), nullable=False)
person_uuid = Column(String(32), ForeignKey('people.uuid'), nullable=False)
preference = Column(Integer, nullable=False)
person = relationship(Person)
def __repr__(self):
return "<VendorContact: %s, %s>" % (self.vendor, self.person)
def __unicode__(self):
return unicode(self.person)
class Vendor(Base):
"""
Represents a vendor from which products are purchased by the store.
"""
__tablename__ = 'vendors'
uuid = uuid_column()
id = Column(String(15))
name = Column(String(40))
special_discount = Column(Numeric(5,3))
contacts = relationship(VendorContact, backref='vendor',
collection_class=ordering_list('preference', count_from=1),
order_by=VendorContact.preference,
cascade='save-update, merge, delete, delete-orphan')
def __repr__(self):
return "<Vendor: %s>" % self.name
def __unicode__(self):
return unicode(self.name or '')
def add_contact(self, person):
contact = VendorContact(person=person)
self.contacts.append(contact)
def add_email_address(self, address, type='Info'):
email = VendorEmailAddress(address=address, type=type)
self.emails.append(email)
def add_phone_number(self, number, type='Voice'):
phone = VendorPhoneNumber(number=number, type=type)
self.phones.append(phone)
Vendor.emails = relationship(
VendorEmailAddress,
backref='vendor',
primaryjoin=VendorEmailAddress.parent_uuid == Vendor.uuid,
foreign_keys=[VendorEmailAddress.parent_uuid],
collection_class=ordering_list('preference', count_from=1),
order_by=VendorEmailAddress.preference,
cascade='save-update, merge, delete, delete-orphan')
Vendor.email = relationship(
VendorEmailAddress,
primaryjoin=and_(
VendorEmailAddress.parent_uuid == Vendor.uuid,
VendorEmailAddress.preference == 1),
foreign_keys=[VendorEmailAddress.parent_uuid],
uselist=False,
viewonly=True)
Vendor.phones = relationship(
VendorPhoneNumber,
backref='vendor',
primaryjoin=VendorPhoneNumber.parent_uuid == Vendor.uuid,
foreign_keys=[VendorPhoneNumber.parent_uuid],
collection_class=ordering_list('preference', count_from=1),
order_by=VendorPhoneNumber.preference,
cascade='save-update, merge, delete, delete-orphan')
Vendor.phone = relationship(
VendorPhoneNumber,
primaryjoin=and_(
VendorPhoneNumber.parent_uuid == Vendor.uuid,
VendorPhoneNumber.preference == 1),
foreign_keys=[VendorPhoneNumber.parent_uuid],
uselist=False,
viewonly=True)
Vendor.contact = relationship(
VendorContact,
primaryjoin=and_(
VendorContact.vendor_uuid == Vendor.uuid,
VendorContact.preference == 1),
uselist=False,
viewonly=True)
class ProductCost(Base):
"""
Represents a source from which a product may be obtained via purchase.
"""
__tablename__ = 'product_costs'
uuid = uuid_column()
product_uuid = Column(String(32), ForeignKey('products.uuid'), nullable=False)
vendor_uuid = Column(String(32), ForeignKey('vendors.uuid'), nullable=False)
preference = Column(Integer, nullable=False)
code = Column(String(15))
case_size = Column(Integer)
case_cost = Column(Numeric(9,5))
pack_size = Column(Integer)
pack_cost = Column(Numeric(9,5))
unit_cost = Column(Numeric(9,5))
effective = Column(DateTime)
vendor = relationship(Vendor)
def __repr__(self):
return "<ProductCost: %s : %s>" % (self.product, self.vendor)
class ProductPrice(Base):
"""
Represents a price for a product.
"""
__tablename__ = 'product_prices'
uuid = uuid_column()
product_uuid = Column(String(32), ForeignKey('products.uuid'), nullable=False)
type = Column(Integer)
level = Column(Integer)
starts = Column(DateTime)
ends = Column(DateTime)
price = Column(Numeric(8,3))
multiple = Column(Integer)
pack_price = Column(Numeric(8,3))
pack_multiple = Column(Integer)
def __repr__(self):
return "<ProductPrice: %s : %s>" % (self.product, self.price)
class Product(Base):
"""
Represents a product for sale and/or purchase.
"""
__tablename__ = 'products'
uuid = uuid_column()
upc = Column(GPCType, index=True)
department_uuid = Column(String(32), ForeignKey('departments.uuid'))
subdepartment_uuid = Column(String(32), ForeignKey('subdepartments.uuid'))
category_uuid = Column(String(32), ForeignKey('categories.uuid'))
brand_uuid = Column(String(32), ForeignKey('brands.uuid'))
description = Column(String(60))
description2 = Column(String(60))
size = Column(String(30))
unit_of_measure = Column(String(4))
regular_price_uuid = Column(
String(32),
ForeignKey('product_prices.uuid',
use_alter=True,
name='products_regular_price_uuid_fkey'))
current_price_uuid = Column(
String(32),
ForeignKey('product_prices.uuid',
use_alter=True,
name='products_current_price_uuid_fkey'))
department = relationship(Department)
subdepartment = relationship(Subdepartment)
category = relationship(Category)
brand = relationship(Brand)
costs = relationship(ProductCost, backref='product',
collection_class=ordering_list('preference', count_from=1),
order_by=ProductCost.preference,
cascade='save-update, merge, delete, delete-orphan')
def __repr__(self):
return "<Product: %s>" % self.description
def __unicode__(self):
return unicode(self.description or '')
Product.prices = relationship(
ProductPrice, backref='product',
primaryjoin=ProductPrice.product_uuid == Product.uuid,
cascade='save-update, merge, delete, delete-orphan')
Product.regular_price = relationship(
ProductPrice,
primaryjoin=Product.regular_price_uuid == ProductPrice.uuid,
lazy='joined',
post_update=True)
Product.current_price = relationship(
ProductPrice,
primaryjoin=Product.current_price_uuid == ProductPrice.uuid,
lazy='joined',
post_update=True)
Product.cost = relationship(
ProductCost,
primaryjoin=and_(
ProductCost.product_uuid == Product.uuid,
ProductCost.preference == 1,
),
uselist=False,
viewonly=True)
Product.vendor = relationship(
Vendor,
secondary=ProductCost.__table__,
primaryjoin=and_(
ProductCost.product_uuid == Product.uuid,
ProductCost.preference == 1,
),
secondaryjoin=Vendor.uuid == ProductCost.vendor_uuid,
uselist=False,
viewonly=True)
class EmployeeEmailAddress(EmailAddress):
"""
Represents an email address associated with a :class:`Employee`.
"""
__mapper_args__ = {'polymorphic_identity': 'Employee'}
class EmployeePhoneNumber(PhoneNumber):
"""
Represents a phone (or fax) number associated with a :class:`Employee`.
"""
__mapper_args__ = {'polymorphic_identity': 'Employee'}
class Employee(Base):
"""
Represents an employee within the organization.
"""
__tablename__ = 'employees'
uuid = uuid_column()
id = Column(Integer)
person_uuid = Column(String(32), ForeignKey('people.uuid'), nullable=False)
status = Column(Integer)
display_name = Column(String(100))
person = relationship(Person)
first_name = association_proxy('person', 'first_name')
last_name = association_proxy('person', 'last_name')
def __repr__(self):
return "<Employee: %s>" % self.person
def __unicode__(self):
return unicode(self.display_name or self.person)
def add_email_address(self, address, type='Home'):
email = EmployeeEmailAddress(address=address, type=type)
self.emails.append(email)
def add_phone_number(self, number, type='Home'):
phone = EmployeePhoneNumber(number=number, type=type)
self.phones.append(phone)
Employee.emails = relationship(
EmployeeEmailAddress,
backref='employee',
primaryjoin=EmployeeEmailAddress.parent_uuid == Employee.uuid,
foreign_keys=[EmployeeEmailAddress.parent_uuid],
collection_class=ordering_list('preference', count_from=1),
order_by=EmployeeEmailAddress.preference,
cascade='save-update, merge, delete, delete-orphan')
Employee.email = relationship(
EmployeeEmailAddress,
primaryjoin=and_(
EmployeeEmailAddress.parent_uuid == Employee.uuid,
EmployeeEmailAddress.preference == 1),
foreign_keys=[EmployeeEmailAddress.parent_uuid],
uselist=False,
viewonly=True)
Employee.phones = relationship(
EmployeePhoneNumber,
backref='employee',
primaryjoin=EmployeePhoneNumber.parent_uuid == Employee.uuid,
foreign_keys=[EmployeePhoneNumber.parent_uuid],
collection_class=ordering_list('preference', count_from=1),
order_by=EmployeePhoneNumber.preference,
cascade='save-update, merge, delete, delete-orphan')
Employee.phone = relationship(
EmployeePhoneNumber,
primaryjoin=and_(
EmployeePhoneNumber.parent_uuid == Employee.uuid,
EmployeePhoneNumber.preference == 1),
foreign_keys=[EmployeePhoneNumber.parent_uuid],
uselist=False,
viewonly=True)
class CustomerEmailAddress(EmailAddress):
"""
Represents an email address associated with a :class:`Customer`.
"""
__mapper_args__ = {'polymorphic_identity': 'Customer'}
class CustomerPhoneNumber(PhoneNumber):
"""
Represents a phone (or fax) number associated with a :class:`Customer`.
"""
__mapper_args__ = {'polymorphic_identity': 'Customer'}
class CustomerPerson(Base):
"""
Represents the association between a :class:`Person` and a customer account
(:class:`Customer`).
"""
__tablename__ = 'customers_people'
uuid = uuid_column()
customer_uuid = Column(String(32), ForeignKey('customers.uuid'))
person_uuid = Column(String(32), ForeignKey('people.uuid'))
ordinal = Column(Integer, nullable=False)
person = relationship(Person)
class Customer(Base):
"""
Represents a customer account. Customer accounts may consist of more than
one :class:`Person`, in some cases.
"""
__tablename__ = 'customers'
uuid = uuid_column()
id = Column(String(20))
name = Column(String(255))
email_preference = Column(Integer)
def __repr__(self):
return "<Customer: %s, %s>" % (self.id, self.name or self.person)
def __unicode__(self):
return unicode(self.name or self.person)
def add_email_address(self, address, type='Home'):
email = CustomerEmailAddress(address=address, type=type)
self.emails.append(email)
def add_phone_number(self, number, type='Home'):
phone = CustomerPhoneNumber(number=number, type=type)
self.phones.append(phone)
Customer.emails = relationship(
CustomerEmailAddress,
backref='customer',
primaryjoin=CustomerEmailAddress.parent_uuid == Customer.uuid,
foreign_keys=[CustomerEmailAddress.parent_uuid],
collection_class=ordering_list('preference', count_from=1),
order_by=CustomerEmailAddress.preference,
cascade='save-update, merge, delete, delete-orphan')
Customer.email = relationship(
CustomerEmailAddress,
primaryjoin=and_(
CustomerEmailAddress.parent_uuid == Customer.uuid,
CustomerEmailAddress.preference == 1),
foreign_keys=[CustomerEmailAddress.parent_uuid],
uselist=False,
viewonly=True)
Customer.phones = relationship(
CustomerPhoneNumber,
backref='customer',
primaryjoin=CustomerPhoneNumber.parent_uuid == Customer.uuid,
foreign_keys=[CustomerPhoneNumber.parent_uuid],
collection_class=ordering_list('preference', count_from=1),
order_by=CustomerPhoneNumber.preference,
cascade='save-update, merge, delete, delete-orphan')
Customer.phone = relationship(
CustomerPhoneNumber,
primaryjoin=and_(
CustomerPhoneNumber.parent_uuid == Customer.uuid,
CustomerPhoneNumber.preference == 1),
foreign_keys=[CustomerPhoneNumber.parent_uuid],
uselist=False,
viewonly=True)
Customer._people = relationship(
CustomerPerson, backref='customer',
primaryjoin=CustomerPerson.customer_uuid == Customer.uuid,
collection_class=ordering_list('ordinal', count_from=1),
order_by=CustomerPerson.ordinal)
Customer.people = association_proxy(
'_people', 'person',
getset_factory=getset_factory,
creator=lambda x: CustomerPerson(person=x))
Customer._person = relationship(
CustomerPerson,
primaryjoin=and_(
CustomerPerson.customer_uuid == Customer.uuid,
CustomerPerson.ordinal == 1),
uselist=False,
viewonly=True)
Customer.person = association_proxy(
'_person', 'person',
getset_factory=getset_factory)
class CustomerGroup(Base):
"""
Represents an arbitrary group to which :class:`Customer` instances may (or
may not) belong.
"""
__tablename__ = 'customer_groups'
uuid = uuid_column()
id = Column(String(20))
name = Column(String(255))
def __repr__(self):
return "<CustomerGroup: %s, %s>" % (self.id, self.name)
def __unicode__(self):
return unicode(self.name or '')
class CustomerGroupAssignment(Base):
"""
Represents the assignment of a :class:`Customer` to a
:class:`CustomerGroup`.
"""
__tablename__ = 'customers_groups'
uuid = uuid_column()
customer_uuid = Column(String(32), ForeignKey('customers.uuid'))
group_uuid = Column(String(32), ForeignKey('customer_groups.uuid'))
ordinal = Column(Integer, nullable=False)
group = relationship(CustomerGroup)
Customer._groups = relationship(
CustomerGroupAssignment, backref='customer',
collection_class=ordering_list('ordinal', count_from=1),
order_by=CustomerGroupAssignment.ordinal)
Customer.groups = association_proxy(
'_groups', 'group',
getset_factory=getset_factory,
creator=lambda x: CustomerGroupAssignment(group=x))
class LabelProfile(Base):
"""
Represents a "profile" (collection of settings) for product label printing.
"""
__tablename__ = 'label_profiles'
uuid = uuid_column()
ordinal = Column(Integer)
code = Column(String(3))
description = Column(String(50))
printer_spec = Column(String(255))
formatter_spec = Column(String(255))
format = Column(Text)
visible = Column(Boolean)
_printer = None
_formatter = None
def __repr__(self):
return "<LabelProfile: %s>" % self.code
def __unicode__(self):
return unicode(self.description or '')
def get_formatter(self):
if not self._formatter and self.formatter_spec:
try:
formatter = edbob.load_spec(self.formatter_spec)
except LoadSpecError:
pass
else:
self._formatter = formatter()
self._formatter.format = self.format
return self._formatter
def get_printer(self):
if not self._printer and self.printer_spec:
try:
printer = edbob.load_spec(self.printer_spec)
except LoadSpecError:
pass
else:
self._printer = printer()
for name in printer.required_settings:
setattr(printer, name, self.get_printer_setting(name))
self._printer.formatter = self.get_formatter()
return self._printer
def get_printer_setting(self, name):
session = object_session(self)
if not self.uuid:
session.flush()
name = 'labels.%s.printer.%s' % (self.uuid, name)
return edbob.get_setting(name, session)
def save_printer_setting(self, name, value):
session = object_session(self)
if not self.uuid:
session.flush()
name = 'labels.%s.printer.%s' % (self.uuid, name)
edbob.save_setting(name, value, session)

136
rattail/db/extensions.py Normal file
View file

@ -0,0 +1,136 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.db.extensions`` -- Database Extensions
"""
import logging
import pkg_resources
import rattail
from rattail import db
from rattail.db.util import get_core_metadata
from rattail.modules import graft, import_module_path
from rattail.exceptions import NoDefaultDatabase, SchemaExtensionNotFound
log = logging.getLogger(__name__)
extensions = None
class Extension(object):
"""
Base class for schema/ORM extensions.
"""
# You can set this to any dotted module path you like. If unset a default
# will be assumed, of the form ``<path.to.extension>.model`` (see
# :meth:`get_models_module()` for more info).
model_module = ''
def extend_namespace(self):
"""
Extends the ``rattail`` namespace with model classes provided by the
extension.
"""
graft(rattail, self.get_model_module())
def get_model_module(self):
"""
Imports and returns a reference to the Python module providing schema
definition for the extension.
:attr:`model_module` is first consulted to determine the dotted module
path. If nothing is found there, a default path is constructed by
appending ``'.model'`` to the extension module's own dotted path.
"""
if self.model_module:
module = self.model_module
else:
module = str(self.__class__.__module__) + '.model'
return import_module_path(module)
def populate_metadata(self, metadata):
"""
Populates ``metadata`` with tables provided by the extension.
"""
model = self.get_model_module()
for name in model.__all__:
obj = getattr(model, name)
if isinstance(obj, type) and issubclass(obj, model.Base):
if obj.__tablename__ not in metadata.tables:
obj.__table__.tometadata(metadata)
def get_extensions():
"""
Returns the map of available :class:`Extension` classes, as determined by
``setuptools`` entry points..
"""
global extensions
if extensions is None:
extensions = {}
for entrypoint in pkg_resources.iter_entry_points('rattail.db.extensions'):
extensions[entrypoint.name] = entrypoint.load()
return extensions
def get_extension(name):
"""
Returns a :class:`Extension` instance, according to ``name``. An error is
raised if the extension cannot be found.
"""
extensions = get_extensions()
if name in extensions:
return extensions[name]()
raise SchemaExtensionNotFound(name)
def install_extension_schema(extension, engine=None):
"""
Installs an extension's schema to the database. ``extension`` must be a
valid :class:`Extension` instance.
If ``engine`` is not provided, :attr:`rattail.db.engine` is assumed.
"""
if engine is None:
engine = db.engine
if not engine:
raise NoDefaultDatabase
if not isinstance(extension, Extension):
extension = get_extension(extension)
meta = get_core_metadata()
extension.populate_metadata(meta)
meta.create_all(engine)

View file

@ -28,20 +28,19 @@
from sqlalchemy.orm import joinedload
import edbob
import edbob.db
import rattail
from rattail.db import Session
from rattail.db import model
class LoadProcessor(edbob.Object):
class LoadProcessor(object):
def load_all_data(self, host_engine, progress=None):
edbob.init_modules(['edbob.db', 'rattail.db'])
rattail.init_modules(['rattail.db'])
self.host_session = edbob.Session(bind=host_engine)
self.session = edbob.Session()
self.host_session = Session(bind=host_engine)
self.session = Session()
cancel = False
for cls in self.relevant_classes():
@ -86,48 +85,48 @@ class LoadProcessor(edbob.Object):
return not cancel
def relevant_classes(self):
yield edbob.Person
yield edbob.User
yield rattail.Store
yield rattail.Department
yield rattail.Subdepartment
yield rattail.Category
yield rattail.Brand
yield rattail.Vendor
yield rattail.Product
yield rattail.CustomerGroup
yield rattail.Customer
yield rattail.Employee
yield model.Person
# yield model.User
yield model.Store
yield model.Department
yield model.Subdepartment
yield model.Category
yield model.Brand
yield model.Vendor
yield model.Product
yield model.CustomerGroup
yield model.Customer
yield model.Employee
classes = edbob.config.get('rattail.db', 'load.extra_classes')
if classes:
for cls in classes.split():
yield getattr(edbob, cls)
# classes = rattail.config.get('rattail.db', 'load.extra_classes')
# if classes:
# for cls in classes.split():
# yield getattr(model, cls)
def query_Customer(self, q):
q = q.options(joinedload(rattail.Customer.phones))
q = q.options(joinedload(rattail.Customer.emails))
q = q.options(joinedload(rattail.Customer._people))
q = q.options(joinedload(rattail.Customer._groups))
q = q.options(joinedload(model.Customer.phones))
q = q.options(joinedload(model.Customer.emails))
q = q.options(joinedload(model.Customer._people))
q = q.options(joinedload(model.Customer._groups))
return q
def query_CustomerPerson(self, q):
q = q.options(joinedload(rattail.CustomerPerson.person))
return q
# def query_CustomerPerson(self, q):
# q = q.options(joinedload(model.CustomerPerson.person))
# return q
def query_Employee(self, q):
q = q.options(joinedload(rattail.Employee.phones))
q = q.options(joinedload(rattail.Employee.emails))
q = q.options(joinedload(model.Employee.phones))
q = q.options(joinedload(model.Employee.emails))
return q
def query_Person(self, q):
q = q.options(joinedload(edbob.Person.phones))
q = q.options(joinedload(edbob.Person.emails))
q = q.options(joinedload(model.Person.phones))
q = q.options(joinedload(model.Person.emails))
return q
def query_Product(self, q):
q = q.options(joinedload(rattail.Product.costs))
q = q.options(joinedload(rattail.Product.prices))
q = q.options(joinedload(model.Product.costs))
q = q.options(joinedload(model.Product.prices))
return q
def merge_Product(self, host_product):
@ -145,12 +144,12 @@ class LoadProcessor(edbob.Object):
product.current_price = self.session.merge(host_product.current_price)
def query_Store(self, q):
q = q.options(joinedload(rattail.Store.phones))
q = q.options(joinedload(rattail.Store.emails))
q = q.options(joinedload(model.Store.phones))
q = q.options(joinedload(model.Store.emails))
return q
def query_Vendor(self, q):
q = q.options(joinedload(rattail.Vendor.contacts))
q = q.options(joinedload(rattail.Vendor.phones))
q = q.options(joinedload(rattail.Vendor.emails))
q = q.options(joinedload(model.Vendor.contacts))
q = q.options(joinedload(model.Vendor.phones))
q = q.options(joinedload(model.Vendor.emails))
return q

2126
rattail/db/model.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -36,32 +36,32 @@ if sys.platform == 'win32':
import sqlalchemy.exc
from sqlalchemy.orm import class_mapper
import edbob
import rattail
from rattail import db
from rattail.modules import load_spec
log = logging.getLogger(__name__)
def get_sync_engines():
edbob.init_modules(['edbob.db'])
rattail.init_modules(['rattail.db'])
keys = edbob.config.get('rattail.db', 'syncs')
keys = rattail.config.get('rattail.db', 'syncs')
if not keys:
return None
engines = {}
for key in keys.split(','):
key = key.strip()
engines[key] = edbob.engines[key]
engines[key] = db.engines[key]
log.info("get_sync_engines: Found engine keys: %s" % ','.join(engines.keys()))
return engines
def dependency_sort(x, y):
map_x = class_mapper(getattr(edbob, x))
map_y = class_mapper(getattr(edbob, y))
map_x = class_mapper(getattr(rattail, x))
map_y = class_mapper(getattr(rattail, y))
dep_x = []
table_y = map_y.tables[0].name
@ -98,7 +98,7 @@ class Synchronizer(object):
while True:
try:
local_session = edbob.Session()
local_session = db.Session()
local_changes = local_session.query(rattail.Change)
if local_changes.count():
@ -118,13 +118,13 @@ class Synchronizer(object):
remote_sessions = []
for remote_engine in engines.itervalues():
remote_sessions.append(
edbob.Session(bind=remote_engine))
db.Session(bind=remote_engine))
for class_name in class_names:
for change in local_changes.filter_by(class_name=class_name):
log.debug("synchronize_changes: processing change: %s" % change)
cls = getattr(edbob, change.class_name)
cls = getattr(rattail, change.class_name)
if change.deleted:
for remote_session in remote_sessions:
@ -278,9 +278,9 @@ def synchronize_changes(engines):
sync.synchronizer_class = myapp.sync:MySynchronizer
"""
cls = edbob.config.get('rattail.db', 'sync.synchronizer_class')
cls = rattail.config.get('rattail.db', 'sync.synchronizer_class')
if cls:
cls = edbob.load_spec(cls)
cls = load_spec(cls)
else:
cls = Synchronizer
sync = cls()

View file

@ -26,8 +26,7 @@
``rattail.db.sync.linux`` -- Database Synchronization for Linux
"""
from edbob.daemon import Daemon
from rattail.daemon import Daemon
from rattail.db.sync import get_sync_engines, synchronize_changes

View file

@ -30,9 +30,8 @@ import sys
import logging
import threading
import edbob
from edbob.win32 import Service
import rattail
from rattail.win32 import Service
from rattail.db.sync import get_sync_engines, synchronize_changes
@ -50,8 +49,6 @@ class DatabaseSynchronizerService(Service):
"and synchronizes them to the configured remote "
"database(s).")
appname = 'rattail'
def Initialize(self):
"""
Service initialization.
@ -60,7 +57,7 @@ class DatabaseSynchronizerService(Service):
if not Service.Initialize(self):
return False
edbob.init_modules(['rattail.db'])
rattail.init_modules(['rattail.db'])
engines = get_sync_engines()
if not engines:

View file

@ -23,34 +23,60 @@
################################################################################
"""
``rattail.sil.sqlalchemy`` -- SQLAlchemy Utilities
"""
``rattail.db.types`` -- Custom Data Types
from __future__ import absolute_import
This module contains various data type engines for use with SQLAlchemy.
"""
import re
from sqlalchemy import types
from sqlalchemy import String, Numeric, Boolean
from sqlalchemy.types import TypeDecorator, BigInteger
from rattail.db import GPCType
from rattail.barcodes import GPC
from rattail.exceptions import SILInvalidDataType
__all__ = ['get_sqlalchemy_type']
__all__ = ['GPCType']
sil_type_pattern = re.compile(r'^(CHAR|NUMBER)\((\d+(?:\,\d+)?)\)$')
def get_sqlalchemy_type(sil_type):
class GPCType(TypeDecorator):
"""
Type engine for GPC data.
This class is responsible for converting values to and from
:class:`rattail.GPC` instances. The underlying data type used to store
values in the database is ``sqlalchemy.types.BigInteger``.
The primary use for this class is to store the :attr:`rattail.Product.upc`
field. It also is used for batches when a ``F01`` column is involved.
"""
impl = BigInteger
def process_bind_param(self, value, dialect):
if value is None:
return None
return int(value)
def process_result_value(self, value, dialect):
if value is None:
return None
return GPC(value)
def get_sil_column_type(sil_type):
"""
Returns a SQLAlchemy data type according to a SIL data type.
"""
if sil_type == 'GPC(14)':
return GPCType
return GPCType()
if sil_type == 'FLAG(1)':
return types.Boolean
return Boolean()
m = sil_type_pattern.match(sil_type)
if m:
@ -64,8 +90,8 @@ def get_sqlalchemy_type(sil_type):
scale = int(scale)
if data_type == 'CHAR':
assert not scale, "FIXME"
return types.String(precision)
return String(precision)
if data_type == 'NUMBER':
return types.Numeric(precision, scale)
return Numeric(precision, scale)
assert False, "FIXME"
raise SILInvalidDataType(sil_type)

154
rattail/db/util.py Normal file
View file

@ -0,0 +1,154 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.db.util`` -- Database Utilities
"""
from sqlalchemy import MetaData
from sqlalchemy.exc import OperationalError, ProgrammingError
import rattail
from rattail import db
from rattail.db.model import Product, Setting
from rattail.exceptions import NoDefaultDatabase
def get_core_metadata():
"""
Returns the core Rattail schema metadata.
:returns: SQLAlchemy ``MetaData`` containing the core Rattail schema.
:rtype: ``sqlalchemy.MetaData`` instance
"""
from rattail.db import model
meta = MetaData()
for name in model.__all__:
obj = getattr(model, name)
if (isinstance(obj, type)
and len(obj.__bases__) == 1
and obj.__bases__[0] is model.Base):
obj.__table__.tometadata(meta)
return meta
def install_core_schema(engine=None):
"""
Installs the core Rattail schema to a database.
:param engine: A SQLAlchemy engine. If none is provided,
:attr:`rattail.db.engine` is assumed.
:type engine: ``sqlalchemy.engine.Engine`` instance, or ``None``
:raises: :class:`rattail.exceptions.NoDefaultDatabase`, if no engine is
provided, nor is one found in configuration. Other errors are also
possible; see below.
:returns: ``None``
Prior to installing the schema, a simple connection is attempted to the
database engine. This is intentionally done with no exception handling, so
that any connection issues may be raised directly to the caller.
.. note::
For all engines *except* SQLite, the underlying database must already
exist. This function is only responsible for adding the schema to an
existing database.
"""
if engine is None:
engine = db.engine
if engine is None:
raise NoDefaultDatabase()
# Attempt connection in order to force an error, if applicable.
conn = engine.connect()
conn.close()
# Create tables for core schema.
meta = get_core_metadata()
meta.create_all(engine)
def core_schema_installed(engine=None):
"""
Checks whether the core schema has been installed to a database.
:param engine: A SQLAlchemy engine. If none is provided,
:attr:`rattail.db.engine` is assumed.
:type engine: ``sqlalchemy.engine.Engine`` instance, or ``None``
:returns: ``True`` if the core schema appears to be installed; otherwise
``False``.
:rtype: boolean
"""
if engine is None:
engine = db.engine
if engine is None:
raise NoDefaultDatabase()
# Check database existence and/or connectivity.
try:
conn = engine.connect()
except OperationalError:
return False
else:
conn.close()
# Issue dummy query to verify core table existence.
session = db.Session(bind=engine)
try:
session.query(Product).count()
except (OperationalError, ProgrammingError):
return False
finally:
session.close()
return True
def get_setting(session, name):
"""
Returns a setting from the database.
"""
setting = session.query(Setting).get(name)
if setting:
return setting.value
return None
def save_setting(session, name, value):
"""
Saves a setting to the database.
"""
setting = session.query(Setting).get(name)
if not setting:
setting = Setting(name=name)
session.add(setting)
setting.value = value

View file

@ -70,6 +70,19 @@ UNIT_OF_MEASURE = {
}
EMAIL_PREFERENCE_NONE = 0
EMAIL_PREFERENCE_TEXT = 1
EMAIL_PREFERENCE_HTML = 2
EMAIL_PREFERENCE_MOBILE = 3
EMAIL_PREFERENCE = {
EMAIL_PREFERENCE_NONE : "No Emails",
EMAIL_PREFERENCE_TEXT : "Text",
EMAIL_PREFERENCE_HTML : "HTML",
EMAIL_PREFERENCE_MOBILE : "Mobile",
}
EMPLOYEE_STATUS_CURRENT = 1
EMPLOYEE_STATUS_FORMER = 2

113
rattail/errors.py Normal file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.errors`` -- Error Alert Emails
"""
import os.path
import sys
import socket
import logging
from traceback import format_exception
from cStringIO import StringIO
import rattail
from rattail.mail import sendmail_with_config
from rattail.files import resource_path
from rattail.time import local_time
log = logging.getLogger(__name__)
def init(config):
"""
Creates a system-wide exception hook which logs exceptions and emails them
to configured recipient(s).
"""
def excepthook(type, value, traceback):
email_exception(type, value, traceback)
sys.__excepthook__(type, value, traceback)
sys.excepthook = excepthook
def email_exception(type=None, value=None, traceback=None):
"""
Sends an email containing a traceback to the configured recipient(s).
"""
if not (type and value and traceback):
type, value, traceback = sys.exc_info()
hostname = socket.gethostname()
traceback = ''.join(format_exception(type, value, traceback))
traceback = traceback.strip()
data = {
'host_name': hostname,
'host_ip': socket.gethostbyname(hostname),
'host_time': local_time(),
'traceback': traceback,
}
body, ctype = render_exception(data)
ctype = rattail.config.get('rattail.errors', 'content_type', default=ctype)
sendmail_with_config('errors', body, content_type=ctype)
def render_exception(data):
"""
Renders the exception data using a Mako template if one is configured;
otherwise as a simple string.
"""
template = rattail.config.get('rattail.errors', 'template')
if template:
template = resource_path(template)
if os.path.exists(template):
# Assume Mako template; render and return.
from mako.template import Template
template = Template(filename=template)
return template.render(**data), 'text/html'
# If not a Mako template, return regular text with substitutions.
body = StringIO()
data['host_time'] = data['host_time'].strftime('%Y-%m-%d %H:%M:%S %Z%z')
body.write("""\
An unhandled exception occurred.
Machine Name: %(host_name)s (%(host_ip)s)
Machine Time: %(host_time)s
%(traceback)s
""" % data)
b = body.getvalue()
body.close()
return b, 'text/plain'

View file

@ -27,6 +27,231 @@
"""
class LabelPrintingError(Exception):
class RattailError(Exception):
"""
Base class for all Rattail exceptions.
"""
pass
class ConfigurationError(RattailError):
"""
Base class for configuration errors.
"""
class MissingConfiguration(ConfigurationError):
"""
Raised when the current configuration is missing a required value.
"""
def __init__(self, section, option):
self.section = section
self.option = option
def __str__(self):
return ("Configuration is missing a required option. Please define "
"'%s' within the [%s] section of your configuration file." % (
self.option, self.section))
class InitializationError(RattailError):
"""
Base class for initialization errors.
"""
class ModuleHasNoInit(InitializationError):
"""
Raised when ``init()`` is attempted for a given module, but the module has
no ``init()`` function.
"""
def __init__(self, module):
self.module = module
def __str__(self):
return "Module has no init() function: %s" % self.module.__name__
class MailError(RattailError):
"""
Base class for all mail-related errors.
"""
class SenderNotFound(MailError):
"""
Raised when no sender could be found in config.
"""
def __init__(self, key):
self.key = key
def __str__(self):
return "No sender configured (set 'sender.%s' or 'sender.default' in [rattail.mail])" % self.key
class RecipientsNotFound(MailError):
"""
Raised when no recipients could be found in config.
"""
def __init__(self, key):
self.key = key
def __str__(self):
return "No recipients configured (set 'recipients.%s' or 'recipients.default' in [rattail.mail])" % self.key
class LoadSpecError(RattailError):
"""
Base class for all errors relating to dynamically loading Python objects
from "spec" strings.
"""
class InvalidSpecString(LoadSpecError):
"""
Raised when a "spec" string is not valid.
"""
def __init__(self, spec):
self.spec = spec
def __str__(self):
return "Python spec string is not valid: %s" % self.spec
class ModuleMissingAttribute(InvalidSpecString):
"""
Raised during :func:`rattail.modules.load_spec()` when the module
referenced by the spec is imported okay, but the attribute referenced by
the spec cannot be found.
"""
def __str__(self):
msg = super(ModuleMissingAttribute, self).__str__()
module, attribute = self.spec.split(':')
return "%s (module '%s' was loaded but attribute '%s' not found)" % (
msg, module, attribute)
class NoDefaultDatabase(ConfigurationError):
"""
Raised when configuration does not specify a default database. This may be
the case if no database configuration is present, or, if multiple databases
are confiured, if none can be identified as the default engine.
"""
def __str__(self):
return ("A default database engine could not be determined from the "
"configuration file(s).")
class SchemaExtensionNotFound(ConfigurationError):
"""
Raised when a schema extension is required to exist, but cannot be found.
"""
def __init__(self, name):
self.name = name
def __str__(self):
return "The schema extension '%s' could not be located." % self.name
class SILError(RattailError):
"""
Base class for all SIL errors.
"""
class SILColumnNotFound(SILError):
"""
Raised when a SIL column is requested, but does not exist in the global
dictionary of supported columns.
"""
def __init__(self, column_name):
self.column_name = column_name
def __str__(self):
return "SIL column not found: %s" % self.column_name
class SILInvalidDataType(SILError):
"""
Raised when a SIL data type string is not supported.
"""
def __init__(self, data_type):
self.data_type = data_type
def __str__(self):
return "Invalid SIL data type: %s" % self.data_type
class BatchError(RattailError):
"""
Base class for all batch-related errors.
"""
class BatchTypeNotFound(BatchError):
def __init__(self, name):
self.name = name
def __str__(self):
return "Batch type not found: %s" % self.name
class BatchTypeNotSupported(BatchError):
"""
Raised when a :class:`rattail.db.batches.BatchExecutor` instance is asked
to execute a batch, but the batch is of an unsupported type.
"""
def __init__(self, executor, batch_type):
self.executor = executor
self.batch_type = batch_type
def __str__(self):
return "Batch type '%s' is not supported by executor: %s" % (
self.batch_type, repr(self.executor))
class BatchNotSaved(BatchError):
"""
Raised when an operation is requested on a :class:`rattail.Batch` instance,
but the instance has not yet been committed to the database.
"""
def __str__(self):
return ("Batch is pending; you must flush it to the database before "
"the operation you requested may proceed.")
class BatchProviderNotFound(BatchError):
def __init__(self, name):
self.name = name
def __str__(self):
return "Batch provider not found: %s" % self.name
class BatchDestinationNotSupported(BatchError):
def __init__(self, batch):
self.batch = batch
def __str__(self):
return "Destination '%s' not supported for batch: %s" % (
self.batch.destination, self.batch.description)
class LabelPrintingError(RattailError):
"""
Base class for all label printing errors.
"""

View file

@ -1,42 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.filemon`` -- Windows File Monitor
"""
from edbob.filemon.win32 import FileMonitorService
class RattailFileMonitor(FileMonitorService):
_svc_name_ = "RattailFileMonitor"
_svc_display_name_ = "Rattail : File Monitoring Service"
appname = 'rattail'
if __name__ == '__main__':
import win32serviceutil
win32serviceutil.HandleCommandLine(RattailFileMonitor)

112
rattail/filemon/__init__.py Normal file
View file

@ -0,0 +1,112 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.filemon`` -- File Monitoring Service
"""
import os.path
import logging
import rattail
from rattail.modules import load_spec
log = logging.getLogger(__name__)
class MonitorProfile(object):
"""
This is a simple profile class, used to represent configuration of the file
monitor service.
"""
def __init__(self, key):
self.key = key
self.dirs = rattail.config.require('rattail.filemon', '%s.dirs' % key)
self.dirs = eval(self.dirs)
actions = rattail.config.require('rattail.filemon', '%s.actions' % key)
actions = eval(actions)
self.actions = []
for action in actions:
if isinstance(action, tuple):
spec = action[0]
args = list(action[1:])
else:
spec = action
args = []
func = load_spec(spec)
self.actions.append((spec, func, args))
self.locks = rattail.config.getboolean(
'rattail.filemon', '%s.locks' % key, default=False)
def get_monitor_profiles():
"""
Convenience function to load monitor profiles from config.
"""
monitored = {}
# Read monitor profile(s) from config.
keys = rattail.config.require('rattail.filemon', 'monitored')
keys = keys.split(',')
for key in keys:
key = key.strip()
profile = MonitorProfile(key)
monitored[key] = profile
for path in profile.dirs[:]:
# Ensure the monitored path exists.
if not os.path.exists(path):
log.warning("get_monitor_profiles: Profile '%s' has nonexistent "
"path, which will be pruned: %s" % (key, path))
profile.dirs.remove(path)
# Ensure the monitored path is a folder.
elif not os.path.isdir(path):
log.warning("get_monitor_profiles: Profile '%s' has non-folder "
"path, which will be pruned: %s" % (key, path))
profile.dirs.remove(path)
for key in monitored.keys():
profile = monitored[key]
# Prune any profiles with no valid folders to monitor.
if not profile.dirs:
log.warning("get_monitor_profiles: Profile '%s' has no folders to "
"monitor, and will be pruned." % key)
del monitored[key]
# Prune any profiles with no valid actions to perform.
elif not profile.actions:
log.warning("get_monitor_profiles: Profile '%s' has no actions to "
"perform, and will be pruned." % key)
del monitored[key]
return monitored

142
rattail/filemon/linux.py Normal file
View file

@ -0,0 +1,142 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.filemon.linux`` -- File Monitor for Linux
"""
import sys
import os
import os.path
import signal
import logging
import pyinotify
import rattail
from rattail.filemon import get_monitor_profiles
log = logging.getLogger(__name__)
class EventHandler(pyinotify.ProcessEvent):
"""
Event processor for file monitor daemon.
"""
def my_init(self, actions=[], locks=False, **kwargs):
self.actions = actions
self.locks = locks
def process_IN_ACCESS(self, event):
log.debug("EventHandler: IN_ACCESS: %s" % event.pathname)
def process_IN_ATTRIB(self, event):
log.debug("EventHandler: IN_ATTRIB: %s" % event.pathname)
def process_IN_CLOSE_WRITE(self, event):
log.debug("EventHandler: IN_CLOSE_WRITE: %s" % event.pathname)
if not self.locks:
self.perform_actions(event.pathname)
def process_IN_CREATE(self, event):
log.debug("EventHandler: IN_CREATE: %s" % event.pathname)
def process_IN_DELETE(self, event):
log.debug("EventHandler: IN_DELETE: %s" % event.pathname)
if self.locks and event.pathname.endswith('.lock'):
self.perform_actions(event.pathname[:-5])
def process_IN_MODIFY(self, event):
log.debug("EventHandler: IN_MODIFY: %s" % event.pathname)
def process_IN_MOVED_TO(self, event):
log.debug("EventHandler: IN_MOVED_TO: %s" % event.pathname)
if not self.locks:
self.perform_actions(event.pathname)
def perform_actions(self, path):
for spec, func, args in self.actions:
func(path, *args)
def get_pid_path():
"""
Returns the path to the PID file for the file monitor daemon.
"""
pid_path = rattail.config.get('rattail.filemon', 'pid_path')
if not pid_path:
pid_path = '/tmp/rattail_filemon.pid'
return pid_path
def start_daemon(daemonize=True):
"""
Starts the file monitor daemon.
"""
pid_path = get_pid_path()
if os.path.exists(pid_path):
print "File monitor is already running"
return
wm = pyinotify.WatchManager()
notifier = pyinotify.Notifier(wm)
monitored = get_monitor_profiles()
mask = (pyinotify.IN_ACCESS | pyinotify.IN_ATTRIB
| pyinotify.IN_CLOSE_WRITE | pyinotify.IN_CREATE
| pyinotify.IN_DELETE | pyinotify.IN_MODIFY
| pyinotify.IN_MOVED_TO)
for profile in monitored.itervalues():
for path in profile.dirs:
wm.add_watch(path, mask, proc_fun=EventHandler(
actions=profile.actions, locks=profile.locks))
if not daemonize:
sys.stderr.write("Starting file monitor. (Press Ctrl+C to quit.)\n")
notifier.loop(daemonize=daemonize, pid_file=pid_path)
def stop_daemon():
"""
Stops the file monitor daemon.
"""
pid_path = get_pid_path()
if not os.path.exists(pid_path):
print "File monitor is not running"
return
f = open(pid_path)
pid = f.read().strip()
f.close()
if not pid.isdigit():
log.warning("stop_daemon: Found bogus PID (%s) in file: %s" % (pid, pid_path))
return
os.kill(int(pid), signal.SIGKILL)
os.remove(pid_path)

177
rattail/filemon/win32.py Normal file
View file

@ -0,0 +1,177 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.filemon.win32`` -- File Monitoring Service for Windows
"""
import os.path
import sys
import Queue
import logging
import threading
from rattail.errors import email_exception
from rattail.win32 import Service, file_is_free
from rattail.filemon import get_monitor_profiles
if sys.platform == 'win32':
import win32api
import win32con
import win32event
import win32file
import win32service
import win32serviceutil
import winnt
log = logging.getLogger(__name__)
class RattailFileMonitor(Service):
"""
Implements Rattail's file monitor Windows service.
"""
_svc_name_ = "RattailFileMonitor"
_svc_display_name_ = "Rattail : File Monitoring Service"
_svc_description_ = ("Monitors one or more folders for incoming files, "
"and performs configured actions as new files arrive.")
def Initialize(self):
"""
Service initialization.
"""
if not Service.Initialize(self):
return False
# Read monitor profile(s) from config.
self.monitored = get_monitor_profiles()
# Make sure we have something to do.
if not self.monitored:
return False
# Create monitor and action threads for each profile.
for key, profile in self.monitored.iteritems():
# Create a file queue for the profile.
queue = Queue.Queue()
# Create a monitor thread for each folder in profile.
for i, path in enumerate(profile.dirs, 1):
name = 'monitor-%s-%u' % (key, i)
log.debug("Initialize: Starting '%s' thread for folder: %s" %
(name, path))
thread = threading.Thread(
target=monitor_files,
name=name,
args=(queue, path, profile))
thread.daemon = True
thread.start()
# Create an action thread for the profile.
name = 'actions-%s' % key
log.debug("Initialize: Starting '%s' thread" % name)
thread = threading.Thread(
target=perform_actions,
name=name,
args=(queue, profile))
thread.daemon = True
thread.start()
return True
def monitor_files(queue, path, profile):
"""
Callable target for file monitor threads.
"""
hDir = win32file.CreateFile(
path,
winnt.FILE_LIST_DIRECTORY,
win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE,
None,
win32con.OPEN_EXISTING,
win32con.FILE_FLAG_BACKUP_SEMANTICS,
None)
if hDir == win32file.INVALID_HANDLE_VALUE:
log.warning("monitor_files: Can't open directory with CreateFile(): %s" % path)
return
while True:
results = win32file.ReadDirectoryChangesW(
hDir,
1024,
False,
win32con.FILE_NOTIFY_CHANGE_FILE_NAME)
log.debug("monitor_files: ReadDirectoryChangesW() results: %s" % results)
for action, fname in results:
fpath = os.path.join(path, fname)
if action in (winnt.FILE_ACTION_ADDED,
winnt.FILE_ACTION_RENAMED_NEW_NAME):
log.debug("monitor_files: Queueing '%s' file: %s" %
(profile.key, fpath))
queue.put(fpath)
def perform_actions(queue, profile):
"""
Callable target for action threads.
"""
while True:
try:
path = queue.get_nowait()
except Queue.Empty:
pass
else:
while not file_is_free(path):
win32api.Sleep(0)
for spec, func, args in profile.actions:
log.info("perform_actions: Calling function '%s' on file: %s" %
(spec, path))
try:
func(path, *args)
except:
log.exception("perform_actions: An exception occurred "
"while processing file: %s" % path)
email_exception()
# This file probably shouldn't be processed any further.
break
if __name__ == '__main__':
win32serviceutil.HandleCommandLine(RattailFileMonitor)

172
rattail/files.py Normal file
View file

@ -0,0 +1,172 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.files`` -- File Utilities
This module contains various utility functions for use with the filesystem.
"""
import os
import os.path
import shutil
import tempfile
import lockfile
from datetime import datetime
import pkg_resources
__all__ = ['count_lines', 'creation_time', 'locking_copy', 'resource_path',
'temp_path']
class DosFile(file):
"""
Subclass of ``file`` which uses DOS line endings when writing the file.
"""
def write(self, string):
super(DosFile, self).write(string.replace(os.linesep, '\r\n'))
def count_lines(path):
"""
Counts the number of lines in a text file. Some attempt is made to ensure
cross-platform compatibility.
:param path: Path to the file.
:type path: string
:returns: Number of lines in the file.
:rtype: integer
"""
f = open(path, 'rb')
lines = f.read().count('\n') + 1
f.close()
return lines
def creation_time(path):
"""
Returns the "naive" (i.e. not timezone-aware) creation timestamp for a
file.
:param path: Path to the file.
:type path: string
:returns: The creation timestamp for the file.
:rtype: ``datetime.datetime`` instance
"""
time = os.path.getctime(path)
return datetime.fromtimestamp(time)
def locking_copy(src, dst):
"""
Implements a "locking" version of ``shutil.copy()``.
This function exists to provide a more atomic method for copying a file
into a folder which is being watched by a file monitor. The assumption is
that the monitor is configured to expect "locks" and therefore only process
files once they have had their locks removed.
:param src: Path to the source file.
:type src: string
:param dst: Path to the destination file (or directory).
:type dst: string
:returns: ``None``
"""
if os.path.isdir(dst):
fn = os.path.basename(src)
dst = os.path.join(dst, fn)
with lockfile.FileLock(dst):
shutil.copy(src, dst)
def overwriting_move(src, dst):
"""
Convenience function which is equivalent to ``shutil.move()``, except it
will cause the destination file to be overwritten if it exists.
"""
if os.path.isdir(dst):
dst = os.path.join(dst, os.path.basename(src))
if os.path.exists(dst):
os.remove(dst)
shutil.move(src, dst)
def resource_path(path):
"""
Obtain a resource file path, extracting the resource and/or coercing the
path as necessary.
:param path: May be either a package resource specifier, or a regular file
path.
:type path: string
:returns: Absolute file path to the resource.
:rtype: string
If ``path`` is a package resource specifier, and the package containing it
is a zipped egg, then the resource will be extracted and the resultant
filename will be returned.
"""
if not os.path.isabs(path) and ':' in path:
return pkg_resources.resource_filename(*path.split(':'))
return path
def temp_path(suffix='.tmp', prefix='rattail.'):
"""
Returns a (presumably "safe" and unique) temporary file path. This path
will be located in the user's temp folder.
.. note::
Really, the meaning of the parameters is the same as for
``tempfile.mkstemp()``, which is called under the hood.
:param suffix: Suffix for the filename. This must include the "dot" if you
wish it to come through as a proper extension.
:type suffix: string
:param prefix: Prefix for the filename.
:type prefix: string
:returns: Path to the temporary file.
:rtype: string
"""
fd, path = tempfile.mkstemp(suffix=suffix, prefix=prefix)
os.close(fd)
os.remove(path)
return path

View file

@ -26,76 +26,15 @@
``rattail.gpc`` -- Global Product Code
"""
import warnings
from rattail import barcodes
class GPC(object):
"""
Class to abstract the details of Global Product Code data. Examples of
this would be UPC or EAN barcodes.
class GPC(barcodes.GPC):
The initial motivation for this class was to provide better SIL support.
To that end, the instances are assumed to always be comprised of only
numeric digits, and must include a check digit. If you do not know the
check digit, provide a ``calc_check_digit`` value to the constructor.
"""
def __init__(self, value, calc_check_digit=False):
"""
Constructor. ``value`` must be either an integer or a long value, or a
string containing only digits.
If ``calc_check_digit`` is ``False``, then ``value`` is assumed to
include the check digit. If the value does not include a check digit
and needs one to be calculated, then ``calc_check_digit`` should be a
keyword signifying the algorithm to be used.
Currently the only check digit algorithm keyword supported is
``'upc'``. As that is likely to always be the default, a
``calc_check_digit`` value of ``True`` will be perceived as equivalent
to ``'upc'``.
"""
value = str(value)
if calc_check_digit is True or calc_check_digit == 'upc':
value += str(barcodes.upc_check_digit(value))
self.value = int(value)
def __eq__(self, other):
try:
return int(self) == int(other)
except (TypeError, ValueError):
return False
def __ne__(self, other):
try:
return int(self) != int(other)
except (TypeError, ValueError):
return True
def __cmp__(self, other):
if int(self) < int(other):
return -1
if int(self) > int(other):
return 1
if int(self) == int(other):
return 0
assert False
def __hash__(self):
return hash(self.value)
def __int__(self):
return int(self.value)
def __long__(self):
return long(self.value)
def __repr__(self):
return "GPC('%014d')" % self.value
def __str__(self):
return str(unicode(self))
def __unicode__(self):
return u'%014d' % self.value
def __init__(self, *args, **kwargs):
warnings.warn("Using 'rattail.gpc.GPC' is deprecated; "
"please use 'rattail.GPC' instead.",
DeprecationWarning)
super(GPC, self).__init__(*args, **kwargs)

136
rattail/initialization.py Normal file
View file

@ -0,0 +1,136 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.initialization`` -- Framework Initialization
"""
import os
import rattail
from rattail.exceptions import ModuleHasNoInit
from rattail.configuration import (
RattailConfigParser, default_system_paths, default_user_paths)
__all__ = ['init', 'init_modules']
inited = []
def init(*args, **kwargs):
"""
Initialize the running application.
This function is primarily responsible for reading the configuration
file(s) which determine the particulars of how the app should behave. In
addition to this (depending on what the config files dictate), various
modules may be asked to initialize as well.
:param \*args: The meaning of ``args`` is as follows:
If ``args`` is empty, the ``RATTAIL_CONFIG`` environment variable is
first consulted. If it is nonempty, then its value is split according
to ``os.pathsep`` and the resulting sequence is passed to
:meth:`rattail.configuration.RattailConfigParser.read()`.
If both ``args`` and ``RATTAIL_CONFIG`` are empty, the "standard"
locations are assumed, and the results of calling both
:func:`rattail.configuration.default_system_paths()` and
:func:`rattail.configuration.default_user_paths()` are passed on to
:meth:`rattail.configuration.RattailConfigParser.read()`.
If ``args`` consists only of a single value of ``None``, then no
configuration files will be read.
Any other values in ``args`` will be passed directly to
:meth:`rattail.configuration.RattailConfigParser.read()`` and so will be
interpreted there. Basically they are assumed to be either strings, or
sequences of strings, which represent paths to various config files,
each being read in the order in which it appears within ``args``.
(Configuration is thereby cascaded such that the file read last will
override those before it.)
:type args: zero or more strings
:param \*\*kwargs: Keyword arguments are currently used only for testing.
:returns: ``None``
"""
config = RattailConfigParser()
if args:
if len(args) == 1 and args[0] is None:
config_paths = []
else:
config_paths = list(args)
elif os.environ.get('RATTAIL_CONFIG'):
config_paths = os.environ['RATTAIL_CONFIG'].split(os.pathsep)
else: # pragma: no cover
config_paths = default_system_paths() + default_user_paths()
service = kwargs.get('service')
if service:
config.read_service(service, config_paths)
else:
for paths in config_paths:
config.read(paths)
if not kwargs.get('testing'):
config.configure_logging()
rattail.config = config
inited.append('rattail')
def init_modules(modules, config=None):
"""
Initialize the given modules, if they haven't yet been. Any modules which
have previously been initialized will be silently skipped.
:param modules: Sequence of strings, each of which must be a dotted module
name which points to a Python module containing an ``init()`` function.
:type modules: sequence
:param config: Configuration object which will be passed to the modules'
``init()`` function. If none is specified, then :attr:`rattail.config`
will be used.
:type config: ``ConfigParser`` instance, or ``None``
:raises: :class:`rattail.exceptions.ModuleHasNoInit`, when a module is
encountered which contains no ``init()`` function.
:returns: ``None``
"""
if config is None:
config = rattail.config
for name in modules:
name = name.strip()
if name and name not in inited:
module = __import__(name, globals(), locals(), fromlist=['init'])
if not hasattr(module, 'init'):
raise ModuleHasNoInit(module)
getattr(module, 'init')(config)
inited.append(name)

View file

@ -32,13 +32,13 @@ import socket
import shutil
from cStringIO import StringIO
import edbob
from edbob.util import OrderedDict, requires_impl
from rattail.exceptions import LabelPrintingError
from rattail.util import Object, OrderedDict
from rattail.files import temp_path
from rattail.time import local_time
class LabelPrinter(edbob.Object):
class LabelPrinter(Object):
"""
Base class for all label printers.
@ -52,13 +52,12 @@ class LabelPrinter(edbob.Object):
formatter = None
required_settings = None
@requires_impl()
def print_labels(self, labels, *args, **kwargs):
"""
Prints labels found in ``labels``.
"""
pass
raise NotImplementedError
class CommandPrinter(LabelPrinter):
@ -115,7 +114,7 @@ class CommandFilePrinter(CommandPrinter):
if not output_dir:
raise LabelPrintingError("Printer does not have an output folder defined")
labels_path = edbob.temp_path(prefix='rattail.', suffix='.labels')
labels_path = temp_path('.labels')
labels_file = open(labels_path, 'w')
header = self.batch_header_commands()
@ -136,7 +135,7 @@ class CommandFilePrinter(CommandPrinter):
labels_file.close()
fn = '%s_%s.labels' % (socket.gethostname(),
edbob.local_time().strftime('%Y-%m-%d_%H-%M-%S'))
local_time().strftime('%Y-%m-%d_%H-%M-%S'))
final_path = os.path.join(output_dir, fn)
shutil.move(labels_path, final_path)
return final_path
@ -202,7 +201,7 @@ class CommandNetworkPrinter(CommandPrinter):
data.close()
class LabelFormatter(edbob.Object):
class LabelFormatter(Object):
"""
Base class for all label formatters.
"""
@ -219,13 +218,12 @@ class LabelFormatter(edbob.Object):
raise NotImplementedError
@requires_impl()
def format_labels(self, labels, progress=None, *args, **kwargs):
"""
Formats ``labels`` and returns the result.
"""
pass
raise NotImplementedError
class CommandFormatter(LabelFormatter):
@ -276,9 +274,8 @@ class CommandFormatter(LabelFormatter):
return None
@requires_impl()
def label_body_commands(self):
pass
raise NotImplementedError
def label_footer_commands(self):
"""
@ -300,14 +297,13 @@ class TwoUpCommandFormatter(CommandFormatter):
"""
@property
@requires_impl(is_property=True)
def half_offset(self):
"""
The X-coordinate value by which the second label should be offset, when
two labels are printed side-by-side.
"""
pass
raise NotImplementedError
def format_labels(self, labels, progress=None):
prog = None

View file

@ -23,13 +23,5 @@
################################################################################
"""
``rattail.extension`` -- Database Extension
``rattail.lib`` -- Third-Party Libraries
"""
from edbob.db.extensions import Extension
class RattailExtension(Extension):
name = 'rattail'
required_extensions = ['contact', 'auth']

76
rattail/lib/pretty.py Normal file
View file

@ -0,0 +1,76 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
pretty
Formats dates, numbers, etc. in a pretty, human readable format.
"""
__author__ = "S Anand (sanand@s-anand.net)"
__copyright__ = "Copyright 2010, S Anand"
__license__ = "WTFPL"
# Note that some modifications exist between this file and the original as
# downloaded from http://pypi.python.org/pypi/py-pretty
from datetime import datetime
from rattail.time import utc_time
def _df(seconds, denominator=1, text='', past=True):
if past: return str((seconds + denominator/2)/ denominator) + text + ' ago'
else: return 'in ' + str((seconds + denominator/2)/ denominator) + text
def date(time=False, asdays=False, short=False):
'''Returns a pretty formatted date.
Inputs:
time is a datetime object or an int timestamp
asdays is True if you only want to measure days, not seconds
short is True if you want "1d ago", "2d ago", etc. False if you want
'''
now = utc_time()
if type(time) is int: time = datetime.fromtimestamp(time)
elif not time: time = now
if time > now: past, diff = False, time - now
else: past, diff = True, now - time
seconds = diff.seconds
days = diff.days
if short:
if days == 0 and not asdays:
if seconds < 10: return 'now'
elif seconds < 60: return _df(seconds, 1, 's', past)
elif seconds < 3600: return _df(seconds, 60, 'm', past)
else: return _df(seconds, 3600, 'h', past)
else:
if days == 0: return 'today'
elif days == 1: return past and 'yest' or 'tom'
elif days < 7: return _df(days, 1, 'd', past)
elif days < 31: return _df(days, 7, 'w', past)
elif days < 365: return _df(days, 30, 'mo', past)
else: return _df(days, 365, 'y', past)
else:
if days == 0 and not asdays:
if seconds < 10: return 'now'
elif seconds < 60: return _df(seconds, 1, ' seconds', past)
elif seconds < 120: return past and 'a minute ago' or 'in a minute'
elif seconds < 3600: return _df(seconds, 60, ' minutes', past)
elif seconds < 7200: return past and 'an hour ago' or'in an hour'
else: return _df(seconds, 3600, ' hours', past)
else:
if days == 0: return 'today'
elif days == 1: return past and 'yesterday' or'tomorrow'
elif days == 2: return past and 'day before' or 'day after'
elif days < 7: return _df(days, 1, ' days', past)
elif days < 14: return past and 'last week' or 'next week'
elif days < 31: return _df(days, 7, ' weeks', past)
elif days < 61: return past and 'last month' or 'next month'
elif days < 365: return _df(days, 30, ' months', past)
elif days < 730: return past and 'last year' or 'next year'
else: return _df(days, 365, ' years', past)

139
rattail/mail.py Normal file
View file

@ -0,0 +1,139 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.mail`` -- Email Framework
"""
import logging
import smtplib
from email.message import Message
import rattail
from rattail.exceptions import SenderNotFound, RecipientsNotFound
log = logging.getLogger(__name__)
def sendmail(sender, recipients, subject, body, content_type='text/plain'):
"""
Sends an email message from ``sender`` to each address in ``recipients``
(which should be a sequence), with subject ``subject`` and body ``body``.
If sending an HTML message instead of plain text, be sure to set
``content_type`` to ``'text/html'``.
"""
message = Message()
message.set_type(content_type)
message['From'] = sender
for recipient in recipients:
message['To'] = recipient
message['Subject'] = subject
message.set_payload(body)
server = rattail.config.get('rattail.mail', 'smtp.server',
default='localhost')
username = rattail.config.get('rattail.mail', 'smtp.username')
password = rattail.config.get('rattail.mail', 'smtp.password')
log.debug("sendmail: connecting to server: %s" % server)
session = smtplib.SMTP(server)
if username and password:
res = session.login(username, password)
log.debug("sendmail: login result is: %s" % str(res))
res = session.sendmail(message['From'], message.get_all('To'), message.as_string())
log.debug("sendmail: sendmail result is: %s" % res)
session.quit()
def get_sender(key):
sender = rattail.config.get('rattail.mail', 'sender.'+key)
if sender:
return sender
sender = rattail.config.get('rattail.mail', 'sender.default')
if sender:
return sender
raise SenderNotFound(key)
def get_recipients(key):
recips = rattail.config.get('rattail.mail', 'recipients.'+key)
if recips:
return eval(recips)
recips = rattail.config.get('rattail.mail', 'recipients.default')
if recips:
return eval(recips)
raise RecipientsNotFound(key)
def get_subject(key):
subject = rattail.config.get('rattail.mail', 'subject.'+key)
if subject:
return subject
subject = rattail.config.get('rattail.mail', 'subject.default')
if subject:
return subject
return "[Rattail]"
def sendmail_with_config(key, body, subject=None, **kwargs):
"""
.. highlight:: ini
Sends mail using sender/recipient/subject values found in config, according
to ``key``. Probably the easiest way to explain would be to show an example::
[rattail.mail]
smtp.server = localhost
sender.default = Lance Edgar <lance@edbob.org>
subject.default = A Nice Shrubbery, Not Too Expensive
recipients.nightly_reports = ['Lance Edgar <lance@edbob.org>']
subject.tragic_errors = The World Is Nearing The End!!
recipients.tragic_errors = ['Lance Edgar <lance@edbob.org>']
Anything not configured explicitly will fall back to defaults where
possible. Note however that a sender and recipients (default or otherwise)
*must* be found or else an exception will be raised.
The above does not include a default recipient list, but it would work the same
as the subject and sender as far as the key goes. To send these mails then::
from rattail.mail import sendmail_with_config
# just a report
sendmail_with_config('nightly_reports', open('report.txt').read())
# you might want to sit down for this one...
sendmail_with_config('tragic_errors', open('OMGWTFBBQ.txt').read())
If you do not provide ``subject`` to this function, and there is no
``subject.default`` setting found in config, a default of "[Rattail]" will
be used.
"""
if subject is None:
subject = get_subject(key)
return sendmail(get_sender(key), get_recipients(key), subject, body, **kwargs)

159
rattail/modules.py Normal file
View file

@ -0,0 +1,159 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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 Affero 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
``rattail.modules`` -- Python Module Tools
This module contains various utilities for working with Python modules.
"""
import sys
from rattail.exceptions import InvalidSpecString, ModuleMissingAttribute
def graft(module, source, names=None):
"""
Grafts attributes from a source namespace onto a target namespace.
:param module: Module onto which the attributes should be grafted.
:type module: module
:param source: Source which contains the attributes to be grafted.
:type source: module or dictionary
:param names: If provided, must be either a string attribute name, or a
sequence of string attribute names. If it not provided, then
"everything" from ``source`` will be grafted. (See note below.)
:type names: varies
:returns: ``None``
.. note::
If "everything" is to be grafted (i.e. ``names`` is ``None``), and
``source`` is a module, then ``source.__all__`` will be consulted if
available. If the module does not contain an ``__all__`` attribute,
then ``dir(source)`` will be used instead. Or if ``source`` is a
dictionary, its keys will be used to determine what should be grafted.
"""
if names is None:
if isinstance(source, dict):
names = source.keys()
elif hasattr(source, '__all__'):
names = source.__all__
else:
names = [x for x in dir(source) if not x.startswith('_')]
elif isinstance(names, basestring):
names = [names]
for name in names:
if hasattr(source, name):
setattr(module, name, getattr(source, name))
else:
setattr(module, name, source.get(name))
if not hasattr(module, '__all__'):
module.__all__ = []
module.__all__.append(name)
def import_module_path(module_path):
"""
Imports and returns an arbitrary Python module, given its "dotted" path
(i.e. not its file path).
"""
if module_path in sys.modules:
return sys.modules[module_path]
module = __import__(module_path)
return last_module(module, module_path)
def last_module(module, module_path):
"""
Returns the "last" module represented by ``module_path``, by walking
``module`` until the desired module is found.
For example, passing a reference to the ``rattail`` module and a module
path of ``"rattail.sw.ishida.slpv"``, this function will ultimately return
a reference to the actual ``rattail.sw.ishida.slpv`` module.
This function is primarily used by :func:`import_module_path()`, since
Python's ``__import__()`` function will typically return the top-most
("first") module in the dotted path.
"""
parts = module_path.split('.')
parts.pop(0)
child = getattr(module, parts[0])
if len(parts) == 1:
return child
return last_module(child, '.'.join(parts))
def load_spec(spec):
"""
.. highlight:: none
Returns an object as found in a module namespace. ``spec`` should be of
the same form which setuptools uses for its entry points, e.g.::
rattail.ce:collect_batch
The above would return a reference to the ``collect_batch`` function found
in the ``rattail.ce`` module namespace. The module is loaded (imported) if
necessary.
"""
if spec.count(':') != 1:
raise InvalidSpecString(spec)
module_path, attr = spec.split(':')
module = import_module_path(module_path)
if not hasattr(module, attr):
raise ModuleMissingAttribute(spec)
return getattr(module, attr)
def prune(module, names):
"""
Remove one or more attributes from a module. This is the complement of
:func:`graft()`, and is meant to undo its changes. (Mostly this exists for
the sake of tests.)
:param module: Module from which attributes should be removed.
:type module: module
:param names: One or more attribute names. A single name may be passed as
a string; otherwise a sequence is expected.
:type names: sequence or string
:returns: ``None``
"""
if isinstance(names, basestring):
names = [names]
for name in names:
module.__all__.remove(name)
del module.__dict__[name]

View file

@ -24,6 +24,8 @@
"""
``rattail.pricing`` -- Pricing Utilities
This module contains various functions for price calculations.
"""
@ -32,18 +34,38 @@ __all__ = ['gross_margin']
def gross_margin(price, cost):
"""
Calculate and return a gross margin percentage based on ``price`` and
``cost``.
Calculate and return a gross margin percentage.
If ``price`` is empty (or zero), returns ``None``.
:param price: The retail price.
:type price: float or ``decimal.Decimal`` instance
If ``cost`` is empty (or zero), returns ``100``.
:param cost: The wholesale cost.
:type cost: float or ``decimal.Decimal`` instance
:returns: The gross margin percentage, or ``None``.
:rtype: varies
If ``price`` is empty or zero, this function will return ``None``.
If ``cost`` is empty or zero: If ``price`` is a ``float`` instance, this
function will return ``100.0``; otherwise it will return ``100``.
If both ``price`` and ``cost`` are non-empty, a true calculation will
be performed. The result will be of the same type as the inputs.
.. note::
If the type of inputs do not match (e.g. one is a ``float`` instance and
the other a ``decimal.Decimal`` instance), the underlying error which
results from attempting to do the math will not be handled and will
raise to the caller.
"""
if not price:
return None
if not cost:
if isinstance(price, float):
return 100.0
return 100
return 100 * (price - cost) / price

View file

@ -23,14 +23,22 @@
################################################################################
"""
``rattail.sil`` -- Standard Interchange Language
``rattail.sil`` -- SIL Support
This module provides support for writing, and otherwise interacting with,
Standard Interchange Language (SIL) elements and files. Its purpose is
primarily to support writing files which target various external systems for
the sake of integration. However, the notion of SIL columns is also used in
Rattail's native batch framework.
The *eventual* goal is to add support for reading SIL files also, to extend the
integration concept so that external systems may create batches within Rattail.
Please see the `Standard Interchange Language Specifications
<http://productcatalog.gs1us.org/Store/tabid/86/CategoryID/21/List/1/catpageindex/2/Level/a/ProductID/46/Default.aspx>`_
for more information.
for more information about SIL itself.
"""
from rattail.sil.columns import *
from rattail.sil.batches import *
from rattail.sil.sqlalchemy import *
from rattail.sil.writer import *

View file

@ -23,34 +23,46 @@
################################################################################
"""
``rattail.sil.batches`` -- Batch Stuff
``rattail.sil.batches`` -- SIL Batch Utilities
"""
import edbob
from rattail.configuration import UserConfigFileStorage
__all__ = ['consume_batch_id']
def consume_batch_id(source='RATAIL'):
def consume_batch_id(source='RATAIL', storage=None):
"""
Returns the next available batch identifier for ``source``, incrementing
the number to preserve uniqueness.
Returns the next available SIL batch identifier for ``source``,
incrementing the number to preserve uniqueness.
:param source: The SIL-compliant source name for the batch. If none is
explicitly provided, ``'RATAIL'`` is assumed.
:type source: string
:param storage: The engine which implements storage of the batch
identifier. This engine will be consulted to determine the next
available identifier, and the incremented identifier value will be
written to it as well.
If no storage engine is specified, an instance of
:class:`rattail.configuration.UserConfigFileStorage` will be created to
handle the request.
:type storage: :class:`rattail.configuration.Storage` instance, or
``None``
:returns: A numeric batch identifier, zero-padded to 8 digits.
:rtype: string
"""
option = 'next_batch_id.%s' % source
if not storage: # pragma: no cover
storage = UserConfigFileStorage()
config = edbob.AppConfigParser('rattail')
config_path = config.get_user_file('rattail.conf', create=True)
config.read(config_path)
batch_id = config.get('rattail.sil', option, default='')
option = 'next_batch_id.%s' % source.lower()
batch_id = storage.get('rattail.sil', option, default='')
if not batch_id.isdigit():
batch_id = '1'
batch_id = int(batch_id)
config.set('rattail.sil', option, str(batch_id + 1))
config_file = open(config_path, 'w')
config.write(config_file)
config_file.close()
return '%08u' % batch_id
storage.put('rattail.sil', option, str(batch_id + 1))
return '%08d' % batch_id

View file

@ -26,35 +26,70 @@
``rattail.sil.columns`` -- SIL Columns
"""
import edbob
from edbob.util import entry_point_map
import pkg_resources
from rattail.sil.exceptions import SILColumnNotFound
from rattail.exceptions import SILColumnNotFound
__all__ = ['get_column']
__all__ = ['Column', 'get_column']
supported_columns = None
class SILColumn(edbob.Object):
class Column(object):
"""
Represents a column for use with SIL.
Represents a column for use with SIL. The following attributes exist for
``Column`` instances, most of which must be provided to the constructor:
.. attribute:: name
The SIL-compliant name for the column as a string, e.g. ``'F01'``.
.. attribute:: data_type
The SIL-compliant data type for the column as a string. Per the SIL
specifications, there are six primary data types available. The
following examples highlight the possibilities which exist for this
attribute:
* ``'GPC(14)'``
* ``'CHAR(30)'``
* ``'NUMBER(9,5)'``
* ``'DATE(7)'``
* ``'TIME(4)'``
* ``'FLAG(1)'``
.. note::
Only the ``'CHAR'`` and ``'NUMBER'`` data types allow arbitrary size
parameters, and only the ``'NUMBER'`` type allows an arbitrary
precision parameter. All other columns must be specified exactly as
listed above. Again, see the official SIL language documentation for
more info.
.. attribute:: description
The human-readable description for the column, e.g. ``"Department
Number"``.
.. attribute:: display_name
The name used when displaying the column within the user interface. If
this attribute is not explicitly provided, then :attr:`description` will
be used instead.
"""
def __init__(self, name, data_type, description, display_name=None, **kwargs):
edbob.Object.__init__(self, **kwargs)
def __init__(self, name, data_type, description, display_name=None):
self.name = name
self.data_type = data_type
self.description = description
self.display_name = display_name or description
def __repr__(self):
return "<SILColumn: %s>" % self.name
return "<Column: %s>" % self.name
def __unicode__(self):
return unicode(self.name)
def __str__(self):
return str(self.name)
def provide_columns():
@ -62,7 +97,7 @@ def provide_columns():
Provides all SIL columns natively supported by Rattail.
"""
SC = SILColumn
SC = Column
standard = [ # These columns are part of the SIL standard.
@ -135,15 +170,25 @@ def provide_columns():
def get_column(name):
"""
Returns the :class:`SILColumn` instance named ``name``.
Consults the global dictionary of supported columns, looking for a column
named ``name``. If found, it is returned; otherwise an error is raised.
:param name: The SIL-compliant name of the column, e.g. ``'F01'``.
:type name: string
:raises: :class:`rattail.exceptions.SILColumnNotFound`, if the column
cannot be found.
:returns: :class:`Column` instance
"""
global supported_columns
if supported_columns is None:
supported_columns = {}
providers = entry_point_map('rattail.sil.column_providers')
for provider in providers.itervalues():
for provider in pkg_resources.iter_entry_points(
'rattail.sil.column_providers'):
provider = provider.load()
supported_columns.update(provider())
column = supported_columns.get(name)

View file

@ -29,51 +29,100 @@
import datetime
from decimal import Decimal
import edbob
import rattail
from rattail.gpc import GPC
__all__ = ['Writer']
class Writer(edbob.Object):
class Writer(object):
"""
This class contains logic for writing SIL files.
"""
def __init__(self, path):
"""
Constructor.
:param path: Path to the SIL file which is to be written.
:type path: string
The new instance will immediately open a file object for writing, using
the path provided.
"""
def __init__(self, path, **kwargs):
edbob.Object.__init__(self, **kwargs)
self.sil_path = path
self.fileobj = self.get_fileobj()
def get_fileobj(self):
"""
Should return a file-like object to which textual commands and data
will be written. This method is called by the constructor.
This may be overriden within a subclass if needed. The default
implementation simply opens the SIL file path (passed to the
constructor) with write access.
:returns: File-like object.
:rtype: object
"""
return open(self.sil_path, 'w')
def close(self):
"""
Closes the underlying file object. You should call this when you have
finished writing the file.
:returns: ``None``
"""
self.fileobj.close()
def write(self, string):
"""
Writes an arbitrary string to the underlying file object.
:param string: String to be written.
:type string: string
:returns: ``None``
"""
self.fileobj.write(string)
def val(self, value):
"""
Returns a string version of ``value``, suitable for inclusion within a
data row of a SIL batch. The conversion is done as follows:
Converts a value to a string, suitable for inclusion within a data row
of a SIL batch.
:param value: Arbitrary value to be converted.
:type value: any
:returns: String version of ``value``, possibly quoted and escaped to
be SIL-compliant.
:rtype: string
The conversion is done as follows:
If ``value`` is ``None``, an empty string is returned.
If it is an ``int`` or ``decimal.Decimal`` instance, it is converted
directly to a string (i.e. not quoted).
If ``value`` is an ``int`` or ``decimal.Decimal`` instance, it is
converted directly to a string (i.e. not quoted).
If it is a ``datetime.date`` instance, it will be formatted as
``'%Y%j'``.
If ``value`` is a ``datetime.date`` instance, it will be formatted as a
7-digit Julian date (``'%Y%j'``).
If it is a ``datetime.time`` instance, it will be formatted as
``'%H%M'``.
If ``value`` is a ``datetime.time`` instance, it will be formatted as a
4-digit numeric-only value (``'%H%M'``).
Otherwise, it is converted to a string if necessary, and quoted with
apostrophes escaped.
Any other value will first be converted to a string (if it isn't
already), and quoted with apostrophes escaped.
"""
if value is None:
return ''
if isinstance(value, GPC):
if isinstance(value, rattail.GPC):
return str(value)
if isinstance(value, int):
return str(value)
@ -89,19 +138,62 @@ class Writer(edbob.Object):
value = str(value)
return "'%s'" % value.replace("'", "''")
def write(self, string):
self.fileobj.write(string)
def write_row(self, row, quote=True, last=False):
"""
Writes a SIL data row string to the underlying file object.
:param row: Sequence of values to be written.
:type row: sequence
:param quote: Whether or not the values within ``row`` should be ran
through the :meth:`val()` method (vs. writing as-is). Note that
this parameter exists primarily for the header writing methods. It
should typically be left alone for writing actual data rows.
:type quote: boolean
:param last: Whether or not the row should be considered the last,
within the current batch. This controls whether the "terminating"
character within the text line being written will be a comma or a
semi-colon.
:type last: boolean
:returns: ``None``
"""
terminator = ';' if last else ','
if quote:
row = [self.val(x) for x in row]
self.fileobj.write('(' + ','.join(row) + ')' + terminator + '\n')
def write_rows(self, rows):
"""
Writes a series of data row strings to the underlying file object.
:param rows: Sequence of sequences (!) containing the data to be
written.
:type rows: sequence
:returns: ``None``
This function simply provides a wrapper for :meth:`write_row()`. Its
primary function is to handle the mundane task of setting the ``last``
flag when calling ``write_row()``.
"""
last = len(rows) - 1
for i, row in enumerate(rows):
self.write_row(row, last=i == last)
def write_batch_header_raw(self, **kwargs):
"""
Writes a SIL batch header string. All keyword arguments correspond to
the SIL specification for the Batch Header Dictionary.
Writes a SIL batch header string to the underlying file object.
**Batch Header Dictionary:**
All keyword arguments correspond to the SIL specification for the Batch
Header Dictionary:
==== ==== ==== ===========
==== ====== ==== ===========
Name Type Size Description
==== ==== ==== ===========
==== ====== ==== ===========
H01 CHAR 2 Batch Type
H02 CHAR 8 Batch Identifier
H03 CHAR 6 Source Identifier
@ -125,8 +217,11 @@ class Writer(edbob.Object):
H21 CHAR 50 Primary Key
H22 CHAR 512 System Specific Command
H23 CHAR 8 Dictionary Revision
==== ====== ==== ===========
Consult the SIL Specification for more information.
Consult the SIL Specifications for more information.
:returns: ``None``
"""
kw = kwargs
@ -167,28 +262,35 @@ class Writer(edbob.Object):
self.val(kw.get('H23')),
]
self.fileobj.write('INSERT INTO HEADER_DCT VALUES\n')
self.write('INSERT INTO HEADER_DCT VALUES\n')
self.write_row(row, quote=False, last=True)
self.fileobj.write('\n')
self.write('\n')
def write_batch_header(self, **kwargs):
"""
Convenience method to take some of the gruntwork out of writing batch
Convenience method to take some of the grunt work out of writing batch
headers.
If you do not override ``H03`` (Source Identifier), then Rattail will
provide a default value for it, as well as ``H20`` (Software Revision)
- that is, unless you've supplied it yourself.
All SIL batch header keyword arguments are accepted, as with
:meth:`write_batch_header_raw()`. However, this method provides some
default values:
If you do not provide values for ``H07`` or ``H08``, the current date
and time will be assumed.
If you do not override ``H03`` (Source Identifier), then a default
value of ``'RATAIL'`` will be used. If you do not override ``H03``
*or* ``H20`` (Software Revision), then ``rattail.__version__`` will be
used for the latter.
If you do not provide values for ``H09`` or ``H10``, it is assumed that
you wish the batch to be immediately executable. Default values will
be provided accordingly.
If you do not override ``H07`` or ``H08`` (Origin Date and Time), the
current date and time will be assumed.
If you do not provide a value for ``H11`` (Purge Date), a default of 90
days from the current date will be assumed.
If you do not override ``H09`` or ``H10`` (Execution Date and Time), it
is assumed that you wish the batch to be immediately executable.
Default values will be provided accordingly.
If you do not override ``H11`` (Purge Date), a default of 90 days from
the current date will be assumed.
:returns: ``None``
"""
kw = kwargs
@ -203,7 +305,7 @@ class Writer(edbob.Object):
# Provide default (current local time) values H07 and H08 (Origin Date /
# Time) if none was specified.
now = edbob.local_time()
now = datetime.datetime.now()
if 'H07' not in kw:
kw['H07'] = now.date()
if 'H08' not in kw:
@ -224,16 +326,16 @@ class Writer(edbob.Object):
def write_create_header(self, **kwargs):
"""
Convenience method to take some of the gruntwork out of writing batch
headers.
Convenience method to write a batch creation header.
The following default values are provided by this method:
All SIL batch header keyword arguments are accepted, as with
:meth:`write_batch_header()`. However, this method provides some
additional default values:
* ``H01`` = ``'HC'``
* ``H12`` = ``'LOAD'``
This method also calls :meth:`write_batch_header()`; see its
documentation for the other default values provided.
:returns: ``None``
"""
kw = kwargs
@ -243,51 +345,17 @@ class Writer(edbob.Object):
def write_maintenance_header(self, **kwargs):
"""
Convenience method to take some of the gruntwork out of writing batch
headers.
Convenience method to write a batch maintenance header.
The following default values are provided by this method:
All SIL batch header keyword arguments are accepted, as with
:meth:`write_batch_header()`. However, this method provides some
additional default values:
* ``H01`` = ``'HM'``
This method also calls :meth:`write_batch_header()`; see its
documentation for the other default values provided.
:returns: ``None``
"""
kw = kwargs
kw.setdefault('H01', 'HM')
self.write_batch_header(**kw)
def write_row(self, row, quote=True, last=False):
"""
Writes a SIL row string.
``row`` should be a sequence of values.
If ``quote`` is ``True``, each value in ``row`` will be ran through the
:func:`val()` function before being written. If it is ``False``, the
values are written as-is.
If ``last`` is ``True``, then ``';'`` will be used as the statement
terminator; otherwise ``','`` is used.
"""
terminator = ';' if last else ','
if quote:
row = [self.val(x) for x in row]
self.fileobj.write('(' + ','.join(row) + ')' + terminator + '\n')
def write_rows(self, rows):
"""
Writes a set of SIL row strings.
``rows`` should be a sequence of sequences, each of which should be
suitable for use with :meth:`write_row()`.
(This funcion primarily exists to handle the mundane task of setting
the ``last`` flag when calling :meth:`write_row()`.)
"""
last = len(rows) - 1
for i, row in enumerate(rows):
self.write_row(row, last=i == last)

View file

@ -0,0 +1,9 @@
An unhandled exception occurred.
*Machine Name:* ${host_name} (${host_ip})
*Machine Time:* ${host_time.strftime('%Y-%m-%d %H:%M:%S %Z%z')}
<pre>
${traceback}
</pre>

21
rattail/tests/__init__.py Normal file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env python
from rattail.configuration import RattailConfigParser
class CancelProgress(object):
def __init__(self, message, maximum):
pass
def update(self, value):
return False
def destroy(self):
pass
def get_config():
config = RattailConfigParser()
config.set('rattail', 'testing', 'True')
return config

View file

@ -0,0 +1 @@
#!/usr/bin/env python

View file

@ -0,0 +1 @@
#!/usr/bin/env python

View file

@ -0,0 +1,132 @@
#!/usr/bin/env python
import unittest
from rattail.tests import CancelProgress
import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from rattail.batches import providers
from rattail.exceptions import BatchProviderNotFound
from rattail.db.model import Batch
from rattail.db.util import install_core_schema
class BogusProvider(providers.BatchProvider):
name = 'bogus'
source = 'BOGUS'
def add_columns(self, batch):
pass
def add_row(self, batch, datum):
pass
class CancelBegin(BogusProvider):
def add_rows_begin(self, batch, data):
return False
class CancelEnd(BogusProvider):
def add_rows_end(self, batch, progress=None):
return False
# class BatchProviderTest(unittest.TestCase):
# def test_add_columns(self):
# provider = providers.BatchProvider()
# batch = Batch()
# self.assertEqual(len(batch.columns), 0)
# self.assertRaises(NotImplementedError, provider.add_columns, batch)
# self.assertEqual(len(batch.columns), 0)
# def test_add_rows_begin(self):
# provider = CancelBegin()
# self.assertEqual(provider.add_rows_begin(Batch(), []), False)
# self.assertEqual(provider.add_rows(Batch(), []), False)
# def test_add_rows(self):
# provider = BogusProvider()
# self.assertEqual(provider.add_rows(Batch(), ['stuff'], CancelProgress), False)
# def test_add_rows_end(self):
# provider = CancelEnd()
# self.assertEqual(provider.add_rows_end(Batch()), False)
# self.assertEqual(provider.add_rows(Batch(), []), False)
# provider = BogusProvider()
# self.assertEqual(provider.add_rows_end(Batch()), None)
# def test_make_batch(self):
# engine = create_engine('sqlite://')
# install_core_schema(engine)
# Session = sessionmaker(bind=engine)
# provider = BogusProvider()
# session = Session()
# batch = provider.make_batch(session, [], id='00000420')
# self.assertEqual(batch.id, '00000420')
# self.assertEqual(batch.provider, 'bogus')
# self.assertEqual(batch.source, 'BOGUS')
# session.rollback()
# session.close()
# def test_make_batch_cancel_end(self):
# engine = create_engine('sqlite://')
# install_core_schema(engine)
# Session = sessionmaker(bind=engine)
# provider = CancelEnd()
# session = Session()
# batch = provider.make_batch(session, [], id='00000420')
# self.assertEqual(batch, None)
# session.rollback()
# session.close()
# def test_execute(self):
# provider = providers.BatchProvider()
# self.assertRaises(NotImplementedError, provider.execute, Batch())
# def test_set_params(self):
# provider = providers.BatchProvider()
# Session = sessionmaker()
# provider.set_params(Session())
# def test_set_purge_date(self):
# provider = providers.BatchProvider()
# batch = Batch()
# provider.set_purge_date(batch)
# self.assertEqual(batch.purge,
# datetime.date.today() + datetime.timedelta(days=90))
class GetProvidersTest(unittest.TestCase):
def test_get_providers(self):
providers_ = providers.get_providers()
self.assertIsInstance(providers_, dict)
self.assertGreater(len(providers_), 0)
class IterProvidersTest(unittest.TestCase):
def test_iter_providers(self):
providers_ = providers.iter_providers()
self.assertIsInstance(providers_, list)
self.assertGreater(len(providers_), 0)
class GetProviderTest(unittest.TestCase):
def test_good(self):
provider = providers.get_provider('print_labels')
self.assertIsInstance(provider, providers.BatchProvider)
self.assertEqual(provider.name, 'print_labels')
def test_bad(self):
self.assertRaises(BatchProviderNotFound, providers.get_provider, 'bogus')

View file

@ -0,0 +1,80 @@
#!/usr/bin/env python
import unittest
from rattail.tests.db import ignores_warnings
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from rattail.batches.providers import labels
from rattail.db.util import install_core_schema
from rattail.db.model import Batch, LabelProfile, Brand, Product
# class PrintLabelsTest(unittest.TestCase):
# def setUp(self):
# engine = create_engine('sqlite://')
# install_core_schema(engine)
# self.Session = sessionmaker(bind=engine)
# session = self.Session()
# brand = Brand()
# brand.name = 'Acme'
# session.add(brand)
# product = Product()
# product.brand = brand
# product.description = 'Test'
# session.add(product)
# profile = LabelProfile()
# profile.code = 'XXX'
# profile.printer_spec = 'rattail.labels:LabelPrinter'
# session.add(profile)
# session.commit()
# session.close()
# def test_make_batch(self):
# session = self.Session()
# provider = labels.PrintLabels()
# batch = provider.make_batch(session, [], id='00000420')
# self.assertEqual(len(batch.columns), 6)
# batch.drop_table()
# session.rollback()
# session.close()
# def test_make_batch_with_profile(self):
# session = self.Session()
# provider = labels.PrintLabels()
# provider.default_profile = session.query(LabelProfile).one()
# batch = provider.make_batch(session, [], id='00000420')
# self.assertEqual(len(batch.columns), 6)
# batch.drop_table()
# session.rollback()
# session.close()
# def test_set_params(self):
# session = self.Session()
# profile = session.query(LabelProfile).one()
# provider = labels.PrintLabels()
# self.assertEqual(provider.default_profile, None)
# self.assertEqual(provider.default_quantity, 1)
# provider.set_params(session, profile='XXX', quantity='2')
# self.assertIs(provider.default_profile, profile)
# self.assertEqual(provider.default_quantity, 2)
# session.close()
# @ignores_warnings
# def test_add_rows(self):
# session = self.Session()
# provider = labels.PrintLabels()
# batch = provider.make_batch(session, session.query(Product), id='00000420')
# self.assertEqual(len(batch.columns), 6)
# self.assertEqual(batch.rowcount, 1)
# batch.drop_table()
# session.rollback()
# session.close()

8
rattail/tests/bogus1.py Normal file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env python
__all__ = ['bogus_thing1']
bogus_thing1 = object()
def init(config):
pass

3
rattail/tests/bogus2.py Normal file
View file

@ -0,0 +1,3 @@
#!/usr/bin/env python
bogus_thing2 = object()

View file

@ -0,0 +1,15 @@
############################################################
#
# base.conf
#
# This specifies only that we're running in "test mode."
#
############################################################
[rattail]
testing = True
some_option = some_value
[rattail.service_config]
RattailFileMonitor = [r'%(here)s\service.conf']

View file

@ -0,0 +1,17 @@
############################################################
#
# db.conf
#
# This configuration is used for those tests needing only
# a single database. The SQLite in-memory engine will
# suffice in these cases.
#
############################################################
[rattail]
testing = True
[rattail.db]
engines = default
default.url = sqlite://

View file

@ -0,0 +1,13 @@
############################################################
#
# local.conf
#
# This is used for testing configuration inheritance.
#
############################################################
[rattail]
testing = True
include_config = [r'%(here)s\base.conf']
another_option = another_value

View file

@ -0,0 +1,12 @@
############################################################
#
# service.conf
#
# This is used for testing special service configuration.
#
############################################################
[rattail]
testing = True
hells = yeah

View file

@ -0,0 +1,40 @@
#!/usr/bin/env python
import os
import unittest
import warnings
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from rattail.db.util import install_core_schema
from rattail.db.model import Base
class DataTestCase(unittest.TestCase):
default_url = 'postgresql://rattail:rattail@localhost/rattail.test'
def setUp(self):
data_url = os.environ.get('RATTAIL_TEST_ENGINE', self.default_url)
self.engine = create_engine(data_url)
install_core_schema(self.engine)
self.Session = sessionmaker(bind=self.engine)
def tearDown(self):
Base.metadata.drop_all(self.engine)
def ignores_warnings(func):
def wrapped(*args, **kwargs):
with warnings.catch_warnings():
warnings.filterwarnings(
'ignore',
r"^Dialect sqlite\+pysqlite does \*not\* support Decimal "
"objects natively, and SQLAlchemy must convert from floating "
"point - rounding errors and other issues may occur\. Please "
"consider storing Decimal numbers as strings or integers on "
"this platform for lossless storage\.$")
return func(*args, **kwargs)
wrapped.__name__ = func.__name__
return wrapped

View file

@ -0,0 +1 @@
#!/usr/bin/env python

View file

@ -0,0 +1,116 @@
#!/usr/bin/env python
import unittest
from rattail.tests.db import DataTestCase
from rattail.db.batches import data
from rattail.db.model import Brand, Product
from rattail.barcodes import GPC
class BatchDataProviderTest(unittest.TestCase):
def test_not_implemented(self):
provider = data.BatchDataProvider()
self.assertRaises(NotImplementedError, len, provider)
self.assertRaises(NotImplementedError, iter, provider)
class QueryDataProviderTest(DataTestCase):
def setUp(self):
super(QueryDataProviderTest, self).setUp()
session = self.Session()
product = Product()
product.upc = '074305001321'
product.description = 'Apple Cider Vinegar'
product.size = '32oz'
session.add(product)
session.commit()
session.close()
def test_len(self):
session = self.Session()
query = session.query(Product)
provider = data.QueryDataProvider(query)
self.assertEqual(len(provider), 1)
query = query.filter_by(upc='074305001161')
provider = data.QueryDataProvider(query)
self.assertEqual(len(provider), 0)
session.close()
class ProductQueryDataProviderTest(DataTestCase):
def setUp(self):
super(ProductQueryDataProviderTest, self).setUp()
session = self.Session()
brand = Brand()
brand.name = "Bragg's"
session.add(brand)
product = Product()
product.brand = brand
product.upc = '074305001321'
product.description = 'Apple Cider Vinegar'
product.size = '32oz'
session.add(product)
product = Product()
product.brand = brand
product.upc = '074305001161'
product.description = 'Apple Cider Vinegar'
product.size = '16oz'
session.add(product)
session.commit()
session.close()
def test_iter(self):
session = self.Session()
query = session.query(Product)
provider = data.ProductQueryDataProvider(query)
self.assertEqual(len(provider), 2)
iterator = iter(provider)
proxy = iterator.next()
proxy = iterator.next()
self.assertRaises(StopIteration, iterator.next)
session.close()
class ProductDataProxyTest(unittest.TestCase):
def test_without_brand(self):
product = Product()
product.upc = GPC('074305001161')
product.description = 'Apple Cider Vinegar'
product.size = '16oz'
proxy = data.ProductDataProxy(product)
self.assertIsInstance(proxy.F01, GPC)
self.assertEqual(proxy.F01, '074305001161')
self.assertEqual(proxy.F02, 'Apple Cider Vinegar')
self.assertEqual(proxy.F22, '16oz')
self.assertEqual(proxy.F155, '')
self.assertRaises(AttributeError, getattr, proxy, 'F420')
def test_with_brand(self):
product = Product()
product.brand = Brand()
product.brand.name = "Bragg's"
product.upc = GPC('074305001321')
product.description = 'Apple Cider Vinegar'
product.size = '32oz'
proxy = data.ProductDataProxy(product)
self.assertIsInstance(proxy.F01, GPC)
self.assertEqual(proxy.F01, '074305001321')
self.assertEqual(proxy.F02, 'Apple Cider Vinegar')
self.assertEqual(proxy.F22, '32oz')
self.assertEqual(proxy.F155, "Bragg's")
self.assertRaises(AttributeError, getattr, proxy, 'F420')

View file

@ -0,0 +1,152 @@
#!/usr/bin/env python
import unittest
from rattail.tests.db import DataTestCase
from rattail.tests import CancelProgress
from rattail.db.batches import executors
from rattail.db.model import Batch, Product, LabelProfile
from rattail.exceptions import BatchTypeNotSupported
from rattail.labels import LabelPrinter
class BatchExecutorTest(DataTestCase):
def test_not_implemented(self):
executor = executors.BatchExecutor()
self.assertIsNone(executor.batch_type)
self.assertRaises(NotImplementedError, executor.execute_batch, None, None)
session = self.Session()
batch = Batch()
session.add(batch)
self.assertRaises(NotImplementedError, executor.execute, batch)
session.rollback()
session.close()
def test_batch_type_not_supported(self):
executor = executors.BatchExecutor()
session = self.Session()
batch = Batch()
batch.type = 'bogus'
session.add(batch)
self.assertRaises(BatchTypeNotSupported, executor.execute, batch)
session.rollback()
session.close()
class GoodPrinter(LabelPrinter):
def print_labels(self, labels, *args, **kwargs):
return True
class BadPrinter(LabelPrinter):
def print_labels(self, labels, *args, **kwargs):
return False
class LabelsBatchExecutorTest(DataTestCase):
def setUp(self):
super(LabelsBatchExecutorTest, self).setUp()
session = self.Session()
product = Product()
product.upc = '074305001321'
product.description = 'Apple Cider Vinegar'
product.size = '32oz'
session.add(product)
profile = LabelProfile()
profile.code = 'REG'
profile.printer_spec = 'rattail.tests.db.batches.test_executors:GoodPrinter'
profile.formatter_spec = 'rattail.labels:LabelFormatter'
session.add(profile)
profile = LabelProfile()
profile.code = 'SAL'
profile.printer_spec = 'rattail.tests.db.batches.test_executors:BadPrinter'
profile.formatter_spec = 'rattail.labels:LabelFormatter'
session.add(profile)
session.commit()
session.close()
def create_batch(self):
batch = Batch()
batch.type = 'labels'
batch.add_column('F01')
batch.add_column('F155')
batch.add_column('F02')
batch.add_column('F22', display_name="Size")
batch.add_column('F95', display_name="Label")
batch.add_column('F94', display_name="Quantity")
return batch
def test_execute_cancel_progress(self):
session = self.Session()
batch = self.create_batch()
session.add(batch)
session.flush()
batch.create_table()
row = batch.rowclass()
row.F01 = '074305001321'
row.F94 = 1
row.F95 = 'REG'
batch.add_row(row)
session.flush()
executor = executors.LabelsBatchExecutor()
self.assertFalse(executor.execute(batch, CancelProgress))
session.rollback()
session.close()
def test_execute_good_printer(self):
session = self.Session()
batch = self.create_batch()
session.add(batch)
session.flush()
batch.create_table()
row = batch.rowclass()
row.F01 = '074305001321'
row.F94 = 1
row.F95 = 'REG'
batch.add_row(row)
session.flush()
executor = executors.LabelsBatchExecutor()
self.assertTrue(executor.execute(batch))
session.commit()
batch.drop_table()
session.commit()
session.close()
def test_execute_bad_printer(self):
session = self.Session()
batch = self.create_batch()
session.add(batch)
session.flush()
batch.create_table()
row = batch.rowclass()
row.F01 = '074305001321'
row.F94 = 1
row.F95 = 'SAL'
batch.add_row(row)
session.flush()
executor = executors.LabelsBatchExecutor()
self.assertFalse(executor.execute(batch))
session.rollback()
session.close()

View file

@ -0,0 +1,187 @@
#!/usr/bin/env python
from rattail.tests.db import DataTestCase, ignores_warnings
from rattail.tests import CancelProgress
from rattail.db.batches import makers
from rattail.db.batches import BatchType, ProductQueryDataProvider
from rattail.db.model import Batch, LabelProfile, Brand, Product
class FakeType(BatchType):
def add_columns(self, batch):
batch.add_column('F01')
batch.add_column('F02')
class FakeMaker(makers.BatchMaker):
def make_batch_fake(self):
fake = FakeType()
batch = Batch()
fake.initialize(batch)
return batch
def process_data_row(self, data_row):
batch = self.get_batch('fake')
row = batch.rowclass()
row.F01 = data_row.F01
row.F02 = data_row.F02
batch.add_row(row)
class FakeMakerCancelBegin(FakeMaker):
def make_batches_begin(self, data):
return False
class FakeMakerCancelEnd(FakeMaker):
def make_batches_end(self):
return False
class BatchMakerTest(DataTestCase):
def test_bad_data(self):
session = self.Session()
maker = makers.BatchMaker(session)
self.assertRaises(TypeError, maker.make_batches, [])
self.assertRaises(TypeError, maker.make_batches, session.query(Product))
session.close()
def test_implemented(self):
session = self.Session()
product = Product()
product.upc = '074305001321'
product.description = 'Apple Cider Vinegar'
session.add(product)
session.flush()
maker = makers.BatchMaker(session)
data = ProductQueryDataProvider(session.query(Product))
self.assertRaises(NotImplementedError, maker.make_batches, data)
session.rollback()
session.close()
@ignores_warnings
def test_custom_maker(self):
session = self.Session()
product = Product()
product.upc = '074305001321'
product.description = 'Apple Cider Vinegar'
session.add(product)
session.flush()
self.assertEqual(session.query(Batch).count(), 0)
maker = FakeMaker(session)
data = ProductQueryDataProvider(session.query(Product))
self.assertTrue(maker.make_batches(data), True)
session.commit()
self.assertEqual(session.query(Batch).count(), 1)
batch = session.query(Batch).one()
self.assertTrue(batch.rowclass.__table__.exists(session.connection()))
batch.drop_table()
session.commit()
session.close()
def test_cancel_begin(self):
session = self.Session()
product = Product()
product.upc = '074305001321'
product.description = 'Apple Cider Vinegar'
session.add(product)
session.flush()
self.assertEqual(session.query(Batch).count(), 0)
maker = FakeMakerCancelBegin(session)
data = ProductQueryDataProvider(session.query(Product))
self.assertEqual(maker.make_batches(data), False)
session.rollback()
self.assertEqual(session.query(Batch).count(), 0)
session.close()
@ignores_warnings
def test_cancel_end(self):
session = self.Session()
product = Product()
product.upc = '074305001321'
product.description = 'Apple Cider Vinegar'
session.add(product)
session.flush()
self.assertEqual(session.query(Batch).count(), 0)
maker = FakeMakerCancelEnd(session)
data = ProductQueryDataProvider(session.query(Product))
self.assertEqual(maker.make_batches(data), False)
session.rollback()
self.assertEqual(session.query(Batch).count(), 0)
session.close()
def test_cancel_progress(self):
session = self.Session()
product = Product()
product.upc = '074305001321'
product.description = 'Apple Cider Vinegar'
session.add(product)
session.flush()
self.assertEqual(session.query(Batch).count(), 0)
maker = FakeMaker(session)
data = ProductQueryDataProvider(session.query(Product))
self.assertEqual(maker.make_batches(data, CancelProgress), False)
session.rollback()
self.assertEqual(session.query(Batch).count(), 0)
session.close()
class LabelsBatchMakerTest(DataTestCase):
@ignores_warnings
def test_maker(self):
session = self.Session()
profile = LabelProfile()
profile.code = 'XXX'
session.add(profile)
brand = Brand()
brand.name = "Bragg's"
session.add(brand)
product = Product()
product.upc = '074305001321'
product.brand = brand
product.description = 'Apple Cider Vinegar'
product.size = '32oz'
session.add(product)
session.commit()
session.close()
session = self.Session()
self.assertEqual(session.query(Batch).count(), 0)
maker = makers.LabelsBatchMaker(session)
data = ProductQueryDataProvider(session.query(Product))
self.assertEqual(maker.make_batches(data), True)
session.commit()
session.close()
session = self.Session()
self.assertEqual(session.query(Batch).count(), 1)
batch = session.query(Batch).one()
self.assertEqual(batch.rowcount, 1)
self.assertEqual(batch.rows.count(), 1)
session.close()

View file

@ -0,0 +1,94 @@
#!/usr/bin/env python
import unittest
import datetime
import pytz
import rattail
from rattail.db.batches import types
from rattail.db.model import Batch
from rattail.time import timezones
from rattail.exceptions import BatchTypeNotFound
class FakeBatchType(types.BatchType):
def add_columns(self, batch):
pass
class GetBatchTypeTest(unittest.TestCase):
def test_invalid(self):
self.assertRaises(BatchTypeNotFound, types.get_batch_type, 'bogus')
def test_labels(self):
type = types.get_batch_type('labels')
self.assertIsInstance(type, types.LabelsBatchType)
class BatchTypeTest(unittest.TestCase):
def setUp(self):
timezones['local'] = pytz.timezone('UTC')
def tearDown(self):
del timezones['local']
def test_not_implemented(self):
type = types.BatchType()
self.assertRaises(NotImplementedError, type.add_columns, Batch())
self.assertRaises(NotImplementedError, type.initialize, Batch())
def test_purge_date(self):
today = datetime.datetime.utcnow().date()
type = types.BatchType()
batch = Batch()
self.assertIsNone(batch.purge)
type.set_purge_date(batch)
self.assertIsNone(batch.purge)
type.purge_date_offset = 90
type.set_purge_date(batch)
self.assertEqual(batch.purge, today + datetime.timedelta(days=90))
type.purge_date_offset = 420
type.set_purge_date(batch)
self.assertEqual(batch.purge, today + datetime.timedelta(days=420))
def test_initialize(self):
type = FakeBatchType()
batch = Batch()
type.initialize(batch)
self.assertIsNone(batch.description)
self.assertIsNone(batch.source)
self.assertIsNone(batch.destination)
self.assertIsNone(batch.action_type)
type.description = 'Testing'
type.source = 'RATAIL'
type.destination = 'LOCAL'
type.action_type = 'ADD'
type.initialize(batch)
self.assertEqual(batch.description, 'Testing')
self.assertEqual(batch.source, 'RATAIL')
self.assertEqual(batch.destination, 'LOCAL')
self.assertEqual(batch.action_type, 'ADD')
class LabelsBatchTypeTest(unittest.TestCase):
def test_initialize(self):
type = types.LabelsBatchType()
batch = Batch()
self.assertIsNone(batch.description)
self.assertEqual(len(batch.columns), 0)
type.initialize(batch)
self.assertEqual(batch.description, 'Print Labels')
self.assertEqual(len(batch.columns), 6)
self.assertEqual(batch.columns[0].name, 'F01')
self.assertEqual(batch.columns[1].name, 'F155')
self.assertEqual(batch.columns[2].name, 'F02')
self.assertEqual(batch.columns[3].name, 'F22')
self.assertEqual(batch.columns[4].name, 'F95')
self.assertEqual(batch.columns[5].name, 'F94')

View file

@ -0,0 +1,222 @@
#!/usr/bin/env python
import unittest
from rattail.tests.db import DataTestCase
import bcrypt
from cStringIO import StringIO
from rattail.db import auth
from rattail.db.model import User, Role
class BcryptAuthenticatorTest(unittest.TestCase):
def test_populate_user(self):
user = User()
self.assertIsNone(user.salt)
self.assertIsNone(user.password)
authenticator = auth.BcryptAuthenticator()
authenticator.populate_user(user, 'seekrit')
self.assertIsNotNone(user.salt)
self.assertIsNotNone(user.password)
old_salt = user.salt
old_password = user.password
authenticator.populate_user(user, 'seekrit')
self.assertNotEqual(user.salt, old_salt)
self.assertNotEqual(user.password, old_password)
def test_authenticate_user(self):
user = User()
authenticator = auth.BcryptAuthenticator()
authenticator.populate_user(user, 'seekrit')
self.assertTrue(authenticator.authenticate_user(user, 'seekrit'))
self.assertFalse(authenticator.authenticate_user(user, 'bogus'))
class AuthenticateUserTest(DataTestCase):
def setUp(self):
super(AuthenticateUserTest, self).setUp()
authenticator = auth.BcryptAuthenticator()
user = User()
user.username = 'fred'
authenticator.populate_user(user, 'seekrit')
session = self.Session()
session.add(user)
session.commit()
session.close()
def test_authenticate_user(self):
session = self.Session()
user = session.query(User).one()
self.assertIs(auth.authenticate_user(session, 'fred', 'seekrit'), user)
self.assertIsNone(auth.authenticate_user(session, 'fred', 'bogus'))
self.assertIsNone(auth.authenticate_user(session, 'wilma', 'bogus'))
session.close()
class AdministratorRoleTest(DataTestCase):
def test_administrator_role(self):
session = self.Session()
admin1 = auth.administrator_role(session)
session.commit()
admin2 = auth.administrator_role(session)
self.assertIs(admin1, admin2)
session.close()
class GuestRoleTest(DataTestCase):
def test_guest_role(self):
session = self.Session()
guest1 = auth.guest_role(session)
session.commit()
guest2 = auth.guest_role(session)
self.assertIs(guest1, guest2)
session.close()
class GrantPermissionTest(DataTestCase):
def test_grant_permission(self):
session = self.Session()
guest = auth.guest_role(session)
session.commit()
session.refresh(guest)
self.assertEqual(len(guest.permissions), 0)
auth.grant_permission(session, guest, 'products.delete')
session.commit()
session.refresh(guest)
self.assertEqual(len(guest.permissions), 1)
self.assertEqual(guest.permissions[0], 'products.delete')
session.close()
class HasPermissionTest(DataTestCase):
def setUp(self):
super(HasPermissionTest, self).setUp()
session = self.Session()
user = User()
user.username = 'fred'
session.add(user)
role = Role()
role.name = 'Testing'
session.add(role)
session.commit()
session.close()
def test_invalid_object(self):
session = self.Session()
self.assertRaises(TypeError, auth.has_permission, session, object(), 'products.delete')
session.close()
def test_user_with_guest(self):
session = self.Session()
user = session.query(User).one()
self.assertFalse(auth.has_permission(
session, user, 'products.delete', include_guest=True))
guest = auth.guest_role(session)
auth.grant_permission(session, guest, 'products.delete')
session.commit()
self.assertTrue(auth.has_permission(
session, user, 'products.delete', include_guest=True))
session.close()
def test_user_without_guest(self):
session = self.Session()
user = session.query(User).one()
self.assertFalse(auth.has_permission(
session, user, 'products.delete', include_guest=False))
guest = auth.guest_role(session)
auth.grant_permission(session, guest, 'products.delete')
session.commit()
self.assertFalse(auth.has_permission(
session, user, 'products.delete', include_guest=False))
session.close()
def test_role_with_guest(self):
session = self.Session()
role = session.query(Role).one()
self.assertFalse(auth.has_permission(
session, role, 'products.delete', include_guest=True))
guest = auth.guest_role(session)
auth.grant_permission(session, guest, 'products.delete')
session.commit()
self.assertTrue(auth.has_permission(
session, role, 'products.delete', include_guest=True))
session.close()
def test_role_without_guest(self):
session = self.Session()
role = session.query(Role).one()
self.assertFalse(auth.has_permission(
session, role, 'products.delete', include_guest=False))
guest = auth.guest_role(session)
auth.grant_permission(session, guest, 'products.delete')
session.commit()
self.assertFalse(auth.has_permission(
session, role, 'products.delete', include_guest=False))
session.close()
def test_none_with_guest(self):
session = self.Session()
self.assertFalse(auth.has_permission(
session, None, 'products.delete', include_guest=True))
guest = auth.guest_role(session)
auth.grant_permission(session, guest, 'products.delete')
session.commit()
self.assertTrue(auth.has_permission(
session, None, 'products.delete', include_guest=True))
session.close()
def test_none_without_guest(self):
session = self.Session()
self.assertFalse(auth.has_permission(
session, None, 'products.delete', include_guest=False))
guest = auth.guest_role(session)
auth.grant_permission(session, guest, 'products.delete')
session.commit()
self.assertFalse(auth.has_permission(
session, None, 'products.delete', include_guest=False))
session.close()
def test_admin(self):
session = self.Session()
admin = auth.administrator_role(session)
session.commit()
session.refresh(admin)
self.assertEqual(len(admin.permissions), 0)
self.assertTrue(auth.has_permission(
session, admin, 'products.delete', include_guest=False))
session.close()
class InitDatabaseTest(DataTestCase):
def test_init_database(self):
session = self.Session()
self.assertEqual(session.query(Role).count(), 0)
self.assertEqual(session.query(User).count(), 0)
stream = StringIO()
auth.init_database(session, output_stream=stream)
self.assertEqual(stream.getvalue(), "Created 'admin' user with password 'admin'\n")
stream.close()
session.commit()
self.assertEqual(session.query(Role).count(), 1)
self.assertEqual(session.query(User).count(), 1)
admin = auth.administrator_role(session)
self.assertIs(session.query(Role).one(), admin)
user = session.query(User).one()
self.assertEqual(user.username, 'admin')
authenticator = auth.BcryptAuthenticator()
self.assertTrue(authenticator.authenticate_user(user, 'admin'))
session.close()

View file

@ -0,0 +1,42 @@
#!/usr/bin/env python
import unittest
# import edbob
# import edbob.db
import rattail
from rattail.files import resource_path
from rattail.db.util import install_core_schema
# class RecordChangesTest(unittest.TestCase):
# def setUp(self):
# # engine = create_engine('sqlite://')
# # Base.metadata.create_all(engine)
# # Session.configure(bind=engine)
# edbob.init('rattail', resource_path('rattail.tests:config/db.conf'))
# install_core_schema()
# # edbob.init_modules(['edbob.db'])
# edbob.init_modules(['rattail.db'])
# def tearDown(self):
# del edbob.inited[:]
# edbob.db.engines.clear()
# edbob.db.engine = None
# def test_no_recording(self):
# session = edbob.Session()
# product = rattail.Product()
# session.add(product)
# session.commit()
# session.close()
# session = edbob.Session()
# products = session.query(rattail.Product)
# self.assertEqual(products.count(), 1)
# changes = session.query(rattail.Change)
# self.assertEqual(changes.count(), 0)
# session.close()

View file

@ -0,0 +1,69 @@
#!/usr/bin/env python
import unittest
from rattail.tests import get_config
from rattail import db
from rattail.exceptions import MissingConfiguration, NoDefaultDatabase
class InitTest(unittest.TestCase):
def tearDown(self):
db.engines.clear()
db.engine = None
db.Session.configure(bind=None)
def test_init_first_default(self):
self.assertEqual(db.engines, {})
self.assertEqual(db.engine, None)
session = db.Session()
self.assertEqual(session.bind, None)
config = get_config()
config.set('rattail.db', 'default.url', 'sqlite://')
config.set('rattail.db', 'engines', 'default')
db.init(config)
self.assertEqual(len(db.engines), 1)
self.assertEqual(str(db.engine.url), 'sqlite://')
session = db.Session()
self.assertIs(session.bind, db.engine)
def test_init_second_default(self):
self.assertEqual(db.engines, {})
self.assertEqual(db.engine, None)
session = db.Session()
self.assertEqual(session.bind, None)
config = get_config()
config.set('rattail.db', 'another.url', 'sqlite://')
config.set('rattail.db', 'engines', 'another')
db.init(config)
self.assertEqual(len(db.engines), 1)
self.assertEqual(str(db.engine.url), 'sqlite://')
session = db.Session()
self.assertIs(session.bind, db.engine)
def test_init_no_engines(self):
self.assertEqual(db.engines, {})
self.assertEqual(db.engine, None)
session = db.Session()
self.assertEqual(session.bind, None)
config = get_config()
self.assertRaises(MissingConfiguration, db.init, config)
def test_init_no_default(self):
self.assertEqual(db.engines, {})
self.assertEqual(db.engine, None)
session = db.Session()
self.assertEqual(session.bind, None)
config = get_config()
config.set('rattail.db', 'engines', 'one, two')
config.set('rattail.db', 'one.url', 'sqlite://')
config.set('rattail.db', 'two.url', 'sqlite://')
self.assertRaises(NoDefaultDatabase, db.init, config)

View file

@ -0,0 +1,126 @@
#!/usr/bin/env python
import unittest
from rattail.tests import CancelProgress, get_config
import os
import warnings
from sqlalchemy import create_engine
import rattail
from rattail import db
from rattail.db import load
from rattail.initialization import inited
from rattail.files import temp_path
from rattail.db.util import install_core_schema
from rattail.db.model import Department, Product, ProductPrice
class LoadProcessorTest(unittest.TestCase):
def setUp(self):
rattail.config = get_config()
inited.append('rattail.db')
self.host_path = temp_path('.sqlite')
self.store_path = temp_path('.sqlite')
db.engines = {
'host': create_engine('sqlite:///%s' % self.host_path.replace('\\', '/')),
'store': create_engine('sqlite:///%s' % self.store_path.replace('\\', '/')),
}
db.engine = db.engines['store']
db.Session.configure(bind=db.engine)
install_core_schema(db.engines['host'])
install_core_schema(db.engines['store'])
def tearDown(self):
db.Session.configure(bind=None)
db.engine = None
db.engines.clear()
inited.remove('rattail.db')
del rattail.config
os.remove(self.host_path)
os.remove(self.store_path)
def test_load_cancel(self):
with warnings.catch_warnings():
warnings.filterwarnings(
'ignore',
r"^Dialect sqlite\+pysqlite does \*not\* support Decimal "
"objects natively, and SQLAlchemy must convert from floating "
"point - rounding errors and other issues may occur\. Please "
"consider storing Decimal numbers as strings or integers on "
"this platform for lossless storage\.$")
host_session = db.Session(bind=db.engines['host'])
self.assertEqual(host_session.query(Product).count(), 0)
for x in range(100):
host_session.add(Product())
host_session.commit()
host_session.close()
host_session = db.Session(bind=db.engines['host'])
self.assertEqual(host_session.query(Product).count(), 100)
host_session.close()
store_session = db.Session()
self.assertEqual(store_session.query(Product).count(), 0)
store_session.close()
proc = load.LoadProcessor()
self.assertEqual(proc.load_all_data(db.engines['host'], CancelProgress), False)
store_session = db.Session()
self.assertEqual(store_session.query(Product).count(), 0)
store_session.close()
def test_load(self):
with warnings.catch_warnings():
warnings.filterwarnings(
'ignore',
r"^Dialect sqlite\+pysqlite does \*not\* support Decimal "
"objects natively, and SQLAlchemy must convert from floating "
"point - rounding errors and other issues may occur\. Please "
"consider storing Decimal numbers as strings or integers on "
"this platform for lossless storage\.$")
host_session = db.Session(bind=db.engines['host'])
self.assertEqual(host_session.query(Product).count(), 0)
department = Department()
product = Product()
product.department = department
price = ProductPrice()
product.prices.append(price)
product.regular_price = price
product.current_price = price
host_session.add(product)
host_session.commit()
host_session.close()
host_session = db.Session(bind=db.engines['host'])
self.assertEqual(host_session.query(Department).count(), 1)
self.assertEqual(host_session.query(Product).count(), 1)
self.assertEqual(host_session.query(ProductPrice).count(), 1)
host_session.close()
store_session = db.Session()
self.assertEqual(store_session.query(Department).count(), 0)
self.assertEqual(store_session.query(Product).count(), 0)
self.assertEqual(store_session.query(ProductPrice).count(), 0)
store_session.close()
proc = load.LoadProcessor()
self.assertEqual(proc.load_all_data(db.engines['host']), True)
store_session = db.Session()
self.assertEqual(store_session.query(Department).count(), 1)
self.assertEqual(store_session.query(Product).count(), 1)
self.assertEqual(store_session.query(ProductPrice).count(), 1)
store_session.close()

View file

@ -0,0 +1,711 @@
#!/usr/bin/env python
import unittest
from decimal import Decimal
from sqlalchemy import Column, String
from rattail.db import model
from rattail.util import Object
from rattail.tests.db import DataTestCase
from rattail.exceptions import BatchNotSaved
class UUIDColumnTest(unittest.TestCase):
def test_uuid_column(self):
col = model.uuid_column()
self.assertIsInstance(col, Column)
self.assertEqual(col.primary_key, True)
self.assertIsInstance(col.type, String)
self.assertEqual(col.default.is_callable, True)
class GetSetFactoryTest(unittest.TestCase):
def test_getset_factory(self):
proxy = Object()
proxy.value_attr = 'second'
getter, setter = model.getset_factory(None, proxy)
self.assertEqual(getter(None), None)
obj = Object()
obj.second = 'something'
self.assertEqual(getter(obj), 'something')
setter(obj, 'another')
self.assertEqual(getter(obj), 'another')
class GetPersonDisplayNameTest(unittest.TestCase):
def test_first_and_last(self):
context = Object()
context.current_parameters = {
'first_name': 'Fred',
'last_name': 'Flintstone',
}
self.assertEqual(model.get_person_display_name(context), 'Fred Flintstone')
def test_first_only(self):
context = Object()
context.current_parameters = {
'first_name': 'Fred',
'last_name': '',
}
self.assertEqual(model.get_person_display_name(context), 'Fred')
def test_last_only(self):
context = Object()
context.current_parameters = {
'first_name': '',
'last_name': 'Flintstone',
}
self.assertEqual(model.get_person_display_name(context), 'Flintstone')
def test_neither(self):
context = Object()
context.current_parameters = {
'first_name': '',
'last_name': '',
}
self.assertEqual(model.get_person_display_name(context), None)
class ChangeTest(unittest.TestCase):
def test_repr(self):
change = model.Change(
class_name='Product',
uuid='97fc2e21403f11e298b460eb69b23879',
deleted=False)
self.assertEqual(repr(change), '<Change: Product, 97fc2e21403f11e298b460eb69b23879, new/changed>')
change = model.Change(
class_name='Product',
uuid='97fc2e21403f11e298b460eb69b23879',
deleted=True)
self.assertEqual(repr(change), '<Change: Product, 97fc2e21403f11e298b460eb69b23879, deleted>')
class BatchColumnTest(unittest.TestCase):
def test_construct(self):
column = model.BatchColumn()
self.assertEqual(column.sil_name, None)
column = model.BatchColumn('F01')
self.assertEqual(column.sil_name, 'F01')
self.assertEqual(column.name, 'F01')
self.assertEqual(column.data_type, 'GPC(14)')
column = model.BatchColumn('F01', data_type='bogus')
self.assertEqual(column.sil_name, 'F01')
self.assertEqual(column.data_type, 'bogus')
def test_repr(self):
column = model.BatchColumn('F01')
self.assertEqual(repr(column), '<BatchColumn: F01>')
def test_unicode(self):
column = model.BatchColumn()
self.assertEqual(unicode(column), u'')
column = model.BatchColumn(display_name='Whatever')
self.assertEqual(unicode(column), u'Whatever')
class BatchRowTest(unittest.TestCase):
def test_unicode(self):
row = model.BatchRow(ordinal=420)
self.assertEqual(unicode(row), u'Row 420')
class BatchTest(unittest.TestCase):
def test_repr(self):
batch = model.Batch()
batch.description = 'Test Batch'
self.assertEqual(repr(batch), '<Batch: Test Batch>')
def test_unicode(self):
batch = model.Batch()
self.assertEqual(unicode(batch), u'')
batch.description = 'Test Batch'
self.assertEqual(unicode(batch), u'Test Batch')
def test_add_column(self):
batch = model.Batch()
self.assertEqual(len(batch.columns), 0)
batch.add_column('F01')
self.assertEqual(len(batch.columns), 1)
column = batch.columns[0]
self.assertEqual(column.sil_name, 'F01')
self.assertEqual(column.name, 'F01')
self.assertEqual(column.data_type, 'GPC(14)')
class BatchDataTest(DataTestCase):
def test_batch(self):
session = self.Session()
self.assertEqual(session.query(model.Batch).count(), 0)
batch = model.Batch()
session.add(batch)
session.commit()
session.close()
session = self.Session()
self.assertEqual(session.query(model.Batch).count(), 1)
session.close()
def test_column(self):
session = self.Session()
self.assertEqual(session.query(model.Batch).count(), 0)
self.assertEqual(session.query(model.BatchColumn).count(), 0)
batch = model.Batch()
batch.add_column('F01')
batch.add_column('F02')
session.add(batch)
session.commit()
session.close()
session = self.Session()
self.assertEqual(session.query(model.Batch).count(), 1)
self.assertEqual(session.query(model.BatchColumn).count(), 2)
session.close()
def test_rowclass(self):
before = len(model.Base.metadata.tables)
session = self.Session()
batch = model.Batch()
batch.add_column('F01')
batch.add_column('F02')
session.add(batch)
self.assertRaises(BatchNotSaved, getattr, batch, 'rowclass')
session.commit()
self.assertEqual(len(model.Base.metadata.tables), before)
cls = batch.rowclass
self.assertEqual(len(model.Base.metadata.tables), before + 1)
session.close()
def test_create_table(self):
session = self.Session()
batch = model.Batch()
batch.add_column('F01')
batch.add_column('F02')
session.add(batch)
session.commit()
batch.create_table()
session.close()
def test_drop_table(self):
session = self.Session()
batch = model.Batch()
batch.add_column('F01')
batch.add_column('F02')
session.add(batch)
session.commit()
batch.create_table()
batch.drop_table()
session.close()
def test_row_batch(self):
session = self.Session()
batch = model.Batch()
batch.add_column('F01')
batch.add_column('F02')
batch.add_column('F22')
session.add(batch)
session.commit()
batch.create_table()
row = batch.rowclass()
row.F01 = '074305001321'
row.F02 = 'Apple Cider Vinegar'
row.F22 = '32oz'
batch.add_row(row)
session.commit()
self.assertIs(row.batch, batch)
session.close()
def test_add_row(self):
session = self.Session()
batch = model.Batch()
batch.add_column('F01')
batch.add_column('F02')
batch.add_column('F22')
session.add(batch)
session.commit()
batch.create_table()
self.assertEqual(session.query(batch.rowclass).count(), 0)
self.assertEqual(batch.rowcount, 0)
row = batch.rowclass()
row.F01 = '074305001321'
row.F02 = 'Apple Cider Vinegar'
row.F22 = '32oz'
batch.add_row(row)
row = batch.rowclass()
row.F01 = '074305001161'
row.F02 = 'Apple Cider Vinegar'
row.F22 = '16oz'
batch.add_row(row)
session.commit()
self.assertEqual(session.query(batch.rowclass).count(), 2)
self.assertEqual(batch.rowcount, 2)
session.close()
def test_rows(self):
session = self.Session()
batch = model.Batch()
batch.add_column('F01')
batch.add_column('F02')
batch.add_column('F22')
session.add(batch)
session.commit()
batch.create_table()
self.assertEqual(batch.rows.count(), 0)
row = batch.rowclass()
row.F01 = '074305001321'
row.F02 = 'Apple Cider Vinegar'
row.F22 = '32oz'
batch.add_row(row)
row = batch.rowclass()
row.F01 = '074305001161'
row.F02 = 'Apple Cider Vinegar'
row.F22 = '16oz'
batch.add_row(row)
session.commit()
self.assertEqual(batch.rows.count(), 2)
session.close()
class PhoneNumberTest(unittest.TestCase):
def test_repr(self):
phone = model.PhoneNumber()
phone.number = '800-555-1234'
self.assertEqual(repr(phone), '<PhoneNumber: 800-555-1234>')
def test_unicode(self):
phone = model.PhoneNumber()
phone.number = '800-555-1234'
self.assertEqual(unicode(phone), u'800-555-1234')
class EmailAddressTest(unittest.TestCase):
def test_repr(self):
email = model.EmailAddress()
email.address = 'fred@mailinator.com'
self.assertEqual(repr(email), '<EmailAddress: fred@mailinator.com>')
def test_unicode(self):
email = model.EmailAddress()
email.address = 'fred@mailinator.com'
self.assertEqual(unicode(email), u'fred@mailinator.com')
class PersonTest(unittest.TestCase):
def test_repr(self):
person = model.Person()
person.display_name = 'Fred Flintstone'
self.assertEqual(repr(person), '<Person: Fred Flintstone>')
def test_unicode(self):
person = model.Person()
self.assertEqual(unicode(person), u'')
person.display_name = 'Fred Flintstone'
self.assertEqual(unicode(person), u'Fred Flintstone')
def test_add_email_address(self):
person = model.Person()
self.assertEqual(len(person.emails), 0)
person.add_email_address('fred@mailinator.com', type='Work')
self.assertEqual(len(person.emails), 1)
email = person.emails[0]
self.assertEqual(email.address, 'fred@mailinator.com')
self.assertEqual(email.type, 'Work')
def test_add_phone_number(self):
person = model.Person()
self.assertEqual(len(person.phones), 0)
person.add_phone_number('800-555-1234', type='Work')
self.assertEqual(len(person.phones), 1)
phone = person.phones[0]
self.assertEqual(phone.number, '800-555-1234')
self.assertEqual(phone.type, 'Work')
class StoreTest(unittest.TestCase):
def test_repr(self):
store = model.Store()
store.id = '1'
store.name = 'Acme Brick'
self.assertEqual(repr(store), '<Store: 1, Acme Brick>')
def test_unicode(self):
store = model.Store()
self.assertEqual(unicode(store), u'')
store.name = 'Acme Brick'
self.assertEqual(unicode(store), u'Acme Brick')
def test_add_email_address(self):
store = model.Store()
self.assertEqual(len(store.emails), 0)
store.add_email_address('info@mailinator.com', type='Info')
self.assertEqual(len(store.emails), 1)
email = store.emails[0]
self.assertEqual(email.address, 'info@mailinator.com')
self.assertEqual(email.type, 'Info')
def test_add_phone_number(self):
store = model.Store()
self.assertEqual(len(store.phones), 0)
store.add_phone_number('800-555-1234', type='Help')
self.assertEqual(len(store.phones), 1)
phone = store.phones[0]
self.assertEqual(phone.number, '800-555-1234')
self.assertEqual(phone.type, 'Help')
class BrandTest(unittest.TestCase):
def test_repr(self):
brand = model.Brand()
brand.name = 'Acme'
self.assertEqual(repr(brand), '<Brand: Acme>')
def test_unicode(self):
brand = model.Brand()
self.assertEqual(unicode(brand), u'')
brand.name = 'Acme'
self.assertEqual(unicode(brand), u'Acme')
class DepartmentTest(unittest.TestCase):
def test_repr(self):
department = model.Department()
department.name = 'Grocery'
self.assertEqual(repr(department), '<Department: Grocery>')
def test_unicode(self):
department = model.Department()
self.assertEqual(unicode(department), u'')
department.name = 'Grocery'
self.assertEqual(unicode(department), u'Grocery')
class SubdepartmentTest(unittest.TestCase):
def test_repr(self):
subdepartment = model.Subdepartment()
subdepartment.name = 'Deli'
self.assertEqual(repr(subdepartment), '<Subdepartment: Deli>')
def test_unicode(self):
subdepartment = model.Subdepartment()
self.assertEqual(unicode(subdepartment), u'')
subdepartment.name = 'Deli'
self.assertEqual(unicode(subdepartment), u'Deli')
class CategoryTest(unittest.TestCase):
def test_repr(self):
category = model.Category()
category.number = 420
category.name = 'Munchies'
self.assertEqual(repr(category), '<Category: 420, Munchies>')
def test_unicode(self):
category = model.Category()
self.assertEqual(unicode(category), u'')
category.name = 'Munchies'
self.assertEqual(unicode(category), u'Munchies')
class VendorContactTest(unittest.TestCase):
def test_repr(self):
vendor = model.Vendor()
vendor.name = 'Acme Distribution'
person = model.Person()
person.display_name = 'Fred Flintstone'
contact = model.VendorContact()
contact.vendor = vendor
contact.person = person
self.assertEqual(repr(contact), '<VendorContact: Acme Distribution, Fred Flintstone>')
def test_unicode(self):
person = model.Person()
person.display_name = 'Fred Flintstone'
contact = model.VendorContact()
contact.person = person
self.assertEqual(unicode(contact), u'Fred Flintstone')
class VendorTest(unittest.TestCase):
def test_repr(self):
vendor = model.Vendor()
vendor.name = 'Acme Distribution'
self.assertEqual(repr(vendor), '<Vendor: Acme Distribution>')
def test_unicode(self):
vendor = model.Vendor()
self.assertEqual(unicode(vendor), u'')
vendor.name = 'Acme Distribution'
self.assertEqual(unicode(vendor), u'Acme Distribution')
def test_add_email_address(self):
vendor = model.Vendor()
self.assertEqual(len(vendor.emails), 0)
vendor.add_email_address('sales@mailinator.com', type='Sales')
self.assertEqual(len(vendor.emails), 1)
email = vendor.emails[0]
self.assertEqual(email.address, 'sales@mailinator.com')
self.assertEqual(email.type, 'Sales')
def test_add_phone_number(self):
vendor = model.Vendor()
self.assertEqual(len(vendor.phones), 0)
vendor.add_phone_number('800-555-1234', type='Sales')
self.assertEqual(len(vendor.phones), 1)
phone = vendor.phones[0]
self.assertEqual(phone.number, '800-555-1234')
self.assertEqual(phone.type, 'Sales')
def test_add_contact(self):
vendor = model.Vendor()
self.assertEqual(len(vendor.contacts), 0)
person = model.Person()
vendor.add_contact(person)
self.assertEqual(len(vendor.contacts), 1)
self.assertIs(vendor.contacts[0].person, person)
class ProductCostTest(unittest.TestCase):
def test_repr(self):
product = model.Product()
product.description = 'Test Item'
vendor = model.Vendor()
vendor.name = 'Acme Distribution'
cost = model.ProductCost()
cost.product = product
cost.vendor = vendor
self.assertEqual(repr(cost), '<ProductCost: Test Item : Acme Distribution>')
class ProductPriceTest(unittest.TestCase):
def test_repr(self):
product = model.Product()
product.description = 'Test Item'
price = model.ProductPrice()
price.product = product
price.price = Decimal('4.20')
self.assertEqual(repr(price), '<ProductPrice: Test Item : 4.20>')
class ProductTest(unittest.TestCase):
def test_repr(self):
product = model.Product()
product.description = 'Test Item'
self.assertEqual(repr(product), '<Product: Test Item>')
def test_unicode(self):
product = model.Product()
self.assertEqual(unicode(product), u'')
product.description = 'Test Item'
self.assertEqual(unicode(product), u'Test Item')
class EmployeeTest(unittest.TestCase):
def test_repr(self):
person = model.Person()
person.display_name = 'Fred Flintstone'
employee = model.Employee()
employee.person = person
self.assertEqual(repr(employee), '<Employee: Fred Flintstone>')
def test_unicode(self):
person = model.Person()
person.display_name = 'Fred Flintstone'
employee = model.Employee()
employee.person = person
self.assertEqual(unicode(employee), u'Fred Flintstone')
employee.display_name = 'Fred F.'
self.assertEqual(unicode(employee), u'Fred F.')
def test_add_email_address(self):
employee = model.Employee()
self.assertEqual(len(employee.emails), 0)
employee.add_email_address('fred@mailinator.com', type='Home')
self.assertEqual(len(employee.emails), 1)
email = employee.emails[0]
self.assertEqual(email.address, 'fred@mailinator.com')
self.assertEqual(email.type, 'Home')
def test_add_phone_number(self):
employee = model.Employee()
self.assertEqual(len(employee.phones), 0)
employee.add_phone_number('800-555-1234', type='Home')
self.assertEqual(len(employee.phones), 1)
phone = employee.phones[0]
self.assertEqual(phone.number, '800-555-1234')
self.assertEqual(phone.type, 'Home')
class CustomerTest(unittest.TestCase):
def test_repr(self):
person = model.Person()
person.display_name = 'Fred Flintstone'
customer = model.Customer()
customer.id = '420'
customer.person = person
self.assertEqual(repr(customer), '<Customer: 420, Fred Flintstone>')
customer.name = 'Fred & Wilma Flintstone'
self.assertEqual(repr(customer), '<Customer: 420, Fred & Wilma Flintstone>')
def test_unicode(self):
person = model.Person()
person.display_name = 'Fred Flintstone'
customer = model.Customer()
customer.id = '420'
customer.person = person
self.assertEqual(unicode(customer), u'Fred Flintstone')
customer.name = 'Fred & Wilma Flintstone'
self.assertEqual(unicode(customer), u'Fred & Wilma Flintstone')
def test_add_email_address(self):
customer = model.Customer()
self.assertEqual(len(customer.emails), 0)
customer.add_email_address('fred@mailinator.com', type='Home')
self.assertEqual(len(customer.emails), 1)
email = customer.emails[0]
self.assertEqual(email.address, 'fred@mailinator.com')
self.assertEqual(email.type, 'Home')
def test_add_phone_number(self):
customer = model.Customer()
self.assertEqual(len(customer.phones), 0)
customer.add_phone_number('800-555-1234', type='Home')
self.assertEqual(len(customer.phones), 1)
phone = customer.phones[0]
self.assertEqual(phone.number, '800-555-1234')
self.assertEqual(phone.type, 'Home')
class CustomerGroupTest(unittest.TestCase):
def test_repr(self):
group = model.CustomerGroup()
group.id = '420'
group.name = 'Important Customers'
self.assertEqual(repr(group), '<CustomerGroup: 420, Important Customers>')
def test_unicode(self):
group = model.CustomerGroup()
self.assertEqual(unicode(group), u'')
group.name = 'Important Customers'
self.assertEqual(unicode(group), u'Important Customers')
class LabelProfileTest(unittest.TestCase):
def test_repr(self):
profile = model.LabelProfile()
profile.description = 'Regular Label'
self.assertEqual(repr(profile), '<LabelProfile: Regular Label>')
def test_unicode(self):
profile = model.LabelProfile()
self.assertEqual(unicode(profile), u'')
profile.description = 'Regular Label'
self.assertEqual(unicode(profile), u'Regular Label')
class RoleTest(unittest.TestCase):
def test_repr(self):
role = model.Role()
role.name = 'Manager'
self.assertEqual(repr(role), '<Role: Manager>')
def test_unicode(self):
role = model.Role()
self.assertEqual(unicode(role), u'')
role.name = 'Manager'
self.assertEqual(unicode(role), u'Manager')
class UserTest(unittest.TestCase):
def test_repr(self):
user = model.User()
user.username = 'fred'
self.assertEqual(repr(user), '<User: fred>')
def test_unicode(self):
user = model.User()
self.assertEqual(unicode(user), u'')
user.username = 'fred'
self.assertEqual(unicode(user), u'fred')
def test_display_name(self):
user = model.User()
user.username = 'fred'
self.assertEqual(user.display_name, 'fred')
person = model.Person()
user.person = person
self.assertEqual(user.display_name, 'fred')
person.display_name = 'Fred Flintstone'
self.assertEqual(user.display_name, 'Fred Flintstone')
class UserRoleTest(unittest.TestCase):
def test_repr(self):
fred = model.User()
fred.username = 'fred'
manager = model.Role()
manager.name = 'Manager'
user_role = model.UserRole()
user_role.user = fred
user_role.role = manager
self.assertEqual(repr(user_role), '<UserRole: fred : Manager>')
class PermissionTest(unittest.TestCase):
def test_repr(self):
manager = model.Role()
manager.name = 'Manager'
permission = model.Permission()
permission.role = manager
permission.permission = 'products.delete'
self.assertEqual(repr(permission), '<Permission: Manager, products.delete>')
def test_unicode(self):
permission = model.Permission()
self.assertEqual(unicode(permission), u'')
permission.permission = 'products.delete'
self.assertEqual(unicode(permission), u'products.delete')

View file

@ -0,0 +1,167 @@
#!/usr/bin/env python
import unittest
from sqlalchemy import create_engine
from sqlalchemy import Column, Integer, String, Numeric, Boolean
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
from rattail.db import types
from rattail.barcodes import GPC
from rattail.exceptions import SILInvalidDataType
Base = declarative_base()
Session = sessionmaker()
class Product(Base):
__tablename__ = 'products'
id = Column(Integer, primary_key=True)
upc = Column(types.GPCType)
class GPCTypeTest(unittest.TestCase):
def setUp(self):
engine = create_engine('sqlite://')
Base.metadata.create_all(engine)
Session.configure(bind=engine)
def test_store_integer(self):
session = Session()
product = Product()
product.upc = 74305001321
session.add(product)
session.commit()
session.close()
session = Session()
product = session.query(Product).one()
self.assertIsInstance(product.upc, GPC)
self.assertEqual(product.upc, GPC('00074305001321'))
session.close()
def test_store_string(self):
session = Session()
product = Product()
product.upc = '074305001321'
session.add(product)
session.commit()
session.close()
session = Session()
product = session.query(Product).one()
self.assertIsInstance(product.upc, GPC)
self.assertEqual(product.upc, GPC('00074305001321'))
session.close()
def test_store_gpc(self):
session = Session()
product = Product()
product.upc = GPC('00074305001321')
session.add(product)
session.commit()
session.close()
session = Session()
product = session.query(Product).one()
self.assertIsInstance(product.upc, GPC)
self.assertEqual(product.upc, GPC('00074305001321'))
session.close()
def test_store_none(self):
session = Session()
product = Product()
product.upc = None
session.add(product)
session.commit()
session.close()
session = Session()
product = session.query(Product).one()
self.assertEqual(product.upc, None)
session.close()
def test_query_integer(self):
session = Session()
product = Product()
product.upc = GPC('00074305001321')
session.add(product)
session.commit()
session.close()
session = Session()
query = session.query(Product).filter_by(upc=74305001321)
self.assertEqual(query.count(), 1)
session.close()
def test_query_string(self):
session = Session()
product = Product()
product.upc = GPC('00074305001321')
session.add(product)
session.commit()
session.close()
session = Session()
query = session.query(Product).filter_by(upc='074305001321')
self.assertEqual(query.count(), 1)
session.close()
def test_query_gpc(self):
session = Session()
product = Product()
product.upc = GPC('00074305001321')
session.add(product)
session.commit()
session.close()
session = Session()
query = session.query(Product).filter_by(upc=GPC('00074305001321'))
self.assertEqual(query.count(), 1)
session.close()
def test_query_none(self):
session = Session()
product = Product()
product.upc = None
session.add(product)
session.commit()
session.close()
session = Session()
query = session.query(Product).filter_by(upc=None)
self.assertEqual(query.count(), 1)
session.close()
class GetSILColumnTypeTest(unittest.TestCase):
def test_gpc(self):
self.assertIsInstance(types.get_sil_column_type('GPC(14)'), types.GPCType)
def test_boolean(self):
self.assertIsInstance(types.get_sil_column_type('FLAG(1)'), Boolean)
def test_string(self):
type_ = types.get_sil_column_type('CHAR(30)')
self.assertIsInstance(type_, String)
self.assertEqual(type_.length, 30)
type_ = types.get_sil_column_type('CHAR(255)')
self.assertIsInstance(type_, String)
self.assertEqual(type_.length, 255)
def test_numeric(self):
type_ = types.get_sil_column_type('NUMBER(9,5)')
self.assertIsInstance(type_, Numeric)
self.assertEqual(type_.precision, 9)
self.assertEqual(type_.scale, 5)
type_ = types.get_sil_column_type('NUMBER(4,0)')
self.assertIsInstance(type_, Numeric)
self.assertEqual(type_.precision, 4)
self.assertEqual(type_.scale, 0)
def test_invalid(self):
self.assertRaises(SILInvalidDataType, types.get_sil_column_type, 'BOGUS(20)')

View file

@ -0,0 +1,61 @@
#!/usr/bin/env python
import unittest
from sqlalchemy import create_engine, MetaData
from rattail import db
from rattail.db import util
from rattail.db.model import Product
from rattail.util import Object
from rattail.exceptions import NoDefaultDatabase
class InstallCoreSchemaTest(unittest.TestCase):
def test_install_core_schema(self):
engine = create_engine('sqlite://')
util.install_core_schema(engine)
session = db.Session(bind=engine)
self.assertEqual(session.query(Product).count(), 0)
product = Product()
session.add(product)
session.commit()
session.close()
session = db.Session(bind=engine)
self.assertEqual(session.query(Product).count(), 1)
session.close()
def test_install_no_engine(self):
self.assertRaises(NoDefaultDatabase, util.install_core_schema)
class CoreSchemaInstalledTest(unittest.TestCase):
def test_no_database(self):
self.assertRaises(NoDefaultDatabase, util.core_schema_installed)
def test_empty_database(self):
db.engine = create_engine('sqlite://')
self.assertEqual(util.core_schema_installed(), False)
db.engine = None
def test_invalid_database(self):
db.engine = create_engine('mysql://this_had_better_not_exist')
self.assertEqual(util.core_schema_installed(), False)
db.engine = None
def test_good_database(self):
engine = create_engine('sqlite://')
util.install_core_schema(engine)
self.assertEqual(util.core_schema_installed(engine), True)
class GetCoreMetadataTest(unittest.TestCase):
def test_get_core_metadata(self):
meta = util.get_core_metadata()
self.assertIsInstance(meta, MetaData)
self.assertGreater(len(meta.tables), 1)

View file

@ -0,0 +1 @@
#!/usr/bin/env python

View file

@ -0,0 +1,31 @@
#!/usr/bin/env python
import unittest
import os
from rattail.sil import batches
from rattail.configuration import UserConfigFileStorage
from rattail.files import temp_path
class ConsumeBatchIDTest(unittest.TestCase):
def setUp(self):
self.config_path = temp_path('.conf')
self.storage = UserConfigFileStorage(self.config_path)
def tearDown(self):
os.remove(self.config_path)
def test_typical(self):
self.assertEqual(self.storage.get('rattail.sil', 'next_batch_id.test'), None)
self.assertEqual(batches.consume_batch_id('test', self.storage), '00000001')
self.assertEqual(self.storage.get('rattail.sil', 'next_batch_id.test'), '2')
self.assertEqual(batches.consume_batch_id('test', self.storage), '00000002')
self.assertEqual(self.storage.get('rattail.sil', 'next_batch_id.test'), '3')
def test_invalid(self):
self.storage.put('rattail.sil', 'next_batch_id.test', 'invalid')
self.assertEqual(batches.consume_batch_id('test', self.storage), '00000001')
self.assertEqual(self.storage.get('rattail.sil', 'next_batch_id.test'), '2')

View file

@ -0,0 +1,87 @@
#!/usr/bin/env python
import unittest
from rattail.sil import columns
from rattail import exceptions
class ColumnTest(unittest.TestCase):
def test_constructor(self):
col = columns.Column('F01', 'GPC(14)',
"Primary Item U.P.C. Number (Key)", "UPC")
self.assertEqual(col.name, 'F01')
self.assertEqual(col.data_type, 'GPC(14)')
self.assertEqual(col.description, "Primary Item U.P.C. Number (Key)")
self.assertEqual(col.display_name, "UPC")
def test_constructor_without_display_name(self):
col = columns.Column('F01', 'GPC(14)',
"Primary Item U.P.C. Number (Key)")
self.assertEqual(col.name, 'F01')
self.assertEqual(col.data_type, 'GPC(14)')
self.assertEqual(col.description, "Primary Item U.P.C. Number (Key)")
self.assertEqual(col.display_name, "Primary Item U.P.C. Number (Key)")
def test_repr(self):
col = columns.Column('F01', 'GPC(14)', "UPC")
self.assertEqual(repr(col), '<Column: F01>')
def test_str(self):
col = columns.Column('F01', 'GPC(14)', "UPC")
self.assertEqual(str(col), 'F01')
class FunctionsTest(unittest.TestCase):
def test_provide_columns(self):
cols = columns.provide_columns()
self.assertTrue(cols)
# Do a sanity check on some of the columns.
self.assertIn('F01', cols)
col = cols['F01']
self.assertEqual(col.data_type, 'GPC(14)')
self.assertEqual(col.description, "Primary Item U.P.C. Number (Key)")
self.assertEqual(col.display_name, "UPC")
self.assertIn('F30', cols)
col = cols['F30']
self.assertEqual(col.data_type, 'NUMBER(8,3)')
self.assertEqual(col.description, "Retail Sell Price")
self.assertEqual(col.display_name, "Retail Sell Price")
self.assertIn('F19', cols)
col = cols['F19']
self.assertEqual(col.data_type, 'NUMBER(4,0)')
self.assertEqual(col.description, "Case Pack Size")
self.assertEqual(col.display_name, "Case Pack Size")
self.assertIn('F03', cols)
col = cols['F03']
self.assertEqual(col.data_type, 'NUMBER(4,0)')
self.assertEqual(col.description, "Department Number")
self.assertEqual(col.display_name, "Department Number")
self.assertIn('F27', cols)
col = cols['F27']
self.assertEqual(col.data_type, 'CHAR(9)')
self.assertEqual(col.description, "Vendor Number")
self.assertEqual(col.display_name, "Vendor Number")
self.assertIn('R38', cols)
col = cols['R38']
self.assertEqual(col.data_type, 'NUMBER(9,5)')
self.assertEqual(col.description, "Unit Receiving Base Cost")
self.assertEqual(col.display_name, "Unit Receiving Base Cost")
def test_get_column(self):
col = columns.get_column('F01')
self.assertEqual(col.data_type, 'GPC(14)')
self.assertEqual(col.description, "Primary Item U.P.C. Number (Key)")
self.assertEqual(col.display_name, "UPC")
def test_get_column_invalid(self):
self.assertRaises(exceptions.SILColumnNotFound, columns.get_column, 'FXXX')

View file

@ -0,0 +1,217 @@
#!/usr/bin/env python
import unittest
import os
import datetime
from decimal import Decimal
import rattail
from rattail.sil.writer import Writer
from rattail.files import temp_path
class WriterTest(unittest.TestCase):
def setUp(self):
self.sil_path = temp_path('.sil')
def tearDown(self):
os.remove(self.sil_path)
def test_construct(self):
writer = Writer(self.sil_path)
self.assertEqual(writer.sil_path, self.sil_path)
writer.close()
def test_write(self):
writer = Writer(self.sil_path)
writer.write('simple test')
writer.close()
sil_file = open(self.sil_path, 'r')
self.assertEqual(sil_file.read(), 'simple test')
sil_file.close()
def test_val_none(self):
writer = Writer(self.sil_path)
self.assertEqual(writer.val(None), '')
writer.close()
def test_val_gpc(self):
writer = Writer(self.sil_path)
self.assertEqual(writer.val(rattail.GPC('074305001321')), '00074305001321')
writer.close()
def test_val_int(self):
writer = Writer(self.sil_path)
self.assertEqual(writer.val(420), '420')
writer.close()
def test_val_float(self):
writer = Writer(self.sil_path)
self.assertEqual(writer.val(420.0), '420.0')
writer.close()
def test_val_decimal(self):
writer = Writer(self.sil_path)
self.assertEqual(writer.val(Decimal('420.0000')), '420.0000')
writer.close()
def test_val_date(self):
writer = Writer(self.sil_path)
self.assertEqual(writer.val(datetime.date(2012, 12, 5)), '2012340')
writer.close()
def test_val_time(self):
writer = Writer(self.sil_path)
self.assertEqual(writer.val(datetime.time(16, 20)), '1620')
writer.close()
def test_val_str(self):
writer = Writer(self.sil_path)
self.assertEqual(writer.val('test'), "'test'")
writer.close()
def test_val_unicode(self):
writer = Writer(self.sil_path)
self.assertEqual(writer.val(u'test'), "'test'")
writer.close()
def test_val_object(self):
writer = Writer(self.sil_path)
self.assertRegexpMatches(writer.val(object()), r"^'<object object at 0x.*>'$")
writer.close()
def test_write_batch_header_raw(self):
writer = Writer(self.sil_path)
writer.write_batch_header_raw()
writer.close()
sil_file = open(self.sil_path, 'r')
self.assertEqual(sil_file.read(), 'INSERT INTO HEADER_DCT VALUES\n(,,,,,,,,,,,,,,,,,,,,,,);\n\n')
sil_file.close()
def parse_header_fields(self):
sil_file = open(self.sil_path, 'r')
sil_file.readline()
fields = sil_file.readline()
fields = fields.lstrip('(')
fields = fields.rstrip(');\n')
fields = fields.split(',')
sil_file.close()
return fields
def test_write_batch_header(self):
writer = Writer(self.sil_path)
writer.write_batch_header(
H01='HM',
H02='00000420',
H03='SOMEGUY',
H04='ANOTHER',
H05='audit.txt',
H06='response.txt',
H07=datetime.date(2012, 12, 5),
H08=datetime.time(16, 20),
H09=datetime.date(2012, 12, 6),
H10=datetime.time(4, 20),
H11=datetime.date(2012, 12, 7),
H12='ADDRPL',
H13="Batch Test",
H14='something',
H15='something else',
H16='another something',
H17=2,
H18=420,
H19='1/1.00',
H20='4.20',
H21='F03+F01',
H22='ABC1000=RESET SALES;DEF960=SALES_RESET;GHI3000=ZEROSALES;',
H23='6.0',
)
writer.close()
sil_file = open(self.sil_path, 'r')
self.assertEqual(sil_file.read(), """\
INSERT INTO HEADER_DCT VALUES
('HM','00000420','SOMEGUY','ANOTHER','audit.txt','response.txt',2012340,1620,2012341,0420,2012342,'ADDRPL','Batch Test','something','something else','another something',2,420,'1/1.00','4.20','F03+F01','ABC1000=RESET SALES;DEF960=SALES_RESET;GHI3000=ZEROSALES;','6.0');
""")
sil_file.close()
def test_write_batch_header_defaults(self):
writer = Writer(self.sil_path)
writer.write_batch_header()
writer.close()
fields = self.parse_header_fields()
self.assertEqual(fields[0], '')
self.assertEqual(fields[1], '')
self.assertEqual(fields[2], "'RATAIL'")
self.assertEqual(fields[3], '')
self.assertEqual(fields[4], '')
self.assertEqual(fields[5], '')
self.assertRegexpMatches(fields[6], r'^\d{7}$')
self.assertRegexpMatches(fields[7], r'^\d{4}$')
self.assertEqual(fields[8], '0000000')
self.assertEqual(fields[9], '0000')
self.assertRegexpMatches(fields[10], r'^\d{7}$')
self.assertEqual(fields[11], '')
self.assertEqual(fields[12], '')
self.assertEqual(fields[13], '')
self.assertEqual(fields[14], '')
self.assertEqual(fields[15], '')
self.assertEqual(fields[16], '')
self.assertEqual(fields[17], '')
self.assertEqual(fields[18], '')
self.assertEqual(fields[19], "'%s'" % rattail.__version__[:4])
self.assertEqual(fields[20], '')
self.assertEqual(fields[21], '')
self.assertEqual(fields[22], '')
def test_write_create_header(self):
writer = Writer(self.sil_path)
writer.write_create_header()
writer.close()
fields = self.parse_header_fields()
self.assertEqual(fields[0], "'HC'")
self.assertEqual(fields[11], "'LOAD'")
def test_write_maintenance_header(self):
writer = Writer(self.sil_path)
writer.write_maintenance_header()
writer.close()
fields = self.parse_header_fields()
self.assertEqual(fields[0], "'HM'")
def test_write_row(self):
writer = Writer(self.sil_path)
writer.write_row([419, 'test'])
writer.write_row([420, 'another'], last=True)
writer.close()
sil_file = open(self.sil_path, 'r')
self.assertEqual(sil_file.read(), """\
(419,'test'),
(420,'another');
""")
sil_file.close()
def test_write_rows(self):
writer = Writer(self.sil_path)
writer.write_rows([
(419, 'test'),
(420, 'another'),
])
writer.close()
sil_file = open(self.sil_path, 'r')
self.assertEqual(sil_file.read(), """\
(419,'test'),
(420,'another');
""")
sil_file.close()

View file

@ -0,0 +1,324 @@
#!/usr/bin/env python
import unittest
from rattail import GPC
from rattail import barcodes
class GPCTest(unittest.TestCase):
def test_constructor_with_None(self):
gpc = GPC('074305001321')
self.assertEqual(gpc, '074305001321')
def test_constructor_with_upc(self):
gpc = GPC('07430500132', calc_check_digit='upc')
self.assertEqual(gpc, '074305001321')
def test_constructor_with_true(self):
gpc = GPC('07430500132', calc_check_digit=True)
self.assertEqual(gpc, '074305001321')
def test_eq_gpc(self):
gpc1 = GPC('074305001321')
gpc2 = GPC('074305001321')
self.assertEqual(gpc1, gpc2)
def test_eq_int(self):
gpc = GPC('074305001321')
self.assertEqual(gpc, 74305001321)
def test_eq_string(self):
gpc = GPC('074305001321')
self.assertEqual(gpc, '074305001321')
def test_eq_string_invalid(self):
gpc = GPC('074305001321')
self.assertRaises(ValueError, gpc.__eq__, 'invalid')
def test_eq_object_invalid(self):
gpc = GPC('074305001321')
self.assertRaises(TypeError, gpc.__eq__, object())
def test_ne_gpc(self):
gpc1 = GPC('074305001321')
gpc2 = GPC('074305001161')
self.assertNotEqual(gpc1, gpc2)
def test_ne_int(self):
gpc = GPC('074305001321')
self.assertNotEqual(gpc, 74305001161)
def test_ne_string(self):
gpc = GPC('074305001321')
self.assertNotEqual(gpc, '074305001161')
def test_ne_string_invalid(self):
gpc = GPC('074305001321')
self.assertRaises(ValueError, gpc.__ne__, 'invalid')
def test_ne_object_invalid(self):
gpc = GPC('074305001321')
self.assertRaises(TypeError, gpc.__ne__, object())
def test_gt_gpc(self):
gpc1 = GPC('074305001321')
gpc2 = GPC('074305001161')
self.assertTrue(gpc1 > gpc2)
def test_gt_int(self):
gpc = GPC('074305001321')
self.assertTrue(gpc > 74305001161)
def test_gt_string(self):
gpc = GPC('074305001321')
self.assertTrue(gpc > '074305001161')
def test_gt_string_invalid(self):
gpc = GPC('074305001321')
self.assertRaises(ValueError, gpc.__gt__, 'invalid')
def test_gt_object_invalid(self):
gpc = GPC('074305001321')
self.assertRaises(TypeError, gpc.__gt__, object())
def test_ge_gpc(self):
gpc1 = GPC('074305001321')
gpc2 = GPC('074305001161')
self.assertTrue(gpc1 >= gpc2)
def test_ge_int(self):
gpc = GPC('074305001321')
self.assertTrue(gpc >= 74305001161)
def test_ge_string(self):
gpc = GPC('074305001321')
self.assertTrue(gpc >= '074305001161')
def test_ge_string_invalid(self):
gpc = GPC('074305001321')
self.assertRaises(ValueError, gpc.__ge__, 'invalid')
def test_ge_object_invalid(self):
gpc = GPC('074305001321')
self.assertRaises(TypeError, gpc.__ge__, object())
def test_lt_gpc(self):
gpc1 = GPC('074305001161')
gpc2 = GPC('074305001321')
self.assertTrue(gpc1 < gpc2)
def test_lt_int(self):
gpc = GPC('074305001161')
self.assertTrue(gpc < 74305001321)
def test_lt_string(self):
gpc = GPC('074305001161')
self.assertTrue(gpc < '074305001321')
def test_lt_string_invalid(self):
gpc = GPC('074305001161')
self.assertRaises(ValueError, gpc.__lt__, 'invalid')
def test_lt_object_invalid(self):
gpc = GPC('074305001161')
self.assertRaises(TypeError, gpc.__lt__, object())
def test_le_gpc(self):
gpc1 = GPC('074305001161')
gpc2 = GPC('074305001321')
self.assertTrue(gpc1 <= gpc2)
def test_le_int(self):
gpc = GPC('074305001161')
self.assertTrue(gpc <= 74305001321)
def test_le_string(self):
gpc = GPC('074305001161')
self.assertTrue(gpc <= '074305001321')
def test_le_string_invalid(self):
gpc = GPC('074305001161')
self.assertRaises(ValueError, gpc.__le__, 'invalid')
def test_le_object_invalid(self):
gpc = GPC('074305001161')
self.assertRaises(TypeError, gpc.__le__, object())
def test_hash(self):
gpc1 = GPC('074305001321')
gpc2 = GPC('074305001161')
gpc3 = GPC('074305001321')
self.assertNotEqual(hash(gpc1), hash(gpc2))
self.assertEqual(hash(gpc1), hash(gpc3))
def test_int(self):
gpc = GPC('074305001321')
self.assertEqual(int(gpc), 74305001321)
def test_long(self):
gpc = GPC('074305001321')
self.assertEqual(long(gpc), 74305001321L)
def test_repr(self):
gpc = GPC('074305001321')
self.assertEqual(repr(gpc), "GPC('00074305001321')")
def test_str(self):
gpc = GPC('074305001321')
self.assertEqual(str(gpc), '00074305001321')
def test_unicode(self):
gpc = GPC('074305001321')
self.assertEqual(unicode(gpc), u'00074305001321')
class UPCCheckDigitTest(unittest.TestCase):
def test_calculations(self):
data = [
('07430500132', 1),
('03600029145', 2),
('03600024145', 7),
('01010101010', 5),
('07430503112', 0),
]
for in_, out in data:
self.assertEqual(barcodes.upc_check_digit(in_), out)
class PriceCheckDigitTest(unittest.TestCase):
def test_calculations(self):
data = [
('0512', 3),
]
for in_, out in data:
self.assertEqual(barcodes.price_check_digit(in_), out)
def test_short_string(self):
self.assertRaises(ValueError, barcodes.price_check_digit, '123')
def test_long_string(self):
self.assertRaises(ValueError, barcodes.price_check_digit, '12345')
def test_not_string(self):
self.assertRaises(ValueError, barcodes.price_check_digit, 1234)
class LuhnCheckDigitTest(unittest.TestCase):
def test_calculations(self):
data = [
('7992739871', 3),
('4992739871', 6),
('123456781234567', 0),
]
for in_, out in data:
self.assertEqual(barcodes.luhn_check_digit(in_), out)
class UPCEToUPCATest(unittest.TestCase):
def test_without_input_check_digit(self):
data = [
('123450', '01200000345'),
('123451', '01210000345'),
('123452', '01220000345'),
('123453', '01230000045'),
('123454', '01234000005'),
('123455', '01234500005'),
('123456', '01234500006'),
('123457', '01234500007'),
('123458', '01234500008'),
('123459', '01234500009'),
('654321', '06510000432'),
('425261', '04210000526'),
('234903', '02340000090'),
('234567', '02345600007'),
('234514', '02345000001'),
('639712', '06320000971'),
('867933', '08670000093'),
('913579', '09135700009'),
('421184', '04211000008'),
]
for in_, out in data:
self.assertEqual(barcodes.upce_to_upca(in_), out)
def test_with_input_check_digit(self):
data = [
('01234505', '01200000345'),
('01234514', '01210000345'),
('01234523', '01220000345'),
('01234531', '01230000045'),
('01234543', '01234000005'),
('01234558', '01234500005'),
('01234565', '01234500006'),
('01234572', '01234500007'),
('01234589', '01234500008'),
('01234596', '01234500009'),
('06543217', '06510000432'),
('04252614', '04210000526'),
('02349036', '02340000090'),
('02345673', '02345600007'),
('02345147', '02345000001'),
('06397126', '06320000971'),
('08679339', '08670000093'),
('09135796', '09135700009'),
('04211842', '04211000008'),
]
for in_, out in data:
self.assertEqual(barcodes.upce_to_upca(in_), out)
def test_with_output_check_digit(self):
data = [
('123450', '012000003455'),
('123451', '012100003454'),
('123452', '012200003453'),
('123453', '012300000451'),
('123454', '012340000053'),
('123455', '012345000058'),
('123456', '012345000065'),
('123457', '012345000072'),
('123458', '012345000089'),
('123459', '012345000096'),
('654321', '065100004327'),
('425261', '042100005264'),
('234903', '023400000906'),
('234567', '023456000073'),
('234514', '023450000017'),
('639712', '063200009716'),
('867933', '086700000939'),
('913579', '091357000096'),
('421184', '042110000082'),
]
for in_, out in data:
self.assertEqual(
barcodes.upce_to_upca(in_, include_check_digit=True), out)
def test_with_input_and_output_check_digits(self):
data = [
('01234505', '012000003455'),
('01234514', '012100003454'),
('01234523', '012200003453'),
('01234531', '012300000451'),
('01234543', '012340000053'),
('01234558', '012345000058'),
('01234565', '012345000065'),
('01234572', '012345000072'),
('01234589', '012345000089'),
('01234596', '012345000096'),
('06543217', '065100004327'),
('04252614', '042100005264'),
('02349036', '023400000906'),
('02345673', '023456000073'),
('02345147', '023450000017'),
('06397126', '063200009716'),
('08679339', '086700000939'),
('09135796', '091357000096'),
('04211842', '042110000082'),
]
for in_, out in data:
self.assertEqual(
barcodes.upce_to_upca(in_, include_check_digit=True), out)

View file

@ -0,0 +1,224 @@
#!/usr/bin/env python
import unittest
from rattail.tests import get_config
import os
import argparse
from cStringIO import StringIO
from sqlalchemy import create_engine
import rattail
from rattail import db
from rattail import commands
from rattail import initialization
from rattail.util import Object
from rattail.files import resource_path, temp_path
from rattail.db.util import install_core_schema
class ArgumentParserTest(unittest.TestCase):
def test_parse_args(self):
parser = commands.ArgumentParser()
args = parser.parse_args(['--config', 'test.conf', 'something'])
self.assertListEqual(args.argv, ['--config', 'test.conf', 'something'])
class SampleCommand(commands.Subcommand):
name = 'sample'
description = 'Just for testing...'
def print_help(self):
self.output_stream.write('bogus help!!!')
def run(self, args):
self.output_stream.write('bogus test!!!')
class CommandTest(unittest.TestCase):
def setUp(self):
self.output_stream = StringIO()
self.command = commands.Command(
subcommands={'sample':SampleCommand},
output_stream=self.output_stream)
def tearDown(self):
self.output_stream.close()
def test_repr(self):
self.assertEqual(repr(self.command), '<Command: rattail>')
def test_unicode(self):
self.assertEqual(unicode(self.command), u'rattail')
def test_iter_subcommands(self):
subcommands = list(self.command.iter_subcommands())
self.assertEqual(len(subcommands), 1)
self.assertEqual(subcommands[0].name, 'sample')
def check_help_text(self):
text = self.output_stream.getvalue()
self.assertRegexpMatches(text, r'^Pythonic Retail Software Framework')
self.assertRegexpMatches(text, r'Usage: rattail \[options\] <command> \[command-options\]')
self.assertRegexpMatches(text, r'sample *Just for testing\.\.\.')
self.assertRegexpMatches(text, r'Try \'rattail help <command>\' for more help.')
def test_print_help(self):
self.assertEqual(self.output_stream.getvalue(), '')
self.command.print_help()
self.check_help_text()
def test_run_empty(self):
self.assertEqual(self.output_stream.getvalue(), '')
self.command.run()
self.check_help_text()
def test_run_help(self):
self.assertEqual(self.output_stream.getvalue(), '')
self.command.run('help')
self.check_help_text()
def test_run_bogus(self):
self.assertEqual(self.output_stream.getvalue(), '')
self.command.run('bogus')
self.check_help_text()
def test_run_help_bogus(self):
self.assertEqual(self.output_stream.getvalue(), '')
self.command.run('help', 'bogus')
self.check_help_text()
def test_run_help_sample(self):
self.assertEqual(self.output_stream.getvalue(), '')
self.command.run('help', 'sample')
self.assertEqual(self.output_stream.getvalue(), 'bogus help!!!')
def test_run_sample_no_init(self):
self.assertEqual(self.output_stream.getvalue(), '')
self.command.run('sample', '--no-init')
self.assertEqual(self.output_stream.getvalue(), 'bogus test!!!')
def test_run_sample_with_init(self):
self.assertEqual(initialization.inited, [])
self.assertEqual(self.output_stream.getvalue(), '')
config_path = resource_path('rattail.tests:config/base.conf')
self.command.run('--config', config_path, 'sample')
self.assertEqual(self.output_stream.getvalue(), 'bogus test!!!')
self.assertEqual(initialization.inited, ['rattail'])
initialization.inited.remove('rattail')
del rattail.config
class SubcommandTest(unittest.TestCase):
def setUp(self):
self.output_stream = StringIO()
self.subcommand = commands.Subcommand(
name='bogus',
output_stream=self.output_stream)
def tearDown(self):
self.output_stream.close()
def test_repr(self):
self.assertEqual(repr(self.subcommand), '<Subcommand: bogus>')
def test_print_help(self):
self.assertEqual(self.output_stream.getvalue(), '')
self.subcommand.print_help()
self.assertRegexpMatches(self.output_stream.getvalue(), 'bogus')
def test_run(self):
self.assertRaises(NotImplementedError, self.subcommand.run, [])
class MainTest(unittest.TestCase):
def test_main(self):
stream = StringIO()
commands.main('help', output_stream=stream)
text = stream.getvalue()
stream.close()
self.assertRegexpMatches(text, r'^Pythonic Retail Software Framework')
self.assertRegexpMatches(text, r'Usage: rattail \[options\] <command> \[command-options\]')
self.assertRegexpMatches(text, r'Try \'rattail help <command>\' for more help.')
class DatabaseSyncCommandTest(unittest.TestCase):
def test_basics(self):
dbsync = commands.DatabaseSyncCommand()
self.assertEqual(dbsync.name, 'dbsync')
parser = argparse.ArgumentParser()
dbsync.add_parser_args(parser)
class FileMonitorCommandTest(unittest.TestCase):
def test_basics(self):
filemon = commands.FileMonitorCommand()
self.assertEqual(filemon.name, 'filemon')
parser = argparse.ArgumentParser()
filemon.add_parser_args(parser)
class LoadHostDataCommandTest(unittest.TestCase):
def setUp(self):
rattail.config = get_config()
initialization.inited.append('rattail.db')
self.stream = StringIO()
def tearDown(self):
self.stream.close()
initialization.inited.remove('rattail.db')
del rattail.config
def test_basics(self):
load = commands.LoadHostDataCommand()
self.assertEqual(load.name, 'load-host-data')
def test_run_no_engine(self):
load = commands.LoadHostDataCommand(output_stream=self.stream)
self.assertEqual(self.stream.getvalue(), '')
load.run([])
self.assertEqual(self.stream.getvalue(), 'Host engine URL not configured.\n')
def test_run_with_engine(self):
host_path = temp_path('.sqlite')
store_path = temp_path('.sqlite')
db.engines = {
'host': create_engine('sqlite:///%s' % host_path.replace('\\', '/')),
'store': create_engine('sqlite:///%s' % store_path.replace('\\', '/')),
}
install_core_schema(db.engines['host'])
install_core_schema(db.engines['store'])
db.engine = db.engines['store']
db.Session.configure(bind=db.engine)
load = commands.LoadHostDataCommand(output_stream=self.stream)
load.run([])
db.Session.configure(bind=None)
db.engine = None
db.engines.clear()
os.remove(host_path)
os.remove(store_path)
class UUIDCommandTest(unittest.TestCase):
def test_run(self):
stream = StringIO()
self.assertEqual(stream.getvalue(), '')
uuid = commands.UUIDCommand(output_stream=stream)
uuid.run([])
self.assertRegexpMatches(stream.getvalue(), r'^[0-9a-f]{32}$')
stream.close()

View file

@ -0,0 +1,322 @@
#!/usr/bin/env python
import unittest
from rattail.tests import get_config
import os
import os.path
import sys
import logging
from rattail import configuration
from rattail.files import resource_path, temp_path
from rattail.exceptions import MissingConfiguration
class RattailConfigParserTest(unittest.TestCase):
def test_get(self):
config = get_config()
self.assertTrue(config.read(resource_path('rattail.tests:config/db.conf')))
self.assertEqual(config.get('rattail.db', 'default.url'), 'sqlite://')
def test_get_default(self):
config = get_config()
self.assertEqual(config.get('test.section', 'test.option'), None)
self.assertEqual(config.get('test.section', 'test.option', default='test.value'), 'test.value')
def test_get_boolean(self):
config = get_config()
config.set('something', 'else', '1')
self.assertEqual(config.getboolean('something', 'else'), True)
config.set('something', 'else', '0')
self.assertEqual(config.getboolean('something', 'else'), False)
def test_get_boolean_default(self):
config = get_config()
self.assertEqual(config.getboolean('something', 'else'), None)
self.assertEqual(config.getboolean('something', 'else', default=True), True)
def test_set(self):
config = get_config()
self.assertFalse(config.has_section('test.section'))
self.assertEqual(config.get('test.section', 'test.option'), None)
config.set('test.section', 'test.option', 'test.value')
self.assertEqual(config.get('test.section', 'test.option'), 'test.value')
def test_get_user_dir(self):
config = get_config()
path = config.get_user_dir()
self.assertRegexpMatches(path, r'^.*rattail$')
def test_get_user_dir_with_create(self):
config = get_config()
path = config.get_user_dir(last_segment='rattail_test_bogus')
self.assertFalse(os.path.exists(path))
path = config.get_user_dir(last_segment='rattail_test_bogus', create=True)
self.assertTrue(os.path.exists(path))
self.assertTrue(os.path.isdir(path))
os.rmdir(path)
def test_get_user_file(self):
config = get_config()
path = config.get_user_file('rattail_test_bogus.conf')
self.assertRegexpMatches(path, r'^.*rattail_test_bogus\.conf$')
self.assertGreater(len(path), len('rattail_test_bogus.conf'))
def test_options(self):
config = get_config()
self.assertEqual(config.options('bogus'), [])
config.set('bogus', 'some', 'value')
self.assertEqual(config.options('bogus'), ['some'])
def test_read_path_only_once(self):
config = get_config()
self.assertEqual(len(config.paths_attempted), 0)
self.assertEqual(len(config.paths_loaded), 0)
path = resource_path('rattail.tests:config/base.conf')
config.read_path(path)
self.assertEqual(len(config.paths_attempted), 1)
self.assertEqual(config.paths_attempted[0], path)
self.assertEqual(len(config.paths_loaded), 1)
self.assertEqual(config.paths_loaded[0], path)
config.read_path(path)
self.assertEqual(len(config.paths_attempted), 1)
self.assertEqual(config.paths_attempted[0], path)
self.assertEqual(len(config.paths_loaded), 1)
self.assertEqual(config.paths_loaded[0], path)
def test_read_path_nonexistent(self):
config = get_config()
self.assertEqual(len(config.paths_attempted), 0)
path = temp_path('.conf')
config.read_path(path)
self.assertEqual(len(config.paths_attempted), 1)
self.assertEqual(config.paths_attempted[0], path)
self.assertEqual(len(config.paths_loaded), 0)
def test_read_path_dir(self):
config = get_config()
self.assertEqual(len(config.paths_attempted), 0)
self.assertEqual(len(config.paths_loaded), 0)
path = temp_path('.conf-dir')
os.mkdir(path)
config.read_path(path)
self.assertEqual(len(config.paths_attempted), 1)
self.assertEqual(config.paths_attempted[0], path)
self.assertEqual(len(config.paths_loaded), 0)
os.rmdir(path)
def test_read_path_recursing(self):
config = get_config()
self.assertEqual(len(config.paths_attempted), 0)
self.assertEqual(len(config.paths_loaded), 0)
local_path = resource_path('rattail.tests:config/local.conf')
base_path = resource_path('rattail.tests:config/base.conf')
config.read_path(local_path, recurse=True)
self.assertEqual(len(config.paths_attempted), 2)
self.assertEqual(config.paths_attempted[0], local_path)
self.assertEqual(config.paths_attempted[1], base_path)
self.assertEqual(len(config.paths_loaded), 2)
self.assertEqual(config.paths_loaded[0], base_path)
self.assertEqual(config.paths_loaded[1], local_path)
self.assertEqual(config.get('rattail', 'some_option'), 'some_value')
def test_read_path_not_recursing(self):
config = get_config()
self.assertEqual(len(config.paths_attempted), 0)
self.assertEqual(len(config.paths_loaded), 0)
local_path = resource_path('rattail.tests:config/local.conf')
config.read_path(local_path, recurse=False)
self.assertEqual(len(config.paths_attempted), 1)
self.assertEqual(config.paths_attempted[0], local_path)
self.assertEqual(len(config.paths_loaded), 1)
self.assertEqual(config.paths_loaded[0], local_path)
self.assertIsNone(config.get('rattail', 'some_option'))
def test_read_only_once(self):
config = get_config()
self.assertEqual(len(config.paths_attempted), 0)
self.assertEqual(len(config.paths_loaded), 0)
path = resource_path('rattail.tests:config/base.conf')
config.read(path)
self.assertEqual(len(config.paths_attempted), 1)
self.assertEqual(config.paths_attempted[0], path)
self.assertEqual(len(config.paths_loaded), 1)
self.assertEqual(config.paths_loaded[0], path)
config.read(path)
self.assertEqual(len(config.paths_attempted), 1)
self.assertEqual(config.paths_attempted[0], path)
self.assertEqual(len(config.paths_loaded), 1)
self.assertEqual(config.paths_loaded[0], path)
def test_read_nonexistent(self):
config = get_config()
self.assertEqual(len(config.paths_attempted), 0)
path = temp_path('.conf')
config.read(path)
self.assertEqual(len(config.paths_attempted), 1)
self.assertEqual(config.paths_attempted[0], path)
self.assertEqual(len(config.paths_loaded), 0)
def test_read_dir(self):
config = get_config()
self.assertEqual(len(config.paths_attempted), 0)
self.assertEqual(len(config.paths_loaded), 0)
path = temp_path('.conf-dir')
os.mkdir(path)
config.read(path)
self.assertEqual(len(config.paths_attempted), 1)
self.assertEqual(config.paths_attempted[0], path)
self.assertEqual(len(config.paths_loaded), 0)
os.rmdir(path)
def test_read_recursing(self):
config = get_config()
self.assertEqual(len(config.paths_attempted), 0)
self.assertEqual(len(config.paths_loaded), 0)
local_path = resource_path('rattail.tests:config/local.conf')
base_path = resource_path('rattail.tests:config/base.conf')
config.read(local_path, recurse=True)
self.assertEqual(len(config.paths_attempted), 2)
self.assertEqual(config.paths_attempted[0], local_path)
self.assertEqual(config.paths_attempted[1], base_path)
self.assertEqual(len(config.paths_loaded), 2)
self.assertEqual(config.paths_loaded[0], base_path)
self.assertEqual(config.paths_loaded[1], local_path)
self.assertEqual(config.get('rattail', 'some_option'), 'some_value')
def test_read_not_recursing(self):
config = get_config()
self.assertEqual(len(config.paths_attempted), 0)
self.assertEqual(len(config.paths_loaded), 0)
local_path = resource_path('rattail.tests:config/local.conf')
config.read(local_path, recurse=False)
self.assertEqual(len(config.paths_attempted), 1)
self.assertEqual(config.paths_attempted[0], local_path)
self.assertEqual(len(config.paths_loaded), 1)
self.assertEqual(config.paths_loaded[0], local_path)
self.assertIsNone(config.get('rattail', 'some_option'))
def test_read_service_with_override(self):
config = get_config()
self.assertEqual(len(config.paths_attempted), 0)
self.assertEqual(len(config.paths_loaded), 0)
base_path = resource_path('rattail.tests:config/base.conf')
service_path = resource_path('rattail.tests:config/service.conf')
config.read_service('RattailFileMonitor', base_path)
self.assertEqual(len(config.paths_attempted), 1)
self.assertEqual(config.paths_attempted[0], service_path)
self.assertEqual(len(config.paths_loaded), 1)
self.assertEqual(config.paths_loaded[0], service_path)
self.assertEqual(config.get('rattail', 'hells'), 'yeah')
def test_require(self):
config = get_config()
self.assertRaises(MissingConfiguration, config.require, 'section', 'option')
config.set('section', 'option', 'value')
self.assertEqual(config.require('section', 'option'), 'value')
def test_save(self):
path = temp_path('.conf')
config = get_config()
config.set('something', 'else', 'bogus')
config.save(path)
config = get_config()
self.assertEqual(config.get('something', 'else'), None)
config.read(path)
self.assertEqual(config.get('something', 'else'), 'bogus')
os.remove(path)
def test_configure_logging_basic(self):
root = logging.getLogger()
before = len(root.handlers)
config = get_config()
config.set('rattail', 'basic_logging', '1')
config.configure_logging()
self.assertEqual(len(root.handlers), before + 1)
handler = root.handlers[-1]
self.assertEqual(
handler.formatter._fmt,
'%(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s')
root.handlers.remove(handler)
self.assertEqual(len(root.handlers), before)
class StorageTest(unittest.TestCase):
def test_get(self):
storage = configuration.Storage()
self.assertRaises(NotImplementedError, storage.get, 'section', 'option')
def test_put(self):
storage = configuration.Storage()
self.assertRaises(NotImplementedError, storage.put, 'section', 'option', 'value')
class UserConfigFileStorageTest(unittest.TestCase):
def setUp(self):
self.config_path = temp_path('.conf')
config_file = open(self.config_path, 'w')
config_file.write('[test.section]\n')
config_file.write('test.option = test.value\n')
config_file.close()
def tearDown(self):
os.remove(self.config_path)
def get_storage(self, config_path=None):
return configuration.UserConfigFileStorage(config_path, testing=True)
def test_construct(self):
storage = self.get_storage()
self.assertRegexpMatches(storage.config_path, r'\brattail\.conf$')
storage = self.get_storage(self.config_path)
self.assertEqual(storage.config_path, self.config_path)
def test_get(self):
storage = self.get_storage(self.config_path)
self.assertEqual(storage.get('test.section', 'test.option'), 'test.value')
self.assertEqual(storage.get('test.section', 'test.bogus'), None)
self.assertEqual(storage.get('test.section', 'test.bogus', 'default.value'), 'default.value')
def test_put(self):
storage = self.get_storage(self.config_path)
storage.put('new_section', 'new_option', 'new_value')
storage = self.get_storage(self.config_path)
self.assertEqual(storage.get('new_section', 'new_option'), 'new_value')
class FunctionsTest(unittest.TestCase):
def test_basic_logging(self):
root = logging.getLogger()
before = len(root.handlers)
configuration.basic_logging()
self.assertEqual(len(root.handlers), before + 1)
handler = root.handlers[-1]
self.assertEqual(
handler.formatter._fmt,
'%(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s')
root.handlers.remove(handler)
self.assertEqual(len(root.handlers), before)
def test_default_system_paths(self):
if sys.platform == 'win32':
expected = 2
else:
expected = 4
paths = configuration.default_system_paths()
self.assertEqual(len(paths), expected)
self.assertTrue('rattail' in paths[0])
self.assertTrue('rattail' in paths[1])
def test_default_user_paths(self):
paths = configuration.default_user_paths()
self.assertEqual(len(paths), 2)
self.assertTrue('rattail' in paths[0])
self.assertTrue('rattail' in paths[1])

View file

@ -0,0 +1,76 @@
#!/usr/bin/env python
import unittest
from rattail import exceptions
from rattail.db.model import Batch
class MissingConfigTest(unittest.TestCase):
def test_str(self):
error = exceptions.MissingConfiguration('test.section', 'test.option')
self.assertEqual(
str(error),
"Configuration is missing a required option. Please define "
"'test.option' within the [test.section] section of your "
"configuration file.")
class ModuleHasNoInitTest(unittest.TestCase):
def test_str(self):
error = exceptions.ModuleHasNoInit(unittest)
self.assertEqual(str(error), "Module has no init() function: unittest")
class NoDefaultDatabaseTest(unittest.TestCase):
def test_str(self):
error = exceptions.NoDefaultDatabase()
self.assertEqual(str(error), "A default database engine could not be determined from the configuration file(s).")
class SILColumnNotFoundTest(unittest.TestCase):
def test_str(self):
error = exceptions.SILColumnNotFound('FXXX')
self.assertEqual(str(error), "SIL column not found: FXXX")
class SILInvalidDataType(unittest.TestCase):
def test_str(self):
error = exceptions.SILInvalidDataType('BOGUS(20)')
self.assertEqual(str(error), "Invalid SIL data type: BOGUS(20)")
class BatchTypeNotFound(unittest.TestCase):
def test_str(self):
error = exceptions.BatchTypeNotFound('bogus')
self.assertEqual(str(error), "Batch type not found: bogus")
class BatchNotSavedTest(unittest.TestCase):
def test_str(self):
error = exceptions.BatchNotSaved()
self.assertEqual(str(error), "Batch is pending; you must flush it to the database before the operation you requested may proceed.")
class BatchProviderNotFoundTest(unittest.TestCase):
def test_str(self):
error = exceptions.BatchProviderNotFound('bogus')
self.assertEqual(str(error), "Batch provider not found: bogus")
class BatchDestinationNotSupported(unittest.TestCase):
def test_str(self):
batch = Batch()
batch.destination = 'BOGUS'
batch.description = 'Test Batch'
error = exceptions.BatchDestinationNotSupported(batch)
self.assertEqual(str(error), "Destination 'BOGUS' not supported for batch: Test Batch")

Some files were not shown because too many files have changed in this diff Show more