Commit b29c9cc3 by Owo Sugiana

Add user management

1 parent 029fbfce
0.1.2 2018-10-29
----------------
- Add user management.
- Add menu.
- Additional argument for setup.py develop-use-pip (ex. --proxy).
0.1.1
-----
......
Web Starter
===========
Change directory into your newly created project::
Change directory into your newly created project:
cd web-starter
Create a Python virtual environment::
Create a Python virtual environment:
python3 -m venv ../env
Upgrade packaging tools::
Upgrade packaging tools:
../env/bin/pip install --upgrade pip setuptools
Install required package::
Install required package:
../env/bin/python setup.py develop-use-pip
Set sqlalchemy.url on development.ini and create tables::
Set sqlalchemy.url on development.ini and create tables:
../env/bin/initialize_web_starter_db development.ini
Run your project::
Run your project:
../env/bin/pserve --reload development.ini
......@@ -38,7 +38,10 @@ if sys.argv[1:] and sys.argv[1] == 'develop-use-pip':
bin_ = os.path.split(sys.executable)[0]
pip = os.path.join(bin_, 'pip')
for package in requires:
cmd = [pip, 'install', package]
if sys.argv[2:]:
cmd = [pip, 'install', sys.argv[2], package]
else:
cmd = [pip, 'install', package]
subprocess.call(cmd)
cmd = [sys.executable, sys.argv[0], 'develop']
subprocess.call(cmd)
......@@ -46,7 +49,7 @@ if sys.argv[1:] and sys.argv[1] == 'develop-use-pip':
setup(
name='web_starter',
version='0.0',
version='0.1.2',
description='Web Starter',
long_description=README + '\n\n' + CHANGES,
classifiers=[
......
No preview for this file type
#
# Indonesian translations for PACKAGE package
# This file is distributed under the same license as the PACKAGE package.
# Owo Sugiana <sugiana@gmail.com>, 2018.
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE 1.0\n"
"POT-Creation-Date: 2018-10-28 21:10+0700\n"
"PO-Revision-Date: 2018-10-28 21:11+0700\n"
"Last-Translator: Owo Sugiana <sugiana@gmail.com>\n"
"Language-Team: Indonesian\n"
"Language: id\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Lingua 4.13\n"
#. Default: Home
#: views/templates/layout-menu.pt:40
msgid "Home"
msgstr "Beranda"
#. Default: Admin<dynamic element>
#: views/templates/layout-menu.pt:44
msgid "Admin"
msgstr "Admin"
#. Default: Users
#: views/templates/layout-menu.pt:46
msgid "Users"
msgstr "Pengguna"
#. Default: Add user
#: views/templates/layout-menu.pt:47
msgid "Add user"
msgstr "Tambah pengguna"
#. Default: My account <dynamic element>
#: views/templates/layout-menu.pt:54
msgid "My account"
msgstr "Akun saya"
#. Default: Change password
#: views/templates/layout-menu.pt:56
msgid "Change password"
msgstr "Ubah kata kunci"
#. Default: ${username} logout
#: views/templates/layout-menu.pt:57
msgid "username-logout"
msgstr "${username} keluar"
No preview for this file type
#
# Indonesian translations for PACKAGE package
# This file is distributed under the same license as the PACKAGE package.
# Owo Sugiana <sugiana@gmail.com>, 2018.
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE 1.0\n"
"POT-Creation-Date: 2018-10-27 14:20+0700\n"
"PO-Revision-Date: 2018-10-27 14:49+0700\n"
"Last-Translator: Owo Sugiana <sugiana@gmail.com>\n"
"Language-Team: Indonesian\n"
"Language: id\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Lingua 4.13\n"
#: web_starter/views/user.py:43
msgid "Users"
msgstr "Pengguna"
#: web_starter/views/user.py:78
msgid "Invalid email format"
msgstr "Susunan email tidak benar"
#. Default: Email ${email} already used by user ID ${uid}
#: web_starter/views/user.py:87
msgid "email-already-used"
msgstr "Email ${email} sudah digunakan oleh ID ${uid}"
#. Default: Only a-z, 0-9, and - characters are allowed
#: web_starter/views/user.py:107
msgid "username-only-contain"
msgstr "Hanya boleh karakter a-z, 0-9, dan -"
#. Default: Only a-z or 0-9 at the start and end
#: web_starter/views/user.py:113
msgid "username-first-end-alphanumeric"
msgstr "Awal dan akhir hanya boleh a-z atau 0-9"
#. Default: Username ${username} already used by ID ${uid}
#: web_starter/views/user.py:122
msgid "username-already-used"
msgstr "Nama ${username} sudah digunakan ID ${uid}"
#: web_starter/views/user.py:141
msgid "Email"
msgstr "Email"
#: web_starter/views/user.py:143
msgid "Username"
msgstr "Nama"
#: web_starter/views/user.py:146
msgid "Group"
msgstr "Grup"
#: web_starter/views/user.py:154
msgid "Status"
msgstr "Status"
#: web_starter/views/user.py:165
msgid "Active"
msgstr "Aktif"
#: web_starter/views/user.py:166
msgid "Inactive"
msgstr "Tidak aktif"
#: web_starter/views/user.py:176
msgid "Save"
msgstr "Simpan"
#: web_starter/views/user.py:177 web_starter/views/user.py:299
msgid "Cancel"
msgstr "Batalkan"
#: web_starter/views/user.py:202
msgid "Add user"
msgstr "Tambah pengguna"
#: web_starter/views/user.py:219
msgid "user-added"
msgstr "${email} sudah ditambahkan dan email untuknya sudah dikirim"
#: web_starter/views/user.py:266
msgid "Edit user"
msgstr "Ubah pengguna"
#. Default: ${username} profile updated
#: web_starter/views/user.py:282
msgid "user-updated"
msgstr "Profil ${username} sudah diperbarui"
#: web_starter/views/user.py:298
msgid "Delete"
msgstr "Hapus"
#: web_starter/views/user.py:303
msgid "Delete user"
msgstr "Hapus pengguna"
#. Default: User ${email} ID ${uid} has been deleted
#: web_starter/views/user.py:306
msgid "user-deleted"
msgstr "${email} ID ${uid} sudah dihapus"
msgid "Registered date"
msgstr "Tanggal pendaftaran"
msgid "Last login"
msgstr "Terakhir masuk"
msgid "Edit"
msgstr "Ubah"
msgid "System"
msgstr "Sistem"
msgid "You"
msgstr "Anda"
msgid "Finance"
msgstr "Keuangan"
msgid "Warning"
msgstr "Perhatian"
msgid "warning-delete-user"
msgstr "Hapus ${email} ID ${uid} ?"
#
# SOME DESCRIPTIVE TITLE
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2018.
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE 1.0\n"
"POT-Creation-Date: 2018-10-28 21:10+0700\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Lingua 4.13\n"
#. Default: Home
#: ./views/templates/layout-menu.pt:40
msgid "Home"
msgstr ""
#. Default: Admin<dynamic element>
#: ./views/templates/layout-menu.pt:44
msgid "Admin"
msgstr ""
#. Default: Users
#: ./views/templates/layout-menu.pt:46
msgid "Users"
msgstr ""
#. Default: Add user
#: ./views/templates/layout-menu.pt:47
msgid "Add user"
msgstr ""
#. Default: My account <dynamic element>
#: ./views/templates/layout-menu.pt:54
msgid "My account"
msgstr ""
#. Default: Change password
#: ./views/templates/layout-menu.pt:56
msgid "Change password"
msgstr ""
#. Default: ${username} logout
#: ./views/templates/layout-menu.pt:57
msgid "username-logout"
msgstr ""
#
# SOME DESCRIPTIVE TITLE
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2018.
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE 1.0\n"
"POT-Creation-Date: 2018-10-27 14:20+0700\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Lingua 4.13\n"
#: ./web_starter/views/user.py:43
msgid "Users"
msgstr ""
#: ./web_starter/views/user.py:78
msgid "Invalid email format"
msgstr ""
#. Default: Email ${email} already used by user ID ${uid}
#: ./web_starter/views/user.py:87
msgid "email-already-used"
msgstr ""
#. Default: Only a-z, 0-9, and - characters are allowed
#: ./web_starter/views/user.py:107
msgid "username-only-contain"
msgstr ""
#. Default: Only a-z or 0-9 at the start and end
#: ./web_starter/views/user.py:113
msgid "username-first-end-alphanumeric"
msgstr ""
#. Default: Username ${username} already used by ID ${uid}
#: ./web_starter/views/user.py:122
msgid "username-already-used"
msgstr ""
#: ./web_starter/views/user.py:141
msgid "Email"
msgstr ""
#: ./web_starter/views/user.py:143
msgid "Username"
msgstr ""
#: ./web_starter/views/user.py:146
msgid "Group"
msgstr ""
#: ./web_starter/views/user.py:154
msgid "Status"
msgstr ""
#: ./web_starter/views/user.py:165
msgid "Active"
msgstr ""
#: ./web_starter/views/user.py:166
msgid "Inactive"
msgstr ""
#: ./web_starter/views/user.py:176
msgid "Save"
msgstr ""
#: ./web_starter/views/user.py:177 ./web_starter/views/user.py:299
msgid "Cancel"
msgstr ""
#: ./web_starter/views/user.py:202
msgid "Add user"
msgstr ""
#: ./web_starter/views/user.py:219
msgid "user-added"
msgstr ""
#: ./web_starter/views/user.py:266
msgid "Edit user"
msgstr ""
#. Default: ${username} profile updated
#: ./web_starter/views/user.py:282
msgid "user-updated"
msgstr ""
#: ./web_starter/views/user.py:298
msgid "Delete"
msgstr ""
#: ./web_starter/views/user.py:303
msgid "Delete user"
msgstr ""
#. Default: User ${email} ID ${uid} has been deleted
#: ./web_starter/views/user.py:306
msgid "user-deleted"
msgstr ""
msgid "Registered date"
msgstr ""
msgid "Last login"
msgstr ""
msgid "Edit"
msgstr ""
msgid "System"
msgstr ""
msgid "You"
msgstr ""
msgid "Finance"
msgstr ""
msgid "Warning"
msgstr ""
msgid "warning-delete-user"
msgstr ""
......@@ -9,3 +9,11 @@ from zope.sqlalchemy import ZopeTransactionExtension
DBSession = scoped_session(
sessionmaker(extension=ZopeTransactionExtension()))
Base = declarative_base()
class CommonModel(object):
def to_dict(self):
values = {}
for column in self.__table__.columns:
values[column.name] = getattr(self, column.name)
return values
from pyramid.security import (
Allow,
Authenticated,
ALL_PERMISSIONS,
)
from sqlalchemy import PrimaryKeyConstraint
import ziggurat_foundations.models
......@@ -18,6 +19,7 @@ from ziggurat_foundations import ziggurat_model_init
from . import (
Base,
DBSession,
CommonModel,
)
......@@ -65,7 +67,7 @@ class UserPermission(UserPermissionMixin, Base):
class UserResourcePermission(UserResourcePermissionMixin, Base):
pass
class User(UserMixin, Base):
class User(UserMixin, Base, CommonModel):
# ... your own properties....
pass
......@@ -96,9 +98,10 @@ class RootFactory:
def __init__(self, request):
self.__acl__ = [
(Allow, Authenticated, 'view'),
(Allow, 'group:1', ALL_PERMISSIONS),
]
for gp in DBSession.query(GroupPermission):
acl_name = 'group:{gid}'.format(gid=gp.group_id)
acl_name = 'group:{}'.format(gp.group_id)
self.__acl__.append((Allow, acl_name, gp.perm_name))
......
......@@ -2,9 +2,12 @@ name,path
home,/
login
logout
password
change-password
change-password-done
reset-password
reset-password-sent
login-by-code-failed
user
user-add,/user/add
user-edit,/user/{id}
user-delete,/user/{id}/delete
......@@ -118,6 +118,26 @@ def restore_csv(table, filename):
return True
def append_csv(table, filename, keys):
with get_file(filename) as f:
reader = csv.DictReader(f)
filter_ = dict()
for cf in reader:
for key in keys:
filter_[key] = cf[key]
q = DBSession.query(table).filter_by(**filter_)
found = q.first()
if found:
continue
row = table()
for fieldname in cf:
val = cf[fieldname]
if not val:
continue
setattr(row, fieldname, val)
DBSession.add(row)
def main(argv=sys.argv):
if len(argv) != 2:
usage(argv)
......@@ -136,5 +156,5 @@ def main(argv=sys.argv):
user = q.first()
password = ask_password(user.user_name)
UserService.set_password(user, password)
restore_csv(Group, 'groups.csv')
append_csv(Group, 'groups.csv', ['group_name'])
restore_csv(UserGroup, 'users_groups.csv')
......@@ -38,3 +38,9 @@ body {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.panel {
margin-top: 20px;
}
.alert {
margin-top: 20px;
}
# items = request.POST.items()
def to_dict(items):
d = dict()
values = None
for item in items:
print(item)
key, value = item
if key == '__start__':
fieldname = value
values = []
elif key == '__end__':
d[fieldname] = values
values = None
elif isinstance(values, list):
values.append(value)
else:
d[key] = value
return d
You have registered on our site. Please click the link below to change the
password.
${url}
This link will expire in ${minutes} minutes.
If you did not request this, please ignore it.
......@@ -127,13 +127,11 @@ def view_logout(request):
def view_login_by_code_failed(request):
return dict(title='Login by code failed')
###################
# Change password #
###################
class Password(colander.Schema):
old_password = colander.SchemaNode(
colander.String(), title=_('Old password'),
widget=widget.PasswordWidget())
class ChangePassword(colander.Schema):
new_password = colander.SchemaNode(
colander.String(), title=_('New password'),
widget=widget.PasswordWidget())
......@@ -141,20 +139,17 @@ class Password(colander.Schema):
colander.String(), title=_('Retype new password'),
widget=widget.PasswordWidget())
def password_validator(form, value):
if not UserService.check_password(
form.request.user, value['old_password']):
raise colander.Invalid(form, _('Invalid old password'))
if value['new_password'] != value['retype_password']:
raise colander.Invalid(form, _('Retype mismatch'))
@view_config(
route_name='change-password', renderer='templates/change-password.pt',
permission='view')
def view_change_password(request):
schema = Password(validator=password_validator)
schema = ChangePassword(validator=password_validator)
btn_submit = Button('save', _('Save'))
btn_cancel = Button('cancel', _('Cancel'))
form = Form(schema, buttons=(btn_submit, btn_cancel))
......@@ -185,7 +180,6 @@ def view_change_password_done(request):
##################
# Reset password #
##################
class ResetPassword(colander.Schema):
email = colander.SchemaNode(
colander.String(), title=_('Email'),
......@@ -198,9 +192,7 @@ class ResetPassword(colander.Schema):
def reset_password_validator(form, value):
user = form.user
if not user or \
not user.status or \
not user.security_code:
if not user or not user.status:
raise colander.Invalid(form, _('Invalid email'))
......@@ -208,7 +200,8 @@ def security_code_age(user):
return create_now() - user.security_code_date
def send_email_security_code(request, user, time_remain):
def send_email_security_code(
request, user, time_remain, subject, body_msg_id, body_default_file):
settings = get_settings()
up = urlparse(request.url)
url = '{}://{}/login?code={}'.format(
......@@ -216,14 +209,14 @@ def send_email_security_code(request, user, time_remain):
minutes = int(time_remain.seconds / 60)
data = dict(url=url, minutes=minutes)
here = os.path.abspath(os.path.dirname(__file__))
body_file = os.path.join(here, 'reset-password-body.tpl')
body_file = os.path.join(here, body_default_file)
with open(body_file) as f:
body_tpl = f.read()
body = _('reset-password-body', default=body_tpl, mapping=data)
body = _(body_msg_id, default=body_tpl, mapping=data)
body = request.localizer.translate(body)
sender = '{} <{}>'.format(
settings['mail.sender_name'], settings['mail.username'])
subject = request.localizer.translate(_('Reset password'))
subject = request.localizer.translate(_(subject))
message = Message(
subject=subject, sender=sender, recipients=[user.email], body=body)
mailer = request.registry['mailer']
......@@ -260,7 +253,9 @@ def view_reset_password(request):
resp['form'] = form.render()
return resp
remain = regenerate_security_code(user)
send_email_security_code(request, user, remain)
send_email_security_code(
request, user, remain, 'Reset password', 'reset-password-body',
'reset-password-body.tpl')
return HTTPFound(location=request.route_url('reset-password-sent'))
resp['form'] = form.render()
return resp
......
......@@ -23,6 +23,18 @@
<div class="container">
<div tal:condition="request.session.peek_flash()">
<div class="alert alert-success" tal:repeat="message request.session.pop_flash()">
${message}
</div>
</div>
<div tal:condition="request.session.peek_flash('error')">
<div class="alert alert-danger" tal:repeat="message request.session.pop_flash('error')">
${message}
</div>
</div>
<div metal:define-slot="content"/>
</div> <!-- /container -->
......
<!DOCTYPE html>
<html lang="en">
<html lang="en" i18n:domain="menu">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="">
<meta name="author" content="">
<link rel="shortcut icon" href="/static/img/favicon.png">
<link rel="shortcut icon" href="/static/pyramid-16x16.png">
<title tal:content="request.title" />
<title tal:content="title"/>
<!-- Bootstrap core CSS -->
<link href="/deform_static/css/bootstrap.min.css" rel="stylesheet">
<link href="/deform_static/css/form.css" rel="stylesheet">
<link href="/deform_static/css/bootstrap.min.css" rel="stylesheet"/>
<link href="/deform_static/css/form.css" rel="stylesheet"/>
<!-- Custom styles for this template -->
<link href="/static/css/navbar-fixed-top.css" rel="stylesheet">
<link href="/static/css/theme.css" rel="stylesheet">
<link href="/static/signin.css" rel="stylesheet">
</head>
<body>
<!-- Fixed navbar -->
<div class="navbar navbar-default navbar-fixed-top" role="navigation">
<div class="navbar navbar-default navbar-fixed-top" role="navigation"
i18n:domain="menu">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
......@@ -35,30 +36,28 @@
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li tal:attributes="class request.path == '/' and 'active'"><a href="/">Home</a></li>
<li tal:attributes="class request.path == '/' and 'active'">
<a href="/" i18n:translate="Home">Home</a></li>
<li class="dropdown" tal:attributes="class request.matched_route.name in ['imgw-message', 'imgw-agent'] and 'active'">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">IM <b class="caret"></b></a>
<ul class="dropdown-menu">
<li><a href="/imgw/message">Pesan</a></li>
<li><a href="/imgw/message/add">Kirim pesan</a></li>
<li><a href="/imgw/agent">Agent</a></li>
</ul>
</li>
<li class="dropdown" tal:attributes="class request.matched_route.name in ['user', 'user-add', 'user-edit', 'user-delete'] and 'active'">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Admin <b class="caret"></b></a>
<a href="#" class="dropdown-toggle" data-toggle="dropdown"
i18n:translate="Admin">Admin<b class="caret"></b></a>
<ul class="dropdown-menu">
<li><a href="/bank">Mutasi Transaksi Bank</a></li>
<li><a href="/user">User</a></li>
<li><a href="/user" i18n:translate="Users">Users</a></li>
<li><a href="/user/add" i18n:translate="Add user">Add user</a></li>
</ul>
</li>
<li class="dropdown" tal:attributes="class request.path in ['/password'] and 'active'">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">My Account <b class="caret"></b></a>
<li class="dropdown" tal:attributes="class request.path in
['/change-password'] and 'active'">
<a href="#" class="dropdown-toggle" data-toggle="dropdown"
i18n:translate="My account">My account <b class="caret"></b></a>
<ul class="dropdown-menu">
<li><a href="/logout">${request.user.nice_username()} Logout</a></li>
<li><a href="/password">Change password</a></li>
<li><a href="/change-password" i18n:translate="Change password">Change password</a></li>
<li><a href="/logout" i18n:translate="username-logout">
<span tal:replace="request.user.user_name" i18n:name="username"/> logout
</a>
</li>
</ul>
</li>
</ul>
......@@ -67,15 +66,20 @@
</div>
<div class="container">
<div tal:condition="request.session.peek_flash()">
<div class="alert alert-success" tal:repeat="message request.session.pop_flash()">${message}</div>
<div class="alert alert-success" tal:repeat="message request.session.pop_flash()">
${message}
</div>
</div>
<div tal:condition="request.session.peek_flash('error')">
<div class="alert alert-danger" tal:repeat="message request.session.pop_flash('error')">${message}</div>
<div class="alert alert-danger" tal:repeat="message request.session.pop_flash('error')">
${message}
</div>
</div>
<div metal:define-slot="content"></div>
<div metal:define-slot="content"/>
</div> <!-- /container -->
......@@ -84,7 +88,9 @@
<!-- Placed at the end of the document so the pages load faster -->
<script type="text/javascript" src="/deform_static/scripts/jquery-2.0.3.min.js"></script>
<script type="text/javascript" src="/deform_static/scripts/bootstrap.min.js"></script>
<script type="text/javascript" src="/deform_static/scripts/deform.js"></script>
<script type="text/javascript" src="/deform_static/scripts/deform.js"></script>
<div metal:define-slot="content-script"/>
</body>
</html>
<div metal:use-macro="load: ../layout-menu.pt">
<div metal:fill-slot="content" i18n:domain="user">
<h1 i18n:translate="Add user">Add User</h1>
<div tal:content="structure form"/>
</div>
</div>
<div metal:use-macro="load: ../layout-menu.pt">
<div metal:fill-slot="content" i18n:domain="user">
<div class="panel panel-danger">
<div class="panel-heading">
<h3 class="panel-title" i18n:translate="Warning">Warning</h3>
</div>
<div class="panel-body" i18n:translate="warning-delete-user">
Delete <span tal:replace="user.email" i18n:name="email"/>
ID <span tal:replace="user.id" i18n:name="uid"/> ?
</div>
</div>
<div tal:content="structure form"/>
</div>
</div>
<div metal:use-macro="load: ../layout-menu.pt">
<div metal:fill-slot="content" i18n:domain="user">
<h1 i18n:translate="Edit user">Edit user</h1>
<div tal:content="structure form"/>
</div>
</div>
<div metal:use-macro="load: ../layout-menu.pt">
<div metal:fill-slot="content" i18n:domain="user">
<h1 i18n:translate="Users">Users</h1>
<table class="table table-striped table-hover">
<thead>
<tr>
<th i18n:translate="Email">Email</th>
<th i18n:translate="Username">Username</th>
<th i18n:translate="Status">Status</th>
<th i18n:translate="Last login">Last login</th>
<th i18n:translate="Registered date">Registered date</th>
<th colspan="2"/>
</tr>
</thead>
<tbody>
<tr tal:repeat="user users">
<td tal:content="user.email"/>
<td tal:content="user.user_name"/>
<td tal:condition="user.status" i18n:translate="Active">Active</td>
<td tal:condition="not user.status"/>
<td tal:condition="user.last_login_date"
tal:content="user.last_login_date.strftime('%d-%m-%Y
%H:%M:%S %z')"/>
<td tal:condition="not user.last_login_date"/>
<td tal:content="user.registered_date.strftime('%d-%m-%Y %H:%M:%S %z')"/>
<td>
<a href="/user/${user.id}" i18n:translate="Edit">Edit</a>
</td>
<td tal:condition="user.id > 1 and user.id != request.user.id">
<a href="/user/${user.id}/delete" i18n:translate="Delete">Delete</a>
</td>
<td tal:condition="user.id <= 1" i18n:translate="System">System</td>
<td tal:condition="user.id > 1 and user.id == request.user.id" i18n:translate="You">You</td>
</tr>
</tbody>
</table>
</div>
</div>
import re
from email.utils import parseaddr
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from pyramid.i18n import TranslationStringFactory
import colander
from deform import (
Form,
ValidationFailure,
Button,
)
from deform.widget import (
SelectWidget,
CheckboxChoiceWidget,
PasswordWidget,
HiddenWidget,
)
from ..models import DBSession
from ..models.ziggurat import (
User,
Group,
UserGroup,
)
from ..tools.deform import to_dict
from ..tools.waktu import create_now
from .login import (
regenerate_security_code,
send_email_security_code,
)
_ = TranslationStringFactory('user')
########
# List #
########
@view_config(
route_name='user', renderer='templates/user/list.pt',
permission='user-edit')
def view_list(request):
q = DBSession.query(User).order_by(User.email)
return dict(title=_('Users'), users=q)
#######
# Add #
#######
@colander.deferred
def deferred_status(node, kw):
values = kw.get('status_list', [])
return SelectWidget(values=values)
@colander.deferred
def deferred_group(node, kw):
values = kw.get('group_list', [])
return CheckboxChoiceWidget(values=values)
class Validator:
def __init__(self, request):
self.request = request
if 'id' in self.request.matchdict:
q = DBSession.query(User).filter_by(id=self.request.matchdict['id'])
self.user = q.first()
else:
self.user = None
class EmailValidator(colander.Email, Validator):
def __init__(self, request):
colander.Email.__init__(self)
Validator.__init__(self, request)
def __call__(self, node, value):
if self.match_object.match(value) is None:
raise colander.Invalid(node, _('Invalid email format'))
email = value.lower()
if self.user and self.user.email == email:
return
q = DBSession.query(User).filter_by(email=email)
found = q.first()
if not found:
return
data = dict(email=email, uid=found.id)
ts = _(
'email-already-used',
default='Email ${email} already used by user ID ${uid}',
mapping=data)
raise colander.Invalid(node, ts)
REGEX_ONLY_CONTAIN = re.compile('([a-z0-9-]*)')
REGEX_BEGIN_END_ALPHANUMERIC = re.compile('^[a-z0-9]+(?:[-][a-z0-9]+)*$')
class UsernameValidator(Validator):
def __init__(self, request):
Validator.__init__(self, request)
def __call__(self, node, value):
username = value.lower()
if self.user and self.user.user_name == username:
return
match = REGEX_ONLY_CONTAIN.search(username)
if not match or match.group(1) != username:
ts = _(
'username-only-contain',
default='Only a-z, 0-9, and - characters are allowed')
raise colander.Invalid(node, ts)
match = REGEX_BEGIN_END_ALPHANUMERIC.search(username)
if not match:
ts = _(
'username-first-end-alphanumeric',
default='Only a-z or 0-9 at the start and end')
raise colander.Invalid(node, ts)
q = DBSession.query(User).filter_by(user_name=username)
found = q.first()
if not found:
return
data = dict(username=username, uid=found.id)
ts = _(
'username-already-used',
default='Username ${username} already used by ID ${uid}',
mapping=data)
raise colander.Invalid(node, ts)
@colander.deferred
def deferred_email_validator(node, kw):
return EmailValidator(kw['request'])
@colander.deferred
def deferred_username_validator(node, kw):
return UsernameValidator(kw['request'])
class AddSchema(colander.Schema):
email = colander.SchemaNode(
colander.String(), title=_('Email'),
validator=deferred_email_validator)
user_name = colander.SchemaNode(colander.String(), title=_('Username'),
validator=deferred_username_validator)
groups = colander.SchemaNode(
colander.Set(), widget=deferred_group, title=_('Group'))
class EditSchema(AddSchema):
id = colander.SchemaNode(
colander.String(), missing=colander.drop,
widget=HiddenWidget(readonly=True))
status = colander.SchemaNode(
colander.String(), widget=deferred_status, title=_('Status'))
class MyEditSchema(AddSchema):
id = colander.SchemaNode(
colander.String(), missing=colander.drop,
widget=HiddenWidget(readonly=True))
def get_form(request, class_form):
status_list = (
(1, _('Active')),
(0, _('Inactive')))
group_list = []
q = DBSession.query(Group).order_by(Group.group_name)
for row in q:
group = (str(row.id), _(row.group_name))
group_list.append(group)
schema = class_form()
schema = schema.bind(
status_list=status_list, group_list=group_list, request=request)
schema.request = request
btn_save = Button('save', _('Save'))
btn_cancel = Button('cancel', _('Cancel'))
return Form(schema, buttons=(btn_save, btn_cancel))
def insert(request, values):
user = User()
user.email = values['email'].lower()
user.user_name = values['user_name'].lower()
user.security_code_date = create_now()
remain = regenerate_security_code(user)
DBSession.add(user)
DBSession.flush()
for gid in values['groups']:
ug = UserGroup(user_id=user.id, group_id=gid)
DBSession.add(ug)
return user, remain
@view_config(
route_name='user-add', renderer='templates/user/add.pt',
permission='user-edit')
def view_add(request):
form = get_form(request, AddSchema)
resp = dict(title=_('Add user'))
if not request.POST:
resp['form'] = form.render()
return resp
if 'save' not in request.POST:
return HTTPFound(location=request.route_url('user'))
items = request.POST.items()
try:
c = form.validate(items)
except ValidationFailure:
resp['form'] = form.render()
return resp
user, remain = insert(request, dict(c.items()))
send_email_security_code(
request, user, remain, 'Welcome new user', 'email-new-user',
'email-new-user.tpl')
data = dict(email=user.email)
ts = _(
'user-added',
default='${email} has been added and the email has been sent.',
mapping=data)
request.session.flash(ts)
return HTTPFound(location=request.route_url('user'))
########
# Edit #
########
def user_group_set(user):
q = DBSession.query(UserGroup).filter_by(user_id=user.id)
r = []
for ug in q:
r.append(str(ug.group_id))
return set(r)
def update(request, values, user):
fnames = ['email', 'user_name']
user.email = values['email'].lower()
user.user_name = values['user_name'].lower()
if user.id != request.user.id:
user.status = values['status']
DBSession.add(user)
existing = user_group_set(user)
unused = existing - values['groups']
if unused:
q = DBSession.query(UserGroup).filter_by(user_id=user.id).filter(
UserGroup.group_id.in_([2]))
q.delete(synchronize_session=False)
new = values['groups'] - existing
for gid in new:
ug = UserGroup(user_id=user.id, group_id=gid)
DBSession.add(ug)
@view_config(
route_name='user-edit', renderer='templates/user/edit.pt',
permission='user-edit')
def view_edit(request):
q = DBSession.query(User).filter_by(id=request.matchdict['id'])
user = q.first()
if not user:
return HTTPNotFound()
if user.id == request.user.id:
form = get_form(request, MyEditSchema)
else:
form = get_form(request, EditSchema)
resp = dict(title=_('Edit user'))
if not request.POST:
d = user.to_dict()
d['groups'] = user_group_set(user)
resp['form'] = form.render(appstruct=d)
return resp
if 'save' not in request.POST:
return HTTPFound(location=request.route_url('user'))
items = request.POST.items()
try:
c = form.validate(items)
except ValidationFailure:
resp['form'] = form.render()
return resp
update(request, dict(c.items()), user)
data = dict(username=user.user_name)
ts = _('user-updated', default='${username} profile updated', mapping=data)
request.session.flash(ts)
return HTTPFound(location=request.route_url('user'))
##########
# Delete #
##########
@view_config(
route_name='user-delete', renderer='templates/user/delete.pt',
permission='user-edit')
def view_delete(request):
q = DBSession.query(User).filter_by(id=request.matchdict['id'])
user = q.first()
if not user:
return HTTPNotFound()
btn_delete = Button('delete', _('Delete'))
btn_cancel = Button('cancel', _('Cancel'))
buttons = (btn_delete, btn_cancel)
form = Form(colander.Schema(), buttons=buttons)
if not request.POST:
return dict(title=_('Delete user'), user=user, form=form.render())
if 'delete' in request.POST:
data = dict(uid=user.id, email=user.email)
ts = _(
'user-deleted',
default='User ${email} ID ${uid} has been deleted',
mapping=data)
q.delete()
request.session.flash(ts)
return HTTPFound(location=request.route_url('user'))
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!