models.py 7.4 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
luxcem's avatar
luxcem committed
4

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

11
from io import BytesIO
12
from datetime import datetime
13

cynddl's avatar
cynddl committed
14
from rp.utils import cleanup_url
15

cynddl's avatar
cynddl committed
16

17 18
ARTICLE_SCORE_THRESHOLD = 3

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

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

32 33 34 35 36 37 38 39 40 41
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
42

luxcem's avatar
luxcem committed
43
class Article(VoteMixin):
cynddl's avatar
cynddl committed
44
    #: Logical state (eg. article submitted, published, or rejected)
45 46 47
    # This is unprotected because superuser should be able to change
    # the status from the django admin interface
    status = FSMField(default='NEW', choices=STATUS_CHOICES)
48

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

161 162
    @transition(field=status, source='NEW', target='NEW',
                permission="rp.can_vote")
163
    @transition(field=status, source='DRAFT', target='DRAFT',
cynddl's avatar
cynddl committed
164
                permission="rp.can_vote")
165
    def downvote(self, by=None):
cynddl's avatar
cynddl committed
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.
cynddl's avatar
cynddl committed
170
        """
171
        super(Article, self).downvote(by)
172

cynddl's avatar
cynddl committed
173
    def add_new_url(url, by=None):
cynddl's avatar
cynddl committed
174 175 176 177
        """ 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
178 179 180 181
        url = cleanup_url(url)
        article, _ = Article.objects.get_or_create(url=url)

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

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

187 188
        # Let's get metadata import
        article.metadata = og.OpenGraph(url=url)
cynddl's avatar
cynddl committed
189 190 191
        article.save()
        return article

192
    # Content extraction
193

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

dave's avatar
dave committed
200 201 202 203
        article.download()
        article.parse()
        self.title = article.title
        self.extracts = article.text
cynddl's avatar
cynddl committed
204
        self.save()
205

206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228
    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)