Princeton-CDH / mep-django
Showing 21 of 34 files from the diff.

@@ -31,6 +31,8 @@
Loading
31 31
32 32
33 33
def creator_lastname(work):
34 +
    '''Get the lastname of the first creator (first author or first
35 +
    editor if no authors) on this work.'''
34 36
    creators = work.creator_set.all()
35 37
    lastname = ''
36 38
    if creators.exists():

@@ -34,9 +34,7 @@
Loading
34 34
        [creator.lower() for creator in creator_types] + [
35 35
        "year",
36 36
        "format",
37 -
        "identified",
38 -
        "work_uri",
39 -
        "edition_uri",
37 +
        "uncertain",
40 38
        "ebook_url",
41 39
        "volumes_issues",
42 40
        "notes",
@@ -76,13 +74,8 @@
Loading
76 74
        if work.work_format:
77 75
            data['format'] = work.work_format.name
78 76
79 -
        # identified: true unless work is marked as uncertain
80 -
        data['identified'] = not work.is_uncertain
77 +
        data['uncertain'] = work.is_uncertain
81 78
82 -
        if work.uri:
83 -
            data['work_uri'] = work.uri
84 -
        if work.edition_uri:
85 -
            data['edition_uri'] = work.edition_uri
86 79
        if work.ebook_url:
87 80
            data['ebook_url'] = work.ebook_url
88 81
        # text listing of volumes/issues

@@ -26,7 +26,7 @@
Loading
26 26
    csv_fields = [
27 27
        'event_type',
28 28
        'start_date', 'end_date',
29 -
        'member_sort_names', 'member_names', 'member_URIs',
29 +
        'member_URIs', 'member_names', 'member_sort_names',
30 30
        # subscription specific
31 31
        'subscription_price_paid', 'subscription_deposit',
32 32
        'subscription_duration', 'subscription_duration_days',
@@ -39,8 +39,8 @@
Loading
39 39
        # purchase specific
40 40
        'purchase_price',
41 41
        # related book/item
42 -
        'item_uri', 'item_title', 'item_work_uri', 'item_volume',
43 -
        'item_notes',
42 +
        'item_uri', 'item_title', 'item_volume', 'item_authors',
43 +
        'item_year', 'item_notes',
44 44
        # footnote/citation
45 45
        'source_citation', 'source_manifest', 'source_image'
46 46
    ]
@@ -114,9 +114,9 @@
Loading
114 114
            return
115 115
116 116
        return OrderedDict([
117 -
            ('sort_names', [m.sort_name for m in members]),
117 +
            ('URIs', [absolutize_url(m.get_absolute_url()) for m in members]),
118 118
            ('names', [m.name for m in members]),
119 -
            ('URIs', [absolutize_url(m.get_absolute_url()) for m in members])
119 +
            ('sort_names', [m.sort_name for m in members])
120 120
        ])
121 121
122 122
    def subscription_info(self, event):
@@ -153,8 +153,11 @@
Loading
153 153
            ])
154 154
            if event.edition:
155 155
                item_info['volume'] = event.edition.display_text()
156 -
            if event.work.uri:
157 -
                item_info['work_uri'] = event.work.uri
156 +
            if event.work.authors:
157 +
                item_info['authors'] = [a.sort_name
158 +
                                        for a in event.work.authors]
159 +
            if event.work.year:
160 +
                item_info['year'] = event.work.year
158 161
            if event.work.public_notes:
159 162
                item_info['notes'] = event.work.public_notes
160 163

@@ -391,8 +391,8 @@
Loading
391 391
        assert data['title'] == exit_e.title
392 392
        assert data['year'] == exit_e.year
393 393
        assert data['format'] == exit_e.work_format.name
394 -
        assert data['identified']  # not marked uncertain
395 -
        assert data['work_uri'] == exit_e.uri
394 +
        assert not data['uncertain']  # not marked uncertain
395 +
        assert 'work_uri' not in data
396 396
        assert 'author' in data
397 397
        # missing data should not be in the dict
398 398
        for field in ['edition uri', 'ebook url', 'volumes/issues']:
@@ -408,9 +408,10 @@
Loading
408 408
        dial = Work.objects.count_events().get(slug='dial')
409 409
        data = self.cmd.get_object_data(dial)
410 410
        assert 'year' not in data
411 -
        assert data['edition_uri'] == dial.edition_uri
411 +
        assert 'edition_uri' not in data
412 412
        assert data['ebook_url'] == dial.ebook_url
413 413
        assert 'volumes_issues' in data
414 +
        assert data['format'] == dial.work_format.name
414 415
        for vol in dial.edition_set.all():
415 416
            assert vol.display_text() in data['volumes_issues']
416 417
        assert data['circulation_years'] == [1936]

@@ -1,4 +1,4 @@
Loading
1 -
__version_info__ = (1, 1, 1, None)
1 +
__version_info__ = (1, 2, 0, None)
2 2
3 3
4 4
# Dot-connect all but the last. Last is dash-connected if not None.

@@ -2,12 +2,14 @@
Loading
2 2
import os
3 3
from unittest.mock import Mock, patch
4 4
5 +
from django.core.exceptions import ValidationError
5 6
from django.test import TestCase
7 +
import pytest
6 8
import requests
7 9
8 10
from mep.accounts.models import Account, Borrow, Event, Purchase
9 11
from mep.books.models import Creator, CreatorType, Edition, EditionCreator, \
10 -
    Format, Genre, Subject, Work
12 +
    Format, Genre, Subject, Work, PastWorkSlug
11 13
from mep.books.tests.test_oclc import FIXTURE_DIR
12 14
from mep.books.utils import work_slug
13 15
from mep.people.models import Person
@@ -336,6 +338,30 @@
Loading
336 338
        unclear3.generate_slug()
337 339
        assert unclear3.slug == 'unclear-3'
338 340
341 +
    def test_save_old_slug(self):
342 +
        work = Work.objects.create(title='unclear', slug='unclear-1')
343 +
        work.slug = 'unclear'
344 +
        work.save()
345 +
        assert work.past_slugs.first().slug == 'unclear-1'
346 +
347 +
        # unsaved with slug — should not error or create past slug
348 +
        work = Work(title='Foo')
349 +
        work.slug = 'new'
350 +
        work.save()
351 +
        assert not work.past_slugs.count()
352 +
353 +
    def test_validate_unique(self):
354 +
        # create a work
355 +
        work = Work.objects.create(title='unclear', slug='unclear')
356 +
        # with a past slug
357 +
        PastWorkSlug.objects.create(work=work, slug='uncle')
358 +
        # no errors
359 +
        work.validate_unique()
360 +
        # attempt to re-use past slug
361 +
        work2 = Work(title='Uncle Tom', slug='uncle')
362 +
        with pytest.raises(ValidationError):
363 +
            work2.validate_unique()
364 +
339 365
    def test_is_uncertain(self):
340 366
        # work without notes should not show icon
341 367
        work1 = Work(title='My Book')

@@ -1,6 +1,6 @@
Loading
1 1
from django.conf.urls import url
2 2
3 -
from mep.accounts.views import AccountAutocomplete
3 +
from mep.accounts.views import AccountAutocomplete, Twitter100yearsReview
4 4
5 5
# url namespace
6 6
app_name = 'accounts'
@@ -8,4 +8,5 @@
Loading
8 8
urlpatterns = [
9 9
    url(r'^accounts/autocomplete/$', AccountAutocomplete.as_view(),
10 10
        name='autocomplete'),
11 +
    url(r'^events/100-years-review/$', Twitter100yearsReview.as_view()),
11 12
]

@@ -0,0 +1,474 @@
Loading
1 +
import sys
2 +
import uuid
3 +
from collections import OrderedDict
4 +
from datetime import date
5 +
from io import StringIO
6 +
from unittest.mock import patch
7 +
8 +
from dateutil.relativedelta import relativedelta
9 +
from django.core.management import call_command
10 +
from django.core.management.base import CommandError
11 +
from django.test import TestCase, override_settings
12 +
from django.urls import reverse
13 +
import pytest
14 +
15 +
from mep.accounts.models import Event
16 +
from mep.accounts.management.commands import twitterbot_100years
17 +
from mep.accounts.templatetags import mep_100years_tags
18 +
from mep.accounts.views import Twitter100yearsReview
19 +
from mep.books.models import Creator, CreatorType, Work
20 +
from mep.people.models import Person
21 +
22 +
23 +
class TestTwitterBot100years(TestCase):
24 +
    fixtures = ['test_events']
25 +
26 +
    def setUp(self):
27 +
        self.cmd = twitterbot_100years.Command()
28 +
        self.cmd.stdout = StringIO()
29 +
30 +
    def test_command_line(self):
31 +
        # test calling via command line with args
32 +
        stdout = StringIO()
