We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
importing .csv file that is empty besides header row causes hung import process. 
**Describe the bug**
importing at least 1 kind of empty .csv file will result in a started import process, which will run forever from the user's point of view, stuck at 0%. there is no feedback to the user that their file was improperly formatted. the file is not *empty* since it contains the header row, and a novice user may not realize there is anything wrong with their file (nor know how to open and inspect a .csv).

**To Reproduce**
Steps to reproduce the behavior:
1. obtain an .csv export from another bookwyrm instance that is empty besides the header row
2. import the .csv file into a new instance from the user's import feature.

the user can see the import "running" in their settings panel where imports are shown. It is listed as Active, but stays stuck at 0% for at least 30 minutes (possibly indefinitely?).

an `import_job.start_import_task` task runs, but no `import_item_task`s are ever seen. 

**Expected behavior**
either a "successful" import (of no books, because the file is empty), or a failed import, or some kind of feedback to the user that the file is empty

**Instance**
export from bookwyrm.social, importing to a fresh self-hosted instance.


--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `bookwyrm/views/imports/import_data.py`
Content:
```
1 """ import books from another app """
2 from io import TextIOWrapper
3 import datetime
4 
5 from django.contrib.auth.decorators import login_required
6 from django.db.models import Avg, ExpressionWrapper, F, fields
7 from django.core.exceptions import PermissionDenied
8 from django.core.paginator import Paginator
9 from django.http import HttpResponseBadRequest
10 from django.shortcuts import redirect
11 from django.template.response import TemplateResponse
12 from django.utils import timezone
13 from django.utils.decorators import method_decorator
14 from django.utils.translation import gettext_lazy as _
15 from django.views import View
16 
17 from bookwyrm import forms, models
18 from bookwyrm.importers import (
19     CalibreImporter,
20     LibrarythingImporter,
21     GoodreadsImporter,
22     StorygraphImporter,
23     OpenLibraryImporter,
24 )
25 from bookwyrm.settings import PAGE_LENGTH
26 from bookwyrm.utils.cache import get_or_set
27 
28 # pylint: disable= no-self-use
29 @method_decorator(login_required, name="dispatch")
30 class Import(View):
31     """import view"""
32 
33     def get(self, request):
34         """load import page"""
35         jobs = models.ImportJob.objects.filter(user=request.user).order_by(
36             "-created_date"
37         )
38         paginated = Paginator(jobs, PAGE_LENGTH)
39         page = paginated.get_page(request.GET.get("page"))
40         data = {
41             "import_form": forms.ImportForm(),
42             "jobs": page,
43             "page_range": paginated.get_elided_page_range(
44                 page.number, on_each_side=2, on_ends=1
45             ),
46         }
47 
48         seconds = get_or_set("avg-import-time", get_average_import_time, timeout=86400)
49         if seconds and seconds > 60**2:
50             data["recent_avg_hours"] = seconds / (60**2)
51         elif seconds:
52             data["recent_avg_minutes"] = seconds / 60
53 
54         return TemplateResponse(request, "import/import.html", data)
55 
56     def post(self, request):
57         """ingest a goodreads csv"""
58         site = models.SiteSettings.objects.get()
59         if not site.imports_enabled:
60             raise PermissionDenied()
61 
62         form = forms.ImportForm(request.POST, request.FILES)
63         if not form.is_valid():
64             return HttpResponseBadRequest()
65 
66         include_reviews = request.POST.get("include_reviews") == "on"
67         privacy = request.POST.get("privacy")
68         source = request.POST.get("source")
69 
70         importer = None
71         if source == "LibraryThing":
72             importer = LibrarythingImporter()
73         elif source == "Storygraph":
74             importer = StorygraphImporter()
75         elif source == "OpenLibrary":
76             importer = OpenLibraryImporter()
77         elif source == "Calibre":
78             importer = CalibreImporter()
79         else:
80             # Default : Goodreads
81             importer = GoodreadsImporter()
82 
83         try:
84             job = importer.create_job(
85                 request.user,
86                 TextIOWrapper(request.FILES["csv_file"], encoding=importer.encoding),
87                 include_reviews,
88                 privacy,
89             )
90         except (UnicodeDecodeError, ValueError, KeyError):
91             return HttpResponseBadRequest(_("Not a valid csv file"))
92 
93         job.start_job()
94 
95         return redirect(f"/import/{job.id}")
96 
97 
98 def get_average_import_time() -> float:
99     """Helper to figure out how long imports are taking (returns seconds)"""
100     last_week = timezone.now() - datetime.timedelta(days=7)
101     recent_avg = (
102         models.ImportJob.objects.filter(created_date__gte=last_week, complete=True)
103         .exclude(status="stopped")
104         .annotate(
105             runtime=ExpressionWrapper(
106                 F("updated_date") - F("created_date"),
107                 output_field=fields.DurationField(),
108             )
109         )
110         .aggregate(Avg("runtime"))
111         .get("runtime__avg")
112     )
113 
114     if recent_avg:
115         return recent_avg.total_seconds()
116     return None
117
```

