class Topic(models.Model):
    """A subject of discussion.  An information silo.  These will generally
       correspond to a page on the front end that will aggregate
       comments and other content around the subject.  This might
       be something like 'Evanston Libraries'

       Fields:

       slug: A shortened, space-replaced, weird character cleaned 
                    version of the title.  This will get used in the URL
                    paths to views related to a given topic.
       topic_tags: Content with any of these tags can get pulled into a topic 
                   view.
       curators: Users who have priveleges to update this topic.
       summary: Short (1-2) paragraph description of the topic, maintained
                by one of the curators.
       articles: Articles (probably recent ones) to display on the front page, selected by curators. Probably ancestors of timeline."""
    title = models.CharField(max_length=80, unique = True) 
    slug = models.SlugField(unique = True)
    summary = models.ForeignKey(Summary)
    topic_tags = models.ManyToManyField(Tag, null=True, blank=True) 
    curators = models.ManyToManyField(UserProfile)
    articles = models.ManyToManyField(Article, blank=True)
    is_deleted = models.BooleanField(default = False)
    date_created = models.DateTimeField(auto_now_add=True)

    def __unicode__(self):
        return self.title

    def recursive_traverse(self, comment, level = 1):
        # Pass through comment replies, showing
        retlist = [comment]
        for child in sorted(comment.comment_set.filter(is_deleted=False, is_parent=True, topics=self), cmp=comment_cmp):
            retlist += [self.recursive_traverse(child, level+1)]
        return retlist

    def comments_to_show(self, cmp_function=comment_cmp):
        # Comment refers to the parent
        rootset = sorted(self.comments.filter(is_deleted=False, is_parent=True, related=None), cmp=cmp_function)


        treemap = {}
        allcomments = rootset

        indentlist = []

        for comment in rootset:
            indentlist += [self.recursive_traverse(comment)]

        return indentlist

    def get_questions(self):
        """Return a QuerySet containing visible questions for this topic."""
        return self.comments.filter(is_deleted=False, \
            comment_type__name="Question")

    def burning_questions(self):
        """Return a list containing burning questions."""

        if not ("_burning_questions" in self.__dict__):
            # HACK ALERT!: This is a really naive approach and should definitely be refactored in
            # the future.

            # TODO: Now that I've implemented core.utils.CountIfConcur, it might
            # be possible to refactor this.  
            # -Geoff <geoffhing@gmail.com> 2010-12-06
            burning_questions = [] 
            burning_question_ids = []
            questions = self.get_questions()
            total_positive_responses = 0

            if questions.count() > 0:
                for question in questions:
                    question.num_positive_responses = \
                        question.num_responses("concur")
                    total_positive_responses = total_positive_responses + \
                        question.num_positive_responses

                # Get average number of positive responses (be sure to cast to 
                # a float)
                avg_positive_responses = \
                    total_positive_responses / float(questions.count())

                # Now that we have the average, let's see if the questions are
                # above average. We have to loop through and re-get the response
                # counts for each question again.
                for question in questions:
                    if question.num_positive_responses > 0 and \
                       question.num_positive_responses >= avg_positive_responses and \
                       not question.is_answered():
                       question.is_burning = True
                       burning_questions.append(question)
                       burning_question_ids.append(question.id)

            self._burning_questions = burning_questions
            self._burning_question_ids = burning_question_ids

        return self._burning_questions

    def burning_question_ids(self):
        if not ("_burning_question_ids" in self.__dict__):
            # We haven't calculated our burning questions yet.  Do this.
            self.burning_questions()

        return self._burning_question_ids

    def question_is_burning(self, question):
        """ Test whether a question is burning. """
        if not ("_burning_questions" in self.__dict__):
            # We haven't calculated our burning questions yet.  Do this.
            self.burning_questions()

        return question.id in self._burning_question_ids

    def top_answers(self):
        """Return a list of top answers for a topic."""
        if not ("_top_answers" in self.__dict__):
            top_answers = [] 
            top_answer_ids = []
            questions = self.get_questions()
            total_positive_responses = 0

            if questions.count() > 0:
                # HACK ALERT: This is a very naive approach to figuring out
                # these averages.  I imagine there's a way to do this using
                # Django's ORM and aggregation.  However, it's non-trivial,
                # so I'm doing it this way to get things working.
                # - Geoff <geoffhing@gmail.com> 2010-11-18

                # We're going to calculate the average positive responses to
                # all of the answers.  First, we have to find all the answers.
                num_answers = 0
                total_positive_responses = 0

                for question in questions:
                    answers = question.get_answers()
                    num_answers += len(answers)

                    for answer in answers:
                        total_positive_responses += \
                            answer.num_responses("concur")

                # Get average number of positive responses (be sure to cast to 
                # a float)
                avg_positive_responses = 0
                if num_answers > 0:
                    avg_positive_responses = \
                        total_positive_responses / float(num_answers)

                # Now that we have the average, let's see if the answers are
                # above average. We have to loop through and re-get the response
                # counts for each question again.
                for question in questions:
                    answers = question.get_answers()

                    for answer in answers:
                        num_positive_responses = answer.num_responses("concur")

                        if num_positive_responses > 0 and \
                        num_positive_responses > avg_positive_responses:
                            answer.is_top_answer = True
                            top_answers.append(answer)
                            top_answer_ids.append(answer.id)

            self._top_answers = top_answers
            self._top_answer_ids = top_answer_ids


        return self._top_answers

    def popular_comments(self, num=5):
        """
        Return a queryset containing the num most popular comments.

        This includes questions, answers and replies.  Most popular means the
        number of responses.

        """
        logger.info("core.models.Comment.popular_comments(self, num=5)")
        return self.comments.filter(is_deleted=False).annotate(\
            num_responses=CountIfConcur('responses')).order_by('-num_responses')[:num]


    def user_responded_comment_ids(self, user_profile, response_type):
        """
        Returns a queryset containing all ids of comments for this topic for which 
        a user has responded in a certain way.

        """
        comments = CommentResponse.objects.filter(user=user_profile, \
            type=response_type).values_list('comment', flat=True)


        return comments

    def user_voted_comment_ids(self, user_profile):
        """ 
        Returns a queryset containing all comment ids for this topic on which a
        user has voted.
        
        """
        return self.user_responded_comment_ids(user_profile=user_profile, \
            response_type='concur') class CommentType(models.Model):
    """Type of comment, e.g. question, concern, answer ...  This is a separate
       class so we can easily add new types of comments."""
    name = models.CharField(max_length=15)

    def __unicode__(self):
        return self.name

    def natural_key(self):
        return self.name class Comment(models.Model):
    """User-generated feedback to the system.  These will implement questions,
       answers, concerns and replies.

       Fields:

       related: Comments that are related to this comment, e.g. an answer to
                a question, a re-phrasing of a comment, a question about
                a comment, or something that is just conceptually related.
       sites: Sites which this content can appear on.  This is anticipating
              sharing content between multiple sites, e.g. a Chicago
              election site and an Evanston local news site.
       comment_type: Question, concern, respsonse ...
       topics: Topics to which this comment relates.
       sources: different from a comment you own; these are comments you're associated with
       where that user may not even be an active user in the database. We'll set to is_active=False, to 
       reflect, e.g., MOS interviews. Will set up claiming process so these users can BECOME active by entering contact
       info, etc.
       is_parent: after several comments are merged, one becomes the parent. By default, this is the only one shown. Defaults
       to true, since comment is assumed to be its own parent when it's independent/visible."""
    text = models.TextField()
    user = models.ForeignKey(UserProfile, related_name="comments")
    sources = models.ManyToManyField(UserProfile, related_name = "sourced_comments", blank=True, null = True)
    tags = models.ManyToManyField(Tag, null=True, blank=True) 
    related = models.ManyToManyField("self", through="CommentRelation", 
                                     symmetrical=False, null=True)
    sites = models.ManyToManyField(Site, blank=True)
    comment_type = models.ForeignKey("CommentType")
    topics = models.ManyToManyField("Topic", blank=True, related_name = 'comments')
    responses = models.ManyToManyField(UserProfile, through="CommentResponse", 
                                       symmetrical=False, null=True,
                                       related_name="responses")
    is_parent = models.BooleanField(default = True)
    is_deleted = models.BooleanField(default = False)
    date_created = models.DateTimeField(auto_now_add=True)

    clips = models.ManyToManyField(Clip, blank=True, null=True)

    def __unicode__(self):
        return self.text[:80]

    def num_responses(self, response_type):
        return CommentResponse.objects.filter(comment=self, \
            type=response_type).count()

    def num_upvotes(self):
        return self.num_responses("concur")

    def opinion_level(self):
        """Return a count of the number of times this comment has been flagged as opinion."""
        return self.num_responses("opinion")

    def get_related(self, relation_type):
        """Return a list of related comments of a specified type.""" 
        related_comments = []
        for relation in CommentRelation.objects.filter(right_comment=self, \
            relation_type=relation_type):
            related_comments.append(relation.left_comment)

        return related_comments

    def get_answers(self):
        """Return a list of answers for this comment (if it's a question)"""
        # TODO: Make the comment relation a constant or something.
        return self.get_related("reply")

    def num_related(self, relation_type):
        return len(self.get_related(relation_type))

    def num_answers(self):
        # TODO: Make the comment relation a constant or something.
        return self.num_related("reply")

    def is_answered(self):
        return self.num_answers() > 0 class Summary(models.Model):
    """Summary of a subject (likely a Topic).  Make this a separate class
       if we want to do versioning."""
    text = models.TextField(blank=True, unique = True)
    date_created = models.DateTimeField(auto_now_add=True)
    
    def __unicode__(self):
        return self.text[:80]

    def natural_key(self):
        return self.text

    class Meta:
        verbose_name_plural = 'Summaries' class CommentRelation(models.Model):
    """Extra information about the relationship between two comments.
    see http://docs.djangoproject.com/en/1.2./topics/db/models/#extra-fields-on-many-to-many-relationships"""
    left_comment = models.ForeignKey(Comment, related_name='+') 
    # Don't need to create an inverse relation
    right_comment = models.ForeignKey(Comment, related_name='+')
    # Don't need to create an inverse relation
    relation_type = models.CharField(max_length=15) class CommentResponse(models.Model):
    """User response to a comment.

       These are responses that are not other comments.  This will
       implement the 'I also have this question' or 'I share this concern'
       functionality.  You can think of it as implementing Facebook 'like'
       style features."""

    COMMENT_RESPONSE_CHOICES = (
        ('share', 'I share this concern'),
        ('have', 'I have this question'),
        ('like', 'I like this'),
        ('concur', 'I concur'),
        ('opinion', 'This answer is primarily opinion'),
        ('accept', 'I accept this as the best answer'),
    )

    comment = models.ForeignKey(Comment)
    user = models.ForeignKey(UserProfile)
    type = models.CharField(max_length=20, choices=COMMENT_RESPONSE_CHOICES)
    created = models.DateTimeField(auto_now_add=True)

    def __unicode__(self):
        return "%s %s %s" % (self.user, self.type, self.comment)  class UnknownOutputFormat(Exception):
    """Exception thrown when an API call is made requesting output in
       an unknown format."""
    pass class NonAjaxRequest(Exception):
    """Exception thrown when an API call is made that is not an AJAX request
       and we don't want to allow this kind of (potentially) cross-site 
       call
    
    """
    pass class MissingParameter(Exception):
    """Exception thrown when a parameter to an API request is missing"""
    pass class RecentlyResponded(Exception):
    """Exception thrown when a user has already responded once within the
       allowable time limit"""
    pass class MethodUnsupported(Exception):
    """Exception thrown when an HTTP request method isn't supported by the REST
       API"""
    pass class InvalidTopic(Exception):
    """Exception thrown when an API call requests an invalid topic via
    title."""
    pass class MaximumExceeded(Exception):
    """Exception thrown when some API call exceeds a threshold.""" 
    pass class UserOwnsItem(Exception):
    """Exception thrown when a user tries to vote on an item that she has 
       created"""
    pass class NotUserQuestionReply(Exception):
    """Exception thrown when a user tries to accept a comment not a reply to 
       her own question"""
    pass class CommentSubmitForm(forms.Form):
    """
    Form to let a user submit a comment tied to a particular topic.  Using 
    the names since those already get accessed in the template; the alternative 
    would be to use a ModelChoiceField with a HiddenInput widget, and make 
    jQuery handle changing the topic based on clicking on tabs. It will be 
    possible to simplify this if the UI changes to allow the user to explicitly 
    select a topic or topics (presumably using a ModelMultipleChoiceField).

    Could switch comment_type_str to use a ModelChoiceField on 
    CommentType.objects.all(). It's difficult to make this a model form, though,
    because we have to add the user info back in after the form is submitted, 
    and handle the possibility of creating new tags, which means we can't just 
    instantiate and go.
    
    """
    
    allcomments = Comment.objects.filter(is_deleted=False).filter(is_parent=True)
    comment_types = CommentType.objects.all()

    comment_type_str = forms.ModelChoiceField(comment_types, \
        label = 'I have a', \
        widget = forms.Select(attrs = {'class' : 'questorcon'}), \
        empty_label = None)
    text = forms.CharField(required=True, label = '', \
        widget=forms.Textarea(attrs= {'class' : 'conquest', }), max_length=300)
    topic = forms.CharField(initial = initial_topic_title, \
        widget=forms.widgets.HiddenInput(attrs = {'class' : 'topic'} ))
    in_reply_to = forms.ModelChoiceField(allcomments, \
        widget = forms.HiddenInput, required=False)
    sources = forms.ModelChoiceField(UserProfile.objects.all(), \
        required = False)
    
    def clean(self):
        """
        make sure we aren't accepting HTML input
        """
        f_value = self.cleaned_data.get('text')
        logger.info("core.forms.CommentSubmitForm(): checking input f_value=%s,"\
            , f_value)
        if f_value == None or f_value.strip() == '':
            logger.error("core.forms.CommentSubmitForm(): comment was empty")
            raise forms.ValidationError('Empty form')
        self.cleaned_data['text'] = utils.sanitize_html(f_value)
        return self.cleaned_data


    class Meta:
        fields = ['comment_type_str', 'text', 'topic', 'in_reply_to', 'sources']
        model = Comment class CommentDeleteForm(forms.Form):
    allcomments = Comment.objects.filter(is_deleted=False).filter(is_parent=True)
    comments = forms.ModelMultipleChoiceField(allcomments, ) class TopicDeleteForm(forms.Form):
    alltopics = Topic.objects.filter(is_deleted=False)
    topics = forms.ModelMultipleChoiceField(alltopics, ) class NewTopicForm(forms.ModelForm):
    # Comment to source from should default to null
    # Really just using the modelform to validate the title as new
    summary_text = forms.CharField(required = True )
    allcomments = Comment.objects.filter(is_deleted=False).filter(is_parent=True)
    source_comment = forms.ModelChoiceField(allcomments, required = False)

    class Meta:
        model = Topic
        fields = ('title', 'summary_text', 'source_comment') class MergeCommentForm(forms.Form):
    """ Take a set of comments, designate one as parent."""

    allcomments = Comment.objects.filter(is_deleted = False).filter(is_parent = True)
    comments = forms.ModelMultipleChoiceField(allcomments)
    parent = forms.ModelChoiceField(allcomments, empty_label = None) class NewSummaryForm(forms.Form):
    """ Form to let a user create a new summary for a topic"""

    alltopics = Topic.objects.filter(is_deleted=False)
    topic = forms.ModelChoiceField(alltopics, )
    summary_text = forms.CharField(required = True, widget = forms.TextInput) class CommentTopicForm(forms.Form):
    allcomments = Comment.objects.filter(is_deleted=False).filter(is_parent=True)
    alltopics = Topic.objects.filter(is_deleted=False)
    comment = forms.ModelChoiceField(allcomments, empty_label = None)
    topic = forms.ModelChoiceField(alltopics, empty_label = None) class CountIfConcur(models.Aggregate):
    """Custom Aggregate class to count "concur" (up vote) responses.
    
    This is based on the work described at 
    http://www.voteruniverse.com/Members/jlantz/blog/conditional-aggregates-in-django
    However, I was not able to use the more general code described in this blog
    post, perhaps because django.db.sql.models.where.WhereNode.as_sql()'s 
    implementation has changed, or because I'm using a ManyToMany relation
    and the example is a ForeignKey relation.  In the interest of expediency, I
    just hardcoded the test.

    The thread at
    http://groups.google.com/group/django-users/browse_thread/thread/bd5a6b329b009cfa?pli=1 
    has good information about implementing custom Aggregate classes in general.
    
    """
    name = 'COUNT'

    def add_to_query(self, query, alias, col, source, is_summary):
        aggregate = SQLCountIfConcur(col, source=source, is_summary=is_summary, **self.extra)
        query.aggregates[alias] = aggregate class ActivationKeyValue(models.Model):