33 +
        reimb = Event.objects.filter(reimbursement__isnull=False,
34 +
                                     start_date__isnull=False).first()
35 +
        call_command('twitterbot_100years', 'report', '-d',
36 +
                     reimb.partial_start_date, stdout=stdout)
37 +
        output = stdout.getvalue()
38 +
        assert 'Event id: %s' % reimb.pk in output
39 +
        assert tweet_content(reimb, reimb.start_date) in output
40 +
41 +
        # get the date 100 years ago
42 +
        day = self.cmd.get_date()
43 +
44 +
        # call schedule
45 +
        with patch.object(twitterbot_100years.Command,
46 +
                          'schedule') as mock_schedule:
47 +
            call_command('twitterbot_100years', 'schedule')
48 +
            assert mock_schedule.call_count == 1
49 +
            mock_schedule.assert_called_with(day)
50 +
51 +
        # call tweet
52 +
        with patch.object(twitterbot_100years.Command,
53 +
                          'tweet') as mock_tweet:
54 +
            # call with valid id
55 +
            call_command('twitterbot_100years', 'tweet', '-e', reimb.pk)
56 +
            assert mock_tweet.call_count == 1
57 +
            mock_tweet.assert_called_with(reimb, day)
58 +
59 +
            # call with invalid id
60 +
            with pytest.raises(CommandError):
61 +
                call_command('twitterbot_100years', 'tweet', '-e', 'foo')
62 +
63 +
    def test_get_date(self):
64 +
        # by default, date is relative to today
65 +
        reldate = date.today() - relativedelta(years=100)
66 +
        assert self.cmd.get_date() == reldate
67 +
        # date ignored if mode is not report
68 +
        assert self.cmd.get_date(date='1920-05-03') == reldate
69 +
        # use date specified
70 +
        assert self.cmd.get_date(date='1920-05-03', mode='report') \
71 +
            == date(1920, 5, 3)
72 +
        with pytest.raises(CommandError):
73 +
            self.cmd.get_date(date='1920-05', mode='report')
74 +
75 +
    def test_find_events(self):
76 +
        borrow = Event.objects.filter(borrow__isnull=False).first()
77 +
        # borrow — both start date and end date
78 +
        assert borrow in self.cmd.find_events(borrow.start_date)
79 +
        assert borrow in self.cmd.find_events(borrow.end_date)
80 +
        # borrows of uncertain items excluded
81 +
        borrow2 = Event.objects.filter(borrow__isnull=False).last()
82 +
        work = borrow2.work
83 +
        work.notes = 'UNCERTAINTYICON'
84 +
        work.save()
85 +
        assert borrow2 not in self.cmd.find_events(borrow2.start_date)
86 +
87 +
        subs = Event.objects.filter(subscription__isnull=False,
88 +
                                    start_date__isnull=False).first()
89 +
        subs.subscription.partial_purchase_date = subs.partial_start_date
90 +
        subs.save()
91 +
        # start/purchase date
92 +
        assert subs in self.cmd.find_events(subs.subscription.purchase_date)
93 +
        # not included based on end date
94 +
        assert subs not in self.cmd.find_events(subs.end_date)
95 +
        # different purchase date
96 +
        subs_sub = subs.subscription
97 +
        subs_sub.partial_purchase_date = '1936-11-15'
98 +
        subs_sub.save()
99 +
        assert subs in self.cmd.find_events(subs_sub.purchase_date)
100 +
101 +
    def test_report(self):
102 +
        reimb = Event.objects.filter(reimbursement__isnull=False,
103 +
                                     start_date__isnull=False).first()
104 +
        self.cmd.report(date=reimb.start_date)
105 +
        output = self.cmd.stdout.getvalue()
106 +
        assert 'Event id: %s' % reimb.pk in output
107 +
        assert tweet_content(reimb, reimb.start_date) in output
108 +
109 +
    @patch.object(twitterbot_100years.Command, 'tweet_at')
110 +
    @patch.object(twitterbot_100years.Command, 'find_events')
111 +
    @patch('mep.accounts.management.commands.twitterbot_100years.can_tweet')
112 +
    def test_schedule(self, mock_can_tweet, mock_find_events, mock_tweet_at):
113 +
        borrow = Event.objects.filter(borrow__isnull=False).first()
114 +
        borrow2 = Event.objects.filter(borrow__isnull=False).last()
115 +
        subs = Event.objects.filter(subscription__isnull=False,
116 +
                                    start_date__isnull=False).first()
117 +
        # mock to return multiple to test filtering & scheduling
118 +
        mock_find_events.return_value = [borrow, borrow2, subs]
119 +
        # can tweet first and last but not second
120 +
        mock_can_tweet.side_effect = (True, False, True)
121 +
        self.cmd.schedule(borrow.start_date)
122 +
        assert mock_tweet_at.call_count == 2
123 +
        mock_tweet_at.assert_any_call(borrow, self.cmd.tweet_times[0])
124 +
        mock_tweet_at.assert_any_call(subs, self.cmd.tweet_times[1])
125 +
126 +
    @patch('mep.accounts.management.commands.twitterbot_100years.subprocess')
127 +
    def test_tweet_at(self, mock_subprocess):
128 +
        event = Event.objects.filter(start_date__isnull=False).first()
129 +
        self.cmd.tweet_at(event, '9:00')
130 +
        assert mock_subprocess.run.call_count == 1
131 +
        args, kwargs = mock_subprocess.run.call_args
132 +
        # sanity check subprocess call
133 +
        assert args[0][0] == '/usr/bin/at'
134 +
        assert args[0][1] == '9:00'
135 +
        command = kwargs['input'].decode()
136 +
        assert command.startswith('bin/cron-wrapper')
137 +
        assert sys.executable in command
138 +
        assert command.endswith(
139 +
            'manage.py twitterbot_100years tweet --event %d' % event.pk)
140 +
141 +
    @patch.object(twitterbot_100years.Command, 'get_tweepy')
142 +
    @patch('mep.accounts.management.commands.twitterbot_100years.tweet_content')
143 +
    def test_tweet(self, mock_tweet_content, mock_get_tweepy):
144 +
        reimb = Event.objects.filter(reimbursement__isnull=False,
145 +
                                     start_date__isnull=False).first()
146 +
        mock_tweet_content.return_value = 'something'
147 +
        self.cmd.tweet(reimb, date.today())
148 +
        # can tweet is false, not tweeted
149 +
        assert mock_get_tweepy.call_count == 0
150 +
151 +
        # can tweet but no tweet content
152 +
        mock_tweet_content.return_value = None
153 +
        self.cmd.tweet(reimb, reimb.start_date)
154 +
        assert mock_get_tweepy.call_count == 0
155 +
156 +
        # simulate successful tweet
157 +
        mock_tweet_content.return_value = 'something'
158 +
        self.cmd.tweet(reimb, reimb.start_date)
159 +
        assert mock_get_tweepy.call_count == 1
160 +
        mock_api = mock_get_tweepy.return_value
161 +
        mock_api.update_status.assert_called_with('something')
162 +
163 +
    @patch('mep.accounts.management.commands.twitterbot_100years.tweepy')
164 +
    def test_get_tweepy(self, mock_tweepy):
165 +
        # error if not configured
166 +
        with override_settings(TWITTER_100YEARS=None):
167 +
            with pytest.raises(CommandError):
168 +
                self.cmd.get_tweepy()
169 +
170 +
            assert mock_tweepy.OAuthHandler.call_count == 0
171 +
172 +
        api_key = uuid.uuid4()
173 +
        api_secret = uuid.uuid4()
174 +
        access_token = uuid.uuid4()
175 +
        access_secret = uuid.uuid4()
176 +
        mock_config = {
177 +
            'API': {
178 +
                'key': api_key,
179 +
                'secret_key': api_secret
180 +
            },
181 +
            'ACCESS': {
182 +
                'token': access_token,
183 +
                'secret': access_secret
184 +
            }
185 +
        }
186 +
187 +
        with override_settings(TWITTER_100YEARS=mock_config):
188 +
            self.cmd.get_tweepy()
189 +
            mock_tweepy.OAuthHandler.assert_called_with(api_key, api_secret)
190 +
            auth = mock_tweepy.OAuthHandler.return_value
191 +
            auth.set_access_token.assert_called_with(access_token,
192 +
                                                     access_secret)
193 +
            mock_tweepy.API.assert_called_with(auth)
194 +
195 +
196 +
class TestWorkLabel(TestCase):
197 +
    fixtures = ['sample_works']
198 +
199 +
    def test_authors(self):
200 +
        # - standard format: author's "title" (year), but handle multiple
201 +
202 +
        # no author, no year
203 +
        blue_train = Work.objects.get(pk=5)
204 +
        assert twitterbot_100years.work_label(blue_train) == \
