Merge pull request #5 from NickDaly/master

Stuff James Will Like
This commit is contained in:
jvasile 2013-02-05 10:55:19 -08:00
commit d0e0285892
18 changed files with 365 additions and 69 deletions

6
.gitignore vendored
View File

@ -20,3 +20,9 @@ templates/*.py
TODO
\#*
.#*
cfg.py
cherrypy.config
data/users.sqlite3
predepend
build/
*.pid

27
INSTALL
View File

@ -2,20 +2,27 @@
## Installing Plinth
Install the python-cheetah package and pandoc:
apt-get install python-cheetah pandoc
Install the python-cheetah package, pandoc, python-augeas, and
bjsonrpc:
apt-get install python-cheetah pandoc python-augeas python-bjsonrpc
Install the python-simplejson
apt-get install python-simplejson
Unzip and untar the source into a directory. Change to the directory
containing the program. Do `make` and then run `./plinth.py` and
point your web browser at `localhost:8000`. The default username is
"admin" and the default password is "secret".
containing the program. Run:
$ make
To start Plinth, run:
$ ./start.sh
and point your web browser at `localhost:8000`. The default username is "admin"
and the default password is "secret".
## Dependencies
@ -27,6 +34,10 @@ point your web browser at `localhost:8000`. The default username is
* *GNU Make* is used to build the templates and such.
* bjsonrpc - used for configuration management layer
* python-augeas and augeas - used for configuration management
The documentation has some dependencies too.
* *Markdown* is used to format and style docs.
@ -42,5 +53,3 @@ The documentation has some dependencies too.
Documentation has been collected into a pdf that can be built using
`make doc`. It also gets built into smaller files and other formats,
including one suitable for install as a man page.
<a name="installing_systemwide" />

View File

@ -1,5 +1,6 @@
#SHELL := /bin/bash
MAKE=make
BUILD_DIR = build
#TODO: add install target
@ -9,9 +10,22 @@ COMPRESSED_CSS := $(patsubst %.css,%.tiny.css,$(CSS))
PWD=`pwd`
## Catch-all tagets
default: cfg cherrypy.config dirs template css docs dbs
default: predepend cfg cherrypy.config dirs template css docs dbs $(BUILD_DIR)/exmachina #$(BUILD_DIR)/bjsonrpc
all: default
build:
mkdir -p $(BUILD_DIR)
predepend:
sudo sh -c "apt-get install augeas-tools python-bjsonrpc python-augeas python-simplejson pandoc python-cheetah"
touch predepend
$(BUILD_DIR)/exmachina: build
git clone git://github.com/tomgalloway/exmachina $(BUILD_DIR)/exmachina
$(BUILD_DIR)/bjsonrpc: build
git clone git://github.com/deavid/bjsonrpc.git $(BUILD_DIR)/bjsonrpc
dbs: data/users.sqlite3
data/users.sqlite3: data/users.sqlite3.distrib
@ -69,3 +83,5 @@ clean:
@find . -name "*.bak" -exec rm {} \;
@$(MAKE) -s -C doc clean
@$(MAKE) -s -C templates clean
rm -rf $(BUILD_DIR)
rm -f predepend

12
NOTES
View File

@ -2,6 +2,18 @@
%
% February 2012
# Edits by bnewbold
## 2012-07-12 "exmachina" configuration management layer
- this new code is very ugly and in the "just make it work" style
- add exmachina code and test code
- modify plinth.py to listen for shared secret on stdin at start
(if appropriate flag is set) and try to connect to exmachina daemon
- use exmachina to read and set /etc/hostname as a demo
- update plinth init.d script to start exmachina and share keys
- update docs with new deps and run instructions
# Edits by seandiggity
## 2012-02-27 new theme based upon bootstrap

3
README
View File

@ -50,4 +50,5 @@ interface will overwrite those changes at first opportunity. This
interface is not a tool for super admins facing complex scenarios. It
is for home users to do a wide variety of basic tasks.
See comments in exmachina/exmachina.py for more details about the configuration
management process seperation scheme.

View File

@ -8,10 +8,12 @@ user_db = os.path.join(data_dir, "users")
status_log_file = os.path.join(data_dir, "status.log")
access_log_file = os.path.join(data_dir, "access.log")
users_dir = os.path.join(data_dir, "users")
pidfile = os.path.join(data_dir, "pidfile.pid")
product_name = "Plinth"
box_name = "FreedomBox"
host = "127.0.0.1"
port = 8000
## Do not edit below this line ##

2
fabfile.py vendored
View File

@ -138,7 +138,7 @@ def apache():
@task
def deps():
"Basic plinth dependencies"
sudo('apt-get install --no-install-recommends -y python make python-cheetah pandoc python-simplejson python-pyme')
sudo('apt-get install --no-install-recommends -y python make python-cheetah pandoc python-simplejson python-pyme python-augeas python-bjsonrpc')
@task
def update():

42
issues/links.org Normal file
View File

@ -0,0 +1,42 @@
# -*- mode: org; mode: auto-fill; fill-column: 80 -*-
#+TITLE: Make Links Portable
#+OPTIONS: d:t
#+LINK_UP: ./
#+LINK_HOME: ../
* Issue
Currently, all the links in Plinth point to 127.0.0.1/(something), and that
sucks for serving Plinth on a local network, like most use cases imply.
* Fixes [0/1]
** TODO Links work when accessed from remote clients.
Investigate the following:
: grep -nHr basehref ../*
: grep -nHr 127.0 ../*
#+begin_ascii
fabfile.py:40: if env.host == "localhost" or env.host=="127.0.0.1":
fabfile.py:46: if env.host == "localhost" or env.host=="127.0.0.1":
fabfile.py:102: hidden_service_config = "HiddenServiceDir %s\nHiddenServicePort 80 127.0.0.1:%d" % (tor_dir, santiago_port)
modules/installed/santiago/santiago.py:48: hidden_service_config = "HiddenServiceDir %s\nHiddenServicePort 80 127.0.0.1:%d" % (self.tor_dir, santiago_port)
plinth.py:119: server.socket_host = '127.0.0.1'
#+end_ascii
Also, why is base_href blank in [[file:~/programs/freedombox/plinth/cfg.sample.py][cfg.sample.py]]?
* Discussion
* Metadata
:PROPERTIES:
:Status: Incomplete
:Priority: 0
:Owner: Nick Daly
:Description:
:Tags:
:END:

View File

@ -1,14 +1,15 @@
class User(dict):
""" Every user must have keys for a username, name, password (this
""" Every user must have keys for a username, name, passphrase (this
is a md5 hash of the password), groups, and an email address. They can be
blank or None, but the keys must exist. """
def __init__(self, dict=None):
for key in ['username', 'name', 'password', 'email']:
for key in ['username', 'name', 'passphrase', 'email']:
self[key] = ''
for key in ['groups']:
self[key] = []
for key in dict:
self[key] = dict[key]
if dict:
for key in dict:
self[key] = dict[key]
def __getattr__(self, attr):
return None

View File

@ -12,10 +12,42 @@ class UserStore(UserStoreModule, sqlite_db):
self.db_file = cfg.user_db
sqlite_db.__init__(self, self.db_file, autocommit=True)
self.__enter__()
def close(self):
self.__exit__()
def expert(self):
return False
self.__exit__(None,None,None)
def expert(self, username=None):
groups = self.attr(username,"groups")
if not groups:
return False
return 'expert' in groups
def attr(self, username=None, field=None):
return self.get(username)[field]
def get(self,username=None):
return User(sqlite_db.get(self,username))
def exists(self, username=None):
try:
user = self.get(username)
if not user:
return False
elif user["username"]=='':
return False
return True
except TypeError:
return False
def remove(self,username=None):
self.__delitem__(username)
self.commit()
def get_all(self):
return self.items()
def set(self,username=None,user=None):
sqlite_db.__setitem__(self,username, user)
class UserStoreOld():
#class UserStore(UserStoreModule):

View File

@ -1,4 +1,4 @@
import os, shutil, subprocess
import os, subprocess
from socket import gethostname
import cherrypy
import simplejson as json
@ -10,6 +10,7 @@ import cfg
from forms import Form
from model import User
from util import *
import platform
class Config(PagePlugin):
def __init__(self, *args, **kwargs):
@ -41,20 +42,21 @@ def valid_hostname(name):
def set_hostname(hostname):
"Sets machine hostname to hostname"
cfg.log.info("Writing '%s' to /etc/hostname" % hostname)
unslurp("/etc/hostname", hostname+"\n")
cfg.log.info("Writing '%s' to /etc/hostname with exmachina" % hostname)
try:
retcode = subprocess.call("/etc/init.d/hostname.sh start", shell=True)
if retcode < 0:
cfg.log.error("Hostname restart terminated by signal: return code is %s" % retcode)
cfg.exmachina.augeas.set("/files/etc/hostname/*", hostname)
cfg.exmachina.augeas.save()
# don't persist/cache change unless it was saved successfuly
sys_store = filedict_con(cfg.store_file, 'sys')
sys_store['hostname'] = hostname
if platform.linux_distribution()[0]=="Ubuntu" :
cfg.exmachina.service.start("hostname")
else:
cfg.log.debug("Hostname restart returned %s" % retcode)
cfg.exmachina.initd.restart("hostname.sh") # is hostname.sh debian-only?
except OSError, e:
raise cherrypy.HTTPError(500, "Hostname restart failed: %s" % e)
sys_store = filedict_con(cfg.store_file, 'sys')
sys_store['hostname'] = hostname
class general(FormPlugin, PagePlugin):
url = ["/sys/config"]
order = 30
@ -72,8 +74,11 @@ class general(FormPlugin, PagePlugin):
def main(self, message='', **kwargs):
sys_store = filedict_con(cfg.store_file, 'sys')
hostname = cfg.exmachina.augeas.get("/files/etc/hostname/*")
# this layer of persisting configuration in sys_store could/should be
# removed -BLN
defaults = {'time_zone': "slurp('/etc/timezone').rstrip()",
'hostname': "gethostname()",
'hostname': "hostname",
}
for k,c in defaults.items():
if not k in kwargs:
@ -81,6 +86,8 @@ class general(FormPlugin, PagePlugin):
kwargs[k] = sys_store[k]
except KeyError:
exec("if not '%(k)s' in kwargs: sys_store['%(k)s'] = kwargs['%(k)s'] = %(c)s" % {'k':k, 'c':c})
# over-ride the sys_store cached value
kwargs['hostname'] = hostname
## Get the list of supported timezones and the index in that list of the current one
module_file = __file__
@ -120,7 +127,8 @@ class general(FormPlugin, PagePlugin):
old_val = sys_store['hostname']
try:
set_hostname(hostname)
except:
except Exception, e:
cfg.log.error(e)
cfg.log.info("Trying to restore old hostname value.")
set_hostname(old_val)
raise
@ -128,8 +136,8 @@ class general(FormPlugin, PagePlugin):
message += msg
if time_zone != sys_store['time_zone']:
src = os.path.join("/usr/share/zoneinfo", time_zone)
cfg.log.info("Copying %s to /etc/localtime" % src)
shutil.copy(src, "/etc/localtime")
cfg.log.info("Setting timezone to %s" % time_zone)
cfg.exmachina.misc.set_timezone(time_zone)
sys_store['time_zone'] = time_zone
return message or "Settings updated."

View File

@ -5,22 +5,23 @@ from plugin_mount import PagePlugin, FormPlugin
import cfg
from forms import Form
from util import *
from model import User
class users(PagePlugin):
order = 20 # order of running init in PagePlugins
def __init__(self, *args, **kwargs):
PagePlugin.__init__(self, *args, **kwargs)
self.register_page("sys.users")
self.register_page("sys.users.add")
self.register_page("sys.users.edit")
@cherrypy.expose
@require()
def index(self):
parts = self.forms('/sys/config')
parts['title']=_("Manage Users and Groups")
return self.fill_template(**parts)
return self.fill_template(title="Manage Users and Groups", sidebar_right="""<strong><a href="/sys/users/add">Add User</a></strong><br/><strong><a href="/sys/users/edit">Edit Users</a></strong>""")
class add(FormPlugin, PagePlugin):
url = ["/sys/users"]
url = ["/sys/users/add"]
order = 30
sidebar_left = ''
@ -40,33 +41,35 @@ class add(FormPlugin, PagePlugin):
form.text_input(_("Username"), name="username", value=username)
form.text_input(_("Full name"), name="name", value=name)
form.text_input(_("Email"), name="email", value=email)
form.text_input(_("Password"), name="password")
form.text_input(_("Password"), name="password", type="password")
form.text_input(name="md5_password", type="hidden")
form.submit(label=_("Create User"), name="create")
return form.render()
def process_form(self, username=None, name=None, email=None, md5_password=None, **kwargs):
msg = ''
msg = Message()
if not username: msg = add_message(msg, _("Must specify a username!"))
if not md5_password: msg = add_message(msg, _("Must specify a password!"))
if not username: msg.add = _("Must specify a username!")
if not md5_password: msg.add = _("Must specify a password!")
if username in cfg.users:
msg = add_message(msg, _("User already exists!"))
if username in cfg.users.get_all():
msg.add = _("User already exists!")
else:
try:
cfg.users[username]= User(dict={'username':username, 'name':name, 'email':email, 'password':md5_password})
di = {'username':username, 'name':name, 'email':email, 'passphrase':md5_password}
new_user = User(di)
cfg.users.set(username,new_user)
except:
msg = add_message(msg, _("Error storing user!"))
msg.add = _("Error storing user!")
if not msg:
msg = add_message(msg, "%s saved." % username)
main = self.make_form(username, name, email, message=msg)
return self.fill_template(title="", main=main, sidebar_left=self.sidebar_left, sidebar_right=self.sidebar_right)
msg.add = _("%s saved." % username)
cfg.log(msg.text)
main = self.main(username, name, email, msg=msg.text)
return self.fill_template(title="Manage Users and Groups", main=main, sidebar_left=self.sidebar_left, sidebar_right=self.sidebar_right)
class edit(FormPlugin, PagePlugin):
url = ["/sys/users"]
url = ["/sys/users/edit"]
order = 35
sidebar_left = ''
@ -77,14 +80,15 @@ class edit(FormPlugin, PagePlugin):
system.</p><p>Deleting users is permanent!</p>""" % (cfg.product_name, cfg.box_name))
def main(self, msg=''):
users = cfg.users.keys()
users = cfg.users.get_all()
add_form = Form(title=_("Edit or Delete User"), action="/sys/users/edit", message=msg)
add_form.html('<span class="indent"><strong>Delete</strong><br /></span>')
for uname in sorted(users.keys()):
for uname in users:
user = User(uname[1])
add_form.html('<span class="indent">&nbsp;&nbsp;%s&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;' %
add_form.get_checkbox(name=uname) +
add_form.get_checkbox(name=user['username']) +
'<a href="/sys/users/edit?username=%s">%s (%s)</a><br /></span>' %
(uname, users[uname]['name'], uname))
(user['username'], user['name'], user['username']))
add_form.submit(label=_("Delete User"), name="delete")
return add_form.render()
@ -95,12 +99,12 @@ class edit(FormPlugin, PagePlugin):
cfg.log.info("%s asked to delete %s" % (cherrypy.session.get(cfg.session_key), usernames))
if usernames:
for username in usernames:
if username in cfg.users:
if cfg.users.exists(username):
try:
del cfg.users[username]
cfg.users.remove(username)
msg.add(_("Deleted user %s." % username))
except IOError, e:
if 'username' in cfg.users:
if cfg.users.exists(username):
m = _("Error on deletion, user %s not fully deleted: %s" % (username, e))
cfg.log.error(m)
msg.add(m)
@ -113,8 +117,8 @@ class edit(FormPlugin, PagePlugin):
msg.add(_("User %s does not exist." % username))
else:
msg.add = _("Must specify at least one valid, existing user.")
main = self.make_form(msg=msg.text)
return self.fill_template(title="", main=main, sidebar_left=self.sidebar_left, sidebar_right=self.sidebar_right)
main = self.main(msg=msg.text)
return self.fill_template(title="Manage Users and Groups", main=main, sidebar_left=self.sidebar_left, sidebar_right=self.sidebar_right)
sidebar_right = ''
u = cfg.users[kwargs['username']]
@ -125,4 +129,4 @@ class edit(FormPlugin, PagePlugin):
main = _("""<strong>Edit User '%s'</strong>""" % u['username'])
sidebar_right = ''
return self.fill_template(title="", main=main, sidebar_left=self.sidebar_left, sidebar_right=sidebar_right)
return self.fill_template(title="Manage Users and Groups", main=main, sidebar_left=self.sidebar_left, sidebar_right=sidebar_right)

View File

@ -17,6 +17,9 @@ from util import *
from logger import Logger
#from modules.auth import AuthController, require, member_of, name_is
from exmachina import ExMachinaClient
import socket
__version__ = "0.2.14"
__author__ = "James Vasile"
__copyright__ = "Copyright 2011, James Vasile"
@ -51,7 +54,7 @@ class Root(plugin_mount.PagePlugin):
raise cherrypy.InternalRedirect('/router')
else:
raise cherrypy.InternalRedirect('/help/about')
def load_modules():
"""Import all the symlinked .py files in the modules directory and
all the .py files in directories linked in the modules directory
@ -71,9 +74,20 @@ def parse_arguments():
parser = argparse.ArgumentParser(description='Plinth web interface for the FreedomBox.')
parser.add_argument('--pidfile', default="",
help='specify a file in which the server may write its pid')
parser.add_argument('--listen-exmachina-key', default=False, action='store_true',
help='listen for JSON-RPC shared secret key on stdin at startup')
args=parser.parse_args()
if args.pidfile:
cfg.pidfile = args.pidfile
else:
if not cfg.pidfile:
cfg.pidfile = "plinth.pid"
if args.listen_exmachina_key:
# this is where we optionally try to read in a shared secret key to
# authenticate connections to exmachina
cfg.exmachina_secret_key = sys.stdin.readline().strip()
else:
cfg.exmachina_secret_key = None
def setup():
parse_arguments()
@ -85,6 +99,13 @@ def setup():
except AttributeError:
pass
try:
cfg.exmachina = ExMachinaClient(
secret_key=cfg.exmachina_secret_key or None)
except socket.error:
cfg.exmachina = None
print "couldn't connect to exmachina daemon, but continuing anyways..."
os.chdir(cfg.file_root)
cherrypy.config.update({'error_page.404': error_page_404})
cherrypy.config.update({'error_page.500': error_page_500})
@ -103,7 +124,7 @@ def setup():
server.subscribe()
# Configure default server
cherrypy.config.update({'server.socket_host': '127.0.0.1',
cherrypy.config.update({'server.socket_host': cfg.host,
'server.socket_port': cfg.port,
'server.thread_pool':10,
'tools.staticdir.root': cfg.file_root,
@ -112,7 +133,7 @@ def setup():
'tools.sessions.storage_type':"file",
'tools.sessions.timeout':90,
'tools.sessions.storage_path':"%s/data/cherrypy_sessions" % cfg.file_root,
})
config = {'/': {'tools.staticdir.root': '%s/static' % cfg.file_root,
@ -124,11 +145,11 @@ def setup():
}
cherrypy.tree.mount(cfg.html_root, '/', config=config)
cherrypy.engine.signal_handler.subscribe()
def main():
setup()
print "localhost %d" % cfg.port
print "%s %d" % (cfg.host, cfg.port)
cherrypy.engine.start()
cherrypy.engine.block()

View File

@ -12,23 +12,53 @@
# This file is /etc/init.d/plinth
DAEMON=/usr/local/bin/plinth.py
EXMACHINA_DAEMON=/usr/local/bin/exmachina.py
PID_FILE=/var/run/plinth.pid
EXMACHINA_PID_FILE=/var/run/exmachina.pid
PLINTH_USER=www-data
PLINTH_GROUP=www-data
test -x $DAEMON || exit 0
test -x $EXMACHINA_DAEMON || exit 0
set -e
. /lib/lsb/init-functions
start_plinth (){
if [ -f $PID_FILE ]; then
echo Already running with a pid of `cat $PID_FILE`.
echo Already running with a pid of `cat $PID_FILE`.
else
$DAEMON --pidfile=$PID_FILE
if [ -f $EXMACHINA_PID_FILE ]; then
echo exmachina was already running with a pid of `cat $EXMACHINA_PID_FILE`.
kill -15 `cat $EXMACHINA_PID_FILE`
rm -rf $EXMACHINA_PID_FILE
fi
SHAREDKEY=`$EXMACHINA_DAEMON --random-key`
touch $PID_FILE
chown $PLINTH_USER:$PLINTH_GROUP $PID_FILE
echo $SHAREDKEY | $EXMACHINA_DAEMON --pidfile=$EXMACHINA_PID_FILE --group=$PLINTH_GROUP || rm $PID_FILE
sleep 0.5
echo $SHAREDKEY | sudo -u $PLINTH_USER -g $PLINTH_GROUP $DAEMON --pidfile=$PID_FILE
fi
}
stop_plinth () {
if [ -f $PID_FILE ]; then
kill -15 `cat $PID_FILE`
kill -15 `cat $PID_FILE` || true
rm -rf $PID_FILE
echo "killed plinth"
else
echo "No pid file at $PID_FILE suggests plinth is not running."
fi
if [ -f $EXMACHINA_PID_FILE ]; then
kill -15 `cat $EXMACHINA_PID_FILE` || true
rm -rf $EXMACHINA_PID_FILE
echo "killed exmachina"
else
echo "No pid file at $EXMACHINA_PID_FILE suggests exmachina is not running."
fi
}
test -x $DAEMON || exit 0
@ -45,5 +75,12 @@ case "$1" in
$0 stop
$0 start
;;
status)
status_of_proc -p $PID_FILE "$DAEMON" plinth && exit 0 || exit $?
;;
*)
echo "Usage: $NAME {start|stop|restart|status}" >&2
exit 1
;;
esac

10
start.sh Executable file
View File

@ -0,0 +1,10 @@
#! /bin/sh
PYTHONPATH=build/exmachina:$PYTHONPATH
export PYTHONPATH
sudo killall exmachina.py
sudo build/exmachina/exmachina.py -v &
python plinth.py
sudo killall exmachina.py

9
test.sh Executable file
View File

@ -0,0 +1,9 @@
#! /bin/sh
PYTHONPATH=build/exmachina:$PYTHONPATH
PYTHONPATH=modules/installed/lib:$PYTHONPATH
PYTHONPATH=vendor:$PYTHONPATH
export PYTHONPATH
python tests/test_user_store.py

86
tests/test_user_store.py Normal file
View File

@ -0,0 +1,86 @@
#! /usr/bin/env python
# -*- mode: python; mode: auto-fill; fill-column: 80 -*-
import user_store
from logger import Logger
import cfg
import unittest
import cherrypy
import plugin_mount
import os
from model import User
cfg.log = Logger()
cherrypy.log.access_file = None
class UserStore(unittest.TestCase):
"""Test each function of user_store to confirm they work as expected"""
def setUp(self):
cfg.user_db = os.path.join(cfg.file_root, "tests/testdata/users");
self.userstore = plugin_mount.UserStoreModule.get_plugins()[0]
def tearDown(self):
for user in self.userstore.get_all():
self.userstore.remove(user[0])
self.userstore.close()
def test_user_does_not_exist(self):
self.assertEqual(self.userstore.exists("notausername"),False)
def test_user_does_exist(self):
self.add_user("isausername", False)
self.assertEqual(self.userstore.exists("isausername"),True)
def test_add_user(self):
self.assertEqual(len(self.userstore.items()),0)
self.add_user("test_user", False)
self.assertEqual(len(self.userstore.items()),1)
def test_user_is_in_expert_group(self):
self.add_user("test_user", True)
self.assertEqual(self.userstore.expert("test_user"),True)
def test_user_is_not_in_expert_group(self):
self.add_user("test_user", False)
self.assertEqual(self.userstore.expert("test_user"),False)
def test_user_removal(self):
self.assertEqual(len(self.userstore.items()),0)
self.add_user("test_user", False)
self.assertEqual(len(self.userstore.items()),1)
self.userstore.remove("test_user")
self.assertEqual(len(self.userstore.items()),0)
def test_get_user_email_attribute(self):
self.add_user("test_user", False,"test@home")
self.assertEqual(self.userstore.attr("test_user","email"),"test@home")
def test_get_user(self):
test_user = self.add_user("test_user", False)
self.assertEqual(self.userstore.get("test_user"),test_user)
def test_get_all_users(self):
self.add_user("test_user1", False)
self.add_user("test_user2", False)
self.assertEqual(len(self.userstore.get_all()),2)
def add_user(self, test_username, add_to_expert_group, email=''):
test_user = self.create_user(test_username, email)
if add_to_expert_group:
test_user = self.add_user_to_expert_group(test_user)
self.userstore.set(test_username,test_user)
return test_user
def create_user(self, username, email=''):
test_user = User()
test_user["username"] = username
test_user["email"] = email
return test_user
def add_user_to_expert_group(self, user):
user["groups"] = ["expert"]
return user
if __name__ == "__main__":
unittest.main()

BIN
tests/testdata/users.sqlite3 vendored Normal file

Binary file not shown.