forked from mirrors/bookwyrm
parent
0728864fe0
commit
701a644c31
6 changed files with 206 additions and 0 deletions
22
bookwyrm/templates/preferences/export.html
Normal file
22
bookwyrm/templates/preferences/export.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
{% extends 'preferences/layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "CSV Export" %}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "CSV Export" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="block content">
|
||||
<p class="notification">
|
||||
{% trans "Your export will include all the books on your shelves, books you have reviewed, and books with reading activity." %}
|
||||
</p>
|
||||
<p>
|
||||
<a href="{% url 'prefs-export-file' %}" class="button">
|
||||
<span class="icon icon-download" aria-hidden="true"></span>
|
||||
<span>Download file</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -24,6 +24,17 @@
|
|||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<h2 class="menu-label">{% trans "Data" %}</h2>
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
{% url 'import' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Import" %}</a>
|
||||
</li>
|
||||
<li>
|
||||
{% url 'prefs-export' as url %}
|
||||
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "CSV export" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<h2 class="menu-label">{% trans "Relationships" %}</h2>
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
|
|
69
bookwyrm/tests/views/test_export.py
Normal file
69
bookwyrm/tests/views/test_export.py
Normal file
|
@ -0,0 +1,69 @@
|
|||
""" test for app action functionality """
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
|
||||
from bookwyrm import models, views
|
||||
from bookwyrm.tests.validate_html import validate_html
|
||||
|
||||
|
||||
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
||||
@patch("bookwyrm.activitystreams.add_status_task.delay")
|
||||
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
|
||||
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
||||
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
|
||||
class ExportViews(TestCase):
|
||||
"""viewing and creating statuses"""
|
||||
|
||||
def setUp(self):
|
||||
"""we need basic test data and mocks"""
|
||||
self.factory = RequestFactory()
|
||||
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
|
||||
"bookwyrm.activitystreams.populate_stream_task.delay"
|
||||
):
|
||||
self.local_user = models.User.objects.create_user(
|
||||
"mouse@local.com",
|
||||
"mouse@mouse.com",
|
||||
"mouseword",
|
||||
local=True,
|
||||
localname="mouse",
|
||||
remote_id="https://example.com/users/mouse",
|
||||
)
|
||||
self.work = models.Work.objects.create(title="Test Work")
|
||||
self.book = models.Edition.objects.create(
|
||||
title="Test Book",
|
||||
remote_id="https://example.com/book/1",
|
||||
parent_work=self.work,
|
||||
isbn_13="9781234567890",
|
||||
bnf_id="beep",
|
||||
)
|
||||
|
||||
def tst_export_get(self, *_):
|
||||
"""request export"""
|
||||
request = self.factory.get("")
|
||||
request.user = self.local_user
|
||||
result = views.Export.as_view()(request)
|
||||
validate_html(result.render())
|
||||
|
||||
def test_export_file(self, *_):
|
||||
"""simple export"""
|
||||
models.ShelfBook.objects.create(
|
||||
shelf=self.local_user.shelf_set.first(),
|
||||
user=self.local_user,
|
||||
book=self.book,
|
||||
)
|
||||
request = self.factory.get("")
|
||||
request.user = self.local_user
|
||||
export = views.export_user_book_data(request)
|
||||
self.assertIsInstance(export, StreamingHttpResponse)
|
||||
self.assertEqual(export.status_code, 200)
|
||||
result = list(export.streaming_content)
|
||||
# pylint: disable=line-too-long
|
||||
self.assertEqual(
|
||||
result[0],
|
||||
b"title,author_text,remote_id,openlibrary_key,inventaire_id,librarything_key,goodreads_key,bnf_id,viaf,wikidata,asin,isbn_10,isbn_13,oclc_number,rating,review_name,review_cw,review_content\r\n",
|
||||
)
|
||||
expected = f"Test Book,,{self.book.remote_id},,,,,beep,,,,123456789X,9781234567890,,,,,\r\n"
|
||||
self.assertEqual(result[1].decode("utf-8"), expected)
|
|
@ -475,6 +475,12 @@ urlpatterns = [
|
|||
views.ChangePassword.as_view(),
|
||||
name="prefs-password",
|
||||
),
|
||||
re_path(r"^preferences/export/?$", views.Export.as_view(), name="prefs-export"),
|
||||
re_path(
|
||||
r"^preferences/export/file/?$",
|
||||
views.export_user_book_data,
|
||||
name="prefs-export-file",
|
||||
),
|
||||
re_path(r"^preferences/delete/?$", views.DeleteUser.as_view(), name="prefs-delete"),
|
||||
re_path(r"^preferences/block/?$", views.Block.as_view(), name="prefs-block"),
|
||||
re_path(r"^block/(?P<user_id>\d+)/?$", views.Block.as_view()),
|
||||
|
|
|
@ -28,6 +28,7 @@ from .admin.user_admin import UserAdmin, UserAdminList
|
|||
# user preferences
|
||||
from .preferences.change_password import ChangePassword
|
||||
from .preferences.edit_user import EditUser
|
||||
from .preferences.export import Export, export_user_book_data
|
||||
from .preferences.delete_user import DeleteUser
|
||||
from .preferences.block import Block, unblock
|
||||
|
||||
|
|
97
bookwyrm/views/preferences/export.py
Normal file
97
bookwyrm/views/preferences/export.py
Normal file
|
@ -0,0 +1,97 @@
|
|||
""" Let users export their book data """
|
||||
import csv
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.template.response import TemplateResponse
|
||||
from django.views import View
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.http import require_GET
|
||||
|
||||
from bookwyrm import models
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
@method_decorator(login_required, name="dispatch")
|
||||
class Export(View):
|
||||
"""Let users export data"""
|
||||
|
||||
def get(self, request):
|
||||
"""Request csv file"""
|
||||
return TemplateResponse(request, "preferences/export.html")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def export_user_book_data(request):
|
||||
"""Streaming the csv file of a user's book data"""
|
||||
data = (
|
||||
models.Edition.viewer_aware_objects(request.user)
|
||||
.filter(
|
||||
Q(shelves__user=request.user)
|
||||
| Q(readthrough__user=request.user)
|
||||
| Q(review__user=request.user)
|
||||
| Q(comment__user=request.user)
|
||||
| Q(quotation__user=request.user)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
generator = csv_row_generator(data, request.user)
|
||||
|
||||
pseudo_buffer = Echo()
|
||||
writer = csv.writer(pseudo_buffer)
|
||||
# for testing, if you want to see the results in the browser:
|
||||
# from django.http import JsonResponse
|
||||
# return JsonResponse(list(generator), safe=False)
|
||||
return StreamingHttpResponse(
|
||||
(writer.writerow(row) for row in generator),
|
||||
content_type="text/csv",
|
||||
headers={"Content-Disposition": 'attachment; filename="bookwyrm-export.csv"'},
|
||||
)
|
||||
|
||||
|
||||
def csv_row_generator(books, user):
|
||||
"""generate a csv entry for the user's book"""
|
||||
deduplication_fields = [
|
||||
f.name
|
||||
for f in models.Edition._meta.get_fields() # pylint: disable=protected-access
|
||||
if getattr(f, "deduplication_field", False)
|
||||
]
|
||||
fields = (
|
||||
["title", "author_text"]
|
||||
+ deduplication_fields
|
||||
+ ["rating", "review_name", "review_cw", "review_content"]
|
||||
)
|
||||
yield fields
|
||||
for book in books:
|
||||
# I think this is more efficient than doing a subquery in the view? but idk
|
||||
review_rating = (
|
||||
models.Review.objects.filter(user=user, book=book, rating__isnull=False)
|
||||
.order_by("-published_date")
|
||||
.first()
|
||||
)
|
||||
|
||||
book.rating = review_rating.rating if review_rating else None
|
||||
|
||||
review = (
|
||||
models.Review.objects.filter(user=user, book=book, content__isnull=False)
|
||||
.order_by("-published_date")
|
||||
.first()
|
||||
)
|
||||
if review:
|
||||
book.review_name = review.name
|
||||
book.review_cw = review.content_warning
|
||||
book.review_content = review.raw_content
|
||||
yield [getattr(book, field, "") or "" for field in fields]
|
||||
|
||||
|
||||
class Echo:
|
||||
"""An object that implements just the write method of the file-like
|
||||
interface. (https://docs.djangoproject.com/en/3.2/howto/outputting-csv/)
|
||||
"""
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def write(self, value):
|
||||
"""Write the value by returning it, instead of storing in a buffer."""
|
||||
return value
|
Loading…
Reference in a new issue