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

10
from io import BytesIO
11
from datetime import datetime
12

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

cynddl's avatar
cynddl committed
15

16
17
ARTICLE_SCORE_THRESHOLD = 3

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

113
    # Finite state logic
114

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

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

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

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

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

    @transition(field=status, source='DRAFT', target='DRAFT')
    @transition(field=status, source='NEW',
cynddl's avatar
cynddl committed
147
                target=RETURN_VALUE('NEW', 'DRAFT'), permission="rp.can_vote")
148
    def upvote(self, by=None):
cynddl's avatar
cynddl committed
149
150
151
152
153
        """
        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_.
        """
154
        super(Article, self).upvote(by)
155
156
157
158
159
        if self.und_score >= ARTICLE_SCORE_THRESHOLD:
            return 'DRAFT'
        else:
            return self.status

160
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
        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
169
        """
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
    def fetch_metadata(self):
        import opengraph_py3 as og

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

        try:
            metadata = og.OpenGraph(url=self.url)
            article.metadata = metadata.to_json()
            article.save()
        except Exception:
            pass

218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
    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)