205 +
            '“Murder on the Blue Train.”'
206 +
207 +
        # single author with years
208 +
        exit_eliza = Work.objects.get(pk=1)
209 +
        assert twitterbot_100years.work_label(exit_eliza) == \
210 +
            "Barry Pain’s “Exit Eliza” (1912)"
211 +
212 +
        # add second author
213 +
        auth2 = Person.objects.create(name='Lara Cain', sort_name='Cain, Lara',
214 +
                                      slug='cain')
215 +
        author = CreatorType.objects.get(name='Author')
216 +
        Creator.objects.create(person=auth2, work=exit_eliza,
217 +
                               creator_type=author, order=2)
218 +
        assert twitterbot_100years.work_label(exit_eliza) == \
219 +
            "Barry Pain and Lara Cain’s “Exit Eliza” (1912)"
220 +
221 +
        # add third author
222 +
        auth3 = Person.objects.create(name='Mary Fain', sort_name='Fain, Mary',
223 +
                                      slug='fain')
224 +
        Creator.objects.create(person=auth3, work=exit_eliza,
225 +
                               creator_type=author, order=3)
226 +
        assert twitterbot_100years.work_label(exit_eliza) == \
227 +
            "Barry Pain et al.’s “Exit Eliza” (1912)"
228 +
229 +
    def test_editors(self):
230 +
        # editors listed if no author but editors
231 +
232 +
        # get work with no author
233 +
        blue_train = Work.objects.get(pk=5)
234 +
235 +
        # add editor
236 +
        ed1 = Person.objects.create(name='Lara Cain', sort_name='Cain, Lara',
237 +
                                    slug='cain')
238 +
        editor = CreatorType.objects.get(name='Editor')
239 +
        Creator.objects.create(person=ed1, work=blue_train,
240 +
                               creator_type=editor, order=1)
241 +
        assert twitterbot_100years.work_label(blue_train) == \
242 +
            "“Murder on the Blue Train,” edited by Lara Cain"
243 +
        # add second editor
244 +
        ed2 = Person.objects.create(name='Mara Vain', sort_name='Vain, Mara',
245 +
                                    slug='vain')
246 +
        Creator.objects.create(person=ed2, work=blue_train,
247 +
                               creator_type=editor, order=2)
248 +
        assert twitterbot_100years.work_label(blue_train) == \
249 +
            "“Murder on the Blue Train,” edited by Lara Cain and Mara Vain"
250 +
        # third editor
251 +
        ed3 = Person.objects.create(name='Nara Wain', sort_name='Wain, Nara',
252 +
                                    slug='wain')
253 +
        Creator.objects.create(person=ed3, work=blue_train,
254 +
                               creator_type=editor, order=3)
255 +
        assert twitterbot_100years.work_label(blue_train) == \
256 +
            "“Murder on the Blue Train,” edited by Lara Cain et al."
257 +
258 +
    def test_year(self):
259 +
        # set pre-modern publication date  — should not be included
260 +
        # - should include period inside quotes
261 +
        exit_eliza = Work.objects.get(pk=1)
262 +
        exit_eliza.year = 1350
263 +
        assert twitterbot_100years.work_label(exit_eliza) == \
264 +
            "Barry Pain’s “Exit Eliza.”"
265 +
266 +
    def test_periodical(self):
267 +
        the_dial = Work.objects.get(pk=4598)
268 +
        assert twitterbot_100years.work_label(the_dial) == \
269 +
            "an issue of “The Dial.”"
270 +
271 +
272 +
class TestCanTweet(TestCase):
273 +
    fixtures = ['test_events']
274 +
275 +
    def test_reimbursement(self):
276 +
        reimb = Event.objects.filter(reimbursement__isnull=False,
277 +
                                     start_date__isnull=False).first()
278 +
        assert twitterbot_100years.can_tweet(reimb, reimb.start_date)
279 +
        assert not twitterbot_100years.can_tweet(reimb, date.today())
280 +
281 +
    def test_borrow(self):
282 +
        # borrow with fully known start and end date
283 +
        borrow = Event.objects.filter(
284 +
            borrow__isnull=False, start_date__isnull=False,
285 +
            end_date__isnull=False, start_date_precision=7).first()
286 +
        # can tweet on start date
287 +
        assert twitterbot_100years.can_tweet(borrow, borrow.start_date)
288 +
        # can tweet on end date
289 +
        assert twitterbot_100years.can_tweet(borrow, borrow.end_date)
290 +
        # cannot tweet on other dats
291 +
        assert not twitterbot_100years.can_tweet(borrow, date.today())
292 +
293 +
        # borrow with partially known date
294 +
        borrow = Event.objects.filter(start_date_precision=6).first()
295 +
        # cannot tweet on any date
296 +
        assert not twitterbot_100years.can_tweet(borrow, borrow.start_date)
297 +
298 +
    def test_purchase(self):
299 +
        purchase = Event.objects.filter(purchase__isnull=False).first()
300 +
        assert twitterbot_100years.can_tweet(purchase, purchase.start_date)
301 +
        assert not twitterbot_100years.can_tweet(purchase, date.today())
302 +
303 +
    def test_subscription(self):
304 +
        # regular subscription with precisely known dates
305 +
        subs = Event.objects.get(pk=8810)
306 +
        # purchase date same as start date
307 +
        subs.subscription.purchase_date = subs.start_date
308 +
        assert twitterbot_100years.can_tweet(subs, subs.start_date)
309 +
        assert not twitterbot_100years.can_tweet(subs, subs.end_date)
310 +
        # purchase date different from start date - can't tweet on start
311 +
        subs.subscription.purchase_date = subs.end_date
312 +
        assert not twitterbot_100years.can_tweet(subs, subs.start_date)
313 +
314 +
315 +
class TestCardUrl(TestCase):
316 +
    fixtures = ['test_events']
317 +
318 +
    def test_card_url(self):
319 +
        ev = Event.objects.filter(footnotes__isnull=False).first()
320 +
        member = ev.account.persons.first()
321 +
        assert twitterbot_100years.card_url(member, ev) == \
322 +
            '%s#e%d' % (
323 +
                reverse('people:member-card-detail', kwargs={
324 +
                        'slug': member.slug,
325 +
                        'short_id': ev.footnotes.first().image.short_id}),
326 +
                ev.id)
327 +
328 +
        ev = Event.objects.filter(footnotes__isnull=True).first()
329 +
        assert not twitterbot_100years.card_url(member, ev)
330 +
331 +
332 +
tweet_content = twitterbot_100years.tweet_content
333 +
334 +
335 +
class TestTweetContent(TestCase):
336 +
    fixtures = ['test_events']
337 +
338 +
    def test_reimbursement(self):
339 +
        reimb = Event.objects.filter(reimbursement__isnull=False,
340 +
                                     start_date__isnull=False).first()
341 +
        tweet = tweet_content(reimb, reimb.partial_start_date)
342 +
        assert reimb.start_date \
343 +
            .strftime(twitterbot_100years.Command.date_format) in tweet
344 +
        assert reimb.account.persons.first().name in tweet
345 +
        refund = 'received a reimbursement for %s%s' % \
346 +
            (reimb.reimbursement.refund,
347 +
             reimb.reimbursement.currency_symbol())
348 +
        assert refund in tweet
349 +
        assert reimb.account.persons.first().get_absolute_url() in tweet
350 +
351 +
        # partial date = no tweet content
352 +
        assert not tweet_content(reimb, reimb.start_date.strftime('%Y'))
353 +
        assert not tweet_content(reimb, reimb.start_date.strftime('%Y-%m'))
354 +
355 +
    def test_borrow(self):
356 +
        # borrow with fully known start and end date
357 +
        borrow = Event.objects.filter(
358 +
            borrow__isnull=False, start_date__isnull=False,
359 +
            end_date__isnull=False, start_date_precision=7).first()
360 +
        # borrow
361 +
        tweet = tweet_content(borrow, borrow.partial_start_date)
362 +
        borrowed = '%s borrowed %s' % \
363 +
            (borrow.account.persons.first().name,
364 +
             twitterbot_100years.work_label(borrow.work))
365 +
        assert borrowed in tweet
366 +
367 +
        # return
368 +
        tweet = tweet_content(borrow, borrow.partial_end_date)
369 +
        returned = '%s returned %s' % \
370 +
            (borrow.account.persons.first().name,
371 +
             twitterbot_100years.work_label(borrow.work))
372 +
        assert returned in tweet
373 +
374 +
    def test_subscription(self):
375 +
        # regular subscription with precisely known dates
376 +
        subs = Event.objects.get(pk=8810)
377 +
        # purchase date same as start date
378 +
        subs.subscription.purchase_date = subs.start_date
379 +
        tweet = tweet_content(subs, subs.partial_start_date)
