class Tag(models.Model):

    name = models.CharField(max_length=255, db_index=True, unique=True)
    threads = models.ManyToManyField("Thread",
        through="Tagging", related_name="tags")
    users = models.ManyToManyField(settings.AUTH_USER_MODEL,
        through="Tagging", related_name="tags")

    class Meta:
        ordering = ["name"]

    def __unicode__(self):
        return 'Tag %s' % (unicode(self.name)) class Tagging(models.Model):

    thread = models.ForeignKey("Thread")
    user = models.ForeignKey(settings.AUTH_USER_MODEL)
    tag = models.ForeignKey("Tag")

    def __unicode__(self):
        return 'Tag %s on %s by %s' % (unicode(self.tag),
                unicode(self.thread), unicode(self.user)) class Favorite(models.Model):
    thread = models.ForeignKey("Thread", related_name="favorites")
    user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="favorites")

    def __unicode__(self):
        return u"%s is a favorite of %s" % (
            unicode(self.thread), unicode(self.user)) class LastView(models.Model):
    thread = models.ForeignKey("Thread", related_name="lastviews")
    user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="lastviews")
    view_date = models.DateTimeField(auto_now=True)

    def __unicode__(self):
        """Unicode representation"""
        return u"Last view of %s by %s was %s" % (
            unicode(self.thread), unicode(self.user),
            self.view_date.isoformat())

    def num_unread(self):
        if self.thread.date_active.replace(tzinfo=utc) <= self.view_date:
            return 0 # avoid the expensive query below
        else:
            return self.thread.emails.filter(date__gt=self.view_date).count() class Thread(models.Model):
    """
    A thread of archived email, from a mailing-list. It is identified by both
    the list name and the thread id.
    """
    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)
    starting_email = models.OneToOneField("Email",
        related_name="started_thread", null=True)

    class Meta:
        unique_together = ("mailinglist", "thread_id")

    @property
    def participants(self):
        """Set of email senders in this thread"""
        return Sender.objects.filter(emails__thread_id=self.id).distinct()

    @property
    def participants_count(self):
        return cache.get_or_set(
            "Thread:%s:participants_count" % self.id,
            lambda: self.participants.count(),
            None)

    def replies_after(self, date):
        return self.emails.filter(date__gt=date)

    #def _get_category(self):
    #    if not self.category_id:
    #        return None
    #    return self.category_obj.name
    #def _set_category(self, name):
    #    if not name:
    #        self.category_id = None
    #        return
    #    session = object_session(self)
    #    try:
    #        category = session.query(Category).filter_by(name=name).one()
    #    except NoResultFound:
    #        category = Category(name=name)
    #        session.add(category)
    #    self.category_id = category.id
    #category = property(_get_category, _set_category)

    @property
    def emails_count(self):
        return cache.get_or_set(
            "Thread:%s:emails_count" % self.id,
            lambda: self.emails.count(),
            None)

    @property
    def subject(self):
        return cache.get_or_set(
            "Thread:%s:subject" % self.id,
            lambda: self.starting_email.subject,
            None)

    def get_votes(self):
        return get_votes(self)

    @property
    def prev_thread(self): # TODO: Make it a relationship
        return Thread.objects.filter(
                mailinglist=self.mailinglist,
                date_active__lt=self.date_active
            ).order_by("-date_active").first()

    @property
    def next_thread(self): # TODO: Make it a relationship
        return Thread.objects.filter(
                mailinglist=self.mailinglist,
                date_active__gt=self.date_active
            ).order_by("date_active").first()

    def is_unread_by(self, user):
        if not user.is_authenticated():
            return False
        try:
            last_view = LastView.objects.get(thread=self, user=user)
        except LastView.DoesNotExist:
            return True
        return self.date_active.replace(tzinfo=utc) > last_view.view_date class MailingList(models.Model):
    """
    An archived mailing-list.
    """
    name = models.CharField(max_length=254, primary_key=True)
    display_name = models.CharField(max_length=255)
    description = models.TextField()
    subject_prefix = models.CharField(max_length=255)
    archive_policy = models.IntegerField(
        choices=[ (p.value, p.name) for p in ArchivePolicy ],
        default=ArchivePolicy.public.value)
    created_at = models.DateTimeField(default=now)

    MAILMAN_ATTRIBUTES = (
        "display_name", "description", "subject_prefix",
        "archive_policy", "created_at",
    )

    @property
    def is_private(self):
        return self.archive_policy == ArchivePolicy.private.value

    @property
    def is_new(self):
        return self.created_at and \
                now() - self.created_at <= datetime.timedelta(days=30)

    def get_recent_dates(self):
        today = now()
        #today -= datetime.timedelta(days=400) #debug
        # the upper boundary is excluded in the search, add one day
        end_date = today + datetime.timedelta(days=1)
        begin_date = end_date - datetime.timedelta(days=32)
        return begin_date, end_date

    def get_participants_count_between(self, begin_date, end_date):
        # We filter on emails dates instead of threads dates because that would
        # also include last month's participants when threads carry from one
        # month to the next
        # TODO: caching?
        return self.emails.filter(
                date__gte=begin_date, date__lt=end_date
            ).values("sender_id").distinct().count()

    def get_threads_between(self, begin_date, end_date):
        return self.threads.filter(
                starting_email__date__lt=end_date,
                date_active__gte=begin_date
            ).order_by("-date_active")

    @property
    def recent_participants_count(self):
        begin_date, end_date = self.get_recent_dates()
        return cache.get_or_set(
            "MailingList:%s:recent_participants_count" % self.name,
            lambda: self.get_participants_count_between(begin_date, end_date),
            3600 * 6) # 6 hours

    @property
    def recent_threads(self):
        begin_date, end_date = self.get_recent_dates()
        return cache.get_or_set(
            "MailingList:%s:recent_threads" % self.name,
            lambda: self.get_threads_between(begin_date, end_date),
            3600 * 6) # 6 hours

    def get_participants_count_for_month(self, year, month):
        def _get_value():
            begin_date = datetime.datetime(year, month, 1, tzinfo=utc)
            end_date = begin_date + datetime.timedelta(days=32)
            end_date = end_date.replace(day=1)
            return self.get_participants_count_between(begin_date, end_date)
        return cache.get_or_set(
            "MailingList:%s:p_count_for:%s:%s" % (self.name, year, month),
            _get_value, None)

    @property
    def top_posters(self):
        begin_date, end_date = self.get_recent_dates()
        query = Sender.objects.filter(
            emails__mailinglist=self,
            emails__date__gte=begin_date,
            emails__date__lt=end_date,
            ).annotate(count=models.Count("emails")
            ).order_by("-count")
        # Because of South, ResultSets are not pickleizable directly, they must
        # be converted to lists (there's an extra field without the _deferred
        # attribute that causes tracebacks)
        return cache.get_or_set(
            "MailingList:%s:top_posters" % self.name,
            lambda: list(query[:5]),
            3600 * 6) # 6 hours
        # It's not actually necessary to convert back to instances since it's
        # only used in templates where access to instance attributes or
        # dictionnary keys is identical
        #return [ Sender.objects.get(address=data["address"]) for data in sender_ids ]
        #return sender_ids

    def update_from_mailman(self):
        try:
            client = get_mailman_client()
            mm_list = client.get_list(self.name)
        except MailmanConnectionError:
            return
        except HTTPError:
            return # can't update at this time
        if not mm_list:
            return
        converters = {
            "created_at": dateutil.parser.parse,
            "archive_policy": lambda p: ArchivePolicy[p].value,
        }
        for propname in self.MAILMAN_ATTRIBUTES:
            try:
                value = getattr(mm_list, propname)
            except AttributeError:
                value = mm_list.settings[propname]
            if propname in converters:
                value = converters[propname](value)
            setattr(self, propname, value)
        self.save() class ThreadCategory(models.Model):
    name = models.CharField(max_length=255, db_index=True, unique=True)
    color = ColorPickerField()

    class Meta:
        verbose_name_plural = "Thread categories"

    def __unicode__(self):
        return u'Thread category "%s"' % (unicode(self.name)) def get_months(mlist):
    """ Return a dictionnary of years, months for which there are
    potentially archives available for a given list (based on the
    oldest post on the list).

    :arg list_name, name of the mailing list in which this email
    should be searched.
    """
    date_first = cache.get_or_set(
        "MailingList:%s:first_date" % mlist.name,
        lambda: mlist.emails.order_by("date"
            ).values_list("date", flat=True).first(),
        None)
    if not date_first:
        return {}
    archives = {}
    now = datetime.datetime.now()
    year = date_first.year
    month = date_first.month
    while year < now.year:
        archives[year] = range(1, 13)[(month -1):]
        year = year + 1
        month = 1
    archives[now.year] = range(1, 13)[:now.month]
    return archives def get_category_widget(request=None, current_category=None, categories=()):
    """
    Returns the category form and the applicable category object (or None if no
    category is set for this thread).

    If current_category is not provided or None, try to deduce it from the POST
    request.
    if request is not provided or None, don't return the category form, return
    None instead.
    """

    if request:
        if request.method == "POST":
            category_form = CategoryForm(request.POST)
        else:
            category_form = CategoryForm(initial={"category": current_category or ""})
        category_form["category"].field.choices = categories
    else:
        category_form = None
    if request and request.method == "POST" and category_form.is_valid():
        # is_valid() must be called after the choices have been set
        current_category = category_form.cleaned_data["category"]

    if not current_category:
        category = None
    else:
        try:
            category = ThreadCategory.objects.get(name=current_category)
        except ThreadCategory.DoesNotExist:
            category = None
    return category, category_form FLASH_MESSAGES = {
    "updated-ok": ("success", "The profile was successfully updated."),
    "sent-ok": ("success", "The message has been sent successfully."),
    "attached-ok": ("success", "Thread successfully re-attached."),
} def check_mlist_private(func):
    @wraps(func, assigned=available_attrs(func))
    def inner(request, *args, **kwargs):
        if "mlist_fqdn" in kwargs:
            mlist_fqdn = kwargs["mlist_fqdn"]
        else:
            mlist_fqdn = args[0]
        try:
            mlist = MailingList.objects.get(name=mlist_fqdn)
        except MailingList.DoesNotExist:
            raise Http404("No archived mailing-list by that name.")
        if not is_mlist_authorized(request, mlist):
            return render(request, "hyperkitty/errors/private.html", {
                            "mlist": mlist,
                          }, status=403)
        return func(request, *args, **kwargs)
    return inner
