diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst
index 273dd66..1410a20 100644
--- a/docs/api/wuttaweb/index.rst
+++ b/docs/api/wuttaweb/index.rst
@@ -29,5 +29,6 @@
views.essential
views.master
views.people
+ views.roles
views.settings
views.users
diff --git a/docs/api/wuttaweb/views.roles.rst b/docs/api/wuttaweb/views.roles.rst
new file mode 100644
index 0000000..8770256
--- /dev/null
+++ b/docs/api/wuttaweb/views.roles.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.views.roles``
+========================
+
+.. automodule:: wuttaweb.views.roles
+ :members:
diff --git a/src/wuttaweb/menus.py b/src/wuttaweb/menus.py
index 0dfb6f5..fc4581a 100644
--- a/src/wuttaweb/menus.py
+++ b/src/wuttaweb/menus.py
@@ -152,6 +152,11 @@ class MenuHandler(GenericHandler):
'route': 'users',
'perm': 'users.list',
},
+ {
+ 'title': "Roles",
+ 'route': 'roles',
+ 'perm': 'roles.list',
+ },
{'type': 'sep'},
{
'title': "App Info",
diff --git a/src/wuttaweb/templates/appinfo/index.mako b/src/wuttaweb/templates/appinfo/index.mako
index e92a660..710d02c 100644
--- a/src/wuttaweb/templates/appinfo/index.mako
+++ b/src/wuttaweb/templates/appinfo/index.mako
@@ -16,6 +16,9 @@
${app.get_title()}
+
+ ${config.production()}
+
diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py
index 2a77df5..1387a99 100644
--- a/src/wuttaweb/views/essential.py
+++ b/src/wuttaweb/views/essential.py
@@ -33,6 +33,7 @@ That will in turn include the following modules:
* :mod:`wuttaweb.views.common`
* :mod:`wuttaweb.views.settings`
* :mod:`wuttaweb.views.people`
+* :mod:`wuttaweb.views.roles`
* :mod:`wuttaweb.views.users`
"""
@@ -44,6 +45,7 @@ def defaults(config, **kwargs):
config.include(mod('wuttaweb.views.common'))
config.include(mod('wuttaweb.views.settings'))
config.include(mod('wuttaweb.views.people'))
+ config.include(mod('wuttaweb.views.roles'))
config.include(mod('wuttaweb.views.users'))
diff --git a/src/wuttaweb/views/roles.py b/src/wuttaweb/views/roles.py
new file mode 100644
index 0000000..51111e0
--- /dev/null
+++ b/src/wuttaweb/views/roles.py
@@ -0,0 +1,100 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# wuttaweb -- Web App for Wutta Framework
+# Copyright © 2024 Lance Edgar
+#
+# This file is part of Wutta Framework.
+#
+# Wutta Framework is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# Wutta Framework is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Wutta Framework. If not, see .
+#
+################################################################################
+"""
+Views for roles
+"""
+
+from wuttjamaican.db.model import Role
+from wuttaweb.views import MasterView
+from wuttaweb.db import Session
+
+
+class RoleView(MasterView):
+ """
+ Master view for roles.
+
+ Notable URLs provided by this class:
+
+ * ``/roles/``
+ * ``/roles/new``
+ * ``/roles/XXX``
+ * ``/roles/XXX/edit``
+ * ``/roles/XXX/delete``
+ """
+ model_class = Role
+
+ grid_columns = [
+ 'name',
+ 'notes',
+ ]
+
+ # TODO: master should handle this, possibly via configure_form()
+ def get_query(self, session=None):
+ """ """
+ model = self.app.model
+ query = super().get_query(session=session)
+ return query.order_by(model.Role.name)
+
+ def configure_grid(self, g):
+ """ """
+ super().configure_grid(g)
+
+ # name
+ g.set_link('name')
+
+ def configure_form(self, f):
+ """ """
+ super().configure_form(f)
+
+ # never show these
+ f.remove('permission_refs',
+ 'user_refs')
+
+ # name
+ f.set_validator('name', self.unique_name)
+
+ def unique_name(self, node, value):
+ """ """
+ model = self.app.model
+ session = Session()
+
+ query = session.query(model.Role)\
+ .filter(model.Role.name == value)
+
+ if self.editing:
+ uuid = self.request.matchdict['uuid']
+ query = query.filter(model.Role.uuid != uuid)
+
+ if query.count():
+ node.raise_invalid("Name must be unique")
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ RoleView = kwargs.get('RoleView', base['RoleView'])
+ RoleView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/tests/views/test_roles.py b/tests/views/test_roles.py
new file mode 100644
index 0000000..49c6197
--- /dev/null
+++ b/tests/views/test_roles.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8; -*-
+
+from unittest.mock import patch
+
+from sqlalchemy import orm
+
+import colander
+
+from wuttaweb.views import roles as mod
+from tests.util import WebTestCase
+
+
+class TestRoleView(WebTestCase):
+
+ def make_view(self):
+ return mod.RoleView(self.request)
+
+ def test_get_query(self):
+ view = self.make_view()
+ query = view.get_query(session=self.session)
+ self.assertIsInstance(query, orm.Query)
+
+ def test_configure_grid(self):
+ model = self.app.model
+ view = self.make_view()
+ grid = view.make_grid(model_class=model.Role)
+ self.assertFalse(grid.is_linked('name'))
+ view.configure_grid(grid)
+ self.assertTrue(grid.is_linked('name'))
+
+ def test_configure_form(self):
+ model = self.app.model
+ view = self.make_view()
+ form = view.make_form(model_class=model.Person)
+ self.assertNotIn('name', form.validators)
+ view.configure_form(form)
+ self.assertIsNotNone(form.validators['name'])
+
+ def test_unique_name(self):
+ model = self.app.model
+ view = self.make_view()
+
+ role = model.Role(name='Foo')
+ self.session.add(role)
+ self.session.commit()
+
+ with patch.object(mod, 'Session', return_value=self.session):
+
+ # invalid if same name in data
+ node = colander.SchemaNode(colander.String(), name='name')
+ self.assertRaises(colander.Invalid, view.unique_name, node, 'Foo')
+
+ # but not if name belongs to current role
+ view.editing = True
+ self.request.matchdict = {'uuid': role.uuid}
+ node = colander.SchemaNode(colander.String(), name='name')
+ self.assertIsNone(view.unique_name(node, 'Foo'))
diff --git a/tests/views/test_users.py b/tests/views/test_users.py
index a7be394..ea67544 100644
--- a/tests/views/test_users.py
+++ b/tests/views/test_users.py
@@ -5,16 +5,15 @@ from unittest.mock import patch
from sqlalchemy import orm
import colander
-from pyramid.httpexceptions import HTTPNotFound
-from wuttaweb.views import users
+from wuttaweb.views import users as mod
from tests.util import WebTestCase
-class TestPersonView(WebTestCase):
+class TestUserView(WebTestCase):
def make_view(self):
- return users.UserView(self.request)
+ return mod.UserView(self.request)
def test_get_query(self):
view = self.make_view()
@@ -45,7 +44,7 @@ class TestPersonView(WebTestCase):
self.session.add(user)
self.session.commit()
- with patch.object(users, 'Session', return_value=self.session):
+ with patch.object(mod, 'Session', return_value=self.session):
# invalid if same username in data
node = colander.SchemaNode(colander.String(), name='username')