Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2021-03-13 14:06:08 -08:00
commit 6a14529893
49 changed files with 1660 additions and 383 deletions

3
.gitignore vendored
View file

@ -2,6 +2,7 @@
/venv
*.pyc
*.swp
**/__pycache__
# VSCode
/.vscode
@ -15,4 +16,4 @@
/images/
# Testing
.coverage
.coverage

View file

@ -258,10 +258,9 @@ def resolve_remote_id(remote_id, model=None, refresh=False, save=True):
# load the data and create the object
try:
data = get_data(remote_id)
except (ConnectorException, ConnectionError):
except ConnectorException:
raise ActivitySerializerError(
"Could not connect to host for remote_id in %s model: %s"
% (model.__name__, remote_id)
"Could not connect to host for remote_id in: %s" % (remote_id)
)
# determine the model implicitly, if not provided
if not model:

View file

@ -70,6 +70,9 @@ class Undo(Verb):
if self.object.type == "Follow":
model = apps.get_model("bookwyrm.UserFollows")
obj = self.object.to_model(model=model, save=False, allow_create=False)
if not obj:
# if we don't have the object, we can't undo it. happens a lot with boosts
return
obj.delete()
@ -137,7 +140,7 @@ class Add(Verb):
def action(self):
""" add obj to collection """
target = resolve_remote_id(self.target, refresh=False)
# we want to related field that isn't the book, this is janky af sorry
# we want to get the related field that isn't the book, this is janky af sorry
model = [t for t in type(target)._meta.related_objects if t.name != "edition"][
0
].related_model
@ -153,7 +156,11 @@ class Remove(Verb):
def action(self):
""" find and remove the activity object """
obj = self.object.to_model(save=False, allow_create=False)
target = resolve_remote_id(self.target, refresh=False)
model = [t for t in type(target)._meta.related_objects if t.name != "edition"][
0
].related_model
obj = self.to_model(model=model, save=False, allow_create=False)
obj.delete()

View file

@ -44,21 +44,10 @@ class AbstractMinimalConnector(ABC):
if min_confidence:
params["min_confidence"] = min_confidence
resp = requests.get(
data = get_data(
"%s%s" % (self.search_url, query),
params=params,
headers={
"Accept": "application/json; charset=utf-8",
"User-Agent": settings.USER_AGENT,
},
)
if not resp.ok:
resp.raise_for_status()
try:
data = resp.json()
except ValueError as e:
logger.exception(e)
raise ConnectorException("Unable to parse json response", e)
results = []
for doc in self.parse_search_data(data)[:10]:
@ -68,24 +57,14 @@ class AbstractMinimalConnector(ABC):
def isbn_search(self, query):
""" isbn search """
params = {}
resp = requests.get(
data = get_data(
"%s%s" % (self.isbn_search_url, query),
params=params,
headers={
"Accept": "application/json; charset=utf-8",
"User-Agent": settings.USER_AGENT,
},
)
if not resp.ok:
resp.raise_for_status()
try:
data = resp.json()
except ValueError as e:
logger.exception(e)
raise ConnectorException("Unable to parse json response", e)
results = []
for doc in self.parse_isbn_search_data(data):
# this shouldn't be returning mutliple results, but just in case
for doc in self.parse_isbn_search_data(data)[:10]:
results.append(self.format_isbn_search_result(doc))
return results
@ -234,17 +213,18 @@ def dict_from_mappings(data, mappings):
return result
def get_data(url):
def get_data(url, params=None):
""" wrapper for request.get """
try:
resp = requests.get(
url,
params=params,
headers={
"Accept": "application/json; charset=utf-8",
"User-Agent": settings.USER_AGENT,
},
)
except (RequestError, SSLError) as e:
except (RequestError, SSLError, ConnectionError) as e:
logger.exception(e)
raise ConnectorException()

View file

@ -18,7 +18,7 @@ def search(query, min_confidence=0.1):
results = []
# Have we got a ISBN ?
isbn = re.sub("[\W_]", "", query)
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)
@ -36,7 +36,7 @@ def search(query, min_confidence=0.1):
pass
# if no isbn search or results, we fallback to generic search
if result_set == None or result_set == []:
if result_set in (None, []):
try:
result_set = connector.search(query, min_confidence=min_confidence)
except (HTTPError, ConnectorException):

View file

@ -161,21 +161,17 @@ def ignore_edition(edition_data):
""" don't load a million editions that have no metadata """
# an isbn, we love to see it
if edition_data.get("isbn_13") or edition_data.get("isbn_10"):
print(edition_data.get("isbn_10"))
return False
# grudgingly, oclc can stay
if edition_data.get("oclc_numbers"):
print(edition_data.get("oclc_numbers"))
return False
# if it has a cover it can stay
if edition_data.get("covers"):
print(edition_data.get("covers"))
return False
# keep non-english editions
if edition_data.get("languages") and "languages/eng" not in str(
edition_data.get("languages")
):
print(edition_data.get("languages"))
return False
return True

View file

@ -143,7 +143,7 @@ class EditionForm(CustomForm):
"created_date",
"updated_date",
"edition_rank",
"authors", # TODO
"authors",
"parent_work",
"shelves",
"subjects", # TODO
@ -231,3 +231,9 @@ class ListForm(CustomForm):
class Meta:
model = models.List
fields = ["user", "name", "description", "curation", "privacy"]
class ReportForm(CustomForm):
class Meta:
model = models.Report
fields = ["user", "reporter", "statuses", "note"]

View file

@ -0,0 +1,113 @@
# Generated by Django 3.0.7 on 2021-03-09 01:56
import bookwyrm.models.fields
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.db.models.expressions
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0048_merge_20210308_1754"),
]
operations = [
migrations.CreateModel(
name="Report",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
(
"remote_id",
bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
),
("note", models.TextField(blank=True, null=True)),
("resolved", models.BooleanField(default=False)),
(
"reporter",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="reporter",
to=settings.AUTH_USER_MODEL,
),
),
(
"statuses",
models.ManyToManyField(blank=True, null=True, to="bookwyrm.Status"),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="ReportComment",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
(
"remote_id",
bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
),
("note", models.TextField()),
(
"report",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="bookwyrm.Report",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.AddConstraint(
model_name="report",
constraint=models.CheckConstraint(
check=models.Q(
_negated=True, reporter=django.db.models.expressions.F("user")
),
name="self_report",
),
),
]

View file

@ -0,0 +1,26 @@
# Generated by Django 3.0.7 on 2021-03-13 00:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0049_auto_20210309_0156"),
]
operations = [
migrations.AlterModelOptions(
name="report",
options={"ordering": ("-created_date",)},
),
migrations.AlterModelOptions(
name="reportcomment",
options={"ordering": ("-created_date",)},
),
migrations.AlterField(
model_name="report",
name="statuses",
field=models.ManyToManyField(blank=True, to="bookwyrm.Status"),
),
]

View file

@ -21,6 +21,7 @@ from .tag import Tag, UserTag
from .user import User, KeyPair, AnnualGoal
from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .report import Report, ReportComment
from .federated_server import FederatedServer
from .import_job import ImportJob, ImportItem

View file

@ -362,14 +362,15 @@ class CollectionItemMixin(ActivitypubMixin):
""" broadcast a remove activity """
activity = self.to_remove_activity()
super().delete(*args, **kwargs)
self.broadcast(activity, self.user)
if self.user.local:
self.broadcast(activity, self.user)
def to_add_activity(self):
""" AP for shelving a book"""
object_field = getattr(self, self.object_field)
collection_field = getattr(self, self.collection_field)
return activitypub.Add(
id="%s#add" % self.remote_id,
id=self.remote_id,
actor=self.user.remote_id,
object=object_field,
target=collection_field.remote_id,
@ -380,7 +381,7 @@ class CollectionItemMixin(ActivitypubMixin):
object_field = getattr(self, self.object_field)
collection_field = getattr(self, self.collection_field)
return activitypub.Remove(
id="%s#remove" % self.remote_id,
id=self.remote_id,
actor=self.user.remote_id,
object=object_field,
target=collection_field.remote_id,
@ -456,8 +457,8 @@ def broadcast_task(sender_id, activity, recipients):
for recipient in recipients:
try:
sign_and_send(sender, activity, recipient)
except (HTTPError, SSLError, ConnectionError) as e:
logger.exception(e)
except (HTTPError, SSLError, ConnectionError):
pass
def sign_and_send(sender, data, destination):

View file

@ -4,7 +4,7 @@ from .base_model import BookWyrmModel
class FederatedServer(BookWyrmModel):
""" store which server's we federate with """
""" store which servers we federate with """
server_name = models.CharField(max_length=255, unique=True)
# federated, blocked, whatever else

37
bookwyrm/models/report.py Normal file
View file

@ -0,0 +1,37 @@
""" flagged for moderation """
from django.db import models
from django.db.models import F, Q
from .base_model import BookWyrmModel
class Report(BookWyrmModel):
""" reported status or user """
reporter = models.ForeignKey(
"User", related_name="reporter", on_delete=models.PROTECT
)
note = models.TextField(null=True, blank=True)
user = models.ForeignKey("User", on_delete=models.PROTECT)
statuses = models.ManyToManyField("Status", blank=True)
resolved = models.BooleanField(default=False)
class Meta:
""" don't let users report themselves """
constraints = [
models.CheckConstraint(check=~Q(reporter=F("user")), name="self_report")
]
ordering = ("-created_date",)
class ReportComment(BookWyrmModel):
""" updates on a report """
user = models.ForeignKey("User", on_delete=models.PROTECT)
note = models.TextField()
report = models.ForeignKey(Report, on_delete=models.PROTECT)
class Meta:
""" sort comments """
ordering = ("-created_date",)

View file

@ -115,13 +115,18 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
def ignore_activity(cls, activity):
""" keep notes if they are replies to existing statuses """
if activity.type == "Announce":
# keep it if the booster or the boosted are local
boosted = activitypub.resolve_remote_id(activity.object, save=False)
try:
boosted = activitypub.resolve_remote_id(activity.object, save=False)
except activitypub.ActivitySerializerError:
# if we can't load the status, definitely ignore it
return True
# keep the boost if we would keep the status
return cls.ignore_activity(boosted.to_activity_dataclass())
# keep if it if it's a custom type
if activity.type != "Note":
return False
# keep it if it's a reply to an existing status
if cls.objects.filter(remote_id=activity.inReplyTo).exists():
return False

View file

@ -209,249 +209,249 @@ function removeClass(el, className) {
*/
class TabGroup {
constructor(container) {
this.container = container;
this.tablist = this.container.querySelector('[role="tablist"]');
this.buttons = this.tablist.querySelectorAll('[role="tab"]');
this.panels = this.container.querySelectorAll(':scope > [role="tabpanel"]');
this.delay = this.determineDelay();
if(!this.tablist || !this.buttons.length || !this.panels.length) {
return;
}
this.keys = this.keys();
this.direction = this.direction();
this.initButtons();
this.initPanels();
this.container = container;
this.tablist = this.container.querySelector('[role="tablist"]');
this.buttons = this.tablist.querySelectorAll('[role="tab"]');
this.panels = this.container.querySelectorAll(':scope > [role="tabpanel"]');
this.delay = this.determineDelay();
if(!this.tablist || !this.buttons.length || !this.panels.length) {
return;
}
this.keys = this.keys();
this.direction = this.direction();
this.initButtons();
this.initPanels();
}
keys() {
return {
end: 35,
home: 36,
left: 37,
up: 38,
right: 39,
down: 40
};
return {
end: 35,
home: 36,
left: 37,
up: 38,
right: 39,
down: 40
};
}
// Add or substract depending on key pressed
direction() {
return {
37: -1,
38: -1,
39: 1,
40: 1
};
return {
37: -1,
38: -1,
39: 1,
40: 1
};
}
initButtons() {
let count = 0;
for(let button of this.buttons) {
let isSelected = button.getAttribute("aria-selected") === "true";
button.setAttribute("tabindex", isSelected ? "0" : "-1");
button.addEventListener('click', this.clickEventListener.bind(this));
button.addEventListener('keydown', this.keydownEventListener.bind(this));
button.addEventListener('keyup', this.keyupEventListener.bind(this));
button.index = count++;
}
}
initPanels() {
let selectedPanelId = this.tablist.querySelector('[role="tab"][aria-selected="true"]').getAttribute("aria-controls");
for(let panel of this.panels) {
if(panel.getAttribute("id") !== selectedPanelId) {
panel.setAttribute("hidden", "");
}
panel.setAttribute("tabindex", "0");
}
}
clickEventListener(event) {
let button = event.target.closest('a');
event.preventDefault();
this.activateTab(button, false);
initButtons() {
let count = 0;
for(let button of this.buttons) {
let isSelected = button.getAttribute("aria-selected") === "true";
button.setAttribute("tabindex", isSelected ? "0" : "-1");
button.addEventListener('click', this.clickEventListener.bind(this));
button.addEventListener('keydown', this.keydownEventListener.bind(this));
button.addEventListener('keyup', this.keyupEventListener.bind(this));
button.index = count++;
}
}
initPanels() {
let selectedPanelId = this.tablist.querySelector('[role="tab"][aria-selected="true"]').getAttribute("aria-controls");
for(let panel of this.panels) {
if(panel.getAttribute("id") !== selectedPanelId) {
panel.setAttribute("hidden", "");
}
panel.setAttribute("tabindex", "0");
}
}
clickEventListener(event) {
let button = event.target.closest('a');
event.preventDefault();
this.activateTab(button, false);
}
// Handle keydown on tabs
keydownEventListener(event) {
var key = event.keyCode;
switch (key) {
case this.keys.end:
event.preventDefault();
// Activate last tab
this.activateTab(this.buttons[this.buttons.length - 1]);
break;
case this.keys.home:
event.preventDefault();
// Activate first tab
this.activateTab(this.buttons[0]);
break;
// Up and down are in keydown
// because we need to prevent page scroll >:)
case this.keys.up:
case this.keys.down:
this.determineOrientation(event);
break;
};
var key = event.keyCode;
switch (key) {
case this.keys.end:
event.preventDefault();
// Activate last tab
this.activateTab(this.buttons[this.buttons.length - 1]);
break;
case this.keys.home:
event.preventDefault();
// Activate first tab
this.activateTab(this.buttons[0]);
break;
// Up and down are in keydown
// because we need to prevent page scroll >:)
case this.keys.up:
case this.keys.down:
this.determineOrientation(event);
break;
}
}
// Handle keyup on tabs
keyupEventListener(event) {
var key = event.keyCode;
switch (key) {
case this.keys.left:
case this.keys.right:
this.determineOrientation(event);
break;
};
var key = event.keyCode;
switch (key) {
case this.keys.left:
case this.keys.right:
this.determineOrientation(event);
break;
}
}
// When a tablists aria-orientation is set to vertical,
// only up and down arrow should function.
// In all other cases only left and right arrow function.
determineOrientation(event) {
var key = event.keyCode;
var vertical = this.tablist.getAttribute('aria-orientation') == 'vertical';
var proceed = false;
if (vertical) {
if (key === this.keys.up || key === this.keys.down) {
event.preventDefault();
proceed = true;
};
}
else {
if (key === this.keys.left || key === this.keys.right) {
proceed = true;
};
};
if (proceed) {
this.switchTabOnArrowPress(event);
};
var key = event.keyCode;
var vertical = this.tablist.getAttribute('aria-orientation') == 'vertical';
var proceed = false;
if (vertical) {
if (key === this.keys.up || key === this.keys.down) {
event.preventDefault();
proceed = true;
}
}
else {
if (key === this.keys.left || key === this.keys.right) {
proceed = true;
}
}
if (proceed) {
this.switchTabOnArrowPress(event);
}
}
// Either focus the next, previous, first, or last tab
// depending on key pressed
switchTabOnArrowPress(event) {
var pressed = event.keyCode;
for (let button of this.buttons) {
button.addEventListener('focus', this.focusEventHandler.bind(this));
};
if (this.direction[pressed]) {
var target = event.target;
if (target.index !== undefined) {
if (this.buttons[target.index + this.direction[pressed]]) {
this.buttons[target.index + this.direction[pressed]].focus();
}
else if (pressed === this.keys.left || pressed === this.keys.up) {
this.focusLastTab();
}
else if (pressed === this.keys.right || pressed == this.keys.down) {
this.focusFirstTab();
}
var pressed = event.keyCode;
for (let button of this.buttons) {
button.addEventListener('focus', this.focusEventHandler.bind(this));
}
if (this.direction[pressed]) {
var target = event.target;
if (target.index !== undefined) {
if (this.buttons[target.index + this.direction[pressed]]) {
this.buttons[target.index + this.direction[pressed]].focus();
}
else if (pressed === this.keys.left || pressed === this.keys.up) {
this.focusLastTab();
}
else if (pressed === this.keys.right || pressed == this.keys.down) {
this.focusFirstTab();
}
}
}
}
}
// Activates any given tab panel
activateTab (tab, setFocus) {
if(tab.getAttribute("role") !== "tab") {
tab = tab.closest('[role="tab"]');
}
setFocus = setFocus || true;
// Deactivate all other tabs
this.deactivateTabs();
// Remove tabindex attribute
tab.removeAttribute('tabindex');
// Set the tab as selected
tab.setAttribute('aria-selected', 'true');
// Give the tab parent an is-active class
tab.parentNode.classList.add('is-active');
// Get the value of aria-controls (which is an ID)
var controls = tab.getAttribute('aria-controls');
// Remove hidden attribute from tab panel to make it visible
document.getElementById(controls).removeAttribute('hidden');
// Set focus when required
if (setFocus) {
tab.focus();
}
if(tab.getAttribute("role") !== "tab") {
tab = tab.closest('[role="tab"]');
}
setFocus = setFocus || true;
// Deactivate all other tabs
this.deactivateTabs();
// Remove tabindex attribute
tab.removeAttribute('tabindex');
// Set the tab as selected
tab.setAttribute('aria-selected', 'true');
// Give the tab parent an is-active class
tab.parentNode.classList.add('is-active');
// Get the value of aria-controls (which is an ID)
var controls = tab.getAttribute('aria-controls');
// Remove hidden attribute from tab panel to make it visible
document.getElementById(controls).removeAttribute('hidden');
// Set focus when required
if (setFocus) {
tab.focus();
}
}
// Deactivate all tabs and tab panels
deactivateTabs() {
for (let button of this.buttons) {
button.parentNode.classList.remove('is-active');
button.setAttribute('tabindex', '-1');
button.setAttribute('aria-selected', 'false');
button.removeEventListener('focus', this.focusEventHandler.bind(this));
}
for (let panel of this.panels) {
panel.setAttribute('hidden', 'hidden');
}
for (let button of this.buttons) {
button.parentNode.classList.remove('is-active');
button.setAttribute('tabindex', '-1');
button.setAttribute('aria-selected', 'false');
button.removeEventListener('focus', this.focusEventHandler.bind(this));
}
for (let panel of this.panels) {
panel.setAttribute('hidden', 'hidden');
}
}
focusFirstTab() {
this.buttons[0].focus();
this.buttons[0].focus();
}
focusLastTab() {
this.buttons[this.buttons.length - 1].focus();
this.buttons[this.buttons.length - 1].focus();
}
// Determine whether there should be a delay
// when user navigates with the arrow keys
determineDelay() {
var hasDelay = this.tablist.hasAttribute('data-delay');
var delay = 0;
if (hasDelay) {
var delayValue = this.tablist.getAttribute('data-delay');
if (delayValue) {
delay = delayValue;
var hasDelay = this.tablist.hasAttribute('data-delay');
var delay = 0;
if (hasDelay) {
var delayValue = this.tablist.getAttribute('data-delay');
if (delayValue) {
delay = delayValue;
}
else {
// If no value is specified, default to 300ms
delay = 300;
}
}
else {
// If no value is specified, default to 300ms
delay = 300;
};
};
return delay;
return delay;
}
focusEventHandler(event) {
var target = event.target;
setTimeout(this.checkTabFocus.bind(this), this.delay, target);
};
var target = event.target;
setTimeout(this.checkTabFocus.bind(this), this.delay, target);
}
// Only activate tab on focus if it still has focus after the delay
checkTabFocus(target) {
let focused = document.activeElement;
if (target === focused) {
this.activateTab(target, false);
}
let focused = document.activeElement;
if (target === focused) {
this.activateTab(target, false);
}
}
}
}

View file

@ -2,18 +2,24 @@
{% load i18n %}
{% load humanize %}
{% block title %}{% trans "Edit Book" %}{% endblock %}
{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %}
{% block content %}
<header class="block">
<h1 class="title level-left">
Edit "{{ book.title }}"
{% if book %}
{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}
{% else %}
{% trans "Add Book" %}
{% endif %}
</h1>
{% if book %}
<div>
<p>{% trans "Added:" %} {{ book.created_date | naturaltime }}</p>
<p>{% trans "Updated:" %} {{ book.updated_date | naturaltime }}</p>
<p>{% trans "Last edited by:" %} <a href="{{ book.last_edited_by.remote_id }}">{{ book.last_edited_by.display_name }}</a></p>
</div>
{% endif %}
</header>
{% if form.non_field_errors %}
@ -22,40 +28,111 @@
</div>
{% endif %}
<form class="block" name="edit-book" action="{{ book.local_path }}/edit" method="post" enctype="multipart/form-data">
{% if book %}
<form class="block" name="edit-book" action="{{ book.local_path }}/{% if confirm_mode %}confirm{% else %}edit{% endif %}" method="post" enctype="multipart/form-data">
{% else %}
<form class="block" name="create-book" action="/create-book{% if confirm_mode %}/confirm{% endif %}" method="post" enctype="multipart/form-data">
{% endif %}
{% csrf_token %}
{% if confirm_mode %}
<div class="box">
<h2 class="title is-4">{% trans "Confirm Book Info" %}</h2>
<div class="columns">
{% if author_matches %}
<div class="column is-half">
{% for author in author_matches %}
<fieldset class="mb-4">
<legend class="title is-5 mb-1">{% blocktrans with name=author.name %}Is "{{ name }}" an existing author?{% endblocktrans %}</legend>
{% with forloop.counter as counter %}
{% for match in author.matches %}
<label><input type="radio" name="author_match-{{ counter }}" value="{{ match.id }}" required> {{ match.name }}</label>
<p class="help">
<a href="{{ author.local_path }}" target="_blank">{% blocktrans with book_title=match.book_set.first.title %}Author of <em>{{ book_title }}</em>{% endblocktrans %}</a>
</p>
{% endfor %}
<label><input type="radio" name="author_match-{{ counter }}" value="0" required> {% trans "This is a new author" %}</label>
{% endwith %}
</fieldset>
{% endfor %}
</div>
{% else %}
<p class="column is-half">{% blocktrans with name=add_author %}Creating a new author: {{ name }}{% endblocktrans %}</p>
{% endif %}
{% if not book %}
<div class="column is-half">
<fieldset>
<legend class="title is-5 mb-1">{% trans "Is this an edition of an existing work?" %}</legend>
{% for match in book_matches %}
<label class="label"><input type="radio" name="parent_work" value="{{ match.parent_work.id }}"> {{ match.parent_work.title }}</label>
{% endfor %}
<label><input type="radio" name="parent_work" value="0" required> {% trans "This is a new work" %}</label>
</fieldset>
</div>
{% endif %}
</div>
<button class="button is-primary" type="submit">{% trans "Confirm" %}</button>
<a href="#" class="button" data-back>
<span>{% trans "Back" %}</span>
</a>
</div>
<hr class="block">
{% endif %}
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
<div class="columns">
<div class="column">
<h2 class="title is-4">{% trans "Metadata" %}</h2>
<p class="fields is-grouped"><label class="label" for="id_title">{% trans "Title:" %}</label> {{ form.title }} </p>
{% for error in form.title.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label> {{ form.subtitle }} </p>
{% for error in form.subtitle.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_description">{% trans "Description:" %}</label> {{ form.description }} </p>
{% for error in form.description.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_series">{% trans "Series:" %}</label> {{ form.series }} </p>
{% for error in form.series.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_series_number">{% trans "Series number:" %}</label> {{ form.series_number }} </p>
{% for error in form.series_number.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_first_published_date">{% trans "First published date:" %}</label> {{ form.first_published_date }} </p>
{% for error in form.first_published_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_published_date">{% trans "Published date:" %}</label> {{ form.published_date }} </p>
{% for error in form.published_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<section class="block">
<h2 class="title is-4">{% trans "Metadata" %}</h2>
<p class="fields is-grouped"><label class="label" for="id_title">{% trans "Title:" %}</label> {{ form.title }} </p>
{% for error in form.title.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label> {{ form.subtitle }} </p>
{% for error in form.subtitle.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_description">{% trans "Description:" %}</label> {{ form.description }} </p>
{% for error in form.description.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_series">{% trans "Series:" %}</label> {{ form.series }} </p>
{% for error in form.series.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_series_number">{% trans "Series number:" %}</label> {{ form.series_number }} </p>
{% for error in form.series_number.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_first_published_date">{% trans "First published date:" %}</label> {{ form.first_published_date }} </p>
{% for error in form.first_published_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="fields is-grouped"><label class="label" for="id_published_date">{% trans "Published date:" %}</label> {{ form.published_date }} </p>
{% for error in form.published_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</section>
<section class="block">
<h2 class="title is-4">{% trans "Authors" %}</h2>
{% if book.authors.exists %}
<fieldset>
{% for author in book.authors.all %}
<label class="label mb-2">
<input type="checkbox" name="remove_authors" value="{{ author.id }}" {% if author.id|stringformat:"i" in remove_authors %}checked{% endif %}>
{% blocktrans with name=author.name path=author.local_path %}Remove <a href="{{ path }}">{{ name }}</a>{% endblocktrans %}
</label>
{% endfor %}
</fieldset>
{% endif %}
<label class="label" for="id_add_author">{% trans "Add Authors:" %}</label>
<p class="help">Separate multiple author names with commas.</p>
<input class="input" type="text" name="add_author" id="id_add_author" placeholder="{% trans 'John Doe, Jane Smith' %}" value="{{ add_author }}" {% if confirm_mode %}readonly{% endif %}>
</section>
</div>
<div class="column">
@ -116,10 +193,12 @@
</div>
</div>
{% 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>
</div>
{% endif %}
</form>
{% endblock %}

View file

@ -114,8 +114,8 @@
{% endif %}
{% if perms.bookwyrm.edit_instance_settings %}
<li>
<a href="{% url 'settings-site' %}" class="navbar-item">
{% trans 'Site Configuration' %}
<a href="{% url 'settings-reports' %}" class="navbar-item">
{% trans 'Admin' %}
</a>
</li>
{% endif %}

View file

@ -0,0 +1,76 @@
{% extends 'settings/admin_layout.html' %}
{% load i18n %}
{% load humanize %}
{% block title %}{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}{% endblock %}
{% block header %}{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}{% endblock %}
{% block panel %}
<div class="block">
<a href="{% url 'settings-reports' %}">{% trans "Back to reports" %}</a>
</div>
<div class="block">
{% include 'moderation/report_preview.html' with report=report %}
</div>
<div class="block content">
<h3>{% trans "Actions" %}</h3>
<p><a href="{{ report.user.local_path }}">{% trans "View user profile" %}</a></p>
<div class="is-flex">
<p class="mr-1">
<a class="button" href="{% url 'direct-messages-user' report.user.username %}">{% trans "Send direct message" %}</a>
</p>
<form name="deactivate" method="post" action="{% url 'settings-report-deactivate' report.id %}">
{% csrf_token %}
{% if report.user.is_active %}
<button type="submit" class="button is-danger is-light">{% trans "Deactivate user" %}</button>
{% else %}
<button class="button">{% trans "Reactivate user" %}</button>
{% endif %}
</form>
</div>
</div>
<div class="block">
<h3 class="title is-4">{% trans "Moderator Comments" %}</h3>
{% for comment in report.reportcomment_set.all %}
<div class="card block">
<p class="card-content">{{ comment.note }}</p>
<div class="card-footer">
<div class="card-footer-item">
<a href="{{ comment.user.local_path }}">{{ comment.user.display_name }}</a>
</div>
<div class="card-footer-item">
{{ comment.created_date | naturaltime }}
</div>
</div>
</div>
{% endfor %}
<form class="block" name="report-comment" method="post" action="{% url 'settings-report' report.id %}">
{% csrf_token %}
<label for="report_comment" class="label">Comment on report</label>
<textarea name="note" id="report_comment" class="textarea"></textarea>
<button class="button">{% trans "Comment" %}</button>
</form>
</div>
<div class="block">
<h3 class="title is-4">{% trans "Reported statuses" %}</h3>
{% if not report.statuses.exists %}
<em>{% trans "No statuses reported" %}</em>
{% else %}
<ul>
{% for status in report.statuses.select_subclasses.all %}
<li>
{% if status.deleted %}
<em>{% trans "Statuses has been deleted" %}</em>
{% else %}
{% include 'snippets/status/status.html' with status=status moderation_mode=True %}
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,37 @@
{% extends 'components/modal.html' %}
{% load i18n %}
{% load humanize %}
{% block modal-title %}
{% blocktrans with username=user.username %}Report @{{ username }}{% endblocktrans %}
{% endblock %}
{% block modal-form-open %}
<form name="report" method="post" action="{% url 'report' %}">
{% endblock %}
{% block modal-body %}
{% csrf_token %}
<input type="hidden" name="reporter" value="{{ reporter.id }}">
<input type="hidden" name="user" value="{{ user.id }}">
<input type="hidden" name="statuses" value="{{ status.id }}">
<section class="content">
<p>{% blocktrans with site_name=site.name %}This report will be sent to {{ site_name }}'s moderators for review.{% endblocktrans %}</p>
<label class="label" for="id_{{ controls_uid }}_report_note">{% trans "More info about this report:" %}</label>
<textarea class="textarea" name="note" id="id_{{ controls_uid }}_report_note"></textarea>
</section>
{% endblock %}
{% block modal-footer %}
<button class="button is-success" type="submit">{% trans "Submit" %}</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="report" controls_uid=report_uuid class="" %}
{% endblock %}
{% block modal-form-close %}</form>{% endblock %}

View file

@ -0,0 +1,37 @@
{% extends 'components/card.html' %}
{% load i18n %}
{% load humanize %}
{% block card-header %}
<h2 class="card-header-title has-background-white-ter is-block">
<a href="{% url 'settings-report' report.id %}">{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}</a>
</h2>
{% endblock %}
{% block card-content %}
<div class="block content">
<p>
{% if report.note %}{{ report.note }}{% else %}<em>{% trans "No notes provided" %}</em>{% endif %}
</p>
</div>
{% endblock %}
{% block card-footer %}
<div class="card-footer-item">
<p>{% blocktrans with username=report.reporter.display_name path=report.reporter.local_path %}Reported by <a href="{{ path }}">{{ username }}</a>{% endblocktrans %}</p>
</div>
<div class="card-footer-item">
{{ report.created_date | naturaltime }}
</div>
<div class="card-footer-item">
<form name="resolve" method="post" action="{% url 'settings-report-resolve' report.id %}">
{% csrf_token %}
<button class="button" type="submit">
{% if report.resolved %}
{% trans "Re-open" %}
{% else %}
{% trans "Resolve" %}
{% endif %}
</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends 'settings/admin_layout.html' %}
{% load i18n %}
{% block title %}{% trans "Reports" %}{% endblock %}
{% block header %}{% trans "Reports" %}{% endblock %}
{% block panel %}
<div class="tabs">
<ul>
<li class="{% if not resolved %}is-active{% endif %}"{% if not resolved == 'open' %} aria-current="page"{% endif %}>
<a href="{% url 'settings-reports' %}?resolved=false">{% trans "Open" %}</a>
</li>
<li class="{% if resolved %}is-active{% endif %}"{% if resolved %} aria-current="page"{% endif %}>
<a href="{% url 'settings-reports' %}?resolved=true">{% trans "Resolved" %}</a>
</li>
</ul>
</div>
<div class="block">
{% for report in reports %}
<div class="block">
{% include 'moderation/report_preview.html' with report=report %}
</div>
{% endfor %}
</div>
{% endblock %}

View file

@ -115,15 +115,7 @@
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-white{% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %} has-text-black{% else %}-bis has-text-grey-dark{% endif %}{% endif %}">
<div class="columns">
<div class="column">
{% if related_status.content %}
<a href="{{ related_status.local_path }}">
{{ related_status.content | safe | truncatewords_html:10 }}{% if related_status.mention_books %} <em>{{ related_status.mention_books.first.title }}</em>{% endif %}
</a>
{% elif related_status.quote %}
<a href="{{ related_status.local_path }}">{{ related_status.quote | safe | truncatewords_html:10 }}</a>
{% elif related_status.rating %}
{% include 'snippets/stars.html' with rating=related_status.rating %}
{% endif %}
{% include 'snippets/status_preview.html' with status=related_status %}
</div>
<div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}">
{{ related_status.published_date | post_date }}

View file

@ -68,6 +68,10 @@
{% include 'snippets/toggle/close_button.html' with text=button_text small=True controls_text="more-results" %}
{% endif %}
</div>
<div class="block">
<a href="/create-book">Manually add book</a>
</div>
{% endif %}
</div>
<div class="column">

View file

@ -18,6 +18,10 @@
{% url 'settings-invites' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Invites" %}</a>
</li>
<li>
{% url 'settings-reports' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Reports" %}</a>
</li>
<li>
{% url 'settings-federation' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Federated Servers" %}</a>
@ -42,7 +46,7 @@
</ul>
{% endif %}
</nav>
<div class="column content">
<div class="column">
{% block panel %}{% endblock %}
</div>
</div>

View file

@ -0,0 +1,10 @@
{% load i18n %}
{% load bookwyrm_tags %}
{% with 0|uuid as report_uuid %}
{% trans "Report" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class="is-danger is-light is-small is-fullwidth" text=button_text controls_text="report" controls_uid=report_uuid focus="modal-title-report" disabled=is_current %}
{% include 'moderation/report_modal.html' with user=user reporter=request.user controls_text="report" controls_uid=report_uuid %}
{% endwith %}

View file

@ -18,7 +18,17 @@
{% block card-footer %}
<div class="card-footer-item">
{% if request.user.is_authenticated %}
{% if moderation_mode and perms.bookwyrm.moderate_post %}
{# moderation options #}
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
{% csrf_token %}
<button class="button is-danger is-light" type="submit">
{% trans "Delete status" %}
</button>
</form>
{% elif request.user.is_authenticated %}
<div class="field has-addons">
<div class="control">
{% trans "Reply" as button_text %}
@ -56,14 +66,16 @@
<div class="card-footer-item">
<a href="{{ status.remote_id }}">{{ status.published_date | post_date }}</a>
</div>
{% if not moderation_mode %}
<div class="card-footer-item">
{% include 'snippets/status/status_options.html' with class="is-small" right=True %}
</div>
{% endif %}
{% endblock %}
{% block card-bonus %}
{% if request.user.is_authenticated %}
{% if request.user.is_authenticated and not moderation_mode %}
{% with status.id|uuid as uuid %}
<section class="hidden" id="show-comment-{{ status.id }}">
<div class="card-footer">

View file

@ -10,6 +10,7 @@
{% block dropdown-list %}
{% if status.user == request.user %}
{# things you can do to your own statuses #}
<li role="menuitem">
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
{% csrf_token %}
@ -19,8 +20,12 @@
</form>
</li>
{% else %}
{# things you can do to other people's statuses #}
<li role="menuitem">
<a href="/direct-messages/{{ status.user|username }}" class="button is-fullwidth is-small">{% trans "Send direct message" %}</a>
<a href="{% url 'direct-messages-user' status.user|username %}" class="button is-small is-fullwidth">{% trans "Send direct message" %}</a>
</li>
<li role="menuitem">
{% include 'snippets/report_button.html' with user=status.user status=status %}
</li>
<li role="menuitem">
{% include 'snippets/block_button.html' with user=status.user class="is-fullwidth" %}

View file

@ -0,0 +1,9 @@
{% if status.content %}
<a href="{{ status.local_path }}">
{{ status.content | safe | truncatewords_html:10 }}{% if status.mention_books %} <em>{{ status.mention_books.first.title }}</em>{% endif %}
</a>
{% elif status.quote %}
<a href="{{ status.local_path }}">{{ status.quote | safe | truncatewords_html:10 }}</a>
{% elif status.rating %}
{% include 'snippets/stars.html' with rating=status.rating %}
{% endif %}

View file

@ -12,6 +12,9 @@
<li role="menuitem">
<a href="/direct-messages/{{ user|username }}" class="button is-fullwidth is-small">{% trans "Send direct message" %}</a>
</li>
<li role="menuitem">
{% include 'snippets/report_button.html' with user=status.user class="is-fullwidth" %}
</li>
<li role="menuitem">
{% include 'snippets/block_button.html' with user=user class="is-fullwidth" %}
</li>

View file

@ -122,3 +122,27 @@ class AbstractConnector(TestCase):
self.assertEqual(result, self.book)
self.assertEqual(models.Edition.objects.count(), 1)
self.assertEqual(models.Edition.objects.count(), 1)
@responses.activate
def test_get_or_create_author(self):
""" load an author """
self.connector.author_mappings = [
Mapping("id"),
Mapping("name"),
]
responses.add(
responses.GET,
"https://www.example.com/author",
json={"id": "https://www.example.com/author", "name": "Test Author"},
)
result = self.connector.get_or_create_author("https://www.example.com/author")
self.assertIsInstance(result, models.Author)
self.assertEqual(result.name, "Test Author")
self.assertEqual(result.origin_id, "https://www.example.com/author")
def test_get_or_create_author_existing(self):
""" get an existing author """
author = models.Author.objects.create(name="Test Author")
result = self.connector.get_or_create_author(author.remote_id)
self.assertEqual(author, result)

View file

@ -19,7 +19,7 @@ class AbstractConnector(TestCase):
books_url="https://example.com/books",
covers_url="https://example.com/covers",
search_url="https://example.com/search?q=",
isbn_search_url="https://example.com/isbn",
isbn_search_url="https://example.com/isbn?q=",
)
class TestConnector(abstract_connector.AbstractMinimalConnector):
@ -50,7 +50,7 @@ class AbstractConnector(TestCase):
self.assertEqual(connector.books_url, "https://example.com/books")
self.assertEqual(connector.covers_url, "https://example.com/covers")
self.assertEqual(connector.search_url, "https://example.com/search?q=")
self.assertEqual(connector.isbn_search_url, "https://example.com/isbn")
self.assertEqual(connector.isbn_search_url, "https://example.com/isbn?q=")
self.assertIsNone(connector.name)
self.assertEqual(connector.identifier, "example.com")
self.assertIsNone(connector.max_query_count)
@ -71,6 +71,30 @@ class AbstractConnector(TestCase):
self.assertEqual(results[1], "b")
self.assertEqual(results[2], "c")
@responses.activate
def test_search_min_confidence(self):
""" makes an http request to the outside service """
responses.add(
responses.GET,
"https://example.com/search?q=a%20book%20title&min_confidence=1",
json=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"],
status=200,
)
results = self.test_connector.search("a book title", min_confidence=1)
self.assertEqual(len(results), 10)
@responses.activate
def test_isbn_search(self):
""" makes an http request to the outside service """
responses.add(
responses.GET,
"https://example.com/isbn?q=123456",
json=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"],
status=200,
)
results = self.test_connector.isbn_search("123456")
self.assertEqual(len(results), 10)
def test_search_result(self):
""" a class that stores info about a search result """
result = SearchResult(

View file

@ -23,10 +23,12 @@ class BookWyrmConnector(TestCase):
)
self.connector = Connector("example.com")
work_file = pathlib.Path(__file__).parent.joinpath("../data/bw_work.json")
edition_file = pathlib.Path(__file__).parent.joinpath("../data/bw_edition.json")
self.work_data = json.loads(work_file.read_bytes())
self.edition_data = json.loads(edition_file.read_bytes())
def test_get_or_create_book_existing(self):
""" load book activity """
work = models.Work.objects.create(title="Test Work")
book = models.Edition.objects.create(title="Test Edition", parent_work=work)
result = self.connector.get_or_create_book(book.remote_id)
self.assertEqual(book, result)
def test_format_search_result(self):
""" create a SearchResult object from search response json """
@ -42,3 +44,11 @@ class BookWyrmConnector(TestCase):
self.assertEqual(result.author, "Susanna Clarke")
self.assertEqual(result.year, 2017)
self.assertEqual(result.connector, self.connector)
def test_format_isbn_search_result(self):
""" just gotta attach the connector """
datafile = pathlib.Path(__file__).parent.joinpath("../data/bw_search.json")
search_data = json.loads(datafile.read_bytes())
results = self.connector.parse_isbn_search_data(search_data)
result = self.connector.format_isbn_search_result(results[0])
self.assertEqual(result.connector, self.connector)

View file

@ -15,7 +15,7 @@ class ConnectorManager(TestCase):
self.work = models.Work.objects.create(title="Example Work")
self.edition = models.Edition.objects.create(
title="Example Edition", parent_work=self.work
title="Example Edition", parent_work=self.work, isbn_10="0000000000"
)
self.work.default_edition = self.edition
self.work.save()
@ -28,6 +28,7 @@ class ConnectorManager(TestCase):
base_url="http://test.com/",
books_url="http://test.com/",
covers_url="http://test.com/",
isbn_search_url="http://test.com/isbn/",
)
def test_get_or_create_connector(self):
@ -58,6 +59,14 @@ class ConnectorManager(TestCase):
self.assertEqual(len(results[0]["results"]), 1)
self.assertEqual(results[0]["results"][0].title, "Example Edition")
def test_search_isbn(self):
""" special handling if a query resembles an isbn """
results = connector_manager.search("0000000000")
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_local_search(self):
""" search only the local database """
results = connector_manager.local_search("Example")

View file

@ -8,6 +8,7 @@ import responses
from bookwyrm import models
from bookwyrm.connectors.openlibrary import Connector
from bookwyrm.connectors.openlibrary import ignore_edition
from bookwyrm.connectors.openlibrary import get_languages, get_description
from bookwyrm.connectors.openlibrary import pick_default_edition, get_openlibrary_key
from bookwyrm.connectors.abstract_connector import SearchResult
@ -237,3 +238,12 @@ class Openlibrary(TestCase):
self.assertEqual(result.pages, 491)
self.assertEqual(result.subjects[0], "Fantasy.")
self.assertEqual(result.physical_format, "Hardcover")
def test_ignore_edition(self):
""" skip editions with poor metadata """
self.assertFalse(ignore_edition({"isbn_13": "hi"}))
self.assertFalse(ignore_edition({"oclc_numbers": "hi"}))
self.assertFalse(ignore_edition({"covers": "hi"}))
self.assertFalse(ignore_edition({"languages": "languages/fr"}))
self.assertTrue(ignore_edition({"languages": "languages/eng"}))
self.assertTrue(ignore_edition({"format": "paperback"}))

View file

@ -8,19 +8,31 @@ from django.core.files.base import ContentFile
from django.db import IntegrityError
from django.test import TestCase
from django.utils import timezone
import responses
from bookwyrm import models, settings
from bookwyrm import activitypub, models, settings
# pylint: disable=too-many-public-methods
@patch("bookwyrm.models.Status.broadcast")
class Status(TestCase):
""" lotta types of statuses """
def setUp(self):
""" useful things for creating a status """
self.user = models.User.objects.create_user(
self.local_user = models.User.objects.create_user(
"mouse", "mouse@mouse.mouse", "mouseword", local=True, localname="mouse"
)
with patch("bookwyrm.models.user.set_remote_server.delay"):
self.remote_user = models.User.objects.create_user(
"rat",
"rat@rat.com",
"ratword",
local=False,
remote_id="https://example.com/users/rat",
inbox="https://example.com/users/rat/inbox",
outbox="https://example.com/users/rat/outbox",
)
self.book = models.Edition.objects.create(title="Test Edition")
image_file = pathlib.Path(__file__).parent.joinpath(
@ -34,22 +46,22 @@ class Status(TestCase):
def test_status_generated_fields(self, _):
""" setting remote id """
status = models.Status.objects.create(content="bleh", user=self.user)
status = models.Status.objects.create(content="bleh", user=self.local_user)
expected_id = "https://%s/user/mouse/status/%d" % (settings.DOMAIN, status.id)
self.assertEqual(status.remote_id, expected_id)
self.assertEqual(status.privacy, "public")
def test_replies(self, _):
""" get a list of replies """
parent = models.Status.objects.create(content="hi", user=self.user)
parent = models.Status.objects.create(content="hi", user=self.local_user)
child = models.Status.objects.create(
content="hello", reply_parent=parent, user=self.user
content="hello", reply_parent=parent, user=self.local_user
)
models.Review.objects.create(
content="hey", reply_parent=parent, user=self.user, book=self.book
content="hey", reply_parent=parent, user=self.local_user, book=self.book
)
models.Status.objects.create(
content="hi hello", reply_parent=child, user=self.user
content="hi hello", reply_parent=child, user=self.local_user
)
replies = models.Status.replies(parent)
@ -75,15 +87,15 @@ class Status(TestCase):
def test_to_replies(self, _):
""" activitypub replies collection """
parent = models.Status.objects.create(content="hi", user=self.user)
parent = models.Status.objects.create(content="hi", user=self.local_user)
child = models.Status.objects.create(
content="hello", reply_parent=parent, user=self.user
content="hello", reply_parent=parent, user=self.local_user
)
models.Review.objects.create(
content="hey", reply_parent=parent, user=self.user, book=self.book
content="hey", reply_parent=parent, user=self.local_user, book=self.book
)
models.Status.objects.create(
content="hi hello", reply_parent=child, user=self.user
content="hi hello", reply_parent=child, user=self.local_user
)
replies = parent.to_replies()
@ -92,7 +104,9 @@ class Status(TestCase):
def test_status_to_activity(self, _):
""" subclass of the base model version with a "pure" serializer """
status = models.Status.objects.create(content="test content", user=self.user)
status = models.Status.objects.create(
content="test content", user=self.local_user
)
activity = status.to_activity()
self.assertEqual(activity["id"], status.remote_id)
self.assertEqual(activity["type"], "Note")
@ -103,7 +117,7 @@ class Status(TestCase):
""" subclass of the base model version with a "pure" serializer """
status = models.Status.objects.create(
content="test content",
user=self.user,
user=self.local_user,
deleted=True,
deleted_date=timezone.now(),
)
@ -114,7 +128,9 @@ class Status(TestCase):
def test_status_to_pure_activity(self, _):
""" subclass of the base model version with a "pure" serializer """
status = models.Status.objects.create(content="test content", user=self.user)
status = models.Status.objects.create(
content="test content", user=self.local_user
)
activity = status.to_activity(pure=True)
self.assertEqual(activity["id"], status.remote_id)
self.assertEqual(activity["type"], "Note")
@ -125,10 +141,10 @@ class Status(TestCase):
def test_generated_note_to_activity(self, _):
""" subclass of the base model version with a "pure" serializer """
status = models.GeneratedNote.objects.create(
content="test content", user=self.user
content="test content", user=self.local_user
)
status.mention_books.set([self.book])
status.mention_users.set([self.user])
status.mention_users.set([self.local_user])
activity = status.to_activity()
self.assertEqual(activity["id"], status.remote_id)
self.assertEqual(activity["type"], "GeneratedNote")
@ -139,10 +155,10 @@ class Status(TestCase):
def test_generated_note_to_pure_activity(self, _):
""" subclass of the base model version with a "pure" serializer """
status = models.GeneratedNote.objects.create(
content="test content", user=self.user
content="test content", user=self.local_user
)
status.mention_books.set([self.book])
status.mention_users.set([self.user])
status.mention_users.set([self.local_user])
activity = status.to_activity(pure=True)
self.assertEqual(activity["id"], status.remote_id)
self.assertEqual(
@ -163,7 +179,7 @@ class Status(TestCase):
def test_comment_to_activity(self, _):
""" subclass of the base model version with a "pure" serializer """
status = models.Comment.objects.create(
content="test content", user=self.user, book=self.book
content="test content", user=self.local_user, book=self.book
)
activity = status.to_activity()
self.assertEqual(activity["id"], status.remote_id)
@ -174,7 +190,7 @@ class Status(TestCase):
def test_comment_to_pure_activity(self, _):
""" subclass of the base model version with a "pure" serializer """
status = models.Comment.objects.create(
content="test content", user=self.user, book=self.book
content="test content", user=self.local_user, book=self.book
)
activity = status.to_activity(pure=True)
self.assertEqual(activity["id"], status.remote_id)
@ -196,7 +212,7 @@ class Status(TestCase):
status = models.Quotation.objects.create(
quote="a sickening sense",
content="test content",
user=self.user,
user=self.local_user,
book=self.book,
)
activity = status.to_activity()
@ -211,7 +227,7 @@ class Status(TestCase):
status = models.Quotation.objects.create(
quote="a sickening sense",
content="test content",
user=self.user,
user=self.local_user,
book=self.book,
)
activity = status.to_activity(pure=True)
@ -235,7 +251,7 @@ class Status(TestCase):
name="Review name",
content="test content",
rating=3,
user=self.user,
user=self.local_user,
book=self.book,
)
activity = status.to_activity()
@ -252,7 +268,7 @@ class Status(TestCase):
name="Review name",
content="test content",
rating=3,
user=self.user,
user=self.local_user,
book=self.book,
)
activity = status.to_activity(pure=True)
@ -275,30 +291,34 @@ class Status(TestCase):
def fav_broadcast_mock(_, activity, user):
""" ok """
self.assertEqual(user.remote_id, self.user.remote_id)
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity["type"], "Like")
models.Favorite.broadcast = fav_broadcast_mock
status = models.Status.objects.create(content="test content", user=self.user)
fav = models.Favorite.objects.create(status=status, user=self.user)
status = models.Status.objects.create(
content="test content", user=self.local_user
)
fav = models.Favorite.objects.create(status=status, user=self.local_user)
# can't fav a status twice
with self.assertRaises(IntegrityError):
models.Favorite.objects.create(status=status, user=self.user)
models.Favorite.objects.create(status=status, user=self.local_user)
activity = fav.to_activity()
self.assertEqual(activity["type"], "Like")
self.assertEqual(activity["actor"], self.user.remote_id)
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["object"], status.remote_id)
models.Favorite.broadcast = real_broadcast
def test_boost(self, _):
""" boosting, this one's a bit fussy """
status = models.Status.objects.create(content="test content", user=self.user)
boost = models.Boost.objects.create(boosted_status=status, user=self.user)
status = models.Status.objects.create(
content="test content", user=self.local_user
)
boost = models.Boost.objects.create(boosted_status=status, user=self.local_user)
activity = boost.to_activity()
self.assertEqual(activity["actor"], self.user.remote_id)
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["object"], status.remote_id)
self.assertEqual(activity["type"], "Announce")
self.assertEqual(activity, boost.to_activity(pure=True))
@ -306,18 +326,20 @@ class Status(TestCase):
def test_notification(self, _):
""" a simple model """
notification = models.Notification.objects.create(
user=self.user, notification_type="FAVORITE"
user=self.local_user, notification_type="FAVORITE"
)
self.assertFalse(notification.read)
with self.assertRaises(IntegrityError):
models.Notification.objects.create(
user=self.user, notification_type="GLORB"
user=self.local_user, notification_type="GLORB"
)
def test_create_broadcast(self, broadcast_mock):
""" should send out two verions of a status on create """
models.Comment.objects.create(content="hi", user=self.user, book=self.book)
models.Comment.objects.create(
content="hi", user=self.local_user, book=self.book
)
self.assertEqual(broadcast_mock.call_count, 2)
pure_call = broadcast_mock.call_args_list[0]
bw_call = broadcast_mock.call_args_list[1]
@ -332,3 +354,48 @@ class Status(TestCase):
args = bw_call[0][0]
self.assertEqual(args["type"], "Create")
self.assertEqual(args["object"]["type"], "Comment")
def test_recipients_with_mentions(self, _):
""" get recipients to broadcast a status """
status = models.GeneratedNote.objects.create(
content="test content", user=self.local_user
)
status.mention_users.add(self.remote_user)
self.assertEqual(status.recipients, [self.remote_user])
def test_recipients_with_reply_parent(self, _):
""" get recipients to broadcast a status """
parent_status = models.GeneratedNote.objects.create(
content="test content", user=self.remote_user
)
status = models.GeneratedNote.objects.create(
content="test content", user=self.local_user, reply_parent=parent_status
)
self.assertEqual(status.recipients, [self.remote_user])
def test_recipients_with_reply_parent_and_mentions(self, _):
""" get recipients to broadcast a status """
parent_status = models.GeneratedNote.objects.create(
content="test content", user=self.remote_user
)
status = models.GeneratedNote.objects.create(
content="test content", user=self.local_user, reply_parent=parent_status
)
status.mention_users.set([self.remote_user])
self.assertEqual(status.recipients, [self.remote_user])
@responses.activate
def test_ignore_activity_boost(self, _):
""" don't bother with most remote statuses """
activity = activitypub.Announce(
id="http://www.faraway.com/boost/12",
actor=self.remote_user.remote_id,
object="http://fish.com/nothing",
)
responses.add(responses.GET, "http://fish.com/nothing", status=404)
self.assertTrue(models.Status.ignore_activity(activity))

View file

@ -84,6 +84,108 @@ class BookViews(TestCase):
self.book.refresh_from_db()
self.assertEqual(self.book.title, "New Title")
def test_edit_book_add_author(self):
""" lets a user edit a book with new authors """
view = views.EditBook.as_view()
self.local_user.groups.add(self.group)
form = forms.EditionForm(instance=self.book)
form.data["title"] = "New Title"
form.data["last_edited_by"] = self.local_user.id
form.data["add_author"] = "Sappho"
request = self.factory.post("", form.data)
request.user = self.local_user
result = view(request, self.book.id)
result.render()
# the changes haven't been saved yet
self.book.refresh_from_db()
self.assertEqual(self.book.title, "Example Edition")
def test_edit_book_add_new_author_confirm(self):
""" lets a user edit a book confirmed with new authors """
view = views.ConfirmEditBook.as_view()
self.local_user.groups.add(self.group)
form = forms.EditionForm(instance=self.book)
form.data["title"] = "New Title"
form.data["last_edited_by"] = self.local_user.id
form.data["add_author"] = "Sappho"
request = self.factory.post("", form.data)
request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
view(request, self.book.id)
self.book.refresh_from_db()
self.assertEqual(self.book.title, "New Title")
self.assertEqual(self.book.authors.first().name, "Sappho")
def test_edit_book_remove_author(self):
""" remove an author from a book """
author = models.Author.objects.create(name="Sappho")
self.book.authors.add(author)
form = forms.EditionForm(instance=self.book)
view = views.EditBook.as_view()
self.local_user.groups.add(self.group)
form = forms.EditionForm(instance=self.book)
form.data["title"] = "New Title"
form.data["last_edited_by"] = self.local_user.id
form.data["remove_authors"] = [author.id]
request = self.factory.post("", form.data)
request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
view(request, self.book.id)
self.book.refresh_from_db()
self.assertEqual(self.book.title, "New Title")
self.assertFalse(self.book.authors.exists())
def test_create_book(self):
""" create an entirely new book and work """
view = views.ConfirmEditBook.as_view()
self.local_user.groups.add(self.group)
form = forms.EditionForm()
form.data["title"] = "New Title"
form.data["last_edited_by"] = self.local_user.id
request = self.factory.post("", form.data)
request.user = self.local_user
view(request)
book = models.Edition.objects.get(title="New Title")
self.assertEqual(book.parent_work.title, "New Title")
def test_create_book_existing_work(self):
""" create an entirely new book and work """
view = views.ConfirmEditBook.as_view()
self.local_user.groups.add(self.group)
form = forms.EditionForm()
form.data["title"] = "New Title"
form.data["parent_work"] = self.work.id
form.data["last_edited_by"] = self.local_user.id
request = self.factory.post("", form.data)
request.user = self.local_user
view(request)
book = models.Edition.objects.get(title="New Title")
self.assertEqual(book.parent_work, self.work)
def test_create_book_with_author(self):
""" create an entirely new book and work """
view = views.ConfirmEditBook.as_view()
self.local_user.groups.add(self.group)
form = forms.EditionForm()
form.data["title"] = "New Title"
form.data["add_author"] = "Sappho"
form.data["last_edited_by"] = self.local_user.id
request = self.factory.post("", form.data)
request.user = self.local_user
view(request)
book = models.Edition.objects.get(title="New Title")
self.assertEqual(book.parent_work.title, "New Title")
self.assertEqual(book.authors.first().name, "Sappho")
self.assertEqual(book.authors.first(), book.parent_work.authors.first())
def test_switch_edition(self):
""" updates user's relationships to a book """
work = models.Work.objects.create(title="test work")

View file

@ -563,6 +563,23 @@ class Inbox(TestCase):
}
views.inbox.activity_task(activity)
def test_handle_unboost_unknown_boost(self):
""" undo a boost """
activity = {
"type": "Undo",
"actor": "hi",
"id": "bleh",
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
"object": {
"type": "Announce",
"id": "http://fake.com/unknown/boost",
"actor": self.remote_user.remote_id,
"object": self.status.remote_id,
},
}
views.inbox.activity_task(activity)
def test_handle_add_book_to_shelf(self):
""" shelving a book """
work = models.Work.objects.create(title="work title")
@ -591,6 +608,41 @@ class Inbox(TestCase):
views.inbox.activity_task(activity)
self.assertEqual(shelf.books.first(), book)
def test_handle_unshelve_book(self):
""" remove a book from a shelf """
work = models.Work.objects.create(title="work title")
book = models.Edition.objects.create(
title="Test",
remote_id="https://bookwyrm.social/book/37292",
parent_work=work,
)
shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf")
shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read"
shelf.save()
shelfbook = models.ShelfBook.objects.create(
user=self.remote_user, shelf=shelf, book=book
)
self.assertEqual(shelf.books.first(), book)
self.assertEqual(shelf.books.count(), 1)
activity = {
"id": shelfbook.remote_id,
"type": "Remove",
"actor": "https://example.com/users/rat",
"object": {
"type": "Edition",
"title": "Test Title",
"work": work.remote_id,
"id": "https://bookwyrm.social/book/37292",
},
"target": "https://bookwyrm.social/user/mouse/shelf/to-read",
"@context": "https://www.w3.org/ns/activitystreams",
}
views.inbox.activity_task(activity)
self.assertFalse(shelf.books.exists())
@responses.activate
def test_handle_add_book_to_list(self):
""" listing a book """

View file

@ -0,0 +1,136 @@
""" test for app action functionality """
from unittest.mock import patch
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import forms, models, views
class ReportViews(TestCase):
""" every response to a get request, html or json """
def setUp(self):
""" we need basic test data and mocks """
self.factory = RequestFactory()
self.local_user = models.User.objects.create_user(
"mouse@local.com",
"mouse@mouse.mouse",
"password",
local=True,
localname="mouse",
)
self.rat = models.User.objects.create_user(
"rat@local.com",
"rat@mouse.mouse",
"password",
local=True,
localname="rat",
)
models.SiteSettings.objects.create()
def test_reports_page(self):
""" there are so many views, this just makes sure it LOADS """
view = views.Reports.as_view()
request = self.factory.get("")
request.user = self.local_user
request.user.is_superuser = True
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
def test_reports_page_with_data(self):
""" there are so many views, this just makes sure it LOADS """
view = views.Reports.as_view()
request = self.factory.get("")
request.user = self.local_user
request.user.is_superuser = True
models.Report.objects.create(reporter=self.local_user, user=self.rat)
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
def test_report_page(self):
""" there are so many views, this just makes sure it LOADS """
view = views.Report.as_view()
request = self.factory.get("")
request.user = self.local_user
request.user.is_superuser = True
report = models.Report.objects.create(reporter=self.local_user, user=self.rat)
result = view(request, report.id)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
def test_report_comment(self):
""" comment on a report """
view = views.Report.as_view()
request = self.factory.post("", {"note": "hi"})
request.user = self.local_user
request.user.is_superuser = True
report = models.Report.objects.create(reporter=self.local_user, user=self.rat)
view(request, report.id)
comment = models.ReportComment.objects.get()
self.assertEqual(comment.user, self.local_user)
self.assertEqual(comment.note, "hi")
self.assertEqual(comment.report, report)
def test_make_report(self):
""" a user reports another user """
form = forms.ReportForm()
form.data["reporter"] = self.local_user.id
form.data["user"] = self.rat.id
request = self.factory.post("", form.data)
request.user = self.local_user
views.make_report(request)
report = models.Report.objects.get()
self.assertEqual(report.reporter, self.local_user)
self.assertEqual(report.user, self.rat)
def test_resolve_report(self):
""" toggle report resolution status """
report = models.Report.objects.create(reporter=self.local_user, user=self.rat)
self.assertFalse(report.resolved)
request = self.factory.post("")
request.user = self.local_user
request.user.is_superuser = True
# resolve
views.resolve_report(request, report.id)
report.refresh_from_db()
self.assertTrue(report.resolved)
# un-resolve
views.resolve_report(request, report.id)
report.refresh_from_db()
self.assertFalse(report.resolved)
def test_deactivate_user(self):
""" toggle whether a user is able to log in """
self.assertTrue(self.rat.is_active)
report = models.Report.objects.create(reporter=self.local_user, user=self.rat)
request = self.factory.post("")
request.user = self.local_user
request.user.is_superuser = True
# de-activate
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
views.deactivate_user(request, report.id)
self.rat.refresh_from_db()
self.assertFalse(self.rat.is_active)
# re-activate
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
views.deactivate_user(request, report.id)
self.rat.refresh_from_db()
self.assertTrue(self.rat.is_active)

View file

@ -216,7 +216,7 @@ class StatusViews(TestCase):
'<a href="%s">'
"archive.org/details/dli.granth.72113/page/n25/mode/2up</a>" % url,
)
url = "https://openlibrary.org/search" "?q=arkady+strugatsky&mode=everything"
url = "https://openlibrary.org/search?q=arkady+strugatsky&mode=everything"
self.assertEqual(
views.status.format_links(url),
'<a href="%s">openlibrary.org/search'
@ -253,3 +253,35 @@ class StatusViews(TestCase):
self.assertEqual(activity["object"]["type"], "Tombstone")
status.refresh_from_db()
self.assertTrue(status.deleted)
def test_handle_delete_status_permission_denied(self):
""" marks a status as deleted """
view = views.DeleteStatus.as_view()
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
status = models.Status.objects.create(user=self.local_user, content="hi")
self.assertFalse(status.deleted)
request = self.factory.post("")
request.user = self.remote_user
view(request, status.id)
status.refresh_from_db()
self.assertFalse(status.deleted)
def test_handle_delete_status_moderator(self):
""" marks a status as deleted """
view = views.DeleteStatus.as_view()
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
status = models.Status.objects.create(user=self.local_user, content="hi")
self.assertFalse(status.deleted)
request = self.factory.post("")
request.user = self.remote_user
request.user.is_superuser = True
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
view(request, status.id)
activity = json.loads(mock.call_args_list[0][0][1])
self.assertEqual(activity["type"], "Delete")
self.assertEqual(activity["object"]["type"], "Tombstone")
status.refresh_from_db()
self.assertTrue(status.deleted)

View file

@ -0,0 +1,42 @@
""" test for app action functionality """
import json
from django.http import JsonResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
class UpdateViews(TestCase):
""" lets the ui check for unread notification """
def setUp(self):
""" we need basic test data and mocks """
self.factory = RequestFactory()
self.local_user = models.User.objects.create_user(
"mouse@local.com",
"mouse@mouse.mouse",
"password",
local=True,
localname="mouse",
)
models.SiteSettings.objects.create()
def test_get_updates(self):
""" there are so many views, this just makes sure it LOADS """
view = views.Updates.as_view()
request = self.factory.get("")
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, JsonResponse)
data = json.loads(result.getvalue())
self.assertEqual(data["notifications"], 0)
models.Notification.objects.create(
notification_type="BOOST", user=self.local_user
)
result = view(request)
self.assertIsInstance(result, JsonResponse)
data = json.loads(result.getvalue())
self.assertEqual(data["notifications"], 1)

View file

@ -5,7 +5,6 @@ from PIL import Image
from django.contrib.auth.models import AnonymousUser
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory

View file

@ -0,0 +1,84 @@
""" test for app action functionality """
import json
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.http import JsonResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
class UserViews(TestCase):
""" view user and edit profile """
def setUp(self):
""" we need basic test data and mocks """
self.factory = RequestFactory()
self.local_user = models.User.objects.create_user(
"mouse@local.com",
"mouse@mouse.mouse",
"password",
local=True,
localname="mouse",
)
models.User.objects.create_user(
"rat@local.com", "rat@rat.rat", "password", local=True, localname="rat"
)
with patch("bookwyrm.models.user.set_remote_server.delay"):
models.User.objects.create_user(
"rat",
"rat@remote.com",
"ratword",
local=False,
remote_id="https://example.com/users/rat",
inbox="https://example.com/users/rat/inbox",
outbox="https://example.com/users/rat/outbox",
)
models.SiteSettings.objects.create()
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
def test_webfinger(self):
""" there are so many views, this just makes sure it LOADS """
request = self.factory.get("", {"resource": "acct:mouse@local.com"})
request.user = self.anonymous_user
result = views.webfinger(request)
self.assertIsInstance(result, JsonResponse)
data = json.loads(result.getvalue())
self.assertEqual(data["subject"], "acct:mouse@local.com")
def test_nodeinfo_pointer(self):
""" just tells you where nodeinfo is """
request = self.factory.get("")
request.user = self.anonymous_user
result = views.nodeinfo_pointer(request)
data = json.loads(result.getvalue())
self.assertIsInstance(result, JsonResponse)
self.assertTrue("href" in data["links"][0])
def test_nodeinfo(self):
""" info about the instance """
request = self.factory.get("")
request.user = self.anonymous_user
result = views.nodeinfo(request)
data = json.loads(result.getvalue())
self.assertIsInstance(result, JsonResponse)
self.assertEqual(data["software"]["name"], "bookwyrm")
self.assertEqual(data["usage"]["users"]["total"], 2)
self.assertEqual(models.User.objects.count(), 3)
def test_instanceinfo(self):
""" about the instance's user activity """
request = self.factory.get("")
request.user = self.anonymous_user
result = views.instance_info(request)
data = json.loads(result.getvalue())
self.assertIsInstance(result, JsonResponse)
self.assertEqual(data["stats"]["user_count"], 2)
self.assertEqual(models.User.objects.count(), 3)

View file

@ -4,7 +4,7 @@ from django.contrib import admin
from django.urls import path, re_path
from bookwyrm import settings, views, wellknown
from bookwyrm import settings, views
from bookwyrm.utils import regex
user_path = r"^user/(?P<username>%s)" % regex.username
@ -31,11 +31,11 @@ urlpatterns = [
re_path(r"^inbox/?$", views.Inbox.as_view()),
re_path(r"%s/inbox/?$" % local_user_path, views.Inbox.as_view()),
re_path(r"%s/outbox/?$" % local_user_path, views.Outbox.as_view()),
re_path(r"^.well-known/webfinger/?$", wellknown.webfinger),
re_path(r"^.well-known/nodeinfo/?$", wellknown.nodeinfo_pointer),
re_path(r"^nodeinfo/2\.0/?$", wellknown.nodeinfo),
re_path(r"^api/v1/instance/?$", wellknown.instance_info),
re_path(r"^api/v1/instance/peers/?$", wellknown.peers),
re_path(r"^.well-known/webfinger/?$", views.webfinger),
re_path(r"^.well-known/nodeinfo/?$", views.nodeinfo_pointer),
re_path(r"^nodeinfo/2\.0/?$", views.nodeinfo),
re_path(r"^api/v1/instance/?$", views.instance_info),
re_path(r"^api/v1/instance/peers/?$", views.peers),
# polling updates
re_path("^api/updates/notifications/?$", views.Updates.as_view()),
# authentication
@ -55,6 +55,24 @@ urlpatterns = [
r"^settings/invites/?$", views.ManageInvites.as_view(), name="settings-invites"
),
re_path(r"^invite/(?P<code>[A-Za-z0-9]+)/?$", views.Invite.as_view()),
# moderation
re_path(r"^settings/reports/?$", views.Reports.as_view(), name="settings-reports"),
re_path(
r"^settings/reports/(?P<report_id>\d+)/?$",
views.Report.as_view(),
name="settings-report",
),
re_path(
r"^settings/reports/(?P<report_id>\d+)/deactivate/?$",
views.deactivate_user,
name="settings-report-deactivate",
),
re_path(
r"^settings/reports/(?P<report_id>\d+)/resolve/?$",
views.resolve_report,
name="settings-report-resolve",
),
re_path(r"^report/?$", views.make_report, name="report"),
# landing pages
re_path(r"^about/?$", views.About.as_view()),
path("", views.Home.as_view()),
@ -62,10 +80,13 @@ urlpatterns = [
re_path(r"^notifications/?$", views.Notifications.as_view()),
# feeds
re_path(r"^(?P<tab>home|local|federated)/?$", views.Feed.as_view()),
re_path(r"^direct-messages/?$", views.DirectMessage.as_view()),
re_path(
r"^direct-messages/?$", views.DirectMessage.as_view(), name="direct-messages"
),
re_path(
r"^direct-messages/(?P<username>%s)?$" % regex.username,
views.DirectMessage.as_view(),
name="direct-messages-user",
),
# search
re_path(r"^search/?$", views.Search.as_view()),
@ -127,6 +148,9 @@ urlpatterns = [
# books
re_path(r"%s(.json)?/?$" % book_path, views.Book.as_view()),
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/confirm?$", views.ConfirmEditBook.as_view()),
re_path(r"%s/editions(.json)?/?$" % book_path, views.Editions.as_view()),
re_path(r"^upload-cover/(?P<book_id>\d+)/?$", views.upload_cover),
re_path(r"^add-description/(?P<book_id>\d+)/?$", views.add_description),

View file

@ -2,7 +2,7 @@
from .authentication import Login, Register, Logout
from .author import Author, EditAuthor
from .block import Block, unblock
from .books import Book, EditBook, Editions
from .books import Book, EditBook, ConfirmEditBook, Editions
from .books import upload_cover, add_description, switch_edition, resolve_book
from .error import not_found_page, server_error_page
from .federation import Federation
@ -14,21 +14,23 @@ from .import_data import Import, ImportStatus
from .inbox import Inbox
from .interaction import Favorite, Unfavorite, Boost, Unboost
from .invite import ManageInvites, Invite
from .isbn import Isbn
from .landing import About, Home, Discover
from .list import Lists, List, Curate, UserLists
from .notifications import Notifications
from .outbox import Outbox
from .reading import edit_readthrough, create_readthrough, delete_readthrough
from .reading import start_reading, finish_reading, delete_progressupdate
from .reports import Report, Reports, make_report, resolve_report, deactivate_user
from .rss_feed import RssFeed
from .password import PasswordResetRequest, PasswordReset, ChangePassword
from .tag import Tag, AddTag, RemoveTag
from .search import Search
from .shelf import Shelf
from .shelf import user_shelves_page, create_shelf, delete_shelf
from .shelf import shelve, unshelve
from .site import Site
from .status import CreateStatus, DeleteStatus
from .tag import Tag, AddTag, RemoveTag
from .updates import Updates
from .user import User, EditUser, Followers, Following
from .isbn import Isbn
from .wellknown import webfinger, nodeinfo_pointer, nodeinfo, instance_info, peers

View file

@ -1,6 +1,7 @@
""" the good stuff! the books! """
from django.core.paginator import Paginator
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.postgres.search import SearchRank, SearchVector
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Avg, Q
from django.http import HttpResponseNotFound
@ -106,23 +107,126 @@ class Book(View):
class EditBook(View):
""" edit a book """
def get(self, request, book_id):
def get(self, request, book_id=None):
""" info about a book """
book = get_edition(book_id)
if not book.description:
book.description = book.parent_work.description
book = None
if book_id:
book = get_edition(book_id)
if not book.description:
book.description = book.parent_work.description
data = {"book": book, "form": forms.EditionForm(instance=book)}
return TemplateResponse(request, "edit_book.html", data)
def post(self, request, book_id):
def post(self, request, book_id=None):
""" edit a book cool """
book = get_object_or_404(models.Edition, id=book_id)
# returns None if no match is found
book = models.Edition.objects.filter(id=book_id).first()
form = forms.EditionForm(request.POST, request.FILES, instance=book)
data = {"book": book, "form": form}
if not form.is_valid():
data = {"book": book, "form": form}
return TemplateResponse(request, "edit_book.html", data)
add_author = request.POST.get("add_author")
# we're adding an author through a free text field
if add_author:
data["add_author"] = add_author
data["author_matches"] = []
for author in add_author.split(","):
if not author:
continue
# check for existing authors
vector = SearchVector("name", weight="A") + SearchVector(
"aliases", weight="B"
)
data["author_matches"].append(
{
"name": author.strip(),
"matches": (
models.Author.objects.annotate(search=vector)
.annotate(rank=SearchRank(vector, add_author))
.filter(rank__gt=0.4)
.order_by("-rank")[:5]
),
}
)
# we're creating a new book
if not book:
# check if this is an edition of an existing work
author_text = book.author_text if book else add_author
data["book_matches"] = connector_manager.local_search(
"%s %s" % (form.cleaned_data.get("title"), author_text),
min_confidence=0.5,
raw=True,
)[:5]
# either of the above cases requires additional confirmation
if add_author or not book:
# creting a book or adding an author to a book needs another step
data["confirm_mode"] = True
# this isn't preserved because it isn't part of the form obj
data["remove_authors"] = request.POST.getlist("remove_authors")
return TemplateResponse(request, "edit_book.html", data)
remove_authors = request.POST.getlist("remove_authors")
for author_id in remove_authors:
book.authors.remove(author_id)
book = form.save()
return redirect("/book/%s" % book.id)
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.edit_book", raise_exception=True), name="dispatch"
)
class ConfirmEditBook(View):
""" confirm edits to a book """
def post(self, request, book_id=None):
""" edit a book cool """
# returns None if no match is found
book = models.Edition.objects.filter(id=book_id).first()
form = forms.EditionForm(request.POST, request.FILES, instance=book)
data = {"book": book, "form": form}
if not form.is_valid():
return TemplateResponse(request, "edit_book.html", data)
with transaction.atomic():
# save book
book = form.save()
# get or create author as needed
if request.POST.get("add_author"):
for (i, author) in enumerate(request.POST.get("add_author").split(",")):
if not author:
continue
match = request.POST.get("author_match-%d" % i)
if match and match != "0":
author = get_object_or_404(
models.Author, id=request.POST["author_match-%d" % i]
)
else:
author = models.Author.objects.create(name=author.strip())
book.authors.add(author)
# create work, if needed
if not book_id:
work_match = request.POST.get("parent_work")
if work_match and work_match != "0":
work = get_object_or_404(models.Work, id=work_match)
else:
work = models.Work.objects.create(title=form.cleaned_data["title"])
work.authors.set(book.authors.all())
book.parent_work = work
# we don't tell the world when creating a book
book.save(broadcast=False)
for author_id in request.POST.getlist("remove_authors"):
book.authors.remove(author_id)
return redirect("/book/%s" % book.id)

97
bookwyrm/views/reports.py Normal file
View file

@ -0,0 +1,97 @@
""" moderation via flagged posts and users """
from django.contrib.auth.decorators import login_required, permission_required
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST
from bookwyrm import forms, models
# pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.moderate_user", raise_exception=True),
name="dispatch",
)
@method_decorator(
permission_required("bookwyrm.moderate_post", raise_exception=True),
name="dispatch",
)
class Reports(View):
""" list of reports """
def get(self, request):
""" view current reports """
resolved = request.GET.get("resolved") == "true"
data = {
"resolved": resolved,
"reports": models.Report.objects.filter(resolved=resolved),
}
return TemplateResponse(request, "moderation/reports.html", data)
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.moderate_user", raise_exception=True),
name="dispatch",
)
@method_decorator(
permission_required("bookwyrm.moderate_post", raise_exception=True),
name="dispatch",
)
class Report(View):
""" view a specific report """
def get(self, request, report_id):
""" load a report """
data = {
"report": get_object_or_404(models.Report, id=report_id),
}
return TemplateResponse(request, "moderation/report.html", data)
def post(self, request, report_id):
""" comment on a report """
report = get_object_or_404(models.Report, id=report_id)
models.ReportComment.objects.create(
user=request.user,
report=report,
note=request.POST.get("note"),
)
return redirect("settings-report", report.id)
@login_required
@permission_required("bookwyrm_moderate_user")
def deactivate_user(_, report_id):
""" mark an account as inactive """
report = get_object_or_404(models.Report, id=report_id)
report.user.is_active = not report.user.is_active
report.user.save()
return redirect("settings-report", report.id)
@login_required
@permission_required("bookwyrm_moderate_post")
def resolve_report(_, report_id):
""" mark a report as (un)resolved """
report = get_object_or_404(models.Report, id=report_id)
report.resolved = not report.resolved
report.save()
if not report.resolved:
return redirect("settings-report", report.id)
return redirect("settings-reports")
@login_required
@require_POST
def make_report(request):
""" a user reports something """
form = forms.ReportForm(request.POST)
if not form.is_valid():
print(form.errors)
return redirect(request.headers.get("Referer", "/"))
form.save()
return redirect(request.headers.get("Referer", "/"))

View file

@ -75,7 +75,7 @@ class DeleteStatus(View):
status = get_object_or_404(models.Status, id=status_id)
# don't let people delete other people's statuses
if status.user != request.user:
if status.user != request.user and not request.user.has_perm("moderate_post"):
return HttpResponseBadRequest()
# perform deletion

View file

@ -4,18 +4,17 @@ from dateutil.relativedelta import relativedelta
from django.http import HttpResponseNotFound
from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_GET
from bookwyrm import models
from bookwyrm.settings import DOMAIN, VERSION
@require_GET
def webfinger(request):
""" allow other servers to ask about a user """
if request.method != "GET":
return HttpResponseNotFound()
resource = request.GET.get("resource")
if not resource and not resource.startswith("acct:"):
if not resource or not resource.startswith("acct:"):
return HttpResponseNotFound()
username = resource.replace("acct:", "")
@ -38,11 +37,9 @@ def webfinger(request):
)
def nodeinfo_pointer(request):
@require_GET
def nodeinfo_pointer(_):
""" direct servers to nodeinfo """
if request.method != "GET":
return HttpResponseNotFound()
return JsonResponse(
{
"links": [
@ -55,11 +52,9 @@ def nodeinfo_pointer(request):
)
def nodeinfo(request):
@require_GET
def nodeinfo(_):
""" basic info about the server """
if request.method != "GET":
return HttpResponseNotFound()
status_count = models.Status.objects.filter(user__local=True).count()
user_count = models.User.objects.filter(local=True).count()
@ -92,11 +87,9 @@ def nodeinfo(request):
)
def instance_info(request):
@require_GET
def instance_info(_):
""" let's talk about your cool unique instance """
if request.method != "GET":
return HttpResponseNotFound()
user_count = models.User.objects.filter(local=True).count()
status_count = models.Status.objects.filter(user__local=True).count()
@ -120,10 +113,8 @@ def instance_info(request):
)
def peers(request):
@require_GET
def peers(_):
""" list of federated servers this instance connects with """
if request.method != "GET":
return HttpResponseNotFound()
names = models.FederatedServer.objects.values_list("server_name", flat=True)
return JsonResponse(list(names), safe=False)

2
bw-dev
View file

@ -29,7 +29,7 @@ function initdb {
}
function makeitblack {
runweb black celerywyrm bookwyrm
docker-compose run --rm web black celerywyrm bookwyrm
}
CMD=$1