├── .coveragerc
├── requirements-test.txt
├── bottle_admin
├── controllers
│ ├── .main.py.swn
│ ├── __init__.py
│ └── main.py
├── __init__.py
├── auth
│ ├── admin.py
│ ├── __init__.py
│ ├── controllers.py
│ └── models.py
├── helpers.py
├── views
│ └── admin
│ │ ├── add.html
│ │ ├── login.html
│ │ ├── index.html
│ │ ├── edit.html
│ │ ├── list.html
│ │ └── base.html
├── options.py
└── sites.py
├── requirements.txt
├── Makefile
├── .travis.yml
├── .gitignore
├── README.md
├── LICENSE
├── tests
├── testutils.py
├── test_options.py
├── test_site.py
├── bottle_application.py
└── test_controllers.py
└── setup.py
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | omit =
3 | *tests*
4 |
--------------------------------------------------------------------------------
/requirements-test.txt:
--------------------------------------------------------------------------------
1 | -r requirements.txt
2 | pytest
3 | flake8
4 | pytest-cov
5 | WebTest==2.0.20
6 |
--------------------------------------------------------------------------------
/bottle_admin/controllers/.main.py.swn:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/avelino/bottle-admin/HEAD/bottle_admin/controllers/.main.py.swn
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | bottle==0.12.9
2 | bottle-boilerplate==0.3
3 | SQLAlchemy==1.0.9
4 | Beaker==1.7.0
5 | bottle-sqlalchemy==0.4.2
6 | bottle-cork==0.12.0
7 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | test:
2 | py.test tests/
3 | cov:
4 | py.test --cov=bottle_admin tests/
5 | covgui: cov
6 | duvet
7 | clean:
8 | find bottle_admin/ tests/ -name *.pyc | xargs rm
9 |
--------------------------------------------------------------------------------
/bottle_admin/controllers/__init__.py:
--------------------------------------------------------------------------------
1 | from .main import (add_model_get_controller, add_model_post_controller,
2 | delete_model_controller, home_controller)
3 |
4 | __all__ = ['add_model_get_controller', 'add_model_post_controller',
5 | 'delete_model_controller', 'home_controller']
6 |
--------------------------------------------------------------------------------
/bottle_admin/__init__.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 |
3 | from bottle import TEMPLATE_PATH
4 | import os
5 |
6 | from .sites import site
7 |
8 | __all__ = ['site']
9 |
10 |
11 | ADMIN_PATH = os.path.join(os.path.abspath(os.path.dirname(__file__)))
12 | TEMPLATE_PATH.insert(1, os.path.join(ADMIN_PATH, 'views'))
13 |
--------------------------------------------------------------------------------
/bottle_admin/auth/admin.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | from bottle_admin.options import ModelAdmin
3 |
4 |
5 | class RoleAdmin(ModelAdmin):
6 | list_display = ('role', 'level')
7 |
8 |
9 | class UserAdmin(ModelAdmin):
10 | list_display = ('username', 'role', 'email_addr', 'creation_date', 'last_login')
11 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | python:
3 | - "2.7"
4 | - "3.4"
5 | install:
6 | - pip install -qq -r requirements-test.txt tox
7 | - pip install -e .
8 | script:
9 | - flake8 --ignore=E501 bottle_admin
10 | - py.test tests -vrsx --cov=bottle_admin
11 | after_success:
12 | - pip install -qq coveralls
13 | - coveralls
14 |
--------------------------------------------------------------------------------
/bottle_admin/helpers.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 |
3 |
4 | def get_object_as_list(model, obj):
5 | """Get the SQLAlchemy query result as a list of tuples"""
6 |
7 | list_display = model.get_list_display()
8 | result = [('id', obj.id)]
9 | result += [(col, getattr(obj, col)) for col in list_display]
10 | print(result)
11 | return result
12 |
13 |
14 | def get_objects_as_list(model, objs):
15 | """Get a list of SQLAlchemy queries as a list of list of tuples"""
16 | return [get_object_as_list(model, obj) for obj in objs]
17 |
--------------------------------------------------------------------------------
/bottle_admin/auth/__init__.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 |
3 | from cork import Cork
4 | from cork.backends import SqlAlchemyBackend
5 |
6 | from .models import Role, User
7 |
8 | __all__ = ['Role', 'User']
9 |
10 |
11 | def get_aaa():
12 | return getattr(__import__(__name__), 'aaa')
13 |
14 |
15 | def setup(engine):
16 | Role.metadata.create_all(engine)
17 | User.metadata.create_all(engine)
18 |
19 | # setup cork auth
20 | backend = SqlAlchemyBackend(engine.url)
21 | aaa = Cork(backend=backend)
22 | setattr(__import__(__name__), 'aaa', aaa)
23 |
--------------------------------------------------------------------------------
/bottle_admin/auth/controllers.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | from bottle import jinja2_view, request
3 | from bottle_admin.auth import get_aaa
4 |
5 |
6 | @jinja2_view('admin/login.html')
7 | def login_get_controller():
8 | return {}
9 |
10 |
11 | def login_post_controller():
12 | username = request.forms.get('username')
13 | password = request.forms.get('password')
14 | aaa = get_aaa()
15 | aaa.login(username, password, success_redirect='/admin',
16 | fail_redirect='/admin/login')
17 |
18 |
19 | def logout_controller():
20 | aaa = get_aaa()
21 | aaa.logout(success_redirect='/admin/login')
22 |
--------------------------------------------------------------------------------
/bottle_admin/views/admin/add.html:
--------------------------------------------------------------------------------
1 | {% extends 'admin/base.html' %}
2 |
3 | {% block title %}
4 | Bottle Admin - Add {{ model.name }}
5 | {% endblock title %}
6 |
7 | {% block container %}
8 |
Add {{ model.name }}
9 |
24 | {% endblock container %}
25 |
--------------------------------------------------------------------------------
/bottle_admin/views/admin/login.html:
--------------------------------------------------------------------------------
1 | {% extends 'admin/base.html' %}
2 |
3 | {% block title %}
4 | Bottle Admin - Login
5 | {% endblock title %}
6 |
7 | {% block container %}
8 |
22 | {% endblock container %}
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 |
5 | # C extensions
6 | *.so
7 |
8 | # Distribution / packaging
9 | .Python
10 | env/
11 | bin/
12 | build/
13 | develop-eggs/
14 | dist/
15 | eggs/
16 | lib/
17 | lib64/
18 | parts/
19 | sdist/
20 | var/
21 | *.egg-info/
22 | .installed.cfg
23 | *.egg
24 |
25 | # Installer logs
26 | pip-log.txt
27 | pip-delete-this-directory.txt
28 |
29 | # Unit test / coverage reports
30 | htmlcov/
31 | .tox/
32 | .coverage
33 | .cache
34 | nosetests.xml
35 | coverage.xml
36 |
37 | # Translations
38 | *.mo
39 |
40 | # Mr Developer
41 | .mr.developer.cfg
42 | .project
43 | .pydevproject
44 |
45 | # Rope
46 | .ropeproject
47 |
48 | # Django stuff:
49 | *.log
50 | *.pot
51 |
52 | # Sphinx documentation
53 | docs/_build/
54 |
55 | # vim
56 | *.sw[op]
57 | *~
58 | .venv
59 |
60 | # sqlite
61 | *.db
62 |
63 | # tox
64 | tox.ini
65 |
66 | # testing
67 | test_data/
68 |
--------------------------------------------------------------------------------
/bottle_admin/views/admin/index.html:
--------------------------------------------------------------------------------
1 | {% extends 'admin/base.html' %}
2 |
3 | {% block container %}
4 |
5 |
6 |
7 |
8 | | App/project name |
9 |
10 |
11 |
12 | {% for model in models %}
13 |
14 | |
15 | {{ model.name|capitalize }}
16 | |
17 |
18 | Add
19 | |
20 |
21 | Change
22 | |
23 |
24 | {% endfor %}
25 |
26 |
27 |
28 | {% endblock container %}
29 |
--------------------------------------------------------------------------------
/bottle_admin/views/admin/edit.html:
--------------------------------------------------------------------------------
1 | {% extends 'admin/base.html' %}
2 |
3 |
4 | {% block title %}
5 | Bottle Admin - Edit {{ model.name }}: {{ obj.id }}
6 | {% endblock title %}
7 |
8 | {% block container %}
9 | Edit {{ model.name }}: {{ obj.id }}
10 |
27 | {% endblock container %}
28 |
29 |
--------------------------------------------------------------------------------
/bottle_admin/auth/models.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | import datetime
3 | from sqlalchemy import Column, DateTime, Integer, Sequence, String
4 | from sqlalchemy.ext.declarative import declarative_base
5 |
6 | Base = declarative_base()
7 |
8 |
9 | class AbstractUser(Base):
10 | __tablename__ = 'users'
11 | id = Column(Integer, Sequence('user_id_seq'), primary_key=True)
12 | username = Column(String(50))
13 | hash = Column(String(20))
14 | role = Column(String(20))
15 | creation_date = Column(DateTime, default=datetime.datetime.now)
16 |
17 | class Meta:
18 | abstract = True
19 |
20 |
21 | class Role(Base):
22 | __tablename__ = 'roles'
23 | id = Column(Integer, Sequence('user_id_seq'), primary_key=True)
24 | role = Column(String(20))
25 | level = Column(Integer)
26 |
27 |
28 | class User(AbstractUser):
29 | fullname = Column(String(100))
30 | email_addr = Column(String(100))
31 | desc = Column(String(100))
32 | last_login = Column(DateTime)
33 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | bottle-admin
2 | ============
3 | [](https://travis-ci.org/avelino/bottle-admin) [](https://coveralls.io/github/avelino/bottle-admin?branch=master)
4 |
5 | Simple and extensible administrative interface framework for Bottle, based on the bottle-boilerplate. Bottle-admin uses the project and app structures from bottle-boilerplate.
6 |
7 | ## How to use
8 | Install bottle-admin
9 | ```
10 | pip install git+git://github.com/avelino/bottle-admin.git@master
11 | ```
12 | Start your project and app with [bottle-boilerplate](https://github.com/avelino/bottle-boilerplate)
13 | ```
14 | bottle-boilerplate startproject YOUR-PROJECT
15 | cd YOUR-PROJECT
16 | python manage.py startapp YOUR-APP
17 | ```
18 | Edit the manage.py file and add the following line:
19 | ```python
20 | from bottle_admin import site
21 | site.setup(engine)
22 | ```
23 | To register your SQLAlchemy models for using inside admin:
24 | ```python
25 | site.register(YourModel)
26 | ```
27 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 Thiago Avelino
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tests/testutils.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | from webtest import TestApp
3 |
4 |
5 | class VerboseTestApp(TestApp):
6 |
7 | """A testapp that prints the traceback when it exists."""
8 |
9 | def _check_status(self, status, res):
10 | print(res.errors)
11 | super(VerboseTestApp, self)._check_status(status, res)
12 |
13 |
14 | class WebFunctionalTest(object):
15 | def login(self, username, password):
16 | post = {'username': username, 'password': password}
17 | response = self.app_test.post('/admin/login', post)
18 | assert response.status == '302 Found'
19 | assert '/admin' in response.location
20 |
21 | def logout(self):
22 | response = self.app_test.get('/admin/logout')
23 | assert response.status == '302 Found'
24 | assert '/admin/login' in response.location
25 |
26 | def assert_302(self, request_url, redirect_url, method='GET'):
27 | if method == 'GET':
28 | response = self.app_test.get(request_url)
29 | elif method == 'POST':
30 | response = self.app_test.post(request_url)
31 | else:
32 | raise Exception(u'Invalid HTTP method')
33 | assert response.status == '302 Found'
34 | assert redirect_url in response.location
35 |
36 | def assert_200(self, request_url):
37 | assert self.app_test.get(request_url).status == '200 OK'
38 |
--------------------------------------------------------------------------------
/tests/test_options.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | from bottle_admin import site
3 | from bottle_admin.options import ModelAdmin
4 | from bottle_admin.sites import AdminSite
5 | # from sqlalchemy.orm import sessionmaker
6 |
7 | from bottle_application import app, Product, User
8 | from testutils import VerboseTestApp, WebFunctionalTest
9 |
10 |
11 | class TestAdminOptions(WebFunctionalTest):
12 | app_test = VerboseTestApp(app)
13 |
14 | @classmethod
15 | def assert_model_meta(cls, model, columns):
16 | assert set(model.columns) == columns
17 | assert model.add_url == '{0}/{1}/add'.format(AdminSite.url_prefix, model.name)
18 | assert model.list_url == '{0}/{1}'.format(AdminSite.url_prefix, model.name)
19 | edit_url = '{0}/{1}/edit'.format(AdminSite.url_prefix, model.name)
20 | assert model.edit_url == edit_url
21 | delete_url = '{0}/{1}/delete'.format(AdminSite.url_prefix, model.name)
22 | assert model.delete_url == delete_url
23 |
24 | def test_object(self):
25 | user_model = ModelAdmin(User, site)
26 | columns = set(('username', 'fullname', 'hash', 'creation_date',
27 | 'role', 'email_addr', 'desc', 'last_login'))
28 | TestAdminOptions.assert_model_meta(user_model, columns)
29 |
30 | product_model = ModelAdmin(Product, site)
31 | columns = set(('name', 'description', 'price'))
32 | TestAdminOptions.assert_model_meta(product_model, columns)
33 |
--------------------------------------------------------------------------------
/bottle_admin/options.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | from sqlalchemy import inspect
3 |
4 |
5 | class ModelAdmin(object):
6 | """
7 | Extends the model to have admin functionalities
8 | """
9 |
10 | list_display = tuple()
11 |
12 | def __repr__(self):
13 | return "".format(self.name)
14 |
15 | def __init__(self, model, site):
16 | from .sites import site
17 | self.model_cls = model # the model class
18 | self.site = site
19 | self.name = self.model_cls.__name__.lower()
20 | self.add_url = '{0}/{1}/add'.format(site.url_prefix, self.name)
21 | self.list_url = '{0}/{1}'.format(site.url_prefix, self.name)
22 | self.edit_url = '{0}/{1}/edit'.format(site.url_prefix, self.name)
23 | self.delete_url = '{0}/{1}/delete'.format(site.url_prefix, self.name)
24 |
25 | @property
26 | def columns(self):
27 | mapper = inspect(self.model_cls)
28 | attrs = [prop.columns[0] for prop in mapper.attrs]
29 | return (prop.name for prop in attrs if prop.name != 'id')
30 |
31 | def get_list_display(self):
32 | return self.list_display
33 |
34 | def get_select_fields(self):
35 | list_display = self.get_list_display()
36 | fields = []
37 | for field in list_display:
38 | attr = getattr(self.model_cls, field)
39 | try:
40 | attr = attr()
41 | except:
42 | pass
43 | fields.append(attr)
44 | return fields
45 |
--------------------------------------------------------------------------------
/bottle_admin/views/admin/list.html:
--------------------------------------------------------------------------------
1 | {% extends 'admin/base.html' %}
2 |
3 |
4 | {% block title %}
5 | Bottle Admin - List {{ model.name }}
6 | {% endblock title %}
7 |
8 | {% block container %}
9 |
10 |
{{ model.name|capitalize }}
11 |
12 |
13 |
14 |
15 |
16 |
17 | {% if not results %}
18 | {{ model.name|capitalize }} not found.
19 | {% else %}
20 |
21 |
22 |
23 | | Actions |
24 | {% for column in model.list_display %}
25 | {{ column }} |
26 | {% endfor %}
27 |
28 |
29 |
30 |
31 | {% for row in results %}
32 |
33 | {% for column, value in row %}
34 | {% if column == 'id' %}
35 | |
36 |
37 |
38 |
39 |
40 |
41 |
42 | |
43 | {% else %}
44 | {{ value }} |
45 | {% endif %}
46 | {% endfor %}
47 |
48 | {% endfor %}
49 |
50 |
51 | {% endif %}
52 | {% endblock container %}
53 |
54 |
55 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding:utf-8 -*-
3 | from setuptools import setup
4 |
5 |
6 | REQUIREMENTS = [i.strip() for i in open("requirements.txt").readlines()]
7 |
8 | classifiers = [
9 | "Framework :: Bottle",
10 | 'Development Status :: 3 - Alpha',
11 | 'Environment :: Console',
12 | 'Intended Audience :: Developers',
13 | 'Natural Language :: English',
14 | 'License :: OSI Approved :: BSD License',
15 | 'Programming Language :: Python',
16 | 'Programming Language :: Python :: 2',
17 | 'Programming Language :: Python :: 2.7',
18 | 'Programming Language :: Python :: 3',
19 | 'Programming Language :: Python :: 3.3',
20 | 'Programming Language :: Python :: 3.4',
21 | 'Programming Language :: Python :: Implementation :: CPython',
22 | 'Programming Language :: Python :: Implementation :: PyPy',
23 | 'Topic :: Software Development']
24 |
25 | description = "Simple and extensible administrative interface framework for Bottle"
26 | try:
27 | long_description = open('README.md').read()
28 | except:
29 | long_description = description
30 |
31 | url = 'https://github.com/avelino/bottle-admin'
32 |
33 | setup(name='bottle-admin',
34 | version=0.1,
35 | description=description,
36 | long_description=long_description,
37 | classifiers=classifiers,
38 | keywords='bottle admin',
39 | author="Thiago Avelino",
40 | author_email="thiago@avelino.xxx",
41 | url=url,
42 | download_url="{0}/tarball/master".format(url),
43 | license="MIT",
44 | install_requires=REQUIREMENTS,
45 | entry_points={
46 | 'console_scripts': ["bottle = bottle_boilerplate:main"]
47 | },
48 | py_modules=['bottle_admin'],
49 | include_package_data=True,
50 | zip_safe=False)
51 |
--------------------------------------------------------------------------------
/bottle_admin/views/admin/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | {% block title %}Bottle Admin{% endblock %}
12 |
13 |
14 |
15 |
29 |
30 | {% block container %}
31 | {% endblock %}
32 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/tests/test_site.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | import pytest
3 | from bottle_admin.auth.models import User
4 | from bottle_admin.options import ModelAdmin
5 | from bottle_admin.sites import AdminSite, AlreadyRegistered, NotRegistered
6 |
7 | from bottle_application import Product
8 | from testutils import VerboseTestApp
9 | from test_options import TestAdminOptions
10 |
11 |
12 | @pytest.fixture(scope="function")
13 | def site(request):
14 | site = AdminSite()
15 | return site
16 |
17 |
18 | class TestAdminSite(VerboseTestApp):
19 | def test_register(self, site):
20 | assert len(site._registry) == 0
21 |
22 | site.register(Product)
23 | assert len(site._registry) == 1
24 |
25 | with pytest.raises(AlreadyRegistered):
26 | site.register(User)
27 | site.register(Product)
28 |
29 | def test_is_registered(self, site):
30 | assert site.is_registered(ModelAdmin(Product, site))
31 | site.register(Product)
32 | product = site._registry[0]
33 | assert site.is_registered(product)
34 |
35 | def test_get_model(self, site):
36 | with pytest.raises(NotRegistered):
37 | site.get_model('user')
38 |
39 | with pytest.raises(NotRegistered):
40 | site.get_model('product')
41 |
42 | site.register(User)
43 | model = site.get_model('user')
44 | assert model.model_cls == User
45 | columns = set(('username', 'fullname', 'hash', 'creation_date',
46 | 'role', 'email_addr', 'desc', 'last_login'))
47 | TestAdminOptions.assert_model_meta(model, columns)
48 |
49 | site.register(Product)
50 | model = site.get_model('product')
51 | assert model.model_cls == Product
52 | columns = set(('name', 'description', 'price'))
53 | TestAdminOptions.assert_model_meta(model, columns)
54 |
55 | def test_get_models(self, site):
56 | assert len(site.get_models()) == 0
57 | site.register(Product)
58 | site.register(User)
59 | assert len(site.get_models()) == 2
60 |
--------------------------------------------------------------------------------
/tests/bottle_application.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | from bottle import Bottle, TEMPLATE_PATH
3 | from beaker.middleware import SessionMiddleware
4 | import os
5 | from sqlalchemy import create_engine, Column, Integer, Numeric, Sequence, String
6 | from sqlalchemy.ext.declarative import declarative_base
7 | from sqlalchemy.orm import sessionmaker
8 |
9 | import bottle_admin
10 | from bottle_admin import site
11 | from bottle_admin.auth import get_aaa
12 | from bottle_admin.auth.models import Role, User
13 | from bottle_admin.options import ModelAdmin
14 |
15 | ADMIN_TEMPLATE_PATH = os.path.join(os.path.dirname(bottle_admin.__path__[0]),
16 | 'bottle_admin',
17 | 'views')
18 |
19 | TEMPLATE_PATH.insert(1, ADMIN_TEMPLATE_PATH)
20 |
21 | engine = create_engine('sqlite:///test.db', echo=True)
22 |
23 | Base = declarative_base()
24 | Base.metadata.drop_all(bind=engine)
25 |
26 |
27 | class Product(Base):
28 | __tablename__ = 'products'
29 | id = Column(Integer, Sequence('user_id_seq'), primary_key=True)
30 | name = Column(String(50))
31 | description = Column(String(50))
32 | price = Column(Numeric(10, 2))
33 |
34 | def __init__(self, name, description, price):
35 | self.name = name
36 | self.description = description
37 | self.price = price
38 |
39 | def __repr__(self):
40 | return "" % (self.name, self.fullname,
41 | self.password)
42 |
43 |
44 | Product.metadata.create_all(engine)
45 |
46 | app = Bottle()
47 |
48 |
49 | class ProductAdmin(ModelAdmin):
50 | list_display = ('name', 'description', 'price')
51 |
52 |
53 | site.setup(engine, app)
54 | site.register(Product, ProductAdmin)
55 |
56 | session_opts = {
57 | 'session.type': 'file',
58 | 'session.auto': True,
59 | 'session.data_dir': './test_data'
60 | }
61 | app = SessionMiddleware(app, session_opts)
62 |
63 | session = sessionmaker(bind=engine)()
64 | role_user = Role(role='user', level=50)
65 | session.add(role_user)
66 | role_admin = Role(role='admin', level=500)
67 | session.add(role_admin)
68 | session.commit()
69 |
70 | aaa = get_aaa()
71 | hash = aaa._hash('user', '123')
72 | session.add(User(username='user', hash=hash, role='user',
73 | fullname='full', email_addr='e@e.com'))
74 | hash = aaa._hash('admin', '123')
75 | session.add(User(username='admin', hash=hash, role='admin',
76 | fullname='admin', email_addr='a@a.com'))
77 | session.commit()
78 |
--------------------------------------------------------------------------------
/bottle_admin/controllers/main.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | from bottle import jinja2_view, redirect, request
3 | from bottle_admin import site
4 | from bottle_admin.auth import get_aaa
5 | from bottle_admin.helpers import get_object_as_list, get_objects_as_list
6 |
7 | from sqlalchemy.orm import sessionmaker
8 |
9 |
10 | @jinja2_view('admin/index.html')
11 | def home_controller():
12 | aaa = get_aaa()
13 | aaa.require(fail_redirect='/admin/login')
14 | return {'models': site.get_models()}
15 |
16 |
17 | @jinja2_view('admin/add.html')
18 | def add_model_get_controller(model_name):
19 | aaa = get_aaa()
20 | aaa.require(fail_redirect='/admin/login')
21 |
22 | model = site.get_model(model_name)
23 | return {'model': model}
24 |
25 |
26 | def add_model_post_controller(model_name):
27 | aaa = get_aaa()
28 | aaa.require(fail_redirect='/admin/login')
29 |
30 | fields = dict(request.forms)
31 | model = site.get_model(model_name)
32 | session = sessionmaker(bind=site.engine)()
33 | try:
34 | obj = model.model_cls(**fields)
35 | except TypeError:
36 | return u'{0} object not created. Not enough data'.format(model_name)
37 | session.add(obj)
38 | session.commit()
39 | return redirect('/admin/{0}'.format(model.name))
40 |
41 |
42 | def delete_model_controller(model_name, model_id):
43 | aaa = get_aaa()
44 | aaa.require(fail_redirect='/admin/login')
45 |
46 | model = site.get_model(model_name)
47 | session = sessionmaker(bind=site.engine)()
48 | obj = session.query(model.model_cls).get(model_id)
49 | if not obj:
50 | return u'{0}: {1} not found'.format(model.name, model_id)
51 | session.delete(obj)
52 | session.commit()
53 | return redirect('/admin/{0}'.format(model.name))
54 |
55 |
56 | @jinja2_view('admin/edit.html')
57 | def edit_model_get_controller(model_name, model_id):
58 | aaa = get_aaa()
59 | aaa.require(fail_redirect='/admin/login')
60 |
61 | model = site.get_model(model_name)
62 | session = sessionmaker(bind=site.engine)()
63 | obj = session.query(model.model_cls).get(model_id)
64 | if not obj:
65 | return u'{0} {1} not found'.format(model.name, model_id)
66 | obj.as_list = get_object_as_list(model, obj)
67 | return {
68 | 'model': model,
69 | 'obj': obj
70 | }
71 |
72 |
73 | @jinja2_view('admin/edit.html')
74 | def edit_model_post_controller(model_name, model_id):
75 | aaa = get_aaa()
76 | aaa.require(fail_redirect='/admin/login')
77 |
78 | model = site.get_model(model_name)
79 | session = sessionmaker(bind=site.engine)()
80 | obj = session.query(model.model_cls).get(model_id)
81 | fields = dict(request.forms)
82 | for column, value in fields.items():
83 | setattr(obj, column, value)
84 | if not obj:
85 | return u'{0} {1} object not found'.format(model_name, model_id)
86 | session.add(obj)
87 | session.commit()
88 | session.close()
89 | return redirect('/admin/{0}'.format(model_name))
90 |
91 |
92 | @jinja2_view('admin/list.html')
93 | def list_model_controller(model_name):
94 | aaa = get_aaa()
95 | aaa.require(fail_redirect='/admin/login')
96 |
97 | model = site.get_model(model_name)
98 | session = sessionmaker(bind=site.engine)()
99 | fields = model.get_select_fields()
100 | objects = list(session.query(model.model_cls.id, *fields).all())
101 |
102 | return {
103 | 'model': model,
104 | 'results': get_objects_as_list(model, objects),
105 | }
106 |
--------------------------------------------------------------------------------
/bottle_admin/sites.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 |
3 | from bottle import Bottle
4 |
5 | from bottle_admin import auth
6 | from bottle_admin.options import ModelAdmin
7 |
8 |
9 | class AlreadyRegistered(Exception):
10 | pass
11 |
12 |
13 | class NotRegistered(Exception):
14 | pass
15 |
16 |
17 | class AdminSite(object):
18 | """
19 | Creates an admin site for models
20 | """
21 |
22 | url_prefix = '/admin'
23 |
24 | def __repr__(self):
25 | return "".format(self.app, self._registry)
26 |
27 | def __init__(self, app=None, engine=None, auth=None):
28 | self.app = app or Bottle()
29 | self.engine = engine
30 | self.auth = auth
31 | self._registry = []
32 |
33 | def setup(self, engine, app):
34 | self.engine = engine
35 | self.setup_routing(app)
36 | self.setup_models()
37 | auth.setup(self.engine)
38 |
39 | def setup_models(self):
40 | from .auth.admin import RoleAdmin, UserAdmin
41 | self.register(auth.Role, RoleAdmin)
42 | self.register(auth.User, UserAdmin)
43 |
44 | def setup_routing(self, app):
45 | from .auth.controllers import (login_get_controller, login_post_controller,
46 | logout_controller)
47 | from .controllers.main import (add_model_get_controller,
48 | add_model_post_controller,
49 | edit_model_get_controller,
50 | edit_model_post_controller,
51 | delete_model_controller, home_controller,
52 | list_model_controller)
53 | self.app.route(
54 | '/',
55 | ['GET'],
56 | home_controller)
57 |
58 | self.app.route(
59 | '//add',
60 | ['GET'],
61 | add_model_get_controller)
62 |
63 | self.app.route(
64 | '//add',
65 | ['POST'],
66 | add_model_post_controller)
67 |
68 | self.app.route(
69 | '//delete/',
70 | ['GET'],
71 | delete_model_controller)
72 |
73 | self.app.route(
74 | '//edit/',
75 | ['GET'],
76 | edit_model_get_controller)
77 |
78 | self.app.route(
79 | '//edit/',
80 | ['POST'],
81 | edit_model_post_controller)
82 |
83 | self.app.route(
84 | '/login',
85 | ['GET'],
86 | login_get_controller)
87 |
88 | self.app.route(
89 | '/login',
90 | ['POST'],
91 | login_post_controller)
92 |
93 | self.app.route(
94 | '/logout',
95 | ['GET'],
96 | logout_controller)
97 |
98 | self.app.route(
99 | '/',
100 | ['GET'],
101 | list_model_controller)
102 |
103 | app.mount(self.url_prefix, self.app)
104 |
105 | def register(self, model, admin_class=None):
106 | if not admin_class:
107 | admin_class = ModelAdmin
108 | admin_obj = admin_class(model, self)
109 |
110 | if self.is_registered(admin_obj):
111 | message = u'Model {0} has already beeen registered'.format(model)
112 | raise AlreadyRegistered(message)
113 |
114 | self._registry.append(admin_obj)
115 |
116 | def is_registered(self, model):
117 | if type(model) is ModelAdmin:
118 | return model in self._registry
119 |
120 | for model_admin in self._registry:
121 | if model == model_admin.model_cls:
122 | return True
123 | return False
124 |
125 | def get_models(self):
126 | return self._registry
127 |
128 | def get_model(self, model_name):
129 | for model_admin in self._registry:
130 | if model_admin.name == model_name:
131 | return model_admin
132 | raise NotRegistered(u'Model {0} has not been registered'.format(model_name))
133 |
134 |
135 | site = AdminSite()
136 |
--------------------------------------------------------------------------------
/tests/test_controllers.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 | from decimal import Decimal
3 | import pytest
4 | from sqlalchemy.orm import sessionmaker
5 |
6 | from bottle_application import app, engine, Product
7 | from testutils import VerboseTestApp, WebFunctionalTest
8 |
9 |
10 | @pytest.fixture(scope="function")
11 | def session(request):
12 | session = sessionmaker(bind=engine)()
13 | return session
14 |
15 |
16 | class TestControllers(WebFunctionalTest):
17 | app_test = VerboseTestApp(app)
18 |
19 | def test_add_model_get_controller(self, session):
20 | # TODO: tests with selenium or similar tool
21 | self.assert_302('/admin/product/add', '/admin/login')
22 |
23 | self.login('admin', '123')
24 | response = self.app_test.get('/admin/product/add')
25 | assert response.status == '200 OK'
26 | inputs = response.html.find_all('input')
27 | assert inputs[0].attrs['name'] == 'name'
28 | assert inputs[1].attrs['name'] == 'description'
29 | assert inputs[2].attrs['name'] == 'price'
30 |
31 | self.logout()
32 |
33 | def test_add_model_post_controller(self, session):
34 | self.assert_302('/admin/product/add', '/admin/login', 'POST')
35 |
36 | self.login('admin', '123')
37 | response = self.app_test.post('/admin/product/add')
38 | assert response.status == '200 OK'
39 | response.mustcontain('not created')
40 |
41 | self.assert_200('/admin/product/add')
42 |
43 | post = {'name': 'pp', 'description': 'des', 'price': 44.44}
44 | response = self.app_test.post('/admin/product/add', post)
45 | assert response.status == '302 Found'
46 | assert '/admin/product' in response.location
47 |
48 | product = session.query(Product).filter_by(name='pp').first()
49 | assert product.name == 'pp'
50 | assert product.description == 'des'
51 | assert product.price == Decimal('44.44')
52 |
53 | self.logout()
54 |
55 | def test_delete_model_controller(self, session):
56 | self.assert_302('/admin/product/delete/1', '/admin/login')
57 |
58 | self.login('admin', '123')
59 | session.query(Product).delete()
60 | session.commit()
61 |
62 | self.app_test.get('/admin/product/delete/1').mustcontain('not found')
63 |
64 | product1 = Product(name='p1', description='desc', price=1.1)
65 | session.add(product1)
66 | product2 = Product(name='p2', description='desc', price=2.2)
67 | session.add(product2)
68 | session.commit()
69 |
70 | url = '/admin/product/delete/{0}'.format(product1.id)
71 | response = self.app_test.get(url)
72 | assert response.status == '302 Found'
73 | assert '/admin/product' in response.location
74 | response = self.app_test.get(url)
75 | response.mustcontain('not found')
76 |
77 | url = '/admin/product/delete/{0}'.format(product2.id)
78 | response = self.app_test.get(url)
79 | assert response.status == '302 Found'
80 | assert '/admin/product' in response.location
81 | response = self.app_test.get(url)
82 | response.mustcontain('not found')
83 |
84 | self.logout()
85 |
86 | def test_edit_model_get_controller(self, session):
87 | self.assert_302('/admin/product/edit/1', '/admin/login')
88 |
89 | self.login('admin', '123')
90 |
91 | # TODO: tests with selenium or similar tool
92 | response = self.app_test.get('/admin/product/edit/1')
93 | assert response.status == '200 OK'
94 | response.mustcontain('not found')
95 |
96 | product1 = Product(name='p1', description='desc', price=1.1)
97 | session.add(product1)
98 | session.commit()
99 |
100 | url = '/admin/product/edit/{0}'.format(product1.id)
101 | response = self.app_test.get(url)
102 | assert response.status == '200 OK'
103 | inputs = response.html.find_all('input')
104 | assert inputs[0].attrs['name'] == 'name'
105 | assert inputs[1].attrs['name'] == 'description'
106 | assert inputs[2].attrs['name'] == 'price'
107 |
108 | self.logout()
109 |
110 | def test_edit_model_post_controller(self, session):
111 | self.assert_302('/admin/product/edit/1', '/admin/login', 'POST')
112 |
113 | self.login('admin', '123')
114 |
115 | session.query(Product).delete()
116 | session.commit()
117 | response = self.app_test.post('/admin/product/edit/1')
118 | assert response.status == '200 OK'
119 | response.mustcontain('not found')
120 |
121 | product1 = Product(name='p1', description='desc', price=1.1)
122 | session.add(product1)
123 | session.commit()
124 |
125 | url = '/admin/product/edit/{0}'.format(product1.id)
126 | assert self.app_test.get(url).status == '200 OK'
127 | session.close()
128 |
129 | post = {'name': 'pe', 'description': 'de', 'price': 1.5}
130 | response = self.app_test.post(url, post)
131 | assert response.status == '302 Found'
132 |
133 | session = sessionmaker(bind=engine)()
134 | p = session.query(Product).get(1)
135 | assert p.name == 'pe'
136 | assert p.description == 'de'
137 | assert p.price == 1.5
138 |
139 | self.logout()
140 | session.close()
141 |
142 | def test_list_model_controller(self, session):
143 | self.assert_302('/admin/product', '/admin/login')
144 |
145 | self.login('admin', '123')
146 | session.query(Product).delete()
147 | session.commit()
148 |
149 | response = self.app_test.get('/admin/product')
150 | response.mustcontain('not found')
151 |
152 | product1 = Product(name='p1', description='desc', price=1.1)
153 | session.add(product1)
154 | session.commit()
155 |
156 | response = self.app_test.get('/admin/product')
157 | rows = len(response.html.find_all('tr'))
158 | assert rows == 2
159 |
160 | product2 = Product(name='p2', description='desc', price=2.2)
161 | session.add(product2)
162 | session.commit()
163 |
164 | response = self.app_test.get('/admin/product')
165 | rows = len(response.html.find_all('tr'))
166 | assert rows == 3
167 |
168 | self.logout()
169 |
170 | def test_routes(self, session):
171 | self.assert_302('/admin', '/admin/login')
172 |
173 | self.login('admin', '123')
174 | self.assert_200('/admin')
175 | self.logout()
176 |
--------------------------------------------------------------------------------