class UserProfile(models.Model):
    USER_TYPE_CHOICES = (
    (u'R', u'reporter'),
    (u'S', u'source'),
    (u'A', u'author'))
    INTEREST_CHOICES = (
        (u'', u'Neither or unanswered'),
        (u'publisher', u'publisher'),
        (u'consumer', u'consumer'),
    )
    def is_reporter(self):
    def __unicode__(self): def ajax_login_required(view_func):
    """
    Decorator for AJAX endpoints requiring authentication.
    
    Thank you http://stackoverflow.com/questions/312925/django-authentication-and-ajax-urls-that-require-login/523196#523196
    
    """

    def wrap(request, *args, **kwargs):
        status = 200 
        data = {}

        try:
            if request.is_ajax():
                if request.user.is_authenticated():
                    return view_func(request, *args, **kwargs)

                else:
                    raise BadUsernameOrPassword

            else:
                # Non-AJAX request.  Disallow for now.
                raise NonAjaxRequest( \
                    "Remote API calls aren't allowed right now. " + \
                    "This might change some day.")

        except NonAjaxRequest, e:
            logger.warning("Non-AJAX API call to %s" % (request.path))
            status = 403 # Forbidden
            data['error'] = "%s" % e

        except BadUsernameOrPassword, error:
            logger.warning("Authentication failure for user %s" % \
                        (request.user.username))
            status = 401 # unauthorized
            data['error'] = "%s" % error

        return HttpResponse(content=json.dumps(data), \
            mimetype='application/json', status=status)

    wrap.__doc__ = view_func.__doc__
    wrap.__dict__ = view_func.__dict__

    return wrap class UserNotAuthenticated(Exception):
   def __init__(self, msg='User is not authenticated.  Please login.'):
       self.msg = msg 

   def __str__(self):
       return self.msg class UserNotReporter(Exception):
    def __init__(self, msg='User is not a reporter.'):
        self.msg = msg
    
    def __str__(self):
        return self.msg class Tag(models.Model):
    """ Generic class to implement tagging. """
    name = models.CharField(max_length=80, db_index=True, unique = True)

    def __unicode__(self):
        return self.name class UrlSubmitForm(forms.Form):
    url_field = forms.URLField(label='Enter the URL of the site you want to clip'\
        , required=True,\
        widget=forms.TextInput(attrs={'size':'70', 'class':'clipper_url_field'}))
    user_comments = forms.CharField(widget=forms.Textarea(\
        attrs={'class':'clipper_text_field', 'col': 52}), required=False, \
        label="Your comments go here: ")
import json
import datetime
import core.utils
from django.http import HttpResponseRedirect, HttpResponse, \
                        HttpResponseNotFound, Http404
from django.shortcuts import render_to_response
from django.conf import settings
from django.core.paginator import Paginator
from django.template import RequestContext
from django.core.urlresolvers import reverse
from django.contrib.auth import authenticate, login, logout
from django.core.context_processors import csrf
from django.core.exceptions import ObjectDoesNotExist
from django.core import serializers
from django.db.models import Count, Sum, Max
from fbapi.facebook import *
from core.models import Topic,CommentType, Comment, Summary, CommentRelation, \
                   CommentResponse
from core.exceptions import UnknownOutputFormat, NonAjaxRequest, \
                                       MissingParameter, RecentlyResponded, \
                                       MethodUnsupported, InvalidTopic, \
                                       MaximumExceeded, UserOwnsItem, \
                                       NotUserQuestionReply
from core.forms import CommentSubmitForm, CommentDeleteForm, \
                                  TopicDeleteForm, NewTopicForm, \
                                  MergeCommentForm, NewSummaryForm, \
                                  CommentTopicForm
from core.utils import CountIfConcur
from users.models import UserProfile, User
from users.views import ajax_login_required 
from users.exceptions import UserNotAuthenticated, UserNotReporter
from tagger.models import Tag
from clipper.forms import UrlSubmitForm
                user = UserProfile.objects.get(user__id = user_id)
            else:
                raise UserNotAuthenticated

            if request.method == 'POST':
                if "type" not in request.POST.keys():
                    raise MissingParameter("You must specify a 'type' " + \
                        "parameter ' + 'to indicate which kind of comment " + \
                        "response is being created")

                response_type = request.POST['type']

                # Try to get the comment object
                comment = Comment.objects.get(id = comment_id)

                # Check to see if the user created the comment.  You can't
                # respond to a comment you created.
                if comment.user.user.id == user.id:
                    # They match!
                    raise UserOwnsItem("You can't vote on an item that you " + \
                                       "created!")

                # Get any previous responses
                responses = CommentResponse.objects.filter(comment=comment, \
                    user__user__id=user_id, \
                    type=response_type).order_by('created')

                if responses.count() < MAX_RESPONSES and \
                   responses.count() > 0:
                    # User hasn't exceeded the maximum number of responses 
                    # allowed
                    
                    if RESPONSE_WINDOW_HOURS is not None and \
                       RESPONSE_WINDOW_MINUTS is not None:
                        # If time limits are enabled check to see
                        # if the user has commented too recently
                    
                        # Get the current time
                        now = datetime.datetime.now()

                        # Get the difference between now and when the user last 
                        # responded
                        time_diff = now - responses[0].created

                        # Calculate the allowable window
                        time_window = datetime.timedelta( \
                            hours=RESPONSE_WINDOW_HOURS, \
                            minutes=RESPONSE_WINDOW_MINUTES);

                        if time_diff < time_window:
                          # User has already commented within the allowable 
                          # window 

                          # Calculate how long the user has to wait
                          wait = time_window - time_diff
                          wait_hours, remainder = divmod(wait.seconds, 3600)
                          wait_minutes, wait_seconds = divmod(remainder, 60)

                          raise RecentlyResponded( \
                            "You must wait %d hours, %d minutes " +
                            "before responding" % (wait_hours, wait_minutes))

                elif responses.count() == 0:
                    pass
                            
                else:
                    raise MaximumExceeded("User has already made maximum " + \
                                          "number of responses")
        
                # Check accept case

                if response_type == 'accept':
                    # This should only be allowed for replies to comments posed by the user

                    # Is the comment a reply at all?
                    try: 
                        reply_relation = CommentRelation.objects.filter(left_comment = comment, relation_type = 'reply')
                    except ObjectDoesNotExist:
                        raise NotUserQuestionReply ("This is not a reply to a question")

                    # It's a reply, but who posed the initial question?

                    if not reply_relation.right_comment.user == user:
                        raise NotUserQuestionReply ("This is a reply to a question posed by another user; user cannot accept it")

                comment_response = CommentResponse(user=user, \
                                                   comment=comment, \
                                                   type=response_type) 
                comment_response.save()

                status = 201
                data['uri'] = "/api/%s/comments/%s/responses/%s/" % \
                              (output_format, comment_id, \
                               comment_response.id)
                    
            elif request.method == 'PUT':
                raise MethodUnsupported("PUT is not supported at this time.")

            elif request.method == 'DELETE':
                raise MethodUnsupported("DELETE is not supported at this time.")
                
            else:
                # GET 
                raise MethodUnsupported("GET is not supported at this time.")

        else:
            # Non-AJAX request.  Disallow for now.
            raise NonAjaxRequest( \
                "Remote API calls aren't allowed right now. " + \
                "This might change some day.")

    except ObjectDoesNotExist, e:
        status = 404 # Not found
        data['error'] = "%s" % e
    except NonAjaxRequest, e:
        status = 403 # Forbidden
        data['error'] = "%s" % e
    except MissingParameter, e:
        status = 400 # Bad Request
        data['error'] = "%s" % e

    except RecentlyResponded, e: