Skip to content

Commit

Permalink
Merge pull request #6 from lebouquetin/master
Browse files Browse the repository at this point in the history
Allow to delete comments in a thread
  • Loading branch information
Tracim committed Dec 24, 2014
2 parents 022b1bd + 24e6285 commit dcba4ce
Show file tree
Hide file tree
Showing 13 changed files with 302 additions and 76 deletions.
11 changes: 11 additions & 0 deletions tracim/development.ini.base
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ resetpassword.smtp_login = smtp.login
resetpassword.smtp_passwd = smtp.password


# Specifies if the update of comments and attached files is allowed (by the owner only).
# Examples:
# 600 means 10 minutes (ie 600 seconds)
# 3600 means 1 hour (60x60 seconds)
#
# Allowed values:
# -1 means that content update is allowed for ever
# 0 means that content update is not allowed
# x means that content update is allowed for x seconds (with x>0)
content.update.allowed.duration = 3600

# The following parameters allow to personalize the home page
# They are html ready (you can put html tags they will be interpreted)
website.title = TRACIM
Expand Down
5 changes: 5 additions & 0 deletions tracim/tracim/config/app_cfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,15 @@ def __setattr__(self, key, value):
# At the time of configuration setup, it can't be evaluated
# We do not show CONTENT in order not to pollute log files
logger.info(self, 'CONFIG: [ {} | {} ]'.format(key, value))
else:
logger.info(self, 'CONFIG: [ {} | <value not shown> ]'.format(key))

self.__dict__[key] = value

def __init__(self):

self.DATA_UPDATE_ALLOWED_DURATION = int(tg.config.get('content.update.allowed.duration', 0))

self.WEBSITE_TITLE = tg.config.get('website.title', 'TRACIM')
self.WEBSITE_HOME_TITLE_COLOR = tg.config.get('website.title.color', '#555')
self.WEBSITE_HOME_IMAGE_URL = tg.lurl('/assets/img/home_illustration.jpg')
Expand Down
78 changes: 76 additions & 2 deletions tracim/tracim/controllers/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import tg
from tg import tmpl_context
from tg.i18n import ugettext as _
from tg.predicates import not_anonymous

import traceback

from tracim.controllers import TIMRestController
Expand All @@ -21,24 +23,32 @@
from tracim.lib.predicates import current_user_is_reader
from tracim.lib.predicates import current_user_is_contributor
from tracim.lib.predicates import current_user_is_content_manager
from tracim.lib.predicates import require_current_user_is_owner

from tracim.model.serializers import Context, CTX, DictLikeClass
from tracim.model.data import ActionDescription
from tracim.model.data import Content
from tracim.model.data import ContentType
from tracim.model.data import Workspace


class UserWorkspaceFolderThreadCommentRestController(TIMRestController):

@property
def _item_type(self):
return ContentType.Comment

@property
def _item_type_label(self):
return _('Comment')

def _before(self, *args, **kw):
TIMRestPathContextSetup.current_user()
TIMRestPathContextSetup.current_workspace()
TIMRestPathContextSetup.current_folder()
TIMRestPathContextSetup.current_thread()

@tg.require(current_user_is_contributor())
@tg.expose()
@tg.require(current_user_is_contributor())
def post(self, content=''):
# TODO - SECURE THIS
workspace = tmpl_context.workspace
Expand All @@ -55,6 +65,70 @@ def post(self, content=''):
tg.flash(_('Comment added'), CST.STATUS_OK)
tg.redirect(next_url)

@tg.expose()
@tg.require(not_anonymous())
def put_delete(self, item_id):
require_current_user_is_owner(int(item_id))

# TODO - CHECK RIGHTS
item_id = int(item_id)
content_api = ContentApi(tmpl_context.current_user)
item = content_api.get_one(item_id, self._item_type, tmpl_context.workspace)

try:

next_url = tg.url('/workspaces/{}/folders/{}/threads/{}').format(tmpl_context.workspace_id,
tmpl_context.folder_id,
tmpl_context.thread_id)
undo_url = tg.url('/workspaces/{}/folders/{}/threads/{}/comments/{}/put_delete_undo').format(tmpl_context.workspace_id,
tmpl_context.folder_id,
tmpl_context.thread_id,
item_id)

