diff --git a/.gitignore b/.gitignore
index 36de90c..b656579 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
todopyramid.sqlite
+*.pyc
+*.egg-info
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..6de862a
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,10 @@
+language: python
+python:
+ - "2.7"
+ - "2.6"
+# command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors
+install:
+ - "pip install -r requirements.txt -e ."
+ - "pip install nose"
+# command to run tests, e.g. python setup.py test
+script: "python setup.py nosetests"
\ No newline at end of file
diff --git a/README.md b/README.md
index b2320c2..68c38d5 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,11 @@
# ToDo Pyramid App
-This is the Pyramid app for the Python Web Shootout.
+[](https://travis-ci.org/saschagottfried/todopyramid)
+
+This is a refactored version of the Pyramid app for the Python Web Shootout originally crafted by SixFeetUp.
+
+Try it out here:
-Try it out here:
## Install
@@ -139,18 +142,18 @@ Then we need to pull in its dependencies (which includes Deform itself). Then up
(todopyramid)$ pip freeze > requirements.txt
```
-Then add the static resources to the `__init__.py`
-
+Since we include deform_bootstrap_extra, it does all the static resources registration usually done manually in __init__.py
```
-# Adding the static resources from Deform
-config.add_static_view('deform_static', 'deform:static', cache_max_age=3600)
-config.add_static_view('deform_bootstrap_static', 'deform_bootstrap:static', cache_max_age=3600)
+pyramid.includes =
+ pyramid_tm
+ pyramid_persona
+ deform_bootstrap_extra
```
Now we need to get our template structure in place. We'll add a `todopyramid/layouts.py` with the following (see the [Creating a Custom UX for Pyramid][customux] tutorial for more details):
```
-ifrom pyramid.renderers import get_renderer
+from pyramid.renderers import get_renderer
from pyramid.decorator import reify
@@ -166,14 +169,18 @@ Add the `global_layout.pt` with at least the following (look at the source code
```
-
+
-
-
-
+
+
+
+
+
@@ -184,17 +191,10 @@ Add the `global_layout.pt` with at least the following (look at the source code
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
```
@@ -207,9 +207,9 @@ from .layouts import Layouts
class ToDoViews(Layouts):
- def __init__(self, context, request):
- self.context = context
+ def __init__(self, request):
self.request = request
+ self.context = request.context
@view_config(route_name='home', renderer='templates/home.pt')
def home_view(request):
@@ -220,7 +220,7 @@ class ToDoViews(Layouts):
Now we can add a `todopyramid/templates/home.pt` to our app with the following
```
-
+
Home
Welcome to the Pyramid version of the ToDo app.
@@ -248,6 +248,48 @@ Now that we have created the shell for our app, it is time to create some models
We will create a `TodoItem` and `Tag` model to start out with. This will give us the basis for our todo list.
+
+### Model Relationships
+
+TBD - Add notes about model relationships that support features offered by todopyramid.
+
+### Explore SQLAlchemy model with IPython
+
+```
+$ bin/pshell production.ini
+Adding asdict2() to Colander.
+Python 2.7.2+ (default, Jul 20 2012, 22:12:53)
+Type "copyright", "credits" or "license" for more information.
+
+IPython 0.13.1 -- An enhanced Interactive Python.
+? -> Introduction and overview of IPython's features.
+%quickref -> Quick reference.
+help -> Python's own help system.
+object? -> Details about 'object', use 'object??' for extra details.
+
+Environment:
+ app The WSGI application.
+ registry Active Pyramid registry.
+ request Active request object.
+ root Root of the default resource tree.
+ root_factory Default root factory used to create `root`.
+
+In [1]: from todopyramid.models import DBSession, TodoUser
+
+In [2]: user = DBSession.query(TodoUser).filter_by(first_name='Arthur').one()
+
+In [3]: user
+Out[3]:
+
+In [4]: user.email
+Out[4]: u'king.arthur@example.com'
+```
+
+### Sorting
+
+TodoPyramids TodoGrid can order a rendered list of TodoItems by task name & due date - ascending and descending.
+
+
[install]: http://pyramid.readthedocs.org/en/latest/narr/install.html
[deform]: http://docs.pylonsproject.org/projects/deform/en/latest/
[deform_bootstrap]: http://pypi.python.org/pypi/deform_bootstrap
diff --git a/development.ini b/development.ini
index a58aa06..e727448 100644
--- a/development.ini
+++ b/development.ini
@@ -14,11 +14,13 @@ pyramid.default_locale_name = en
pyramid.includes =
pyramid_debugtoolbar
pyramid_tm
+ pyramid_persona
+ deform_bootstrap_extra
sqlalchemy.url = sqlite:///%(here)s/todopyramid.sqlite
persona.secret = s00per s3cr3t
-persona.audiences = http://localhost:6543
+persona.audiences = todopyramid.localhost
persona.siteName = ToDo Pyramid
# Option to generate content for new users to try out
@@ -34,7 +36,7 @@ todopyramid.generate_content = true
[server:main]
use = egg:waitress#main
-host = 0.0.0.0
+host = 127.0.0.1
port = 6543
###
diff --git a/production.ini b/production.ini
index 0d47e8e..c2bb11e 100644
--- a/production.ini
+++ b/production.ini
@@ -13,19 +13,21 @@ pyramid.debug_routematch = false
pyramid.default_locale_name = en
pyramid.includes =
pyramid_tm
+ pyramid_persona
+ deform_bootstrap_extra
sqlalchemy.url = sqlite:///%(here)s/todopyramid.sqlite
persona.secret = s00per s3cr3t
-persona.audiences = http://demo.todo.sixfeetup.com
+persona.audiences = todopyramid.localhost
persona.siteName = ToDo Pyramid
# Option to generate content for new users to try out
-todopyramid.generate_content = true
+todopyramid.generate_content = false
[server:main]
use = egg:waitress#main
-host = 0.0.0.0
+host = 127.0.0.1
port = 6543
###
diff --git a/requirements.txt b/requirements.txt
index aaa61d9..0cffb66 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,20 +4,20 @@ MarkupSafe==0.15
PasteDeploy==1.5.0
PyBrowserID==0.9.1
Pygments==1.6
-SQLAlchemy==0.8.0b2
+SQLAlchemy==0.8.0
WebHelpers==1.3
WebOb==1.2.3
-bag==0.3.2
+bag==0.3.4
colander==1.0a2
deform==0.9.6
-deform-bootstrap==0.2.6
-deform-bootstrap-extra==0.2
+deform-bootstrap==0.2.8
+deform-bootstrap-extra==0.2.8
distribute==0.6.35
ipython==0.13.1
peppercorn==0.4
-pyramid==1.4
+pyramid==1.4.3
pyramid-debugtoolbar==1.0.4
-pyramid-deform==0.2a5
+pyramid-deform==0.2
pyramid-persona==1.3.1
pyramid-tm==0.7
pytz==2012j
diff --git a/setup.py b/setup.py
index 3cb5700..24adcaf 100644
--- a/setup.py
+++ b/setup.py
@@ -7,23 +7,24 @@
CHANGES = open(os.path.join(here, 'CHANGES.txt')).read()
requires = [
- 'pyramid',
+ 'pyramid==1.4.3',
'SQLAlchemy',
- 'transaction',
'pyramid_tm',
'pyramid_debugtoolbar',
'zope.sqlalchemy',
'waitress',
- 'deform_bootstrap',
- 'deform_bootstrap_extra',
+ 'deform<=1.999',
+ 'deform_bootstrap==0.2.8',
+ 'deform_bootstrap_extra==0.2.8',
'pyramid_persona',
'WebHelpers',
'pytz',
+ 'requests==1.1.0',
]
setup(
name='todopyramid',
- version='1.0',
+ version='1.1',
description='todopyramid',
long_description=README + '\n\n' + CHANGES,
classifiers=[
diff --git a/todopyramid/__init__.py b/todopyramid/__init__.py
index d99e9b2..56026c5 100644
--- a/todopyramid/__init__.py
+++ b/todopyramid/__init__.py
@@ -6,6 +6,11 @@
Base,
)
+from .views import get_user
+
+def get_db_session(request):
+ """return thread-local DB session"""
+ return DBSession
def main(global_config, **settings):
""" This function returns a Pyramid WSGI application.
@@ -17,29 +22,41 @@ def main(global_config, **settings):
settings=settings,
root_factory='todopyramid.models.RootFactory',
)
- config.include('pyramid_persona')
- config.include('deform_bootstrap_extra')
+
+ includeme(config)
+
+ # scan modules for config descriptors
+ config.scan()
+ return config.make_wsgi_app()
+
+
+def includeme(config):
+ """we use this concept to include routes and configuration setup in test cases
+
+ http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/testing.html#creating-integration-tests
+ """
config.add_static_view('static', 'static', cache_max_age=3600)
- # Adding the static resources from Deform
- config.add_static_view(
- 'deform_static', 'deform:static', cache_max_age=3600
- )
- config.add_static_view(
- 'deform_bootstrap_static', 'deform_bootstrap:static',
- cache_max_age=3600
- )
- config.add_static_view(
- 'deform_bootstrap_extra_static', 'deform_bootstrap_extra:static',
- cache_max_age=3600
- )
+
# Misc. views
config.add_route('home', '/')
config.add_route('about', '/about')
# Users
config.add_route('account', '/account')
# Viewing todo lists
- config.add_route('list', '/list')
+ config.add_route('todos', '/todos')
config.add_route('tags', '/tags')
- config.add_route('tag', '/tags/{tag_name}')
- config.scan()
- return config.make_wsgi_app()
+ config.add_route('taglist', '/tags/{tag_name}')
+ # AJAX
+ config.add_route('todo', '/todos/{todo_id}')
+ config.add_route('delete.task', '/delete.task/{todo_id}')
+ config.add_route('tags.autocomplete', '/tags.autocomplete')
+
+ # make DB session a request attribute
+ # http://blog.safaribooksonline.com/2014/01/07/building-pyramid-applications/
+ config.add_request_method(get_db_session, 'db', reify=True)
+
+ # Making A User Object Available as a Request Attribute
+ # http://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/auth/user_object.html
+ config.add_request_method(get_user, 'user', reify=True)
+
+
diff --git a/todopyramid/grid.py b/todopyramid/grid.py
index 858ea57..9d13734 100644
--- a/todopyramid/grid.py
+++ b/todopyramid/grid.py
@@ -95,11 +95,13 @@ def __html__(self):
def tags_td(self, col_num, i, item):
"""Generate the column for the tags.
+
+ Apply special tag CSS for currently selected tag matched route '/tags/{tag_name}'
"""
tag_links = []
for tag in item.sorted_tags:
- tag_url = '%s/tags/%s' % (self.request.application_url, tag.name)
+ tag_url = self.request.route_url('taglist', tag_name=tag.name)
tag_class = 'label'
if self.selected_tag and tag.name == self.selected_tag:
tag_class += ' label-warning'
@@ -112,13 +114,15 @@ def tags_td(self, col_num, i, item):
def due_date_td(self, col_num, i, item):
"""Generate the column for the due date.
+
+ Time-Zone Localization is done in the model
"""
if item.due_date is None:
return HTML.td('')
span_class = 'due-date badge'
if item.past_due:
span_class += ' badge-important'
- due_date = localize_datetime(item.due_date, self.user_tz)
+ due_date = item.due_date
span = HTML.tag(
"span",
c=HTML.literal(due_date.strftime('%Y-%m-%d %H:%M:%S')),
diff --git a/todopyramid/layouts.py b/todopyramid/layouts.py
index 87d7162..519b800 100644
--- a/todopyramid/layouts.py
+++ b/todopyramid/layouts.py
@@ -1,14 +1,69 @@
from pyramid.renderers import get_renderer
from pyramid.decorator import reify
+
+#ToDoPyramid currently highlights navbar item 'todos' for multiple routes
+#Original version implemented navbar highlighting by setting a section variable
+menu_items = [
+ {'route': 'home', 'title': 'Home', 'routes': ['home']},
+ {'route': 'todos', 'title': 'List', 'routes': ['todos', 'taglist']},
+ {'route': 'tags', 'title': 'Tags', 'routes': ['tags']},
+ {'route': 'account', 'title': 'Account', 'routes': ['account']},
+ {'route': 'about', 'title': 'About', 'routes': ['about']}
+]
class Layouts(object):
"""This is the main layout for our application. This currently
just sets up the global layout template. See the views module and
their associated templates to see how this gets used.
"""
+
+ site_menu = menu_items
+
+ def __init__(self, request):
+ """Set some common variables needed for each view.
+ """
+ self.request = request
+
@reify
def global_template(self):
- renderer = get_renderer("templates/global_layout.pt")
+ renderer = get_renderer("todopyramid:templates/global_layout.pt")
return renderer.implementation().macros['layout']
+
+
+ @reify
+ def navbar(self):
+ """return navbar menu items and help to find current navbar item
+
+ site menu concept inspired by
+ http://docs.pylonsproject.org/projects/pyramid-tutorials/en/latest/humans/creatingux/step07/index.html
+
+ concept can be extended by components registering routes and adding their items to navbar menu
+ TodoPyramid adds Home, Todos, Tags, Account, About to this registry
+ When another TodoPyramid add-on component is activated by configuration it could add their menu item into this registry as well
+
+ catching the matched route name inspired by
+ http://stackoverflow.com/questions/13552992/get-current-route-instead-of-route-path-in-pyramid
+ """
+ def is_active_item(request, item):
+ """if we have a match between menu route and matched route set a boolean variable 'current' to true, else to false"""
+ if not request.matched_route:
+ item['active'] = False
+ return item
+ activate_item = True if request.matched_route.name in item['routes'] else False
+ item['active'] = activate_item
+ return item
+
+ def menu_url(request, item):
+ item['url'] = request.route_url(item['route'])
+ return item
+
+ def process_item(request, item):
+ item = menu_url(request, item)
+ item = is_active_item(request, item)
+ return item
+
+ return [process_item(self.request, item) for item in self.site_menu]
+
+
diff --git a/todopyramid/models.py b/todopyramid/models.py
index ebbdcdf..394485e 100644
--- a/todopyramid/models.py
+++ b/todopyramid/models.py
@@ -15,15 +15,13 @@
from sqlalchemy.orm import sessionmaker
from zope.sqlalchemy import ZopeTransactionExtension
+from .utils import localize_datetime
+from .utils import universify_datetime
+
DBSession = scoped_session(sessionmaker(extension=ZopeTransactionExtension()))
Base = declarative_base()
-todoitemtag_table = Table(
- 'todoitemtag',
- Base.metadata,
- Column('tag_id', Integer, ForeignKey('tags.name')),
- Column('todo_id', Integer, ForeignKey('todoitems.id')),
-)
+
class RootFactory(object):
@@ -36,16 +34,12 @@ class RootFactory(object):
def __init__(self, request):
pass
-
-class Tag(Base):
- """The Tag model is a many to many relationship to the TodoItem.
- """
- __tablename__ = 'tags'
- name = Column(Text, primary_key=True)
- todoitem_id = Column(Integer, ForeignKey('todoitems.id'))
-
- def __init__(self, name):
- self.name = name
+todoitemtag_table = Table(
+ 'todoitemtags',
+ Base.metadata,
+ Column('tag_name', Integer, ForeignKey('tags.name')),
+ Column('todo_id', Integer, ForeignKey('todoitems.id')),
+)
class TodoItem(Base):
@@ -55,14 +49,17 @@ class TodoItem(Base):
__tablename__ = 'todoitems'
id = Column(Integer, primary_key=True)
task = Column(Text, nullable=False)
- due_date = Column(DateTime)
- user = Column(Integer, ForeignKey('users.email'), nullable=False)
- tags = relationship(Tag, secondary=todoitemtag_table, lazy='dynamic')
+ _due_date = Column('due_date', DateTime)
+ user = Column(Integer, ForeignKey('users.email'))
+ author = relationship('TodoUser')
+
+ # # many to many TodoItem<->Tag
+ tags = relationship("Tag", secondary=todoitemtag_table, backref="todos")
def __init__(self, user, task, tags=None, due_date=None):
self.user = user
self.task = task
- self.due_date = due_date
+ self.due_date = due_date # date will be universified
if tags is not None:
self.apply_tags(tags)
@@ -71,13 +68,40 @@ def apply_tags(self, tags):
creates the associated tag object. We strip off whitespace
and lowercase the tags to keep a normalized list.
"""
+ #tags = []
for tag_name in tags:
- tag = tag_name.strip().lower()
- self.tags.append(DBSession.merge(Tag(tag)))
+ tag_name = self.sanitize_tag(tag_name)
+ tag = self._find_or_create_tag(tag_name)
+ self.tags.append(tag)
+
+ #reset collection of tags
+ #self.tags = tags
+
+ def sanitize_tag(self, tag_name):
+ """tag name input validation"""
+ tag = tag_name.strip().lower()
+ return tag
+
+ def _find_or_create_tag(self, tag_name):
+ """ensure tag names are unique
+
+ http://stackoverflow.com/questions/2310153/inserting-data-in-many-to-many-relationship-in-sqlalchemy
+
+ why we need that - prevent multiple tags
+ http://stackoverflow.com/questions/13149829/many-to-many-in-sqlalchemy-preventing-sqlalchemy-from-inserting-into-a-table-if
+ """
+ q = DBSession.query(Tag).filter_by(name=tag_name)
+ t = q.first()
+ if not(t):
+ t = Tag(tag_name)
+ return t
@property
def sorted_tags(self):
"""Return a list of sorted tags for this task.
+
+ TODO: we can apply sorting using the relationship
+ TODO: order case-insensitive ???
"""
return sorted(self.tags, key=lambda x: x.name)
@@ -85,8 +109,50 @@ def sorted_tags(self):
def past_due(self):
"""Determine if this task is past its due date. Notice that we
compare to `utcnow` since dates are stored in UTC.
+
+ TODO: write tests
"""
- return self.due_date and self.due_date < datetime.utcnow()
+ return self._due_date and self._due_date < datetime.utcnow()
+
+ def universify_due_date(self, date):
+ """convert datetime to UTC for storage"""
+ if date is not None:
+ self._due_date = universify_datetime(date)
+
+ def localize_due_date(self):
+ """create a timezone-aware object for a given datetime and timezone name
+ """
+ if self._due_date is not None and hasattr(self.author, 'time_zone'):
+ due_dt = localize_datetime(self._due_date, self.author.time_zone)
+ return due_dt
+ return self._due_date
+
+ due_date = property(localize_due_date, universify_due_date)
+
+ def __repr__(self):
+ """return representation - helps in IPython"""
+ return "TodoItem(%r, %r, %r, %r)" % (self.user, self.task, self.tags, self.due_date)
+
+
+class Tag(Base):
+ """The Tag model is a many to many relationship to the TodoItem.
+
+ http://docs.sqlalchemy.org/en/rel_0_9/orm/tutorial.html#building-a-many-to-many-relationship
+ """
+ __tablename__ = 'tags'
+
+ #id = Column(Integer, primary_key=True)
+ #name = Column(Text, nullable=False, unique=True)
+
+ name = Column(Text, primary_key=True)
+
+
+ def __init__(self, name):
+ self.name = name
+
+ def __repr__(self):
+ """return representation - helps in IPython"""
+ return "Tag(%r)" % (self.name)
class TodoUser(Base):
@@ -99,6 +165,7 @@ class TodoUser(Base):
first_name = Column(Text)
last_name = Column(Text)
time_zone = Column(Text)
+ todos = relationship(TodoItem)
todo_list = relationship(TodoItem, lazy='dynamic')
def __init__(self, email, first_name=None, last_name=None,
@@ -108,16 +175,61 @@ def __init__(self, email, first_name=None, last_name=None,
self.last_name = last_name
self.time_zone = time_zone
+
+ def todos_by_tag(self, tag, order):
+ """return user todos with given tag"""
+ tag_filter = TodoItem.tags.any(name=tag)
+ qry = self.todo_list.filter(tag_filter)
+
+ if order:
+ qry.order_by(order)
+
+ return qry.all()
+
+
@property
def user_tags(self):
"""Find all tags a user has created
+
+ BUG: does not find user created tags that actually have no related todos
+
+ returns KeyedTuples with key 'tag_name'
+ TODO: refactor to return collection of Tag model - consider lazy
+
+ explore code samples - we also have user/author model and a many-to-many relationship between todo and tag
+ http://docs.sqlalchemy.org/en/rel_0_9/orm/tutorial.html#building-a-many-to-many-relationship
"""
- qry = self.todo_list.session.query(todoitemtag_table.columns['tag_id'])
+ qry = self.todo_list.session.query(todoitemtag_table.columns['tag_name'])
qry = qry.join(TodoItem).filter_by(user=self.email)
- qry = qry.group_by('tag_id')
- qry = qry.order_by('tag_id')
+ qry = qry.group_by('tag_name')
+ qry = qry.order_by('tag_name')
return qry.all()
+ def user_tags_autocomplete(self, term):
+ """given a term return a unique collection (set) of user tags that start with it
+
+ BUG: returns only tags currently applied to user todos, therefore missing tags related to deleted user todos
+
+
+ In [19]: for todo in user.todos:
+ for tag in todo.tags:
+ if tag.name.startswith('ber'):
+ ....: print tag.name
+ ....:
+ berlin
+ berlin
+ berlin
+ berlin
+ """
+ matching_tags = set()
+ for todo in self.todos:
+ for tag in todo.tags:
+ if tag.name.startswith(term):
+ matching_tags.add(tag)
+
+ return matching_tags
+
+
@property
def profile_complete(self):
"""A check to see if the user has completed their profile. If
@@ -125,3 +237,39 @@ def profile_complete(self):
settings.
"""
return self.first_name and self.last_name
+
+ def delete_todo(self, todo_id):
+ """given a todo ID we delete it is contained in user todos
+
+ delete from a collection
+ http://docs.sqlalchemy.org/en/latest/orm/session.html#deleting-from-collections
+ http://stackoverflow.com/questions/10378468/deleting-an-object-from-collection-in-sqlalchemy"""
+ todo_item = self.todo_list.filter(
+ TodoItem.id == todo_id)
+
+ todo_item.delete()
+
+ def create_todo(self, task, tags=None, due_date=None):
+ """may be we prefer using this method from authenticated views
+ this way we always create a user TodoItem instead of allowing view code to modify SQLAlchemy TodoItem collection
+ """
+ #check common pitfall - mutable as default argument
+ if tags==None:
+ tags = []
+
+ todo = TodoItem(self.email, task, tags, due_date)
+ self.todos.append(todo)
+
+ def edit_todo(self, todo_id, task, tags=None, due_date=None):
+ todo = self.todo_list.filter_by(id=todo_id).one()
+ todo.task = task
+ todo.apply_tags(tags)
+ todo.due_date = due_date
+
+ def update_prefs(self, first_name, last_name, time_zone=u'US/Eastern'):
+ """update account preferences"""
+ self.first_name = first_name
+ self.last_name = last_name
+ self.time_zone = time_zone
+
+
diff --git a/todopyramid/schema.py b/todopyramid/schema.py
index 0da654b..d088516 100644
--- a/todopyramid/schema.py
+++ b/todopyramid/schema.py
@@ -38,6 +38,8 @@ def deferred_datetime_node(node, kw):
class TodoSchema(MappingSchema):
"""This is the form schema used for list_view and tag_view. This is
the basis for the add and edit form for tasks.
+
+ TODO: schema.TodoSchema.name != models.TodoItem.task
"""
id = SchemaNode(
Integer(),
@@ -53,7 +55,7 @@ class TodoSchema(MappingSchema):
description=(
"Enter a comma after each tag to add it. Backspace to delete."
),
- missing=[],
+ missing=None,
)
due_date = SchemaNode(
deferred_datetime_node,
diff --git a/todopyramid/scripts/initializedb.py b/todopyramid/scripts/initializedb.py
index e258a1e..fb242b6 100644
--- a/todopyramid/scripts/initializedb.py
+++ b/todopyramid/scripts/initializedb.py
@@ -18,6 +18,7 @@
Base,
)
+from ..utils import localize_datetime
def usage(argv):
cmd = os.path.basename(argv[0])
@@ -26,42 +27,67 @@ def usage(argv):
sys.exit(1)
-def create_dummy_content(user_id):
- """Create some tasks by default to show off the site
+def create_dummy_user():
+ """create our dummy user
+
+ we handle transaction not here - design decision
"""
+
+ user = TodoUser(
+ email=u'king.arthur@example.com',
+ first_name=u'Arthur',
+ last_name=u'Pendragon',
+ )
+ DBSession.add(user)
+ user_id = user.email
+ return user_id
+
+def create_dummy_content(user_id):
+ """Create some tasks for this user with by default to show off the site
+
+ either called during application startup or during content creation while a new user registers
+ we do not handle transaction here - design decision
+
+ TODO: bulk adding of new content
+ """
+
+ user = DBSession.query(TodoUser).filter(TodoUser.email == user_id).first()
+ time_zone = user.time_zone
+
+ #this user creates several todo items with localized times
task = TodoItem(
user=user_id,
task=u'Find a shrubbery',
tags=[u'quest', u'ni', u'knight'],
- due_date=datetime.utcnow() + timedelta(days=60),
+ due_date=localize_datetime((datetime.utcnow() + timedelta(days=60)), time_zone),
)
DBSession.add(task)
task = TodoItem(
user=user_id,
task=u'Search for the holy grail',
tags=[u'quest'],
- due_date=datetime.utcnow() - timedelta(days=1),
+ due_date=localize_datetime((datetime.utcnow() + timedelta(days=1)), time_zone),
)
DBSession.add(task)
task = TodoItem(
user=user_id,
task=u'Recruit Knights of the Round Table',
tags=[u'quest', u'knight', u'discuss'],
- due_date=datetime.utcnow() + timedelta(minutes=45),
+ due_date=localize_datetime((datetime.utcnow() + timedelta(minutes=45)), time_zone),
)
DBSession.add(task)
task = TodoItem(
user=user_id,
task=u'Build a Trojan Rabbit',
tags=[u'quest', u'rabbit'],
- due_date=datetime.utcnow() + timedelta(days=1),
+ due_date=localize_datetime((datetime.utcnow() + timedelta(days=1)), time_zone),
)
DBSession.add(task)
task = TodoItem(
user=user_id,
task=u'Talk to Tim the Enchanter',
tags=[u'quest', u'discuss'],
- due_date=datetime.utcnow() + timedelta(days=90),
+ due_date=localize_datetime((datetime.utcnow() + timedelta(days=90)), time_zone),
)
DBSession.add(task)
task = TodoItem(
@@ -90,10 +116,7 @@ def main(argv=sys.argv):
DBSession.configure(bind=engine)
Base.metadata.create_all(engine)
with transaction.manager:
- user = TodoUser(
- email=u'king.arthur@example.com',
- first_name=u'Arthur',
- last_name=u'Pendragon',
- )
- DBSession.add(user)
- create_dummy_content(u'king.arthur@example.com')
+ user_id = create_dummy_user()
+ create_dummy_content(user_id)
+
+
diff --git a/todopyramid/static/todo_list.js b/todopyramid/static/todo_list.js
index f0472b9..da21ea0 100644
--- a/todopyramid/static/todo_list.js
+++ b/todopyramid/static/todo_list.js
@@ -12,11 +12,10 @@ $(function() {
e.preventDefault();
var todo_id = $(this).closest('ul').attr('id');
$.getJSON(
- '/edit.task',
- {'id': todo_id},
+ '/todos/' + todo_id,
function(json) {
if (json) {
- edit_form = $('#task-form');
+ var edit_form = $('#task-form');
// Set the title to Edit
edit_form.find('h3').text('Edit Task');
$.each(json, function(k, v) {
@@ -47,7 +46,7 @@ $(function() {
});
});
- // Compete a todo task when the link is clicked
+ // Complete a todo task when the link is clicked
$("a.todo-complete").click(function(e) {
e.preventDefault();
var todo_id = $(this).closest('ul').attr('id');
@@ -57,8 +56,7 @@ $(function() {
bootbox.confirm(confirm_text, function(complete_item) {
if (complete_item) {
$.getJSON(
- '/delete.task',
- {'id': todo_id},
+ '/delete.task/'+ todo_id,
function(json) {
if (json) {
// Delete the row
diff --git a/todopyramid/templates/global_layout.pt b/todopyramid/templates/global_layout.pt
index fe61a7b..b186fee 100644
--- a/todopyramid/templates/global_layout.pt
+++ b/todopyramid/templates/global_layout.pt
@@ -13,7 +13,7 @@
+ href="${css_path}"/>
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/todopyramid/tests.py b/todopyramid/tests.py
index 621ff7f..0c81305 100644
--- a/todopyramid/tests.py
+++ b/todopyramid/tests.py
@@ -1,33 +1,379 @@
-import unittest
+try:
+ import unittest2 as unittest
+except ImportError:
+ import unittest
import transaction
from pyramid import testing
-from .models import DBSession
+from .models import (
+ DBSession,
+ TodoUser,
+ Base,
+ )
+def _initTestingDB():
+ """setup testing DB, insert sample entry and return Session
+
+ http://docs.pylonsproject.org/projects/pyramid/en/1.5-branch/quick_tutorial/databases.html
+ """
+ from sqlalchemy import create_engine
+ engine = create_engine('sqlite://')
+ from .models import (
+ Base,
+ TodoUser,
+ )
+ DBSession.configure(bind=engine)
+ Base.metadata.create_all(engine)
+
+ return DBSession
-class TestMyView(unittest.TestCase):
+def _insert_first_user(session):
+ with transaction.manager:
+ user = TodoUser(
+ email=u'king.arthur@example.com',
+ first_name=u'Arthur',
+ last_name=u'Pendragon',
+ )
+ session.add(user)
+
+
+class ModelTests(unittest.TestCase):
def setUp(self):
+ self.session = _initTestingDB()
self.config = testing.setUp()
- from sqlalchemy import create_engine
- engine = create_engine('sqlite://')
- from .models import (
- Base,
- MyModel,
- )
- DBSession.configure(bind=engine)
- Base.metadata.create_all(engine)
- with transaction.manager:
- model = MyModel(name='one', value=55)
- DBSession.add(model)
def tearDown(self):
- DBSession.remove()
+ self.session.remove()
testing.tearDown()
- def test_it(self):
- from .views import my_view
- request = testing.DummyRequest()
- info = my_view(request)
- self.assertEqual(info['one'].name, 'one')
- self.assertEqual(info['project'], 'todopyramid')
+
+class UserModelTests(ModelTests):
+
+ def _getTargetClass(self):
+ from .models import TodoUser
+ return TodoUser
+
+ def _makeOne(self, email, first_name=None, last_name=None, time_zone=u'US/Eastern'):
+ return self._getTargetClass()(email, first_name, last_name, time_zone)
+
+ def test_constructor(self):
+ instance = self._makeOne(u'king.arthur@example.com',
+ u'Arthur',
+ u'Pendragon')
+ self.assertEqual(instance.email, u'king.arthur@example.com')
+ self.assertEqual(instance.first_name, u'Arthur')
+ self.assertEqual(instance.last_name, u'Pendragon')
+ self.assertEqual(instance.time_zone, u'US/Eastern')
+
+ def test_profile_is_not_complete(self):
+ instance = self._makeOne(u'king.arthur@example.com',
+ u'Arthur',
+ None)
+ self.assertFalse(instance.profile_complete)
+
+ def test_profile_complete(self):
+ instance = self._makeOne(u'king.arthur@example.com',
+ u'Arthur',
+ u'Pendragon')
+ self.assertTrue(instance.profile_complete)
+
+
+ def test_given_a_new_user_when_I_ask_for_user_tags_then_I_get_an_empty_list(self):
+ instance = self._makeOne(u'king.arthur@example.com',
+ u'Arthur',
+ u'Pendragon')
+ self.session.add(instance)
+ tags = instance.user_tags
+ self.assertEqual(tags, [])
+
+
+ def test_given_a_new_user_when_I_ask_for_todos_Then_I_get_back_an_empty_list(self):
+ user = self._makeOne(u'king.arthur@example.com',
+ u'Arthur',
+ u'Pendragon')
+ self.session.add(user)
+ todos = user.todos
+ self.assertEqual(todos, [])
+
+ def test_given_a_user_when_I_add_a_todo_Then_I_can_access_it_from_user_todo_collection(self):
+ """test user model method to delete a single todo"""
+ from .models import Tag
+ from .models import TodoUser
+ from .models import TodoItem
+
+ user = TodoUser(
+ email=u'king.arthur@example.com',
+ first_name=u'Arthur',
+ last_name=u'Pendragon',
+ )
+ self.session.add(user)
+
+ tags = [u'quest', u'ni', u'knight']
+
+ todo = TodoItem(user.email,
+ u'Find a shrubbery',
+ [u'quest', u'ni', u'knight']
+ )
+ self.session.add(todo)
+
+ user_todo = user.todo_list.one()
+ self.assertTrue(todo is user_todo)
+
+
+ def test_given_a_user_has_a_todo_When_I_delete_it_Then_it_is_gone(self):
+ """test user model method to delete a single todo"""
+ from .models import Tag
+ from .models import TodoUser
+ from .models import TodoItem
+
+ user = TodoUser(
+ email=u'king.arthur@example.com',
+ first_name=u'Arthur',
+ last_name=u'Pendragon',
+ )
+ self.session.add(user)
+
+ tags = [u'quest', u'ni', u'knight']
+
+ todo = TodoItem(user.email,
+ u'Find a shrubbery',
+ [u'quest', u'ni', u'knight']
+ )
+ self.session.add(todo)
+
+ #after inserting we have 1 todo
+ user_todos = user.todo_list.count()
+ self.assertEqual(user_todos, 1)
+
+ #after delete we have zero todos
+ user.delete_todo(todo.id)
+ user_todos = user.todo_list.count()
+ self.assertEqual(user_todos, 0)
+
+
+
+
+class TodoItemModelTests(ModelTests):
+
+ def _getTargetClass(self):
+ from .models import TodoItem
+ return TodoItem
+
+ def _makeOne(self, user, task, tags=None, due_date=None):
+ return self._getTargetClass()(user, task, tags, due_date)
+
+ def test_constructor(self):
+ instance = self._makeOne(1,
+ u'Find a shrubbery')
+ #trigger model
+ instance.author
+
+ #make assertions
+ self.assertEqual(instance.user, 1)
+ self.assertEqual(instance.task, u'Find a shrubbery')
+ self.assertEqual(instance.due_date, None)
+ self.assertEqual(instance.tags, [])
+
+ def test_given_that_I_add_a_user_and_insert_a_task_with_several_tags_I_can_access_tag_collection(self):
+ """tests model backref todoitem.tags"""
+ from .models import Tag
+ instance = self._makeOne(1,
+ u'Find a shrubbery',
+ [u'quest', u'ni', u'knight'])
+ self.assertEqual(instance.tags[0].name, u'quest')
+ self.assertEqual(instance.tags[1].name, u'ni')
+ self.assertEqual(instance.tags[2].name, u'knight')
+
+ def test_tag_relationship_todos(self):
+ """test model backref tag.todos"""
+ from .models import Tag
+ instance = self._makeOne(1,
+ u'Find a shrubbery',
+ [u'quest', u'ni', u'knight'])
+ self.session.add(instance)
+ tag = self.session.query(Tag).filter_by(name=u'ni').one()
+ self.assertEqual(tag.todos[0].task, u'Find a shrubbery')
+
+
+ def test_inserting_todoitems_without_transaction_manager_with_same_tags_keep_tags_unique(self):
+ from .models import Tag
+ from .models import TodoItem
+
+ instance = self._makeOne(1,
+ u'Find a shrubbery',
+ [u'quest', u'ni', u'knight']
+ )
+ self.session.add(instance)
+
+ instance = self._makeOne(1,
+ u'Find another shrubbery',
+ [u'quest', u'ni', u'knight']
+ )
+
+ self.session.add(instance)
+
+ todos = self.session.query(TodoItem).count()
+ self.assertEqual(todos, 2)
+
+
+ def test_inserting_2_todoitems_with_same_tags_when_I_ask_for_tag_todos_then_I_get_2(self):
+ from .models import Tag
+ from .models import TodoItem
+
+ instance = self._makeOne(1,
+ u'Find a shrubbery',
+ [u'quest', u'ni', u'knight']
+ )
+ self.session.add(instance)
+
+ instance = self._makeOne(1,
+ u'Find another shrubbery',
+ [u'quest', u'ni', u'knight']
+ )
+
+ self.session.add(instance)
+
+ #tag is referenced by 2 todo items
+ tag = self.session.query(Tag).filter(Tag.name == u'quest').one()
+ self.assertEqual(tag.name, u'quest')
+ self.assertEqual(len(tag.todos), 2)
+
+
+ @unittest.skip('skip because it raises IntegrityError')
+ def test_inserting_multiple_todoitems_with_same_tags_using_addall_keep_tags_unique(self):
+ from .models import Tag
+
+ first_item = self._makeOne(1,
+ u'Find a shrubbery',
+ [u'quest', u'ni', u'knight']
+ )
+
+ second_item = self._makeOne(1,
+ u'Find another shrubbery',
+ [u'quest', u'ni', u'knight']
+ )
+
+ self.session.add_all([first_item, second_item])
+
+ tag = self.session.query(Tag).filter(Tag.name == u'quest').one()
+ self.assertEqual(tag.name, u'quest')
+
+
+class ViewIntegrationTests(unittest.TestCase):
+
+ #@unittest.skip('skip because view uses self.request.user provided by config.add_request_method')
+ def test_anonymous(self):
+ from .views import ToDoViews
+
+ with testing.testConfig() as config:
+ config.include('todopyramid')
+
+ request = testing.DummyRequest()
+ request.user = None
+ inst = ToDoViews(request)
+ response = inst.home_view()
+ self.assertEqual(response['user'], None)
+ self.assertEqual(response['count'], None)
+ self.assertEqual(response['section'], 'home')
+
+
+class TestTagsView(ModelTests):
+
+ def test_user_tags(self):
+ """user model property"""
+ from .models import Tag
+ from .models import TodoUser
+ from .models import TodoItem
+
+ user = TodoUser(
+ email=u'king.arthur@example.com',
+ first_name=u'Arthur',
+ last_name=u'Pendragon',
+ )
+ self.session.add(user)
+
+ tags = [u'quest', u'ni', u'knight']
+
+ todo = TodoItem(user.email,
+ u'Find a shrubbery',
+ [u'quest', u'ni', u'knight']
+ )
+
+ self.session.add(todo)
+ user_tags = user.user_tags
+ for user_tag in user_tags:
+ self.assertIn(user_tag.tag_name, tags, '%s should be one of these tags %s' % (user_tag, tags))
+
+class TestTagView(ModelTests):
+
+ def test_todos_by_tag(self):
+ """return user todos that are tagged with given tag
+
+ saved IPython session to demonstrate SQLAlchemy API
+ In [1]: from todopyramid.models import *
+
+ In [2]: user = DBSession.query(TodoUser).filter_by(first_name='Sascha').one()
+
+ In [3]: user.todos
+ Out[3]:
+ [TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find another knight', [], datetime.datetime(2014, 2, 2, 23, 0)),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find sascha', [], None)]
+
+ In [4]: tag_filter = TodoItem.tags.any(Tag.name.in_([u'berlin']))
+
+ In [5]: user.todo_list.filter(tag_filter).all()
+ Out[5]:
+ [TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find sascha', [], None)]
+
+ In [6]: user.todo_list.filter(tag_filter).order_by(Tag.name)
+ Out[6]:
+
+
+ In [8]: user.todo_list.filter(tag_filter).order_by(TodoItem.task).all()
+ Out[8]:
+ [TodoItem(u's.gottfried@hhpberlin.de', u'find sascha', [], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None)]
+
+ In [9]: from sqlalchemy import asc
+
+ In [10]: from sqlalchemy import desc
+
+ In [11]: user.todo_list.filter(tag_filter).order_by(asc(TodoItem.task)).all()
+ Out[11]:
+ [TodoItem(u's.gottfried@hhpberlin.de', u'find sascha', [], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None)]
+
+ In [12]: user.todo_list.filter(tag_filter).order_by(desc(TodoItem.task)).all()
+ Out[12]:
+ [TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find sascha', [], None)]
+
+ In [13]: user.todo_list.filter(tag_filter).order_by(desc(TodoItem.due_date)).all()
+ Out[13]:
+ [TodoItem(u's.gottfried@hhpberlin.de', u'find sascha', [], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None)]
+
+ In [14]: user.todo_list.filter(tag_filter).order_by(asc(TodoItem.due_date)).all()
+ Out[14]:
+ [TodoItem(u's.gottfried@hhpberlin.de', u'find sascha', [], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None)]
+
+ In [19]: tag_filter = TodoItem.tags.any(name=u'knight')
+
+ In [20]: user.todo_list.filter(tag_filter).order_by(asc(TodoItem.due_date)).all()
+ Out[20]:
+ [TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find tim', [, ], None),
+ TodoItem(u's.gottfried@hhpberlin.de', u'find another knight', [], datetime.datetime(2014, 2, 2, 23, 0))]
+
+ """
\ No newline at end of file
diff --git a/todopyramid/utils.py b/todopyramid/utils.py
index 606b13e..d6d8ae6 100644
--- a/todopyramid/utils.py
+++ b/todopyramid/utils.py
@@ -2,7 +2,7 @@
def localize_datetime(dt, tz_name):
- """Provide a timzeone-aware object for a given datetime and timezone name
+ """Provide a timezone-aware object for a given datetime and timezone name
"""
assert dt.tzinfo == None
utc = pytz.timezone('UTC')
diff --git a/todopyramid/views.py b/todopyramid/views.py
index f14c7e3..e8593fa 100644
--- a/todopyramid/views.py
+++ b/todopyramid/views.py
@@ -1,149 +1,104 @@
from pyramid.httpexceptions import HTTPFound
from pyramid.response import Response
-from pyramid.security import authenticated_userid
+from pyramid.security import unauthenticated_userid
from pyramid.security import remember
from pyramid.security import forget
from pyramid.settings import asbool
from pyramid.view import forbidden_view_config
from pyramid.view import notfound_view_config
from pyramid.view import view_config
+from pyramid.decorator import reify
from deform import Form
from deform import ValidationFailure
-from peppercorn import parse
+from pyramid_deform import FormView
from pyramid_persona.views import verify_login
import transaction
from .grid import TodoGrid
from .scripts.initializedb import create_dummy_content
from .layouts import Layouts
-from .models import DBSession
from .models import Tag
from .models import TodoItem
from .models import TodoUser
from .schema import SettingsSchema
from .schema import TodoSchema
-from .utils import localize_datetime
-from .utils import universify_datetime
+
+from sqlalchemy.exc import OperationalError as SqlAlchemyOperationalError
+
+@view_config(context=SqlAlchemyOperationalError)
+def failed_sqlalchemy(exception, request):
+ """catch missing database, logout and redirect to homepage, add flash message with error
+
+ implementation inspired by pylons group message
+ https://groups.google.com/d/msg/pylons-discuss/BUtbPrXizP4/0JhqB2MuoL4J
+ """
+ msg = 'There was an error connecting to database'
+ request.session.flash(msg, queue='error')
+ headers = forget(request)
+
+ # Send the user back home, everything else is protected
+ return HTTPFound(request.route_url('home'), headers=headers)
+
+def get_user(request):
+ # the below line is just an example, use your own method of
+ # accessing a database connection here (this could even be another
+ # request property such as request.db, implemented using this same
+ # pattern).
+ user_id = unauthenticated_userid(request)
+ if user_id is not None:
+ # this should return None if the user doesn't exist
+ # in the database
+ return request.db.query(TodoUser).filter(TodoUser.email == user_id).first()
+
+
+
class ToDoViews(Layouts):
"""This class has all the views for our application. The Layouts
base class has the master template set up.
- """
+ """
+
+
- def __init__(self, context, request):
- """Set some common variables needed for each view.
- """
- self.context = context
- self.request = request
- self.user_id = authenticated_userid(request)
- self.todo_list = []
- self.user = None
- if self.user_id is not None:
- query = DBSession.query(TodoUser)
- self.user = query.filter(TodoUser.email == self.user_id).first()
-
- def form_resources(self, form):
- """Get a list of css and javascript resources for a given form.
- These are then used to place the resources in the global layout.
- """
- resources = form.get_widget_resources()
- js_resources = resources['js']
- css_resources = resources['css']
- js_links = ['deform:static/%s' % r for r in js_resources]
- css_links = ['deform:static/%s' % r for r in css_resources]
- return (css_links, js_links)
-
- def sort_order(self):
- """The list_view and tag_view both use this helper method to
- determine what the current sort parameters are.
- """
- order = self.request.GET.get('order_col', 'due_date')
- order_dir = self.request.GET.get('order_dir', 'asc')
- if order == 'due_date':
- # handle sorting of NULL values so they are always at the end
- order = 'CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date'
- if order == 'task':
- # Sort ignoring case
- order += ' COLLATE NOCASE'
- if order_dir:
- order = ' '.join([order, order_dir])
- return order
+ @view_config(route_name='home', renderer='templates/home.pt')
+ def home_view(self):
+ """This is the first page the user will see when coming to the
+ application. If they are anonymous, the count is None and the
+ template shows some enticing welcome text.
- def generate_task_form(self, formid="deform"):
- """This helper code generates the form that will be used to add
- and edit the tasks based on the schema of the form.
+ If the user is logged in, then this gets a count of the user's
+ tasks, and shows that number on the home page with a link to
+ the `list_view`.
"""
- schema = TodoSchema().bind(user_tz=self.user.time_zone)
- options = """
- {success:
- function (rText, sText, xhr, form) {
- deform.processCallbacks();
- deform.focusFirstInput();
- var loc = xhr.getResponseHeader('X-Relocate');
- if (loc) {
- document.location = loc;
- };
- }
+
+ #we have a home_view test that does not attach our user to our request
+ #FIX with enhanced testing strategies
+ count = len(self.request.user.todos) if self.request.user else None
+
+ return {'user': self.request.user,
+ 'count': count,
+ 'section': 'home',
}
- """
- return Form(
- schema,
- buttons=('submit',),
- formid=formid,
- use_ajax=True,
- ajax_options=options,
- )
-
- def process_task_form(self, form):
- """This helper code processes the task from that we have
- generated from Colander and Deform.
- This handles both the initial creation and subsequent edits for
- a task.
+ @view_config(route_name='tags', renderer='templates/todo_tags.pt',
+ permission='view')
+ def tags_view(self):
+ """This view simply shows all of the tags a user has created.
+
+ TODO: use request.route_url API to generate URLs in view code
"""
- try:
- # try to validate the submitted values
- controls = self.request.POST.items()
- captured = form.validate(controls)
- action = 'created'
- with transaction.manager:
- tags = captured.get('tags', [])
- if tags:
- tags = tags.split(',')
- due_date = captured.get('due_date')
- if due_date is not None:
- # Convert back to UTC for storage
- due_date = universify_datetime(due_date)
- task_name = captured.get('name')
- task = TodoItem(
- user=self.user_id,
- task=task_name,
- tags=tags,
- due_date=due_date,
- )
- task_id = captured.get('id')
- if task_id is not None:
- action = 'updated'
- task.id = task_id
- DBSession.merge(task)
- msg = "Task %s %s successfully" % (task_name, action)
- self.request.session.flash(msg, queue='success')
- # Reload the page we were on
- location = self.request.url
- return Response(
- '',
- headers=[
- ('X-Relocate', location),
- ('Content-Type', 'text/html'),
- ]
- )
- html = form.render({})
- except ValidationFailure as e:
- # the submitted values could not be validated
- html = e.render()
- return Response(html)
+ # Special case when the db was blown away
+ #if self.user_id is not None and self.user is None:
+ # return self.logout()
+
+ tags = self.request.user.user_tags
+ return {
+ 'section': 'tags',
+ 'count': len(tags),
+ 'tags': tags,
+ }
@view_config(route_name='about', renderer='templates/about.pt')
def about_view(self):
@@ -164,6 +119,8 @@ def forbidden(self):
a page that they don't have permission to see. In the same way
that the notfound view is set up, this will fit nicely into our
global layout.
+
+ We just set the section to control visibility of person login button in navbar
"""
return {'section': 'login'}
@@ -176,7 +133,7 @@ def logout(self):
"""
headers = forget(self.request)
# Send the user back home, everything else is protected
- return HTTPFound('/', headers=headers)
+ return HTTPFound(self.request.route_url('home'), headers=headers)
@view_config(route_name='login', check_csrf=True)
def login_view(self):
@@ -191,7 +148,8 @@ def login_view(self):
email = verify_login(self.request)
headers = remember(self.request, email)
# Check to see if the user exists
- user = DBSession.query(TodoUser).filter(
+ session = self.request.db
+ user = session.query(TodoUser).filter(
TodoUser.email == email).first()
if user and user.profile_complete:
self.request.session.flash('Logged in successfully')
@@ -199,237 +157,335 @@ def login_view(self):
elif user and not user.profile_complete:
msg = "Before you begin, please update your profile."
self.request.session.flash(msg, queue='info')
- return HTTPFound('/account', headers=headers)
+ return HTTPFound(self.request.route_url('account'), headers=headers)
# Otherwise, create an account and optionally create some content
settings = self.request.registry.settings
generate_content = asbool(
settings.get('todopyramid.generate_content', None)
)
# Create the skeleton user
- with transaction.manager:
- DBSession.add(TodoUser(email))
- if generate_content:
- create_dummy_content(email)
+ session.add(TodoUser(email))
+ if generate_content:
+ create_dummy_content(email)
msg = (
"This is your first visit, we hope your stay proves to be "
"prosperous. Before you begin, please update your profile."
)
self.request.session.flash(msg)
- return HTTPFound('/account', headers=headers)
+ return HTTPFound(self.request.route_url('account'), headers=headers)
- @view_config(route_name='account', renderer='templates/account.pt',
- permission='view')
- def account_view(self):
- """This is the settings form for the user. The first time a
- user logs in, they are taken here so we can get their first and
- last name.
+
+class BaseView(FormView):
+ """subclass view to return links to static CSS/JS resources"""
+
+ def __call__(self):
+ """same as base class method but customizes links to JS/CSS resources
+
+ Prepares and render the form according to provided options.
+
+ Upon receiving a ``POST`` request, this method will validate
+ the request against the form instance. After validation,
+ this calls a method based upon the name of the button used for
+ form submission and whether the validation succeeded or failed.
+ If the button was named ``save``, then :meth:`save_success` will be
+ called on successful validation or :meth:`save_failure` will
+ be called upon failure. An exception to this is when no such
+ ``save_failure`` method is present; in this case, the fallback
+ is :meth:`failure``.
+
+ Returns a ``dict`` structure suitable for provision tog the given
+ view. By default, this is the page template specified
"""
- # Special case when the db was blown away
- if self.user_id is not None and self.user is None:
- return self.logout()
- section_name = 'account'
- schema = SettingsSchema()
- form = Form(schema, buttons=('submit',))
- css_resources, js_resources = self.form_resources(form)
- if 'submit' in self.request.POST:
- controls = self.request.POST.items()
- try:
- form.validate(controls)
- except ValidationFailure as e:
- msg = 'There was an error saving your settings.'
- self.request.session.flash(msg, queue='error')
- return {
- 'form': e.render(),
- 'css_resources': css_resources,
- 'js_resources': js_resources,
- 'section': section_name,
- }
- values = parse(self.request.params.items())
- # Update the user
- with transaction.manager:
- self.user.first_name = values.get('first_name', u'')
- self.user.last_name = values.get('last_name', u'')
- self.user.time_zone = values.get('time_zone', u'US/Eastern')
- DBSession.add(self.user)
- self.request.session.flash(
- 'Settings updated successfully',
- queue='success',
- )
- return HTTPFound('/list')
- # Get existing values
- if self.user is not None:
- appstruct = dict(
- first_name=self.user.first_name,
- last_name=self.user.last_name,
- time_zone=self.user.time_zone,
- )
- else:
- appstruct = {}
- return {
- 'form': form.render(appstruct),
- 'css_resources': css_resources,
- 'js_resources': js_resources,
- 'section': section_name,
- }
+ use_ajax = getattr(self, 'use_ajax', False)
+ ajax_options = getattr(self, 'ajax_options', '{}')
+ self.schema = self.schema.bind(**self.get_bind_data())
+ form = self.form_class(self.schema, buttons=self.buttons,
+ use_ajax=use_ajax, ajax_options=ajax_options,
+ **dict(self.form_options))
+ self.before(form)
+ reqts = form.get_widget_resources()
+ result = None
- @view_config(renderer='json', name='tags.autocomplete', permission='view')
- def tag_autocomplete(self):
- """Get a list of dictionaries for the given term. This gives
- the tag input the information it needs to do auto completion.
+ for button in form.buttons:
+ if button.name in self.request.POST:
+ success_method = getattr(self, '%s_success' % button.name)
+ try:
+ controls = self.request.POST.items()
+ validated = form.validate(controls)
+ result = success_method(validated)
+ except ValidationFailure as e:
+ fail = getattr(self, '%s_failure' % button.name, None)
+ if fail is None:
+ fail = self.failure
+ result = fail(e)
+ break
+
+ if result is None:
+ result = self.show(form)
+
+ if isinstance(result, dict):
+ result['js_resources'] = [self.request.static_url('deform:static/%s' % r) for r in reqts['js']]
+ result['css_resources'] = [self.request.static_url('deform:static/%s' % r) for r in reqts['css']]
+
+ return result
+
+
+@view_config(route_name='account', renderer='templates/account.pt', permission='view')
+class AccountEditView(BaseView, Layouts):
+ """view class for account from
+
+ inherits from BaseView to get customized JS/CSS resources behaviour
+ inherits from Layout to use global TodoPyramid template
+ """
+ schema = SettingsSchema()
+ buttons = ('save', 'cancel')
+ section = 'account' # current section of navbar
+
+ def save_success(self, appstruct):
+ """save button handler - called after successful validation
+
+ save validated user prefs and redirect to list view"""
+ self.request.user.update_prefs(**appstruct)
+ self.request.session.flash(
+ 'Settings updated successfully',
+ queue='success',
+ )
+ return HTTPFound(self.request.route_url('home'))
+
+ def save_failure(self, exc):
+ """save button failure handler - called after validation failure
+
+ add custom message as flash message and render form
+ TODO: investigate exception"""
+ msg = 'There was an error saving your settings.'
+ self.request.session.flash(msg, queue='error')
+
+ def cancel_success(self, appstruct):
+ """cancel button handler redirects to todo list view"""
+ previous_page = self.request.referer
+ todos_page = self.request.route_url('todos')
+
+ return HTTPFound(todos_page)
+
+
+ def appstruct(self):
+ """This allows edit forms to pre-fill form values conveniently.
+
+ TODO: find out how to generate appstruct from model - sort of model binding API or helper"""
+
+ user = self.request.user
+ return {'first_name': user.first_name,
+ 'last_name': user.last_name,
+ 'time_zone': user.time_zone}
+
+
+@view_config(route_name="taglist", renderer='templates/todo_list.pt', permission='view')
+@view_config(route_name='todos', renderer='templates/todo_list.pt', permission='view')
+class TodoItemForm(BaseView, Layouts):
+ """view class to renderer all user todos or todos-by-tag - use case depends on matched route
+
+ responsibilities
+ * render TaskForm
+ * render TodoGrid
+ * care about sort_order
+ * edit task AJAX
+ * delete task AJAX
+ * feed AutoComplete Ajax Widget
+ """
+ schema = TodoSchema()
+ buttons = ('save',)
+ form_options = (('formid', 'deform'),)
+ use_ajax = True
+ ajax_options = """
+ {success:
+ function (rText, sText, xhr, form) {
+ deform.processCallbacks();
+ deform.focusFirstInput();
+ var loc = xhr.getResponseHeader('X-Relocate');
+ if (loc) {
+ document.location = loc;
+ };
+ }
+ }
+ """
+ def save_success(self, appstruct):
+ """save button handler
+
+ handle create/edit action and redirect to page
+
+ TODO: pass appstruct as **kwargs to domain method
"""
- term = self.request.params.get('term', '')
- if len(term) < 2:
- return []
- # XXX: This is global tags, need to hook into "user_tags"
- tags = DBSession.query(Tag).filter(Tag.name.startswith(term)).all()
- return [
- dict(id=tag.name, value=tag.name, label=tag.name)
- for tag in tags
- ]
+ #TodoSchema colander schema and SQLAlchemy model TodoItem differ
+ id = appstruct['id'] #hidden with colander.missing
+ name = appstruct['name'] #required
+ tags = appstruct['tags']
+ if tags:
+ tags = tags.split(',') #optional with colander.missing, multiple tags are seperated with commas
+ due_date = appstruct['due_date'] #optional with colander.missing
+
+ #encapsulate with try-except
+ if id:
+ #edit user todo
+ self.request.user.edit_todo(id, name, tags, due_date)
+ action = 'updated'
+ else:
+ #create new user todo
+ self.request.user.create_todo(name, tags, due_date)
+ action = 'created'
+
+ msg = "Task %s %s successfully" % (name, action)
+ self.request.session.flash(msg, queue='success')
+
+ #reload the current page
+ location = self.request.url
+ return Response(
+ '',
+ headers=[
+ ('X-Relocate', location),
+ ('Content-Type', 'text/html'),
+ ]
+ )
+
+ def update_success(self):
+ """target create/edit use cases with different button handlers"""
+ pass
- @view_config(renderer='json', name='edit.task', permission='view')
- def edit_task(self):
- """Get the values to fill in the edit form
+ @view_config(route_name='todo', renderer='json', permission='view', xhr=True)
+ def get_task(self):
+ """Get the task to fill in the bootbox edit form
+
+ returns multiple tags separated by comma to target deform_bootstrap_extra TagsWidget
+ TODO: encapsulate datetime localization into model - done
+ TODO: make datetime string configurable
"""
- todo_id = self.request.params.get('id', None)
+ todo_id = self.request.matchdict['todo_id']
if todo_id is None:
return False
- task = DBSession.query(TodoItem).filter(
- TodoItem.id == todo_id).first()
- due_date = None
- # If there is a due date, localize the time
- if task.due_date is not None:
- due_dt = localize_datetime(task.due_date, self.user.time_zone)
- due_date = due_dt.strftime('%Y-%m-%d %H:%M:%S')
+
+ task = self.request.user.todo_list.filter_by(id=todo_id).one()
+ due_date = task.due_date.strftime('%Y-%m-%d %H:%M:%S') if task.due_date is not None else None
+
return dict(
id=task.id,
name=task.task,
tags=','.join([tag.name for tag in task.sorted_tags]),
due_date=due_date,
)
-
- @view_config(renderer='json', name='delete.task', permission='view')
+
+
+ @view_config(route_name="delete.task", renderer='json', permission='view', xhr=True)
def delete_task(self):
"""Delete a todo list item
- TODO: Add a guard here so that you can only delete your tasks
+ TODO: Add a guard here so that you can only delete your tasks - done
"""
- todo_id = self.request.params.get('id', None)
- if todo_id is not None:
- todo_item = DBSession.query(TodoItem).filter(
- TodoItem.id == todo_id)
- with transaction.manager:
- todo_item.delete()
+ todo_id = self.request.matchdict['todo_id']
+ if todo_id is None:
+ return False
+
+ self.request.user.delete_todo(todo_id)
return True
- @view_config(route_name='home', renderer='templates/home.pt')
- def home_view(self):
- """This is the first page the user will see when coming to the
- application. If they are anonymous, the count is None and the
- template shows some enticing welcome text.
- If the user is logged in, then this gets a count of the user's
- tasks, and shows that number on the home page with a link to
- the `list_view`.
+ @view_config(route_name="tags.autocomplete", renderer='json', permission='view', xhr=True)
+ def tag_autocomplete(self):
+ """Get a list of dictionaries for the given term. This gives
+ the tag input the information it needs to do auto completion.
+
+ TODO: improve model to support user_tags - done
"""
- # Special case when the db was blown away
- if self.user_id is not None and self.user is None:
- return self.logout()
- if self.user_id is None:
- count = None
- else:
- count = len(self.user.todo_list.all())
- return {'user': self.user, 'count': count, 'section': 'home'}
-
- @view_config(route_name='list', renderer='templates/todo_list.pt',
- permission='view')
- def list_view(self):
- """This is the main functional page of our application. It
- shows a listing of the tasks that the currently logged in user
- has created.
+ term = self.request.GET.get('term','')
+ if len(term) < 2:
+ return []
+
+ tags = self.request.user.user_tags_autocomplete(term)
+ return [
+ dict(id=tag.name, value=tag.name, label=tag.name)
+ for tag in tags
+ ]
+
+ def get_bind_data(self):
+ """deferred binding of user time zone
+
+ TODO: do we still need it after refactoring timezone conversion into model ???"""
+ data = super(TodoItemForm, self).get_bind_data()
+ data.update({'user_tz': self.request.user.time_zone})
+ return data
+
+ def sort_order(self):
+ """The list_view and tag_view both use this helper method to
+ determine what the current sort parameters are.
+
+ TODO: try to refactor using SQLAlchemy API or plain Python
"""
- # Special case when the db was blown away
- if self.user_id is not None and self.user is None:
- return self.logout()
- form = self.generate_task_form()
- if 'submit' in self.request.POST:
- return self.process_task_form(form)
- order = self.sort_order()
- todo_items = self.user.todo_list.order_by(order).all()
- grid = TodoGrid(
- self.request,
- None,
- self.user.time_zone,
- todo_items,
- ['task', 'tags', 'due_date', ''],
- )
- count = len(todo_items)
- item_label = 'items' if count > 1 or count == 0 else 'item'
- css_resources, js_resources = self.form_resources(form)
- return {
- 'page_title': 'Todo List',
- 'count': count,
- 'item_label': item_label,
- 'section': 'list',
- 'items': todo_items,
- 'grid': grid,
- 'form': form.render(),
- 'css_resources': css_resources,
- 'js_resources': js_resources,
- }
+ order = self.request.GET.get('order_col', 'due_date')
+ order_dir = self.request.GET.get('order_dir', 'asc')
+ if order == 'due_date':
+ # handle sorting of NULL values so they are always at the end
+ order = 'CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date'
+ if order == 'task':
+ # Sort ignoring case
+ order += ' COLLATE NOCASE'
+ if order_dir:
+ order = ' '.join([order, order_dir])
+ return order
- @view_config(route_name='tags', renderer='templates/todo_tags.pt',
- permission='view')
- def tags_view(self):
- """This view simply shows all of the tags a user has created.
+
+ def show(self, form):
+ """Override to inject TodoGrid and other stuff
+
+ address both use cases by testing which route matched
+ in contrast to original version I set both routes to highlight List menu item in navbar
"""
# Special case when the db was blown away
- if self.user_id is not None and self.user is None:
- return self.logout()
- tags = self.user.user_tags
- return {
- 'section': 'tags',
- 'count': len(tags),
- 'tags': tags,
- }
+ #if self.user_id is not None and self.user is None:
+ # return self.logout()
- @view_config(route_name='tag', renderer='templates/todo_list.pt',
- permission='view')
- def tag_view(self):
- """Very similar to the list_view, this view just filters the
- list of tags down to the tag selected in the url based on the
- tag route replacement marker that ends up in the `matchdict`.
- """
- # Special case when the db was blown away
- if self.user_id is not None and self.user is None:
- return self.logout()
- form = self.generate_task_form()
- if 'submit' in self.request.POST:
- return self.process_task_form(form)
order = self.sort_order()
- qry = self.user.todo_list.order_by(order)
- tag_name = self.request.matchdict['tag_name']
- tag_filter = TodoItem.tags.any(Tag.name.in_([tag_name]))
- todo_items = qry.filter(tag_filter)
- count = todo_items.count()
- item_label = 'items' if count > 1 or count == 0 else 'item'
+ tag_name = self.request.matchdict.get('tag_name')
+ if tag_name:
+ #route match for todos-by-tag
+ todo_items = self.request.user.todos_by_tag(tag_name, order)
+ page_title = 'ToDo List by Tag'
+ else:
+ #route match for todos
+ todo_items = self.request.user.todo_list.order_by(order).all()
+ page_title = 'ToDo List'
+
grid = TodoGrid(
self.request,
tag_name,
- self.user.time_zone,
+ self.request.user.time_zone,
todo_items,
['task', 'tags', 'due_date', ''],
)
- css_resources, js_resources = self.form_resources(form)
- return {
- 'page_title': 'Tag List',
+
+ count = len(todo_items)
+ item_label = 'items' if count > 1 or count == 0 else 'item'
+
+ todos = {
+ 'page_title': page_title,
'count': count,
'item_label': item_label,
- 'section': 'tags',
- 'tag_name': tag_name,
+ 'tag_name' : tag_name,
+ 'section' : 'list',
'items': todo_items,
'grid': grid,
- 'form': form.render({'tags': tag_name}),
- 'css_resources': css_resources,
- 'js_resources': js_resources,
}
+
+
+ #copied from FormView.show
+ appstruct = self.appstruct()
+ if appstruct is None:
+ rendered = form.render()
+ else:
+ rendered = form.render(appstruct)
+ taskform = {
+ 'form': rendered,
+ }
+
+
+ #merge and return to renderer
+ todos.update(taskform)
+ return todos
+
\ No newline at end of file