diff --git a/CHANGES.txt b/CHANGES.txt new file mode 100644 index 0000000..fbe7946 --- /dev/null +++ b/CHANGES.txt @@ -0,0 +1,5 @@ + +0.1a1 +----- + +- Initial version diff --git a/MANIFEST.in b/MANIFEST.in index 2b01ad2..7dc6232 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,2 @@ -include COPYING.txt +include *.txt include ez_setup.py -# recursive-include edbob/data * -# recursive-include edbob/db/schema * diff --git a/docs/edbob.rst b/docs/edbob.rst index ad1fe84..8cb3f6b 100644 --- a/docs/edbob.rst +++ b/docs/edbob.rst @@ -37,8 +37,6 @@ The following functions are considered "core" to ``edbob``: .. autofunction:: basic_logging -.. autofunction:: entry_point_map - .. autofunction:: get_uuid .. autofunction:: graft diff --git a/edbob/commands.py b/edbob/commands.py index adacff3..813ee45 100644 --- a/edbob/commands.py +++ b/edbob/commands.py @@ -34,7 +34,7 @@ import subprocess import logging import edbob -from edbob.util import requires_impl +from edbob.util import entry_point_map, requires_impl class ArgumentParser(argparse.ArgumentParser): @@ -75,7 +75,7 @@ See the file COPYING.txt for more information. def __init__(self, **kwargs): edbob.Object.__init__(self, **kwargs) - self.subcommands = edbob.entry_point_map('%s.commands' % self.name) + self.subcommands = entry_point_map('%s.commands' % self.name) def __str__(self): return str(self.name) @@ -259,8 +259,74 @@ class DatabaseCommand(Subcommand): def add_parser_args(self, parser): parser.add_argument('-D', '--database', metavar='URL', help="Database engine (default is edbob.db.engine)") - parser.add_argument('command', choices=['upgrade', 'extensions', 'activate', 'deactivate'], - help="Command to execute against database") + # parser.add_argument('command', choices=['upgrade', 'extensions', 'activate', 'deactivate'], + # help="Command to execute against database") + subparsers = parser.add_subparsers(title='subcommands') + + extensions = subparsers.add_parser('extensions', + help="Display current extension status for the database") + extensions.set_defaults(func=self.extensions) + + activate = subparsers.add_parser('activate', + help="Activate an extension within the database") + activate.add_argument('extension', help="Name of extension to activate") + activate.set_defaults(func=self.activate) + + deactivate = subparsers.add_parser('deactivate', + help="Deactivate an extension within the database") + deactivate.add_argument('extension', help="Name of extension to deactivate") + deactivate.set_defaults(func=self.deactivate) + + def activate(self, engine, args): + from edbob.db.extensions import ( + available_extensions, + extension_active, + activate_extension, + ) + + if args.extension in available_extensions(): + if not extension_active(args.extension, engine): + activate_extension(args.extension, engine) + print "Activated extension '%s' in database:" % args.extension + print ' %s' % engine.url + else: + print >> sys.stderr, "Extension already active: %s" % args.extension + else: + print >> sys.stderr, "Extension unknown: %s" % args.extension + + def deactivate(self, engine, args): + from edbob.db.extensions import ( + available_extensions, + extension_active, + deactivate_extension, + ) + + if args.extension in available_extensions(): + if extension_active(args.extension, engine): + deactivate_extension(args.extension, engine) + print "Deactivated extension '%s' in database:" % args.extension + print ' %s' % engine.url + else: + print >> sys.stderr, "Extension already inactive: %s" % args.extension + else: + print >> sys.stderr, "Extension unknown: %s" % args.extension + + def extensions(self, engine, args): + from edbob.db.extensions import ( + available_extensions, + extension_active, + ) + + print "Extensions for database:" + print ' %s' % engine.url + print '' + print " Name Active?" + print "------------------------" + for name in sorted(available_extensions()): + print " %-16s %s" % ( + name, 'Yes' if extension_active(name, engine) else 'No') + print '' + print "Use 'edbob db [de]activate ' to change." def run(self, args): if args.database: @@ -276,9 +342,7 @@ class DatabaseCommand(Subcommand): if not engine: print >> sys.stderr, "Database not configured; please change that or specify -D URL" return - - if args.command == 'upgrade': - print 'got upgrade ..' + args.func(engine, args) # class ExtensionsCommand(RattailCommand): diff --git a/edbob/core.py b/edbob/core.py index 6335c91..f100cf8 100644 --- a/edbob/core.py +++ b/edbob/core.py @@ -29,10 +29,9 @@ import logging import uuid -from pkg_resources import iter_entry_points -__all__ = ['Object', 'basic_logging', 'entry_point_map', 'get_uuid', 'graft'] +__all__ = ['Object', 'basic_logging', 'get_uuid', 'graft'] class Object(object): @@ -75,21 +74,6 @@ def basic_logging(): logging.getLogger().addHandler(handler) -def entry_point_map(key): - """ - Convenience function to retrieve a dictionary of entry points, keyed by - name. - - ``key`` must be the "section name" for the entry points you're after, e.g. - ``'edbob.commands'``. - """ - - epmap = {} - for ep in iter_entry_points(key): - epmap[ep.name] = ep.load() - return epmap - - def get_uuid(): """ Generates a universally-unique identifier and returns its 32-character hex diff --git a/edbob/db/__init__.py b/edbob/db/__init__.py index b766b72..6bd6549 100644 --- a/edbob/db/__init__.py +++ b/edbob/db/__init__.py @@ -26,20 +26,22 @@ ``edbob.db`` -- Database Framework """ -from sqlalchemy import engine_from_config +from sqlalchemy import engine_from_config, MetaData from sqlalchemy.orm import sessionmaker import edbob -__all__ = ['engines', 'engine', 'Session', 'metadata', - 'get_setting', 'save_setting'] +# __all__ = ['engines', 'engine', 'Session', 'metadata', +# 'get_setting', 'save_setting'] + +__all__ = ['engines', 'engine', 'Session', 'get_setting', 'save_setting'] inited = False engines = None engine = None Session = sessionmaker() -metadata = None +# metadata = None def init(config): @@ -65,13 +67,16 @@ def init(config): """ import edbob.db - from edbob.db import classes + # from edbob.db import classes + from edbob.db import model from edbob.db import enum - from edbob.db.model import get_metadata - from edbob.db.mappers import make_mappers - from edbob.db.extensions import extend_framework + from edbob.db.model import Base + # from edbob.db.model import get_metadata + # from edbob.db.mappers import make_mappers + # from edbob.db.extensions import extend_framework - global inited, engines, engine, metadata + # global inited, engines, engine, metadata + global inited, engines, engine keys = config.get('edbob.db', 'sqlalchemy.keys') if keys: @@ -94,13 +99,15 @@ def init(config): engine = engines.get('default') if engine: Session.configure(bind=engine) + Base.metadata.bind = engine - metadata = get_metadata(bind=engine) - make_mappers(metadata) - extend_framework() + # metadata = get_metadata(bind=engine) + # make_mappers(metadata) + # extend_framework() edbob.graft(edbob, edbob.db) - edbob.graft(edbob, classes) + # edbob.graft(edbob, classes) + edbob.graft(edbob, model) edbob.graft(edbob, enum) inited = True @@ -139,19 +146,35 @@ def save_setting(name, value, session=None): session.close() +def get_core_metadata(): + """ + Returns a :class:`sqlalchemy.MetaData` instance containing only those + :class:`sqlalchemy.Table`s which are part of the core ``edbob`` schema. + """ + + from edbob.db import model + + meta = MetaData() + for name in model.__all__: + if name != 'Base': + obj = getattr(model, name) + if isinstance(obj, type) and issubclass(obj, model.Base): + obj.__table__.tometadata(meta) + return meta + + def needs_session(func): """ Decorator which adds helpful session handling. """ def wrapped(*args, **kwargs): - session = kwargs.get('session') - _session = session + session = kwargs.pop('session', None) + _orig_session = session if not session: session = Session() - kwargs['session'] = session res = func(session, *args, **kwargs) - if not _session: + if not _orig_session: session.commit() session.close() return res diff --git a/edbob/db/alembic.ini b/edbob/db/alembic.ini new file mode 100644 index 0000000..891ae32 --- /dev/null +++ b/edbob/db/alembic.ini @@ -0,0 +1,46 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = schema + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/edbob/db/extensions.py b/edbob/db/extensions/__init__.py similarity index 53% rename from edbob/db/extensions.py rename to edbob/db/extensions/__init__.py index 801fa75..029be48 100644 --- a/edbob/db/extensions.py +++ b/edbob/db/extensions/__init__.py @@ -30,6 +30,7 @@ import logging # from pkg_resources import iter_entry_points import sqlalchemy.exc +from sqlalchemy import MetaData # from sqlalchemy.orm import clear_mappers import migrate.versioning.api @@ -47,20 +48,19 @@ import edbob import edbob.db from edbob.db import exceptions from edbob.db import Session -from edbob.db.classes import ActiveExtension +# from edbob.db.classes import ActiveExtension +from edbob.db.model import Base, ActiveExtension from edbob.db.util import ( get_database_version, get_repository_path, get_repository_version, ) -from edbob.util import requires_impl +from edbob.modules import import_module_path +from edbob.util import entry_point_map, requires_impl log = logging.getLogger(__name__) -available_extensions = edbob.entry_point_map('edbob.db.extensions') -active_extensions = {} - class Extension(edbob.Object): """ @@ -71,22 +71,27 @@ class Extension(edbob.Object): # derived class. required_extensions = [] - @property - @requires_impl(is_property=True) - def name(self): - """ - The name of the extension. - """ - pass + # You can set this to any dotted module path you like. If unset a default + # will be assumed, of the form ``.model`` (see + # :meth:`Extension.get_models_module()` for more info). + model_module = '' - @property - @requires_impl(is_property=True) - def schema(self): - """ - Should return a reference to the extension's ``schema`` module, which - is assumed to be a SQLAlchemy-Migrate repository. - """ - pass + # @property + # @requires_impl(is_property=True) + # def name(self): + # """ + # The name of the extension. + # """ + # pass + + # @property + # @requires_impl(is_property=True) + # def schema(self): + # """ + # Should return a reference to the extension's ``schema`` module, which + # is assumed to be a SQLAlchemy-Migrate repository. + # """ + # pass def add_class(self, cls): """ @@ -119,13 +124,54 @@ class Extension(edbob.Object): """ pass - def get_metadata(self): + def get_metadata(self, recurse=False): """ - Should return a :class:`sqlalchemy.MetaData` instance containing the - schema definition for the extension, or ``None``. + Returns a :class:`sqlalchemy.MetaData` instance containing the schema + definition for the extension. + + If ``recurse`` evaluates to true, then tables from any extensions upon + which this one relies will be included as well. """ - return None + meta = MetaData() + self.populate_metadata(meta, recurse) + return meta + + def get_model_module(self): + """ + Imports and returns a reference to the Python module providing schema + definition for the extension. + + :attr:`Extension.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, recurse=False): + """ + Populates ``metadata`` with tables provided by the extension. + + If ``recurse`` evaluates to true, then tables for any extension upon + which this one relies will also be included. + """ + + if recurse: + for name in self.required_extensions: + ext = get_extension(name) + ext.populate_metadata(metadata, True) + + model = self.get_model_module() + for name in model.__all__: + obj = getattr(model, name) + if isinstance(obj, type) and issubclass(obj, model.Base): + obj.__table__.tometadata(metadata) def remove_class(self, name): """ @@ -164,54 +210,81 @@ def activate_extension(extension, engine=None): if not isinstance(extension, Extension): extension = get_extension(extension) - log.info("Activating extension: %s" % extension.name) + # Skip all this if already active. + if extension_active(extension, engine): + return + + log.debug("Activating extension: %s" % extension.name) + + # Activate all required extensions first. + for name in extension.required_extensions: + activate_extension(name, engine) + + # Install schema for this extension. install_extension_schema(extension, engine) + # Add ActiveExtension record for this extension. session = Session(bind=engine) if not session.query(ActiveExtension).get(extension.name): session.add(ActiveExtension(name=extension.name)) session.commit() session.close() - merge_extension_metadata(extension) - extension.extend_classes() - extension.extend_mappers(rattail.metadata) - active_extensions[extension.name] = extension + # merge_extension_metadata(extension) + # extension.extend_classes() + # extension.extend_mappers(Base.metadata) + + # Add extension to in-memory active extensions tracker. + active_extensions(engine).append(extension.name) -# def deactivate_extension(extension, engine=None): -# """ -# Uninstalls an extension's schema from the primary database, and immediately -# requests it to restore the ORM API. +_available_extensions = None +def available_extensions(): + """ + Returns the map of available :class:`Extension` classes, as determined by + ``'edbob.db.extensions'`` entry points.. + """ -# If ``engine`` is not provided, then ``rattail.engine`` is assumed. -# """ + global _available_extensions -# if engine is None: -# engine = rattail.engine - -# if not isinstance(extension, RattailExtension): -# extension = get_extension(extension) + if _available_extensions is None: + _available_extensions = entry_point_map('edbob.db.extensions') + return _available_extensions -# log.info("Deactivating extension: %s" % extension.name) -# if extension.name in _active_extensions: -# del _active_extensions[extension.name] -# session = Session() -# ext = session.query(ActiveExtension).get(extension.name) -# if ext: -# session.delete(ext) -# session.commit() -# session.close() +def deactivate_extension(extension, engine=None): + """ + Uninstalls an extension's schema from a database. -# uninstall_extension_schema(extension, engine) -# unmerge_extension_metadata(extension) -# extension.restore_classes() + If ``engine`` is not provided, :attr:`edbob.db.engine` is assumed. + """ -# clear_mappers() -# make_mappers(rattail.metadata) -# for name in sorted(_active_extensions, extension_sorter(_active_extensions)): -# _active_extensions[name].extend_mappers(rattail.metadata) + if engine is None: + engine = edbob.db.engine + + if not isinstance(extension, Extension): + extension = get_extension(extension) + + log.debug("Deactivating extension: %s" % extension.name) + active = active_extensions(engine) + if extension.name in active: + active.remove(extension.name) + + session = Session(bind=engine) + ext = session.query(ActiveExtension).get(extension.name) + if ext: + session.delete(ext) + session.commit() + session.close() + + uninstall_extension_schema(extension, engine) + # unmerge_extension_metadata(extension) + # extension.restore_classes() + + # clear_mappers() + # make_mappers(rattail.metadata) + # for name in sorted(_active_extensions, extension_sorter(_active_extensions)): + # _active_extensions[name].extend_mappers(rattail.metadata) def extend_framework(): @@ -232,11 +305,11 @@ def extend_framework(): except sqlalchemy.exc.OperationalError: return - # Check database version to see if core schema is installed. - try: - db_version = get_database_version(engine) - except exceptions.CoreSchemaNotInstalled: - return + # # Check database version to see if core schema is installed. + # try: + # db_version = get_database_version(engine) + # except exceptions.CoreSchemaNotInstalled: + # return # Since extensions may depend on one another, we must first retrieve the # list of active extensions' names from the database and *then* sort them @@ -264,15 +337,20 @@ def extend_framework(): active_extensions[name] = ext -# def extension_active(extension): -# """ -# Returns boolean indicating whether or not the given ``extension`` is active -# within the current database. -# """ +def extension_active(extension, engine=None): + """ + Returns boolean indicating whether or not the given ``extension`` is active + within a database. -# if not isinstance(extension, RattailExtension): -# extension = get_extension(extension) -# return extension.name in _active_extensions + If ``engine`` is not provided, :attr:`edbob.db.engine` is assumed. + """ + + if not engine: + engine = edbob.db.engine + + if not isinstance(extension, Extension): + extension = get_extension(extension) + return extension.name in active_extensions(engine) def extension_sorter(extensions): @@ -306,8 +384,9 @@ def get_extension(name): raised if the extension cannot be found. """ - if name in available_extensions: - return available_extensions[name]() + extensions = available_extensions() + if name in extensions: + return extensions[name]() raise exceptions.ExtensionNotFound(name) @@ -322,23 +401,38 @@ def install_extension_schema(extension, engine=None): if engine is None: engine = edbob.db.engine - # Extensions aren't required to provide metadata... - ext_meta = extension.get_metadata() - if not ext_meta: - return + # # Extensions aren't required to provide metadata... + # ext_meta = extension.get_metadata() + # if not ext_meta: + # return - # ...but if they do they must also provide a SQLAlchemy-Migrate repository. - assert extension.schema, "Extension does not implement 'schema': %s" % extension.name + # # ...but if they do they must also provide a SQLAlchemy-Migrate repository. + # assert extension.schema, "Extension does not implement 'schema': %s" % extension.name - meta = edbob.db.metadata - for table in meta.sorted_tables: - table.tometadata(ext_meta) - for table in ext_meta.sorted_tables: - if table.name not in meta.tables: - table.create(bind=engine, checkfirst=True) + # meta = edbob.db.metadata + # for table in meta.sorted_tables: + # table.tometadata(ext_meta) + # for table in ext_meta.sorted_tables: + # if table.name not in meta.tables: + # table.create(bind=engine, checkfirst=True) - migrate.versioning.api.version_control( - str(engine.url), get_repository_path(extension), get_repository_version(extension)) + # TODO: This sucks, please fix. + # edbob.db.Base.metadata.create_all(engine) + + # meta = MetaData(engine) + # for tables in (edbob.db.iter_tables(), extension.iter_tables()): + # for table in tables: + # table.tometadata(meta) + # meta.create_all() + + core_meta = edbob.db.get_core_metadata() + ext_meta = extension.get_metadata(recurse=True) + for table in ext_meta.sorted_tables: + table.tometadata(core_meta) + core_meta.create_all(engine) + + # migrate.versioning.api.version_control( + # str(engine.url), get_repository_path(extension), get_repository_version(extension)) def merge_extension_metadata(ext): @@ -353,7 +447,7 @@ def merge_extension_metadata(ext): ext_meta = ext.get_metadata() if not ext_meta: return - meta = edbob.db.metadata + meta = Base.metadata for table in meta.sorted_tables: table.tometadata(ext_meta) for table in ext_meta.sorted_tables: @@ -361,30 +455,53 @@ def merge_extension_metadata(ext): table.tometadata(meta) -# def uninstall_extension_schema(extension, engine=None): -# """ -# Uninstalls an extension's tables from the database represented by -# ``engine`` (or ``rattail.engine`` if none is provided), and removes -# SQLAlchemy-Migrate version control for the extension. -# """ +def uninstall_extension_schema(extension, engine=None): + """ + Uninstalls an extension's tables from the database represented by + ``engine`` (or :attr:`edbob.db.engine` if none is provided), and removes + SQLAlchemy-Migrate version control for the extension. + """ -# if engine is None: -# engine = rattail.engine + if engine is None: + engine = edbob.db.engine -# ext_meta = extension.get_metadata() -# if not ext_meta: -# return + # ext_meta = extension.get_metadata() + # if not ext_meta: + # return -# schema = ControlledSchema(engine, get_repository_path(extension)) -# engine.execute(schema.table.delete().where( -# schema.table.c.repository_id == schema.repository.id)) + # schema = ControlledSchema(engine, get_repository_path(extension)) + # engine.execute(schema.table.delete().where( + # schema.table.c.repository_id == schema.repository.id)) -# meta = get_metadata() -# for table in meta.sorted_tables: -# table.tometadata(ext_meta) -# for table in reversed(ext_meta.sorted_tables): -# if table.name not in meta.tables: -# table.drop(bind=engine) + # meta = get_metadata() + # for table in meta.sorted_tables: + # table.tometadata(ext_meta) + # for table in reversed(ext_meta.sorted_tables): + # if table.name not in meta.tables: + # table.drop(bind=engine) + + # core_meta = edbob.db.get_core_metadata() + # ext_meta = extension.get_metadata() + # for table in ext_meta.sorted_tables: + # table.tometadata(core_meta) + # core_meta.create_all(engine) + + # core_meta = edbob.db.get_core_metadata() + # ext_meta = extension.get_metadata() + # for table in ext_meta.sorted_tables: + # table.tometadata(core_meta) + # for table in reversed(core_meta.sorted_tables): + # if table in ext_meta: + # table.drop(engine) + + core_meta = edbob.db.get_core_metadata() + ext_fullmeta = extension.get_metadata(True) + for table in ext_fullmeta.sorted_tables: + table.tometadata(core_meta) + ext_meta = extension.get_metadata() + for table in reversed(core_meta.sorted_tables): + if table in ext_meta: + table.drop(engine) # def unmerge_extension_metadata(extension): @@ -433,3 +550,27 @@ def merge_extension_metadata(ext): # # # Extensions may override permission display names. # # if ext_perms[perm_name][1]: # # perms[perm_name][1] = ext_perms[perm_name][1] + + +_active_extensions = {} +def active_extensions(engine=None): + """ + Returns a list of names for extensions which are active within a database. + + If ``engine`` is not provided, ``edbob.db.engine`` is assumed. + """ + + if not engine: + engine = edbob.db.engine + + exts = _active_extensions.get(engine.url) + if exts: + return exts + + session = Session() + q = session.query(ActiveExtension.name) + exts = [x[0] for x in q] + session.close() + + _active_extensions[engine.url] = exts + return exts diff --git a/edbob/db/extensions/auth/__init__.py b/edbob/db/extensions/auth/__init__.py new file mode 100644 index 0000000..c49f400 --- /dev/null +++ b/edbob/db/extensions/auth/__init__.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# edbob -- Pythonic Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of edbob. +# +# edbob 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. +# +# edbob 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 edbob. If not, see . +# +################################################################################ + +""" +``edbob.db.extensions.auth`` -- 'auth' Extension +""" + +from sqlalchemy import MetaData + +from edbob.db.extensions import Extension + + +class AuthExtension(Extension): + + name = 'auth' diff --git a/edbob/db/extensions/auth/model.py b/edbob/db/extensions/auth/model.py new file mode 100644 index 0000000..9491908 --- /dev/null +++ b/edbob/db/extensions/auth/model.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# edbob -- Pythonic Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of edbob. +# +# edbob 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. +# +# edbob 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 edbob. If not, see . +# +################################################################################ + +""" +``edbob.db.extensions.auth.model`` -- Schema Definition +""" + +from sqlalchemy import * +from sqlalchemy.orm import relationship + +import edbob +from edbob.db.model import Base + + +__all__ = ['Person', 'User'] + + +def get_person_display_name(context): + first_name = context.current_parameters['first_name'] + last_name = context.current_parameters['last_name'] + if not (first_name or last_name): + return None + return '%(first_name)s %(last_name)s' % locals() + + +class Person(Base): + """ + Represents a real, living and breathing person. (Or, at least was + previously living and breathing, in the case of the deceased.) + """ + + __tablename__ = 'people' + + uuid = Column(String(32), primary_key=True, default=edbob.get_uuid) + first_name = Column(String(50)) + last_name = Column(String(50)) + display_name = Column(String(100), default=get_person_display_name) + + def __repr__(self): + return "" % self.display_name + + def __str__(self): + return str(self.display_name or '') + + +class User(Base): + """ + Represents a user of the system. This may or may not correspond to a real + person, i.e. some users may exist solely for automated tasks. + """ + + __tablename__ = 'users' + + uuid = Column(String(32), primary_key=True, default=edbob.get_uuid) + username = Column(String(25), nullable=False, unique=True) + person_uuid = Column(String(32), ForeignKey('people.uuid')) + + person = relationship(Person, backref='user') + + # roles = association_proxy('_roles', 'role', + # creator=lambda x: UserRole(role=x), + # getset_factory=getset_factory) + + def __repr__(self): + return "" % self.username + + def __str__(self): + return str(self.username or '') diff --git a/edbob/db/model.py b/edbob/db/model.py index cf3e574..bc5a4a3 100644 --- a/edbob/db/model.py +++ b/edbob/db/model.py @@ -26,80 +26,134 @@ ``edbob.db.model`` -- Core Schema Definition """ -from sqlalchemy import * +from sqlalchemy import Column, String, Text +from sqlalchemy.ext.declarative import declarative_base -from edbob.sqlalchemy import table_with_uuid +import edbob +# from edbob import Object, get_uuid -def get_metadata(*args, **kwargs): +__all__ = ['ActiveExtension', 'Setting'] + +Base = declarative_base() + + +# class ClassWithUuid(Object): +# """ +# Simple mixin class which defines a ``uuid`` column as primary key. +# """ + +# Column('uuid', String(32), primary_key=True, default=get_uuid) + + +def uuid_column(*args): """ - Returns the core ``edbob`` schema definition. - - Note that when :func:`edbob.init()` is called, the ``sqlalchemy.MetaData`` - instance which is returned from this function will henceforth be available - as ``edbob.metadata``. However, ``edbob.init()`` may extend - ``edbob.metadata`` as well, depending on which extensions are activated - within the primary database. - - This function then serves two purposes: First, it provides the core - metadata instance. Secondly, it allows edbob to always know what its core - schema looks like, as opposed to what's held in the current - ``edbob.metadata`` instance, which may have been extended locally. (The - latter use is necessary in order for edbob to properly manage its - extensions.) - - All arguments (positional and keyword) are passed directly to the - ``sqlalchemy.MetaData()`` constructor. + Convenience function which returns a ``uuid`` column for use as a table's + primary key. """ - metadata = MetaData(*args, **kwargs) + return Column(String(32), primary_key=True, default=edbob.get_uuid, *args) - active_extensions = Table( - 'active_extensions', metadata, - Column('name', String(50), primary_key=True), - ) - def get_person_display_name(context): - first_name = context.current_parameters['first_name'] - last_name = context.current_parameters['last_name'] - if not (first_name or last_name): - return None - return '%(first_name)s %(last_name)s' % locals() +class ActiveExtension(Base): + """ + Represents an extension which has been activated within a database. + """ - people = table_with_uuid( - 'people', metadata, - Column('first_name', String(50)), - Column('last_name', String(50)), - Column('display_name', String(100), default=get_person_display_name), - ) + __tablename__ = 'active_extensions' - permissions = Table( - 'permissions', metadata, - Column('role_uuid', String(32), ForeignKey('roles.uuid'), primary_key=True), - Column('permission', String(50), primary_key=True), - ) + name = Column(String(50), primary_key=True) - roles = table_with_uuid( - 'roles', metadata, - Column('name', String(25), nullable=False, unique=True), - ) + def __repr__(self): + return "" % self.name - settings = Table( - 'settings', metadata, - Column('name', String(255), primary_key=True), - Column('value', Text), - ) + def __str__(self): + return str(self.name or '') - users = table_with_uuid( - 'users', metadata, - Column('username', String(25), nullable=False, unique=True), - Column('person_uuid', String(32), ForeignKey('people.uuid')), - ) - users_roles = table_with_uuid( - 'users_roles', metadata, - Column('user_uuid', String(32), ForeignKey('users.uuid')), - Column('role_uuid', String(32), ForeignKey('roles.uuid')), - ) +class Setting(Base): + """ + Represents a setting stored within the database. + """ - return metadata + __tablename__ = 'settings' + + name = Column(String(255), primary_key=True) + value = Column(Text) + + def __repr__(self): + return "" % self.name + + +# def get_metadata(*args, **kwargs): +# """ +# Returns the core ``edbob`` schema definition. + +# Note that when :func:`edbob.init()` is called, the ``sqlalchemy.MetaData`` +# instance which is returned from this function will henceforth be available +# as ``edbob.metadata``. However, ``edbob.init()`` may extend +# ``edbob.metadata`` as well, depending on which extensions are activated +# within the primary database. + +# This function then serves two purposes: First, it provides the core +# metadata instance. Secondly, it allows edbob to always know what its core +# schema looks like, as opposed to what's held in the current +# ``edbob.metadata`` instance, which may have been extended locally. (The +# latter use is necessary in order for edbob to properly manage its +# extensions.) + +# All arguments (positional and keyword) are passed directly to the +# ``sqlalchemy.MetaData()`` constructor. +# """ + +# metadata = MetaData(*args, **kwargs) + +# active_extensions = Table( +# 'active_extensions', metadata, +# Column('name', String(50), primary_key=True), +# ) + +# def get_person_display_name(context): +# first_name = context.current_parameters['first_name'] +# last_name = context.current_parameters['last_name'] +# if not (first_name or last_name): +# return None +# return '%(first_name)s %(last_name)s' % locals() + +# people = table_with_uuid( +# 'people', metadata, +# Column('first_name', String(50)), +# Column('last_name', String(50)), +# Column('display_name', String(100), default=get_person_display_name), +# ) + +# permissions = Table( +# 'permissions', metadata, +# Column('role_uuid', String(32), ForeignKey('roles.uuid'), primary_key=True), +# Column('permission', String(50), primary_key=True), +# ) + +# roles = table_with_uuid( +# 'roles', metadata, +# Column('name', String(25), nullable=False, unique=True), +# ) + +# settings = Table( +# 'settings', metadata, +# Column('name', String(255), primary_key=True), +# Column('value', Text), +# ) + +# users = table_with_uuid( +# 'users', metadata, +# Column('username', String(25), nullable=False, unique=True), +# Column('person_uuid', String(32), ForeignKey('people.uuid')), +# ) + +# users_roles = table_with_uuid( +# 'users_roles', metadata, +# Column('user_uuid', String(32), ForeignKey('users.uuid')), +# Column('role_uuid', String(32), ForeignKey('roles.uuid')), +# ) + +# return metadata diff --git a/edbob/db/schema/README b/edbob/db/schema/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/edbob/db/schema/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/edbob/db/schema/env.py b/edbob/db/schema/env.py new file mode 100644 index 0000000..755bf4f --- /dev/null +++ b/edbob/db/schema/env.py @@ -0,0 +1,71 @@ +from __future__ import with_statement +from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Pyhton logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url) + + with context.begin_transaction(): + context.run_migrations() + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + engine = engine_from_config( + config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool) + + connection = engine.connect() + context.configure( + connection=connection, + target_metadata=target_metadata + ) + + try: + with context.begin_transaction(): + context.run_migrations() + finally: + connection.close() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() + diff --git a/edbob/db/schema/script.py.mako b/edbob/db/schema/script.py.mako new file mode 100644 index 0000000..fb720d1 --- /dev/null +++ b/edbob/db/schema/script.py.mako @@ -0,0 +1,21 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision} +Create Date: ${create_date} + +""" + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +def upgrade(): + ${upgrades if upgrades else "pass"} + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/edbob/db/util.py b/edbob/db/util.py index daf87a8..69181a5 100644 --- a/edbob/db/util.py +++ b/edbob/db/util.py @@ -41,7 +41,8 @@ import migrate.exceptions import edbob.db from edbob.db import exceptions -from edbob.db.model import get_metadata +from edbob.db.model import Base +# from edbob.db.model import get_metadata # def core_schema_installed(engine=None): @@ -121,25 +122,27 @@ def install_core_schema(engine=None): if not engine: engine = edbob.db.engine - # Try to connect in order to force an error, if applicable. + # Attempt connection in order to force an error, if applicable. conn = engine.connect() conn.close() - # Check DB version to see if core schema is already installed. - try: - db_version = get_database_version(engine) - except exceptions.CoreSchemaNotInstalled: - pass - else: - raise exceptions.CoreSchemaAlreadyInstalled(db_version) + # # Check DB version to see if core schema is already installed. + # try: + # db_version = get_database_version(engine) + # except exceptions.CoreSchemaNotInstalled: + # pass + # else: + # raise exceptions.CoreSchemaAlreadyInstalled(db_version) # Create tables for core schema. - metadata = get_metadata() - metadata.create_all(bind=engine) + # metadata = get_metadata() + # Base.metadata.create_all(engine) + meta = edbob.db.get_core_metadata() + meta.create_all(engine) - # Add versioning for core schema. - migrate.versioning.api.version_control( - str(engine.url), get_repository_path(), get_repository_version()) + # # Add versioning for core schema. + # migrate.versioning.api.version_control( + # str(engine.url), get_repository_path(), get_repository_version()) # WTF # session = Session(bind=engine) diff --git a/edbob/pyramid/__init__.py b/edbob/pyramid/__init__.py index 390fab7..cd82da9 100644 --- a/edbob/pyramid/__init__.py +++ b/edbob/pyramid/__init__.py @@ -26,6 +26,17 @@ ``edbob.pyramid`` -- Pyramid Framework """ +from sqlalchemy.orm import scoped_session +from zope.sqlalchemy import ZopeTransactionExtension + +import edbob.db + + +__all__ = ['Session'] + +Session = scoped_session(edbob.db.Session) +Session.configure(extension=ZopeTransactionExtension()) + def includeme(config): config.include('edbob.pyramid.static') diff --git a/edbob/pyramid/filters.py b/edbob/pyramid/filters.py new file mode 100644 index 0000000..5e3d93e --- /dev/null +++ b/edbob/pyramid/filters.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# edbob -- Pythonic Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of edbob. +# +# edbob 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. +# +# edbob 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 edbob. If not, see . +# +################################################################################ + +""" +``edbob.pyramid.filters`` -- Search Filters +""" + +import re + +from pyramid_simpleform import Form +from pyramid_simpleform.renderers import FormRenderer +from webhelpers.html import tags + +import edbob +from edbob.util import prettify + + +__all__ = ['SearchFilter', 'SearchForm'] + + +class SearchFilter(edbob.Object): + + def __init__(self, name, label=None, **kwargs): + edbob.Object.__init__(self, **kwargs) + self.name = name + self.label = label or prettify(name) + + def types_select(self): + types = [ + ('is', 'is'), + ('nt', 'is not'), + ('lk', 'contains'), + ('nl', 'doesn\'t contain'), + ] + options = [] + filter_map = self.search.config['filter_map'][self.name] + for value, label in types: + if value in filter_map: + options.append((value, label)) + return tags.select('filter_type_'+self.name, + self.search.config.get('filter_type_'+self.name), + options, class_='filter-type') + + def value_control(self): + return tags.text(self.name, self.search.config.get(self.name)) + + +class SearchForm(Form): + + def __init__(self, request, filters, config, *args, **kwargs): + Form.__init__(self, request, *args, **kwargs) + self.filters = filters + for f in filters: + filters[f].search = self + self.config = config + + +class SearchFormRenderer(FormRenderer): + + def __init__(self, form, *args, **kwargs): + FormRenderer.__init__(self, form, *args, **kwargs) + self.filters = form.filters + self.config = form.config + + def checkbox(self, name, checked=None, *args, **kwargs): + if name.startswith('include_filter_'): + if checked is None: + checked = self.config[name] + return tags.checkbox(name, checked=checked, *args, **kwargs) + if checked is None: + checked = False + return FormRenderer.checkbox(self, name, checked=checked, *args, **kwargs) + + def text(self, name, *args, **kwargs): + return tags.text(name, value=self.config.get(name), *args, **kwargs) + + def sorted_filters(self): + return sorted(self.filters, key=lambda x: self.filters[x].label) + + def add_filter(self, visible): + options = ['add a filter'] + for f in sorted(self.filters): + f = self.filters[f] + options.append((f.name, f.label)) + return self.select('add-filter', options, + style='display: none;' if len(visible) == len(self.filters) else None) + + def render(self, **kwargs): + from formalchemy import config + return config.engine('filterset', search=self, **kwargs) + + +def filter_exact(field): + """ + Returns a filter map entry, with typical logic built in for "exact match" + queries applied to ``field``. + """ + return { + 'is': + lambda q, v: q.filter(field == v) if v else q, + 'nt': + lambda q, v: q.filter(field != v) if v else q, + } + + +def filter_ilike(field): + """ + Returns a filter map entry, with typical logic built in for ILIKE queries + applied to ``field``. + """ + return { + 'lk': + lambda q, v: q.filter(field.ilike('%%%s%%' % v)) if v else q, + 'nl': + lambda q, v: q.filter(~field.ilike('%%%s%%' % v)) if v else q, + } + + +def filter_query(query, config, join_map={}): + filter_map = config['filter_map'] + if config.get('search'): + search = config['search'].config + joins = config.setdefault('joins', []) + include_filter = re.compile(r'^include_filter_(.*)$') + for key in search: + m = include_filter.match(key) + if m and search[key]: + field = m.group(1) + if field in join_map and field not in joins: + query = join_map[field](query) + joins.append(field) + value = search.get(field) + if value: + f = filter_map[field][search['filter_type_'+field]] + query = f(query, value) + return query + + +def get_filter_map(cls, exact=[], ilike=[], **kwargs): + """ + Convenience function which returns a filter map for ``cls``. All fields + represented by ``names`` will be included in the map. + + Each field's entry will use the :func:`filter_ilike()` function unless the + field's name is also found within ``exact``, in which case the + :func:`filter_exact()` function will be used instead. + """ + + fmap = {} + for name in exact: + fmap[name] = filter_exact(getattr(cls, name)) + for name in ilike: + fmap[name] = filter_ilike(getattr(cls, name)) + fmap.update(kwargs) + return fmap + + +def get_search_config(name, request, filter_map, **kwargs): + """ + Returns a dictionary of configuration options for a search form. + """ + + config = {} + for field in filter_map: + config['include_filter_'+field] = False + config.update(kwargs) + + def update_config(dict_, prefix='', exclude_by_default=False): + for field in filter_map: + if prefix+'include_filter_'+field in dict_: + include = dict_[prefix+'include_filter_'+field] + include = bool(include) and include != '0' + config['include_filter_'+field] = include + elif exclude_by_default: + config['include_filter_'+field] = False + if prefix+'filter_type_'+field in dict_: + config['filter_type_'+field] = dict_[prefix+'filter_type_'+field] + if prefix+field in dict_: + config[field] = dict_[prefix+field] + + update_config(request.session, prefix=name+'.') + if request.params.get('filters'): + update_config(request.params, exclude_by_default=True) + for key in config: + if not key.startswith('filter_factory_'): + request.session[name+'.'+key] = config[key] + config['request'] = request + config['filter_map'] = filter_map + return config + + +def get_search_form(config, **labels): + filters = {} + for field in config['filter_map']: + factory = config.get('filter_factory_%s' % field, SearchFilter) + filters[field] = factory(field, label=labels.get(field)) + return SearchForm(config['request'], filters, config) diff --git a/edbob/pyramid/forms/__init__.py b/edbob/pyramid/forms/__init__.py index e69de29..a6f3935 100644 --- a/edbob/pyramid/forms/__init__.py +++ b/edbob/pyramid/forms/__init__.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# edbob -- Pythonic Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of edbob. +# +# edbob 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. +# +# edbob 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 edbob. If not, see . +# +################################################################################ + +""" +``edbob.pyramid.forms`` -- Forms +""" + +from edbob.pyramid.forms.formalchemy import * diff --git a/edbob/pyramid/forms/formalchemy.py b/edbob/pyramid/forms/formalchemy.py index 1530389..b27b6f2 100644 --- a/edbob/pyramid/forms/formalchemy.py +++ b/edbob/pyramid/forms/formalchemy.py @@ -26,3 +26,303 @@ ``edbob.pyramid.forms.formalchemy`` -- FormAlchemy Interface """ +from __future__ import absolute_import + +import datetime + +from pyramid.renderers import render +from webhelpers import paginate +from webhelpers.html.builder import format_attrs +from webhelpers.html.tags import literal + +import formalchemy +from formalchemy.validators import accepts_none + +import edbob +from edbob.lib import pretty +from edbob.util import prettify +from edbob.pyramid import Session + + +__all__ = ['AlchemyGrid', 'EnumFieldRenderer', 'PrettyDateTimeFieldRenderer', + 'make_fieldset', 'required'] + + +class TemplateEngine(formalchemy.templates.TemplateEngine): + """ + Mako template engine for FormAlchemy. + """ + + def render(self, template, prefix='/forms/', suffix='.mako', **kwargs): + template = ''.join((prefix, template, suffix)) + return render(template, kwargs) + + +# Make our TemplateEngine the default. +engine = TemplateEngine() +formalchemy.config.engine = engine + + +class FieldSet(formalchemy.FieldSet): + """ + Adds a little magic to the ``FieldSet`` class. + """ + + prettify = staticmethod(prettify) + + def __init__(self, model, class_name=None, crud_title=None, url=None, + route_name=None, url_action=None, url_cancel=None, **kwargs): + formalchemy.FieldSet.__init__(self, model, **kwargs) + self.class_name = class_name or self._original_cls.__name__.lower() + self.crud_title = crud_title or prettify(self.class_name) + self.edit = isinstance(model, self._original_cls) + self.route_name = route_name or (self.class_name + 's') + self.url_action = url_action or url(self.route_name) + self.url_cancel = url_cancel or url(self.route_name) + + def get_display_text(self): + return str(self.model) + + def render(self, **kwargs): + kwargs.setdefault('class_', self.class_name) + return formalchemy.FieldSet.render(self, **kwargs) + + +class AlchemyGrid(formalchemy.Grid): + """ + This class defines the basic grid which you see in pretty much all + Rattail/Pyramid apps. + + .. todo:: + This needs to be documented more fully, along with the rest of + rattail.pyramid I suppose... + """ + + prettify = staticmethod(prettify) + + # uuid_key = None + + # def __init__(self, cls, instances, config, url_kwargs={}, *args, **kwargs): + # formalchemy.Grid.__init__(self, cls, instances, *args, **kwargs) + # self.pager = instances if isinstance(instances, paginate.Page) else None + # self.config = config + # self.url_kwargs = url_kwargs + # self.sortable = config.get('sortable', False) + + def __init__(self, cls, instances, config, url_grid, url_object=None, + url_delete=None, **kwargs): + """ + Grid constructor. + + ``url`` must be the URL used to access the grid itself. This url/view + must accept a GET query string parameter of "partial=True", which will + indicate that the grid *only* is being requested, as opposed to the + full page in which the grid normally resides. + """ + + formalchemy.Grid.__init__(self, cls, instances, **kwargs) + self.config = config + self.url_grid = url_grid + self.url_object = url_object + self.url_delete = url_delete + self.sortable = config.get('sortable', False) + self.deletable = config.get('deletable', False) + self.pager = instances if isinstance(instances, paginate.Page) else None + + def field_name(self, field): + return field.name + + def iter_fields(self): + for field in self.render_fields.itervalues(): + yield field + + def render_field(self, field, readonly): + if readonly: + return field.render_readonly() + return field.render() + + def row_attrs(self, i): + return format_attrs( + uuid=self.model.uuid, + class_='even' if i % 2 else 'odd', + ) + + def url_attrs(self): + return format_attrs(url=self.url_grid, + objurl=self.url_object, + delurl=self.url_delete) + + # def render(self, class_=None, **kwargs): + # """ + # Renders the grid into HTML, and returns the result. + + # ``class_`` (if provided) is used to define the class of the ``
`` + # (wrapper) and ```` elements of the grid. + + # Any remaining ``kwargs`` are passed directly to the underlying + # ``formalchemy.Grid.render()`` method. + # """ + + # kwargs['class_'] = class_ + # # kwargs.setdefault('get_uuid', self.get_uuid) + # kwargs.setdefault('checkboxes', False) + # return formalchemy.Grid.render(self, **kwargs) + + def render(self, **kwargs): + engine = self.engine or formalchemy.config.engine + if self.readonly: + return engine('grid_readonly', grid=self, **kwargs) + kwargs.setdefault('request', self._request) + return engine('grid', grid=self, **kwargs) + + def th_sortable(self, field): + class_ = '' + label = field.label() + if self.sortable and field.key in self.config.get('sort_map', {}): + class_ = 'sortable' + if field.key == self.config['sort']: + class_ += ' sorted ' + self.config['dir'] + label = literal('') + label + literal('') + if class_: + class_ = ' class="%s"' % class_ + return literal('') + label + literal('') + + # def url(self): + # # TODO: Probably clean this up somehow... + # if self.pager is not None: + # u = self.pager._url_generator(self.pager.page, partial=True) + # else: + # u = self._url or '' + # qs = self.query_string() + # if qs: + # if '?' not in u: + # u += '?' + # u += qs + # elif '?' not in u: + # u += '?partial=True' + # return u + + # def query_string(self): + # # TODO: Probably clean this up somehow... + # qs = '' + # if self.url_kwargs: + # for k, v in self.url_kwargs.items(): + # qs += '&%s=%s' % (urllib.quote_plus(k), urllib.quote_plus(v)) + # return qs + + # def get_actions(self): + + # def get_class(text): + # c = text.lower() + # c = c.replace(' ', '-') + # return c + + # res = '' + # for action in self.config['actions']: + # if isinstance(action, basestring): + # text = action + # class_ = get_class(text) + # else: + # text = action[0] + # if len(action) > 1: + # class_ = action[1] + # else: + # class_ = get_class(text) + # res += literal('' % + # (' ' + class_ if class_ else '', text)) + # return res + + # def get_uuid(self): + # """ + # .. highlight:: none + + # Returns a unique identifier for a given record, in the form of an HTML + # attribute for direct inclusion in a ```` element within a template. + # An example of what this function might return would be the string:: + + # 'uuid="420"' + + # Rattail itself will tend to use *universally-unique* IDs (true UUIDs), + # but this method may be overridden to support legacy databases with + # auto-increment IDs, etc. Really the only important thing is that the + # value returned be unique across the relevant data set. + + # If the concept is unsupported, the method should return an empty + # string. + # """ + + # def uuid(): + # if self.uuid_key and hasattr(self.model, self.uuid_key): + # return getattr(self.model, self.uuid_key) + # if hasattr(self.model, 'uuid'): + # return getattr(self.model, 'uuid') + # if hasattr(self.model, 'id'): + # return getattr(self.model, 'id') + + # uuid = uuid() + # if uuid: + # return literal('uuid="%s"' % uuid) + # return '' + + +def make_fieldset(model, **kwargs): + kwargs.setdefault('session', Session()) + return FieldSet(model, **kwargs) + + +@accepts_none +def required(value, field=None): + if value is None or value == '': + msg = "Please provide a value" + if field: + msg = "You must provide a value for %s" % field.label() + raise formalchemy.ValidationError(msg) + + +def EnumFieldRenderer(enum): + """ + Adds support for enumeration fields. + """ + + class Renderer(formalchemy.fields.SelectFieldRenderer): + + def render_readonly(self, **kwargs): + value = self.raw_value + if value is None: + return '' + if value in enum: + return enum[value] + return value + + def render(self, **kwargs): + opts = [] + for value in sorted(enum): + opts.append((enum[value], value)) + return formalchemy.fields.SelectFieldRenderer.render(self, opts, **kwargs) + + return Renderer + + +def pretty_datetime(value): + """ + Formats a ``datetime.datetime`` instance and returns a "pretty" + human-readable string from it, e.g. "42 minutes ago". ``value`` is + rendered directly as a string if no date/time can be parsed from it. + """ + + if not isinstance(value, datetime.datetime): + return str(value) if value else '' + value = edbob.local_time(value) + fmt = formalchemy.fields.DateTimeFieldRenderer.format + return literal('%s' % ( + value.strftime(fmt), + pretty.date(value))) + + +class PrettyDateTimeFieldRenderer(formalchemy.fields.DateTimeFieldRenderer): + """ + Adds "pretty" date/time support for FormAlchemy. + """ + + def render_readonly(self, **kwargs): + return pretty_datetime(self.raw_value) diff --git a/edbob/pyramid/grids.py b/edbob/pyramid/grids.py new file mode 100644 index 0000000..fa70cc0 --- /dev/null +++ b/edbob/pyramid/grids.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# edbob -- Pythonic Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of edbob. +# +# edbob 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. +# +# edbob 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 edbob. If not, see . +# +################################################################################ + +""" +``edbob.pyramid.grids`` -- Grid Tables +""" + +from collections import OrderedDict + +from sqlalchemy.orm import Query + +from pyramid.renderers import render +from pyramid.response import Response +from webhelpers import paginate +from webhelpers.html import literal +from webhelpers.html.builder import format_attrs + +import edbob +from edbob.pyramid.filters import SearchFormRenderer +from edbob.util import prettify + + +class BasicGrid(edbob.Object): + """ + Basic grid class for those times when SQLAlchemy is not needed. + """ + + def __init__(self, columns, rows, config, url, sortable=True, deletable=False, **kwargs): + edbob.Object.__init__(self, **kwargs) + self.rows = rows + self.config = config + self.url = url + self.sortable = sortable + self.deletable = deletable + self.columns = OrderedDict() + for col in columns: + if isinstance(col, (tuple, list)): + if len(col) == 2: + self.columns[col[0]] = col[1] + continue + elif isinstance(col, basestring): + self.columns[col] = prettify(col) + continue + raise ValueError("Column element must be either a string or 2-tuple") + + def _set_active(self, row): + self.model = {} + for i, col in enumerate(self.columns.keys()): + if i >= len(row): + break + self.model[col] = row[i] + + def field_label(self, name): + return self.columns[name] + + def field_name(self, field): + return field + + def iter_fields(self): + for col in self.columns.keys(): + yield col + + def render(self, **kwargs): + kwargs['grid'] = self + return render('forms/grid_readonly.mako', kwargs) + + def render_field(self, field, readonly): + return self.model[field] + + def row_attrs(self, i): + return format_attrs(class_='even' if i % 2 else 'odd') + + def th_sortable(self, field): + class_ = '' + label = self.field_label(field) + if self.sortable and field in self.config.get('sort_map', {}): + class_ = 'sortable' + if field == self.config['sort']: + class_ += ' sorted ' + self.config['dir'] + label = literal('') + label + literal('') + if class_: + class_ = ' class="%s"' % class_ + return literal('') + label + literal('') + + def url_attrs(self): + return format_attrs(url=self.url) + + +def get_grid_config(name, request, search=None, url=None, **kwargs): + config = { + 'actions': [], + 'per_page': 20, + 'page': 1, + 'sortable': True, + 'dir': 'asc', + 'object_url': '', + 'deletable': False, + 'delete_url': '', + 'use_dialog': False, + } + config.update(kwargs) + # words = name.split('.') + # if len(words) == 2: + # config.setdefault('object_url', request.route_url(words[0], action='crud')) + # config.setdefault('delete_url', config['object_url']) + for key in config: + full_key = name+'_'+key + if request.params.get(key): + value = request.params[key] + config[key] = value + request.session[full_key] = value + elif request.session.get(full_key): + value = request.session[full_key] + config[key] = value + config['search'] = search + config['url'] = url + return config + + +def get_pager(query, config): + query = query(config) + count = None + if isinstance(query, Query): + count = query.count() + return paginate.Page( + query, item_count=count, + items_per_page=int(config['per_page']), + page=int(config['page']), + url=paginate.PageURL(config['url'], {}), + ) + + +def get_sort_map(cls, names, **kwargs): + """ + Convenience function which returns a sort map. + """ + + smap = {} + for name in names: + smap[name] = sorter(getattr(cls, name)) + smap.update(kwargs) + return smap + + +def render_grid(request, grid, search=None, **kwargs): + if request.params.get('partial'): + return Response(body=grid, content_type='text/html') + kwargs['grid'] = grid + if search: + kwargs['search'] = SearchFormRenderer(search) + return kwargs + + +def sort_query(query, config, sort_map, join_map={}): + field = config['sort'] + joins = config.setdefault('joins', []) + if field in join_map and field not in joins: + query = join_map[field](query) + joins.append(field) + config['sort_map'] = sort_map + return sort_map[field](query, config['dir']) + + +def sorter(field): + """ + Returns a function suitable for a sort map callable, with typical + logic built in for sorting applied to ``field``. + """ + return lambda q, d: q.order_by(getattr(field, d)()) diff --git a/edbob/pyramid/scaffolds/edbob/+package+/db.py_tmpl b/edbob/pyramid/scaffolds/edbob/+package+/db.py_tmpl deleted file mode 100644 index 6d7a1a3..0000000 --- a/edbob/pyramid/scaffolds/edbob/+package+/db.py_tmpl +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env python - -""" -``{{package}}.db`` -- Database Stuff -""" - -from sqlalchemy.orm import scoped_session, sessionmaker -from zope.sqlalchemy import ZopeTransactionExtension - - -__all__ = ['Session'] - -Session = scoped_session(sessionmaker(extension=ZopeTransactionExtension())) diff --git a/edbob/pyramid/scaffolds/edbob/+package+/templates/base.mako_tmpl b/edbob/pyramid/scaffolds/edbob/+package+/templates/base.mako_tmpl index 0999fee..d9c2da3 100644 --- a/edbob/pyramid/scaffolds/edbob/+package+/templates/base.mako_tmpl +++ b/edbob/pyramid/scaffolds/edbob/+package+/templates/base.mako_tmpl @@ -1,7 +1,7 @@ -<%inherit file="edbob/base.mako" /> +<%inherit file="/edbob/base.mako" /> <%def name="global_title()">{{project}} <%def name="footer()"> - {{project}} v${{{package}}.__version__} powered by + {{project}} v${ {{package}}.__version__} powered by ${h.link_to("edbob", 'http://edbob.org/', target='_blank')} v${edbob.__version__} ${parent.body()} diff --git a/edbob/pyramid/scaffolds/edbob/+package+/templates/home.mako_tmpl b/edbob/pyramid/scaffolds/edbob/+package+/templates/home.mako_tmpl index dfcdb08..71e198c 100644 --- a/edbob/pyramid/scaffolds/edbob/+package+/templates/home.mako_tmpl +++ b/edbob/pyramid/scaffolds/edbob/+package+/templates/home.mako_tmpl @@ -1,4 +1,4 @@ -<%inherit file="base.mako" /> +<%inherit file="/base.mako" />

Welcome to {{project}}

diff --git a/edbob/pyramid/scaffolds/edbob/production.ini_tmpl b/edbob/pyramid/scaffolds/edbob/production.ini_tmpl index 35a86b0..c42110b 100644 --- a/edbob/pyramid/scaffolds/edbob/production.ini_tmpl +++ b/edbob/pyramid/scaffolds/edbob/production.ini_tmpl @@ -14,7 +14,7 @@ whatever = you like #################### -# Pyramid +# pyramid #################### [app:main] @@ -36,6 +36,20 @@ host = 0.0.0.0 port = 6543 +#################### +# alembic +#################### + +[alembic] +# path to migration scripts +script_location = schema + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +sqlalchemy.url = postgresql://user:pass@localhost/{{package}} + + #################### # edbob #################### diff --git a/edbob/pyramid/scaffolds/edbob/setup.py_tmpl b/edbob/pyramid/scaffolds/edbob/setup.py_tmpl index 197159d..3d5969a 100644 --- a/edbob/pyramid/scaffolds/edbob/setup.py_tmpl +++ b/edbob/pyramid/scaffolds/edbob/setup.py_tmpl @@ -37,25 +37,9 @@ requires = [ # outside the lines with regard to these soft limits. If bugs are # encountered then they should be filed as such. # - # package # low high + # package # low high - # Beaker dependency included here because 'pyramid_beaker' uses incorrect - # case in its requirement declaration. - 'Beaker', # 1.6.3 - - 'decorator', # 3.3.2 - 'edbob', # 0.1a1 - 'Mako', # 0.6.2 - 'pyramid', # 1.3b2 - 'pyramid_beaker', # 0.6.1 - 'pyramid_debugtoolbar', # 1.0 - 'pyramid_tm', # 0.3 - 'SQLAlchemy', # 0.7.6 - 'Tempita', # 0.5.1 - 'transaction', # 1.2.0 - 'waitress', # 0.8.1 - 'WebHelpers', # 1.3 - 'zope.sqlalchemy', # 0.7 + 'edbob[db,pyramid]', # 0.1a1.dev ] diff --git a/edbob/pyramid/static/css/edbob.css b/edbob/pyramid/static/css/edbob.css index 032935a..744d430 100644 --- a/edbob/pyramid/static/css/edbob.css +++ b/edbob/pyramid/static/css/edbob.css @@ -24,6 +24,15 @@ li { line-height: 2em; } +.wrapper { + overflow: auto; +} + +table.wrapper { + /* border: 1px solid black; */ + width: 100%; +} + .left { float: left; text-align: left; @@ -34,6 +43,15 @@ li { text-align: right; } +td.right { + float: none; +} + +table.wrapper td.right { + vertical-align: bottom; +} + + /****************************** * Main Layout ******************************/ @@ -110,7 +128,7 @@ h1 { } h2 { - margin-bottom: 10px; + margin: 20px auto 10px auto; } p { @@ -166,6 +184,10 @@ div.dialog { * Filters ******************************/ +div.filterset { + margin-bottom: 8px; +} + div.filters { /* margin-bottom: 10px; */ } @@ -214,23 +236,24 @@ table.search-wrapper td.grid-mgmt { * Grids ******************************/ -a.add-object { - display: block; - float: right; -} +/* a.add-object { */ +/* display: block; */ +/* float: right; */ +/* } */ -ul.grid-menu { - display: block; - float: right; - list-style-type: none; - margin-bottom: 5px; -} +/* ul.grid-menu { */ +/* display: block; */ +/* float: right; */ +/* list-style-type: none; */ +/* margin-bottom: 5px; */ +/* } */ div.grid { clear: both; + /* margin-top: 8px; */ } -table.grid { +div.grid table { border-top: 1px solid black; border-left: 1px solid black; border-collapse: collapse; @@ -239,47 +262,47 @@ table.grid { width: 100%; } -table.grid th, -table.grid td { +div.grid table th, +div.grid table td { border-right: 1px solid black; border-bottom: 1px solid black; padding: 2px 3px; } -table.grid th.sortable a { +div.grid table th.sortable a { display: block; padding-right: 18px; } -table.grid th.sorted { +div.grid table th.sorted { background-position: right center; background-repeat: no-repeat; } -table.grid th.sorted.asc { +div.grid table th.sorted.asc { background-image: url(../img/sort_arrow_up.png); } -table.grid th.sorted.desc { +div.grid table th.sorted.desc { background-image: url(../img/sort_arrow_down.png); } -table.grid tr.even { +div.grid table tr.even { background-color: #e0e0e0; } -table.grid thead th.checkbox, -table.grid tbody td.checkbox { +div.grid table thead th.checkbox, +div.grid table tbody td.checkbox { text-align: center; vertical-align: middle; width: 15px; } -table.grid td.action { +div.grid table td.action { cursor: default; } -table.grid td.delete { +div.grid table td.delete { text-align: center; width: 18px; background-image: url(../img/delete.png); @@ -288,25 +311,25 @@ table.grid td.delete { cursor: pointer; } -table.grid tbody tr.hovering { +div.grid table tbody tr.hovering { background-color: #bbbbbb; } -table.grid.hoverable tbody tr { +div.grid table.hoverable tbody tr { cursor: default; } -table.grid.clickable tbody tr { +div.grid.clickable table tbody tr { cursor: pointer; } -table.grid.selectable tbody tr, -table.grid.checkable tbody tr { +div.grid table.selectable tbody tr, +div.grid table.checkable tbody tr { cursor: pointer; } -table.grid.selectable tbody tr.selected, -table.grid.checkable tbody tr.selected { +div.grid table.selectable tbody tr.selected, +div.grid table.checkable tbody tr.selected { background-color: #666666; color: white; } @@ -361,7 +384,7 @@ div.field-couple div.field { div.field-couple div.field input[type=text], div.field-couple div.field select { - width: 180px; + width: 320px; } div.checkbox { diff --git a/edbob/pyramid/static/img/delete.png b/edbob/pyramid/static/img/delete.png new file mode 100644 index 0000000..f338385 Binary files /dev/null and b/edbob/pyramid/static/img/delete.png differ diff --git a/edbob/pyramid/static/img/sort_arrow_down.png b/edbob/pyramid/static/img/sort_arrow_down.png new file mode 100644 index 0000000..e99a2ce Binary files /dev/null and b/edbob/pyramid/static/img/sort_arrow_down.png differ diff --git a/edbob/pyramid/static/img/sort_arrow_up.png b/edbob/pyramid/static/img/sort_arrow_up.png new file mode 100644 index 0000000..5980552 Binary files /dev/null and b/edbob/pyramid/static/img/sort_arrow_up.png differ diff --git a/edbob/pyramid/static/js/edbob.js b/edbob/pyramid/static/js/edbob.js index 08b4525..b238410 100644 --- a/edbob/pyramid/static/js/edbob.js +++ b/edbob/pyramid/static/js/edbob.js @@ -174,7 +174,7 @@ $(function() { return false; }); - $('table.grid th.sortable a').live('click', function() { + $('div.grid table th.sortable a').live('click', function() { var div = $(this).parents('div.grid:first'); var th = $(this).parents('th:first'); var dir = 'asc'; @@ -185,43 +185,44 @@ $(function() { var url = div.attr('url'); url += url.match(/\?/) ? '&' : '?'; url += 'sort=' + th.attr('field') + '&dir=' + dir; + url += '&partial=true'; div.load(url); return false; }); - $('table.grid.hoverable tbody tr').live('mouseenter', function() { + $('div.grid.hoverable table tbody tr').live('mouseenter', function() { $(this).addClass('hovering'); }); - $('table.grid.hoverable tbody tr').live('mouseleave', function() { + $('div.grid.hoverable table tbody tr').live('mouseleave', function() { $(this).removeClass('hovering'); }); - $('table.grid.clickable tbody tr').live('mouseenter', function() { + $('div.grid.clickable table tbody tr').live('mouseenter', function() { $(this).addClass('hovering'); }); - $('table.grid.clickable tbody tr').live('mouseleave', function() { + $('div.grid.clickable table tbody tr').live('mouseleave', function() { $(this).removeClass('hovering'); }); - $('table.grid.selectable tbody tr').live('mouseenter', function() { + $('div.grid.selectable table tbody tr').live('mouseenter', function() { $(this).addClass('hovering'); }); - $('table.grid.selectable tbody tr').live('mouseleave', function() { + $('div.grid.selectable table tbody tr').live('mouseleave', function() { $(this).removeClass('hovering'); }); - $('table.grid.checkable tbody tr').live('mouseenter', function() { + $('div.grid.checkable table tbody tr').live('mouseenter', function() { $(this).addClass('hovering'); }); - $('table.grid.checkable tbody tr').live('mouseleave', function() { + $('div.grid.checkable table tbody tr').live('mouseleave', function() { $(this).removeClass('hovering'); }); - $('table.grid.clickable tbody tr').live('click', function() { + $('div.grid.clickable table tbody tr').live('click', function() { var div = $(this).parents('div.grid:first'); if (div.attr('usedlg') == 'True') { var dlg = get_dialog('grid-object'); @@ -240,9 +241,9 @@ $(function() { } }); - $('table.grid.checkable thead th.checkbox input[type=checkbox]').live('click', function() { + $('div.grid.checkable table thead th.checkbox input[type=checkbox]').live('click', function() { var checked = $(this).is(':checked'); - var table = $(this).parents('table.grid:first'); + var table = $(this).parents('table:first'); table.find('tbody tr').each(function() { $(this).find('td.checkbox input[type=checkbox]').attr('checked', checked); if (checked) { @@ -253,7 +254,7 @@ $(function() { }); }); - $('table.grid.selectable tbody tr').live('click', function() { + $('div.grid.selectable table tbody tr').live('click', function() { var table = $(this).parents('table:first'); if (! table.hasClass('multiple')) { table.find('tbody tr').removeClass('selected'); @@ -261,12 +262,22 @@ $(function() { $(this).addClass('selected'); }); - $('table.grid.checkable tbody tr').live('click', function() { + $('div.grid.checkable table tbody tr').live('click', function() { var checkbox = $(this).find('td:first input[type=checkbox]'); checkbox.attr('checked', !checkbox.is(':checked')); $(this).toggleClass('selected'); }); + $('div.grid td.delete').live('click', function() { + if (confirm("Do you really wish to delete this object?")) { + var grid = $(this).parents('div.grid:first'); + var url = grid.attr('delurl'); +// alert(url + '?uuid=' + get_uuid(this) + '&delete=true'); + location.href = url + '?uuid=' + get_uuid(this) + '&delete=true'; + } + return false; + }); + $('#grid-page-count').live('change', function() { var div = $(this).parents('div.grid:first'); loading(div); @@ -320,7 +331,7 @@ $(function() { $('div.dialog.lookup button.ok').live('click', function() { var dialog = $(this).parents('div.dialog.lookup:first'); - var tr = dialog.find('table.grid tbody tr.selected'); + var tr = dialog.find('div.grid table tbody tr.selected'); if (! tr.length) { alert("You haven't selected anything."); return false; diff --git a/edbob/pyramid/subscribers.py b/edbob/pyramid/subscribers.py index 60ee3eb..13315e8 100644 --- a/edbob/pyramid/subscribers.py +++ b/edbob/pyramid/subscribers.py @@ -26,8 +26,8 @@ ``edbob.pyramid.subscribers`` -- Subscribers """ +from pyramid import threadlocal from pyramid.security import authenticated_userid -# from sqlahelper import get_session import edbob from edbob.db.auth import has_permission @@ -43,9 +43,11 @@ def before_render(event): * ``edbob`` """ + request = event.get('request') or threadlocal.get_current_request() + renderer_globals = event renderer_globals['h'] = helpers - renderer_globals['url'] = event['request'].route_url + renderer_globals['url'] = request.route_url renderer_globals['edbob'] = edbob diff --git a/edbob/pyramid/templates/crud.mako b/edbob/pyramid/templates/crud.mako new file mode 100644 index 0000000..6a1a27f --- /dev/null +++ b/edbob/pyramid/templates/crud.mako @@ -0,0 +1,2 @@ +<%inherit file="/edbob/crud.mako" /> +${parent.body()} diff --git a/edbob/pyramid/templates/edbob/base.mako b/edbob/pyramid/templates/edbob/base.mako index 0f4aeee..5a09414 100644 --- a/edbob/pyramid/templates/edbob/base.mako +++ b/edbob/pyramid/templates/edbob/base.mako @@ -1,6 +1,7 @@ <%def name="global_title()">edbob -<%def name="title()"> +<%def name="title()">${(fieldset.crud_title+' : '+fieldset.get_display_text() if fieldset.edit else 'New '+fieldset.crud_title) if crud else ''} <%def name="head_tags()"> +<%def name="home_link()">

${h.link_to("Home", url('home'))}

<%def name="footer()"> powered by ${h.link_to('edbob', 'http://edbob.org', target='_blank')} v${edbob.__version__} @@ -10,14 +11,14 @@ ${self.global_title()}${' : ' + capture(self.title) if capture(self.title) else ''} - ${h.javascript_link('edbob/js/jquery.js')} - ${h.javascript_link('edbob/js/jquery.ui.js')} - ${h.javascript_link('edbob/js/jquery.loading.js')} - ${h.javascript_link('edbob/js/jquery.autocomplete.js')} - ${h.javascript_link('edbob/js/edbob.js')} + ${h.javascript_link(request.static_url('edbob.pyramid:static/js/jquery.js'))} + ${h.javascript_link(request.static_url('edbob.pyramid:static/js/jquery.ui.js'))} + ${h.javascript_link(request.static_url('edbob.pyramid:static/js/jquery.loading.js'))} + ${h.javascript_link(request.static_url('edbob.pyramid:static/js/jquery.autocomplete.js'))} + ${h.javascript_link(request.static_url('edbob.pyramid:static/js/edbob.js'))} - ${h.stylesheet_link('edbob/css/smoothness/jquery-ui-1.8.2.custom.css')} - ${h.stylesheet_link('edbob/css/edbob.css')} + ${h.stylesheet_link(request.static_url('edbob.pyramid:static/css/smoothness/jquery-ui-1.8.2.custom.css'))} + ${h.stylesheet_link(request.static_url('edbob.pyramid:static/css/edbob.css'))} ${self.head_tags()} @@ -27,7 +28,7 @@
%s
+ + %for field in fieldset.render_fields.itervalues(): + %if field.requires_label: + + + + + %endif + %endfor + +
${field.label()|h}${field.render_readonly()|n}
\ No newline at end of file diff --git a/edbob/pyramid/templates/forms/filterset.mako b/edbob/pyramid/templates/forms/filterset.mako new file mode 100644 index 0000000..8ae1814 --- /dev/null +++ b/edbob/pyramid/templates/forms/filterset.mako @@ -0,0 +1,36 @@ +
+ ${search.begin()} + <% visible = [] %> + % for f in search.sorted_filters(): + <% f = search.filters[f] %> + + % if search.config.get('include_filter_'+f.name): + <% visible.append(f.name) %> + % endif + % endfor +
+ ${search.add_filter(visible)} + ${search.submit('submit', "Search", style='display: none;' if not visible else None)} + +
+ ${search.end()} + % if visible: + + % endif +
diff --git a/edbob/pyramid/templates/forms/grid_readonly.mako b/edbob/pyramid/templates/forms/grid_readonly.mako new file mode 100644 index 0000000..d05feae --- /dev/null +++ b/edbob/pyramid/templates/forms/grid_readonly.mako @@ -0,0 +1,57 @@ +
+ +##url="${grid.url_grid}" +## objurl="${grid.url_object}" delurl="${grid.url_object}" +## usedlg="${grid.config['use_dialog']}"> + + + + + % if checkboxes: + + % endif + % for field in grid.iter_fields(): + ${grid.th_sortable(field)|n} + % endfor +## % for i in range(len(grid.config['actions'])): +## +## % endfor + % if grid.deletable: + + % endif + + + + + % for i, row in enumerate(grid.rows): + <% grid._set_active(row) %> +## + + % if checkboxes: + + % endif + % for field in grid.iter_fields(): + + % endfor +## ${grid.get_actions()} + %if grid.deletable: + + %endif + + % endfor + +
${h.checkbox('check-all')}  
${h.checkbox('check-'+grid.model.uuid, disabled=True)}${grid.render_field(field, True)|n} 
+ % if hasattr(grid, 'pager') and grid.pager: +
+

