models.py 7.29 KB
Newer Older
cynddl's avatar
cynddl committed
1
from django.db import models
cynddl's avatar
cynddl committed
2
from django.utils.translation import ugettext_lazy as _
3
from django.core import files
4
from django.core.files.base import ContentFile
luxcem's avatar
luxcem committed
5

cynddl's avatar
cynddl committed
6
from taggit.managers import TaggableManager
luxcem's avatar
luxcem committed
7
from newspaper import Article as ArticleParser
luxcem's avatar
luxcem committed
8
from django_und.models import VoteMixin
9
from django_fsm import FSMField, transition, RETURN_VALUE
luxcem's avatar
luxcem committed
10

11
from io import BytesIO
12
from datetime import datetime
13 14 15
from tempfile import NamedTemporaryFile

from project.settings import env
cynddl's avatar
cynddl committed
16
from rp.utils import cleanup_url
17

cynddl's avatar
cynddl committed
18

19 20
ARTICLE_SCORE_THRESHOLD = 3

luxcem's avatar
luxcem committed
21
STATUS_CHOICES = (
22 23
    ("NEW", _("New")),
    ("DRAFT", _("Draft")),
luxcem's avatar
luxcem committed
24 25 26 27
    ("PUBLISHED", _("Published")),
    ("REJECTED", _("Rejected"))
)

28 29 30 31 32 33
LANG_CHOICES = (
    ("FR", _("French")),
    ("EN", _("English")),
    ("NA", _("Other"))
)

34 35 36 37 38 39 40 41 42 43
URL_HELP_TEXT = """The URL should not contain any marketing tags. We
automatically strip the most known tags."""

TITLE_HELP_TEXT = """Please remove non-necessary parts such as newspapers'
names and leave only the article title."""

EXTRACTS_HELP_TEXT = """Please select short and helpful extracts from the
article content. You should aim at around 500 characters. Use bracket ellipsis
[…] to cut parts not required to understand the context."""

luxcem's avatar
luxcem committed
44

luxcem's avatar
luxcem committed
45
class Article(VoteMixin):
cynddl's avatar
cynddl committed
46
    #: Logical state (eg. article submitted, published, or rejected)
47 48
    status = FSMField(default='NEW', choices=STATUS_CHOICES, protected=True)

cynddl's avatar
cynddl committed
49
    #: Original URL
50
    url = models.URLField("URL", help_text=URL_HELP_TEXT)
cynddl's avatar
cynddl committed
51 52

    #: Language of the webpage
53 54
    lang = models.CharField(
        _("Language"), choices=LANG_CHOICES, default="NA", max_length=50)
cynddl's avatar
cynddl committed
55 56

    #: Plain-text Opengraph metadata
57 58
    metadata = models.TextField(
        _("Opengraph metadata"), blank=True, null=True)
cynddl's avatar
cynddl committed
59 60

    #: Screenshot or banner image for the original webpage
61 62
    screenshot = models.ImageField(
        _("Article screenshot"), blank=True, null=True)
cynddl's avatar
cynddl committed
63 64

    #: Article title
65 66 67
    title = models.CharField(
        _("Article title"), max_length=255, default="",
        help_text=TITLE_HELP_TEXT)
cynddl's avatar
cynddl committed
68 69

    #: Short name for the website (eg. "NY Times")
cynddl's avatar
cynddl committed
70
    website = models.CharField(_("Website"), max_length=255, default="")
cynddl's avatar
cynddl committed
71 72

    #: Short content extracts (eg. two to three paragraphs)
73 74 75
    extracts = models.TextField(
        _("Content extracts"), blank=True, null=True,
        help_text=EXTRACTS_HELP_TEXT)
cynddl's avatar
cynddl committed
76

cynddl's avatar
cynddl committed
77
    #: First submission date
cynddl's avatar
cynddl committed
78
    created_at = models.DateTimeField(_("Creation date"), auto_now_add=True)
cynddl's avatar
cynddl committed
79 80

    #: Name of the user who first submitted the article
81
    created_by = models.CharField(max_length=255, null=True)
cynddl's avatar
cynddl committed
82 83

    #: Last update date
cynddl's avatar
cynddl committed
84
    updated_at = models.DateTimeField(_("Last update"), auto_now=True)
cynddl's avatar
cynddl committed
85 86

    #: Published date
87 88
    published_at = models.DateTimeField(
        _("Publication date"), blank=True, null=True)
cynddl's avatar
cynddl committed
89

luxcem's avatar
luxcem committed
90 91
    #: priority: True if article have priority
    priority = models.BooleanField(default=False)
92

cynddl's avatar
cynddl committed
93
    #: List of short tags to describe the article (eg. "Privacy", "Copyright")
94
    tags = TaggableManager(blank=True)
95

96 97 98
    class Meta:
        verbose_name = _("Article")
        verbose_name_plural = _("Articles")
cynddl's avatar
cynddl committed
99

luxcem's avatar
luxcem committed
100 101 102 103
        permissions = (
            ("can_change_status", "Can change article status"),
            ("can_change_priority", "Can change article priority"),
            ("can_vote", "Can vote articles"),
104
            ("can_edit", "Can edit articles")
luxcem's avatar
luxcem committed
105
        )