msg = _('{} deleted. <a class="alert-link" href="{}">Cancel action</a>').format(self._item_type_label, undo_url)
content_api.delete(item)
content_api.save(item, ActionDescription.DELETION)

tg.flash(msg, CST.STATUS_OK, no_escape=True)
tg.redirect(next_url)

except ValueError as e:
back_url = tg.url('/workspaces/{}/folders/{}/threads/{}').format(tmpl_context.workspace_id,
tmpl_context.folder_id,
tmpl_context.thread_id)
msg = _('{} not deleted: {}').format(self._item_type_label, str(e))
tg.flash(msg, CST.STATUS_ERROR)
tg.redirect(back_url)


@tg.expose()
@tg.require(not_anonymous())
def put_delete_undo(self, item_id):
require_current_user_is_owner(int(item_id))

item_id = int(item_id)
content_api = ContentApi(tmpl_context.current_user, True, True) # Here we do not filter deleted items
item = content_api.get_one(item_id, self._item_type, tmpl_context.workspace)
try:
next_url = tg.url('/workspaces/{}/folders/{}/threads/{}').format(tmpl_context.workspace_id,
tmpl_context.folder_id,
tmpl_context.thread_id)
msg = _('{} undeleted.').format(self._item_type_label)
content_api.undelete(item)
content_api.save(item, ActionDescription.UNDELETION)

tg.flash(msg, CST.STATUS_OK)
tg.redirect(next_url)

except ValueError as e:
logger.debug(self, 'Exception: {}'.format(e.__str__))
back_url = tg.url('/workspaces/{}/folders/{}/threads/{}').format(tmpl_context.workspace_id,
tmpl_context.folder_id,
tmpl_context.thread_id)
msg = _('{} not un-deleted: {}').format(self._item_type_label, str(e))
tg.flash(msg, CST.STATUS_ERROR)
tg.redirect(back_url)