Path: `bookwyrm/importers/importer.py`
Content:
```
1 """ handle reading a csv from an external service, defaults are from Goodreads """
2 import csv
3 from django.utils import timezone
4 from bookwyrm.models import ImportJob, ImportItem
5 
6 
7 class Importer:
8     """Generic class for csv data import from an outside service"""
9 
10     service = "Import"
11     delimiter = ","
12     encoding = "UTF-8"
13 
14     # these are from Goodreads
15     row_mappings_guesses = [
16         ("id", ["id", "book id"]),
17         ("title", ["title"]),
18         ("authors", ["author", "authors", "primary author"]),
19         ("isbn_10", ["isbn10", "isbn"]),
20         ("isbn_13", ["isbn13", "isbn", "isbns"]),
21         ("shelf", ["shelf", "exclusive shelf", "read status", "bookshelf"]),
22         ("review_name", ["review name"]),
23         ("review_body", ["my review", "review"]),
24         ("rating", ["my rating", "rating", "star rating"]),
25         ("date_added", ["date added", "entry date", "added"]),
26         ("date_started", ["date started", "started"]),
27         ("date_finished", ["date finished", "last date read", "date read", "finished"]),
28     ]
29     date_fields = ["date_added", "date_started", "date_finished"]
30     shelf_mapping_guesses = {
31         "to-read": ["to-read", "want to read"],
32         "read": ["read", "already read"],
33         "reading": ["currently-reading", "reading", "currently reading"],
34     }
35 
36     def create_job(self, user, csv_file, include_reviews, privacy):
37         """check over a csv and creates a database entry for the job"""
38         csv_reader = csv.DictReader(csv_file, delimiter=self.delimiter)
39         rows = enumerate(list(csv_reader))
40         job = ImportJob.objects.create(
41             user=user,
42             include_reviews=include_reviews,
43             privacy=privacy,
44             mappings=self.create_row_mappings(csv_reader.fieldnames),
45             source=self.service,
46         )
47 
48         for index, entry in rows:
49             self.create_item(job, index, entry)
50         return job
51 
52     def update_legacy_job(self, job):
53         """patch up a job that was in the old format"""
54         items = job.items
55         headers = list(items.first().data.keys())
56         job.mappings = self.create_row_mappings(headers)
57         job.updated_date = timezone.now()
58         job.save()
59 
60         for item in items.all():
61             normalized = self.normalize_row(item.data, job.mappings)
62             normalized["shelf"] = self.get_shelf(normalized)
63             item.normalized_data = normalized
64             item.save()
65 
66     def create_row_mappings(self, headers):
67         """guess what the headers mean"""
68         mappings = {}
69         for (key, guesses) in self.row_mappings_guesses:
70             value = [h for h in headers if h.lower() in guesses]
71             value = value[0] if len(value) else None
72             if value:
73                 headers.remove(value)
74             mappings[key] = value
75         return mappings
76 
77     def create_item(self, job, index, data):
78         """creates and saves an import item"""
79         normalized = self.normalize_row(data, job.mappings)
80         normalized["shelf"] = self.get_shelf(normalized)
81         ImportItem(job=job, index=index, data=data, normalized_data=normalized).save()
82 
83     def get_shelf(self, normalized_row):
84         """determine which shelf to use"""
85         shelf_name = normalized_row.get("shelf")
86         if not shelf_name:
87             return None
88         shelf_name = shelf_name.lower()
89         shelf = [
90             s for (s, gs) in self.shelf_mapping_guesses.items() if shelf_name in gs
91         ]
92         return shelf[0] if shelf else None
93 
94     def normalize_row(self, entry, mappings):  # pylint: disable=no-self-use
95         """use the dataclass to create the formatted row of data"""
96         return {k: entry.get(v) for k, v in mappings.items()}
97 
98     def create_retry_job(self, user, original_job, items):
99         """retry items that didn't import"""
100         job = ImportJob.objects.create(
101             user=user,
102             include_reviews=original_job.include_reviews,
103             privacy=original_job.privacy,
104             source=original_job.source,
105             # TODO: allow users to adjust mappings
106             mappings=original_job.mappings,
107             retry=True,
108         )
109         for item in items:
110             # this will re-normalize the raw data
111             self.create_item(job, item.index, item.data)
112         return job
113
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py
--- a/bookwyrm/importers/importer.py
+++ b/bookwyrm/importers/importer.py
@@ -36,7 +36,11 @@
     def create_job(self, user, csv_file, include_reviews, privacy):
         """check over a csv and creates a database entry for the job"""
         csv_reader = csv.DictReader(csv_file, delimiter=self.delimiter)
-        rows = enumerate(list(csv_reader))
+        rows = list(csv_reader)
+        if len(rows) < 1:
+            raise ValueError("CSV file is empty")
+        rows = enumerate(rows)
+
         job = ImportJob.objects.create(
             user=user,
             include_reviews=include_reviews,
diff --git a/bookwyrm/views/imports/import_data.py b/bookwyrm/views/imports/import_data.py
--- a/bookwyrm/views/imports/import_data.py
+++ b/bookwyrm/views/imports/import_data.py
@@ -11,7 +11,6 @@
 from django.template.response import TemplateResponse
 from django.utils import timezone
 from django.utils.decorators import method_decorator
-from django.utils.translation import gettext_lazy as _
 from django.views import View
 
 from bookwyrm import forms, models
@@ -30,7 +29,7 @@
 class Import(View):
     """import view"""
 
-    def get(self, request):
+    def get(self, request, invalid=False):
         """load import page"""
         jobs = models.ImportJob.objects.filter(user=request.user).order_by(
             "-created_date"
@@ -43,6 +42,7 @@
             "page_range": paginated.get_elided_page_range(
                 page.number, on_each_side=2, on_ends=1
             ),
+            "invalid": invalid,
         }
 
         seconds = get_or_set("avg-import-time", get_average_import_time, timeout=86400)
@@ -88,7 +88,7 @@
                 privacy,
             )
         except (UnicodeDecodeError, ValueError, KeyError):
-            return HttpResponseBadRequest(_("Not a valid csv file"))
+            return self.get(request, invalid=True)
 
         job.start_job()

{"golden_diff": "diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py\n--- a/bookwyrm/importers/importer.py\n+++ b/bookwyrm/importers/importer.py\n@@ -36,7 +36,11 @@\n     def create_job(self, user, csv_file, include_reviews, privacy):\n         \"\"\"check over a csv and creates a database entry for the job\"\"\"\n         csv_reader = csv.DictReader(csv_file, delimiter=self.delimiter)\n-        rows = enumerate(list(csv_reader))\n+        rows = list(csv_reader)\n+        if len(rows) < 1:\n+            raise ValueError(\"CSV file is empty\")\n+        rows = enumerate(rows)\n+\n         job = ImportJob.objects.create(\n             user=user,\n             include_reviews=include_reviews,\ndiff --git a/bookwyrm/views/imports/import_data.py b/bookwyrm/views/imports/import_data.py\n--- a/bookwyrm/views/imports/import_data.py\n+++ b/bookwyrm/views/imports/import_data.py\n@@ -11,7 +11,6 @@\n from django.template.response import TemplateResponse\n from django.utils import timezone\n from django.utils.decorators import method_decorator\n-from django.utils.translation import gettext_lazy as _\n from django.views import View\n \n from bookwyrm import forms, models\n@@ -30,7 +29,7 @@\n class Import(View):\n     \"\"\"import view\"\"\"\n \n-    def get(self, request):\n+    def get(self, request, invalid=False):\n         \"\"\"load import page\"\"\"\n         jobs = models.ImportJob.objects.filter(user=request.user).order_by(\n             \"-created_date\"\n@@ -43,6 +42,7 @@\n             \"page_range\": paginated.get_elided_page_range(\n                 page.number, on_each_side=2, on_ends=1\n             ),\n+            \"invalid\": invalid,\n         }\n \n         seconds = get_or_set(\"avg-import-time\", get_average_import_time, timeout=86400)\n@@ -88,7 +88,7 @@\n                 privacy,\n             )\n         except (UnicodeDecodeError, ValueError, KeyError):\n-            return HttpResponseBadRequest(_(\"Not a valid csv file\"))\n+            return self.get(request, invalid=True)\n \n         job.start_job()\n", "issue": "importing .csv file that is empty besides header row causes hung import process. \n**Describe the bug**\r\nimporting at least 1 kind of empty .csv file will result in a started import process, which will run forever from the user's point of view, stuck at 0%. there is no feedback to the user that their file was improperly formatted. the file is not *empty* since it contains the header row, and a novice user may not realize there is anything wrong with their file (nor know how to open and inspect a .csv).\r\n\r\n**To Reproduce**\r\nSteps to reproduce the behavior:\r\n1. obtain an .csv export from another bookwyrm instance that is empty besides the header row\r\n2. import the .csv file into a new instance from the user's import feature.\r\n\r\nthe user can see the import \"running\" in their settings panel where imports are shown. It is listed as Active, but stays stuck at 0% for at least 30 minutes (possibly indefinitely?).\r\n\r\nan `import_job.start_import_task` task runs, but no `import_item_task`s are ever seen. \r\n\r\n**Expected behavior**\r\neither a \"successful\" import (of no books, because the file is empty), or a failed import, or some kind of feedback to the user that the file is empty\r\n\r\n**Instance**\r\nexport from bookwyrm.social, importing to a fresh self-hosted instance.\r\n\n", "before_files": [{"content": "\"\"\" import books from another app \"\"\"\nfrom io import TextIOWrapper\nimport datetime\n\nfrom django.contrib.auth.decorators import login_required\nfrom django.db.models import Avg, ExpressionWrapper, F, fields\nfrom django.core.exceptions import PermissionDenied\nfrom django.core.paginator import Paginator\nfrom django.http import HttpResponseBadRequest\nfrom django.shortcuts import redirect\nfrom django.template.response import TemplateResponse\nfrom django.utils import timezone\nfrom django.utils.decorators import method_decorator\nfrom django.utils.translation import gettext_lazy as _\nfrom django.views import View\n\nfrom bookwyrm import forms, models\nfrom bookwyrm.importers import (\n    CalibreImporter,\n    LibrarythingImporter,\n    GoodreadsImporter,\n    StorygraphImporter,\n    OpenLibraryImporter,\n)\nfrom bookwyrm.settings import PAGE_LENGTH\nfrom bookwyrm.utils.cache import get_or_set\n\n# pylint: disable= no-self-use\n@method_decorator(login_required, name=\"dispatch\")\nclass Import(View):\n    \"\"\"import view\"\"\"\n\n    def get(self, request):\n        \"\"\"load import page\"\"\"\n        jobs = models.ImportJob.objects.filter(user=request.user).order_by(\n            \"-created_date\"\n        )\n        paginated = Paginator(jobs, PAGE_LENGTH)\n        page = paginated.get_page(request.GET.get(\"page\"))\n        data = {\n            \"import_form\": forms.ImportForm(),\n            \"jobs\": page,\n            \"page_range\": paginated.get_elided_page_range(\n                page.number, on_each_side=2, on_ends=1\n            ),\n        }\n\n        seconds = get_or_set(\"avg-import-time\", get_average_import_time, timeout=86400)\n        if seconds and seconds > 60**2:\n            data[\"recent_avg_hours\"] = seconds / (60**2)\n        elif seconds:\n            data[\"recent_avg_minutes\"] = seconds / 60\n\n        return TemplateResponse(request, \"import/import.html\", data)\n\n    def post(self, request):\n        \"\"\"ingest a goodreads csv\"\"\"\n        site = models.SiteSettings.objects.get()\n        if not site.imports_enabled:\n            raise PermissionDenied()\n\n        form = forms.ImportForm(request.POST, request.FILES)\n        if not form.is_valid():\n            return HttpResponseBadRequest()\n\n        include_reviews = request.POST.get(\"include_reviews\") == \"on\"\n        privacy = request.POST.get(\"privacy\")\n        source = request.POST.get(\"source\")\n\n        importer = None\n        if source == \"LibraryThing\":\n            importer = LibrarythingImporter()\n        elif source == \"Storygraph\":\n            importer = StorygraphImporter()\n        elif source == \"OpenLibrary\":\n            importer = OpenLibraryImporter()\n        elif source == \"Calibre\":\n            importer = CalibreImporter()\n        else:\n            # Default : Goodreads\n            importer = GoodreadsImporter()\n\n        try:\n            job = importer.create_job(\n                request.user,\n                TextIOWrapper(request.FILES[\"csv_file\"], encoding=importer.encoding),\n                include_reviews,\n                privacy,\n            )\n        except (UnicodeDecodeError, ValueError, KeyError):\n            return HttpResponseBadRequest(_(\"Not a valid csv file\"))\n\n        job.start_job()\n\n        return redirect(f\"/import/{job.id}\")\n\n\ndef get_average_import_time() -> float:\n    \"\"\"Helper to figure out how long imports are taking (returns seconds)\"\"\"\n    last_week = timezone.now() - datetime.timedelta(days=7)\n    recent_avg = (\n        models.ImportJob.objects.filter(created_date__gte=last_week, complete=True)\n        .exclude(status=\"stopped\")\n        .annotate(\n            runtime=ExpressionWrapper(\n                F(\"updated_date\") - F(\"created_date\"),\n                output_field=fields.DurationField(),\n            )\n        )\n        .aggregate(Avg(\"runtime\"))\n        .get(\"runtime__avg\")\n    )\n\n    if recent_avg:\n        return recent_avg.total_seconds()\n    return None\n", "path": "bookwyrm/views/imports/import_data.py"}, {"content": "\"\"\" handle reading a csv from an external service, defaults are from Goodreads \"\"\"\nimport csv\nfrom django.utils import timezone\nfrom bookwyrm.models import ImportJob, ImportItem\n\n\nclass Importer:\n    \"\"\"Generic class for csv data import from an outside service\"\"\"\n\n    service = \"Import\"\n    delimiter = \",\"\n    encoding = \"UTF-8\"\n\n    # these are from Goodreads\n    row_mappings_guesses = [\n        (\"id\", [\"id\", \"book id\"]),\n        (\"title\", [\"title\"]),\n        (\"authors\", [\"author\", \"authors\", \"primary author\"]),\n        (\"isbn_10\", [\"isbn10\", \"isbn\"]),\n        (\"isbn_13\", [\"isbn13\", \"isbn\", \"isbns\"]),\n        (\"shelf\", [\"shelf\", \"exclusive shelf\", \"read status\", \"bookshelf\"]),\n        (\"review_name\", [\"review name\"]),\n        (\"review_body\", [\"my review\", \"review\"]),\n        (\"rating\", [\"my rating\", \"rating\", \"star rating\"]),\n        (\"date_added\", [\"date added\", \"entry date\", \"added\"]),\n        (\"date_started\", [\"date started\", \"started\"]),\n        (\"date_finished\", [\"date finished\", \"last date read\", \"date read\", \"finished\"]),\n    ]\n    date_fields = [\"date_added\", \"date_started\", \"date_finished\"]\n    shelf_mapping_guesses = {\n        \"to-read\": [\"to-read\", \"want to read\"],\n        \"read\": [\"read\", \"already read\"],\n        \"reading\": [\"currently-reading\", \"reading\", \"currently reading\"],\n    }\n\n    def create_job(self, user, csv_file, include_reviews, privacy):\n        \"\"\"check over a csv and creates a database entry for the job\"\"\"\n        csv_reader = csv.DictReader(csv_file, delimiter=self.delimiter)\n        rows = enumerate(list(csv_reader))\n        job = ImportJob.objects.create(\n            user=user,\n            include_reviews=include_reviews,\n            privacy=privacy,\n            mappings=self.create_row_mappings(csv_reader.fieldnames),\n            source=self.service,\n        )\n\n        for index, entry in rows:\n            self.create_item(job, index, entry)\n        return job\n\n    def update_legacy_job(self, job):\n        \"\"\"patch up a job that was in the old format\"\"\"\n        items = job.items\n        headers = list(items.first().data.keys())\n        job.mappings = self.create_row_mappings(headers)\n        job.updated_date = timezone.now()\n        job.save()\n\n        for item in items.all():\n            normalized = self.normalize_row(item.data, job.mappings)\n            normalized[\"shelf\"] = self.get_shelf(normalized)\n            item.normalized_data = normalized\n            item.save()\n\n    def create_row_mappings(self, headers):\n        \"\"\"guess what the headers mean\"\"\"\n        mappings = {}\n        for (key, guesses) in self.row_mappings_guesses:\n            value = [h for h in headers if h.lower() in guesses]\n            value = value[0] if len(value) else None\n            if value:\n                headers.remove(value)\n            mappings[key] = value\n        return mappings\n\n    def create_item(self, job, index, data):\n        \"\"\"creates and saves an import item\"\"\"\n        normalized = self.normalize_row(data, job.mappings)\n        normalized[\"shelf\"] = self.get_shelf(normalized)\n        ImportItem(job=job, index=index, data=data, normalized_data=normalized).save()\n\n    def get_shelf(self, normalized_row):\n        \"\"\"determine which shelf to use\"\"\"\n        shelf_name = normalized_row.get(\"shelf\")\n        if not shelf_name:\n            return None\n        shelf_name = shelf_name.lower()\n        shelf = [\n            s for (s, gs) in self.shelf_mapping_guesses.items() if shelf_name in gs\n        ]\n        return shelf[0] if shelf else None\n\n    def normalize_row(self, entry, mappings):  # pylint: disable=no-self-use\n        \"\"\"use the dataclass to create the formatted row of data\"\"\"\n        return {k: entry.get(v) for k, v in mappings.items()}\n\n    def create_retry_job(self, user, original_job, items):\n        \"\"\"retry items that didn't import\"\"\"\n        job = ImportJob.objects.create(\n            user=user,\n            include_reviews=original_job.include_reviews,\n            privacy=original_job.privacy,\n            source=original_job.source,\n            # TODO: allow users to adjust mappings\n            mappings=original_job.mappings,\n            retry=True,\n        )\n        for item in items:\n            # this will re-normalize the raw data\n            self.create_item(job, item.index, item.data)\n        return job\n", "path": "bookwyrm/importers/importer.py"}], "after_files": [{"content": "\"\"\" import books from another app \"\"\"\nfrom io import TextIOWrapper\nimport datetime\n\nfrom django.contrib.auth.decorators import login_required\nfrom django.db.models import Avg, ExpressionWrapper, F, fields\nfrom django.core.exceptions import PermissionDenied\nfrom django.core.paginator import Paginator\nfrom django.http import HttpResponseBadRequest\nfrom django.shortcuts import redirect\nfrom django.template.response import TemplateResponse\nfrom django.utils import timezone\nfrom django.utils.decorators import method_decorator\nfrom django.views import View\n\nfrom bookwyrm import forms, models\nfrom bookwyrm.importers import (\n    CalibreImporter,\n    LibrarythingImporter,\n    GoodreadsImporter,\n    StorygraphImporter,\n    OpenLibraryImporter,\n)\nfrom bookwyrm.settings import PAGE_LENGTH\nfrom bookwyrm.utils.cache import get_or_set\n\n# pylint: disable= no-self-use\n@method_decorator(login_required, name=\"dispatch\")\nclass Import(View):\n    \"\"\"import view\"\"\"\n\n    def get(self, request, invalid=False):\n        \"\"\"load import page\"\"\"\n        jobs = models.ImportJob.objects.filter(user=request.user).order_by(\n            \"-created_date\"\n        )\n        paginated = Paginator(jobs, PAGE_LENGTH)\n        page = paginated.get_page(request.GET.get(\"page\"))\n        data = {\n            \"import_form\": forms.ImportForm(),\n            \"jobs\": page,\n            \"page_range\": paginated.get_elided_page_range(\n                page.number, on_each_side=2, on_ends=1\n            ),\n            \"invalid\": invalid,\n        }\n\n        seconds = get_or_set(\"avg-import-time\", get_average_import_time, timeout=86400)\n        if seconds and seconds > 60**2:\n            data[\"recent_avg_hours\"] = seconds / (60**2)\n        elif seconds:\n            data[\"recent_avg_minutes\"] = seconds / 60\n\n        return TemplateResponse(request, \"import/import.html\", data)\n\n    def post(self, request):\n        \"\"\"ingest a goodreads csv\"\"\"\n        site = models.SiteSettings.objects.get()\n        if not site.imports_enabled:\n            raise PermissionDenied()\n\n        form = forms.ImportForm(request.POST, request.FILES)\n        if not form.is_valid():\n            return HttpResponseBadRequest()\n\n        include_reviews = request.POST.get(\"include_reviews\") == \"on\"\n        privacy = request.POST.get(\"privacy\")\n        source = request.POST.get(\"source\")\n\n        importer = None\n        if source == \"LibraryThing\":\n            importer = LibrarythingImporter()\n        elif source == \"Storygraph\":\n            importer = StorygraphImporter()\n        elif source == \"OpenLibrary\":\n            importer = OpenLibraryImporter()\n        elif source == \"Calibre\":\n            importer = CalibreImporter()\n        else:\n            # Default : Goodreads\n            importer = GoodreadsImporter()\n\n        try:\n            job = importer.create_job(\n                request.user,\n                TextIOWrapper(request.FILES[\"csv_file\"], encoding=importer.encoding),\n                include_reviews,\n                privacy,\n            )\n        except (UnicodeDecodeError, ValueError, KeyError):\n            return self.get(request, invalid=True)\n\n        job.start_job()\n\n        return redirect(f\"/import/{job.id}\")\n\n\ndef get_average_import_time() -> float:\n    \"\"\"Helper to figure out how long imports are taking (returns seconds)\"\"\"\n    last_week = timezone.now() - datetime.timedelta(days=7)\n    recent_avg = (\n        models.ImportJob.objects.filter(created_date__gte=last_week, complete=True)\n        .exclude(status=\"stopped\")\n        .annotate(\n            runtime=ExpressionWrapper(\n                F(\"updated_date\") - F(\"created_date\"),\n                output_field=fields.DurationField(),\n            )\n        )\n        .aggregate(Avg(\"runtime\"))\n        .get(\"runtime__avg\")\n    )\n\n    if recent_avg:\n        return recent_avg.total_seconds()\n    return None\n", "path": "bookwyrm/views/imports/import_data.py"}, {"content": "\"\"\" handle reading a csv from an external service, defaults are from Goodreads \"\"\"\nimport csv\nfrom django.utils import timezone\nfrom bookwyrm.models import ImportJob, ImportItem\n\n\nclass Importer:\n    \"\"\"Generic class for csv data import from an outside service\"\"\"\n\n    service = \"Import\"\n    delimiter = \",\"\n    encoding = \"UTF-8\"\n\n    # these are from Goodreads\n    row_mappings_guesses = [\n        (\"id\", [\"id\", \"book id\"]),\n        (\"title\", [\"title\"]),\n        (\"authors\", [\"author\", \"authors\", \"primary author\"]),\n        (\"isbn_10\", [\"isbn10\", \"isbn\"]),\n        (\"isbn_13\", [\"isbn13\", \"isbn\", \"isbns\"]),\n        (\"shelf\", [\"shelf\", \"exclusive shelf\", \"read status\", \"bookshelf\"]),\n        (\"review_name\", [\"review name\"]),\n        (\"review_body\", [\"my review\", \"review\"]),\n        (\"rating\", [\"my rating\", \"rating\", \"star rating\"]),\n        (\"date_added\", [\"date added\", \"entry date\", \"added\"]),\n        (\"date_started\", [\"date started\", \"started\"]),\n        (\"date_finished\", [\"date finished\", \"last date read\", \"date read\", \"finished\"]),\n    ]\n    date_fields = [\"date_added\", \"date_started\", \"date_finished\"]\n    shelf_mapping_guesses = {\n        \"to-read\": [\"to-read\", \"want to read\"],\n        \"read\": [\"read\", \"already read\"],\n        \"reading\": [\"currently-reading\", \"reading\", \"currently reading\"],\n    }\n\n    def create_job(self, user, csv_file, include_reviews, privacy):\n        \"\"\"check over a csv and creates a database entry for the job\"\"\"\n        csv_reader = csv.DictReader(csv_file, delimiter=self.delimiter)\n        rows = list(csv_reader)\n        if len(rows) < 1:\n            raise ValueError(\"CSV file is empty\")\n        rows = enumerate(rows)\n\n        job = ImportJob.objects.create(\n            user=user,\n            include_reviews=include_reviews,\n            privacy=privacy,\n            mappings=self.create_row_mappings(csv_reader.fieldnames),\n            source=self.service,\n        )\n\n        for index, entry in rows:\n            self.create_item(job, index, entry)\n        return job\n\n    def update_legacy_job(self, job):\n        \"\"\"patch up a job that was in the old format\"\"\"\n        items = job.items\n        headers = list(items.first().data.keys())\n        job.mappings = self.create_row_mappings(headers)\n        job.updated_date = timezone.now()\n        job.save()\n\n        for item in items.all():\n            normalized = self.normalize_row(item.data, job.mappings)\n            normalized[\"shelf\"] = self.get_shelf(normalized)\n            item.normalized_data = normalized\n            item.save()\n\n    def create_row_mappings(self, headers):\n        \"\"\"guess what the headers mean\"\"\"\n        mappings = {}\n        for (key, guesses) in self.row_mappings_guesses:\n            value = [h for h in headers if h.lower() in guesses]\n            value = value[0] if len(value) else None\n            if value:\n                headers.remove(value)\n            mappings[key] = value\n        return mappings\n\n    def create_item(self, job, index, data):\n        \"\"\"creates and saves an import item\"\"\"\n        normalized = self.normalize_row(data, job.mappings)\n        normalized[\"shelf\"] = self.get_shelf(normalized)\n        ImportItem(job=job, index=index, data=data, normalized_data=normalized).save()\n\n    def get_shelf(self, normalized_row):\n        \"\"\"determine which shelf to use\"\"\"\n        shelf_name = normalized_row.get(\"shelf\")\n        if not shelf_name:\n            return None\n        shelf_name = shelf_name.lower()\n        shelf = [\n            s for (s, gs) in self.shelf_mapping_guesses.items() if shelf_name in gs\n        ]\n        return shelf[0] if shelf else None\n\n    def normalize_row(self, entry, mappings):  # pylint: disable=no-self-use\n        \"\"\"use the dataclass to create the formatted row of data\"\"\"\n        return {k: entry.get(v) for k, v in mappings.items()}\n\n    def create_retry_job(self, user, original_job, items):\n        \"\"\"retry items that didn't import\"\"\"\n        job = ImportJob.objects.create(\n            user=user,\n            include_reviews=original_job.include_reviews,\n            privacy=original_job.privacy,\n            source=original_job.source,\n            # TODO: allow users to adjust mappings\n            mappings=original_job.mappings,\n            retry=True,\n        )\n        for item in items:\n            # this will re-normalize the raw data\n            self.create_item(job, item.index, item.data)\n        return job\n", "path": "bookwyrm/importers/importer.py"}]}