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)