380 +
        assert 'subscribed for 1 month at 2 volumes per month'  \
381 +
            in tweet
382 +
383 +
    def test_tweet_text_tag(self):
384 +
        subs = Event.objects.get(pk=8810)
385 +
        subs.subscription.purchase_date = subs.start_date
386 +
        assert mep_100years_tags.tweet_text(subs, subs.partial_start_date) == \
387 +
            tweet_content(subs, subs.partial_start_date)
388 +
389 +
390 +
class TestTwitter100yearsReview(TestCase):
391 +
    fixtures = ['test_events']
392 +
393 +
    def setUp(self):
394 +
        self.view = Twitter100yearsReview()
395 +
396 +
    def test_get_date_range(self):
397 +
        start, end = self.view.get_date_range()
398 +
        assert start == date.today() - relativedelta(years=100)
399 +
        assert end == start + relativedelta(months=3)
400 +
        assert self.view.date_start == start
401 +
        assert self.view.date_end == end
402 +
403 +
    def test_get_queryset(self):
404 +
        with patch.object(self.view, 'get_date_range') as mock_get_date_range:
405 +
            # borrowed on 11/19, returned 11/20
406 +
            borrow = Event.objects.get(end_date='1936-11-25')
407 +
            mock_get_date_range.return_value = \
408 +
                (date(1936, 11, 20), date(1936, 12, 20))
409 +
            events = self.view.get_queryset()
410 +
            assert len(events) == 1
411 +
            # borrow should be included based on end date
412 +
            assert borrow in events
413 +
414 +
            # after/between fixture dates — no events
415 +
            mock_get_date_range.return_value = \
416 +
                (date(1936, 12, 1), date(1936, 12, 30))
417 +
            assert not self.view.get_queryset().exists()
418 +
419 +
            # uncertain works excluded
420 +
            work = borrow.work
421 +
            work.notes = 'UNCERTAINTYICON'
422 +
            work.save()
423 +
            mock_get_date_range.return_value = \
424 +
                (date(1936, 11, 20), date(1936, 12, 20))
425 +
            assert not self.view.get_queryset().exists()
426 +
427 +
            # ignore partially known dates
428 +
            mock_get_date_range.return_value = \
429 +
                (date(1900, 1, 1), date(1900, 2, 1))
430 +
            assert not self.view.get_queryset().exists()
431 +
432 +
    def test_get_context_data(self):
433 +
        # borrowed on 11/19, returned 11/20
434 +
        borrow = Event.objects.get(end_date='1936-11-25')
435 +
        reimb = Event.objects.filter(reimbursement__isnull=False,
436 +
                                     start_date__isnull=False).first()
437 +
        subs = Event.objects.filter(subscription__isnull=False,
438 +
                                    start_date__isnull=False).first()
439 +
        # fixture includes a borrow with unknown start but known end
440 +
        no_start = Event.objects.filter(start_date__isnull=True,
441 +
                                        end_date__isnull=False).first()
442 +
443 +
        # wide date range for the reimbursement
444 +
        self.view.date_start = date(1936, 11, 20)
445 +
        self.view.date_end = date(1941, 12, 6)
446 +
        self.view.object_list = [borrow, reimb, no_start, subs]
447 +
448 +
        context = self.view.get_context_data()
449 +
        events = context['events_by_date']
450 +
        assert isinstance(events, OrderedDict)
451 +
        # inspect the dictionary of dates and events
452 +
        assert borrow.partial_end_date in events
453 +
        print('partial start %s' % reimb.partial_start_date)
454 +
        assert reimb.partial_start_date in events
455 +
        assert borrow in events[borrow.partial_end_date]
456 +
        assert reimb in events[reimb.partial_start_date]
457 +
        # borrow start before the range should not be present
458 +
        assert borrow.partial_start_date not in events
459 +
        # end date on no start is out of range
460 +
        assert no_start.partial_end_date not in events
461 +
        # no unset keys from unknown dates
462 +
        assert None not in events
463 +
464 +
        # widen the range
465 +
        self.view.date_start = date(1936, 1, 1)
466 +
        context = self.view.get_context_data()
467 +
        events = context['events_by_date']
468 +
        # now includes borrow start and no-start borrow end
469 +
        assert borrow.partial_start_date in events
470 +
        assert no_start.partial_end_date in events
471 +
        # still includes others
472 +
        assert reimb.partial_start_date in events
473 +
474 +
    # not currently testing review template

@@ -18,12 +18,14 @@
Loading
18 18
19 19
from djiffy.models import Canvas, Manifest
20 20
21 -
from mep.accounts.management.commands import import_figgy_cards, \
22 -
    report_timegaps, export_events
21 +
from mep.accounts.management.commands import export_events, \
22 +
    import_figgy_cards, report_timegaps
23 23
from mep.accounts.models import Account, Borrow, Event
24 +
from mep.books.models import Creator, CreatorType
24 25
from mep.common.management.export import StreamArray
25 26
from mep.common.utils import absolutize_url
26 27
from mep.footnotes.models import Bibliography, Footnote
28 +
from mep.people.models import Person
27 29
28 30
29 31
class TestReportTimegaps(TestCase):
@@ -482,7 +484,9 @@
Loading
482 484
        info = self.cmd.item_info(event)
483 485
        assert info['title'] == event.work.title
484 486
        assert info['uri'] == absolutize_url(event.work.get_absolute_url())
485 -
        assert info['work_uri'] == event.work.uri
487 +
        assert 'work_uri' not in info  # no longer included
488 +
        assert 'authors' not in info   # not on this record
489 +
        assert info['year'] == event.work.year
486 490
        assert info['notes'] == event.work.public_notes
487 491
        assert 'volume' not in info
488 492
@@ -503,6 +507,19 @@
Loading
503 507
        event = Event.objects.filter(work__isnull=True).first()
504 508
        assert not self.cmd.item_info(event)
505 509
510 +
        # work with author
511 +
        event = Event.objects.filter(work__isnull=False).first()
512 +
        author1 = Person.objects.create(name='Smith', slug='s')
513 +
        author2 = Person.objects.create(name='Jones', slug='j')
514 +
        author_type = CreatorType.objects.get(name='Author')
515 +
        Creator.objects.create(
516 +
            creator_type=author_type, person=author1, work=event.work)
517 +
        Creator.objects.create(
518 +
            creator_type=author_type, person=author2, work=event.work)
519 +
520 +
        info = self.cmd.item_info(event)
521 +
        assert info['authors'] == [a.sort_name for a in event.work.authors]
522 +
506 523
    def test_source_info(self):
507 524
        # footnote
508 525
        event = Event.objects.filter(footnotes__isnull=False).first()

@@ -1,6 +1,14 @@
Loading
1 +
import datetime
2 +
from collections import defaultdict, OrderedDict
3 +
1 4
from dal import autocomplete
5 +
from dateutil.relativedelta import relativedelta
6 +
from django.contrib.auth.mixins import LoginRequiredMixin
2 7
from django.db.models import Q
3 -
from .models import Account
8 +
from django.views.generic import ListView
9 +
10 +
from mep.accounts.models import Account, Event
11 +
from mep.accounts.partial_date import DatePrecision
4 12
5 13
6 14
class AccountAutocomplete(autocomplete.Select2QuerySetView):
@@ -20,3 +28,75 @@
Loading
20 28
            Q(locations__street_address__icontains=self.q) |
21 29
            Q(locations__city__icontains=self.q)
22 30
        ).distinct().order_by('id')
31 +
32 +
33 +
class Twitter100yearsReview(LoginRequiredMixin, ListView):
34 +
    '''Admin view to review upcoming 100 years tweets before they
35 +
    are posted on twitter. Finds and displays tweets for events
36 +
    in the next three months, using the same logic for generating
37 +
    tweet content that the twitter bot manage command uses.
38 +
    '''
39 +
40 +
    model = Event
41 +
    template_name = 'accounts/100years_twitter_review.html'
42 +
43 +
    full_precision = DatePrecision.year | DatePrecision.month | \
44 +
        DatePrecision.day
45 +
46 +
    def get_date_range(self):
47 +
        '''Determine start and end date for events to review. Start
48 +
        100 years before today, end 4 weeks after that.'''
49 +
        # determine date exactly 100 years earlier
50 +
        self.date_start = datetime.date.today() - relativedelta(years=100)
51 +
        # determine end date for tweets to review
52 +
        self.date_end = self.date_start + relativedelta(months=3)
53 +
54 +
        return self.date_start, self.date_end
55 +
56 +
    def get_queryset(self):
57 +
        date_start, date_end = self.get_date_range()
58 +
59 +
        events = Event.objects \
60 +
            .filter(Q(start_date__gte=date_start,
61 +
                      start_date__lte=date_end) |
62 +
                    Q(subscription__purchase_date__gte=date_start,
63 +
                      subscription__purchase_date__lte=date_end) |
64 +
                    Q(borrow__isnull=False, end_date__gte=date_start,
65 +
                      end_date__lte=date_end)) \
66 +
            .filter(Q(start_date_precision__isnull=True) |
67 +
                    Q(start_date_precision=int(self.full_precision))) \
68 +
            .exclude(work__notes__contains="UNCERTAINTYICON")
69 +
70 +
        return events
71 +
72 +
    def get_context_data(self):
73 +
        context = super().get_context_data()
74 +
75 +
        # construct a dictionary of dates with a list of events,
76 +
        # to make it easy to display all tweets in order
77 +
        events_by_date = defaultdict(list)
78 +
79 +
        for ev in self.object_list:
80 +
            if ev.event_label in ['Subscription', 'Renewal']:
81 +
                events_by_date[
82 +
                    ev.subscription.partial_purchase_date].append(ev)
83 +
            elif ev.event_label == 'Borrow':
84 +
                events_by_date[ev.partial_start_date].append(ev)
85 +
                events_by_date[ev.partial_end_date].append(ev)
86 +
            else:
87 +
                events_by_date[ev.partial_start_date].append(ev)
88 +
89 +
        # could include None for unset dates; remove without error
90 +
        events_by_date.pop(None, None)
91 +
92 +
        # convert to a standard dict to avoid problems with django templates;
93 +
        # sort by date & converted to ordered dict so review will be sequential
94 +
        # filter out any dates before the current range
95 +
        date_start_iso = self.date_start.isoformat()
96 +
        date_end_iso = self.date_end.isoformat()
97 +
        events_by_date = OrderedDict([
98 +
            (k, events_by_date[k])
99 +
            for k in sorted(events_by_date)
100 +
            if k and date_start_iso <= k <= date_end_iso])
101 +
        context['events_by_date'] = events_by_date
102 +
        return context

@@ -2,7 +2,7 @@
Loading
2 2
3 3
import rdflib
4 4
import requests
5 -
from django.core.exceptions import ObjectDoesNotExist
5 +
from django.core.exceptions import ObjectDoesNotExist, ValidationError
6 6
from django.db import models
7 7
from django.urls import reverse
8 8
from django.utils.html import format_html, strip_tags
@@ -11,8 +11,8 @@
Loading
11 11
from mep.accounts.event_set import EventSetMixin
12 12
from mep.accounts.partial_date import (DatePrecisionField, PartialDate,
13 13
                                       PartialDateMixin)
14 -
from mep.books.utils import nonstop_words, work_slug, generate_sort_title
15 -
from mep.common.models import Named, Notable
14 +
from mep.books.utils import generate_sort_title, nonstop_words, work_slug
15 +
from mep.common.models import Named, Notable, TrackChangesModel
16 16
from mep.common.validators import verify_latlon
17 17
from mep.people.models import Person
18 18
@@ -220,6 +220,24 @@
Loading
220 220
            works = Work.objects.filter(id__in=list(work_ids))
221 221
            ModelIndexable.index_items(works)
222 222
223 +
    @staticmethod
224 +
    def event_save(sender=None, instance=None, raw=False, **kwargs):
225 +
        '''when an event is saved, reindex associated work if there is one'''
226 +
        # raw = saved as presented; don't query the database
227 +
        if raw or not instance.pk:
228 +
            return
229 +
        # if any books are associated
230 +
        if instance.work:
231 +
            ModelIndexable.index_items([instance.work])
232 +
233 +
    @staticmethod
234 +
    def event_delete(sender, instance, **kwargs):
235 +
        '''when an event is delete, reindex people associated
236 +
        with the corresponding account.'''
237 +
        # get a list of ids for deleted event
238 +
        if instance.work:
239 +
            ModelIndexable.index_items([instance.work])
240 +
223 241
224 242
class WorkQuerySet(models.QuerySet):
225 243
    '''Custom :class:`models.QuerySet` for :class:`Work`'''
@@ -232,7 +250,7 @@
Loading
232 250
                             models.Count('event__purchase', distinct=True))
233 251
234 252
235 -
class Work(Notable, ModelIndexable, EventSetMixin):
253 +
class Work(TrackChangesModel, Notable, ModelIndexable, EventSetMixin):
236 254
    '''Work record for an item that circulated in the library or was
237 255
    other referenced in library activities.'''
238 256
@@ -309,8 +327,24 @@
Loading
309 327
            self.generate_slug()
310 328
        # recalculate sort title in case title has changed
311 329
        self.sort_title = generate_sort_title(self.title)
330 +
331 +
        # if slug has changed, save the old one as a past slug
332 +
        # (skip if record is not yet saved)
333 +
        if self.pk and self.has_changed('slug'):
334 +
            PastWorkSlug.objects.get_or_create(slug=self.initial_value('slug'),
335 +
                                               work=self)
336 +
312 337
        super(Work, self).save(*args, **kwargs)
313 338
339 +
    def validate_unique(self, exclude=None):
340 +
        # customize uniqueness validation to ensure new slugs don't
341 +
        # conflict with past slugs
342 +
        super().validate_unique(exclude)
343 +
        if PastWorkSlug.objects.filter(slug=self.slug) \
344 +
                               .exclude(work=self).count():
345 +
            raise ValidationError('Slug is not unique ' +
346 +
                                  '(conflicts with previously used slugs)')
347 +
314 348
    def __repr__(self):
315 349
        # provide pk for easy lookup and string for recognition
316 350
        return '<Work pk:%s %s>' % (self.pk or '??', str(self))
@@ -431,6 +465,10 @@
Loading
431 465
        'books.Format': {
432 466
            'post_save': WorkSignalHandlers.format_save,
433 467
            'pre_delete': WorkSignalHandlers.format_delete,
468 +
        },
469 +
        'accounts.Event': {
470 +
            'post_save': WorkSignalHandlers.event_save,
471 +
            'pre_delete': WorkSignalHandlers.event_delete,
434 472
        }
435 473
    }
436 474
@@ -559,6 +597,20 @@
Loading
559 597
                self.slug = '%s-%s' % (self.slug, slug_count + 1)
560 598
561 599
600 +
class PastWorkSlug(models.Model):
601 +
    '''A slug that was previously associated with a :class:`Work`;
602 +
    preserved so that former slugs will resolve to the correct work.'''
603 +
604 +
    #: work record this slug belonged to
605 +
    work = models.ForeignKey(Work, related_name='past_slugs',
606 +
                             on_delete=models.CASCADE)
607 +
    #: slug
608 +
    slug = models.SlugField(
609 +
        max_length=100, unique=True,
610 +
        help_text='Short, durable, unique identifier for use in URLs. ' +
611 +
        'Editing will change the public, citable URL for library books.')
612 +
613 +
562 614
class Edition(Notable):
563 615
    '''A specific known edition of a :class:`Work` that circulated.'''
564 616

@@ -9,7 +9,7 @@
Loading
9 9
from parasolr.query.queryset import EmptySolrQuerySet
10 10
import pytest
11 11
12 -
from mep.books.models import Edition, Work
12 +
from mep.books.models import PastWorkSlug, Work
13 13
from mep.books.views import WorkCirculation, WorkCardList, WorkList
14 14
from mep.common.utils import absolutize_url, login_temporarily_required
15 15
from mep.footnotes.models import Footnote
@@ -376,20 +376,9 @@
Loading
376 376
        # check that markdown is rendered
377 377
        self.assertContains(response, '<em>formatted</em>')
378 378
379 -
    def test_edition_volume_display(self):
380 -
        # fetch a periodical with issue information
381 -
        work = Work.objects.get(title='The Dial')
382 -
        issues = Edition.objects.filter(work=work)
383 -
        url = reverse('books:book-detail', kwargs={'slug': work.slug})
384 -
        response = self.client.get(url)
385 -
        # check that all issues are rendered in a list format
386 -
        self.assertContains(response, '<h2>Volume/Issue</h2>')
387 -
        for issue in issues:
388 -
            self.assertContains(response, issue.display_html())
389 -
390 379
391 380
class TestWorkCirculation(TestCase):
392 -
    fixtures = ['test_events.json']
381 +
    fixtures = ['test_events']
393 382
394 383
    def setUp(self):
395 384
        self.work = Work.objects.get(title="The Dial")
@@ -449,7 +438,7 @@
Loading
449 438
450 439
451 440
class TestWorkCardList(TestCase):
452 -
    fixtures = ['test_events.json']
441 +
    fixtures = ['test_events']
453 442
454 443
    def setUp(self):
455 444
        self.work = Work.objects.get(slug='lonigan-young-manhood')
