Princeton-CDH / mep-django

@@ -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
@@ -250,7 +250,7 @@
Loading
250 250
                             models.Count('event__purchase', distinct=True))
251 251
252 252
253 -
class Work(Notable, ModelIndexable, EventSetMixin):
253 +
class Work(TrackChangesModel, Notable, ModelIndexable, EventSetMixin):
254 254
    '''Work record for an item that circulated in the library or was
255 255
    other referenced in library activities.'''
256 256
@@ -327,8 +327,24 @@
Loading
327 327
            self.generate_slug()
328 328
        # recalculate sort title in case title has changed
329 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 +
330 337
        super(Work, self).save(*args, **kwargs)
331 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 +
332 348
    def __repr__(self):
333 349
        # provide pk for easy lookup and string for recognition
334 350
        return '<Work pk:%s %s>' % (self.pk or '??', str(self))
@@ -581,6 +597,20 @@
Loading
581 597
                self.slug = '%s-%s' % (self.slug, slug_count + 1)
582 598
583 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 +
584 614
class Edition(Notable):
585 615
    '''A specific known edition of a :class:`Work` that circulated.'''
586 616

@@ -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

@@ -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')

@@ -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
@@ -378,7 +378,7 @@
Loading
378 378
379 379
380 380
class TestWorkCirculation(TestCase):
381 -
    fixtures = ['test_events.json']
381 +
    fixtures = ['test_events']
382 382
383 383
    def setUp(self):
384 384
        self.work = Work.objects.get(title="The Dial")
@@ -438,7 +438,7 @@
Loading
438 438
439 439
440 440
class TestWorkCardList(TestCase):
441 -
    fixtures = ['test_events.json']
441 +
    fixtures = ['test_events']
442 442
443 443
    def setUp(self):
444 444
        self.work = Work.objects.get(slug='lonigan-young-manhood')
@@ -528,3 +528,36 @@
Loading
528 528
            (reverse('people:member-card-detail',
529 529
                     args=[member.slug, work_footnote.image.short_id]),
530 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': 'foobar'}))
562 +
            assert response.status_code == 404
563 +

@@ -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 +
    ]

@@ -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,33 @@
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 +
        try:
227 +
            return super().get(request, *args, **kwargs)
228 +
        except Http404:
229 +
            # if not found, check for a match on a past slug
230 +
            work = Work.objects.filter(past_slugs__slug=self.kwargs['slug']) \
231 +
                .first()
232 +
            # if found, redirect to the correct url for this view
233 +
            if work:
234 +
                # patch in the correct slug for use with get absolute url
235 +
                self.kwargs['slug'] = work.slug
236 +
                self.object = work   # used by member detail absolute url
237 +
                return HttpResponsePermanentRedirect(self.get_absolute_url())
238 +
239 +
            # otherwise, raise the 404
240 +
            raise
241 +
242 +
243 +
class WorkDetail(WorkPastSlugMixin, WorkLastModifiedListMixin,
244 +
                 DetailView, RdfViewMixin):
218 245
    '''Detail page for a single library book.'''
219 246
    model = Work
220 247
    template_name = 'books/work_detail.html'
@@ -260,7 +287,8 @@
Loading
260 287
        return context
261 288
262 289
263 -
class WorkCirculation(WorkLastModifiedListMixin, ListView, RdfViewMixin):
290 +
class WorkCirculation(WorkPastSlugMixin, WorkLastModifiedListMixin,
291 +
                      ListView, RdfViewMixin):
264 292
    '''Display a list of circulation events (borrows, purchases, etc)
265 293
    for an individual work.'''
266 294
    model = Event
@@ -298,7 +326,8 @@
Loading
298 326
        ]
299 327
300 328
301 -
class WorkCardList(WorkLastModifiedListMixin, ListView, RdfViewMixin):
329 +
class WorkCardList(WorkPastSlugMixin, WorkLastModifiedListMixin,
330 +
                   ListView, RdfViewMixin):
302 331
    '''Card thumbnails for lending card associated with a single library
303 332
    member.'''
304 333
    model = Footnote
Files Coverage
mep 98.44%
srcmedia/ts 86.55%
Project Totals (223 files) 98.00%
2441.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