Add support for managing and deleting attachments.

Modified substantially by tabbott to fix tons of issues.

Fixes #454.
This commit is contained in:
paxapy 2016-12-28 16:46:42 +03:00 committed by Tim Abbott
parent f528af2be0
commit 9a5179c460
18 changed files with 238 additions and 2 deletions

View File

@ -19,6 +19,7 @@
"page_params": false, "page_params": false,
"status_classes": false, "status_classes": false,
"password_quality": false, "password_quality": false,
"attachments_ui": false,
"csrf_token": false, "csrf_token": false,
"typeahead_helper": false, "typeahead_helper": false,
"popovers": false, "popovers": false,

View File

@ -42,6 +42,7 @@ function render(template_name, args) {
'notification-settings', 'notification-settings',
'bot-settings', 'bot-settings',
'alert-word-settings', 'alert-word-settings',
'attachments-settings',
'ui-settings', 'ui-settings',
]); ]);
}()); }());
@ -252,6 +253,22 @@ function render(template_name, args) {
global.write_handlebars_output("announce_stream_docs", html); global.write_handlebars_output("announce_stream_docs", html);
}()); }());
(function attachment_settings_item() {
var html = '<ul id="attachments">';
var attachments = [
{messages: [], id: 42, name: "foo.txt"},
{messages: [], id: 43, name: "bar.txt"},
];
_.each(attachments, function (attachment) {
var args = {attachment: attachment};
html += render('attachment-item', args);
});
html += "</ul>";
global.write_handlebars_output("attachment-item", html);
var li = $(html).find("li.attachment-item:first");
assert.equal(li.attr('data-attachment'), 42);
}());
(function bankruptcy_modal() { (function bankruptcy_modal() {
var args = { var args = {
unread_count: 99, unread_count: 99,

View File

@ -0,0 +1,30 @@
var attachments_ui = (function () {
var exports = {};
function delete_attachments(attachment) {
channel.del({url: '/json/attachments/' + attachment, idempotent: true});
}
exports.set_up_attachments = function () {
// The settings page must be rendered before this function gets called.
var attachment_list = $('#attachments_list');
_.each(page_params.attachments, function (attachment) {
var li = templates.render('attachment-item', {attachment: attachment});
attachment_list.append(li);
});
$('#attachments_list').on('click', '.remove-attachment', function (event) {
var li = $(event.currentTarget).parents('li');
li.remove();
delete_attachments($(this).data('attachment'));
});
};
return exports;
}());
if (typeof module !== 'undefined') {
module.exports = attachments_ui;
}

View File

@ -199,6 +199,7 @@ function _setup_page() {
$("#ui-settings-status").hide(); $("#ui-settings-status").hide();
alert_words_ui.set_up_alert_words(); alert_words_ui.set_up_alert_words();
attachments_ui.set_up_attachments();
$("#api_key_value").text(""); $("#api_key_value").text("");
$("#get_api_key_box").hide(); $("#get_api_key_box").hide();

View File

@ -462,7 +462,7 @@ input[type=checkbox].inline-block {
position: inherit; position: inherit;
} }
#alert_words_list { #alert_words_list, #attachments_list {
list-style-type: none; list-style-type: none;
margin: auto; margin: auto;
} }
@ -472,7 +472,7 @@ input[type=checkbox].inline-block {
margin-top: 8px; margin-top: 8px;
} }
#alert_words_list .edit-alert-word-buttons { #alert_words_list .edit-alert-word-buttons, #attachments_list .edit-attachment-buttons {
position: absolute; position: absolute;
right: 20px; right: 20px;
top: 5px; top: 5px;

View File

@ -0,0 +1,24 @@
<li class="attachment-item" data-attachment='{{attachment.id}}'>
<div class="attachment-information-box list-container">
<div class="attachment_listing">
<strong>{{attachment.name}}</strong>
</div>
<div class="edit-attachment-buttons">
<button type="submit"
class="button small btn-small btn-danger remove-attachment"
title="{{t 'Delete file' }}" data-attachment="{{attachment.id}}">
<i class="icon-vector-trash"></i>
</button>
</div>
{{#if attachment.messages }}
<ul class="attachment-messages">
{{#each attachment.messages}}
<li>
<a href="/#narrow/id/{{ this.id }}">{{ this.name }}</a>
</li>
{{/each}}
</ul>
{{/if}}
</div>
</li>

View File

@ -0,0 +1,12 @@
<div id="attachments-settings" class="settings-section" data-name="uploaded-files">
<div class="settings-section-title">
<i class="icon-vector-paper-clip settings-section-icon"></i>
{{t "Uploaded files" }}
</div>
<div class="side-padded-container">
<p class="alert-word-settings-note">
{{t 'For each file, we list any messages that link to it.' }}
</p>
</div>
<ul id="attachments_list"></ul>
</div>

View File

@ -11,5 +11,7 @@
{{ partial "alert-word-settings" }} {{ partial "alert-word-settings" }}
{{ partial "attachments-settings" }}
{{ partial "ui-settings" }} {{ partial "ui-settings" }}
</div> </div>

View File

@ -28,6 +28,10 @@
<div class="icon icon-vector-book"></div> <div class="icon icon-vector-book"></div>
<div class="text">{{ _('Custom alert words') }}</div> <div class="text">{{ _('Custom alert words') }}</div>
</li> </li>
<li tabindex="1" data-section="uploaded-files">
<div class="icon icon-vector-paper-clip"></div>
<div class="text">{{ _('Uploaded files') }}</div>
</li>
<li tabindex="1" data-section="zulip-labs"> <li tabindex="1" data-section="zulip-labs">
<i class="icon icon-vector-beaker"></i> <i class="icon icon-vector-beaker"></i>
<div class="text">{{ _('Zulip labs') }}</div> <div class="text">{{ _('Zulip labs') }}</div>

29
zerver/lib/attachments.py Normal file
View File

@ -0,0 +1,29 @@
from __future__ import absolute_import
from django.utils.translation import ugettext as _
from typing import Any, Dict, List
from zerver.lib.request import JsonableError
from zerver.lib.upload import delete_message_image
from zerver.models import Attachment, UserProfile
def user_attachments(user_profile):
# type: (UserProfile) -> List[Dict[str, Any]]
attachments = Attachment.objects.filter(owner=user_profile).prefetch_related('messages')
return [a.to_dict() for a in attachments]
def access_attachment_by_id(user_profile, attachment_id, needs_owner=False):
# type: (UserProfile, int, bool) -> Attachment
query = Attachment.objects.filter(id=attachment_id)
if needs_owner:
query = query.filter(owner=user_profile)
attachment = query.first()
if attachment is None:
raise JsonableError(_("Invalid attachment"))
return attachment
def remove_attachment(user_profile, attachment):
# type: (UserProfile, Attachment) -> None
delete_message_image(attachment.path_id)
attachment.delete()

View File

@ -18,6 +18,7 @@ from typing import (
session_engine = import_module(settings.SESSION_ENGINE) session_engine = import_module(settings.SESSION_ENGINE)
from zerver.lib.alert_words import user_alert_words from zerver.lib.alert_words import user_alert_words
from zerver.lib.attachments import user_attachments
from zerver.lib.narrow import check_supported_events_narrow_filter from zerver.lib.narrow import check_supported_events_narrow_filter
from zerver.lib.request import JsonableError from zerver.lib.request import JsonableError
from zerver.lib.actions import validate_user_access_to_subscribers_helper, \ from zerver.lib.actions import validate_user_access_to_subscribers_helper, \
@ -54,6 +55,9 @@ def fetch_initial_state_data(user_profile, event_types, queue_id):
if want('alert_words'): if want('alert_words'):
state['alert_words'] = user_alert_words(user_profile) state['alert_words'] = user_alert_words(user_profile)
if want('attachments'):
state['attachments'] = user_attachments(user_profile)
if want('message'): if want('message'):
# The client should use get_old_messages() to fetch messages # The client should use get_old_messages() to fetch messages
# starting with the max_message_id. They will get messages # starting with the max_message_id. They will get messages

View File

@ -1140,6 +1140,19 @@ class Attachment(ModelReprMixin, models.Model):
# type: () -> bool # type: () -> bool
return self.messages.count() > 0 return self.messages.count() > 0
def to_dict(self):
# type: () -> Dict[str, Any]
return {
'id': self.id,
'name': self.file_name,
'path_id': self.path_id,
'messages': [{
'id': m.id,
'name': '{m.pub_date:%Y-%m-%d %H:%M} {recipient}/{m.subject}'.format(
recipient=get_display_recipient(m.recipient), m=m)
} for m in self.messages.all()]
}
def get_old_unclaimed_attachments(weeks_ago): def get_old_unclaimed_attachments(weeks_ago):
# type: (int) -> Sequence[Attachment] # type: (int) -> Sequence[Attachment]
# TODO: Change return type to QuerySet[Attachment] # TODO: Change return type to QuerySet[Attachment]

View File

@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
from __future__ import print_function
import mock
import ujson
from typing import Any
from zerver.lib.attachments import user_attachments
from zerver.lib.test_helpers import get_user_profile_by_email
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import Attachment
class AttachmentsTests(ZulipTestCase):
def setUp(self):
# type: () -> None
user = get_user_profile_by_email("cordelia@zulip.com")
self.attachment = Attachment.objects.create(
file_name='test.txt', path_id='foo/bar/test.txt', owner=user)
def test_list_by_user(self):
# type: () -> None
self.login("cordelia@zulip.com")
result = self.client_get('/json/attachments')
self.assert_json_success(result)
user = get_user_profile_by_email("cordelia@zulip.com")
attachments = user_attachments(user)
data = ujson.loads(result.content)
self.assertEqual(data['attachments'], attachments)
@mock.patch('zerver.lib.attachments.delete_message_image')
def test_remove_attachment(self, ignored):
# type: (Any) -> None
self.login("cordelia@zulip.com")
result = self.client_delete('/json/attachments/{pk}'.format(pk=self.attachment.pk))
self.assert_json_success(result)
user = get_user_profile_by_email("cordelia@zulip.com")
attachments = user_attachments(user)
self.assertEqual(attachments, [])
def test_list_another_user(self):
# type: () -> None
self.login("iago@zulip.com")
result = self.client_get('/json/attachments')
self.assert_json_success(result)
data = ujson.loads(result.content)
self.assertEqual(data['attachments'], [])
def test_remove_another_user(self):
# type: () -> None
self.login("iago@zulip.com")
result = self.client_delete('/json/attachments/{pk}'.format(pk=self.attachment.pk))
self.assert_json_error(result, 'Invalid attachment')
user = get_user_profile_by_email("cordelia@zulip.com")
attachments = user_attachments(user)
self.assertEqual(attachments, [self.attachment.to_dict()])
def test_list_unauthenticated(self):
# type: () -> None
result = self.client_get('/json/attachments')
self.assert_json_error(result, 'Not logged in: API authentication or user session required', status_code=401)
def test_delete_unauthenticated(self):
# type: () -> None
result = self.client_delete('/json/attachments/{pk}'.format(pk=self.attachment.pk))
self.assert_json_error(result, 'Not logged in: API authentication or user session required', status_code=401)

View File

@ -1856,6 +1856,7 @@ class HomeTest(ZulipTestCase):
# Keep this list sorted!!! # Keep this list sorted!!!
expected_keys = [ expected_keys = [
"alert_words", "alert_words",
"attachments",
"autoscroll_forever", "autoscroll_forever",
"avatar_source", "avatar_source",
"avatar_url", "avatar_url",

View File

@ -0,0 +1,22 @@
from __future__ import absolute_import
from django.http import HttpRequest, HttpResponse
from zerver.decorator import REQ
from zerver.models import UserProfile
from zerver.lib.validator import check_int
from zerver.lib.response import json_success
from zerver.lib.attachments import user_attachments, remove_attachment, \
access_attachment_by_id
def list_by_user(request, user_profile):
# type: (HttpRequest, UserProfile) -> HttpResponse
return json_success({"attachments": user_attachments(user_profile)})
def remove(request, user_profile, attachment_id=REQ(validator=check_int)):
# type: (HttpRequest, UserProfile, int) -> HttpResponse
attachment = access_attachment_by_id(user_profile, attachment_id,
needs_owner=True)
remove_attachment(user_profile, attachment)
return json_success()

View File

@ -264,6 +264,7 @@ def home_real(request):
furthest_read_time = sent_time_in_epoch_seconds(latest_read), furthest_read_time = sent_time_in_epoch_seconds(latest_read),
save_stacktraces = settings.SAVE_FRONTEND_STACKTRACES, save_stacktraces = settings.SAVE_FRONTEND_STACKTRACES,
alert_words = register_ret['alert_words'], alert_words = register_ret['alert_words'],
attachments = register_ret['attachments'],
muted_topics = register_ret['muted_topics'], muted_topics = register_ret['muted_topics'],
realm_filters = register_ret['realm_filters'], realm_filters = register_ret['realm_filters'],
realm_default_streams = register_ret['realm_default_streams'], realm_default_streams = register_ret['realm_default_streams'],

View File

@ -852,6 +852,7 @@ JS_SPECS = {
'js/message_flags.js', 'js/message_flags.js',
'js/alert_words.js', 'js/alert_words.js',
'js/alert_words_ui.js', 'js/alert_words_ui.js',
'js/attachments_ui.js',
'js/message_store.js', 'js/message_store.js',
'js/server_events.js', 'js/server_events.js',
'js/zulip.js', 'js/zulip.js',

View File

@ -228,6 +228,12 @@ v1_api_and_json_patterns = [
{'PUT': 'zerver.views.reactions.add_reaction_backend', {'PUT': 'zerver.views.reactions.add_reaction_backend',
'DELETE': 'zerver.views.reactions.remove_reaction_backend'}), 'DELETE': 'zerver.views.reactions.remove_reaction_backend'}),
# attachments -> zerver.views.attachments
url(r'^attachments$', rest_dispatch,
{'GET': 'zerver.views.attachments.list_by_user'}),
url(r'^attachments/(?P<attachment_id>[0-9]+)$', rest_dispatch,
{'DELETE': 'zerver.views.attachments.remove'}),
# typing -> zerver.views.typing # typing -> zerver.views.typing
# POST sends a typing notification event to recipients # POST sends a typing notification event to recipients
url(r'^typing$', rest_dispatch, url(r'^typing$', rest_dispatch,