+ showing + ${grid.pager.first_item} thru ${grid.pager.last_item} of ${grid.pager.item_count} +

+ +
+ % endif +
diff --git a/edbob/pyramid/views/__init__.py b/edbob/pyramid/views/__init__.py index cfcc6b4..8f5f02a 100644 --- a/edbob/pyramid/views/__init__.py +++ b/edbob/pyramid/views/__init__.py @@ -39,17 +39,17 @@ from edbob.db.auth import authenticate_user _here = os.path.join(os.path.dirname(__file__), os.pardir) -_favicon = open(os.path.join(_here, 'static', 'favicon.ico'), 'rb').read() -_favicon_response = Response(content_type='image/x-icon', body=_favicon) +# _favicon = open(os.path.join(_here, 'static', 'favicon.ico'), 'rb').read() +# _favicon_response = Response(content_type='image/x-icon', body=_favicon) -@view_config(route_name='favicon.ico') -def favicon_ico(context, request): - return _favicon_response +# @view_config(route_name='favicon.ico') +# def favicon_ico(context, request): +# return _favicon_response -@view_config(route_name='home', renderer='home.mako') -def home(context, request): - return {} +# @view_config(route_name='home', renderer='/home.mako') +# def home(context, request): +# return {} @view_config(route_name='login', renderer='login.mako') @@ -80,17 +80,17 @@ def login(context, request): return {} -_robots = open(os.path.join(_here, 'static', 'robots.txt')).read() -_robots_response = Response(content_type='text/plain', body=_robots) +# _robots = open(os.path.join(_here, 'static', 'robots.txt')).read() +# _robots_response = Response(content_type='text/plain', body=_robots) -@view_config(route_name='robots.txt') -def robots_txt(context, request): - return _robots_response +# @view_config(route_name='robots.txt') +# def robots_txt(context, request): +# return _robots_response def includeme(config): - config.add_route('home', '/') - config.add_route('favicon.ico', '/favicon.ico') - config.add_route('robots.txt', '/robots.txt') + # config.add_route('home', '/') + # config.add_route('favicon.ico', '/favicon.ico') + # config.add_route('robots.txt', '/robots.txt') config.add_route('login', '/login') config.scan() diff --git a/edbob/pyramid/views/crud.py b/edbob/pyramid/views/crud.py new file mode 100644 index 0000000..d0e12c9 --- /dev/null +++ b/edbob/pyramid/views/crud.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +################################################################################ +# +# edbob -- Pythonic Software Framework +# Copyright © 2010-2012 Lance Edgar +# +# This file is part of edbob. +# +# edbob 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. +# +# edbob 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 edbob. If not, see . +# +################################################################################ + +""" +``edbob.pyramid.views.crud`` -- CRUD View Function +""" + +# from pyramid.renderers import render_to_response +# from pyramid.httpexceptions import HTTPException, HTTPFound, HTTPOk, HTTPUnauthorized +import transaction +from pyramid.httpexceptions import HTTPFound, HTTPException + +# import sqlahelper + +# # import rattail.pyramid.forms.util as util +# from rattail.db.perms import has_permission +# from rattail.pyramid.forms.formalchemy import Grid + +from edbob.pyramid import Session + + +def crud(request, cls, fieldset_factory, home=None, delete=None, post_sync=None, pre_render=None): + """ + Adds a common CRUD mechanism for objects. + + ``cls`` should be a SQLAlchemy-mapped class, presumably deriving from + :class:`edbob.Object`. + + ``fieldset_factory`` must be a callable which accepts the fieldset's + "model" as its only positional argument. + + ``home`` will be used as the redirect location once a form is fully + validated and data saved. If you do not speficy this parameter, the + user will be redirected to be the CRUD page for the new object (e.g. so + an object may be created before certain properties may be edited). + + ``delete`` may either be a string containing a URL to which the user + should be redirected after the object has been deleted, or else a + callback which will be executed *instead of* the normal algorithm + (which is merely to delete the object via the Session). + + ``post_sync`` may be a callback which will be executed immediately + after :meth:`FieldSet.sync()` is called, i.e. after validation as well. + + ``pre_render`` may be a callback which will be executed after any POST + processing has occured, but just before rendering. + """ + + uuid = request.params.get('uuid') + obj = Session.query(cls).get(uuid) if uuid else cls + assert obj + + if request.params.get('delete'): + if delete: + if isinstance(delete, basestring): + with transaction.manager: + Session.delete(obj) + return HTTPFound(location=delete) + with transaction.manager: + res = delete(obj) + if res: + return res + else: + with transaction.manager: + Session.delete(obj) + if not home: + raise ValueError("Must specify 'home' or 'delete' url " + "in call to crud()") + return HTTPFound(location=home) + + fs = fieldset_factory(obj) + + # if not fs.readonly and self.request.params.get('fieldset'): + # fs.rebind(data=self.request.params) + # if fs.validate(): + # fs.sync() + # if post_sync: + # res = post_sync(fs) + # if isinstance(res, HTTPFound): + # return res + # if self.request.params.get('partial'): + # self.Session.flush() + # return self.json_success(uuid=fs.model.uuid) + # return HTTPFound(location=self.request.route_url(objects, action='index')) + + if not fs.readonly and request.POST: + # print self.request.POST + fs.rebind(data=request.params) + if fs.validate(): + with transaction.manager: + fs.sync() + if post_sync: + res = post_sync(fs) + if res: + return res + + if request.params.get('partial'): + # Session.flush() + # return self.json_success(uuid=fs.model.uuid) + assert False, "need to fix this" + + # Session.commit() + if not home: + # FIXME + # home = request.route_url.current() + '?uuid=' + fs.model.uuid + # home = request.route_url('home') + fs.model = Session.merge(fs.model) + home = request.current_route_url() + '?uuid=' + fs.model.uuid + request.session.flash("%s \"%s\" has been %s." % ( + fs.crud_title, fs.get_display_text(), + 'updated' if fs.edit else 'created')) + return HTTPFound(location=home) + + data = {'fieldset': fs, 'crud': True} + + if pre_render: + res = pre_render(fs) + if res: + if isinstance(res, HTTPException): + return res + data.update(res) + + # data = {'fieldset':fs} + # if self.request.params.get('partial'): + # return render_to_response('/%s/crud_partial.mako' % objects, + # data, request=self.request) + # return data + + return data + + +# class needs_perm(object): +# """ +# Decorator to be used for handler methods which should restrict access based +# on the current user's permissions. +# """ + +# def __init__(self, permission, **kwargs): +# self.permission = permission +# self.kwargs = kwargs + +# def __call__(self, fn): +# permission = self.permission +# kw = self.kwargs +# def wrapped(self): +# if not self.request.current_user: +# self.request.session['referrer'] = self.request.url_generator.current() +# self.request.session.flash("You must be logged in to do that.", 'error') +# return HTTPFound(location=self.request.route_url('login')) +# if not has_permission(self.request.current_user, permission): +# self.request.session.flash("You do not have permission to do that.", 'error') +# home = kw.get('redirect', self.request.route_url('home')) +# return HTTPFound(location=home) +# return fn(self) +# return wrapped + + +# def needs_user(fn): +# """ +# Decorator for handler methods which require simply that a user be currently +# logged in. +# """ + +# def wrapped(self): +# if not self.request.current_user: +# self.request.session['referrer'] = self.request.url_generator.current() +# self.request.session.flash("You must be logged in to do that.", 'error') +# return HTTPFound(location=self.request.route_url('login')) +# return fn(self) +# return wrapped + + +# class Handler(object): + +# def __init__(self, request): +# self.request = request +# self.Session = sqlahelper.get_session() + +# # def json_response(self, data={}): +# # response = render_to_response('json', data, request=self.request) +# # response.headers['Content-Type'] = 'application/json' +# # return response + + +# class CrudHandler(Handler): +# # """ +# # This handler provides all the goodies typically associated with general +# # CRUD functionality, e.g. search filters and grids. +# # """ + +# def crud(self, cls, fieldset_factory, home=None, delete=None, post_sync=None, pre_render=None): +# """ +# Adds a common CRUD mechanism for objects. + +# ``cls`` should be a SQLAlchemy-mapped class, presumably deriving from +# :class:`rattail.Object`. + +# ``fieldset_factory`` must be a callable which accepts the fieldset's +# "model" as its only positional argument. + +# ``home`` will be used as the redirect location once a form is fully +# validated and data saved. If you do not speficy this parameter, the +# user will be redirected to be the CRUD page for the new object (e.g. so +# an object may be created before certain properties may be edited). + +# ``delete`` may either be a string containing a URL to which the user +# should be redirected after the object has been deleted, or else a +# callback which will be executed *instead of* the normal algorithm +# (which is merely to delete the object via the Session). + +# ``post_sync`` may be a callback which will be executed immediately +# after ``FieldSet.sync()`` is called, i.e. after validation as well. + +# ``pre_render`` may be a callback which will be executed after any POST +# processing has occured, but just before rendering. +# """ + +# uuid = self.request.params.get('uuid') +# obj = self.Session.query(cls).get(uuid) if uuid else cls +# assert obj + +# if self.request.params.get('delete'): +# if delete: +# if isinstance(delete, basestring): +# self.Session.delete(obj) +# return HTTPFound(location=delete) +# res = delete(obj) +# if res: +# return res +# else: +# self.Session.delete(obj) +# if not home: +# raise ValueError("Must specify 'home' or 'delete' url " +# "in call to CrudHandler.crud()") +# return HTTPFound(location=home) + +# fs = fieldset_factory(obj) + +# # if not fs.readonly and self.request.params.get('fieldset'): +# # fs.rebind(data=self.request.params) +# # if fs.validate(): +# # fs.sync() +# # if post_sync: +# # res = post_sync(fs) +# # if isinstance(res, HTTPFound): +# # return res +# # if self.request.params.get('partial'): +# # self.Session.flush() +# # return self.json_success(uuid=fs.model.uuid) +# # return HTTPFound(location=self.request.route_url(objects, action='index')) + +# if not fs.readonly and self.request.POST: +# # print self.request.POST +# fs.rebind(data=self.request.params) +# if fs.validate(): +# fs.sync() +# if post_sync: +# res = post_sync(fs) +# if res: +# return res +# if self.request.params.get('partial'): +# self.Session.flush() +# return self.json_success(uuid=fs.model.uuid) + +# if not home: +# self.Session.flush() +# home = self.request.url_generator.current() + '?uuid=' + fs.model.uuid +# self.request.session.flash("%s \"%s\" has been %s." % ( +# fs.crud_title, fs.get_display_text(), +# 'updated' if fs.edit else 'created')) +# return HTTPFound(location=home) + +# data = {'fieldset': fs, 'crud': True} + +# if pre_render: +# res = pre_render(fs) +# if res: +# if isinstance(res, HTTPException): +# return res +# data.update(res) + +# # data = {'fieldset':fs} +# # if self.request.params.get('partial'): +# # return render_to_response('/%s/crud_partial.mako' % objects, +# # data, request=self.request) +# # return data + +# return data + +# def grid(self, *args, **kwargs): +# """ +# Convenience function which returns a grid. The only functionality this +# method adds is the ``session`` parameter. +# """ + +# return Grid(session=self.Session(), *args, **kwargs) + +# # def get_grid(self, name, grid, query, search=None, url=None, **defaults): +# # """ +# # Convenience function for obtaining the configuration for a grid, +# # and then obtaining the grid itself. + +# # ``name`` is essentially the config key, e.g. ``'products.lookup'``, and +# # in fact is expected to take that precise form (where the first part is +# # considered the handler name and the second part the action name). + +# # ``grid`` must be a callable with a signature of ``grid(query, +# # config)``, and ``query`` will be passed directly to the ``grid`` +# # callable. ``search`` will be used to inform the grid of the search in +# # effect, if any. ``defaults`` will be used to customize the grid config. +# # """ + +# # if not url: +# # handler, action = name.split('.') +# # url = self.request.route_url(handler, action=action) +# # config = util.get_grid_config(name, self.request, search, +# # url=url, **defaults) +# # return grid(query, config) + +# # def get_search_form(self, name, labels={}, **defaults): +# # """ +# # Convenience function for obtaining the configuration for a search form, +# # and then obtaining the form itself. + +# # ``name`` is essentially the config key, e.g. ``'products.lookup'``. +# # The ``labels`` dictionary can be used to override the default labels +# # displayed for the various search fields. The ``defaults`` dictionary +# # is used to customize the search config. +# # """ + +# # config = util.get_search_config(name, self.request, +# # self.filter_map(), **defaults) +# # form = util.get_search_form(config, **labels) +# # return form + +# # def object_crud(self, cls, objects=None, post_sync=None): +# # """ +# # This method is a desperate attempt to encapsulate shared CRUD logic +# # which is useful across all editable data objects. + +# # ``objects``, if provided, should be the plural name for the class as +# # used in internal naming, e.g. ``'products'``. A default will be used +# # if you do not provide this value. + +# # ``post_sync``, if provided, should be a callable which accepts a +# # ``formalchemy.Fieldset`` instance as its only argument. It will be +# # called immediately after the fieldset is synced. +# # """ + +# # if not objects: +# # objects = cls.__name__.lower() + 's' + +# # uuid = self.request.params.get('uuid') +# # obj = self.Session.query(cls).get(uuid) if uuid else cls +# # assert obj + +# # fs = self.fieldset(obj) + +# # if not fs.readonly and self.request.params.get('fieldset'): +# # fs.rebind(data=self.request.params) +# # if fs.validate(): +# # fs.sync() +# # if post_sync: +# # res = post_sync(fs) +# # if isinstance(res, HTTPFound): +# # return res +# # if self.request.params.get('partial'): +# # self.Session.flush() +# # return self.json_success(uuid=fs.model.uuid) +# # return HTTPFound(location=self.request.route_url(objects, action='index')) + +# # data = {'fieldset':fs} +# # if self.request.params.get('partial'): +# # return render_to_response('/%s/crud_partial.mako' % objects, +# # data, request=self.request) +# # return data + +# # def render_grid(self, grid, search=None, **kwargs): +# # """ +# # Convenience function to render a standard grid. Really just calls +# # :func:`dtail.forms.util.render_grid()`. +# # """ + +# # return util.render_grid(self.request, grid, search, **kwargs) diff --git a/edbob/util.py b/edbob/util.py index 57be141..ef4be0c 100644 --- a/edbob/util.py +++ b/edbob/util.py @@ -26,9 +26,42 @@ ``edbob.util`` -- Utilities """ +from pkg_resources import iter_entry_points + import edbob +def entry_point_map(key): + """ + Convenience function to retrieve a dictionary of entry points, keyed by + name. + + ``key`` must be the "section name" for the entry points you're after, e.g. + ``'edbob.commands'``. + """ + + epmap = {} + for ep in iter_entry_points(key): + epmap[ep.name] = ep.load() + return epmap + + +def prettify(text): + """ + Returns a "prettified" version of ``text``, which is more or less assumed + to be a Pythonic representation of an (singular or plural) entity name. It + splits the text into capitalized words, e.g. "purchase_orders" becomes + "Purchase Orders". + + .. note:: + No attempt is made to handle pluralization; the spelling of ``text`` is + always preserved. + """ + + words = text.replace('_', ' ').split() + return ' '.join([x.capitalize() for x in words]) + + class requires_impl(edbob.Object): """ Decorator for properties or methods defined on parent classes only for diff --git a/setup.py b/setup.py index 4e86304..1efca16 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,8 @@ from setuptools import setup, find_packages here = os.path.abspath(os.path.dirname(__file__)) execfile(os.path.join(here, 'edbob', '_version.py')) -readme = open(os.path.join(here, 'README.txt')).read() +README = open(os.path.join(here, 'README.txt')).read() +CHANGES = open(os.path.join(here, 'CHANGES.txt')).read() requires = [ @@ -67,10 +68,11 @@ requires = [ # outside the lines with regard to these soft limits. If bugs are # encountered then they should be filed as such. # - # package # low high + # package # low high - 'progressbar', # 2.3 - 'pytz', # 2012b + 'decorator', # 3.3.2 + 'progressbar', # 2.3 + 'pytz', # 2012b ] if sys.version_info < (2, 7): @@ -79,7 +81,7 @@ if sys.version_info < (2, 7): requires += [ # # package # low high - # + 'argparse', # 1.2.1 ] @@ -92,7 +94,7 @@ setup( url = "http://edbob.org/", license = "GNU Affero GPL v3", description = "Pythonic Software Framework", - long_description = readme, + long_description = README + '\n\n' + CHANGES, classifiers = [ 'Development Status :: 3 - Alpha', @@ -117,30 +119,44 @@ setup( 'db': [ # # package # low high - # + 'alembic', # 0.2.1 - 'decorator', # 3.3.2 'py-bcrypt', # 0.2 'SQLAlchemy', # 0.7.6 - # 'sqlalchemy-migrate', # 0.7.2 'Tempita', # 0.5.1 ], 'docs': [ # # package # low high - # + 'Sphinx', # 1.1.3 ], 'pyramid': [ # # package # low high - # + # Beaker dependency included here because 'pyramid_beaker' uses incorrect + # case in its requirement declaration. + 'Beaker', # 1.6.3 + # Pyramid 1.3 introduced 'pcreate' command (and friends) to replace # deprecated 'paster create' (and friends). 'pyramid>=1.3a1', # 1.3b2 + + 'FormAlchemy', # 1.4.2 + 'FormEncode', # 1.2.4 + 'Mako', # 0.6.2 + 'pyramid_beaker', # 0.6.1 + 'pyramid_debugtoolbar', # 1.0 + 'pyramid_simpleform', # 0.6.1 + 'pyramid_tm', # 0.3 + 'Tempita', # 0.5.1 + 'transaction', # 1.2.0 + 'waitress', # 0.8.1 + 'WebHelpers', # 1.3 + 'zope.sqlalchemy', # 0.7 ], }, @@ -164,5 +180,8 @@ db = edbob.commands:DatabaseCommand shell = edbob.commands:ShellCommand uuid = edbob.commands:UuidCommand +[edbob.db.extensions] +auth = edbob.db.extensions.auth:AuthExtension + """, )