@@ -539,3 +528,35 @@
Loading
539 528
            (reverse('people:member-card-detail',
540 529
                     args=[member.slug, work_footnote.image.short_id]),
541 530
             work_borrow.pk))
531 +
532 +
533 +
class TestPastSlugRedirects(TestCase):
534 +
    fixtures = ['sample_works']
535 +
536 +
    # short id for first canvas in manifest for stein's card
537 +
    canvas_id = '68fd36f1-a463-441e-9f13-dfc4a6cd4114'
538 +
    kwargs = {'slug': 'stein-gertrude', 'short_id': canvas_id}
539 +
540 +
    def setUp(self):
541 +
        self.work = Work.objects.get(slug="dial")
542 +
        self.slug = self.work.slug
543 +
        self.old_slug = 'old_slug'
544 +
        PastWorkSlug.objects.create(work=self.work,
545 +
                                    slug=self.old_slug)
546 +
547 +
    def test_work_detail_pages(self):
548 +
        # single member detail pages that don't require extra args
549 +
        for named_url in ['book-detail', 'book-circ', 'book-card-list']:
550 +
            # old slug should return permanent redirect to equivalent new
551 +
            route = 'books:%s' % named_url
552 +
            response = self.client.get(reverse(route,
553 +
                                       kwargs={'slug': self.old_slug}))
554 +
555 +
            assert response.status_code == 301  # permanent redirect
556 +
            # redirect to same view with the *correct* slug
557 +
            assert response['location'].endswith(
558 +
                reverse(route, kwargs={'slug': self.slug}))
559 +
560 +
            # check that it still 404s correctly
561 +
            response = self.client.get(reverse(route, kwargs={'slug': 'foo'}))
562 +
            assert response.status_code == 404

@@ -1250,7 +1250,8 @@
Loading
1250 1250
            response, 'data-sort="%s"' % subs.partial_start_date)
1251 1251
        self.assertContains(
1252 1252
            response, 'data-sort="%s"' % subs.partial_end_date)
1253 -
        self.assertContains(response, subs.price_paid)
1253 +
        self.assertContains(response, subs.total_amount())
1254 +
        # print(response.content)
1254 1255
        self.assertContains(response, subs.currency_symbol())
1255 1256
        self.assertContains(response, 'Reimbursement')
1256 1257
        reimburse = self.events['reimbursement']

@@ -0,0 +1,31 @@
Loading
1 +
# Generated by Django 2.2.11 on 2020-07-07 16:08
2 +
3 +
from django.db import migrations, models
4 +
import django.db.models.deletion
5 +
6 +
7 +
class Migration(migrations.Migration):
8 +
9 +
    dependencies = [
10 +
        ('books', '0025_populate_sort_title'),
11 +
    ]
12 +
13 +
    operations = [
14 +
        migrations.AlterModelOptions(
15 +
            name='edition',
16 +
            options={'ordering': ['date', 'volume']},
17 +
        ),
18 +
        migrations.AlterField(
19 +
            model_name='work',
20 +
            name='public_notes',
21 +
            field=models.TextField(blank=True, help_text='Notes for display on the public website.  Use markdown for formatting.'),
22 +
        ),
23 +
        migrations.CreateModel(
24 +
            name='PastWorkSlug',
25 +
            fields=[
26 +
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
27 +
                ('slug', models.SlugField(help_text='Short, durable, unique identifier for use in URLs. Editing will change the public, citable URL for library books.', max_length=100, unique=True)),
28 +
                ('work', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='past_slugs', to='books.Work')),
29 +
            ],
30 +
        ),
31 +
    ]

@@ -867,6 +867,18 @@
Loading
867 867
        assert dur == expect, \
868 868
            "Month of February should display as '%s', got '%s'" % (expect, dur)
869 869
870 +
    def test_total_amount(self):
871 +
        # price paid, no deposit
872 +
        assert self.subscription.total_amount() == 3.20
873 +
874 +
        # add a deposit amount
875 +
        self.subscription.deposit = 5.70
876 +
        assert self.subscription.total_amount() == 8.90
877 +
878 +
        # remove price
879 +
        self.subscription.price_paid = None
880 +
        assert self.subscription.total_amount() == 5.70
881 +
870 882
871 883
class TestPurchase(TestCase):
872 884

@@ -1,12 +1,13 @@
Loading
1 1
from dal import autocomplete
2 2
from django.db.models import F, Q
3 -
from django.db.models.functions import Coalesce
3 +
from django.http import Http404, HttpResponsePermanentRedirect
4 4
from django.shortcuts import get_object_or_404
5 5
from django.urls import reverse
6 6
from django.utils.html import strip_tags
7 7
from django.views.generic import DetailView, ListView
8 8
from django.views.generic.edit import FormMixin
9 9
10 +
10 11
from mep.accounts.models import Event
11 12
from mep.accounts.templatetags.account_tags import as_ranges
12 13
from mep.books.forms import WorkSearchForm
@@ -214,7 +215,36 @@
Loading
214 215
        return {'item_type': 'work', 'slug_s': self.kwargs['slug']}
215 216
216 217
217 -
class WorkDetail(WorkLastModifiedListMixin, DetailView, RdfViewMixin):
218 +
class WorkPastSlugMixin:
219 +
    '''View mixin to handle redirects for previously used slugs.
220 +
    If the main view logic raises a 404, looks for a work
221 +
    by past slug; if one is found, redirects to the corresponding
222 +
    work detail page with the new slug.
223 +
    '''
224 +
225 +
    def get(self, request, *args, **kwargs):
226 +
        '''Handle a 404 on the default GET logic — if the slug matches
227 +
        a past slug, redirect to the equivalent url for that work; otherwise
228 +
        raise the 404.'''
229 +
        try:
230 +
            return super().get(request, *args, **kwargs)
231 +
        except Http404:
232 +
            # if not found, check for a match on a past slug
233 +
            work = Work.objects.filter(past_slugs__slug=self.kwargs['slug']) \
234 +
                .first()
235 +
            # if found, redirect to the correct url for this view
236 +
            if work:
237 +
                # patch in the correct slug for use with get absolute url
238 +
                self.kwargs['slug'] = work.slug
239 +
                self.object = work   # used by member detail absolute url
240 +
                return HttpResponsePermanentRedirect(self.get_absolute_url())
241 +
242 +
            # otherwise, raise the 404
243 +
            raise
244 +
245 +
246 +
class WorkDetail(WorkPastSlugMixin, WorkLastModifiedListMixin,
247 +
                 DetailView, RdfViewMixin):
218 248
    '''Detail page for a single library book.'''
219 249
    model = Work
220 250
    template_name = 'books/work_detail.html'
@@ -260,7 +290,8 @@
Loading
260 290
        return context
261 291
262 292
263 -
class WorkCirculation(WorkLastModifiedListMixin, ListView, RdfViewMixin):
293 +
class WorkCirculation(WorkPastSlugMixin, WorkLastModifiedListMixin,
294 +
                      ListView, RdfViewMixin):
264 295
    '''Display a list of circulation events (borrows, purchases, etc)
265 296
    for an individual work.'''
266 297
    model = Event
@@ -298,7 +329,8 @@
Loading
298 329
        ]
299 330
300 331
301 -
class WorkCardList(WorkLastModifiedListMixin, ListView, RdfViewMixin):
332 +
class WorkCardList(WorkPastSlugMixin, WorkLastModifiedListMixin,
333 +
                   ListView, RdfViewMixin):
302 334
    '''Card thumbnails for lending card associated with a single library
303 335
    member.'''
304 336
    model = Footnote

@@ -97,7 +97,8 @@
Loading
97 97
        }),
98 98
        ('Additional metadata', {
99 99
            'fields': (
100 -
                'notes', 'public_notes', 'ebook_url', 'mep_id'
100 +
                'notes', 'public_notes', 'ebook_url', 'mep_id',
101 +
                'past_slugs_list'
101 102
            )
102 103
        }),
103 104
        ('OCLC metadata', {
@@ -109,7 +110,7 @@
Loading
109 110
        })
110 111
    )
111 112
    readonly_fields = ('mep_id', 'events', 'borrows', 'purchases',
112 -
                       'sort_title')
113 +
                       'sort_title', 'past_slugs_list')
113 114
    filter_horizontal = ('genres', 'subjects')
114 115
115 116
    actions = ['export_to_csv']
@@ -245,6 +246,13 @@
Loading
245 246
        ]
246 247
        return urls + super(WorkAdmin, self).get_urls()
247 248
249 +
    def past_slugs_list(self, instance=None):
250 +
        '''list of previous slugs for this work, for read-only display'''
251 +
        if instance:
252 +
            return ', '.join([p.slug for p in instance.past_slugs.all()])
253 +
    past_slugs_list.short_description = "Past slugs"
254 +
    past_slugs_list.long_description = 'Alternate slugs from edits'
