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)