class UserWorkspaceFolderFileRestController(TIMWorkspaceContentRestController):
"""
Expand Down
34 changes: 32 additions & 2 deletions tracim/tracim/lib/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@
"""WebHelpers used in tracim."""

#from webhelpers import date, feedgenerator, html, number, misc, text

import datetime
from markupsafe import Markup
from datetime import datetime

import tg

from tracim.config.app_cfg import CFG

from tracim.lib import app_globals as plag

from tracim.lib import CST
from tracim.lib.base import logger
from tracim.lib.content import ContentApi
from tracim.lib.workspace import WorkspaceApi

Expand Down Expand Up @@ -39,7 +43,7 @@ def user_friendly_file_size(file_size: int):
return '{:.3f} Mo'.format(int(mega_size))

def current_year():
now = datetime.now()
now = datetime.datetime.now()
return now.strftime('%Y')

def formatLongDateAndTime(datetime_object, format=''):
Expand Down Expand Up @@ -150,5 +154,31 @@ def user_role(user, workspace) -> int:

return 0

def delete_label_for_item(item) -> str:
"""
:param item: is a serialized Content item (be carefull; it's not an instance of 'Content')
:return: the delete label to show to the user (in the right language)
"""
return ContentType._DELETE_LABEL[item.type]

def is_item_still_editable(item):
# HACK - D.A - 2014-12-24 - item contains a datetime object!!!
# 'item' is a variable which is created by serialization and it should be an instance of DictLikeClass.
# therefore, it contains strins, integers and booleans (something json-ready or almost json-ready)
#
# BUT, the property 'created' is still a datetime object
#
edit_duration = CFG.get_instance().DATA_UPDATE_ALLOWED_DURATION
if edit_duration<0:
return True
elif edit_duration==0:
return False
else:
time_limit = item.created + datetime.timedelta(0, edit_duration)
logger.warning(is_item_still_editable, 'limit is: {}'.format(time_limit))
if datetime.datetime.now() < time_limit:
return True
return False

from tracim.config.app_cfg import CFG as CFG_ORI
CFG = CFG_ORI.get_instance() # local CFG var is an instance of CFG class found in app_cfg
13 changes: 12 additions & 1 deletion tracim/tracim/lib/predicates.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
# -*- coding: utf-8 -*-

from tg import expose, flash, require, url, lurl, request, redirect, tmpl_context
from tg import abort
from tg import request
from tg import tmpl_context
from tg.i18n import lazy_ugettext as l_
from tg.i18n import ugettext as _
from tg.predicates import Predicate

from tracim.model.data import ContentType
from tracim.lib.base import logger
from tracim.lib.content import ContentApi

from tracim.model.data import UserRoleInWorkspace

Expand Down Expand Up @@ -56,3 +61,9 @@ class current_user_is_workspace_manager(WorkspaceRelatedPredicate):
def minimal_role_level(self):
return UserRoleInWorkspace.WORKSPACE_MANAGER

def require_current_user_is_owner(item_id: int):
current_user = tmpl_context.current_user
item = ContentApi(current_user, True, True).get_one(item_id, ContentType.Any)

if item.owner_id!=current_user.user_id:
abort(403, _('You\'re not allowed to access this resource'))
12 changes: 11 additions & 1 deletion tracim/tracim/model/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,16 @@ class ContentType(object):
'comment': 4,
}

_DELETE_LABEL = {
'dashboard': '',
'workspace': l_('Delete this workspace'),
'folder': l_('Delete this folder'),
'file': l_('Delete this file'),
'page': l_('Delete this page'),
'thread': l_('Delete this thread'),
'comment': l_('Delete this comment'),
}

@classmethod
def icon(cls, type: str):
assert(type in ContentType._ICONS) # DYN_REMOVE
Expand Down Expand Up @@ -429,7 +439,7 @@ def get_last_action(self) -> ActionDescription:
def get_comments(self):
children = []
for child in self.children:
if child.type==ContentType.Comment:
if ContentType.Comment==child.type and not child.is_deleted and not child.is_archived:
children.append(child)
return children

Expand Down
6 changes: 5 additions & 1 deletion tracim/tracim/model/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ def serialize_content_for_menu_api(content: Content, context: Context):
a_attr = { 'href' : context.url(ContentType.fill_url(content)) },
li_attr = { 'title': content.get_label(), 'class': 'tracim-tree-item-is-a-folder' },
type = content.type,
state = { 'opened': False, 'selected': False }
state = { 'opened': True if ContentType.Folder!=content.type else False, 'selected': False }
)
return result

Expand Down Expand Up @@ -405,6 +405,9 @@ def serialize_node_for_page(item: Content, context: Context):
owner = context.toDict(item.owner),
# REMOVE parent = context.toDict(item.parent),
type = item.type,
urls = context.toDict({
'delete': context.url('/workspaces/{wid}/folders/{fid}/{ctype}/{cid}/comments/{commentid}/put_delete'.format(wid = item.workspace_id, fid=item.parent.parent_id, ctype=item.parent.type+'s', cid=item.parent.content_id, commentid=item.content_id))
})
)

if item.type==ContentType.Folder:
Expand Down Expand Up @@ -771,6 +774,7 @@ def serialize_workspace_for_menu_api(workspace: Workspace, context: Context):
def serialize_node_tree_item_for_menu_api_tree(item: NodeTreeItem, context: Context):
if isinstance(item.node, Content):
ContentType.fill_url(item.node)

return DictLikeClass(
id=CST.TREEVIEW_MENU.ID_TEMPLATE__FULL.format(item.node.workspace_id, item.node.content_id),
children=True if ContentType.Folder==item.node.type and len(item.children)<=0 else context.toDict(item.children),
Expand Down
4 changes: 2 additions & 2 deletions tracim/tracim/public/assets/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ iframe { border: 5px solid #b3e7ff; }
.tracim-status-selected { background-color: #EEE; }
.tracim-panel-separator { border-width: 12px 0 0 0; }

.tracim-thread-item {}
.tracim-thread-item-content { margin-left: 12px; border-left: 8px solid #EEE; padding: 0 0.5em; }
.tracim-timeline-item {}
.tracim-timeline-item-content { margin-left: 12px; border-left: 8px solid #EEE; padding: 0 0.5em; }

#tracim-footer-separator { margin-bottom: 30px; }
.pod-footer {
Expand Down
21 changes: 1 addition & 20 deletions tracim/tracim/templates/user_workspace_folder_thread_get_one.mak
Original file line number Diff line number Diff line change
Expand Up @@ -74,26 +74,7 @@ ${WIDGETS.BREADCRUMB('current-page-breadcrumb', fake_api.breadcrumb)}
% endif

% for comment in result.thread.comments:
<div class="tracim-thread-item">
<h5 style="margin: 0;">
<div class="pull-right text-right">
<div class="label" style="font-size: 10px; border: 1px solid #CCC; color: #777; ">
${h.format_short(comment.created)|n}
</div>
<br/>
## SHOW REMOVE ACTION <a class="btn btn-default btn-xs" style="margin-top: 6px;" href="">
## <img src="assets/icons/16x16/places/user-trash.png" title="Supprimer ce commentaire"></a>
</div>

${TIM.ICO(32, comment.icon)}
<span class="tracim-less-visible">${_('<strong>{}</strong> wrote:').format(comment.owner.name)|n}</span>
</h5>
<div class="tracim-thread-item-content">
<div>${comment.content|n}</div>
<br/>
</div>
</div>

${WIDGETS.SECURED_TIMELINE_ITEM(fake_api.current_user, comment)}
% endfor

## <hr class="tracim-panel-separator"/>
Expand Down
25 changes: 25 additions & 0 deletions tracim/tracim/templates/user_workspace_widgets.mak
Original file line number Diff line number Diff line change
Expand Up @@ -264,3 +264,28 @@
% endif
</div>
</%def>

<%def name="SECURED_TIMELINE_ITEM(user, item)">
<div class="tracim-timeline-item">
<h5 style="margin: 0;">
${TIM.ICO(32, item.icon)}
<span class="tracim-less-visible">${_('<strong>{}</strong> wrote:').format(item.owner.name)|n}</span>

<div class="pull-right text-right">
<div class="label" style="font-size: 10px; border: 1px solid #CCC; color: #777; ">
${h.format_short(item.created)|n}
</div>
% if h.is_item_still_editable(item) and item.owner.id==user.id:
<br/>
<div class="btn-group">
<a class="btn btn-default btn-xs" style="margin-top: 8px; padding-bottom: 3px;" href="${item.urls.delete}">${TIM.ICO_TOOLTIP(16, 'status/user-trash-full', h.delete_label_for_item(item))}</a>
</div>
% endif
</div>
</h5>
<div class="tracim-timeline-item-content">
<div>${item.content|n}</div>
<br/>
</div>
</div>
</%def>
40 changes: 40 additions & 0 deletions tracim/tracim/tests/library/test_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-

import datetime

from nose.tools import eq_
from nose.tools import ok_

import tracim.lib.helpers as h
from tracim.model.data import Content
from tracim.model.data import ContentType
from tracim.model.data import Workspace

from tracim.model.serializers import Context
from tracim.model.serializers import CTX
from tracim.model.serializers import DictLikeClass

from tracim.tests import TestStandard



class TestHelpers(TestStandard):

def test_is_item_still_editable(self):
item = DictLikeClass()

h.CFG.DATA_UPDATE_ALLOWED_DURATION = 0
item.created = datetime.datetime.now() - datetime.timedelta(0, 10)
eq_(False, h.is_item_still_editable(item))

h.CFG.DATA_UPDATE_ALLOWED_DURATION = -1
item.created = datetime.datetime.now() - datetime.timedelta(0, 10)
eq_(True, h.is_item_still_editable(item))

h.CFG.DATA_UPDATE_ALLOWED_DURATION = 12
item.created = datetime.datetime.now() - datetime.timedelta(0, 10)
eq_(True, h.is_item_still_editable(item), 'created: {}, now: {}'.format(item.created, datetime.datetime.now())) # This test will pass only if the test duration is less than 120s !!!

h.CFG.DATA_UPDATE_ALLOWED_DURATION = 8
item.created = datetime.datetime.now() - datetime.timedelta(0, 10)
eq_(False, h.is_item_still_editable(item))

0 comments on commit dcba4ce

Please sign in to comment.