Commit 6e3e6a52 authored by Aurélien Bompard's avatar Aurélien Bompard

Merge branch 'delete-email'

parents d2208872 2c3e0a14
......@@ -30,7 +30,7 @@ from django.utils.translation import ugettext_lazy as _
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Submit
from hyperkitty.models import Profile
from hyperkitty.models.profile import Profile
# pylint: disable=too-few-public-methods
......@@ -169,3 +169,9 @@ class PostForm(forms.Form):
class CategoryForm(forms.Form):
category = forms.ChoiceField(label="", required=False)
class MessageDeleteForm(forms.Form):
email = forms.ModelMultipleChoiceField(
queryset=None, widget=forms.ModelMultipleChoiceField.hidden_widget,
)
......@@ -35,7 +35,7 @@ def compute_thread_order_and_depth(thread):
obj = graph.node[msgid]["obj"]
obj.thread_depth = thread_pos["d"]
obj.thread_order = thread_pos["o"]
obj.save()
obj.save(update_fields=["thread_depth", "thread_order"])
thread_pos["d"] += 1
thread_pos["o"] += 1
for succ in sorted(graph.successors(msgid),
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('hyperkitty', '0005_MailingList_list_id'),
]
operations = [
migrations.AlterField(
model_name='thread',
name='category',
field=models.ForeignKey(related_name='threads', on_delete=django.db.models.deletion.SET_NULL, to='hyperkitty.ThreadCategory', null=True),
),
migrations.AlterField(
model_name='thread',
name='starting_email',
field=models.OneToOneField(related_name='started_thread', null=True, on_delete=django.db.models.deletion.SET_NULL, to='hyperkitty.Email'),
),
]
......@@ -268,7 +268,7 @@ class Email(models.Model):
self.save(update_fields=["parent"])
starter = children[0]
starter.parent = None
starter.save()
starter.save(update_fields=["parent"])
children.all().update(parent=starter)
else:
children.update(parent=self.parent)
......@@ -286,7 +286,7 @@ class Email(models.Model):
else:
if thread.starting_email is None:
thread.find_starting_email()
thread.save()
thread.save(update_fields=["starting_email"])
compute_thread_order_and_depth(thread)
......
......@@ -48,9 +48,10 @@ class Thread(models.Model):
mailinglist = models.ForeignKey("MailingList", related_name="threads")
thread_id = models.CharField(max_length=255, db_index=True)
date_active = models.DateTimeField(db_index=True, default=now)
category = models.ForeignKey("ThreadCategory", related_name="threads", null=True)
category = models.ForeignKey("ThreadCategory",
related_name="threads", null=True, on_delete=models.SET_NULL)
starting_email = models.OneToOneField("Email",
related_name="started_thread", null=True)
related_name="started_thread", null=True, on_delete=models.SET_NULL)
class Meta:
unique_together = ("mailinglist", "thread_id")
......
......@@ -137,21 +137,9 @@ ul.list-stats {
li+li {
padding-left: 0.75em;
}
}
.participant:before {
content: "\e609";
color: #BFBFBF;
font-size: 1.14em;
line-height: 1em;
}
.discussion:before {
content: "\e607";
color: #BFBFBF;
font-size: 1.14em;
line-height: 1em;
}
.fa-envelope {
color: #BFBFBF;
.fa {
color: #BFBFBF;
}
}
......
......@@ -163,6 +163,9 @@ $headerNavPadding: 7px;
.list-name {
color: $subheadingColor;
}
ul.list-stats .fa {
color: $subheadingColor;
}
}
// 'new' label in front of list name
......
......@@ -246,6 +246,19 @@ $oddEmailColor: rgb(238, 238, 238);
font-size: 70%;
}
#thread-overview-info, #message-overview-info {
a {
color: #999;
}
.fa {
color: #BFBFBF;
}
.postorius .fa {
color: black;
}
}
#thread-overview-info {
#thread-date-info {
padding-bottom: 8px;
......@@ -261,7 +274,6 @@ $oddEmailColor: rgb(238, 238, 238);
margin-bottom: 0;
}
.favorite a {
color: #999;
i {
margin-right: 0.3em;
}
......
......@@ -29,11 +29,11 @@
{% endif %}
<ul class="list-unstyled pull-right list-stats">
<li>
<i class="icomoon participant"></i>
<i class="fa fa-user"></i>
{{ thread.participants_count }}
</li>
<li>
<i class="icomoon discussion"></i>
<i class="fa fa-comment"></i>
{{ thread|num_comments }}
</li>
<li class="hidden-tn">
......
......@@ -137,7 +137,7 @@
</div>
<ul class="list-stats list-unstyled">
<li>
<i class="icomoon participant"></i>
<i class="fa fa-user"></i>
{% if mlist.can_view %}
{{ mlist.recent_participants_count|default_if_none:"..." }}
{% else %}
......@@ -146,7 +146,7 @@
{% trans 'participants' %}
</li>
<li>
<i class="icomoon discussion"></i>
<i class="fa fa-comments"></i>
{% if mlist.can_view %}
{{ mlist.recent_threads_count|default_if_none:"..." }}
{% else %}
......@@ -221,7 +221,7 @@
</div>
<ul class="list-stats list-unstyled">
<li>
<i class="icomoon participant"></i>
<i class="fa fa-user"></i>
{% if mlist.can_view %}
{{ mlist.recent_participants_count|default_if_none:"..." }}
{% else %}
......@@ -230,7 +230,7 @@
{% trans 'participants' %}
</li>
<li>
<i class="icomoon discussion"></i>
<i class="fa fa-comments"></i>
{% if mlist.can_view %}
{{ mlist.recent_threads_count|default_if_none:"..." }}
{% else %}
......
......@@ -14,7 +14,7 @@
{% include 'hyperkitty/fragments/month_list.html' %}
<div class="col-md-7">
<div class="col-tn-12 col-sm-10">
<div class="message-header row">
<div class="col-tn-2 message-back">
......@@ -28,9 +28,16 @@
</div>
</div>
<section id="thread-content">
{% include 'hyperkitty/messages/message.html' with email=message unfolded='True' %}
</section>
<div class="row">
<div class="col-sm-9">
<section id="thread-content">
{% include 'hyperkitty/messages/message.html' with email=message unfolded='True' %}
</section>
</div>
<div class="col-sm-3">
{% include 'hyperkitty/messages/right_col.html' %}
</div>
</div>
</div>
......
{% extends "hyperkitty/base.html" %}
{% load i18n %}
{% load gravatar %}
{% load hk_generic %}
{% block title %}
{% trans "Delete message(s)" %} - {{ block.super }}
{% endblock %}
{% block content %}
<div class="row">
{# {% include 'hyperkitty/fragments/month_list.html' %} #}
<div class="col-sm-10">
<div class="message-header">
<h1>
{% trans "Delete message(s)" %}
</h1>
</div>
<p>
{% blocktrans with count=form.initial.email|length %}
{{ count }} message(s) will be deleted. Do you want to continue?
{% endblocktrans %}
</p>
<form method="post" enctype="multipart/form-data" action="{{ form_action }}">
{% csrf_token %}
<ul class="list-unstyled">
{% for error in form.non_field_errors %}
<li><i class="fa-li fa fa-times-circle"></i> {{ error }}</li>
{% endfor %}
</ul>
<div class="form-group {% if form.email.errors %}has-error{% endif %}">
{{ form.email }}
<span class="help-block">{{ form.email.errors }}</span>
</div>
<p class="buttons">
<button type="submit" class="submit btn btn-primary">{% trans "Delete" %}</button>
{% trans "or" %} <a class="cancel" href="{{ cancel_url }}">{% trans "cancel" %}</a>
</p>
</form>
</div>
</div>
{% endblock %}
{% load i18n %}
{% load gravatar %}
{% load hk_generic %}
{% load cache %}
<!-- right column -->
<section id="message-overview-info">
<p>
<a href="{% url 'hk_thread' threadid=message.thread.thread_id mlist_fqdn=mlist.name %}#{{message.message_id_hash}}">
<i class="fa fa-fw fa-comments"></i>
<span class="hidden-tn hidden-xs">{% trans "Back to the thread" %}</span>
</a>
</p>
<p>
<a href="{% url 'hk_list_overview' mlist_fqdn=mlist.name %}">
<i class="fa fa-fw fa-envelope-o"></i>
{% trans "Back to the list" %}
</a>
</p>
{% if user.is_staff or user.is_superuser %}
<div class="panel panel-danger">
<div class="panel-body">
<div><i class="fa fa-fw fa-trash"></i>
<a href="{% url 'hk_message_delete' mlist_fqdn=mlist.name message_id_hash=message.message_id_hash %}"
>{% trans "Delete this message" %}</a>
</div>
</div>
</div>
{% endif %}
</section>
......@@ -158,8 +158,8 @@
{% trans "the past <strong>30</strong> days:" %}
</p>
<ul class="list-stats list-unstyled">
<li><i class="icomoon participant"></i>{{ mlist.recent_participants_count }} {% trans "participants" %}</li>
<li><i class="icomoon discussion"></i>{{ mlist.recent_threads.count }} {% trans "discussions" %}</li>
<li><i class="fa fa-user"></i>{{ mlist.recent_participants_count }} {% trans "participants" %}</li>
<li><i class="fa fa-comments"></i>{{ mlist.recent_threads.count }} {% trans "discussions" %}</li>
</ul>
<div class="row">
......
......@@ -40,7 +40,7 @@
{% endif %}
<ul class="list-unstyled list-stats col-tn-12 col-sm-6 col-md-4">
<li>
<span class="icomoon discussion"></span>
<span class="fa fa-comment"></span>
{{ emails.paginator.count }} {% trans "messages" %}
</li>
</ul>
......
......@@ -119,8 +119,8 @@
<div class="col-sm-3">
<div class="anchor-link">
<a id="stats"></a>
</div>
<a id="stats"></a>
</div>
{% include 'hyperkitty/threads/right_col.html' %}
</div>
......
......@@ -35,12 +35,12 @@
<ul class="list-unstyled list-stats thread-list-info col-tn-6 col-xs-8">
{% if participants_count %}
<li>
<i class="icomoon participant"></i>
<i class="fa fa-user"></i>
{{ participants_count }} {% trans "participants" %}
</li>
{% endif %}
<li>
<i class="icomoon discussion"></i>
<i class="fa fa-comment"></i>
{{ threads.paginator.count }} {% trans "discussions" %}
</li>
</ul>
......
......@@ -28,25 +28,25 @@
</p>
{% if postorius_installed %}
<p>
<p class="postorius">
<a class="btn btn-default btn-sm" href="{{ postorius_installed }}/{{ mlist.name }}">
<i class="fa fa-inbox"></i>
<i class="fa fa-fw fa-inbox"></i>
Manage subscription</a>
</p>
{% endif %}
<p>
<p class="thread-overview-details">
<div>
<i class="icomoon discussion"></i>
<i class="fa fa-fw fa-comment"></i>
{{ num_comments }} {% trans "comments" %}
</div>
<div>
<i class="icomoon participant"></i>
<i class="fa fa-fw fa-user"></i>
{{ thread.participants_count }} {% trans "participants" %}
</div>
{% if user.is_authenticated %}
<div>
<i class="fa fa-envelope"></i> {{ unread_count }} {% trans "unread" %}
<i class="fa fa-fw fa-envelope"></i> {{ unread_count }} {% trans "unread" %}
<span class="hidden-sm"> {% trans "messages" %}</span>
</div>
{% endif %}
......@@ -58,17 +58,25 @@
<input type="hidden" name="action" value="{{ fav_action }}" />
<p>
<a href="#AddFav" class="notsaved{% if not user.is_authenticated %} disabled" title="{% trans 'You must be logged-in to have favorites.' %}{% endif %}">
<i class="fa fa-star"></i>{% trans "Add to favorites" %}</a>
<i class="fa fa-fw fa-star"></i>{% trans "Add to favorites" %}</a>
<a href="#RmFav" class="saved">
<i class="fa fa-star"></i>{% trans "Remove from favorites" %}</a>
<i class="fa fa-fw fa-star"></i>{% trans "Remove from favorites" %}</a>
</p>
</form>
{% if user.is_staff %}
<p><i class="icon-resize-small"></i>
<a href="{% url 'hk_thread_reattach' mlist_fqdn=mlist.name threadid=thread.thread_id %}"
>{% trans "Reattach this thread" %}</a>
</p>
{% if user.is_staff or user.is_superuser %}
<div class="panel panel-danger">
<div class="panel-body">
<div><i class="fa fa-fw fa-plug"></i>
<a href="{% url 'hk_thread_reattach' mlist_fqdn=mlist.name threadid=thread.thread_id %}"
>{% trans "Reattach this thread" %}</a>
</div>
<div><i class="fa fa-fw fa-trash"></i>
<a href="{% url 'hk_thread_delete' mlist_fqdn=mlist.name threadid=thread.thread_id %}"
>{% trans "Delete this thread" %}</a>
</div>
</div>
</div>
{% endif %}
<div id="tags">
......
......@@ -46,11 +46,11 @@
</div>
<ul class="list-unstyled list-stats col-tn-12 col-sm-5 col-xl-3">
<li>
<i class="icomoon participant"></i>
<i class="fa fa-user"></i>
{{ thread.participants_count }} {% trans "participants" %}
</li>
<li>
<i class="icomoon discussion"></i>
<i class="fa fa-comment"></i>
{{ thread|num_comments }} {% trans "comments" %}
</li>
</ul>
......
......@@ -290,7 +290,37 @@ HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
# the site admins on every HTTP 500 error when DEBUG=False.
# See http://docs.djangoproject.com/en/dev/topics/logging for
# more details on how to customize your logging configuration.
LOGGING = {}
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
},
'loggers': {
'django': {
'handlers': ['console'],
'level': 'INFO',
'propagate': True,
},
'hyperkitty': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': True,
},
},
'formatters': {
'verbose': {
'format': '%(levelname)s %(asctime)s %(module)s: %(message)s'
},
},
'root': {
'handlers': ['console'],
'level': 'INFO',
},
}
#
# HyperKitty-specific
......
......@@ -35,7 +35,9 @@ from django_gravatar.helpers import get_gravatar_url
from hyperkitty.lib.utils import get_message_id_hash
from hyperkitty.lib.incoming import add_to_list
from hyperkitty.models import Email, MailingList
from hyperkitty.models.email import Email
from hyperkitty.models.mailinglist import MailingList
from hyperkitty.models.thread import Thread
from hyperkitty.tests.utils import TestCase, get_flash_messages
......@@ -321,3 +323,89 @@ class MessageViewsTestCase(TestCase):
get_message_id_hash("msg2")))
response = self.client.get(url)
self.assertNotContains(response, "someone-else@example.com", status_code=200)
def test_delete_forbidden(self):
url = reverse('hk_message_delete', args=("list@example.com",
get_message_id_hash("msg")))
response = self.client.post(url)
self.assertEqual(response.status_code, 403)
def test_delete_single_message(self):
self.user.is_staff = True
self.user.save()
msg = Email.objects.get(message_id="msg")
thread_id = msg.thread.pk
url = reverse('hk_message_delete',
args=("list@example.com", msg.message_id_hash))
response = self.client.post(url, {"email": msg.pk})
self.assertRedirects(
response, reverse('hk_list_overview', kwargs={
"mlist_fqdn": "list@example.com"}))
# Flash message
messages = get_flash_messages(response)
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0].tags, "success")
# The message and the thread must be deleted.
self.assertFalse(Email.objects.filter(message_id="msg").exists())
self.assertFalse(Thread.objects.filter(pk=thread_id).exists())
def test_delete_single_in_thread(self):
# Delete an email in a thread that contains other emails
self.user.is_staff = True
self.user.save()
msg = Email.objects.get(message_id="msg")
msg2 = Message()
msg2["From"] = "dummy@example.com"
msg2["Message-ID"] = "<msg2>"
msg2["In-Reply-To"] = "<msg>"
msg2.set_payload("Dummy message")
add_to_list("list@example.com", msg2)
msg2 = Email.objects.get(message_id="msg2")
thread_id = msg.thread.thread_id
url = reverse('hk_message_delete',
args=("list@example.com", msg.message_id_hash))
response = self.client.post(url, {"email": msg.pk})
self.assertRedirects(
response, reverse('hk_thread', kwargs={
"mlist_fqdn": "list@example.com",
"threadid": thread_id}))
# Flash message
messages = get_flash_messages(response)
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0].tags, "success")
# The message must be deleted, but not the other message or the thread.
self.assertFalse(Email.objects.filter(message_id="msg").exists())
self.assertTrue(Email.objects.filter(message_id="msg2").exists())
thread = Thread.objects.get(thread_id=thread_id)
self.assertIsNotNone(thread)
# msg2 must now be the thread starter.
msg2.refresh_from_db()
self.assertIsNone(msg2.parent_id)
self.assertEqual(thread.starting_email.message_id, "msg2")
def test_delete_all_messages_in_thread(self):
self.user.is_staff = True
self.user.save()
msg = Email.objects.get(message_id="msg")
msg2 = Message()
msg2["From"] = "dummy@example.com"
msg2["Message-ID"] = "<msg2>"
msg2["In-Reply-To"] = "<msg>"
msg2.set_payload("Dummy message")
add_to_list("list@example.com", msg2)
msg2 = Email.objects.get(message_id="msg2")
thread_id = msg.thread.pk
url = reverse('hk_thread_delete',
args=("list@example.com", msg.thread.thread_id))
response = self.client.post(url, {"email": [msg.pk, msg2.pk]})
self.assertRedirects(
response, reverse('hk_list_overview', kwargs={
"mlist_fqdn": "list@example.com"}))
# Flash message
messages = get_flash_messages(response)
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0].tags, "success")
# Alls messages and the thread must be deleted.
self.assertFalse(Email.objects.filter(message_id="msg").exists())
self.assertFalse(Email.objects.filter(message_id="msg2").exists())
self.assertFalse(Thread.objects.filter(pk=thread_id).exists())
......@@ -77,6 +77,8 @@ urlpatterns = [
message.vote, name='hk_message_vote'),
url(r'^list/(?P<mlist_fqdn>[^/@]+@[^/@]+)/message/(?P<message_id_hash>\w+)/reply$',
message.reply, name='hk_message_reply'),
url(r'^list/(?P<mlist_fqdn>[^/@]+@[^/@]+)/message/(?P<message_id_hash>\w+)/delete$',
message.delete, name='hk_message_delete'),
url(r'^list/(?P<mlist_fqdn>[^/@]+@[^/@]+)/message/new$',
message.new_message, name='hk_message_new'),
......@@ -97,6 +99,8 @@ urlpatterns = [
thread.reattach, name='hk_thread_reattach'),
url(r'^list/(?P<mlist_fqdn>[^/@]+@[^/@]+)/thread/(?P<threadid>\w+)/reattach-suggest$',
thread.reattach_suggest, name='hk_thread_reattach_suggest'),
url(r'^list/(?P<mlist_fqdn>[^/@]+@[^/@]+)/thread/(?P<threadid>\w+)/delete$',
message.delete, name='hk_thread_delete'),
# Search
......
......@@ -19,7 +19,7 @@
# Author: Aurelien Bompard <abompard@fedoraproject.org>
#
from __future__ import absolute_import, unicode_literals
from __future__ import absolute_import, unicode_literals, print_function
import urllib
import datetime
......@@ -38,8 +38,13 @@ from hyperkitty.lib.mailman import ModeratedListException
from hyperkitty.lib.posting import post_to_list, PostingFailed, reply_subject
from hyperkitty.lib.view_helpers import (
get_months, check_mlist_private, get_posting_form)
from hyperkitty.models import MailingList, Email, Attachment
from hyperkitty.forms import PostForm, ReplyForm
from hyperkitty.models.email import Email, Attachment
from hyperkitty.models.mailinglist import MailingList
from hyperkitty.models.thread import Thread
from hyperkitty.forms import PostForm, ReplyForm, MessageDeleteForm
import logging
logger = logging.getLogger(__name__)
@check_mlist_private
......@@ -225,3 +230,91 @@ def new_message(request, mlist_fqdn):
'months_list': get_months(mlist),
}
return render(request, "hyperkitty/message_new.html", context)
@login_required
@check_mlist_private
def delete(request, mlist_fqdn, threadid=None, message_id_hash=None):
"""Delete messages. """
if not request.user.is_staff and not request.user.is_superuser:
return HttpResponse('You must be a staff member to delete a message',
content_type="text/plain", status=403)
mlist = get_object_or_404(MailingList, name=mlist_fqdn)
if threadid is not None:
thread = get_object_or_404(Thread, thread_id=threadid)
message = None
elif message_id_hash is not None:
message = get_object_or_404(Email,
mailinglist=mlist, message_id_hash=message_id_hash)
thread = None
else:
raise SuspiciousOperation
form_queryset = Email.objects.filter(mailinglist=mlist)
if thread is not None:
form_queryset = form_queryset.filter(thread=thread)
elif message is not None:
form_queryset = form_queryset.filter(pk=message.pk)
if request.method == 'POST':
form = MessageDeleteForm(request.POST)
form.fields["email"].queryset = form_queryset
if form.is_valid():
thread_ids = []
for email in sorted(form.cleaned_data["email"], reverse=True):
email.refresh_from_db()
thread_id = email.thread.pk
try:
email.delete()
except Exception as e:
form.add_error("email",
_("Could not delete message %(msg_id_hash)s: %(error)s")
% {"msg_id_hash": email.message_id_hash, "error": e})
continue
logger.info("Deleted email %s (%s)", email.pk, email.message_id)
thread_ids.append(thread_id)
if thread_ids:
messages.success(
request, _("Successfully deleted %(count)s messages.")
% {"count": len(thread_ids)})
if not form.has_error("email"):
if len(set(thread_ids)) == 1:
try:
thread = Thread.objects.get(pk=thread_ids[0])
return redirect(reverse('hk_thread', kwargs={
"mlist_fqdn": mlist_fqdn,
"threadid": thread.thread_id}))
except Thread.DoesNotExist:
# The thread has been deleted to in cascade, go back to
# the list.
pass
return redirect(reverse('hk_list_overview', kwargs={
"mlist_fqdn": mlist_fqdn}))
else:
initial = form_queryset.values_list("pk", flat=True)
form = MessageDeleteForm(initial={"email": initial})
form.fields["email"].queryset = form_queryset
context = {
"mlist": mlist,
"form": form,
#'months_list': get_months(mlist),
}
if thread is not None:
context.update({