Repository: bookwyrm-social/bookwyrm

Issue:
Book with no parent work causing server errors
**Describe the bug**
My instance threw server errors at /feed and / when logged in while a book without a parent work is in the feed. The profile of a user also responds with server error when the book is in their feed. The page of the book itself returns book not found. The feed is fine if that book is not in there (checked with a new user).

Log excerpt from bookwyrm_web:
``` python
Traceback (most recent call last):
  File "/usr/local/lib/python3.9/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/usr/local/lib/python3.9/site-packages/django/core/handlers/base.py", line 204, in _get_response
    response = response.render()
  File "/usr/local/lib/python3.9/site-packages/django/template/loader_tags.py", line 188, in render
    values = {

<many more lines of python stacktrace>

  File "/app/bookwyrm/templatetags/rating_tags.py", line 16, in get_rating
    f"book-rating-{book.parent_work.id}",
AttributeError: 'NoneType' object has no attribute 'id'
```

I don't know how the corrupt book entry was created.

The following bit (provided by mouse) executed in the django shell repaired the book
``` django
from bookwyrm import models
models.Edition.objects.filter(id=<that editions id>).update(parent_work=models.Work.objects.create(title="The English Understand Wool"))
```

**Expected behavior**

1. Such a book entry shouldn't exist
2. If it exists, the feed shouldn't crash
3. Ideally, the admin would get notified or, if possible, the entry gets repaired somehow 

**Instance**
wyrms.de


Discussion:

Resolving patch:
diff --git a/bookwyrm/management/commands/repair_editions.py b/bookwyrm/management/commands/repair_editions.py
new file mode 100644
--- /dev/null
+++ b/bookwyrm/management/commands/repair_editions.py
@@ -0,0 +1,21 @@
+""" Repair editions with missing works """
+from django.core.management.base import BaseCommand
+from bookwyrm import models
+
+
+class Command(BaseCommand):
+    """command-line options"""
+
+    help = "Repairs an edition that is in a broken state"
+
+    # pylint: disable=unused-argument
+    def handle(self, *args, **options):
+        """Find and repair broken editions"""
+        # Find broken editions
+        editions = models.Edition.objects.filter(parent_work__isnull=True)
+        self.stdout.write(f"Repairing {editions.count()} edition(s):")
+
+        # Do repair
+        for edition in editions:
+            edition.repair()
+            self.stdout.write(".", ending="")
diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py
--- a/bookwyrm/models/book.py
+++ b/bookwyrm/models/book.py
@@ -380,6 +380,19 @@ def save(self, *args, **kwargs):
 
         return super().save(*args, **kwargs)
 
+    @transaction.atomic
+    def repair(self):
+        """If an edition is in a bad state (missing a work), let's fix that"""
+        # made sure it actually NEEDS reapir
+        if self.parent_work:
+            return
+
+        new_work = Work.objects.create(title=self.title)
+        new_work.authors.set(self.authors.all())
+
+        self.parent_work = new_work
+        self.save(update_fields=["parent_work"], broadcast=False)
+
     @classmethod
     def viewer_aware_objects(cls, viewer):
         """annotate a book query with metadata related to the user"""
diff --git a/bookwyrm/templatetags/rating_tags.py b/bookwyrm/templatetags/rating_tags.py
--- a/bookwyrm/templatetags/rating_tags.py
+++ b/bookwyrm/templatetags/rating_tags.py
@@ -12,6 +12,10 @@
 @register.filter(name="rating")
 def get_rating(book, user):
     """get the overall rating of a book"""
+    # this shouldn't happen, but it CAN
+    if not book.parent_work:
+        return None
+
     return cache.get_or_set(
         f"book-rating-{book.parent_work.id}",
         lambda u, b: models.Review.objects.filter(


Test patch:
diff --git a/bookwyrm/tests/models/test_book_model.py b/bookwyrm/tests/models/test_book_model.py
--- a/bookwyrm/tests/models/test_book_model.py
+++ b/bookwyrm/tests/models/test_book_model.py
@@ -24,8 +24,7 @@ def setUp(self):
             title="Example Work", remote_id="https://example.com/book/1"
         )
         self.first_edition = models.Edition.objects.create(
-            title="Example Edition",
-            parent_work=self.work,
+            title="Example Edition", parent_work=self.work
         )
         self.second_edition = models.Edition.objects.create(
             title="Another Example Edition",
@@ -143,3 +142,15 @@ def test_populate_sort_title(self):
             for article in articles
         )
         self.assertTrue(all(book.sort_title == "test edition" for book in books))
+
+    def test_repair_edition(self):
+        """Fix editions with no works"""
+        edition = models.Edition.objects.create(title="test")
+        edition.authors.set([models.Author.objects.create(name="Author Name")])
+        self.assertIsNone(edition.parent_work)
+
+        edition.repair()
+        edition.refresh_from_db()
+
+        self.assertEqual(edition.parent_work.title, "test")
+        self.assertEqual(edition.parent_work.authors.count(), 1)
diff --git a/bookwyrm/tests/templatetags/test_rating_tags.py b/bookwyrm/tests/templatetags/test_rating_tags.py
--- a/bookwyrm/tests/templatetags/test_rating_tags.py
+++ b/bookwyrm/tests/templatetags/test_rating_tags.py
@@ -71,6 +71,12 @@ def test_get_rating(self, *_):
         )
         self.assertEqual(rating_tags.get_rating(self.book, self.local_user), 5)
 
+    def test_get_rating_broken_edition(self, *_):
+        """Don't have a server error if an edition is missing a work"""
+        broken_book = models.Edition.objects.create(title="Test")
+        broken_book.parent_work = None
+        self.assertIsNone(rating_tags.get_rating(broken_book, self.local_user))
+
     def test_get_user_rating(self, *_):
         """get a user's most recent rating of a book"""
         with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):