admin-users: add audit page to allow showing user actions in RhodeCode.
- this is a helper filter that allows to filter out admin journal for particular user. - refs #5256
This commit is contained in:
parent
e621d8dd88
commit
a5b6055c00
8 changed files with 237 additions and 98 deletions
|
|
@ -79,6 +79,11 @@ def admin_routes(config):
|
|||
name='edit_user_groups_management_updates',
|
||||
pattern='/users/{user_id:\d+}/edit/edit_user_groups_management/updates')
|
||||
|
||||
# user audit logs
|
||||
config.add_route(
|
||||
name='edit_user_audit_logs',
|
||||
pattern='/users/{user_id:\d+}/edit/audit')
|
||||
|
||||
|
||||
def includeme(config):
|
||||
settings = config.get_settings()
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import logging
|
|||
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
from pyramid.view import view_config
|
||||
|
||||
from rhodecode.lib.helpers import Page
|
||||
from rhodecode_tools.lib.ext_json import json
|
||||
|
||||
from rhodecode.apps._base import BaseAppView
|
||||
|
|
@ -31,6 +33,7 @@ from rhodecode.lib import helpers as h
|
|||
from rhodecode.lib.utils import PartialRenderer
|
||||
from rhodecode.lib.utils2 import safe_int, safe_unicode
|
||||
from rhodecode.model.auth_token import AuthTokenModel
|
||||
from rhodecode.model.user import UserModel
|
||||
from rhodecode.model.user_group import UserGroupModel
|
||||
from rhodecode.model.db import User, or_
|
||||
from rhodecode.model.meta import Session
|
||||
|
|
@ -238,7 +241,6 @@ class AdminUsersView(BaseAppView):
|
|||
|
||||
return HTTPFound(h.route_path('edit_user_auth_tokens', user_id=user_id))
|
||||
|
||||
|
||||
@LoginRequired()
|
||||
@HasPermissionAllDecorator('hg.admin')
|
||||
@view_config(
|
||||
|
|
@ -257,7 +259,6 @@ class AdminUsersView(BaseAppView):
|
|||
|
||||
return self._get_template_context(c)
|
||||
|
||||
|
||||
@LoginRequired()
|
||||
@HasPermissionAllDecorator('hg.admin')
|
||||
@view_config(
|
||||
|
|
@ -282,4 +283,35 @@ class AdminUsersView(BaseAppView):
|
|||
c.active = 'user_groups_management'
|
||||
h.flash(_("Groups successfully changed"), category='success')
|
||||
|
||||
return HTTPFound(h.route_path('edit_user_groups_management', user_id=user_id))
|
||||
return HTTPFound(h.route_path(
|
||||
'edit_user_groups_management', user_id=user_id))
|
||||
|
||||
@LoginRequired()
|
||||
@HasPermissionAllDecorator('hg.admin')
|
||||
@view_config(
|
||||
route_name='edit_user_audit_logs', request_method='GET',
|
||||
renderer='rhodecode:templates/admin/users/user_edit.mako')
|
||||
def user_audit_logs(self):
|
||||
_ = self.request.translate
|
||||
c = self.load_default_context()
|
||||
|
||||
user_id = self.request.matchdict.get('user_id')
|
||||
c.user = User.get_or_404(user_id, pyramid_exc=True)
|
||||
self._redirect_for_default_user(c.user.username)
|
||||
c.active = 'audit'
|
||||
|
||||
p = safe_int(self.request.GET.get('page', 1), 1)
|
||||
|
||||
filter_term = self.request.GET.get('filter')
|
||||
c.user_log = UserModel().get_user_log(c.user, filter_term)
|
||||
|
||||
def url_generator(**kw):
|
||||
if filter_term:
|
||||
kw['filter'] = filter_term
|
||||
return self.request.current_route_path(_query=kw)
|
||||
|
||||
c.user_log = Page(c.user_log, page=p, items_per_page=10,
|
||||
url=url_generator)
|
||||
c.filter_term = filter_term
|
||||
return self._get_template_context(c)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,101 +28,17 @@ import logging
|
|||
from pylons import request, tmpl_context as c, url
|
||||
from pylons.controllers.util import redirect
|
||||
from sqlalchemy.orm import joinedload
|
||||
from whoosh.qparser.default import QueryParser, query
|
||||
from whoosh.qparser.dateparse import DateParserPlugin
|
||||
from whoosh.fields import (TEXT, Schema, DATETIME)
|
||||
from sqlalchemy.sql.expression import or_, and_, func
|
||||
|
||||
from rhodecode.model.db import UserLog, PullRequest
|
||||
from rhodecode.lib.user_log_filter import user_log_filter
|
||||
from rhodecode.lib.auth import LoginRequired, HasPermissionAllDecorator
|
||||
from rhodecode.lib.base import BaseController, render
|
||||
from rhodecode.lib.utils2 import safe_int, remove_prefix, remove_suffix
|
||||
from rhodecode.lib.utils2 import safe_int
|
||||
from rhodecode.lib.helpers import Page
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# JOURNAL SCHEMA used only to generate queries in journal. We use whoosh
|
||||
# querylang to build sql queries and filter journals
|
||||
JOURNAL_SCHEMA = Schema(
|
||||
username=TEXT(),
|
||||
date=DATETIME(),
|
||||
action=TEXT(),
|
||||
repository=TEXT(),
|
||||
ip=TEXT(),
|
||||
)
|
||||
|
||||
|
||||
def _journal_filter(user_log, search_term):
|
||||
"""
|
||||
Filters sqlalchemy user_log based on search_term with whoosh Query language
|
||||
http://packages.python.org/Whoosh/querylang.html
|
||||
|
||||
:param user_log:
|
||||
:param search_term:
|
||||
"""
|
||||
log.debug('Initial search term: %r' % search_term)
|
||||
qry = None
|
||||
if search_term:
|
||||
qp = QueryParser('repository', schema=JOURNAL_SCHEMA)
|
||||
qp.add_plugin(DateParserPlugin())
|
||||
qry = qp.parse(unicode(search_term))
|
||||
log.debug('Filtering using parsed query %r' % qry)
|
||||
|
||||
def wildcard_handler(col, wc_term):
|
||||
if wc_term.startswith('*') and not wc_term.endswith('*'):
|
||||
# postfix == endswith
|
||||
wc_term = remove_prefix(wc_term, prefix='*')
|
||||
return func.lower(col).endswith(wc_term)
|
||||
elif wc_term.startswith('*') and wc_term.endswith('*'):
|
||||
# wildcard == ilike
|
||||
wc_term = remove_prefix(wc_term, prefix='*')
|
||||
wc_term = remove_suffix(wc_term, suffix='*')
|
||||
return func.lower(col).contains(wc_term)
|
||||
|
||||
def get_filterion(field, val, term):
|
||||
|
||||
if field == 'repository':
|
||||
field = getattr(UserLog, 'repository_name')
|
||||
elif field == 'ip':
|
||||
field = getattr(UserLog, 'user_ip')
|
||||
elif field == 'date':
|
||||
field = getattr(UserLog, 'action_date')
|
||||
elif field == 'username':
|
||||
field = getattr(UserLog, 'username')
|
||||
else:
|
||||
field = getattr(UserLog, field)
|
||||
log.debug('filter field: %s val=>%s' % (field, val))
|
||||
|
||||
# sql filtering
|
||||
if isinstance(term, query.Wildcard):
|
||||
return wildcard_handler(field, val)
|
||||
elif isinstance(term, query.Prefix):
|
||||
return func.lower(field).startswith(func.lower(val))
|
||||
elif isinstance(term, query.DateRange):
|
||||
return and_(field >= val[0], field <= val[1])
|
||||
return func.lower(field) == func.lower(val)
|
||||
|
||||
if isinstance(qry, (query.And, query.Term, query.Prefix, query.Wildcard,
|
||||
query.DateRange)):
|
||||
if not isinstance(qry, query.And):
|
||||
qry = [qry]
|
||||
for term in qry:
|
||||
field = term.fieldname
|
||||
val = (term.text if not isinstance(term, query.DateRange)
|
||||
else [term.startdate, term.enddate])
|
||||
user_log = user_log.filter(get_filterion(field, val, term))
|
||||
elif isinstance(qry, query.Or):
|
||||
filters = []
|
||||
for term in qry:
|
||||
field = term.fieldname
|
||||
val = (term.text if not isinstance(term, query.DateRange)
|
||||
else [term.startdate, term.enddate])
|
||||
filters.append(get_filterion(field, val, term))
|
||||
user_log = user_log.filter(or_(*filters))
|
||||
|
||||
return user_log
|
||||
|
||||
|
||||
class AdminController(BaseController):
|
||||
|
||||
|
|
@ -139,7 +55,7 @@ class AdminController(BaseController):
|
|||
# FILTERING
|
||||
c.search_term = request.GET.get('filter')
|
||||
try:
|
||||
users_log = _journal_filter(users_log, c.search_term)
|
||||
users_log = user_log_filter(users_log, c.search_term)
|
||||
except Exception:
|
||||
# we want this to crash for now
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ from webob.exc import HTTPBadRequest
|
|||
from pylons import request, tmpl_context as c, response, url
|
||||
from pylons.i18n.translation import _
|
||||
|
||||
from rhodecode.controllers.admin.admin import _journal_filter
|
||||
from rhodecode.controllers.admin.admin import user_log_filter
|
||||
from rhodecode.model.db import UserLog, UserFollowing, User, UserApiKeys
|
||||
from rhodecode.model.meta import Session
|
||||
import rhodecode.lib.helpers as h
|
||||
|
|
@ -89,7 +89,7 @@ class JournalController(BaseController):
|
|||
.options(joinedload(UserLog.repository))
|
||||
#filter
|
||||
try:
|
||||
journal = _journal_filter(journal, c.search_term)
|
||||
journal = user_log_filter(journal, c.search_term)
|
||||
except Exception:
|
||||
# we want this to crash for now
|
||||
raise
|
||||
|
|
|
|||
112
rhodecode/lib/user_log_filter.py
Normal file
112
rhodecode/lib/user_log_filter.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (C) 2010-2017 RhodeCode GmbH
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License, version 3
|
||||
# (only), as published by the Free Software Foundation.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This program is dual-licensed. If you wish to learn more about the
|
||||
# RhodeCode Enterprise Edition, including its added features, Support services,
|
||||
# and proprietary license terms, please see https://rhodecode.com/licenses/
|
||||
|
||||
import logging
|
||||
|
||||
from whoosh.qparser.default import QueryParser, query
|
||||
from whoosh.qparser.dateparse import DateParserPlugin
|
||||
from whoosh.fields import (TEXT, Schema, DATETIME)
|
||||
from sqlalchemy.sql.expression import or_, and_, func
|
||||
|
||||
from rhodecode.model.db import UserLog
|
||||
from rhodecode.lib.utils2 import remove_prefix, remove_suffix
|
||||
|
||||
# JOURNAL SCHEMA used only to generate queries in journal. We use whoosh
|
||||
# querylang to build sql queries and filter journals
|
||||
JOURNAL_SCHEMA = Schema(
|
||||
username=TEXT(),
|
||||
date=DATETIME(),
|
||||
action=TEXT(),
|
||||
repository=TEXT(),
|
||||
ip=TEXT(),
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def user_log_filter(user_log, search_term):
|
||||
"""
|
||||
Filters sqlalchemy user_log based on search_term with whoosh Query language
|
||||
http://packages.python.org/Whoosh/querylang.html
|
||||
|
||||
:param user_log:
|
||||
:param search_term:
|
||||
"""
|
||||
log.debug('Initial search term: %r' % search_term)
|
||||
qry = None
|
||||
if search_term:
|
||||
qp = QueryParser('repository', schema=JOURNAL_SCHEMA)
|
||||
qp.add_plugin(DateParserPlugin())
|
||||
qry = qp.parse(unicode(search_term))
|
||||
log.debug('Filtering using parsed query %r' % qry)
|
||||
|
||||
def wildcard_handler(col, wc_term):
|
||||
if wc_term.startswith('*') and not wc_term.endswith('*'):
|
||||
# postfix == endswith
|
||||
wc_term = remove_prefix(wc_term, prefix='*')
|
||||
return func.lower(col).endswith(wc_term)
|
||||
elif wc_term.startswith('*') and wc_term.endswith('*'):
|
||||
# wildcard == ilike
|
||||
wc_term = remove_prefix(wc_term, prefix='*')
|
||||
wc_term = remove_suffix(wc_term, suffix='*')
|
||||
return func.lower(col).contains(wc_term)
|
||||
|
||||
def get_filterion(field, val, term):
|
||||
|
||||
if field == 'repository':
|
||||
field = getattr(UserLog, 'repository_name')
|
||||
elif field == 'ip':
|
||||
field = getattr(UserLog, 'user_ip')
|
||||
elif field == 'date':
|
||||
field = getattr(UserLog, 'action_date')
|
||||
elif field == 'username':
|
||||
field = getattr(UserLog, 'username')
|
||||
else:
|
||||
field = getattr(UserLog, field)
|
||||
log.debug('filter field: %s val=>%s' % (field, val))
|
||||
|
||||
# sql filtering
|
||||
if isinstance(term, query.Wildcard):
|
||||
return wildcard_handler(field, val)
|
||||
elif isinstance(term, query.Prefix):
|
||||
return func.lower(field).startswith(func.lower(val))
|
||||
elif isinstance(term, query.DateRange):
|
||||
return and_(field >= val[0], field <= val[1])
|
||||
return func.lower(field) == func.lower(val)
|
||||
|
||||
if isinstance(qry, (query.And, query.Term, query.Prefix, query.Wildcard,
|
||||
query.DateRange)):
|
||||
if not isinstance(qry, query.And):
|
||||
qry = [qry]
|
||||
for term in qry:
|
||||
field = term.fieldname
|
||||
val = (term.text if not isinstance(term, query.DateRange)
|
||||
else [term.startdate, term.enddate])
|
||||
user_log = user_log.filter(get_filterion(field, val, term))
|
||||
elif isinstance(qry, query.Or):
|
||||
filters = []
|
||||
for term in qry:
|
||||
field = term.fieldname
|
||||
val = (term.text if not isinstance(term, query.DateRange)
|
||||
else [term.startdate, term.enddate])
|
||||
filters.append(get_filterion(field, val, term))
|
||||
user_log = user_log.filter(or_(*filters))
|
||||
|
||||
return user_log
|
||||
|
|
@ -33,6 +33,7 @@ from sqlalchemy.exc import DatabaseError
|
|||
from sqlalchemy.sql.expression import true, false
|
||||
|
||||
from rhodecode import events
|
||||
from rhodecode.lib.user_log_filter import user_log_filter
|
||||
from rhodecode.lib.utils2 import (
|
||||
safe_unicode, get_current_rhodecode_user, action_logger_generic,
|
||||
AttributeDict, str2bool)
|
||||
|
|
@ -40,7 +41,7 @@ from rhodecode.lib.caching_query import FromCache
|
|||
from rhodecode.model import BaseModel
|
||||
from rhodecode.model.auth_token import AuthTokenModel
|
||||
from rhodecode.model.db import (
|
||||
User, UserToPerm, UserEmailMap, UserIpMap)
|
||||
or_, joinedload, User, UserToPerm, UserEmailMap, UserIpMap, UserLog)
|
||||
from rhodecode.lib.exceptions import (
|
||||
DefaultUserException, UserOwnsReposException, UserOwnsRepoGroupsException,
|
||||
UserOwnsUserGroupsException, NotAllowedToCreateUserError)
|
||||
|
|
@ -847,3 +848,14 @@ class UserModel(BaseModel):
|
|||
Session().commit()
|
||||
|
||||
return
|
||||
|
||||
def get_user_log(self, user, filter_term):
|
||||
user_log = UserLog.query()\
|
||||
.filter(or_(UserLog.user_id == user.user_id,
|
||||
UserLog.username == user.username))\
|
||||
.options(joinedload(UserLog.user))\
|
||||
.options(joinedload(UserLog.repository))\
|
||||
.order_by(UserLog.action_date.desc())
|
||||
|
||||
user_log = user_log_filter(user_log, filter_term)
|
||||
return user_log
|
||||
|
|
|
|||
|
|
@ -37,11 +37,8 @@
|
|||
<li class="${'active' if c.active=='perms_summary' else ''}"><a href="${h.url('edit_user_perms_summary', user_id=c.user.user_id)}">${_('Permissions summary')}</a></li>
|
||||
<li class="${'active' if c.active=='emails' else ''}"><a href="${h.url('edit_user_emails', user_id=c.user.user_id)}">${_('Emails')}</a></li>
|
||||
<li class="${'active' if c.active=='ips' else ''}"><a href="${h.url('edit_user_ips', user_id=c.user.user_id)}">${_('Ip Whitelist')}</a></li>
|
||||
|
||||
<li class="${'active' if c.active=='groups' else ''}">
|
||||
<a href="${h.route_path('edit_user_groups_management', user_id=c.user.user_id)}">${_('User Groups Management')}</a>
|
||||
</li>
|
||||
|
||||
<li class="${'active' if c.active=='groups' else ''}"><a href="${h.route_path('edit_user_groups_management', user_id=c.user.user_id)}">${_('User Groups Management')}</a></li>
|
||||
<li class="${'active' if c.active=='audit' else ''}"><a href="${h.route_path('edit_user_audit_logs', user_id=c.user.user_id)}">${_('User audit')}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
65
rhodecode/templates/admin/users/user_edit_audit.mako
Normal file
65
rhodecode/templates/admin/users/user_edit_audit.mako
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%namespace name="base" file="/base/base.mako"/>
|
||||
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">${_('User Audit Logs')} -
|
||||
${_ungettext('%s entry', '%s entries', c.user_log.item_count) % (c.user_log.item_count)}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
|
||||
${h.form(None, id_="filter_form", method="get")}
|
||||
<input class="q_filter_box ${'' if c.filter_term else 'initial'}" id="j_filter" size="15" type="text" name="filter" value="${c.filter_term or ''}" placeholder="${_('audit filter...')}"/>
|
||||
<input type='submit' value="${_('filter')}" class="btn" />
|
||||
${h.end_form()}
|
||||
<p class="tooltip filterexample" style="position: inherit" title="${h.tooltip(h.journal_filter_help())}">${_('Example Queries')}</p>
|
||||
|
||||
% if c.user_log:
|
||||
<table class="rctable admin_log">
|
||||
<tr>
|
||||
<th>${_('Username')}</th>
|
||||
<th>${_('Action')}</th>
|
||||
<th>${_('Repository')}</th>
|
||||
<th>${_('Date')}</th>
|
||||
<th>${_('From IP')}</th>
|
||||
</tr>
|
||||
|
||||
%for cnt,l in enumerate(c.user_log):
|
||||
<tr class="parity${cnt%2}">
|
||||
<td class="td-user">
|
||||
%if l.user is not None:
|
||||
${base.gravatar_with_user(l.user.email)}
|
||||
%else:
|
||||
${l.username}
|
||||
%endif
|
||||
</td>
|
||||
<td class="td-journalaction">${h.action_parser(l)[0]()}
|
||||
<div class="journal_action_params">
|
||||
${h.literal(h.action_parser(l)[1]())}
|
||||
</div>
|
||||
</td>
|
||||
<td class="td-componentname">
|
||||
%if l.repository is not None:
|
||||
${h.link_to(l.repository.repo_name,h.url('summary_home',repo_name=l.repository.repo_name))}
|
||||
%else:
|
||||
${l.repository_name}
|
||||
%endif
|
||||
</td>
|
||||
|
||||
<td class="td-time">${h.format_date(l.action_date)}</td>
|
||||
<td class="td-ip">${l.user_ip}</td>
|
||||
</tr>
|
||||
%endfor
|
||||
</table>
|
||||
|
||||
<div class="pagination-wh pagination-left">
|
||||
${c.user_log.pager('$link_previous ~2~ $link_next')}
|
||||
</div>
|
||||
% else:
|
||||
${_('No actions yet')}
|
||||
% endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue