2
0
Fork 0

Merge branch 'production' into nix

This commit is contained in:
D Anzorge 2021-05-11 03:32:08 +02:00
commit 8913896715
58 changed files with 4015 additions and 9420 deletions

View file

@ -8,6 +8,4 @@ jobs:
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: psf/black@stable
with:
args: ". --check -l 80 -S"
- uses: psf/black@21.4b2

View file

@ -122,7 +122,6 @@ class AbstractConnector(AbstractMinimalConnector):
# load the json
data = self.get_book_data(remote_id)
mapped_data = dict_from_mappings(data, self.book_mappings)
if self.is_work_data(data):
try:
edition_data = self.get_edition_from_work_data(data)
@ -130,24 +129,26 @@ class AbstractConnector(AbstractMinimalConnector):
# hack: re-use the work data as the edition data
# this is why remote ids aren't necessarily unique
edition_data = data
work_data = mapped_data
work_data = data
else:
edition_data = data
try:
work_data = self.get_work_from_edition_data(data)
work_data = dict_from_mappings(work_data, self.book_mappings)
except (KeyError, ConnectorException):
work_data = mapped_data
except (KeyError, ConnectorException) as e:
logger.exception(e)
work_data = data
if not work_data or not edition_data:
raise ConnectorException("Unable to load book data: %s" % remote_id)
with transaction.atomic():
# create activitypub object
work_activity = activitypub.Work(**work_data)
work_activity = activitypub.Work(
**dict_from_mappings(work_data, self.book_mappings)
)
# this will dedupe automatically
work = work_activity.to_model(model=models.Work)
for author in self.get_authors_from_data(data):
for author in self.get_authors_from_data(work_data):
work.authors.add(author)
edition = self.create_edition_from_data(work, edition_data)

View file

@ -19,7 +19,7 @@ class ConnectorException(HTTPError):
"""when the connector can't do what was asked"""
def search(query, min_confidence=0.1):
def search(query, min_confidence=0.1, return_first=False):
"""find books based on arbitary keywords"""
if not query:
return []
@ -29,23 +29,18 @@ def search(query, min_confidence=0.1):
isbn = re.sub(r"[\W_]", "", query)
maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13
dedup_slug = lambda r: "%s/%s/%s" % (r.title, r.author, r.year)
result_index = set()
for connector in get_connectors():
result_set = None
if maybe_isbn:
if maybe_isbn and connector.isbn_search_url and connector.isbn_search_url == "":
# Search on ISBN
if not connector.isbn_search_url or connector.isbn_search_url == "":
result_set = []
else:
try:
result_set = connector.isbn_search(isbn)
except Exception as e: # pylint: disable=broad-except
logger.exception(e)
continue
try:
result_set = connector.isbn_search(isbn)
except Exception as e: # pylint: disable=broad-except
logger.exception(e)
# if this fails, we can still try regular search
# if no isbn search or results, we fallback to generic search
if result_set in (None, []):
# if no isbn search results, we fallback to generic search
if not result_set:
try:
result_set = connector.search(query, min_confidence=min_confidence)
except Exception as e: # pylint: disable=broad-except
@ -53,16 +48,20 @@ def search(query, min_confidence=0.1):
logger.exception(e)
continue
# if the search results look the same, ignore them
result_set = [r for r in result_set if dedup_slug(r) not in result_index]
# `|=` concats two sets. WE ARE GETTING FANCY HERE
result_index |= set(dedup_slug(r) for r in result_set)
results.append(
{
"connector": connector,
"results": result_set,
}
)
if return_first and result_set:
# if we found anything, return it
return result_set[0]
if result_set or connector.local:
results.append(
{
"connector": connector,
"results": result_set,
}
)
if return_first:
return None
return results
@ -83,11 +82,7 @@ def isbn_local_search(query, raw=False):
def first_search_result(query, min_confidence=0.1):
"""search until you find a result that fits"""
for connector in get_connectors():
result = connector.search(query, min_confidence=min_confidence)
if result:
return result[0]
return None
return search(query, min_confidence=min_confidence, return_first=True) or None
def get_connectors():

View file

@ -74,6 +74,14 @@ class Connector(AbstractConnector):
**{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks"]},
}
def search(self, query, min_confidence=None):
"""overrides default search function with confidence ranking"""
results = super().search(query)
if min_confidence:
# filter the search results after the fact
return [r for r in results if r.confidence >= min_confidence]
return results
def parse_search_data(self, data):
return data.get("results")
@ -84,6 +92,9 @@ class Connector(AbstractConnector):
if images
else None
)
# a deeply messy translation of inventaire's scores
confidence = float(search_result.get("_score", 0.1))
confidence = 0.1 if confidence < 150 else 0.999
return SearchResult(
title=search_result.get("label"),
key=self.get_remote_id(search_result.get("uri")),
@ -92,6 +103,7 @@ class Connector(AbstractConnector):
self.base_url, search_result.get("uri")
),
cover=cover,
confidence=confidence,
connector=self,
)
@ -123,8 +135,10 @@ class Connector(AbstractConnector):
def load_edition_data(self, work_uri):
"""get a list of editions for a work"""
url = "{:s}?action=reverse-claims&property=wdt:P629&value={:s}".format(
self.books_url, work_uri
url = (
"{:s}?action=reverse-claims&property=wdt:P629&value={:s}&sort=true".format(
self.books_url, work_uri
)
)
return get_data(url)
@ -137,9 +151,8 @@ class Connector(AbstractConnector):
return self.get_book_data(self.get_remote_id(uri))
def get_work_from_edition_data(self, data):
try:
uri = data["claims"]["wdt:P629"]
except KeyError:
uri = data.get("wdt:P629", [None])[0]
if not uri:
raise ConnectorException("Invalid book data")
return self.get_book_data(self.get_remote_id(uri))

View file

@ -3,3 +3,4 @@
from .importer import Importer
from .goodreads_import import GoodreadsImporter
from .librarything_import import LibrarythingImporter
from .storygraph_import import StorygraphImporter

View file

@ -0,0 +1,34 @@
""" handle reading a csv from librarything """
import re
import math
from . import Importer
class StorygraphImporter(Importer):
"""csv downloads from librarything"""
service = "Storygraph"
# mandatory_fields : fields matching the book title and author
mandatory_fields = ["Title"]
def parse_fields(self, entry):
"""custom parsing for storygraph"""
data = {}
data["import_source"] = self.service
data["Title"] = entry["Title"]
data["Author"] = entry["Authors"] if "Authors" in entry else entry["Author"]
data["ISBN13"] = entry["ISBN"]
data["My Review"] = entry["Review"]
if entry["Star Rating"]:
data["My Rating"] = math.ceil(float(entry["Star Rating"]))
else:
data["My Rating"] = ""
data["Date Added"] = re.sub(r"[/]", "-", entry["Date Added"])
data["Date Read"] = re.sub(r"[/]", "-", entry["Last Date Read"])
data["Exclusive Shelf"] = (
{"read": "read", "currently-reading": "reading", "to-read": "to-read"}
).get(entry["Read Status"], None)
return data

View file

@ -128,7 +128,9 @@ class ImportItem(models.Model):
@property
def rating(self):
"""x/5 star rating for a book"""
return int(self.data["My Rating"])
if self.data.get("My Rating", None):
return int(self.data["My Rating"])
return None
@property
def date_added(self):

View file

@ -372,7 +372,10 @@ class AnnualGoal(BookWyrmModel):
def books(self):
"""the books you've read this year"""
return (
self.user.readthrough_set.filter(finish_date__year__gte=self.year)
self.user.readthrough_set.filter(
finish_date__year__gte=self.year,
finish_date__year__lt=self.year + 1,
)
.order_by("-finish_date")
.all()
)
@ -396,7 +399,8 @@ class AnnualGoal(BookWyrmModel):
def book_count(self):
"""how many books you've read this year"""
return self.user.readthrough_set.filter(
finish_date__year__gte=self.year
finish_date__year__gte=self.year,
finish_date__year__lt=self.year + 1,
).count()

View file

@ -38,7 +38,7 @@ LOCALE_PATHS = [
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env("SECRET_KEY")
@ -108,7 +108,7 @@ MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200))
STREAMS = ["home", "local", "federated"]
# Database
# https://docs.djangoproject.com/en/2.0/ref/settings/#databases
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
BOOKWYRM_DATABASE_BACKEND = env("BOOKWYRM_DATABASE_BACKEND", "postgres")
@ -130,7 +130,7 @@ LOGIN_URL = "/login/"
AUTH_USER_MODEL = "bookwyrm.User"
# Password validation
# https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
@ -149,7 +149,7 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
# https://docs.djangoproject.com/en/2.0/topics/i18n/
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = "en-us"
LANGUAGES = [
@ -171,7 +171,7 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/
# https://docs.djangoproject.com/en/3.2/howto/static-files/
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_URL = "/static/"

View file

@ -22,12 +22,11 @@
</div>
</div>
<div class="block">
<div class="block content">
{% if author.bio %}
<p>
{{ author.bio | to_markdown | safe }}
</p>
{% endif %}
{% if author.wikipedia_link %}
<p><a href="{{ author.wikipedia_link }}" rel=”noopener” target="_blank">{% trans "Wikipedia" %}</a></p>
{% endif %}

View file

@ -239,7 +239,7 @@
{% if not confirm_mode %}
<div class="block">
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
<a class="button" href="/book/{{ book.id }}">{% trans "Cancel" %}</a>
<a class="button" href="{{ book.local_path}}">{% trans "Cancel" %}</a>
</div>
{% endif %}
</form>

View file

@ -15,14 +15,14 @@
{% for book in editions %}
<div class="columns is-gapless mb-6">
<div class="column is-cover">
<a href="/book/{{ book.id }}">
<a href="{{ book.local_path }}">
{% include 'snippets/book_cover.html' with book=book cover_class='is-w-m is-h-m align to-l-mobile' %}
</a>
</div>
<div class="column my-3-mobile ml-3-tablet mr-auto">
<h2 class="title is-5 mb-1">
<a href="/book/{{ book.id }}" class="has-text-black">
<a href="{{ book.local_path }}" class="has-text-black">
{{ book.title }}
</a>
</h2>

View file

@ -1,7 +1,5 @@
{% extends 'layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load humanize %}
{% block title %}{% trans "Directory" %}{% endblock %}
@ -41,59 +39,7 @@
<div class="columns is-multiline">
{% for user in users %}
<div class="column is-one-third">
<div class="card is-stretchable">
<div class="card-content">
<div class="media">
<a href="{{ user.local_path }}" class="media-left">
{% include 'snippets/avatar.html' with user=user large=True %}
</a>
<div class="media-content">
<a href="{{ user.local_path }}" class="is-block mb-2">
<span class="title is-4 is-block">{{ user.display_name }}</span>
<span class="subtitle is-7 is-block">@{{ user|username }}</span>
</a>
{% include 'snippets/follow_button.html' with user=user %}
</div>
</div>
<div>
{% if user.summary %}
{{ user.summary | to_markdown | safe | truncatechars_html:40 }}
{% else %}&nbsp;{% endif %}
</div>
</div>
<footer class="card-footer">
{% if user != request.user %}
{% if user.mutuals %}
<div class="card-footer-item">
<div class="has-text-centered">
<p class="title is-6 mb-0">{{ user.mutuals }}</p>
<p class="help">{% blocktrans count counter=user.mutuals %}follower you follow{% plural %}followers you follow{% endblocktrans %}</p>
</div>
</div>
{% elif user.shared_books %}
<div class="card-footer-item">
<div class="has-text-centered">
<p class="title is-6 mb-0">{{ user.shared_books }}</p>
<p class="help">{% blocktrans count counter=user.shared_books %}book on your shelves{% plural %}books on your shelves{% endblocktrans %}</p>
</div>
</div>
{% endif %}
{% endif %}
<div class="card-footer-item">
<div class="has-text-centered">
<p class="title is-6 mb-0">{{ user.status_set.count|intword }}</p>
<p class="help">{% trans "posts" %}</p>
</div>
</div>
<div class="card-footer-item">
<div class="has-text-centered">
<p class="title is-6 mb-0">{{ user.last_active_date|naturalday }}</p>
<p class="help">{% trans "last active" %}</p>
</div>
</div>
</footer>
</div>
{% include 'directory/user_card.html' %}
</div>
{% endfor %}
</div>

View file

@ -0,0 +1,57 @@
{% load i18n %}
{% load bookwyrm_tags %}
{% load humanize %}
<div class="card is-stretchable">
<div class="card-content">
<div class="media">
<a href="{{ user.local_path }}" class="media-left">
{% include 'snippets/avatar.html' with user=user large=True %}
</a>
<div class="media-content">
<a href="{{ user.local_path }}" class="is-block mb-2">
<span class="title is-4 is-block">{{ user.display_name }}</span>
<span class="subtitle is-7 is-block">@{{ user|username }}</span>
</a>
{% include 'snippets/follow_button.html' with user=user %}
</div>
</div>
<div>
{% if user.summary %}
{{ user.summary | to_markdown | safe | truncatechars_html:40 }}
{% else %}&nbsp;{% endif %}
</div>
</div>
<footer class="card-footer">
{% if user != request.user %}
{% if user.mutuals %}
<div class="card-footer-item">
<div class="has-text-centered">
<p class="title is-6 mb-0">{{ user.mutuals }}</p>
<p class="help">{% blocktrans count counter=user.mutuals %}follower you follow{% plural %}followers you follow{% endblocktrans %}</p>
</div>
</div>
{% elif user.shared_books %}
<div class="card-footer-item">
<div class="has-text-centered">
<p class="title is-6 mb-0">{{ user.shared_books }}</p>
<p class="help">{% blocktrans count counter=user.shared_books %}book on your shelves{% plural %}books on your shelves{% endblocktrans %}</p>
</div>
</div>
{% endif %}
{% endif %}
<div class="card-footer-item">
<div class="has-text-centered">
<p class="title is-6 mb-0">{{ user.status_set.count|intword }}</p>
<p class="help">{% trans "posts" %}</p>
</div>
</div>
<div class="card-footer-item">
<div class="has-text-centered">
<p class="title is-6 mb-0">{{ user.last_active_date|naturalday }}</p>
<p class="help">{% trans "last active" %}</p>
</div>
</div>
</footer>
</div>

View file

@ -17,7 +17,7 @@
<div class="column mt-3-mobile ml-3-tablet">
<h3 class="title is-5">
<a href="/book/{{ book.id }}">{{ book.title }}</a>
<a href="{{ book.local_path }}">{{ book.title }}</a>
</h3>
{% if book.authors %}

View file

@ -11,7 +11,7 @@
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
<h3 class="title is-6">
<a href="/book/{{ book.id }}">{{ book.title }}</a>
<a href="{{ book.local_path }}">{{ book.title }}</a>
</h3>
{% if book.authors %}

View file

@ -76,7 +76,7 @@
<div class="block">
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
<a class="button" href="/author/{{ author.id }}">{% trans "Cancel" %}</a>
<a class="button" href="{{ author.local_path }}">{% trans "Cancel" %}</a>
</div>
</form>

View file

@ -10,11 +10,11 @@
{% trans "Direct Messages" %}
{% endif %}
</h1>
{% if partner %}<p class="subtitle"><a href="/direct-messages"><span class="icon icon-arrow-left" aria-hidden="true"></span> {% trans "All messages" %}</a></p>{% endif %}
{% if partner %}<p class="subtitle"><a href="{% url 'direct-messages' %}"><span class="icon icon-arrow-left" aria-hidden="true"></span> {% trans "All messages" %}</a></p>{% endif %}
</header>
<div class="box">
{% include 'snippets/create_status_form.html' with type="direct" uuid=1 mentions=partner %}
{% include 'snippets/create_status_form.html' with type="direct" uuid=1 mention=partner %}
</div>
<section class="block">

View file

@ -20,6 +20,9 @@
<option value="GoodReads" {% if current == 'GoodReads' %}selected{% endif %}>
GoodReads (CSV)
</option>
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
Storygraph (CSV)
</option>
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
LibraryThing (TSV)
</option>
@ -56,7 +59,7 @@
{% endif %}
<ul>
{% for job in jobs %}
<li><a href="/import/{{ job.id }}">{{ job.created_date | naturaltime }}</a></li>
<li><a href="{% url 'import-status' job.id %}">{{ job.created_date | naturaltime }}</a></li>
{% endfor %}
</ul>
</div>

View file

@ -124,7 +124,7 @@
<tr>
<td>
{% if item.book %}
<a href="/book/{{ item.book.id }}">
<a href="{{ item.book.local_path }}">
{% include 'snippets/book_cover.html' with book=item.book cover_class='is-h-s' %}
</a>
{% endif %}

View file

@ -94,12 +94,12 @@
</a>
</li>
<li>
<a href="/import" class="navbar-item">
<a href="{% url 'import' %}" class="navbar-item">
{% trans 'Import Books' %}
</a>
</li>
<li>
<a href="/preferences/profile" class="navbar-item">
<a href="{% url 'prefs-profile' %}" class="navbar-item">
{% trans 'Settings' %}
</a>
</li>
@ -122,14 +122,14 @@
{% endif %}
<li class="navbar-divider" role="presentation"></li>
<li>
<a href="/logout" class="navbar-item">
<a href="{% url 'logout' %}" class="navbar-item">
{% trans 'Log out' %}
</a>
</li>
</ul>
</div>
<div class="navbar-item">
<a href="/notifications" class="tags has-addons">
<a href="{% url 'notifications' %}" class="tags has-addons">
<span class="tag is-medium">
<span class="icon icon-bell" title="{% trans 'Notifications' %}">
<span class="is-sr-only">{% trans "Notifications" %}</span>
@ -158,7 +158,7 @@
<div class="column">
<label class="is-sr-only" for="id_password">{% trans "Username:" %}</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="{% trans 'password' %}">
<p class="help"><a href="/password-reset">{% trans "Forgot your password?" %}</a></p>
<p class="help"><a href="{% url 'password-reset' %}">{% trans "Forgot your password?" %}</a></p>
</div>
<div class="column is-narrow">
<button class="button is-primary" type="submit">{% trans "Log in" %}</button>
@ -195,7 +195,7 @@
<div class="columns">
<div class="column is-one-fifth">
<p>
<a href="/about">{% trans "About this server" %}</a>
<a href="{% url 'about' %}">{% trans "About this server" %}</a>
</p>
{% if site.admin_email %}
<p>

View file

@ -33,7 +33,7 @@
<button class="button is-primary" type="submit">{% trans "Log in" %}</button>
</div>
<div class="control">
<small><a href="/password-reset">{% trans "Forgot your password?" %}</a></small>
<small><a href="{% url 'password-reset' %}">{% trans "Forgot your password?" %}</a></small>
</div>
</div>
</form>
@ -56,7 +56,7 @@
{% include 'snippets/about.html' %}
<p class="block">
<a href="/about/">{% trans "More about this site" %}</a>
<a href="{% url 'about' %}">{% trans "More about this site" %}</a>
</p>
</div>
</div>

View file

@ -6,13 +6,30 @@
{% block title %}{% trans "Notifications" %}{% endblock %}
{% block content %}
<div class="block">
<h1 class="title">{% trans "Notifications" %}</h1>
<header class="columns">
<div class="column">
<h1 class="title">{% trans "Notifications" %}</h1>
</div>
<form name="clear" action="/notifications" method="POST">
<form name="clear" action="/notifications" method="POST" class="column is-narrow">
{% csrf_token %}
<button class="button is-danger is-light" type="submit" class="secondary">{% trans "Delete notifications" %}</button>
</form>
</header>
<div class="block">
<nav class="tabs">
<ul>
{% url 'notifications' as tab_url %}
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
<a href="{{ tab_url }}">{% trans "All" %}</a>
</li>
{% url 'notifications' 'mentions' as tab_url %}
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
<a href="{{ tab_url }}">{% trans "Mentions" %}</a>
</li>
</ul>
</nav>
</div>
<div class="block">
@ -107,7 +124,8 @@
{% endif %}
{% endif %}
{% elif notification.related_import %}
{% blocktrans with related_id=notification.related_import.id %}Your <a href="/import/{{ related_id }}">import</a> completed.{% endblocktrans %}
{% url 'import-status' notification.related_import.id as url %}
{% blocktrans %}Your <a href="{{ url }}">import</a> completed.{% endblocktrans %}
{% elif notification.related_report %}
{% url 'settings-report' notification.related_report.id as path %}
{% blocktrans with related_id=path %}A new <a href="{{ path }}">report</a> needs moderation.{% endblocktrans %}

View file

@ -11,16 +11,19 @@
<h2 class="menu-label">{% trans "Account" %}</h2>
<ul class="menu-list">
<li>
<a href="/preferences/profile"{% if '/preferences/profile' in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Profile" %}</a>
{% url 'prefs-profile' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Profile" %}</a>
</li>
<li>
<a href="/preferences/password"{% if '/preferences/password' in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Change Password" %}</a>
{% url 'prefs-password' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Change Password" %}</a>
</li>
</ul>
<h2 class="menu-label">{% trans "Relationships" %}</h2>
<ul class="menu-list">
<li>
<a href="/preferences/block"{% if '/preferences/block' in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Blocked Users" %}</a>
{% url 'prefs-block' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Blocked Users" %}</a>
</li>
</ul>
</nav>

View file

@ -0,0 +1,78 @@
{% extends 'search/layout.html' %}
{% load i18n %}
{% block panel %}
{% if results %}
{% with results|first as local_results %}
<ul class="block">
{% for result in local_results.results %}
<li class="pd-4">
{% include 'snippets/search_result_text.html' with result=result %}
</li>
{% endfor %}
</ul>
{% endwith %}
<div class="block">
{% for result_set in results|slice:"1:" %}
{% if result_set.results %}
<section class="box has-background-white-bis">
{% if not result_set.connector.local %}
<header class="columns is-mobile">
<div class="column">
<h3 class="title is-5">
Results from
<a href="{{ result_set.connector.base_url }}" target="_blank">{{ result_set.connector.name|default:result_set.connector.identifier }}</a>
</h3>
</div>
<div class="column is-narrow">
{% trans "Open" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text small=True controls_text="more-results-panel" controls_uid=result_set.connector.identifier class="is-small" icon_with_text="arrow-down" pressed=forloop.first %}
{% trans "Close" as button_text %}
{% include 'snippets/toggle/close_button.html' with text=button_text small=True controls_text="more-results-panel" controls_uid=result_set.connector.identifier class="is-small" icon_with_text="arrow-up" pressed=forloop.first %}
</div>
</header>
{% endif %}
<div class="box has-background-white is-shadowless{% if not forloop.first %} is-hidden{% endif %}" id="more-results-panel-{{ result_set.connector.identifier }}">
<div class="is-flex is-flex-direction-row-reverse">
<div>
</div>
<ul class="is-flex-grow-1">
{% for result in result_set.results %}
<li class="mb-5">
{% include 'snippets/search_result_text.html' with result=result remote_result=True %}
</li>
{% endfor %}
</ul>
</div>
</div>
</section>
{% endif %}
{% endfor %}
</div>
{% endif %}
<p class="block">
{% if request.user.is_authenticated %}
{% if not remote %}
<a href="{{ request.path }}?q={{ query }}&type=book&remote=true">
{% trans "Load results from other catalogues" %}
</a>
{% else %}
<a href="{% url 'create-book' %}">
{% trans "Manually add book" %}
</a>
{% endif %}
{% else %}
<a href="{% url 'login' %}">
{% trans "Log in to import or add books." %}
</a>
{% endif %}
</p>
{% endblock %}

View file

@ -0,0 +1,70 @@
{% extends 'layout.html' %}
{% load i18n %}
{% block title %}{% trans "Search" %}{% endblock %}
{% block content %}
<div class="block">
<h1 class="title">
{% blocktrans %}Search{% endblocktrans %}
</h1>
</div>
<form class="block" action="{% url 'search' %}" method="GET">
<div class="field has-addons">
<div class="control">
<input type="input" class="input" name="q" value="{{ query }}" aria-label="{% trans 'Search query' %}">
</div>
<div class="control">
<div class="select" aria-label="{% trans 'Search type' %}">
<select name="type">
<option value="book" {% if type == "book" %}selected{% endif %}>{% trans "Books" %}</option>
{% if request.user.is_authenticated %}
<option value="user" {% if type == "user" %}selected{% endif %}>{% trans "Users" %}</option>
{% endif %}
<option value="list" {% if type == "list" %}selected{% endif %}>{% trans "Lists" %}</option>
</select>
</div>
</div>
<div class="control">
<button type="submit" class="button is-primary">
<span>Search</span>
<span class="icon icon-search" aria-hidden="true"></span>
</button>
</div>
</div>
</form>
{% if query %}
<nav class="tabs">
<ul>
<li{% if type == "book" %} class="is-active"{% endif %}>
<a href="{% url 'search' %}?q={{ query }}&type=book">{% trans "Books" %}</a>
</li>
{% if request.user.is_authenticated %}
<li{% if type == "user" %} class="is-active"{% endif %}>
<a href="{% url 'search' %}?q={{ query }}&type=user">{% trans "Users" %}</a>
</li>
{% endif %}
<li{% if type == "list" %} class="is-active"{% endif %}>
<a href="{% url 'search' %}?q={{ query }}&type=list">{% trans "Lists" %}</a>
</li>
</ul>
</nav>
<section class="block">
{% if not results %}
<p>
<em>{% blocktrans %}No results found for "{{ query }}"{% endblocktrans %}</em>
</p>
{% endif %}
{% block panel %}
{% endblock %}
<div>
{% include 'snippets/pagination.html' with page=results path=request.path %}
</div>
</section>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,7 @@
{% extends 'search/layout.html' %}
{% block panel %}
{% include 'lists/list_items.html' with lists=results %}
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends 'search/layout.html' %}
{% load bookwyrm_tags %}
{% block panel %}
<div class="columns is-multiline">
{% for user in results %}
<div class="column is-one-third">
{% include 'directory/user_card.html' %}
</div>
{% endfor %}
</div>
{% endblock %}

View file

@ -1,133 +0,0 @@
{% extends 'layout.html' %}
{% load i18n %}
{% block title %}{% trans "Search Results" %}{% endblock %}
{% block content %}
{% with book_results|first as local_results %}
<div class="block">
<h1 class="title">{% blocktrans %}Search Results for "{{ query }}"{% endblocktrans %}</h1>
</div>
<div class="block columns">
<div class="column">
<h2 class="title is-4">{% trans "Matching Books" %}</h2>
<section class="block">
{% if not local_results.results %}
<p><em>{% blocktrans %}No books found for "{{ query }}"{% endblocktrans %}</em></p>
{% if not user.is_authenticated %}
<p>
<a href="{% url 'login' %}">{% trans "Log in to import or add books." %}</a>
</p>
{% endif %}
{% else %}
<ul>
{% for result in local_results.results %}
<li class="pd-4">
{% include 'snippets/search_result_text.html' with result=result %}
</li>
{% endfor %}
</ul>
{% endif %}
</section>
{% if request.user.is_authenticated %}
{% if book_results|slice:":1" and local_results.results %}
<div class="block">
<h3 class="title is-6">
{% trans "Didn't find what you were looking for?" %}
</h3>
{% trans "Show results from other catalogues" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text small=True controls_text="more-results" %}
{% if local_results.results %}
{% trans "Hide results from other catalogues" as button_text %}
{% include 'snippets/toggle/close_button.html' with text=button_text small=True controls_text="more-results" %}
{% endif %}
</div>
{% endif %}
<div class="{% if local_results.results %}is-hidden{% endif %}" id="more-results">
{% for result_set in book_results|slice:"1:" %}
{% if result_set.results %}
<section class="box has-background-white-bis">
{% if not result_set.connector.local %}
<header class="columns is-mobile">
<div class="column">
<h3 class="title is-5">
Results from
<a href="{{ result_set.connector.base_url }}" target="_blank">{{ result_set.connector.name|default:result_set.connector.identifier }}</a>
</h3>
</div>
<div class="column is-narrow">
{% trans "Show" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text small=True controls_text="more-results-panel" controls_uid=result_set.connector.identifier class="is-small" icon="arrow-down" pressed=forloop.first %}
</div>
</header>
{% endif %}
<div class="box has-background-white is-shadowless{% if not forloop.first %} is-hidden{% endif %}" id="more-results-panel-{{ result_set.connector.identifier }}">
<div class="is-flex is-flex-direction-row-reverse">
<div>
{% trans "Close" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with label=button_text class="delete" nonbutton=True controls_text="more-results-panel" controls_uid=result_set.connector.identifier pressed=forloop.first %}
</div>
<ul class="is-flex-grow-1">
{% for result in result_set.results %}
<li class="mb-5">
{% include 'snippets/search_result_text.html' with result=result remote_result=True %}
</li>
{% endfor %}
</ul>
</div>
</div>
</section>
{% endif %}
{% endfor %}
</div>
<div class="block">
<a href="/create-book">Manually add book</a>
</div>
{% endif %}
</div>
<div class="column">
{% if request.user.is_authenticated %}
<section class="box">
<h2 class="title is-4">{% trans "Matching Users" %}</h2>
{% if not user_results %}
<p><em>{% blocktrans %}No users found for "{{ query }}"{% endblocktrans %}</em></p>
{% endif %}
<ul>
{% for result in user_results %}
<li class="block">
<a href="{{ result.local_path }}">
{% include 'snippets/avatar.html' with user=result %}
{{ result.display_name }}
</a> ({{ result.username }})
{% include 'snippets/follow_button.html' with user=result %}
</li>
{% endfor %}
</ul>
</section>
{% endif %}
<section class="box">
<h2 class="title is-4">{% trans "Lists" %}</h2>
{% if not list_results %}
<p><em>{% blocktrans %}No lists found for "{{ query }}"{% endblocktrans %}</em></p>
{% endif %}
{% for result in list_results %}
<div class="block">
<ul>
<li>
<a href="{% url 'list' result.id %}">{{ result.name }}</a>
</li>
</ul>
</div>
{% endfor %}
</section>
</div>
</div>
{% endwith %}
{% endblock %}

View file

@ -5,7 +5,7 @@
{% endcomment %}
{% for author in book.authors.all %}
<a
href="/author/{{ author.id }}"
href="{{ author.local_path }}"
class="author"
itemprop="author"
itemscope

View file

@ -13,10 +13,6 @@
{% endif %}
"
{% if aria != "show" %}
aria-hidden="true"
{% endif %}
{% if book.alt_text %}
title="{{ book.alt_text }}"
{% endif %}

View file

@ -3,7 +3,7 @@
<div class="column is-narrow">
<div class="box is-flex is-flex-direction-column is-align-items-center">
<div class="mb-3">
<a href="/book/{{ book.id }}">
<a href="{{ book.local_path }}">
{% include 'snippets/book_cover.html' with book=book cover_class='is-w-l-mobile is-h-l-mobile is-w-l-tablet is-h-xl-tablet' %}
</a>
</div>

View file

@ -36,13 +36,23 @@
<div class="control">
{% if type == 'quotation' %}
<textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.quote|default:'' }}</textarea>
{% elif type == 'reply' %}
{% include 'snippets/content_warning_field.html' with parent_status=status %}
<textarea name="content" class="textarea" id="id_content_{{ type }}-{{ book.id }}{{reply_parent.id}}" placeholder="{{ placeholder }}" aria-label="{% trans 'Reply' %}" required>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mentions %}@{{ mentions|username }} {% endif %}{{ draft.content|default:'' }}</textarea>
<textarea
name="quote"
class="textarea"
id="id_quote_{{ book.id }}_{{ type }}"
placeholder="{{ placeholder }}"
required
>{{ draft.quote|default:'' }}</textarea>
{% else %}
{% include 'snippets/content_warning_field.html' with parent_status=status %}
<textarea name="content" class="textarea" id="id_content_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.content|default:'' }}</textarea>
<textarea
name="content"
class="textarea"
id="id_content_{{ type }}-{{ book.id }}{{reply_parent.id}}"
placeholder="{{ placeholder }}"
aria-label="{% if reply_parent %}{% trans 'Reply' %}{% else %}{% trans "Content" %}{% endif %}"
required
>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mention %}@{{ mention|username }} {% endif %}{{ draft.content|default:'' }}</textarea>
{% endif %}
</div>
</div>

View file

@ -1,5 +1,5 @@
{% load i18n %}
<nav class="pagination" aria-label="pagination">
<nav class="pagination is-centered" aria-label="pagination">
<a
class="pagination-previous {% if not page.has_previous %}is-disabled{% endif %}"
{% if page.has_previous %}
@ -23,4 +23,18 @@
{% trans "Next" %}
<span class="icon icon-arrow-right" aria-hidden="true"></span>
</a>
{% if page.has_other_pages and page_range %}
<ul class="pagination-list">
{% for num in page_range %}
{% if num == page.number %}
<li><a class="pagination-link is-current" aria-label="Page {{ num }}" aria-current="page">{{ num }}</a></li>
{% elif num == '…' %}
<li><span class="pagination-ellipsis">&hellip;</span></li>
{% else %}
<li><a class="pagination-link" aria-label="Goto page {{ num }}" href="{{ path }}?{% for k, v in request.GET.items %}{% if k != 'page' %}{{ k }}={{ v }}&{% endif %}{% endfor %}page={{ num }}{{ anchor }}">{{ num }}</a></li>
{% endif %}
{% endfor %}
</ul>
{% endif %}
</nav>

View file

@ -44,7 +44,7 @@
{% else %}
<div class="card-footer-item">
<a href="/login">
<a href="{% url 'login' %}">
<span class="icon icon-comment is-small" title="{% trans 'Reply' %}">
<span class="is-sr-only">{% trans "Reply" %}</span>
</span>

View file

@ -47,7 +47,7 @@
{% if status.book %}
{% if status.status_type == 'GeneratedNote' or status.status_type == 'Rating' %}
<a href="/book/{{ status.book.id }}">{{ status.book|title }}</a>{% if status.status_type == 'Rating' %}: {% include 'snippets/stars.html' with rating=status.rating %}
<a href="{{ status.book.local_path }}">{{ status.book|title }}</a>{% if status.status_type == 'Rating' %}: {% include 'snippets/stars.html' with rating=status.rating %}
<span
itemprop="reviewRating"
itemscope
@ -58,7 +58,7 @@
{% if status.book %}
{% if status.status_type == 'GeneratedNote' or status.status_type == 'Rating' %}
<a href="/book/{{ status.book.id }}">{{ status.book|title }}</a>{% if status.status_type == 'Rating' %}:
<a href="{{ status.book.local_path }}">{{ status.book|title }}</a>{% if status.status_type == 'Rating' %}:
<span
itemprop="reviewRating"
itemscope
@ -76,7 +76,7 @@
{% include 'snippets/book_titleby.html' with book=status.book %}
{% endif %}
{% elif status.mention_books %}
<a href="/book/{{ status.mention_books.first.id }}">
<a href="{{ status.mention_books.first.local_path }}">
{{ status.mention_books.first.title }}
</a>
{% endif %}
@ -86,7 +86,7 @@
{% include 'snippets/book_titleby.html' with book=status.book %}
{% endif %}
{% elif status.mention_books %}
<a href="/book/{{ status.mention_books.first.id }}">{{ status.mention_books.first|title }}</a>
<a href="{{ status.mention_books.first.local_path }}">{{ status.mention_books.first|title }}</a>
{% endif %}
</h3>

View file

@ -10,7 +10,7 @@
{% block dropdown-list %}
<li role="menuitem">
<a href="/direct-messages/{{ user|username }}" class="button is-fullwidth is-small">{% trans "Send direct message" %}</a>
<a href="{% url 'direct-messages-user' user|username %}" class="button is-fullwidth is-small">{% trans "Send direct message" %}</a>
</li>
<li role="menuitem">
{% include 'snippets/report_button.html' with user=user class="is-fullwidth" %}

View file

@ -24,7 +24,7 @@
{% for follow in follow_list %}
<div class="block columns">
<div class="column">
<a href="{{ follower.local_path }}">
<a href="{{ follow.local_path }}">
{% include 'snippets/avatar.html' with user=follow %}
{{ follow.display_name }}
</a>

View file

@ -1,5 +1,6 @@
""" interface between the app and various connectors """
from django.test import TestCase
import responses
from bookwyrm import models
from bookwyrm.connectors import connector_manager
@ -17,6 +18,9 @@ class ConnectorManager(TestCase):
self.edition = models.Edition.objects.create(
title="Example Edition", parent_work=self.work, isbn_10="0000000000"
)
self.edition = models.Edition.objects.create(
title="Another Edition", parent_work=self.work, isbn_10="1111111111"
)
self.connector = models.Connector.objects.create(
identifier="test_connector",
@ -29,6 +33,18 @@ class ConnectorManager(TestCase):
isbn_search_url="http://test.com/isbn/",
)
self.remote_connector = models.Connector.objects.create(
identifier="test_connector_remote",
priority=1,
local=False,
connector_file="bookwyrm_connector",
base_url="http://fake.ciom/",
books_url="http://fake.ciom/",
search_url="http://fake.ciom/search/",
covers_url="http://covers.fake.ciom/",
isbn_search_url="http://fake.ciom/isbn/",
)
def test_get_or_create_connector(self):
"""loads a connector if the data source is known or creates one"""
remote_id = "https://example.com/object/1"
@ -42,23 +58,38 @@ class ConnectorManager(TestCase):
def test_get_connectors(self):
"""load all connectors"""
remote_id = "https://example.com/object/1"
connector_manager.get_or_create_connector(remote_id)
connectors = list(connector_manager.get_connectors())
self.assertEqual(len(connectors), 2)
self.assertIsInstance(connectors[0], SelfConnector)
self.assertIsInstance(connectors[1], BookWyrmConnector)
@responses.activate
def test_search(self):
"""search all connectors"""
responses.add(
responses.GET,
"http://fake.ciom/search/Example?min_confidence=0.1",
json={},
)
results = connector_manager.search("Example")
self.assertEqual(len(results), 1)
self.assertIsInstance(results[0]["connector"], SelfConnector)
self.assertEqual(len(results[0]["results"]), 1)
self.assertEqual(results[0]["results"][0].title, "Example Edition")
def test_search_empty_query(self):
"""don't panic on empty queries"""
results = connector_manager.search("")
self.assertEqual(results, [])
@responses.activate
def test_search_isbn(self):
"""special handling if a query resembles an isbn"""
responses.add(
responses.GET,
"http://fake.ciom/isbn/0000000000",
json={},
)
results = connector_manager.search("0000000000")
self.assertEqual(len(results), 1)
self.assertIsInstance(results[0]["connector"], SelfConnector)
@ -75,8 +106,22 @@ class ConnectorManager(TestCase):
"""only get one search result"""
result = connector_manager.first_search_result("Example")
self.assertEqual(result.title, "Example Edition")
no_result = connector_manager.first_search_result("dkjfhg")
self.assertIsNone(no_result)
def test_first_search_result_empty_query(self):
"""only get one search result"""
result = connector_manager.first_search_result("")
self.assertIsNone(result)
@responses.activate
def test_first_search_result_no_results(self):
"""only get one search result"""
responses.add(
responses.GET,
"http://fake.ciom/search/dkjfhg?min_confidence=0.1",
json={},
)
result = connector_manager.first_search_result("dkjfhg")
self.assertIsNone(result)
def test_load_connector(self):
"""load a connector object from the database entry"""

View file

@ -35,29 +35,49 @@ class InboxAdd(TestCase):
work = models.Work.objects.create(title="work title")
self.book = models.Edition.objects.create(
title="Test",
remote_id="https://bookwyrm.social/book/37292",
remote_id="https://example.com/book/37292",
parent_work=work,
)
models.SiteSettings.objects.create()
@responses.activate
def test_handle_add_book_to_shelf(self):
"""shelving a book"""
shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf")
shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read"
shelf.remote_id = "https://example.com/user/rat/shelf/to-read"
shelf.save()
responses.add(
responses.GET,
"https://example.com/user/rat/shelf/to-read",
json={
"id": shelf.remote_id,
"type": "Shelf",
"totalItems": 1,
"first": "https://example.com/shelf/22?page=1",
"last": "https://example.com/shelf/22?page=1",
"name": "Test Shelf",
"owner": self.remote_user.remote_id,
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/rat/followers"],
"summary": "summary text",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams",
},
)
activity = {
"id": "https://bookwyrm.social/shelfbook/6189#add",
"id": "https://example.com/shelfbook/6189#add",
"type": "Add",
"actor": "https://example.com/users/rat",
"object": {
"actor": self.remote_user.remote_id,
"type": "ShelfItem",
"book": self.book.remote_id,
"id": "https://bookwyrm.social/shelfbook/6189",
"id": "https://example.com/shelfbook/6189",
},
"target": "https://bookwyrm.social/user/mouse/shelf/to-read",
"target": "https://example.com/user/rat/shelf/to-read",
"@context": "https://www.w3.org/ns/activitystreams",
}
views.inbox.activity_task(activity)
@ -68,7 +88,7 @@ class InboxAdd(TestCase):
"""listing a book"""
responses.add(
responses.GET,
"https://bookwyrm.social/user/mouse/list/to-read",
"https://example.com/user/mouse/list/to-read",
json={
"id": "https://example.com/list/22",
"type": "BookList",
@ -86,17 +106,17 @@ class InboxAdd(TestCase):
)
activity = {
"id": "https://bookwyrm.social/listbook/6189#add",
"id": "https://example.com/listbook/6189#add",
"type": "Add",
"actor": "https://example.com/users/rat",
"object": {
"actor": self.remote_user.remote_id,
"type": "ListItem",
"book": self.book.remote_id,
"id": "https://bookwyrm.social/listbook/6189",
"id": "https://example.com/listbook/6189",
"order": 1,
},
"target": "https://bookwyrm.social/user/mouse/list/to-read",
"target": "https://example.com/user/mouse/list/to-read",
"@context": "https://www.w3.org/ns/activitystreams",
}
views.inbox.activity_task(activity)
@ -105,4 +125,4 @@ class InboxAdd(TestCase):
listitem = models.ListItem.objects.get()
self.assertEqual(booklist.name, "Test List")
self.assertEqual(booklist.books.first(), self.book)
self.assertEqual(listitem.remote_id, "https://bookwyrm.social/listbook/6189")
self.assertEqual(listitem.remote_id, "https://example.com/listbook/6189")

View file

@ -47,39 +47,6 @@ class BookViews(TestCase):
)
models.SiteSettings.objects.create()
def test_date_regression(self):
"""ensure that creating a new book actually saves the published date fields
this was initially a regression due to using a custom date picker tag
"""
first_published_date = "2021-04-20"
published_date = "2022-04-20"
self.local_user.groups.add(self.group)
view = views.EditBook.as_view()
form = forms.EditionForm(
{
"title": "New Title",
"last_edited_by": self.local_user.id,
"first_published_date": first_published_date,
"published_date": published_date,
}
)
request = self.factory.post("", form.data)
request.user = self.local_user
with patch("bookwyrm.connectors.connector_manager.local_search"):
result = view(request)
result.render()
self.assertContains(
result,
f'<input type="date" name="first_published_date" class="input" id="id_first_published_date" value="{first_published_date}">',
)
self.assertContains(
result,
f'<input type="date" name="published_date" class="input" id="id_published_date" value="{published_date}">',
)
def test_book_page(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.Book.as_view()

View file

@ -2,6 +2,7 @@
import json
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.http import JsonResponse
from django.template.response import TemplateResponse
from django.test import TestCase
@ -12,7 +13,7 @@ from bookwyrm.connectors import abstract_connector
from bookwyrm.settings import DOMAIN
class ShelfViews(TestCase):
class Views(TestCase):
"""tag views"""
def setUp(self):
@ -52,7 +53,7 @@ class ShelfViews(TestCase):
self.assertEqual(data[0]["title"], "Test Book")
self.assertEqual(data[0]["key"], "https://%s/book/%d" % (DOMAIN, self.book.id))
def test_search_html_response(self):
def test_search_books(self):
"""searches remote connectors"""
view = views.Search.as_view()
@ -92,7 +93,7 @@ class ShelfViews(TestCase):
connector=connector,
)
request = self.factory.get("", {"q": "Test Book"})
request = self.factory.get("", {"q": "Test Book", "remote": True})
request.user = self.local_user
with patch("bookwyrm.views.search.is_api_request") as is_api:
is_api.return_value = False
@ -101,19 +102,44 @@ class ShelfViews(TestCase):
response = view(request)
self.assertIsInstance(response, TemplateResponse)
response.render()
self.assertEqual(
response.context_data["book_results"][0].title, "Gideon the Ninth"
)
self.assertEqual(response.context_data["results"][0].title, "Gideon the Ninth")
def test_search_html_response_users(self):
def test_search_users(self):
"""searches remote connectors"""
view = views.Search.as_view()
request = self.factory.get("", {"q": "mouse"})
request = self.factory.get("", {"q": "mouse", "type": "user"})
request.user = self.local_user
with patch("bookwyrm.views.search.is_api_request") as is_api:
is_api.return_value = False
with patch("bookwyrm.connectors.connector_manager.search"):
response = view(request)
response = view(request)
self.assertIsInstance(response, TemplateResponse)
response.render()
self.assertEqual(response.context_data["user_results"][0], self.local_user)
self.assertEqual(response.context_data["results"][0], self.local_user)
def test_search_users_logged_out(self):
"""searches remote connectors"""
view = views.Search.as_view()
request = self.factory.get("", {"q": "mouse", "type": "user"})
anonymous_user = AnonymousUser
anonymous_user.is_authenticated = False
request.user = anonymous_user
response = view(request)
response.render()
self.assertFalse("results" in response.context_data)
def test_search_lists(self):
"""searches remote connectors"""
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
booklist = models.List.objects.create(
user=self.local_user, name="test list"
)
view = views.Search.as_view()
request = self.factory.get("", {"q": "test", "type": "list"})
request.user = self.local_user
response = view(request)
self.assertIsInstance(response, TemplateResponse)
response.render()
self.assertEqual(response.context_data["results"][0], booklist)

View file

@ -45,8 +45,12 @@ urlpatterns = [
# authentication
re_path(r"^login/?$", views.Login.as_view(), name="login"),
re_path(r"^register/?$", views.Register.as_view()),
re_path(r"^logout/?$", views.Logout.as_view()),
re_path(r"^password-reset/?$", views.PasswordResetRequest.as_view()),
re_path(r"^logout/?$", views.Logout.as_view(), name="logout"),
re_path(
r"^password-reset/?$",
views.PasswordResetRequest.as_view(),
name="password-reset",
),
re_path(
r"^password-reset/(?P<code>[A-Za-z0-9]+)/?$", views.PasswordReset.as_view()
),
@ -134,7 +138,12 @@ urlpatterns = [
re_path(r"^about/?$", views.About.as_view(), name="about"),
path("", views.Home.as_view(), name="landing"),
re_path(r"^discover/?$", views.Discover.as_view()),
re_path(r"^notifications/?$", views.Notifications.as_view()),
re_path(r"^notifications/?$", views.Notifications.as_view(), name="notifications"),
re_path(
r"^notifications/(?P<notification_type>mentions)/?$",
views.Notifications.as_view(),
name="notifications",
),
re_path(r"^directory/?", views.Directory.as_view(), name="directory"),
# Get started
re_path(
@ -163,10 +172,10 @@ urlpatterns = [
name="direct-messages-user",
),
# search
re_path(r"^search/?$", views.Search.as_view()),
re_path(r"^search/?$", views.Search.as_view(), name="search"),
# imports
re_path(r"^import/?$", views.Import.as_view()),
re_path(r"^import/(\d+)/?$", views.ImportStatus.as_view()),
re_path(r"^import/?$", views.Import.as_view(), name="import"),
re_path(r"^import/(\d+)/?$", views.ImportStatus.as_view(), name="import-status"),
# users
re_path(r"%s/?$" % user_path, views.User.as_view(), name="user-feed"),
re_path(r"%s\.json$" % user_path, views.User.as_view()),
@ -229,7 +238,7 @@ urlpatterns = [
views.ChangePassword.as_view(),
name="prefs-password",
),
re_path(r"^preferences/block/?$", views.Block.as_view()),
re_path(r"^preferences/block/?$", views.Block.as_view(), name="prefs-block"),
re_path(r"^block/(?P<user_id>\d+)/?$", views.Block.as_view()),
re_path(r"^unblock/(?P<user_id>\d+)/?$", views.unblock),
# statuses
@ -272,7 +281,7 @@ urlpatterns = [
),
re_path(r"%s/edit/?$" % book_path, views.EditBook.as_view()),
re_path(r"%s/confirm/?$" % book_path, views.ConfirmEditBook.as_view()),
re_path(r"^create-book/?$", views.EditBook.as_view()),
re_path(r"^create-book/?$", views.EditBook.as_view(), name="create-book"),
re_path(r"^create-book/confirm?$", views.ConfirmEditBook.as_view()),
re_path(r"%s/editions(.json)?/?$" % book_path, views.Editions.as_view()),
re_path(

View file

@ -10,7 +10,12 @@ from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import forms, models
from bookwyrm.importers import Importer, LibrarythingImporter, GoodreadsImporter
from bookwyrm.importers import (
Importer,
LibrarythingImporter,
GoodreadsImporter,
StorygraphImporter,
)
from bookwyrm.tasks import app
# pylint: disable= no-self-use
@ -42,6 +47,8 @@ class Import(View):
importer = None
if source == "LibraryThing":
importer = LibrarythingImporter()
elif source == "Storygraph":
importer = StorygraphImporter()
else:
# Default : GoodReads
importer = GoodreadsImporter()

View file

@ -18,6 +18,7 @@ from django.views.decorators.http import require_POST
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager
from bookwyrm.settings import PAGE_LENGTH
from .helpers import is_api_request, privacy_filter
from .helpers import get_user_from_username
@ -133,7 +134,7 @@ class List(View):
.order_by(directional_sort_by)
)
paginated = Paginator(items, 12)
paginated = Paginator(items, PAGE_LENGTH)
if query and request.user.is_authenticated:
# search for books
@ -156,9 +157,13 @@ class List(View):
).order_by("-updated_date")
][: 5 - len(suggestions)]
page = paginated.get_page(request.GET.get("page"))
data = {
"list": book_list,
"items": paginated.get_page(request.GET.get("page")),
"items": page,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
"pending_count": book_list.listitem_set.filter(approved=False).count(),
"suggested_books": suggestions,
"list_form": forms.ListForm(instance=book_list),

View file

@ -11,10 +11,14 @@ from django.views import View
class Notifications(View):
"""notifications view"""
def get(self, request):
def get(self, request, notification_type=None):
"""people are interacting with you, get hyped"""
notifications = request.user.notification_set.all().order_by("-created_date")
unread = [n.id for n in notifications.filter(read=False)]
if notification_type == "mentions":
notifications = notifications.filter(
notification_type__in=["REPLY", "MENTION", "TAG"]
)
unread = [n.id for n in notifications.filter(read=False)[:50]]
data = {
"notifications": notifications[:50],
"unread": unread,

View file

@ -2,6 +2,7 @@
import re
from django.contrib.postgres.search import TrigramSimilarity
from django.core.paginator import Paginator
from django.db.models.functions import Greatest
from django.http import JsonResponse
from django.template.response import TemplateResponse
@ -9,6 +10,7 @@ from django.views import View
from bookwyrm import models
from bookwyrm.connectors import connector_manager
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.utils import regex
from .helpers import is_api_request, privacy_filter
from .helpers import handle_remote_webfinger
@ -22,6 +24,10 @@ class Search(View):
"""that search bar up top"""
query = request.GET.get("q")
min_confidence = request.GET.get("min_confidence", 0.1)
search_type = request.GET.get("type")
search_remote = (
request.GET.get("remote", False) and request.user.is_authenticated
)
if is_api_request(request):
# only return local book results via json so we don't cascade
@ -30,48 +36,87 @@ class Search(View):
)
return JsonResponse([r.json() for r in book_results], safe=False)
data = {"query": query or ""}
if not search_type:
search_type = "user" if "@" in query else "book"
# use webfinger for mastodon style account@domain.com username
if query and re.match(regex.full_username, query):
handle_remote_webfinger(query)
endpoints = {
"book": book_search,
"user": user_search,
"list": list_search,
}
if not search_type in endpoints:
search_type = "book"
# do a user search
if request.user.is_authenticated:
data["user_results"] = (
models.User.viewer_aware_objects(request.user)
.annotate(
similarity=Greatest(
TrigramSimilarity("username", query),
TrigramSimilarity("localname", query),
)
)
.filter(
similarity__gt=0.5,
)
.order_by("-similarity")[:10]
data = {
"query": query or "",
"type": search_type,
"remote": search_remote,
}
if query:
results = endpoints[search_type](
query, request.user, min_confidence, search_remote
)
if results:
paginated = Paginator(results, PAGE_LENGTH).get_page(
request.GET.get("page")
)
data["results"] = paginated
# any relevent lists?
data["list_results"] = (
privacy_filter(
request.user,
models.List.objects,
privacy_levels=["public", "followers"],
return TemplateResponse(request, "search/{:s}.html".format(search_type), data)
def book_search(query, _, min_confidence, search_remote=False):
"""the real business is elsewhere"""
if search_remote:
return connector_manager.search(query, min_confidence=min_confidence)
results = connector_manager.local_search(query, min_confidence=min_confidence)
if not results:
return None
return [{"results": results}]
def user_search(query, viewer, *_):
"""cool kids members only user search"""
# logged out viewers can't search users
if not viewer.is_authenticated:
return models.User.objects.none()
# use webfinger for mastodon style account@domain.com username to load the user if
# they don't exist locally (handle_remote_webfinger will check the db)
if re.match(regex.full_username, query):
handle_remote_webfinger(query)
return (
models.User.viewer_aware_objects(viewer)
.annotate(
similarity=Greatest(
TrigramSimilarity("username", query),
TrigramSimilarity("localname", query),
)
.annotate(
similarity=Greatest(
TrigramSimilarity("name", query),
TrigramSimilarity("description", query),
)
)
.filter(
similarity__gt=0.1,
)
.order_by("-similarity")[:10]
)
data["book_results"] = connector_manager.search(
query, min_confidence=min_confidence
.filter(
similarity__gt=0.5,
)
return TemplateResponse(request, "search_results.html", data)
.order_by("-similarity")[:10]
)
def list_search(query, viewer, *_):
"""any relevent lists?"""
return (
privacy_filter(
viewer,
models.List.objects,
privacy_levels=["public", "followers"],
)
.annotate(
similarity=Greatest(
TrigramSimilarity("name", query),
TrigramSimilarity("description", query),
)
)
.filter(
similarity__gt=0.1,
)
.order_by("-similarity")[:10]
)

View file

@ -57,12 +57,16 @@ class Shelf(View):
PAGE_LENGTH,
)
page = paginated.get_page(request.GET.get("page"))
data = {
"user": user,
"is_self": is_self,
"shelves": shelves.all(),
"shelf": shelf,
"books": paginated.get_page(request.GET.get("page")),
"books": page,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
}
return TemplateResponse(request, "user/shelf/shelf.html", data)

View file

@ -1,157 +1,15 @@
"""
Django settings for celerywyrm project.
""" bookwyrm settings and configuration """
from bookwyrm.settings import *
Generated by 'django-admin startproject' using Django 3.0.3.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.0/ref/settings/
"""
import os
from environs import Env
env = Env()
# emailing
EMAIL_HOST = env("EMAIL_HOST")
EMAIL_PORT = env("EMAIL_PORT")
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS")
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False)
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# celery/rebbitmq
CELERY_BROKER_URL = env("CELERY_BROKER")
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_BACKEND = "redis"
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "0a^0gpwjc1ap+lb$dinin=efc@e&_0%102$o3(&gt9e7lndiaw"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env.bool("DEBUG", True)
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
INSTALLED_APPS = INSTALLED_APPS + [
"celerywyrm",
"bookwyrm",
"celery",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "celerywyrm.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "celerywyrm.wsgi.application"
# Database
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
BOOKWYRM_DATABASE_BACKEND = env("BOOKWYRM_DATABASE_BACKEND", "postgres")
BOOKWYRM_DBS = {
"postgres": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": env("POSTGRES_DB", "fedireads"),
"USER": env("POSTGRES_USER", "fedireads"),
"PASSWORD": env("POSTGRES_PASSWORD", "fedireads"),
"HOST": env("POSTGRES_HOST", ""),
"PORT": 5432,
},
"sqlite": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "fedireads.db"),
},
}
DATABASES = {"default": BOOKWYRM_DBS[BOOKWYRM_DATABASE_BACKEND]}
# Password validation
# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/3.0/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = False
USE_L10N = False
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
MEDIA_URL = "/images/"
MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff