feat: add first-time setup page to create admin user
This commit is contained in:
		
							parent
							
								
									bc49392140
								
							
						
					
					
						commit
						675b51cac2
					
				
					 6 changed files with 241 additions and 79 deletions
				
			
		
							
								
								
									
										20
									
								
								src/wuttaweb/templates/setup.mako
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/wuttaweb/templates/setup.mako
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,20 @@ | ||||||
|  | ## -*- coding: utf-8; -*- | ||||||
|  | <%inherit file="/form.mako" /> | ||||||
|  | 
 | ||||||
|  | <%def name="title()">First-Time Setup</%def> | ||||||
|  | 
 | ||||||
|  | <%def name="page_content()"> | ||||||
|  |   <b-notification type="is-success"> | ||||||
|  |     <p class="block"> | ||||||
|  |       The app is running okay! | ||||||
|  |     </p> | ||||||
|  |     <p class="block"> | ||||||
|  |       Please setup the first Administrator account below. | ||||||
|  |     </p> | ||||||
|  |   </b-notification> | ||||||
|  | 
 | ||||||
|  |   ${parent.page_content()} | ||||||
|  | </%def> | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | ${parent.body()} | ||||||
|  | @ -47,10 +47,16 @@ class AuthView(View): | ||||||
|         * route: ``login`` |         * route: ``login`` | ||||||
|         * template: ``/auth/login.mako`` |         * template: ``/auth/login.mako`` | ||||||
|         """ |         """ | ||||||
|  |         model = self.app.model | ||||||
|  |         session = session or Session() | ||||||
|         auth = self.app.get_auth_handler() |         auth = self.app.get_auth_handler() | ||||||
| 
 | 
 | ||||||
|         # TODO: should call request.get_referrer() |         # nb. redirect to /setup if no users exist | ||||||
|         referrer = self.request.route_url('home') |         user = session.query(model.User).first() | ||||||
|  |         if not user: | ||||||
|  |             return self.redirect(self.request.route_url('setup')) | ||||||
|  | 
 | ||||||
|  |         referrer = self.request.get_referrer() | ||||||
| 
 | 
 | ||||||
|         # redirect if already logged in |         # redirect if already logged in | ||||||
|         if self.request.user: |         if self.request.user: | ||||||
|  | @ -69,7 +75,6 @@ class AuthView(View): | ||||||
|         if data: |         if data: | ||||||
| 
 | 
 | ||||||
|             # truly validate user credentials |             # truly validate user credentials | ||||||
|             session = session or Session() |  | ||||||
|             user = auth.authenticate_user(session, data['username'], data['password']) |             user = auth.authenticate_user(session, data['username'], data['password']) | ||||||
|             if user: |             if user: | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -24,7 +24,11 @@ | ||||||
| Common Views | Common Views | ||||||
| """ | """ | ||||||
| 
 | 
 | ||||||
|  | import colander | ||||||
|  | 
 | ||||||
| from wuttaweb.views import View | from wuttaweb.views import View | ||||||
|  | from wuttaweb.forms import widgets | ||||||
|  | from wuttaweb.db import Session | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class CommonView(View): | class CommonView(View): | ||||||
|  | @ -32,7 +36,7 @@ class CommonView(View): | ||||||
|     Common views shared by all apps. |     Common views shared by all apps. | ||||||
|     """ |     """ | ||||||
| 
 | 
 | ||||||
|     def home(self): |     def home(self, session=None): | ||||||
|         """ |         """ | ||||||
|         Home page view. |         Home page view. | ||||||
| 
 | 
 | ||||||
|  | @ -40,12 +44,88 @@ class CommonView(View): | ||||||
| 
 | 
 | ||||||
|         This is normally the view shown when a user navigates to the |         This is normally the view shown when a user navigates to the | ||||||
|         root URL for the web app. |         root URL for the web app. | ||||||
| 
 |  | ||||||
|         """ |         """ | ||||||
|  |         model = self.app.model | ||||||
|  |         session = session or Session() | ||||||
|  | 
 | ||||||
|  |         # nb. redirect to /setup if no users exist | ||||||
|  |         user = session.query(model.User).first() | ||||||
|  |         if not user: | ||||||
|  |             return self.redirect(self.request.route_url('setup')) | ||||||
|  | 
 | ||||||
|         return { |         return { | ||||||
|             'index_title': self.app.get_title(), |             'index_title': self.app.get_title(), | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  |     def setup(self, session=None): | ||||||
|  |         """ | ||||||
|  |         View for first-time app setup, to create admin user. | ||||||
|  | 
 | ||||||
|  |         Template: ``/setup.mako`` | ||||||
|  | 
 | ||||||
|  |         This page is only meant for one-time use.  As such, if the app | ||||||
|  |         DB contains any users, this page will always redirect to the | ||||||
|  |         home page. | ||||||
|  | 
 | ||||||
|  |         However if no users exist yet, this will show a form which may | ||||||
|  |         be used to create the first admin user.  When finished, user | ||||||
|  |         will be redirected to the login page. | ||||||
|  | 
 | ||||||
|  |         .. note:: | ||||||
|  | 
 | ||||||
|  |            As long as there are no users in the DB, both the home and | ||||||
|  |            login pages will automatically redirect to this one. | ||||||
|  |         """ | ||||||
|  |         model = self.app.model | ||||||
|  |         session = session or Session() | ||||||
|  | 
 | ||||||
|  |         # nb. this view only available until first user is created | ||||||
|  |         user = session.query(model.User).first() | ||||||
|  |         if user: | ||||||
|  |             return self.redirect(self.request.route_url('home')) | ||||||
|  | 
 | ||||||
|  |         form = self.make_form(fields=['username', 'password', 'first_name', 'last_name'], | ||||||
|  |                               show_button_cancel=False, | ||||||
|  |                               show_button_reset=True) | ||||||
|  |         form.set_widget('password', widgets.CheckedPasswordWidget()) | ||||||
|  |         form.set_required('first_name', False) | ||||||
|  |         form.set_required('last_name', False) | ||||||
|  | 
 | ||||||
|  |         if form.validate(): | ||||||
|  |             auth = self.app.get_auth_handler() | ||||||
|  |             data = form.validated | ||||||
|  | 
 | ||||||
|  |             # make user | ||||||
|  |             user = auth.make_user(session=session, username=data['username']) | ||||||
|  |             auth.set_user_password(user, data['password']) | ||||||
|  | 
 | ||||||
|  |             # assign admin role | ||||||
|  |             admin = auth.get_role_administrator(session) | ||||||
|  |             user.roles.append(admin) | ||||||
|  | 
 | ||||||
|  |             # ensure all built-in roles exist | ||||||
|  |             auth.get_role_authenticated(session) | ||||||
|  |             auth.get_role_anonymous(session) | ||||||
|  | 
 | ||||||
|  |             # maybe make person | ||||||
|  |             if data['first_name'] or data['last_name']: | ||||||
|  |                 first = data['first_name'] | ||||||
|  |                 last = data['last_name'] | ||||||
|  |                 person = model.Person(first_name=first, | ||||||
|  |                                       last_name=last, | ||||||
|  |                                       full_name=(f"{first} {last}").strip()) | ||||||
|  |                 session.add(person) | ||||||
|  |                 user.person = person | ||||||
|  | 
 | ||||||
|  |             # send user to /login | ||||||
|  |             self.request.session.flash("Account created! Please login below.") | ||||||
|  |             return self.redirect(self.request.route_url('login')) | ||||||
|  | 
 | ||||||
|  |         return { | ||||||
|  |             'index_title': self.app.get_title(), | ||||||
|  |             'form': form, | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|     @classmethod |     @classmethod | ||||||
|     def defaults(cls, config): |     def defaults(cls, config): | ||||||
|         cls._defaults(config) |         cls._defaults(config) | ||||||
|  | @ -62,6 +142,12 @@ class CommonView(View): | ||||||
|                         route_name='home', |                         route_name='home', | ||||||
|                         renderer='/home.mako') |                         renderer='/home.mako') | ||||||
| 
 | 
 | ||||||
|  |         # setup | ||||||
|  |         config.add_route('setup', '/setup') | ||||||
|  |         config.add_view(cls, attr='setup', | ||||||
|  |                         route_name='setup', | ||||||
|  |                         renderer='/setup.mako') | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| def defaults(config, **kwargs): | def defaults(config, **kwargs): | ||||||
|     base = globals() |     base = globals() | ||||||
|  |  | ||||||
|  | @ -75,12 +75,12 @@ class RoleView(MasterView): | ||||||
|         auth = self.app.get_auth_handler() |         auth = self.app.get_auth_handler() | ||||||
| 
 | 
 | ||||||
|         # prevent delete for built-in roles |         # prevent delete for built-in roles | ||||||
|         if role is auth.get_role_administrator(session): |  | ||||||
|             return False |  | ||||||
|         if role is auth.get_role_authenticated(session): |         if role is auth.get_role_authenticated(session): | ||||||
|             return False |             return False | ||||||
|         if role is auth.get_role_anonymous(session): |         if role is auth.get_role_anonymous(session): | ||||||
|             return False |             return False | ||||||
|  |         if role is auth.get_role_administrator(session): | ||||||
|  |             return False | ||||||
| 
 | 
 | ||||||
|         return True |         return True | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,90 +1,88 @@ | ||||||
| # -*- coding: utf-8; -*- | # -*- coding: utf-8; -*- | ||||||
| 
 | 
 | ||||||
| from unittest import TestCase | from unittest.mock import MagicMock, patch | ||||||
| from unittest.mock import MagicMock |  | ||||||
| 
 | 
 | ||||||
| from pyramid import testing |  | ||||||
| from pyramid.httpexceptions import HTTPFound, HTTPForbidden | from pyramid.httpexceptions import HTTPFound, HTTPForbidden | ||||||
| 
 | 
 | ||||||
| from wuttjamaican.conf import WuttaConfig |  | ||||||
| from wuttaweb.views import auth as mod | from wuttaweb.views import auth as mod | ||||||
| from wuttaweb.auth import WuttaSecurityPolicy | from tests.util import WebTestCase | ||||||
| from wuttaweb.subscribers import new_request |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TestAuthView(TestCase): | class TestAuthView(WebTestCase): | ||||||
| 
 | 
 | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         self.config = WuttaConfig(defaults={ |         self.setup_web() | ||||||
|             'wutta.db.default.url': 'sqlite://', |  | ||||||
|         }) |  | ||||||
| 
 |  | ||||||
|         self.request = testing.DummyRequest(wutta_config=self.config, user=None) |  | ||||||
|         self.pyramid_config = testing.setUp(request=self.request, settings={ |  | ||||||
|             'wutta_config': self.config, |  | ||||||
|         }) |  | ||||||
| 
 |  | ||||||
|         self.app = self.config.get_app() |  | ||||||
|         auth = self.app.get_auth_handler() |  | ||||||
|         model = self.app.model |  | ||||||
|         model.Base.metadata.create_all(bind=self.config.appdb_engine) |  | ||||||
|         self.session = self.app.make_session() |  | ||||||
|         self.user = model.User(username='barney') |  | ||||||
|         self.session.add(self.user) |  | ||||||
|         auth.set_user_password(self.user, 'testpass') |  | ||||||
|         self.session.commit() |  | ||||||
| 
 |  | ||||||
|         self.pyramid_config.set_security_policy(WuttaSecurityPolicy(db_session=self.session)) |  | ||||||
|         self.pyramid_config.include('wuttaweb.views.auth') |  | ||||||
|         self.pyramid_config.include('wuttaweb.views.common') |         self.pyramid_config.include('wuttaweb.views.common') | ||||||
| 
 | 
 | ||||||
|     def tearDown(self): |     def make_view(self): | ||||||
|         testing.tearDown() |         return mod.AuthView(self.request) | ||||||
|  | 
 | ||||||
|  |     def test_includeme(self): | ||||||
|  |         self.pyramid_config.include('wuttaweb.views.auth') | ||||||
| 
 | 
 | ||||||
|     def test_login(self): |     def test_login(self): | ||||||
|         view = mod.AuthView(self.request) |         model = self.app.model | ||||||
|         context = view.login() |         auth = self.app.get_auth_handler() | ||||||
|  |         view = self.make_view() | ||||||
|  | 
 | ||||||
|  |         # until user exists, will redirect | ||||||
|  |         self.assertEqual(self.session.query(model.User).count(), 0) | ||||||
|  |         response = view.login(session=self.session) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  | 
 | ||||||
|  |         # make a user | ||||||
|  |         barney = model.User(username='barney') | ||||||
|  |         auth.set_user_password(barney, 'testpass') | ||||||
|  |         self.session.add(barney) | ||||||
|  |         self.session.commit() | ||||||
|  | 
 | ||||||
|  |         # now since user exists, form will display | ||||||
|  |         context = view.login(session=self.session) | ||||||
|         self.assertIn('form', context) |         self.assertIn('form', context) | ||||||
| 
 | 
 | ||||||
|         # redirect if user already logged in |         # redirect if user already logged in | ||||||
|         self.request.user = self.user |         with patch.object(self.request, 'user', new=barney): | ||||||
|         view = mod.AuthView(self.request) |             view = self.make_view() | ||||||
|         redirect = view.login(session=self.session) |             response = view.login(session=self.session) | ||||||
|         self.assertIsInstance(redirect, HTTPFound) |         self.assertEqual(response.status_code, 302) | ||||||
| 
 | 
 | ||||||
|         # login fails w/ wrong password |         # login fails w/ wrong password | ||||||
|         self.request.user = None |  | ||||||
|         self.request.method = 'POST' |         self.request.method = 'POST' | ||||||
|         self.request.POST = {'username': 'barney', 'password': 'WRONG'} |         self.request.POST = {'username': 'barney', 'password': 'WRONG'} | ||||||
|         view = mod.AuthView(self.request) |         view = self.make_view() | ||||||
|         context = view.login(session=self.session) |         context = view.login(session=self.session) | ||||||
|         self.assertIn('form', context) |         self.assertIn('form', context) | ||||||
| 
 | 
 | ||||||
|         # redirect if login succeeds |         # redirect if login succeeds | ||||||
|         self.request.method = 'POST' |         self.request.method = 'POST' | ||||||
|         self.request.POST = {'username': 'barney', 'password': 'testpass'} |         self.request.POST = {'username': 'barney', 'password': 'testpass'} | ||||||
|         view = mod.AuthView(self.request) |         view = self.make_view() | ||||||
|         redirect = view.login(session=self.session) |         response = view.login(session=self.session) | ||||||
|         self.assertIsInstance(redirect, HTTPFound) |         self.assertEqual(response.status_code, 302) | ||||||
| 
 | 
 | ||||||
|     def test_logout(self): |     def test_logout(self): | ||||||
|         view = mod.AuthView(self.request) |         self.pyramid_config.add_route('login', '/login') | ||||||
|  |         view = self.make_view() | ||||||
|         self.request.session.delete = MagicMock() |         self.request.session.delete = MagicMock() | ||||||
|         redirect = view.logout() |         response = view.logout() | ||||||
|         self.request.session.delete.assert_called_once_with() |         self.request.session.delete.assert_called_once_with() | ||||||
|         self.assertIsInstance(redirect, HTTPFound) |         self.assertEqual(response.status_code, 302) | ||||||
| 
 | 
 | ||||||
|     def test_change_password(self): |     def test_change_password(self): | ||||||
|         view = mod.AuthView(self.request) |         model = self.app.model | ||||||
|         auth = self.app.get_auth_handler() |         auth = self.app.get_auth_handler() | ||||||
|  |         barney = model.User(username='barney') | ||||||
|  |         self.session.add(barney) | ||||||
|  |         self.session.commit() | ||||||
|  |         view = self.make_view() | ||||||
| 
 | 
 | ||||||
|         # unauthenticated user is redirected |         # unauthenticated user is redirected | ||||||
|         redirect = view.change_password() |         redirect = view.change_password() | ||||||
|         self.assertIsInstance(redirect, HTTPFound) |         self.assertIsInstance(redirect, HTTPFound) | ||||||
| 
 | 
 | ||||||
|         # now "login" the user, and set initial password |         # now "login" the user, and set initial password | ||||||
|         self.request.user = self.user |         self.request.user = barney | ||||||
|         auth.set_user_password(self.user, 'foo') |         auth.set_user_password(barney, 'foo') | ||||||
|         self.session.commit() |         self.session.commit() | ||||||
| 
 | 
 | ||||||
|         # view should now return context w/ form |         # view should now return context w/ form | ||||||
|  | @ -105,9 +103,8 @@ class TestAuthView(TestCase): | ||||||
|         redirect = view.change_password() |         redirect = view.change_password() | ||||||
|         self.assertIsInstance(redirect, HTTPFound) |         self.assertIsInstance(redirect, HTTPFound) | ||||||
|         self.session.commit() |         self.session.commit() | ||||||
|         self.session.refresh(self.user) |         self.assertFalse(auth.check_user_password(barney, 'foo')) | ||||||
|         self.assertFalse(auth.check_user_password(self.user, 'foo')) |         self.assertTrue(auth.check_user_password(barney, 'bar')) | ||||||
|         self.assertTrue(auth.check_user_password(self.user, 'bar')) |  | ||||||
| 
 | 
 | ||||||
|         # at this point 'foo' is the password, now let's submit some |         # at this point 'foo' is the password, now let's submit some | ||||||
|         # invalid forms and make sure we get back a context w/ form |         # invalid forms and make sure we get back a context w/ form | ||||||
|  | @ -147,8 +144,6 @@ class TestAuthView(TestCase): | ||||||
|         self.assertEqual(dform['new_password'].errormsg, "New password must be different from old password.") |         self.assertEqual(dform['new_password'].errormsg, "New password must be different from old password.") | ||||||
| 
 | 
 | ||||||
|     def test_become_root(self): |     def test_become_root(self): | ||||||
|         event = MagicMock(request=self.request) |  | ||||||
|         new_request(event)      # add request.get_referrer() |  | ||||||
|         view = mod.AuthView(self.request) |         view = mod.AuthView(self.request) | ||||||
| 
 | 
 | ||||||
|         # GET not allowed |         # GET not allowed | ||||||
|  | @ -168,8 +163,6 @@ class TestAuthView(TestCase): | ||||||
|         self.assertTrue(self.request.session['is_root']) |         self.assertTrue(self.request.session['is_root']) | ||||||
| 
 | 
 | ||||||
|     def test_stop_root(self): |     def test_stop_root(self): | ||||||
|         event = MagicMock(request=self.request) |  | ||||||
|         new_request(event)      # add request.get_referrer() |  | ||||||
|         view = mod.AuthView(self.request) |         view = mod.AuthView(self.request) | ||||||
| 
 | 
 | ||||||
|         # GET not allowed |         # GET not allowed | ||||||
|  |  | ||||||
|  | @ -1,27 +1,85 @@ | ||||||
| # -*- coding: utf-8; -*- | # -*- coding: utf-8; -*- | ||||||
| 
 | 
 | ||||||
| from unittest import TestCase | from wuttaweb.views import common as mod | ||||||
| 
 | from tests.util import WebTestCase | ||||||
| from pyramid import testing |  | ||||||
| 
 |  | ||||||
| from wuttjamaican.conf import WuttaConfig |  | ||||||
| from wuttaweb.views import common |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TestCommonView(TestCase): | class TestCommonView(WebTestCase): | ||||||
| 
 | 
 | ||||||
|     def setUp(self): |     def make_view(self): | ||||||
|         self.config = WuttaConfig() |         return mod.CommonView(self.request) | ||||||
|         self.app = self.config.get_app() | 
 | ||||||
|         self.request = testing.DummyRequest() |     def test_includeme(self): | ||||||
|         self.request.wutta_config = self.config |  | ||||||
|         self.pyramid_config = testing.setUp(request=self.request) |  | ||||||
|         self.pyramid_config.include('wuttaweb.views.common') |         self.pyramid_config.include('wuttaweb.views.common') | ||||||
| 
 | 
 | ||||||
|     def tearDown(self): |  | ||||||
|         testing.tearDown() |  | ||||||
| 
 |  | ||||||
|     def test_home(self): |     def test_home(self): | ||||||
|         view = common.CommonView(self.request) |         self.pyramid_config.add_route('setup', '/setup') | ||||||
|         context = view.home() |         model = self.app.model | ||||||
|  |         view = self.make_view() | ||||||
|  | 
 | ||||||
|  |         # if no users then home page will redirect | ||||||
|  |         response = view.home(session=self.session) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  | 
 | ||||||
|  |         # so add a user | ||||||
|  |         user = model.User(username='foo') | ||||||
|  |         self.session.add(user) | ||||||
|  |         self.session.commit() | ||||||
|  | 
 | ||||||
|  |         # now we see the home page | ||||||
|  |         context = view.home(session=self.session) | ||||||
|         self.assertEqual(context['index_title'], self.app.get_title()) |         self.assertEqual(context['index_title'], self.app.get_title()) | ||||||
|  | 
 | ||||||
|  |     def test_setup(self): | ||||||
|  |         self.pyramid_config.add_route('home', '/') | ||||||
|  |         self.pyramid_config.add_route('login', '/login') | ||||||
|  |         model = self.app.model | ||||||
|  |         auth = self.app.get_auth_handler() | ||||||
|  |         view = self.make_view() | ||||||
|  | 
 | ||||||
|  |         # at first, can see the setup page | ||||||
|  |         self.assertEqual(self.session.query(model.User).count(), 0) | ||||||
|  |         context = view.setup(session=self.session) | ||||||
|  |         self.assertEqual(context['index_title'], self.app.get_title()) | ||||||
|  | 
 | ||||||
|  |         # so add a user | ||||||
|  |         user = model.User(username='foo') | ||||||
|  |         self.session.add(user) | ||||||
|  |         self.session.commit() | ||||||
|  | 
 | ||||||
|  |         # once user exists it will always redirect | ||||||
|  |         response = view.setup(session=self.session) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  | 
 | ||||||
|  |         # delete that user | ||||||
|  |         self.session.delete(user) | ||||||
|  |         self.session.commit() | ||||||
|  | 
 | ||||||
|  |         # so we can see the setup page again | ||||||
|  |         context = view.setup(session=self.session) | ||||||
|  |         self.assertEqual(context['index_title'], self.app.get_title()) | ||||||
|  | 
 | ||||||
|  |         # and finally, post data to create admin user | ||||||
|  |         self.request.method = 'POST' | ||||||
|  |         self.request.POST = { | ||||||
|  |             'username': 'barney', | ||||||
|  |             '__start__': 'password:mapping', | ||||||
|  |             'password': 'testpass', | ||||||
|  |             'password-confirm': 'testpass', | ||||||
|  |             '__end__': 'password:mapping', | ||||||
|  |             'first_name': "Barney", | ||||||
|  |             'last_name': "Rubble", | ||||||
|  |         } | ||||||
|  |         response = view.setup(session=self.session) | ||||||
|  |         # nb. redirects on success | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         barney = self.session.query(model.User).one() | ||||||
|  |         self.assertEqual(barney.username, 'barney') | ||||||
|  |         self.assertTrue(auth.check_user_password(barney, 'testpass')) | ||||||
|  |         admin = auth.get_role_administrator(self.session) | ||||||
|  |         self.assertIn(admin, barney.roles) | ||||||
|  |         self.assertIsNotNone(barney.person) | ||||||
|  |         person = barney.person | ||||||
|  |         self.assertEqual(person.first_name, "Barney") | ||||||
|  |         self.assertEqual(person.last_name, "Rubble") | ||||||
|  |         self.assertEqual(person.full_name, "Barney Rubble") | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Lance Edgar
						Lance Edgar