diff --git a/media/css/seahub.css b/media/css/seahub.css
index b0b4608ca7..19c735d5f0 100644
--- a/media/css/seahub.css
+++ b/media/css/seahub.css
@@ -1198,6 +1198,7 @@ textarea:-moz-placeholder {/* for FF */
.tabnav,
.repo-file-list-topbar,
.commit-list-topbar,
+.file-audit-list-topbar,
#dir-view .repo-op,
.wiki-top {
padding:9px 10px;
@@ -1219,6 +1220,7 @@ textarea:-moz-placeholder {/* for FF */
position:relative;
}
.commit-list-topbar,
+.file-audit-list-topbar,
.repo-file-list-topbar {
margin-bottom:0;
}
@@ -1822,6 +1824,7 @@ textarea:-moz-placeholder {/* for FF */
margin: 2px 0 5px;
}
.repo-file-list-outer-container,
+.file-audit-list-outer-container,
.commit-list-outer-container {
padding:3px;
background:#eee;
@@ -1829,6 +1832,7 @@ textarea:-moz-placeholder {/* for FF */
margin:10px 0 25px;
}
.repo-file-list-inner-container,
+.file-audit-list-inner-container,
.commit-list-inner-container {
min-height:250px;
background:#fff;
@@ -1838,10 +1842,12 @@ textarea:-moz-placeholder {/* for FF */
.repo-file-list-not-show {
padding-left:10px;
}
+.file-audit-list-inner-container .file-audit-list-topbar,
.commit-list-inner-container .commit-list-topbar,
.repo-file-list-inner-container .repo-file-list-topbar {
padding:8px 10px;
}
+.file-audit-list-topbar .path,
.commit-list-topbar .path,
.repo-file-list-topbar .path {
font-size:14px;
@@ -2181,12 +2187,15 @@ textarea:-moz-placeholder {/* for FF */
#back {
margin-top:3px;
}
+.file-audit-list,
.commit-list {
margin:0 0 20px;
}
+.file-audit-list .user,
.commit-list .time {
padding-left:10px;
}
+.file-audit-list .avatar,
.commit-list .avatar {
border-radius:2px;
}
diff --git a/seahub/templates/file_access.html b/seahub/templates/file_access.html
new file mode 100644
index 0000000000..fadf2dc29c
--- /dev/null
+++ b/seahub/templates/file_access.html
@@ -0,0 +1,91 @@
+{% extends base_template %}
+{% load seahub_tags avatar_tags i18n %}
+{% load url from future %}
+
+{% block sub_title %}{% trans "Access Log" %} - {% endblock %}
+
+{% block main_panel %}
+
{{ filename }} {% trans "Access Log" %}
+
+
+
+
+ {% trans 'Current Path:' %}
+ {% for name, link in zipped %}
+ {% if not forloop.last %}
+ {{ name }} /
+ {% else %}
+ {{ name }}
+ {% endif %}
+ {% endfor %}
+
+
+
+ {% if events %}
+
+
+ {% trans "User" %} |
+ {% trans "Type" %} |
+ {% trans "IP / Device" %} |
+ {% trans "Date" %} |
+
+
+ {% for e in events %}
+
+
+ {% avatar e.user 16 %}
+ {% if e.user %}
+ {{ e.user }}
+ {% else %}
+ {% trans "Anonymous User" %}
+ {% endif %}
+ |
+ {{ e.event_type }} |
+
+ {% if e.show_device %}
+ {{ e.ip }} / {{ e.show_device }}
+ {% else %}
+ {{ e.ip }}
+ {% endif %}
+ |
+ {{ e.time|date:"Y-m-d G:i:s" }} |
+
+ {% endfor %}
+
+
+
+ {% if current_page != 1 %}
+
{% trans "Previous" %}
+ {% endif %}
+ {% if page_next %}
+
{% trans "Next" %}
+ {% endif %}
+ {% if current_page != 1 or page_next %}
+ |
+ {% endif %}
+
{% trans "Per page: " %}
+ {% if per_page == 25 %}
+
25
+ {% else %}
+
25
+ {% endif %}
+ {% if per_page == 50 %}
+
50
+ {% else %}
+
50
+ {% endif %}
+ {% if per_page == 100 %}
+
100
+ {% else %}
+
100
+ {% endif %}
+
+ {% else %}
+
+
{% trans "No file access infomation" %}
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/seahub/templates/js/templates.html b/seahub/templates/js/templates.html
index 4941359463..221525611a 100644
--- a/seahub/templates/js/templates.html
+++ b/seahub/templates/js/templates.html
@@ -296,6 +296,7 @@
{% trans "Copy" %}
{% trans "History" %}
<% if (is_pro) { %>
+ {% trans "Access Log" %}
<% if (dirent.is_locked) { %>
<% if (dirent.locked_by_me) { %>
{% trans "Unlock" %}
diff --git a/seahub/urls.py b/seahub/urls.py
index 6a3929da6b..73a2659e62 100644
--- a/seahub/urls.py
+++ b/seahub/urls.py
@@ -7,7 +7,7 @@ from seahub.views import *
from seahub.views.file import view_repo_file, view_history_file, view_trash_file,\
view_snapshot_file, file_edit, view_shared_file, view_file_via_shared_dir,\
text_diff, view_priv_shared_file, view_raw_file, view_raw_shared_file, \
- download_file, view_lib_file
+ download_file, view_lib_file, file_access
from seahub.views.repo import repo, repo_history_view, view_shared_dir, \
view_shared_upload_link
from notifications.views import notification_list
@@ -70,6 +70,7 @@ urlpatterns = patterns(
(r'^repo/upload_error/(?P[-0-9a-f]{36})/$', upload_file_error),
(r'^repo/update_error/(?P[-0-9a-f]{36})/$', update_file_error),
url(r'^repo/file_revisions/(?P[-0-9a-f]{36})/$', file_revisions, name='file_revisions'),
+ url(r'^repo/file-access/(?P[-0-9a-f]{36})/$', file_access, name='file_access'),
url(r'^repo/text_diff/(?P[-0-9a-f]{36})/$', text_diff, name='text_diff'),
url(r'^repo/(?P[-0-9a-f]{36})/$', repo, name='repo'),
url(r'^repo/history/(?P[-0-9a-f]{36})/$', repo_history, name='repo_history'),
diff --git a/seahub/utils/__init__.py b/seahub/utils/__init__.py
index b60e612c27..697fd06d03 100644
--- a/seahub/utils/__init__.py
+++ b/seahub/utils/__init__.py
@@ -641,6 +641,30 @@ if EVENTS_CONFIG_FILE:
def get_org_user_events(org_id, username, start, count):
return _get_events(username, start, count, org_id=org_id)
+ def generate_file_audit_event_type(e):
+ return {
+ 'file-download-web': (_('web'), ''),
+ 'file-download-share-link': (_('share-link'),''),
+ 'file-download-api': (_('API'), e.device),
+ 'repo-download-sync': (_('download-sync'), e.device),
+ 'repo-upload-sync': (_('upload-sync'), e.device),
+ }[e.etype]
+
+ def get_file_audit_events_by_path(email, org_id, repo_id, file_path, start, limit):
+ """Return file audit events list by file path. (If no file audit, return 'None')
+
+ For example:
+ ``get_file_audit_events_by_path(email, org_id, repo_id, file_path, 0, 10)`` returns the first 10
+ events.
+ ``get_file_audit_events_by_path(email, org_id, repo_id, file_path, 5, 10)`` returns the 6th through
+ 15th events.
+ """
+ with _get_seafevents_session() as session:
+ events = seafevents.get_file_audit_events_by_path(session,
+ email, org_id, repo_id, file_path, start, limit)
+
+ return events if events else None
+
def get_file_audit_events(email, org_id, repo_id, start, limit):
"""Return file audit events list. (If no file audit, return 'None')
@@ -701,6 +725,10 @@ else:
pass
def get_org_user_events():
pass
+ def generate_file_audit_event_type():
+ pass
+ def get_file_audit_events_by_path():
+ pass
def get_file_audit_events():
pass
def get_file_update_events():
diff --git a/seahub/views/file.py b/seahub/views/file.py
index d0012ab766..dae4fd4baa 100644
--- a/seahub/views/file.py
+++ b/seahub/views/file.py
@@ -56,8 +56,10 @@ from seahub.utils import show_delete_days, render_error, is_org_context, \
render_permission_error, is_pro_version, \
is_textual_file, mkstemp, EMPTY_SHA1, HtmlDiff, \
check_filename_with_rename, gen_inner_file_get_url, normalize_file_path, \
- user_traffic_over_limit, do_md5
+ user_traffic_over_limit, do_md5, get_file_audit_events_by_path, \
+ generate_file_audit_event_type
from seahub.utils.ip import get_remote_ip
+from seahub.utils.timeutils import utc_to_local
from seahub.utils.file_types import (IMAGE, PDF, DOCUMENT, SPREADSHEET, AUDIO,
MARKDOWN, TEXT, OPENDOCUMENT, VIDEO)
from seahub.utils.star import is_file_starred
@@ -1568,3 +1570,81 @@ def view_priv_shared_file(request, token):
'accessible_repos': accessible_repos,
'save_to_link': save_to_link,
}, context_instance=RequestContext(request))
+
+@login_required
+def file_access(request, repo_id):
+ """List file access log.
+ """
+
+ if not is_pro_version():
+ raise Http404
+
+ referer = request.META.get('HTTP_REFERER', None)
+ next = settings.SITE_ROOT if referer is None else referer
+
+ repo = get_repo(repo_id)
+ if not repo:
+ messages.error(request, _("Library does not exist"))
+ return HttpResponseRedirect(next)
+
+ path = request.GET.get('p', None)
+ if not path:
+ messages.error(request, _("Argument missing"))
+ return HttpResponseRedirect(next)
+
+ if not seafile_api.get_file_id_by_path(repo_id, path):
+ messages.error(request, _("File does not exist"))
+ return HttpResponseRedirect(next)
+
+ # perm check
+ if check_folder_permission(request, repo_id, path) != 'rw':
+ messages.error(request, _("Permission denied"))
+ return HttpResponseRedirect(next)
+
+ # Make sure page request is an int. If not, deliver first page.
+ try:
+ current_page = int(request.GET.get('page', '1'))
+ per_page = int(request.GET.get('per_page', '100'))
+ except ValueError:
+ current_page = 1
+ per_page = 100
+
+ start = per_page * (current_page - 1)
+ limit = per_page + 1
+
+ if is_org_context(request):
+ org_id = request.user.org.org_id
+ events = get_file_audit_events_by_path(None, org_id, repo_id, path, start, limit)
+ else:
+ events = get_file_audit_events_by_path(None, 0, repo_id, path, start, limit)
+
+ events = events if events else []
+ if len(events) == per_page + 1:
+ page_next = True
+ else:
+ page_next = False
+
+ events = events[:per_page]
+
+ for ev in events:
+ ev.repo = get_repo(ev.repo_id)
+ ev.filename = os.path.basename(ev.file_path)
+ ev.time = utc_to_local(ev.timestamp)
+ ev.event_type, ev.show_device = generate_file_audit_event_type(ev)
+
+ filename = os.path.basename(path)
+ zipped = gen_path_link(path, repo.name)
+ extra_href = "&p=%s" % urlquote(path)
+ return render_to_response('file_access.html', {
+ 'repo': repo,
+ 'path': path,
+ 'filename': filename,
+ 'zipped': zipped,
+ 'events': events,
+ 'extra_href': extra_href,
+ 'current_page': current_page,
+ 'prev_page': current_page-1,
+ 'next_page': current_page+1,
+ 'per_page': per_page,
+ 'page_next': page_next,
+ }, context_instance=RequestContext(request))
diff --git a/tests/seahub/views/test_file.py b/tests/seahub/views/test_file.py
new file mode 100644
index 0000000000..4471f1e899
--- /dev/null
+++ b/tests/seahub/views/test_file.py
@@ -0,0 +1,144 @@
+import datetime
+
+from mock import patch
+
+from django.core.urlresolvers import reverse
+
+from seahub.test_utils import BaseTestCase
+
+
+class FileAccessLogTest(BaseTestCase):
+
+ def setUp(self):
+ self.login_as(self.user)
+ self.file_path = self.file
+ self.repo_id = self.repo.id
+
+ def tearDown(self):
+ self.remove_repo()
+
+ def generate_file_audit_event_type(self, e):
+ return {
+ 'file-download-web': ('web', ''),
+ 'file-download-share-link': ('share-link',''),
+ 'file-download-api': ('API', e.device),
+ 'repo-download-sync': ('download-sync', e.device),
+ 'repo-upload-sync': ('upload-sync', e.device),
+ }[e.etype]
+
+ @patch('seahub.views.file.is_pro_version')
+ def test_can_not_render_if_not_pro(self, mock_is_pro_version):
+ mock_is_pro_version.return_value = False
+
+ url = reverse('file_access', args=[self.repo_id]) + '?p=' + self.file_path
+ resp = self.client.get(url)
+ self.assertEqual(404, resp.status_code)
+
+ @patch('seahub.views.file.generate_file_audit_event_type')
+ @patch('seahub.views.file.get_file_audit_events_by_path')
+ @patch('seahub.views.file.is_pro_version')
+ def test_can_show_web_type(self, mock_is_pro_version,
+ mock_get_file_audit_events_by_path, mock_generate_file_audit_event_type):
+
+ etype = 'file-download-web'
+ event = Event(self.user.email, self.repo_id, self.file_path, etype)
+
+ mock_is_pro_version.return_value = True
+ mock_get_file_audit_events_by_path.return_value = [event]
+ mock_generate_file_audit_event_type.side_effect = self.generate_file_audit_event_type
+
+ url = reverse('file_access', args=[self.repo_id]) + '?p=' + self.file_path
+ resp = self.client.get(url)
+ self.assertEqual(200, resp.status_code)
+ self.assertTemplateUsed(resp, 'file_access.html')
+ self.assertContains(resp, 'web')
+
+ @patch('seahub.views.file.generate_file_audit_event_type')
+ @patch('seahub.views.file.get_file_audit_events_by_path')
+ @patch('seahub.views.file.is_pro_version')
+ def test_can_show_share_link_type(self, mock_is_pro_version,
+ mock_get_file_audit_events_by_path, mock_generate_file_audit_event_type):
+
+ etype = 'file-download-share-link'
+ event = Event(self.user.email, self.repo_id, self.file_path, etype)
+
+ mock_is_pro_version.return_value = True
+ mock_get_file_audit_events_by_path.return_value = [event]
+ mock_generate_file_audit_event_type.side_effect = self.generate_file_audit_event_type
+
+ url = reverse('file_access', args=[self.repo_id]) + '?p=' + self.file_path
+ resp = self.client.get(url)
+ self.assertEqual(200, resp.status_code)
+ self.assertTemplateUsed(resp, 'file_access.html')
+ self.assertContains(resp, 'share-link')
+
+ @patch('seahub.views.file.generate_file_audit_event_type')
+ @patch('seahub.views.file.get_file_audit_events_by_path')
+ @patch('seahub.views.file.is_pro_version')
+ def test_can_show_api_type(self, mock_is_pro_version,
+ mock_get_file_audit_events_by_path, mock_generate_file_audit_event_type):
+
+ etype = 'file-download-api'
+ event = Event(self.user.email, self.repo_id, self.file_path, etype)
+
+ mock_is_pro_version.return_value = True
+ mock_get_file_audit_events_by_path.return_value = [event]
+ mock_generate_file_audit_event_type.side_effect = self.generate_file_audit_event_type
+
+ url = reverse('file_access', args=[self.repo_id]) + '?p=' + self.file_path
+ resp = self.client.get(url)
+ self.assertEqual(200, resp.status_code)
+ self.assertTemplateUsed(resp, 'file_access.html')
+ self.assertContains(resp, 'API')
+
+ @patch('seahub.views.file.generate_file_audit_event_type')
+ @patch('seahub.views.file.get_file_audit_events_by_path')
+ @patch('seahub.views.file.is_pro_version')
+ def test_can_show_download_sync_type(self, mock_is_pro_version,
+ mock_get_file_audit_events_by_path, mock_generate_file_audit_event_type):
+
+ etype = 'repo-download-sync'
+ event = Event(self.user.email, self.repo_id, self.file_path, etype)
+
+ mock_is_pro_version.return_value = True
+ mock_get_file_audit_events_by_path.return_value = [event]
+ mock_generate_file_audit_event_type.side_effect = self.generate_file_audit_event_type
+
+ url = reverse('file_access', args=[self.repo_id]) + '?p=' + self.file_path
+ resp = self.client.get(url)
+ self.assertEqual(200, resp.status_code)
+ self.assertTemplateUsed(resp, 'file_access.html')
+ self.assertContains(resp, 'download-sync')
+
+ @patch('seahub.views.file.generate_file_audit_event_type')
+ @patch('seahub.views.file.get_file_audit_events_by_path')
+ @patch('seahub.views.file.is_pro_version')
+ def test_can_show_upload_sync_type(self, mock_is_pro_version,
+ mock_get_file_audit_events_by_path, mock_generate_file_audit_event_type):
+
+ etype = 'repo-upload-sync'
+ event = Event(self.user.email, self.repo_id, self.file_path, etype)
+
+ mock_is_pro_version.return_value = True
+ mock_get_file_audit_events_by_path.return_value = [event]
+ mock_generate_file_audit_event_type.side_effect = self.generate_file_audit_event_type
+
+ url = reverse('file_access', args=[self.repo_id]) + '?p=' + self.file_path
+ resp = self.client.get(url)
+ self.assertEqual(200, resp.status_code)
+ self.assertTemplateUsed(resp, 'file_access.html')
+ self.assertContains(resp, 'upload-sync')
+
+
+class Event(object):
+
+ def __init__(self, user, repo_id, file_path, etype):
+
+ self.device = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.80 Safari/537.36'
+ self.ip = '192.168.1.124'
+ self.org_id = -1
+ self.timestamp = datetime.datetime.now()
+ self.user = user
+ self.repo_id = repo_id
+ self.file_path = file_path
+ self.etype = etype