255 +
248 256
249 257
class CreatorTypeAdmin(admin.ModelAdmin):
250 258
    model = CreatorType

@@ -0,0 +1,8 @@
Loading
1 +
from django.template.defaulttags import register
2 +
3 +
from mep.accounts.management.commands.twitterbot_100years import tweet_content
4 +
5 +
6 +
@register.simple_tag
7 +
def tweet_text(event, day):
8 +
    return tweet_content(event, day)

@@ -0,0 +1,319 @@
Loading
1 +
import datetime
2 +
import subprocess
3 +
import sys
4 +
5 +
from dateutil.relativedelta import relativedelta
6 +
from django.conf import settings
7 +
from django.core.management.base import BaseCommand, CommandError
8 +
from django.db.models import Q
9 +
from django.urls import reverse
10 +
import tweepy
11 +
12 +
from mep.accounts.models import Event
13 +
from mep.accounts.partial_date import DatePrecision
14 +
from mep.common.utils import absolutize_url
15 +
16 +
17 +
class Command(BaseCommand):
18 +
19 +
    # date format:  Saturday, May 8, 1920
20 +
    date_format = '%A, %B %-d, %Y'
21 +
22 +
    full_precision = DatePrecision.year | DatePrecision.month | \
23 +
        DatePrecision.day
24 +
25 +
    def add_arguments(self, parser):
26 +
        parser.add_argument('mode', choices=['report', 'schedule', 'tweet'])
27 +
        parser.add_argument(
28 +
            '-d', '--date',
29 +
            help='Specify an alternate date in YYYY-MM-DD format. ' +
30 +
                 '(default is today)')
31 +
        parser.add_argument(
32 +
            '-e', '--event', type=int,
33 +
            help='Database id for the event to be tweeted. ' +
34 +
                 '(Required for tweet mode)')
35 +
36 +
    def handle(self, *args, **kwargs):
37 +
        date = self.get_date(**kwargs)
38 +
39 +
        if kwargs['mode'] == 'report':
40 +
            self.report(date)
41 +
        elif kwargs['mode'] == 'schedule':
42 +
            self.schedule(date)
43 +
        elif kwargs['mode'] == 'tweet':
44 +
            # find the event and tweet it, if possible & appropriate
45 +
            try:
46 +
                ev = Event.objects.get(pk=kwargs['event'])
47 +
                self.tweet(ev, date)
48 +
            except Event.DoesNotExist:
49 +
                self.stderr.write('Error: event %(event)s not found' % kwargs)
50 +
51 +
    def get_date(self, date=None, mode=None, **kwargs):
52 +
        '''Find events relative to the specified day, if set,
53 +
        or the date 100 years ago. Overriding the date is only allowed
54 +
        in **report** mode.'''
55 +
56 +
        # only allow overriding date for report
57 +
        if date and mode == 'report':
58 +
            try:
59 +
                relative_date = datetime.date(*[int(n)
60 +
                                                for n in date.split('-')])
61 +
            except TypeError:
62 +
                raise CommandError('Invalid date %s' % date)
63 +
        else:
64 +
            # by default, report relative to today
65 +
            # determine date 100 years earlier
66 +
            relative_date = datetime.date.today() - relativedelta(years=100)
67 +
68 +
        return relative_date
69 +
70 +
    def find_events(self, date):
71 +
        '''Find events 100 years before the current day or
72 +
        a specified day in YYYY-MM-DD format.'''
73 +
74 +
        # find all events for this date
75 +
        # exclude partially known dates
76 +
        # - purchase date precision == start date precision
77 +
        # (borrow end *could* have different precision than start date)
78 +
        events = Event.objects \
79 +
            .filter(Q(start_date=date) |
80 +
                    Q(subscription__purchase_date=date) |
81 +
                    Q(borrow__isnull=False, end_date=date)) \
82 +
            .filter(Q(start_date_precision__isnull=True) |
83 +
                    Q(start_date_precision=int(self.full_precision))) \
84 +
            .exclude(work__notes__contains="UNCERTAINTYICON")
85 +
        return events
86 +
87 +
    def report(self, date):
88 +
        '''Print out the content that would be tweeted on the specified day'''
89 +
        for ev in self.find_events(date):
90 +
            tweet_text = tweet_content(ev, date)
91 +
            if tweet_text:
92 +
                self.stdout.write('Event id: %s' % ev.id)
93 +
                self.stdout.write(tweet_text)
94 +
                self.stdout.write('\n')
95 +
96 +
    # times:  9 AM, 12 PM, 1:30 PM, 3 PM, 4:30 PM, 6 PM, 8 PM
97 +
    tweet_times = ['9:00', '12:00', '13:30', '15:00', '16:30', '18:00',
98 +
                   '20:00', '10:15', '11:30', '19:00']
99 +
100 +
    def schedule(self, date):
101 +
        '''Schedule all tweetable events for the specified date.'''
102 +
        # find all events for today
103 +
        self.find_events(date)
104 +
        # filter out any that can't be tweeted
105 +
        events = [ev for ev in self.find_events(date) if can_tweet(ev, date)]
106 +
107 +
        # schedule the ones that can be tweeted
108 +
        for i, ev in enumerate(events):
109 +
            self.tweet_at(ev, self.tweet_times[i])
110 +
111 +
    def tweet_at(self, event, time):
112 +
        '''schedule a tweet for later today'''
113 +
        # use current python executable (within virtualenv)
114 +
        cmd = 'bin/cron-wrapper %s %s/manage.py twitterbot_100years tweet --event %s' % \
115 +
            (sys.executable, settings.PROJECT_ROOT, event.id)
116 +
        # could add debug logging here if there are problems
117 +
        subprocess.run(['/usr/bin/at', time], input=cmd.encode(),
118 +
                       stdout=subprocess.PIPE, stderr=subprocess.PIPE)
119 +
120 +
    def tweet(self, event, date):
121 +
        '''Tweet the content for the event on the specified date.'''
122 +
        # make sure the event is tweetable
123 +
        if not can_tweet(event, date):
124 +
            return
125 +
        content = tweet_content(event, date)
126 +
        if not content:
127 +
            return
128 +
        api = self.get_tweepy()
129 +
        api.update_status(content)
130 +
131 +
    def get_tweepy(self):
132 +
        '''Initialize tweepy API client based on django settings.'''
133 +
        if not getattr(settings, 'TWITTER_100YEARS', None):
134 +
            raise CommandError('Configuration for twitter access not found')
135 +
136 +
        auth = tweepy.OAuthHandler(
137 +
            settings.TWITTER_100YEARS['API']['key'],
138 +
            settings.TWITTER_100YEARS['API']['secret_key'])
139 +
        auth.set_access_token(settings.TWITTER_100YEARS['ACCESS']['token'],
140 +
                              settings.TWITTER_100YEARS['ACCESS']['secret'])
141 +
        return tweepy.API(auth)
142 +
143 +
tweetable_event_types = ['Subscription', 'Renewal', 'Reimbursement',
144 +
                         'Borrow', 'Purchase', 'Request']
145 +
146 +
147 +
def can_tweet(ev, day):
148 +
    '''Check if the event can be tweeted on the specified day'''
149 +
150 +
    # convert to string and compare against partial dates
151 +
    # to ensure we don't tweet an event with an unknown date
152 +
    day = day.isoformat()
153 +
    # disallows subscription on start date != purchase
154 +
    if ev.event_label in ['Subscription', 'Renewal'] and \
155 +
       ev.subscription.partial_purchase_date != day:
156 +
        return False
157 +
158 +
    return any([
159 +
        # subscription event and date matches purchase
160 +
        (ev.event_label == 'Subscription' and
161 +
         ev.subscription.partial_purchase_date == day),
162 +
        # borrow event and date matches end date
163 +
        (ev.event_label == 'Borrow' and ev.partial_end_date == day),
164 +
        # any other tweetable event and date matches start
165 +
        ev.event_label in tweetable_event_types and
166 +
        ev.partial_start_date == day])
167 +
168 +
169 +
tweet_format = {
170 +
    'verbed': '%(member)s %(verb)s %(work)s%(period)s',
171 +
    'subscription': '%(member)s %(verb)s for %(duration)s%(volumes)s.',
172 +
    'reimbursement': '%(member)s received a reimbursement for ' +
173 +
                     '%(amount)s%(currency)s.',
174 +
}
175 +
176 +
177 +
def tweet_content(ev, day):
178 +
    '''Generate tweet content for the specified event on the specified
179 +
    day.'''
180 +
181 +
    # handle multiple members, but use first member for url
182 +
    member = ev.account.persons.first()
183 +
184 +
    if isinstance(day, str):
185 +
        try:
186 +
            day = datetime.date(*[int(n) for n in day.split('-')])
187 +
        except TypeError:
188 +
            # given a partial date
189 +
            return
190 +
191 +
    # all tweets start the same way
192 +
    prolog = '#100YearsAgoToday on %s at Shakespeare and Company, ' % \
193 +
        day.strftime(Command.date_format)
194 +
    # handle shared accountsr
195 +
    member_name = ' and '.join(m.firstname_last
196 +
                               for m in ev.account.persons.all())
197 +
    tweet_info = {
198 +
        'member': member_name
199 +
    }
200 +
    event_label = ev.event_label
201 +
    tweet_pattern = None
202 +
203 +
    if event_label in ['Subscription', 'Renewal'] \
204 +
       and ev.subscription.purchase_date == day:
205 +
        tweet_pattern = 'subscription'
206 +
        verb = 'subscribed' if event_label == 'Subscription' else 'renewed'
207 +
        # renewals include duration
208 +
        tweet_info.update({
209 +
            'verb': verb,
210 +
            'duration': ev.subscription.readable_duration(),
211 +
            'volumes': ''
212 +
        })
213 +
        # include volume count if known
214 +
        if ev.subscription.volumes:
215 +
            tweet_info['volumes'] = ' at %d volume%s per month' % \
216 +
                (ev.subscription.volumes,
217 +
                 '' if ev.subscription.volumes == 1 else 's')
218 +
219 +
    elif event_label in ['Borrow', 'Purchase', 'Request']:
220 +
        tweet_pattern = 'verbed'
221 +
        # convert event type into verb for the sentence
222 +
        verb = '%sed' % ev.event_type.lower().rstrip('e')
223 +
        if event_label == 'Borrow' and ev.end_date == day:
224 +
            verb = 'returned'
225 +
        work_text = work_label(ev.work)
226 +
        tweet_info.update({
227 +
            'verb': verb,
228 +
            'work': work_text,
229 +
            # don't duplicate period inside quotes when no year
230 +
            'period': '' if work_text[-1] == '.' else '.'
231 +
        })
232 +
233 +
    elif event_label == 'Reimbursement':
234 +
        # received a reimbursement for $##
235 +
        tweet_pattern = 'reimbursement'
236 +
        tweet_info.update({
237 +
            'amount': ev.reimbursement.refund,
238 +
            'currency': ev.reimbursement.currency_symbol()
239 +
        })
240 +
241 +
    # if tweet format is set, generate tweet content
242 +
    if tweet_pattern:
243 +
        content = tweet_format[tweet_pattern] % tweet_info
244 +
        # use card detail url when available
245 +
        url = card_url(member, ev) or member.get_absolute_url()
246 +
        return '%s%s\n%s' % (prolog, content, absolutize_url(url))
247 +
248 +
249 +
def work_label(work):
250 +
    '''Convert a :class:`~mep.accounts.models.Work` for display
251 +
    in tweet content. Standard formats:
252 +
    - author’s “title” (year)
253 +
    - periodical: an issue of “title”
254 +
255 +
    Handles multiple authors (and for two, et al. for more), includes
256 +
    editors if there are no authors. Only include years after 1500.
257 +
    '''
258 +
    parts = []
259 +
    # indicate issue of periodical based on format
260 +
    if work.format() == 'Periodical':
261 +
        # not including issue details even if known;
262 +
        # too much variability in format
263 +
        parts.append('an issue of')
264 +
265 +
    include_editors = False
266 +
267 +
    # include author if known
268 +
    if work.authors:
269 +
        # handle multiple authors
270 +
        if len(work.authors) <= 2:
271 +
            # one or two: join by and
272 +
            author = ' and '.join([a.name for a in work.authors])
273 +
        else:
274 +
            # more than two: first name et al
275 +
            author = '%s et al.' % work.authors[0].name
276 +
        parts.append('%s’s' % author)
277 +
278 +
    # if no author but editors, we will include editor
279 +
    elif work.editors:
280 +
        include_editors = True
281 +
282 +
    # should always have title; use quotes since we can't italicize
283 +
    # strip quotes if already present (uncertain title)
284 +
    # add comma if we will add an editor; add period if no date
285 +
    title_punctuation = ''
286 +
    if include_editors:
287 +
        title_punctuation = ','
288 +
    elif not work.year or work.year < 1500:
289 +
        title_punctuation = '.'
290 +
291 +
    parts.append('“%s%s”' % (work.title.strip('"“”'),
292 +
                 title_punctuation))
293 +
294 +
    # add editors after title
295 +
    if include_editors:
296 +
        if len(work.editors) <= 2:
297 +
            # one or two: join by and
298 +
            editor = ' and '.join([ed.name for ed in work.editors])
299 +
        else:
300 +
            # more than two: first name et al
301 +
            editor = '%s et al.' % work.editors[0].name
302 +
        parts.append('edited by %s' % editor)
303 +
304 +
    # include work year if known not before 1500
305 +
    if work.year and work.year > 1500:
306 +
        parts.append('(%s)' % work.year)
307 +
308 +
    return ' '.join(parts)
309 +
310 +
311 +
def card_url(member, ev):
312 +
    '''Return the member card detail url for the event based on footnote
313 +
    image, if present.'''
314 +
    footnote = ev.footnotes.first()
315 +
    if footnote and footnote.image:
316 +
        url = reverse('people:member-card-detail', kwargs={
317 +
                      'slug': member.slug,
318 +
                      'short_id': footnote.image.short_id})
319 +
        return '%s#e%d' % (url, ev.id)

@@ -3,6 +3,7 @@
Loading
3 3
import pytest
4 4
from parasolr.django.indexing import ModelIndexable
5 5
6 +
from mep.accounts.models import Account, Event
6 7
from mep.books.models import (Creator, CreatorType, Format, Work,
7 8
                              WorkSignalHandlers)
8 9
from mep.people.models import Person
@@ -149,3 +150,35 @@
Loading
149 150
    WorkSignalHandlers.format_delete(Format, zine)
150 151
    assert mock_indexitems.call_count == 1
151 152
    assert poems in mock_indexitems.call_args[0][0]
153 +
154 +
155 +
@pytest.mark.django_db
156 +
@patch.object(ModelIndexable, 'index_items')
157 +
def test_event_save(mock_indexitems):
158 +
    # not associated with work; ignore
159 +
    acct = Account.objects.create()
160 +
    ev = Event.objects.create(account=acct)
161 +
    WorkSignalHandlers.event_save(Event, ev)
162 +
    mock_indexitems.assert_not_called()
163 +
164 +
    # associate with work; should be called
165 +
    poems = Work.objects.create(title='Poems', year=1916)
166 +
    ev.work = poems
167 +
    WorkSignalHandlers.event_save(Event, ev)
168 +
    assert mock_indexitems.call_count == 1
169 +
    assert poems in mock_indexitems.call_args[0][0]
170 +
171 +
172 +
@pytest.mark.django_db
173 +
@patch.object(ModelIndexable, 'index_items')
174 +
def test_event_delete(mock_indexitems):
175 +
    # not associated with work; ignore
176 +
    acct = Account.objects.create()
177 +
    ev = Event.objects.create(account=acct)
178 +
    WorkSignalHandlers.event_delete(Event, ev)
179 +
    mock_indexitems.assert_not_called()
180 +
181 +
    # associate with work; should be called
182 +
    poems = Work.objects.create(title='Poems', year=1916)
183 +
    ev.work = poems
184 +
    WorkSignalHandlers.event_delete(Event, ev)

@@ -514,6 +514,11 @@
Loading
514 514
    readable_duration.short_description = 'Duration'
515 515
    readable_duration.admin_order_field = 'duration'
516 516
517 +
    def total_amount(self):
518 +
        '''total amount paid (price paid + deposit if any)'''
519 +
        # NOTE: using sum to simplify decimal/float issues for zeroes
520 +
        return sum([x for x in (self.price_paid, self.deposit) if x])
521 +
517 522
518 523
class Borrow(Event):
519 524
    '''Inherited table indicating borrow events'''
Files Coverage
mep 98.43%
srcmedia/ts 86.55%
Project Totals (223 files) 97.99%
2452.1
TRAVIS_PYTHON_VERSION=3.5
TRAVIS_OS_NAME=linux
2451.1
TRAVIS_PYTHON_VERSION=3.5
TRAVIS_OS_NAME=linux

No yaml found.

Create your codecov.yml to customize your Codecov experience

Sunburst
The inner-most circle is the entire project, moving away from the center are folders then, finally, a single file. The size and color of each slice is representing the number of statements and the coverage, respectively.
Icicle
The top section represents the entire project. Proceeding with folders and finally individual files. The size and color of each slice is representing the number of statements and the coverage, respectively.
Grid
Each block represents a single file in the project. The size and color of each block is represented by the number of statements and the coverage, respectively.
Loading