diff --git a/CHANGES.txt b/CHANGES.txt index cb458ca..8bfbfbc 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,94 @@ +0.1.2 +----- + +* Allow config file to prevent logging configuration from happening. + + +0.1.1 +----- + +* Some random things required in production at MaMa Jean's... + + Specifically this is known to replace occurrences of e.g. ``edbob.User`` with + a more standard (properly imported) reference to ``User``. + + +0.1a29 +------ + +* Removed ``setup.cfg`` file. + +* Changed some logging instances from ``INFO`` to ``DEBUG``. + +* Updated ``repr()`` output for model classes. + + +0.1a28 +------ + +- [bug] Secured daemon PID files. They are no longer readable or writeable + (whoops) by anyone other than the user who owns the process. + +- [feature] Added ``--progress`` argument to command system. + +- [general] Added initial Fabric script. + + +0.1a27 +------ + +- [feature] Overhauled file monitors. Added the "process existing" (defaults + to true) and "stop on error" (defaults to false) features to both Linux and + Win32 file monitors. Both features may be overridden in config. The Linux + file monitor was rewritten as an ``edbob.daemon.Daemon`` class. + +0.1a26 +------ + +- [feature] Added ``StrippingFieldRenderer``. + +- [general] Some style tweaks. + +- [bug] Fixed ``graft()`` function when called with module reference. + +0.1a25 +------ + +- [feature] Added "sqlerror" tween which, in combination with ``pyramid_tm``, + can allow Pyramid apps to gracefully handle database server restarts. Any + transaction which fails due to a disconnection from the server may be + attempted more than once. The assumption is that the second attempt will + succeed, since the underlying connection pool will have been invalidated upon + the first failure. + +- [feature] Added ``PHONE_TYPE`` enumeration. + +- [general] Added some ``__unicode__()`` methods to database models. + +- [bug] Fixed CSS in the ``progress.mako`` template. + +- [feature] Improved the ``load_spec()`` function so that it raises an + ``InvalidSpec`` exception if the spec doesn't contain exactly one colon. + +0.1a24 +------ + +- [bug] Fixed bug where creating a new ``Role`` with the UI would use default + permissions of the Guest role. Now the default permissions are empty. + +- [bug] Fixed ``User.roles`` UI so that the Guest role is never shown. + +0.1a23 +------ + +- [feature] Added ``capture_output()`` function to ``win32`` module. This is a + convenience function which works around an issue when attempting to capture + output from a command when the calling application is a Windows GUI app which + was launched via the Windows console (DOS terminal). + +- [feature] Updated ``User`` and ``Role`` management views for Pyramid apps. + 0.1a22 ------ diff --git a/edbob/__init__.py b/edbob/__init__.py index 8ab239f..e1574df 100644 --- a/edbob/__init__.py +++ b/edbob/__init__.py @@ -28,6 +28,7 @@ from edbob._version import __version__ +from edbob.enum import * from edbob.core import * from edbob.time import * from edbob.files import * diff --git a/edbob/_version.py b/edbob/_version.py index 48aa257..10939f0 100644 --- a/edbob/_version.py +++ b/edbob/_version.py @@ -1 +1 @@ -__version__ = '0.1a22' +__version__ = '0.1.2' diff --git a/edbob/commands.py b/edbob/commands.py index 6c47598..c6e5b0f 100644 --- a/edbob/commands.py +++ b/edbob/commands.py @@ -105,6 +105,7 @@ Options: Config path (may be specified more than once) -n, --no-init Don't load config before executing command -d, --debug Increase logging level to DEBUG + -P, --progress Show progress indicators (where relevant) -v, --verbose Increase logging level to INFO -V, --version Display program version and exit @@ -132,6 +133,7 @@ Try '%(name)s help ' for more help.""" % self metavar='PATH') parser.add_argument('-d', '--debug', action='store_true', dest='debug') parser.add_argument('-n', '--no-init', action='store_true', default=False) + parser.add_argument('-P', '--progress', action='store_true', default=False) parser.add_argument('-v', '--verbose', action='store_true', dest='verbose') parser.add_argument('-V', '--version', action='version', version="%%(prog)s %s" % self.version) @@ -182,6 +184,7 @@ Try '%(name)s help ' for more help.""" % self # And finally, do something of real value... cmd = self.subcommands[cmd](parent=self) + cmd.show_progress = args.progress cmd._run(*(args.command + args.argv)) @@ -432,11 +435,6 @@ class FileMonitorCommand(Subcommand): uninstall = subparsers.add_parser('uninstall', help="Uninstall (remove) service") uninstall.set_defaults(subcommand='remove') - else: - parser.add_argument('-D', '--dont-daemonize', - action='store_false', dest='daemonize', - help="Don't daemonize when starting") - def get_win32_module(self): from edbob.filemon import win32 return win32 @@ -454,10 +452,10 @@ class FileMonitorCommand(Subcommand): from edbob.filemon import linux as filemon if args.subcommand == 'start': - filemon.start_daemon(self.appname, daemonize=args.daemonize) + filemon.start_daemon(self.appname) elif args.subcommand == 'stop': - filemon.stop_daemon() + filemon.stop_daemon(self.appname) elif sys.platform == 'win32': from edbob import win32 diff --git a/edbob/core.py b/edbob/core.py index 553ddab..70ebc79 100644 --- a/edbob/core.py +++ b/edbob/core.py @@ -112,7 +112,7 @@ def graft(target, source, names=None): if hasattr(source, '__all__'): names = source.__all__ else: - names = dir(source) + names = [x for x in dir(source) if not x.startswith('_')] elif isinstance(names, basestring): names = [names] diff --git a/edbob/daemon.py b/edbob/daemon.py index 11fbfaa..05c90cb 100644 --- a/edbob/daemon.py +++ b/edbob/daemon.py @@ -3,10 +3,11 @@ from __future__ import absolute_import -# This code was stolen from: +# This code was (mostly, with some tweaks) stolen from: # http://www.jejik.com/articles/2007/02/a_simple_unix_linux_daemon_in_python/ import sys, os, time, atexit +import stat from signal import SIGTERM class Daemon: @@ -65,6 +66,7 @@ class Daemon: atexit.register(self.delpid) pid = str(os.getpid()) file(self.pidfile,'w+').write("%s\n" % pid) + os.chmod(self.pidfile, stat.S_IRUSR|stat.S_IWUSR) def delpid(self): os.remove(self.pidfile) diff --git a/edbob/db/auth.py b/edbob/db/auth.py index ad7d72f..31fbb5b 100644 --- a/edbob/db/auth.py +++ b/edbob/db/auth.py @@ -105,7 +105,7 @@ def grant_permission(role, permission, session=None): role.permissions.append(permission) -def has_permission(obj, perm, session=None): +def has_permission(obj, perm, include_guest=True, session=None): """ Checks the given ``obj`` (which may be either a :class:`edbob.User`` or :class:`edbob.Role` instance), and returns a boolean indicating whether or @@ -124,8 +124,9 @@ def has_permission(obj, perm, session=None): if not session: session = object_session(obj) assert session + if include_guest: + roles.append(guest_role(session)) admin = administrator_role(session) - roles.append(guest_role(session)) for role in roles: if role is admin: return True diff --git a/edbob/db/extensions/__init__.py b/edbob/db/extensions/__init__.py index cf875da..a235b3e 100644 --- a/edbob/db/extensions/__init__.py +++ b/edbob/db/extensions/__init__.py @@ -366,7 +366,7 @@ def extend_framework(): session.close() for name in sorted(extensions, extension_sorter(extensions)): - log.info("Applying active extension: %s" % name) + log.debug("Applying active extension: %s" % name) ext = extensions[name] # merge_extension_metadata(ext) # ext.extend_classes() diff --git a/edbob/db/extensions/auth/model.py b/edbob/db/extensions/auth/model.py index 44f486b..f167739 100644 --- a/edbob/db/extensions/auth/model.py +++ b/edbob/db/extensions/auth/model.py @@ -51,10 +51,11 @@ class Permission(Base): permission = Column(String(50), primary_key=True) def __repr__(self): - return "" % (self.role, self.permission) + return "Permission(role_uuid={0}, permission={1})".format( + repr(self.role_uuid), repr(self.permission)) - def __str__(self): - return str(self.permission or '') + def __unicode__(self): + return unicode(self.permission or '') class UserRole(Base): @@ -69,7 +70,7 @@ class UserRole(Base): role_uuid = Column(String(32), ForeignKey('roles.uuid')) def __repr__(self): - return "" % (self.user, self.role) + return "UserRole(uuid={0})".format(repr(self.uuid)) class Role(Base): @@ -89,16 +90,18 @@ class Role(Base): creator=lambda x: Permission(permission=x), getset_factory=getset_factory) - _users = relationship(UserRole, backref='role') + _users = relationship( + UserRole, backref='role', + cascade='save-update, merge, delete, delete-orphan') users = association_proxy('_users', 'user', creator=lambda x: UserRole(user=x), getset_factory=getset_factory) def __repr__(self): - return "" % self.name + return "Role(uuid={0})".format(repr(self.uuid)) - def __str__(self): - return str(self.name or '') + def __unicode__(self): + return unicode(self.name or '') class User(Base): @@ -122,17 +125,18 @@ class User(Base): getset_factory=getset_factory) def __repr__(self): - return "" % self.username + return "User(uuid={0})".format(repr(self.uuid)) - def __str__(self): - return str(self.username or '') + def __unicode__(self): + return unicode(self.username or '') @property def display_name(self): """ - Returns the user's ``person.display_name``, if present, otherwise the - ``username``. + Returns :attr:`Person.display_name` if present; otherwise returns + :attr:`username`. """ + if self.person and self.person.display_name: return self.person.display_name return self.username diff --git a/edbob/db/extensions/contact/model.py b/edbob/db/extensions/contact/model.py index 8cd05de..6576b5c 100644 --- a/edbob/db/extensions/contact/model.py +++ b/edbob/db/extensions/contact/model.py @@ -72,7 +72,8 @@ class PhoneNumber(Base): __mapper_args__ = {'polymorphic_on': parent_type} def __repr__(self): - return "<%s: %s>" % (self.__class__.__name__, self.number) + return "{0}(uuid={1})".format( + self.__class__.__name__, repr(self.uuid)) def __unicode__(self): return unicode(self.number) @@ -103,7 +104,8 @@ class EmailAddress(Base): __mapper_args__ = {'polymorphic_on': parent_type} def __repr__(self): - return "<%s: %s>" % (self.__class__.__name__, self.address) + return "{0}(uuid={1})".format( + self.__class__.__name__, repr(self.uuid)) def __unicode__(self): return unicode(self.address) @@ -131,7 +133,7 @@ class Person(Base): display_name = Column(String(100), default=get_person_display_name) def __repr__(self): - return "" % self.display_name + return "Person(uuid={0})".format(repr(self.uuid)) def __unicode__(self): return unicode(self.display_name or '') diff --git a/edbob/db/model.py b/edbob/db/model.py index a077ba0..b5222c3 100644 --- a/edbob/db/model.py +++ b/edbob/db/model.py @@ -54,7 +54,7 @@ class ActiveExtension(Base): name = Column(String(50), primary_key=True) def __repr__(self): - return "" % self.name + return "ActiveExtension(name={0})".format(repr(self.name)) def __str__(self): return str(self.name or '') @@ -71,4 +71,4 @@ class Setting(Base): value = Column(Text) def __repr__(self): - return "" % self.name + return "Setting(name={0})".format(repr(self.name)) diff --git a/edbob/db/extensions/contact/enum.py b/edbob/enum.py similarity index 81% rename from edbob/db/extensions/contact/enum.py rename to edbob/enum.py index 9972273..958dd7e 100644 --- a/edbob/db/extensions/contact/enum.py +++ b/edbob/enum.py @@ -23,7 +23,7 @@ ################################################################################ """ -``edbob.db.extensions.contact.enum`` -- Enumerations +``edbob.enum`` -- Enumerations """ @@ -38,3 +38,14 @@ EMAIL_PREFERENCE = { EMAIL_PREFERENCE_HTML : "HTML", EMAIL_PREFERENCE_MOBILE : "Mobile", } + + +PHONE_TYPE_HOME = 'home' +PHONE_TYPE_MOBILE = 'mobile' +PHONE_TYPE_OTHER = 'other' + +PHONE_TYPE = { + PHONE_TYPE_HOME : "Home", + PHONE_TYPE_MOBILE : "Mobile", + PHONE_TYPE_OTHER : "Other", + } diff --git a/edbob/exceptions.py b/edbob/exceptions.py index a5a802e..58da82f 100644 --- a/edbob/exceptions.py +++ b/edbob/exceptions.py @@ -73,6 +73,12 @@ class LoadSpecError(Exception): return None +class InvalidSpec(LoadSpecError): + + def specifics(self): + return "invalid spec" + + class ModuleMissingAttribute(LoadSpecError): """ Raised during :func:`edbob.load_spec()` when the module imported okay but diff --git a/edbob/filemon/__init__.py b/edbob/filemon/__init__.py index 07662e5..205ac31 100644 --- a/edbob/filemon/__init__.py +++ b/edbob/filemon/__init__.py @@ -26,10 +26,18 @@ ``edbob.filemon`` -- File Monitoring Service """ +import os import os.path +import sys +import Queue import logging import edbob +from edbob.errors import email_exception + +if sys.platform == 'win32': + import win32api + from edbob.win32 import file_is_free log = logging.getLogger(__name__) @@ -65,6 +73,12 @@ class MonitorProfile(object): self.locks = edbob.config.getboolean( '%s.filemon' % appname, '%s.locks' % key, default=False) + self.process_existing = edbob.config.getboolean( + '%s.filemon' % appname, '%s.process_existing' % key, default=True) + + self.stop_on_error = edbob.config.getboolean( + '%s.filemon' % appname, '%s.stop_on_error' % key, default=False) + def get_monitor_profiles(appname): """ @@ -110,3 +124,105 @@ def get_monitor_profiles(appname): del monitored[key] return monitored + + +def queue_existing(profile, path): + """ + Adds files found in a watched folder to a processing queue. This is called + when the monitor first starts, to handle the case of files which exist + prior to startup. + + If files are found, they are first sorted by modification timestamp, using + a lexical sort on the filename as a tie-breaker, and then added to the + queue in that order. + + :param profile: Monitor profile for which the folder is to be watched. The + profile is expected to already have a queue attached; any existing files + will be added to this queue. + :type profile: :class:`edbob.filemon.MonitorProfile` instance + + :param path: Folder path which is to be checked for files. + :type path: string + + :returns: ``None`` + """ + + def sorter(x, y): + mtime_x = os.path.getmtime(x) + mtime_y = os.path.getmtime(y) + if mtime_x < mtime_y: + return -1 + if mtime_x > mtime_y: + return 1 + return cmp(x, y) + + paths = [os.path.join(path, x) for x in os.listdir(path)] + for path in sorted(paths, cmp=sorter): + + # Only process normal files. + if not os.path.isfile(path): + continue + + # If using locks, don't process "in transit" files. + if profile.locks and path.endswith('.lock'): + continue + + log.debug("queue_existing: queuing existing file for " + "profile '%s': %s" % (profile.key, path)) + profile.queue.put(path) + + +def perform_actions(profile): + """ + Callable target for action threads. + """ + + keep_going = True + while keep_going: + + try: + path = profile.queue.get_nowait() + except Queue.Empty: + pass + else: + + # In some cases, processing one file may cause other related files + # to also be processed. When this happens, a path on the queue may + # point to a file which no longer exists. + if not os.path.exists(path): + log.info("perform_actions: path does not exist: %s" % path) + continue + + log.debug("perform_actions: processing file: %s" % path) + + if sys.platform == 'win32': + while not file_is_free(path): + win32api.Sleep(0) + + for spec, func, args in profile.actions: + + log.info("perform_actions: calling function '%s' on file: %s" % + (spec, path)) + + try: + func(path, *args) + + except: + log.exception("perform_actions: exception occurred " + "while processing file: %s" % path) + email_exception() + + # Don't process any more files if the profile is so + # configured. + if profile.stop_on_error: + keep_going = False + + # Either way this particular file probably shouldn't be + # processed any further. + log.warning("perform_actions: no further processing " + "will be done for file: %s" % path) + break + + log.warning("perform_actions: error encountered, and configuration " + "dictates that no more actions will be processed for " + "profile: %s" % profile.key) diff --git a/edbob/filemon/linux.py b/edbob/filemon/linux.py index d5902cc..ae1c4a0 100644 --- a/edbob/filemon/linux.py +++ b/edbob/filemon/linux.py @@ -27,14 +27,24 @@ """ import sys -import os import os.path -import signal +import threading +import Queue import logging -import pyinotify + +try: + import pyinotify +except ImportError: + # Mock out for testing on Windows. + class Dummy(object): + pass + pyinotify = Dummy() + pyinotify.ProcessEvent = Dummy import edbob -from edbob.filemon import get_monitor_profiles +from edbob import filemon +from edbob.daemon import Daemon +from edbob.errors import email_exception log = logging.getLogger(__name__) @@ -45,9 +55,8 @@ class EventHandler(pyinotify.ProcessEvent): Event processor for file monitor daemon. """ - def my_init(self, actions=[], locks=False, **kwargs): - self.actions = actions - self.locks = locks + def my_init(self, profile=None, **kwargs): + self.profile = profile def process_IN_ACCESS(self, event): log.debug("EventHandler: IN_ACCESS: %s" % event.pathname) @@ -57,87 +66,85 @@ class EventHandler(pyinotify.ProcessEvent): def process_IN_CLOSE_WRITE(self, event): log.debug("EventHandler: IN_CLOSE_WRITE: %s" % event.pathname) - if not self.locks: - self.perform_actions(event.pathname) + if not self.profile.locks: + self.profile.queue.put(event.pathname) def process_IN_CREATE(self, event): log.debug("EventHandler: IN_CREATE: %s" % event.pathname) def process_IN_DELETE(self, event): log.debug("EventHandler: IN_DELETE: %s" % event.pathname) - if self.locks and event.pathname.endswith('.lock'): - self.perform_actions(event.pathname[:-5]) + if self.profile.locks and event.pathname.endswith('.lock'): + self.profile.queue.put(event.pathname[:-5]) def process_IN_MODIFY(self, event): log.debug("EventHandler: IN_MODIFY: %s" % event.pathname) def process_IN_MOVED_TO(self, event): log.debug("EventHandler: IN_MOVED_TO: %s" % event.pathname) - if not self.locks: - self.perform_actions(event.pathname) - - def perform_actions(self, path): - for spec, func, args in self.actions: - func(path, *args) + if not self.profile.locks: + self.profile.queue.put(event.pathname) -def get_pid_path(): - """ - Returns the path to the PID file for the file monitor daemon. - """ +class FileMonitorDaemon(Daemon): - basename = os.path.basename(sys.argv[0]) - pid_path = edbob.config.get('%s.filemon' % basename, 'pid_path') + def run(self): + + wm = pyinotify.WatchManager() + notifier = pyinotify.Notifier(wm) + + mask = (pyinotify.IN_ACCESS + | pyinotify.IN_ATTRIB + | pyinotify.IN_CLOSE_WRITE + | pyinotify.IN_CREATE + | pyinotify.IN_DELETE + | pyinotify.IN_MODIFY + | pyinotify.IN_MOVED_TO) + + monitored = filemon.get_monitor_profiles(self.appname) + for key, profile in monitored.iteritems(): + + # Create a file queue for the profile. + profile.queue = Queue.Queue() + + # Perform setup for each of the watched folders. + for path in profile.dirs: + + # Maybe put all pre-existing files in the queue. + if profile.process_existing: + filemon.queue_existing(profile, path) + + # Create a watch for the folder. + log.debug("start_daemon: profile '%s' watches folder: %s" % (key, path)) + wm.add_watch(path, mask, proc_fun=EventHandler(profile=profile)) + + # Create an action thread for the profile. + name = 'actions-%s' % key + log.debug("start_daemon: starting action thread: %s" % name) + thread = threading.Thread(target=filemon.perform_actions, + name=name, args=(profile,)) + thread.daemon = True + thread.start() + + # Fire up the watchers. + notifier.loop() + + +def get_daemon(appname=None): + if appname is None: + appname = os.path.basename(sys.argv[0]) + pid_path = edbob.config.get('%s.filemon' % appname, 'pid_path') if not pid_path: - pid_path = '/tmp/%s_filemon.pid' % basename - return pid_path + pid_path = '/tmp/%s_filemon.pid' % appname + + monitor = FileMonitorDaemon(pid_path) + monitor.appname = appname + return monitor -def start_daemon(appname, daemonize=True): - """ - Starts the file monitor daemon. - """ - - pid_path = get_pid_path() - if os.path.exists(pid_path): - print "File monitor is already running" - return - - wm = pyinotify.WatchManager() - notifier = pyinotify.Notifier(wm) - - monitored = get_monitor_profiles(appname) - - mask = (pyinotify.IN_ACCESS | pyinotify.IN_ATTRIB - | pyinotify.IN_CLOSE_WRITE | pyinotify.IN_CREATE - | pyinotify.IN_DELETE | pyinotify.IN_MODIFY - | pyinotify.IN_MOVED_TO) - for profile in monitored.itervalues(): - for path in profile.dirs: - wm.add_watch(path, mask, proc_fun=EventHandler( - actions=profile.actions, locks=profile.locks)) - - if not daemonize: - sys.stderr.write("Starting file monitor. (Press Ctrl+C to quit.)\n") - notifier.loop(daemonize=daemonize, pid_file=pid_path) +def start_daemon(appname): + get_daemon(appname).start() -def stop_daemon(): - """ - Stops the file monitor daemon. - """ - - pid_path = get_pid_path() - if not os.path.exists(pid_path): - print "File monitor is not running" - return - - f = open(pid_path) - pid = f.read().strip() - f.close() - if not pid.isdigit(): - log.warning("stop_daemon: Found bogus PID (%s) in file: %s" % (pid, pid_path)) - return - - os.kill(int(pid), signal.SIGKILL) - os.remove(pid_path) +def stop_daemon(appname): + get_daemon(appname).stop() diff --git a/edbob/filemon/win32.py b/edbob/filemon/win32.py index eca8485..504406c 100644 --- a/edbob/filemon/win32.py +++ b/edbob/filemon/win32.py @@ -33,8 +33,8 @@ import logging import threading import edbob +from edbob import filemon from edbob.errors import email_exception -from edbob.filemon import get_monitor_profiles from edbob.win32 import Service, file_is_free if sys.platform == 'win32': # docs should build for everyone @@ -69,7 +69,7 @@ class FileMonitorService(Service): return False # Read monitor profile(s) from config. - self.monitored = get_monitor_profiles(self.appname) + self.monitored = filemon.get_monitor_profiles(self.appname) # Make sure we have something to do. if not self.monitored: @@ -79,34 +79,36 @@ class FileMonitorService(Service): for key, profile in self.monitored.iteritems(): # Create a file queue for the profile. - queue = Queue.Queue() + profile.queue = Queue.Queue() - # Create a monitor thread for each folder in profile. + # Perform setup for each of the watched folders. for i, path in enumerate(profile.dirs, 1): + + # Maybe put all pre-existing files in the queue. + if profile.process_existing: + filemon.queue_existing(profile, path) + + # Create a monitor thread for the folder. name = 'monitor-%s-%u' % (key, i) log.debug("Initialize: Starting '%s' thread for folder: %s" % (name, path)) - thread = threading.Thread( - target=monitor_files, - name=name, - args=(queue, path, profile)) + thread = threading.Thread(target=monitor_files, + name=name, args=(profile, path)) thread.daemon = True thread.start() # Create an action thread for the profile. name = 'actions-%s' % key log.debug("Initialize: Starting '%s' thread" % name) - thread = threading.Thread( - target=perform_actions, - name=name, - args=(queue, profile)) + thread = threading.Thread(target=filemon.perform_actions, + name=name, args=(profile,)) thread.daemon = True thread.start() return True -def monitor_files(queue, path, profile): +def monitor_files(profile, path): """ Callable target for file monitor threads. """ @@ -138,40 +140,7 @@ def monitor_files(queue, path, profile): winnt.FILE_ACTION_RENAMED_NEW_NAME): log.debug("monitor_files: Queueing '%s' file: %s" % (profile.key, fpath)) - queue.put(fpath) - - -def perform_actions(queue, profile): - """ - Callable target for action threads. - """ - - while True: - - try: - path = queue.get_nowait() - except Queue.Empty: - pass - else: - - while not file_is_free(path): - win32api.Sleep(0) - - for spec, func, args in profile.actions: - - log.info("perform_actions: Calling function '%s' on file: %s" % - (spec, path)) - - try: - func(path, *args) - - except: - log.exception("perform_actions: An exception occurred " - "while processing file: %s" % path) - email_exception() - - # This file probably shouldn't be processed any further. - break + profile.queue.put(fpath) if __name__ == '__main__': diff --git a/edbob/initialization.py b/edbob/initialization.py index 410b572..af2aeff 100644 --- a/edbob/initialization.py +++ b/edbob/initialization.py @@ -89,7 +89,8 @@ def init(appname='edbob', *args, **kwargs): shell = kwargs.get('shell', False) for paths in config_paths: config.read(paths, recurse=not shell) - config.configure_logging() + if config.getboolean('edbob', 'configure_logging', default=True): + config.configure_logging() default_modules = 'edbob.time' modules = config.get('edbob', 'init', default=default_modules) diff --git a/edbob/modules.py b/edbob/modules.py index e3d4287..2cdf59b 100644 --- a/edbob/modules.py +++ b/edbob/modules.py @@ -82,6 +82,9 @@ def load_spec(spec): necessary. """ + if spec.count(':') != 1: + raise exceptions.InvalidSpec(spec) + module_path, obj = spec.split(':') module = import_module_path(module_path) try: diff --git a/edbob/pyramid/forms/formalchemy/__init__.py b/edbob/pyramid/forms/formalchemy/__init__.py index 3bfe237..d6aec5f 100644 --- a/edbob/pyramid/forms/formalchemy/__init__.py +++ b/edbob/pyramid/forms/formalchemy/__init__.py @@ -50,7 +50,8 @@ from edbob.pyramid.forms.formalchemy.renderers import * __all__ = ['ChildGridField', 'PropertyField', 'EnumFieldRenderer', 'PrettyDateTimeFieldRenderer', 'AutocompleteFieldRenderer', 'FieldSet', 'make_fieldset', 'required', 'pretty_datetime', - 'AssociationProxyField', 'YesNoFieldRenderer'] + 'AssociationProxyField', 'StrippingFieldRenderer', + 'YesNoFieldRenderer'] class TemplateEngine(formalchemy.templates.TemplateEngine): diff --git a/edbob/pyramid/forms/formalchemy/renderers.py b/edbob/pyramid/forms/formalchemy/renderers.py index a51d494..7b5f7e3 100644 --- a/edbob/pyramid/forms/formalchemy/renderers.py +++ b/edbob/pyramid/forms/formalchemy/renderers.py @@ -32,7 +32,7 @@ from pyramid.renderers import render __all__ = ['AutocompleteFieldRenderer', 'EnumFieldRenderer', - 'YesNoFieldRenderer'] + 'StrippingFieldRenderer', 'YesNoFieldRenderer'] def AutocompleteFieldRenderer(service_url, field_value=None, field_display=None, width='300px'): @@ -83,6 +83,17 @@ def EnumFieldRenderer(enum): return Renderer +class StrippingFieldRenderer(formalchemy.TextFieldRenderer): + """ + Standard text field renderer, which strips whitespace from either end of + the input value on deserialization. + """ + + def deserialize(self): + value = super(StrippingFieldRenderer, self).deserialize() + return value.strip() + + class YesNoFieldRenderer(formalchemy.fields.CheckBoxFieldRenderer): def render_readonly(self, **kwargs): diff --git a/edbob/pyramid/grids/alchemy.py b/edbob/pyramid/grids/alchemy.py index 4402ac4..ca9e787 100644 --- a/edbob/pyramid/grids/alchemy.py +++ b/edbob/pyramid/grids/alchemy.py @@ -54,6 +54,9 @@ class AlchemyGrid(Grid): self._formalchemy_grid.prettify = prettify self.noclick_fields = [] + def __delattr__(self, attr): + delattr(self._formalchemy_grid, attr) + def __getattr__(self, attr): return getattr(self._formalchemy_grid, attr) diff --git a/edbob/pyramid/handlers/__init__.py b/edbob/pyramid/handlers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/edbob/pyramid/handlers/base.py b/edbob/pyramid/handlers/base.py deleted file mode 100644 index bf2eac2..0000000 --- a/edbob/pyramid/handlers/base.py +++ /dev/null @@ -1,291 +0,0 @@ -#!/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.handlers.base`` -- Base Handlers -""" - -from pyramid.renderers import render_to_response -from pyramid.httpexceptions import HTTPException, HTTPFound, HTTPOk, HTTPUnauthorized - -# import sqlahelper - -# # import rattail.pyramid.forms.util as util -# from rattail.db.perms import has_permission -# from rattail.pyramid.forms.formalchemy import Grid - - -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/pyramid/handlers/util.py b/edbob/pyramid/handlers/util.py deleted file mode 100644 index 0530947..0000000 --- a/edbob/pyramid/handlers/util.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/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.handlers.util`` -- Handler Utilities -""" - -from pyramid.httpexceptions import HTTPFound - -from edbob.db.perms import has_permission - - -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 diff --git a/edbob/pyramid/static/css/forms.css b/edbob/pyramid/static/css/forms.css index b698cfd..ff17b12 100644 --- a/edbob/pyramid/static/css/forms.css +++ b/edbob/pyramid/static/css/forms.css @@ -78,6 +78,15 @@ div.field-wrapper div.field textarea { width: 320px; } +label input[type=checkbox] { + margin-right: 8px; +} + + +/****************************** + * Buttons + ******************************/ + div.buttons { clear: both; margin-top: 10px; diff --git a/edbob/pyramid/static/css/layout.css b/edbob/pyramid/static/css/layout.css index c6092ba..d389c0d 100644 --- a/edbob/pyramid/static/css/layout.css +++ b/edbob/pyramid/static/css/layout.css @@ -28,6 +28,7 @@ body > #container { } #footer { + clear: both; margin-top: -4em; text-align: center; } diff --git a/edbob/pyramid/static/css/perms.css b/edbob/pyramid/static/css/perms.css index 764f8eb..86fcfce 100644 --- a/edbob/pyramid/static/css/perms.css +++ b/edbob/pyramid/static/css/perms.css @@ -1,17 +1,33 @@ /****************************** - * perms.css + * Permission Lists ******************************/ -div.field-couple.permissions div.field p.group { +div.field-wrapper.permissions div.field div.group { + margin-bottom: 10px; +} + +div.field-wrapper.permissions div.field div.group p { font-weight: bold; } -div.field-couple.permissions div.field label { +div.field-wrapper.permissions div.field label { float: none; font-weight: normal; } -div.field-couple.permissions div.field label input { +div.field-wrapper.permissions div.field label input { + margin-left: 15px; + margin-right: 10px; +} + +div.field-wrapper.permissions div.field div.group p.perm { + font-weight: normal; + margin-left: 15px; +} + +div.field-wrapper.permissions div.field div.group p.perm span { + font-family: monospace; + /* font-weight: bold; */ margin-right: 10px; } diff --git a/edbob/pyramid/subscribers.py b/edbob/pyramid/subscribers.py index 9331a35..0340d97 100644 --- a/edbob/pyramid/subscribers.py +++ b/edbob/pyramid/subscribers.py @@ -79,6 +79,13 @@ def context_found(event): return has_permission(request.user, perm, session=Session()) request.has_perm = has_perm + def has_any_perm(perms): + for perm in perms: + if has_permission(request.user, perm, session=Session()): + return True + return False + request.has_any_perm = has_any_perm + def get_referrer(default=None): if request.params.get('referrer'): return request.params['referrer'] diff --git a/edbob/pyramid/templates/crud.mako b/edbob/pyramid/templates/crud.mako index 0ca6e40..8844cd1 100644 --- a/edbob/pyramid/templates/crud.mako +++ b/edbob/pyramid/templates/crud.mako @@ -1,3 +1,18 @@ -<%inherit file="/edbob/crud.mako" /> +<%inherit file="/form.mako" /> + +<%def name="title()">${"New "+form.pretty_name if form.creating else form.pretty_name+' : '+h.literal(str(form.fieldset.model))} + +<%def name="head_tags()"> + ${parent.head_tags()} + + ${parent.body()} diff --git a/edbob/pyramid/templates/edbob/crud.mako b/edbob/pyramid/templates/edbob/crud.mako deleted file mode 100644 index 4fc6112..0000000 --- a/edbob/pyramid/templates/edbob/crud.mako +++ /dev/null @@ -1,5 +0,0 @@ -<%inherit file="/form.mako" /> - -<%def name="title()">${"New "+form.pretty_name if form.creating else form.pretty_name+' : '+h.literal(str(form.fieldset.model))} - -${parent.body()} diff --git a/edbob/pyramid/templates/forms/fieldset_readonly.mako b/edbob/pyramid/templates/forms/fieldset_readonly.mako index 0eea814..350a315 100644 --- a/edbob/pyramid/templates/forms/fieldset_readonly.mako +++ b/edbob/pyramid/templates/forms/fieldset_readonly.mako @@ -1,7 +1,7 @@
% for field in fieldset.render_fields.itervalues(): % if field.requires_label: -
+
${field.label_tag()|n}
${field.render_readonly()} diff --git a/edbob/pyramid/templates/progress.mako b/edbob/pyramid/templates/progress.mako index 7626e46..ea83598 100644 --- a/edbob/pyramid/templates/progress.mako +++ b/edbob/pyramid/templates/progress.mako @@ -5,7 +5,8 @@ Working... ${h.javascript_link(request.static_url('edbob.pyramid:static/js/jquery.js'))} ${h.javascript_link(request.static_url('edbob.pyramid:static/js/edbob.js'))} - ${h.stylesheet_link(request.static_url('edbob.pyramid:static/css/edbob.css'))} + ${h.stylesheet_link(request.static_url('edbob.pyramid:static/css/base.css'))} + ${h.stylesheet_link(request.static_url('edbob.pyramid:static/css/layout.css'))}