luxcem's avatar
luxcem committed
106

cynddl's avatar
cynddl committed
107
        #: By default, sort articles by published, updated, or created date
108 109
        ordering = ["-published_at", "-updated_at", "-created_at"]

luxcem's avatar
luxcem committed
110
    def __str__(self):
cynddl's avatar
cynddl committed
111
        """ Returns article title. """
luxcem's avatar
luxcem committed
112
        return self.title
dave's avatar
dave committed
113

114
    # Finite state logic
115

116
    @transition(field=status, source='DRAFT', target='PUBLISHED',
cynddl's avatar
cynddl committed
117
                permission="rp.can_change_status")
118
    def publish(self):
cynddl's avatar
cynddl committed
119
        """ Publish a complete draft. """
120 121 122
        self.published_at = datetime.now()

    @transition(field=status, source='NEW', target='DRAFT',
cynddl's avatar
cynddl committed
123
                permission="rp.can_change_status")
124
    def recover(self):
cynddl's avatar
cynddl committed
125
        """ Force an article to be considered as a draft. """
126 127 128
        pass

    @transition(field=status, source=['NEW', 'DRAFT'], target='REJECTED',
cynddl's avatar
cynddl committed
129
                permission="rp.can_change_status")
130
    def reject(self):
cynddl's avatar
cynddl committed
131
        """ Manual rejection of the article. """
132 133 134
        pass

    @transition(field=status, source='DRAFT', target='DRAFT',
cynddl's avatar
cynddl committed
135
                permission="rp.can_change_priority")
136
    def set_priority(self):
cynddl's avatar
cynddl committed
137
        """ Set the boolean priority of an article to True. """
138 139 140
        self.priority = True

    @transition(field=status, source='DRAFT', target='DRAFT',
cynddl's avatar
cynddl committed
141
                permission="rp.can_change_priority")
142
    def unset_priority(self):
cynddl's avatar
cynddl committed
143
        """ Set the boolean priority of an article to False. """
144
        self.priority = False
145 146 147

    @transition(field=status, source='DRAFT', target='DRAFT')
    @transition(field=status, source='NEW',
cynddl's avatar
cynddl committed
148
                target=RETURN_VALUE('NEW', 'DRAFT'), permission="rp.can_vote")
149
    def upvote(self, by=None):
cynddl's avatar
cynddl committed
150 151 152 153 154
        """
        Upvote the article score for the given user and remove previous votes.
        If the score crosses the threshold ```ARTICLE_SCORE_THRESHOLD```,
        automatically moves the article from _NEW_ to _DRAFT_.
        """
155
        super(Article, self).upvote(by)
156 157 158 159 160
        if self.und_score >= ARTICLE_SCORE_THRESHOLD:
            return 'DRAFT'
        else:
            return self.status

cynddl's avatar
cynddl committed
161
    @transition(field=status, source='NEW', target='NEW', permission="rp.can_vote")
162
    @transition(field=status, source='DRAFT', target='DRAFT',
cynddl's avatar
cynddl committed
163
                permission="rp.can_vote")
164
    def downvote(self, by=None):
cynddl's avatar
cynddl committed
165 166 167 168 169
        """
        Downvote the article score for the given user and remove previous votes.
        Draft articles can be downvoted but will not be moved back in the
        _NEW_ queue.
        """
170
        super(Article, self).downvote(by)
171

cynddl's avatar
cynddl committed
172
    def add_new_url(url, by=None):
cynddl's avatar
cynddl committed
173 174 175 176
        """ Manually add a new article from its URL.
        Verify if the article has not been submitted before and automatically
        upvote for the given user if applicable.
        """
cynddl's avatar
cynddl committed
177 178 179 180
        url = cleanup_url(url)
        article, _ = Article.objects.get_or_create(url=url)

        if article.created_by is None:
181
            article.created_by = str(by)
cynddl's avatar
cynddl committed
182 183 184 185 186 187 188

        if by is not None:
            article.upvote(by)

        article.save()
        return article

189
    # Content extraction
190

191
    def fetch_content(self):
cynddl's avatar
cynddl committed
192
        if self.lang != "NA":
193
            article = ArticleParser(url=self.url, language=self.lang.lower())
cynddl's avatar
cynddl committed
194 195 196
        else:
            article = ArticleParser(url=self.url)

dave's avatar
dave committed
197 198 199 200
        article.download()
        article.parse()
        self.title = article.title
        self.extracts = article.text
cynddl's avatar
cynddl committed
201
        self.save()
202

203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225
    def fetch_image(self):
        import requests
        import imghdr

        if self.lang != "NA":
            article = ArticleParser(url=self.url, language=self.lang.lower())
        else:
            article = ArticleParser(url=self.url)

        article.download()
        article.parse()

        img_path = article.meta_img
        if img_path:
            resp = requests.get(img_path, stream=True)
            if resp.status_code == requests.codes.ok:
                fp = BytesIO()
                fp.write(resp.content)

                file_name_ext = imghdr.what(None, resp.content)
                self.screenshot.save(
                    "screenshot-{0}.{1}".format(self.id, file_name_ext),
                    files.File(fp), save=True)