import datetime
import re
import json
import robot_detection
from django.http import HttpResponse, Http404
from django.template import RequestContext, loader
from django.shortcuts import render, redirect, get_object_or_404
from django.core.urlresolvers import reverse
from django.core.exceptions import SuspiciousOperation
from django.utils.timezone import utc
from haystack.query import SearchQuerySet
from hyperkitty.models import Tag, Tagging, Favorite, LastView, Thread, MailingList, ThreadCategory
from hyperkitty.views.forms import AddTagForm, ReplyForm
from hyperkitty.lib.utils import stripped_subject
from hyperkitty.lib.view_helpers import (get_months, get_category_widget,
        FLASH_MESSAGES, check_mlist_private)
        'reply_form': ReplyForm(),
        'last_view': last_view,
    }
    context["replies"] = _get_thread_replies(request, thread, offset=offset,
                                             limit=chunk_size)

    replies_tpl = loader.get_template('hyperkitty/ajax/replies.html')
    replies_html = replies_tpl.render(RequestContext(request, context))
    response = {"replies_html": replies_html,
                "more_pending": False,
                "next_offset": None,
               }
    if len(context["replies"]) == chunk_size:
        response["more_pending"] = True
        response["next_offset"] = offset + chunk_size
    return HttpResponse(json.dumps(response),
                        content_type='application/javascript')


@check_mlist_private
def tags(request, mlist_fqdn, threadid):
    """ Add or remove one or more tags on a given thread. """
    if not request.user.is_authenticated():
        return HttpResponse('You must be logged in to add a tag',
                            content_type="text/plain", status=403)
    thread = get_object_or_404(Thread,
        mailinglist__name=mlist_fqdn, thread_id=threadid)

    if request.method != 'POST':
        raise SuspiciousOperation
    action = request.POST.get("action")

    if action == "add":
        form = AddTagForm(request.POST)
        if not form.is_valid():
            return HttpResponse("Error adding tag: invalid data",
                                content_type="text/plain", status=500)
        tagname = form.data['tag']
    elif action == "rm":
        tagname = request.POST.get('tag')
    else:
        raise SuspiciousOperation
    tagnames = [ t.strip() for t in re.findall(r"[\w'_ -]+", tagname) ]
    for tagname in tagnames:
        if action == "add":
            tag = Tag.objects.get_or_create(name=tagname)[0]
            Tagging.objects.get_or_create(
                tag=tag, thread=thread, user=request.user)
        elif action == "rm":
            try:
                Tagging.objects.get(tag__name=tagname, thread=thread,
                                    user=request.user).delete()
            except Tagging.DoesNotExist:
                raise Http404("No such tag: %s" % tagname)
            # cleanup
            if not Tagging.objects.filter(tag__name=tagname).exists():
                Tag.objects.filter(name=tagname).delete()

    # Now refresh the tag list
    tpl = loader.get_template('hyperkitty/threads/tags.html')
    html = tpl.render(RequestContext(request, {
            "thread": thread,
            }))

    response = {"tags": [ t.name for t in thread.tags.distinct() ],
                "html": html}
    return HttpResponse(json.dumps(response),
                        content_type='application/javascript')

@check_mlist_private
def suggest_tags(request, mlist_fqdn, threadid):
    term = request.GET.get("term")
    current_tags = Tag.objects.filter(
            threads__mailing_list__name=mlist_fqdn,
            threads__thread_id=threadid
        ).values_list("name", flat=True)
    if term:
        tags_db = Tag.objects.filter(
            threads__mailing_list__name=mlist_fqdn,
            name__istartswith=term)
    else:
        tags_db = Tag.objects.all()
    tag_names = [ t.encode("utf-8") for t in
             tags_db.exclude(name__in=current_tags
                ).values_list("name", flat=True)[:20] ]
    return HttpResponse(json.dumps(tag_names),
                        content_type='application/javascript')


@check_mlist_private
def favorite(request, mlist_fqdn, threadid):
    """ Add or remove from favorites"""
    if not request.user.is_authenticated():
        return HttpResponse('You must be logged in to have favorites',
                            content_type="text/plain", status=403)
    if request.method != 'POST':
        raise SuspiciousOperation

    thread = get_object_or_404(Thread,
        mailinglist__name=mlist_fqdn, thread_id=threadid)
    if request.POST["action"] == "add":
        Favorite.objects.get_or_create(thread=thread, user=request.user)
    elif request.POST["action"] == "rm":
        Favorite.objects.filter(thread=thread, user=request.user).delete()
    else:
        raise SuspiciousOperation
    return HttpResponse("success", content_type='text/plain')


@check_mlist_private
def set_category(request, mlist_fqdn, threadid):
    """ Set the category for a given thread. """
    if not request.user.is_authenticated():
        return HttpResponse('You must be logged in to add a tag',
                            content_type="text/plain", status=403)
    if request.method != 'POST':
        raise SuspiciousOperation

    thread = get_object_or_404(Thread,
        mailinglist__name=mlist_fqdn, thread_id=threadid)

    category, category_form = get_category_widget(request)