Merge pull request #1527 from bookwyrm-social/book-file-links

Book file links
This commit is contained in:
Mouse Reeve 2022-01-13 11:10:05 -08:00 committed by GitHub
commit 0c2537e27a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 1681 additions and 206 deletions

View file

@ -20,22 +20,6 @@ class ActivityEncoder(JSONEncoder):
return o.__dict__
@dataclass
class Link:
"""for tagging a book in a status"""
href: str
name: str
type: str = "Link"
@dataclass
class Mention(Link):
"""a subtype of Link for mentioning an actor"""
type: str = "Mention"
@dataclass
# pylint: disable=invalid-name
class Signature:
@ -198,8 +182,9 @@ class ActivityObject:
)
return instance
def serialize(self):
def serialize(self, **kwargs):
"""convert to dictionary with context attr"""
omit = kwargs.get("omit", ())
data = self.__dict__.copy()
# recursively serialize
for (k, v) in data.items():
@ -208,8 +193,9 @@ class ActivityObject:
data[k] = v.serialize()
except TypeError:
pass
data = {k: v for (k, v) in data.items() if v is not None}
data["@context"] = "https://www.w3.org/ns/activitystreams"
data = {k: v for (k, v) in data.items() if v is not None and k not in omit}
if "@context" not in omit:
data["@context"] = "https://www.w3.org/ns/activitystreams"
return data
@ -222,35 +208,32 @@ def set_related_field(
model = apps.get_model(f"bookwyrm.{model_name}", require_ready=True)
origin_model = apps.get_model(f"bookwyrm.{origin_model_name}", require_ready=True)
with transaction.atomic():
if isinstance(data, str):
existing = model.find_existing_by_remote_id(data)
if existing:
data = existing.to_activity()
else:
data = get_data(data)
activity = model.activity_serializer(**data)
if isinstance(data, str):
existing = model.find_existing_by_remote_id(data)
if existing:
data = existing.to_activity()
else:
data = get_data(data)
activity = model.activity_serializer(**data)
# this must exist because it's the object that triggered this function
instance = origin_model.find_existing_by_remote_id(related_remote_id)
if not instance:
raise ValueError(f"Invalid related remote id: {related_remote_id}")
# this must exist because it's the object that triggered this function
instance = origin_model.find_existing_by_remote_id(related_remote_id)
if not instance:
raise ValueError(f"Invalid related remote id: {related_remote_id}")
# set the origin's remote id on the activity so it will be there when
# the model instance is created
# edition.parentWork = instance, for example
model_field = getattr(model, related_field_name)
if hasattr(model_field, "activitypub_field"):
setattr(
activity, getattr(model_field, "activitypub_field"), instance.remote_id
)
item = activity.to_model()
# set the origin's remote id on the activity so it will be there when
# the model instance is created
# edition.parentWork = instance, for example
model_field = getattr(model, related_field_name)
if hasattr(model_field, "activitypub_field"):
setattr(activity, getattr(model_field, "activitypub_field"), instance.remote_id)
item = activity.to_model()
# if the related field isn't serialized (attachments on Status), then
# we have to set it post-creation
if not hasattr(model_field, "activitypub_field"):
setattr(item, related_field_name, instance)
item.save()
# if the related field isn't serialized (attachments on Status), then
# we have to set it post-creation
if not hasattr(model_field, "activitypub_field"):
setattr(item, related_field_name, instance)
item.save()
def get_model_from_type(activity_type):
@ -304,3 +287,27 @@ def resolve_remote_id(
# if we're refreshing, "result" will be set and we'll update it
return item.to_model(model=model, instance=result, save=save)
@dataclass(init=False)
class Link(ActivityObject):
"""for tagging a book in a status"""
href: str
name: str = None
mediaType: str = None
id: str = None
attributedTo: str = None
type: str = "Link"
def serialize(self, **kwargs):
"""remove fields"""
omit = ("id", "type", "@context")
return super().serialize(omit=omit)
@dataclass(init=False)
class Mention(Link):
"""a subtype of Link for mentioning an actor"""
type: str = "Mention"

View file

@ -17,6 +17,8 @@ class BookData(ActivityObject):
goodreadsKey: str = None
bnfId: str = None
lastEditedBy: str = None
links: List[str] = field(default_factory=lambda: [])
fileLinks: List[str] = field(default_factory=lambda: [])
# pylint: disable=invalid-name

View file

@ -15,6 +15,11 @@ class PublicKey(ActivityObject):
publicKeyPem: str
type: str = "PublicKey"
def serialize(self, **kwargs):
"""remove fields"""
omit = ("type", "@context")
return super().serialize(omit=omit)
# pylint: disable=invalid-name
@dataclass(init=False)

View file

@ -216,6 +216,18 @@ class CoverForm(CustomForm):
help_texts = {f: None for f in fields}
class LinkDomainForm(CustomForm):
class Meta:
model = models.LinkDomain
fields = ["name"]
class FileLinkForm(CustomForm):
class Meta:
model = models.FileLink
fields = ["url", "filetype", "book", "added_by"]
class EditionForm(CustomForm):
class Meta:
model = models.Edition
@ -230,6 +242,8 @@ class EditionForm(CustomForm):
"shelves",
"connector",
"search_vector",
"links",
"file_links",
]
widgets = {
"title": forms.TextInput(attrs={"aria-describedby": "desc_title"}),
@ -439,7 +453,7 @@ class GroupForm(CustomForm):
class ReportForm(CustomForm):
class Meta:
model = models.Report
fields = ["user", "reporter", "statuses", "note"]
fields = ["user", "reporter", "statuses", "links", "note"]
class EmailBlocklistForm(CustomForm):

View file

@ -3,7 +3,7 @@ from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from bookwyrm.models import Connector, FederatedServer, SiteSettings, User
from bookwyrm import models
def init_groups():
@ -55,7 +55,7 @@ def init_permissions():
},
]
content_type = ContentType.objects.get_for_model(User)
content_type = models.ContentType.objects.get_for_model(User)
for permission in permissions:
permission_obj = Permission.objects.create(
codename=permission["codename"],
@ -72,7 +72,7 @@ def init_permissions():
def init_connectors():
"""access book data sources"""
Connector.objects.create(
models.Connector.objects.create(
identifier="bookwyrm.social",
name="BookWyrm dot Social",
connector_file="bookwyrm_connector",
@ -84,7 +84,7 @@ def init_connectors():
priority=2,
)
Connector.objects.create(
models.Connector.objects.create(
identifier="inventaire.io",
name="Inventaire",
connector_file="inventaire",
@ -96,7 +96,7 @@ def init_connectors():
priority=3,
)
Connector.objects.create(
models.Connector.objects.create(
identifier="openlibrary.org",
name="OpenLibrary",
connector_file="openlibrary",
@ -113,7 +113,7 @@ def init_federated_servers():
"""big no to nazis"""
built_in_blocks = ["gab.ai", "gab.com"]
for server in built_in_blocks:
FederatedServer.objects.create(
models.FederatedServer.objects.create(
server_name=server,
status="blocked",
)
@ -121,18 +121,61 @@ def init_federated_servers():
def init_settings():
"""info about the instance"""
SiteSettings.objects.create(
models.SiteSettings.objects.create(
support_link="https://www.patreon.com/bookwyrm",
support_title="Patreon",
)
def init_link_domains(*_):
"""safe book links"""
domains = [
("standardebooks.org", "Standard EBooks"),
("www.gutenberg.org", "Project Gutenberg"),
("archive.org", "Internet Archive"),
("openlibrary.org", "Open Library"),
("theanarchistlibrary.org", "The Anarchist Library"),
]
for domain, name in domains:
models.LinkDomain.objects.create(
domain=domain,
name=name,
status="approved",
)
class Command(BaseCommand):
help = "Initializes the database with starter data"
def add_arguments(self, parser):
parser.add_argument(
"--limit",
default=None,
help="Limit init to specific table",
)
def handle(self, *args, **options):
init_groups()
init_permissions()
init_connectors()
init_federated_servers()
init_settings()
limit = options.get("limit")
tables = [
"group",
"permission",
"connector",
"federatedserver",
"settings",
"linkdomain",
]
if limit not in tables:
raise Exception("Invalid table limit:", limit)
if not limit or limit == "group":
init_groups()
if not limit or limit == "permission":
init_permissions()
if not limit or limit == "connector":
init_connectors()
if not limit or limit == "federatedserver":
init_federated_servers()
if not limit or limit == "settings":
init_settings()
if not limit or limit == "linkdomain":
init_link_domains()

View file

@ -22,13 +22,6 @@ class Command(BaseCommand):
help = "Populate list streams for all users"
def add_arguments(self, parser):
parser.add_argument(
"--stream",
default=None,
help="Specifies which time of stream to populate",
)
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
"""run feed builder"""

View file

@ -0,0 +1,144 @@
# Generated by Django 3.2.10 on 2022-01-10 21:20
import bookwyrm.models.activitypub_mixin
import bookwyrm.models.fields
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0125_alter_user_preferred_language"),
]
operations = [
migrations.CreateModel(
name="LinkDomain",
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],
),
),
("domain", models.CharField(max_length=255, unique=True)),
(
"status",
models.CharField(
choices=[
("approved", "Approved"),
("blocked", "Blocked"),
("pending", "Pending"),
],
default="pending",
max_length=50,
),
),
("name", models.CharField(max_length=100)),
(
"reported_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Link",
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],
),
),
("url", bookwyrm.models.fields.URLField(max_length=255)),
(
"added_by",
bookwyrm.models.fields.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
(
"domain",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="links",
to="bookwyrm.linkdomain",
),
),
],
options={
"abstract": False,
},
bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model),
),
migrations.CreateModel(
name="FileLink",
fields=[
(
"link_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="bookwyrm.link",
),
),
("filetype", bookwyrm.models.fields.CharField(max_length=5)),
(
"book",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="file_links",
to="bookwyrm.book",
),
),
],
options={
"abstract": False,
},
bases=("bookwyrm.link",),
),
]

View file

@ -0,0 +1,22 @@
# Generated by Django 3.2.10 on 2022-01-10 22:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0126_filelink_link_linkdomain"),
]
operations = [
migrations.RemoveConstraint(
model_name="report",
name="self_report",
),
migrations.AddField(
model_name="report",
name="links",
field=models.ManyToManyField(blank=True, to="bookwyrm.Link"),
),
]

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.10 on 2022-01-13 01:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0126_auto_20220112_2315"),
("bookwyrm", "0127_auto_20220110_2211"),
]
operations = []

View file

@ -4,6 +4,7 @@ import sys
from .book import Book, Work, Edition, BookDataModel
from .author import Author
from .link import Link, FileLink, LinkDomain
from .connector import Connector
from .shelf import Shelf, ShelfBook

View file

@ -241,8 +241,11 @@ class Work(OrderedCollectionPageMixin, Book):
)
activity_serializer = activitypub.Work
serialize_reverse_fields = [("editions", "editions", "-edition_rank")]
deserialize_reverse_fields = [("editions", "editions")]
serialize_reverse_fields = [
("editions", "editions", "-edition_rank"),
("file_links", "fileLinks", "-created_date"),
]
deserialize_reverse_fields = [("editions", "editions"), ("file_links", "fileLinks")]
# https://schema.org/BookFormatType
@ -296,6 +299,8 @@ class Edition(Book):
activity_serializer = activitypub.Edition
name_field = "title"
serialize_reverse_fields = [("file_links", "fileLinks", "-created_date")]
deserialize_reverse_fields = [("file_links", "fileLinks")]
def get_rank(self):
"""calculate how complete the data is on this edition"""

View file

@ -517,6 +517,10 @@ class CharField(ActivitypubFieldMixin, models.CharField):
"""activitypub-aware char field"""
class URLField(ActivitypubFieldMixin, models.URLField):
"""activitypub-aware url field"""
class TextField(ActivitypubFieldMixin, models.TextField):
"""activitypub-aware text field"""

85
bookwyrm/models/link.py Normal file
View file

@ -0,0 +1,85 @@
""" outlink data """
from urllib.parse import urlparse
from django.core.exceptions import PermissionDenied
from django.db import models
from django.utils.translation import gettext_lazy as _
from bookwyrm import activitypub
from .activitypub_mixin import ActivitypubMixin
from .base_model import BookWyrmModel
from . import fields
class Link(ActivitypubMixin, BookWyrmModel):
"""a link to a website"""
url = fields.URLField(max_length=255, activitypub_field="href")
added_by = fields.ForeignKey(
"User", on_delete=models.SET_NULL, null=True, activitypub_field="attributedTo"
)
domain = models.ForeignKey(
"LinkDomain",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="links",
)
activity_serializer = activitypub.Link
reverse_unfurl = True
@property
def name(self):
"""link name via the assocaited domain"""
return self.domain.name
def save(self, *args, **kwargs):
"""create a link"""
# get or create the associated domain
if not self.domain:
domain = urlparse(self.url).netloc
self.domain, _ = LinkDomain.objects.get_or_create(domain=domain)
# this is never broadcast, the owning model broadcasts an update
if "broadcast" in kwargs:
del kwargs["broadcast"]
return super().save(*args, **kwargs)
class FileLink(Link):
"""a link to a file"""
book = models.ForeignKey(
"Book", on_delete=models.CASCADE, related_name="file_links", null=True
)
filetype = fields.CharField(max_length=5, activitypub_field="mediaType")
StatusChoices = [
("approved", _("Approved")),
("blocked", _("Blocked")),
("pending", _("Pending")),
]
class LinkDomain(BookWyrmModel):
"""List of domains used in links"""
domain = models.CharField(max_length=255, unique=True)
status = models.CharField(max_length=50, choices=StatusChoices, default="pending")
name = models.CharField(max_length=100)
reported_by = models.ForeignKey(
"User", blank=True, null=True, on_delete=models.SET_NULL
)
def raise_not_editable(self, viewer):
if viewer.has_perm("moderate_post"):
return
raise PermissionDenied()
def save(self, *args, **kwargs):
"""set a default name"""
if not self.name:
self.name = self.domain
super().save(*args, **kwargs)

View file

@ -1,6 +1,5 @@
""" flagged for moderation """
from django.db import models
from django.db.models import F, Q
from .base_model import BookWyrmModel
@ -13,14 +12,12 @@ class Report(BookWyrmModel):
note = models.TextField(null=True, blank=True)
user = models.ForeignKey("User", on_delete=models.PROTECT)
statuses = models.ManyToManyField("Status", blank=True)
links = models.ManyToManyField("Link", blank=True)
resolved = models.BooleanField(default=False)
class Meta:
"""don't let users report themselves"""
"""set order by default"""
constraints = [
models.CheckConstraint(check=~Q(reporter=F("user")), name="self_report")
]
ordering = ("-created_date",)

View file

@ -346,6 +346,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def delete(self, *args, **kwargs):
"""deactivate rather than delete a user"""
# pylint: disable=attribute-defined-outside-init
self.is_active = False
# skip the logic in this class's save()
super().save(*args, **kwargs)
@ -406,14 +407,6 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
self.private_key, self.public_key = create_key_pair()
return super().save(*args, **kwargs)
def to_activity(self, **kwargs):
"""override default AP serializer to add context object
idk if this is the best way to go about this"""
activity_object = super().to_activity(**kwargs)
del activity_object["@context"]
del activity_object["type"]
return activity_object
def get_current_year():
"""sets default year for annual goal to this year"""

View file

@ -9,12 +9,12 @@ from django.utils.translation import gettext_lazy as _
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
VERSION = "0.1.1"
VERSION = "0.2.0"
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "9b4cc1f7"
JS_CACHE = "a47cc2ca"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")

View file

@ -720,6 +720,10 @@ ol.ordered-list li::before {
}
}
.overflow-wrap-anywhere {
overflow-wrap: anywhere;
}
/* Threads
******************************************************************************/

View file

@ -0,0 +1,165 @@
(function () {
"use strict";
/**
* Suggest a completion as a user types
*
* Use `data-autocomplete="<completions set identifier>"`on the input field.
* specifying the trie to be used for autocomplete
*
* @example
* <input
* type="input"
* data-autocomplete="mimetype"
* >
* @param {Event} event
* @return {undefined}
*/
function autocomplete(event) {
const input = event.target;
// Get suggestions
let trie = tries[input.getAttribute("data-autocomplete")];
let suggestions = getSuggestions(input.value, trie);
const boxId = input.getAttribute("list");
// Create suggestion box, if needed
let suggestionsBox = document.getElementById(boxId);
// Clear existing suggestions
suggestionsBox.innerHTML = "";
// Populate suggestions box
suggestions.forEach((suggestion) => {
const suggestionItem = document.createElement("option");
suggestionItem.textContent = suggestion;
suggestionsBox.appendChild(suggestionItem);
});
}
function getSuggestions(input, trie) {
// Follow the trie through the provided input
input.split("").forEach((letter) => {
if (!trie) {
return;
}
trie = trie[letter];
});
if (!trie) {
return [];
}
return searchTrie(trie);
}
function searchTrie(trie) {
const options = Object.values(trie);
if (typeof trie == "string") {
return [trie];
}
return options
.map((option) => {
const newTrie = option;
if (typeof newTrie == "string") {
return [newTrie];
}
return searchTrie(newTrie);
})
.reduce((prev, next) => prev.concat(next));
}
document.querySelectorAll("[data-autocomplete]").forEach((input) => {
input.addEventListener("input", autocomplete);
});
})();
const tries = {
mimetype: {
a: {
a: {
c: "AAC",
},
z: {
w: "AZW",
},
},
d: {
a: {
i: {
s: {
y: "Daisy",
},
},
},
},
e: {
p: {
u: {
b: "ePub",
},
},
},
f: {
l: {
a: {
c: "FLAC",
},
},
},
h: {
t: {
m: {
l: "HTML",
},
},
},
m: {
4: {
a: "M4A",
b: "M4B",
},
o: {
b: {
i: "MOBI",
},
},
p: {
3: "MP3",
},
},
o: {
g: {
g: "OGG",
},
},
p: {
d: {
f: "PDF",
},
l: {
a: {
i: {
n: {
t: {
e: {
x: {
t: "Plaintext",
},
},
},
},
},
},
},
},
},
};

View file

@ -35,7 +35,7 @@ let BookWyrm = new (class {
.forEach((node) => node.addEventListener("change", this.disableIfTooLarge.bind(this)));
document
.querySelectorAll("button[data-modal-open]")
.querySelectorAll("[data-modal-open]")
.forEach((node) => node.addEventListener("click", this.handleModalButton.bind(this)));
document

View file

@ -183,7 +183,7 @@
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add_description" controls_uid=book.id focus="id_description" hide_active=True id="hide_description" %}
<div class="box is-hidden" id="add_description_{{ book.id }}">
<form name="add-description" method="POST" action="/add-description/{{ book.id }}">
<form name="add-description" method="POST" action="{% url "add-description" book.id %}">
{% csrf_token %}
<p class="fields is-grouped">
<label class="label" for="id_description_{{ book.id }}">{% trans "Description:" %}</label>
@ -319,7 +319,7 @@
</div>
</div>
</div>
<div class="column is-one-fifth">
<div class="column is-one-fifth is-clipped">
{% if book.subjects %}
<section class="content block">
<h2 class="title is-5">{% trans "Subjects" %}</h2>
@ -344,11 +344,11 @@
{% endif %}
{% if lists.exists or request.user.list_set.exists %}
<section class="content block">
<section class="content block is-clipped">
<h2 class="title is-5">{% trans "Lists" %}</h2>
<ul>
{% for list in lists %}
<li><a href="{{ list.local_path }}">{{ list.name }}</a></li>
<li><a href="{{ list.local_path }}">{{ list.name }}</a></li>
{% endfor %}
</ul>
@ -358,7 +358,7 @@
<input type="hidden" name="book" value="{{ book.id }}">
<label class="label" for="id_list">{% trans "Add to list" %}</label>
<div class="field has-addons">
<div class="select control">
<div class="select control is-clipped">
<select name="list" id="id_list">
{% for list in user.list_set.all %}
<option value="{{ list.id }}">{{ list.name }}</option>
@ -373,6 +373,10 @@
{% endif %}
</section>
{% endif %}
<section class="content block">
{% include "book/file_links/links.html" %}
</section>
</div>
</div>
@ -382,4 +386,5 @@
{% block scripts %}
<script src="{% static "js/vendor/tabs.js" %}?v={{ js_cache }}"></script>
<script src="{% static "js/autocomplete.js" %}?v={{ js_cache }}"></script>
{% endblock %}

View file

@ -0,0 +1,56 @@
{% extends 'components/modal.html' %}
{% load i18n %}
{% load static %}
{% block modal-title %}
{% trans "Add file link" %}
{% endblock %}
{% block modal-form-open %}
<form name="add-link" method="POST" action="{% url 'file-link-add' book.id %}">
{% endblock %}
{% block modal-body %}
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="added_by" value="{{ request.user.id }}">
<p class="notification">
{% trans "Links from unknown domains will need to be approved by a moderator before they are added." %}
</p>
<div class="columns">
<div class="column is-four-fifths">
<label class="label" for="id_url">{% trans "URL:" %}</label>
<input type="url" name="url" maxlength="255" class="input" required="" id="id_url" value="{% firstof file_link_form.url.value "" %}" placeholder="https://..." aria-describedby="desc_name">
{% include 'snippets/form_errors.html' with errors_list=file_link_form.url.errors id="desc_url" %}
</div>
<div class="column is-one-fifth">
<label class="label" for="id_filetype">{% trans "File type:" %}</label>
<input
type="text"
name="filetype"
maxlength="5"
class="input"
required=""
id="id_filetype"
value="{% firstof file_link_form.filetype.value '' %}"
placeholder="ePub"
list="mimetypes-list"
data-autocomplete="mimetype"
>
<datalist id="mimetypes-list"></datalist>
{% include 'snippets/form_errors.html' with errors_list=file_link_form.filetype.errors id="desc_filetype" %}
</div>
</div>
{% endblock %}
{% block modal-footer %}
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
{% if not static %}
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
{% endif %}
{% endblock %}
{% block modal-form-close %}</form>{% endblock %}

View file

@ -0,0 +1,82 @@
{% extends 'layout.html' %}
{% load i18n %}
{% load utilities %}
{% block title %}{% trans "Edit links" %}{% endblock %}
{% block content %}
<header class="block content">
<h1 class="title">
{% blocktrans with title=book|book_title %}
Links for "<em>{{ title }}</em>"
{% endblocktrans %}
</h1>
</header>
<nav class="breadcrumb subtitle" aria-label="breadcrumbs">
<ul>
<li><a href="{% url 'book' book.id %}">{{ book|book_title }}</a></li>
<li class="is-active">
<a href="#" aria-current="page">
{% trans "Edit links" %}
</a>
</li>
</ul>
</nav>
<section class="block content">
<div class="table-container">
<table class="is-striped is-fullwidth">
<tr>
<th>{% trans "URL" %}</th>
<th>{% trans "Added by" %}</th>
<th>{% trans "Filetype" %}</th>
<th>{% trans "Domain" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
{% for link in book.file_links.all %}
<tr>
<td class="overflow-wrap-anywhere">
<a href="{{ link.url }}" target="_blank" rel="noopener">{{ link.url }}</a>
</td>
<td>
<a href="{% url 'user-feed' link.added_by.id %}">{{ link.added_by.display_name }}</a>
</td>
<td>
{{ link.filelink.filetype }}
</td>
<td>
{{ link.domain.name }} ({{ link.domain.get_status_display }})
<p>
<a href="{% url 'report-link' link.added_by.id link.id %}">{% trans "Report spam" %}</a>
</p>
</td>
<td>
<form name="delete-link-{{ link.id }}" class="control" method="post" action="{% url 'file-link' book.id link.id %}">
{% csrf_token %}
<button class="button is-danger is-light" type="submit">Delete link</button>
</form>
</td>
</tr>
{% endfor %}
{% if not book.file_links.exists %}
<tr>
<td colspan="5"><em>{% trans "No links available for this book." %}</em></td>
</tr>
{% endif %}
</table>
</div>
{% url 'file-link-add' book.id as fallback_url %}
<form name="add-link" method="get" action="{{ fallback_url }}">
<button class="button" type="submit" data-modal-open="add-links">
<span class="icon icon-plus m-0-mobile" aria-hidden="true"></span>
<span class="is-sr-only-mobile">
{% trans "Add link to file" %}
</span>
</button>
</form>
</section>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% extends 'layout.html' %}
{% load i18n %}
{% load static %}
{% block title %}
{% trans "File Links" %}
{% endblock %}
{% block content %}
{% include "book/file_links/add_link_modal.html" with book=book active=True static=True id="file-link" %}
{% endblock %}
{% block scripts %}
<script src="{% static "js/autocomplete.js" %}?v={{ js_cache }}"></script>
{% endblock %}

View file

@ -0,0 +1,52 @@
{% load i18n %}
{% load bookwyrm_tags %}
{% load utilities %}
{% get_book_file_links book as links %}
{% if links.exists or request.user.is_authenticated %}
<header class="columns is-mobile mb-0">
<div class="column">
<h2 class="title is-5">{% trans "Get a copy" %}</h2>
</div>
{% if can_edit_book %}
<div class="column is-narrow">
{% url 'file-link-add' book.id as fallback_url %}
<form name="add-link" method="get" action="{{ fallback_url }}">
<button class="button is-small" type="submit" data-modal-open="add-links">
<span class="icon icon-plus">
<span class="is-sr-only">
{% trans "Add link to file" %}
</span>
</span>
</button>
</form>
</div>
{% endif %}
</header>
{% if links %}
<ul class="mt-0">
{% for link in links.all %}
{% join "verify" link.id as verify_modal %}
<li>
<a href="{{ link.url }}" rel="noopener" target="_blank" title="{{ link.url }}" data-modal-open="{{ verify_modal }}">{{ link.name }}</a>
({{ link.filetype }})
</li>
{% endfor %}
</ul>
{% for link in links.all %}
{% join "verify" link.id as verify_modal %}
{% include "book/file_links/verification_modal.html" with id=verify_modal %}
{% endfor %}
{% else %}
<em>{% trans "No links available" %}</em>
{% endif %}
{% if can_edit_book and links.exists %}
<a href="{% url 'file-link' book.id %}" class="is-pulled-right">
<span class="icon icon-pencil" aria-hidden="true"></span>
<span>{% trans "Edit links" %}</span>
</a>
{% include 'book/file_links/add_link_modal.html' with book=book id="add-links" %}
{% endif %}
{% endif %}

View file

@ -0,0 +1,29 @@
{% extends 'components/modal.html' %}
{% load i18n %}
{% block modal-title %}
{% trans "Leaving BookWyrm" %}
{% endblock %}
{% block modal-body %}
{% blocktrans trimmed with link_url=link.url %}
This link is taking you to: <code>{{ link_url }}</code>.<br>
Is that where you'd like to go?
{% endblocktrans %}
{% endblock %}
{% block modal-footer %}
<a href="{{ link.url }}" target="_blank" rel="noopener" class="button is-primary">{% trans "Continue" %}</a>
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
{% if request.user.is_authenticated %}
<div class="has-text-right is-flex-grow-1">
<a href="{% url 'report-link' link.added_by.id link.id %}">{% trans "Report spam" %}</a>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,10 @@
{% extends "layout.html" %}
{% load i18n %}
{% block title %}
{% trans "Report" %}
{% endblock %}
{% block content %}
{% include "snippets/report_modal.html" with user=user active=True static=True %}
{% endblock %}

View file

@ -48,6 +48,17 @@
</a>
</div>
{% endif %}
{% if pending_domains %}
<div class="column">
<a href="{% url 'settings-link-domain' %}" class="notification is-primary is-block">
{% blocktrans trimmed count counter=pending_domains with display_count=pending_domains|intcomma %}
{{ display_count }} domain needs review
{% plural %}
{{ display_count }} domains need review
{% endblocktrans %}
</a>
</div>
{% endif %}
{% if not site.allow_registration and site.allow_invite_requests and invite_requests %}
<div class="column">
<a href="{% url 'settings-invite-requests' %}" class="notification is-block is-success is-light">

View file

@ -62,6 +62,10 @@
{% url 'settings-ip-blocks' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "IP Address Blocklist" %}</a>
</li>
<li>
{% url 'settings-link-domain' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Link Domains" %}</a>
</li>
</ul>
{% endif %}
{% if perms.bookwyrm.edit_instance_settings %}

View file

@ -0,0 +1,25 @@
{% extends 'components/modal.html' %}
{% load i18n %}
{% block modal-title %}
{% blocktrans with url=domain.domain %}Set display name for {{ url }}{% endblocktrans %}
{% endblock %}
{% block modal-form-open %}
<form name="edit-domain-{{ domain.id }}" method="post" action="{% url 'settings-link-domain' status=status domain_id=domain.id %}">
{% endblock %}
{% block modal-body %}
{% csrf_token %}
<label class="label" for="id_name">{% trans "Name:" %}</label>
<div class="control">
<input type="text" id="id_name" class="input" name="name" value="{{ domain.name }}" required>
</div>
{% endblock %}
{% block modal-footer %}
<button type="submit" class="button is-primary">{% trans "Set" %}</button>
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
{% endblock %}
{% block modal-form-close %}</form>{% endblock %}

View file

@ -0,0 +1,106 @@
{% extends 'settings/layout.html' %}
{% load i18n %}
{% load utilities %}
{% block title %}{% trans "Link Domains" %}{% endblock %}
{% block header %}{% trans "Link Domains" %}{% endblock %}
{% block panel %}
<p class="notification block">
{% trans "Link domains must be approved before they are shown on book pages. Please make sure that the domains are not hosting spam, malicious code, or deceptive links before approving." %}
</p>
<div class="block">
<div class="tabs">
<ul>
{% url 'settings-link-domain' status='pending' as url %}
<li {% if request.path in url %}class="is-active" aria-current="page"{% endif %}>
<a href="{{ url }}">{% trans "Pending" %} ({{ counts.pending }})</a>
</li>
{% url 'settings-link-domain' status='approved' as url %}
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
<a href="{{ url }}">{% trans "Approved" %} ({{ counts.approved }})</a>
</li>
{% url 'settings-link-domain' status='blocked' as url %}
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
<a href="{{ url }}">{% trans "Blocked" %} ({{ counts.blocked }})</a>
</li>
</ul>
</div>
{% for domain in domains %}
{% join "domain" domain.id as domain_modal %}
<div class="box content" id="{{ domain.id }}">
<div class="columns is-mobile">
<header class="column">
<h2 class="title is-5">
{{ domain.name }}
(<a href="http://{{ domain.domain }}" target="_blank" rel="noopener">{{ domain.domain }}</a>)
</h2>
</header>
<div class="column is-narrow">
<button type="button" class="button" data-modal-open="{{ domain_modal }}">
<span class="icon icon-pencil m-0-mobile" aria-hidden="treu"></span>
<span>{% trans "Set display name" %}</span>
</button>
</div>
</div>
<div class="block">
<details class="details-panel">
<summary>
<span role="heading" aria-level="3" class="title is-6 mb-0">
{% trans "View links" %}
({{ domain.links.count }})
</span>
<span class="details-close icon icon-x" aria-hidden></span>
</summary>
<div class="table-container mt-4">
{% include "settings/link_domains/link_table.html" with links=domain.links.all|slice:10 %}
</div>
</details>
</div>
{% include "settings/link_domains/edit_domain_modal.html" with domain=domain id=domain_modal %}
<div class="field has-addons">
{% if status != "approved" %}
<form
name="domain-{{ domains.id }}-approve"
class="control"
method="post"
action="{% url 'settings-link-domain-status' domain.id 'approved' %}"
>
{% csrf_token %}
<button type="submit" class="button is-success is-light">{% trans "Approve" %}</button>
</form>
{% endif %}
{% if status != "blocked" %}
<form
name="domain-{{ domains.id }}-block"
class="control"
method="post"
action="{% url 'settings-link-domain-status' domain.id 'blocked' %}"
>
{% csrf_token %}
<button type="submit" class="button is-danger is-light">{% trans "Block" %}</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
{% if not domains.exists %}
{% if status == "approved" %}
<em>{% trans "No domains currently approved" %}</em>
{% elif status == "pending" %}
<em>{% trans "No domains currently pending" %}</em>
{% else %}
<em>{% trans "No domains currently blocked" %}</em>
{% endif %}
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,42 @@
{% load i18n %}
{% load utilities %}
<table class="is-striped is-fullwidth">
<tr>
<th>{% trans "URL" %}</th>
<th>{% trans "Added by" %}</th>
<th>{% trans "Filetype" %}</th>
<th>{% trans "Book" %}</th>
{% block additional_headers %}{% endblock %}
</tr>
{% for link in links %}
<tr>
<td class="overflow-wrap-anywhere">
<a href="{{ link.url }}" target="_blank" rel="noopener">{{ link.url }}</a>
</td>
<td>
<a href="{% url 'settings-user' link.added_by.id %}">@{{ link.added_by|username }}</a>
</td>
<td>
{% if link.filelink.filetype %}
{{ link.filelink.filetype }}
{% endif %}
</td>
<td>
{% if link.filelink.book %}
{% with book=link.filelink.book %}
{% include "snippets/book_titleby.html" with book=book %}
{% endwith %}
{% endif %}
</td>
{% block additional_data %}{% endblock %}
</tr>
{% endfor %}
{% if not links %}
<tr>
<td colspan="7"><em>{% trans "No links available for this domain." %}</em></td>
</tr>
{% endif %}
</table>

View file

@ -2,10 +2,12 @@
{% load i18n %}
{% load humanize %}
{% block title %}{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}{% endblock %}
{% block title %}
{% include "settings/reports/report_header.html" with report=report %}
{% endblock %}
{% block header %}
{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}
{% include "settings/reports/report_header.html" with report=report %}
<a href="{% url 'settings-reports' %}" class="has-text-weight-normal help">{% trans "Back to reports" %}</a>
{% endblock %}
@ -15,6 +17,36 @@
{% include 'settings/reports/report_preview.html' with report=report %}
</div>
{% if report.statuses.exists %}
<div class="block">
<h3 class="title is-4">{% trans "Reported statuses" %}</h3>
<ul>
{% for status in report.statuses.select_subclasses.all %}
<li>
{% if status.deleted %}
<em>{% trans "Status has been deleted" %}</em>
{% else %}
{% include 'snippets/status/status.html' with status=status moderation_mode=True %}
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if report.links.exists %}
<div class="block">
<h3 class="title is-4">{% trans "Reported links" %}</h3>
<div class="card block">
<div class="card-content content">
<div class="table-container">
{% include "settings/reports/report_links_table.html" with links=report.links.all %}
</div>
</div>
</div>
</div>
{% endif %}
{% include 'settings/users/user_info.html' with user=report.user %}
{% include 'settings/users/user_moderation_actions.html' with user=report.user %}
@ -41,23 +73,4 @@
<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 "Status 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,22 @@
{% load i18n %}
{% load utilities %}
{% if report.statuses.exists %}
{% blocktrans trimmed with report_id=report.id username=report.user|username %}
Report #{{ report_id }}: Status posted by @{{ username }}
{% endblocktrans %}
{% elif report.links.exists %}
{% blocktrans trimmed with report_id=report.id username=report.user|username %}
Report #{{ report_id }}: Link added by @{{ username }}
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with report_id=report.id username=report.user|username %}
Report #{{ report_id }}: User @{{ username }}
{% endblocktrans %}
{% endif %}

View file

@ -0,0 +1,20 @@
{% extends "settings/link_domains/link_table.html" %}
{% load i18n %}
{% block additional_headers %}
<th>{% trans "Domain" %}</th>
<th>{% trans "Actions" %}</th>
{% endblock %}
{% block additional_data %}
<td>
<a href="{% url 'settings-link-domain' 'pending' %}#{{ link.domain.id }}">
{{ link.domain.domain }}
</a>
</td>
<td>
<form>
<button type="submit" class="button is-danger is-light">{% trans "Block domain" %}</button>
</form>
</td>
{% endblock %}

View file

@ -1,9 +1,13 @@
{% extends 'components/card.html' %}
{% load i18n %}
{% load humanize %}
{% load utilities %}
{% 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>
<a href="{% url 'settings-report' report.id %}">
{% include "settings/reports/report_header.html" with report=report %}
</a>
</h2>
{% endblock %}
@ -17,7 +21,7 @@
{% 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>
<p>{% blocktrans with username=report.reporter|username path=report.reporter.local_path %}Reported by <a href="{{ path }}">@{{ username }}</a>{% endblocktrans %}</p>
</div>
<div class="card-footer-item">
{{ report.created_date | naturaltime }}

View file

@ -5,71 +5,73 @@
{% trans "Permanently deleted" %}
</div>
{% else %}
<h3>{% trans "Actions" %}</h3>
<h3>{% trans "User Actions" %}</h3>
<div class="is-flex">
{% if user.is_active %}
<p class="mr-1">
<a class="button" href="{% url 'direct-messages-user' user.username %}">{% trans "Send direct message" %}</a>
</p>
{% endif %}
<div class="box">
<div class="is-flex">
{% if user.is_active %}
<p class="mr-1">
<a class="button" href="{% url 'direct-messages-user' user.username %}">{% trans "Send direct message" %}</a>
</p>
{% endif %}
{% if user.is_active or user.deactivation_reason == "pending" %}
<form name="suspend" method="post" action="{% url 'settings-report-suspend' user.id %}" class="mr-1">
{% csrf_token %}
<button type="submit" class="button is-danger is-light">{% trans "Suspend user" %}</button>
</form>
{% else %}
<form name="unsuspend" method="post" action="{% url 'settings-report-unsuspend' user.id %}" class="mr-1">
{% csrf_token %}
<button class="button">{% trans "Un-suspend user" %}</button>
</form>
{% if user.is_active or user.deactivation_reason == "pending" %}
<form name="suspend" method="post" action="{% url 'settings-report-suspend' user.id %}" class="mr-1">
{% csrf_token %}
<button type="submit" class="button is-danger is-light">{% trans "Suspend user" %}</button>
</form>
{% else %}
<form name="unsuspend" method="post" action="{% url 'settings-report-unsuspend' user.id %}" class="mr-1">
{% csrf_token %}
<button class="button">{% trans "Un-suspend user" %}</button>
</form>
{% endif %}
{% if user.local %}
<div>
{% trans "Permanently delete user" as button_text %}
{% include "snippets/toggle/open_button.html" with controls_text="delete_user" text=button_text class="is-danger is-light" %}
</div>
{% endif %}
</div>
{% if user.local %}
<div>
{% include "settings/users/delete_user_form.html" with controls_text="delete_user" class="mt-2 mb-2" %}
</div>
{% endif %}
{% if user.local %}
<div>
{% trans "Permanently delete user" as button_text %}
{% include "snippets/toggle/open_button.html" with controls_text="delete_user" text=button_text class="is-danger is-light" %}
<form name="permission" method="post" action="{% url 'settings-user' user.id %}">
{% csrf_token %}
<label class="label" for="id_user_group">{% trans "Access level:" %}</label>
{% if group_form.non_field_errors %}
{{ group_form.non_field_errors }}
{% endif %}
{% with group=user.groups.first %}
<div class="select">
<select name="groups" id="id_user_group" aria-describedby="desc_user_group">
{% for value, name in group_form.fields.groups.choices %}
<option value="{{ value }}" {% if name == group.name %}selected{% endif %}>
{{ name|title }}
</option>
{% endfor %}
<option value="" {% if not group %}selected{% endif %}>
User
</option>
</select>
</div>
{% include 'snippets/form_errors.html' with errors_list=group_form.groups.errors id="desc_user_group" %}
{% endwith %}
<button class="button">
{% trans "Save" %}
</button>
</form>
</div>
{% endif %}
</div>
{% if user.local %}
<div>
{% include "settings/users/delete_user_form.html" with controls_text="delete_user" class="mt-2 mb-2" %}
</div>
{% endif %}
{% if user.local %}
<div>
<form name="permission" method="post" action="{% url 'settings-user' user.id %}">
{% csrf_token %}
<label class="label" for="id_user_group">{% trans "Access level:" %}</label>
{% if group_form.non_field_errors %}
{{ group_form.non_field_errors }}
{% endif %}
{% with group=user.groups.first %}
<div class="select">
<select name="groups" id="id_user_group" aria-describedby="desc_user_group">
{% for value, name in group_form.fields.groups.choices %}
<option value="{{ value }}" {% if name == group.name %}selected{% endif %}>
{{ name|title }}
</option>
{% endfor %}
<option value="" {% if not group %}selected{% endif %}>
User
</option>
</select>
</div>
{% include 'snippets/form_errors.html' with errors_list=group_form.groups.errors id="desc_user_group" %}
{% endwith %}
<button class="button">
{% trans "Save" %}
</button>
</form>
</div>
{% endif %}
{% endif %}
</div>

View file

@ -12,6 +12,6 @@
>
{% trans "Report" %}
</button>
{% include 'snippets/report_modal.html' with user=user reporter=request.user id=modal_id %}
{% include 'snippets/report_modal.html' with user=user id=modal_id status=status.id %}
{% endwith %}

View file

@ -1,9 +1,16 @@
{% extends 'components/modal.html' %}
{% load i18n %}
{% load utilities %}
{% load humanize %}
{% block modal-title %}
{% blocktrans with username=user.username %}Report @{{ username }}{% endblocktrans %}
{% if status %}
{% blocktrans with username=user|username %}Report @{{ username }}'s status{% endblocktrans %}
{% elif link %}
{% blocktrans with domain=link.domain.domain %}Report {{ domain }} link{% endblocktrans %}
{% else %}
{% blocktrans with username=user|username %}Report @{{ username }}{% endblocktrans %}
{% endif %}
{% endblock %}
{% block modal-form-open %}
@ -13,14 +20,22 @@
{% block modal-body %}
{% csrf_token %}
<input type="hidden" name="reporter" value="{{ reporter.id }}">
<input type="hidden" name="reporter" value="{{ request.user.id }}">
<input type="hidden" name="user" value="{{ user.id }}">
{% if status %}
<input type="hidden" name="statuses" value="{{ status.id }}">
<input type="hidden" name="statuses" value="{{ status_id }}">
{% endif %}
{% if link %}
<input type="hidden" name="links" value="{{ link.id }}">
{% endif %}
<section class="content">
<p>{% blocktrans with site_name=site.name %}This report will be sent to {{ site_name }}'s moderators for review.{% endblocktrans %}</p>
<p class="notification">
{% blocktrans with site_name=site.name %}This report will be sent to {{ site_name }}'s moderators for review.{% endblocktrans %}
{% if link %}
{% trans "Links from this domain will be removed until your report has been reviewed." %}
{% endif %}
</p>
<div class="control">
<label class="label" for="id_{{ controls_uid }}_report_note">
{% trans "More info about this report:" %}
@ -35,7 +50,9 @@
{% block modal-footer %}
<button class="button is-success" type="submit">{% trans "Submit" %}</button>
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
{% if not static %}
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
{% endif %}
{% endblock %}

View file

@ -198,3 +198,9 @@ def suggested_books(context):
# this happens here instead of in the view so that the template snippet can
# be cached in the template
return get_suggested_books(context["request"].user)
@register.simple_tag(takes_context=False)
def get_book_file_links(book):
"""links for a book"""
return book.file_links.filter(domain__status="approved")

View file

@ -0,0 +1,41 @@
""" testing models """
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
class Link(TestCase):
"""some activitypub oddness ahead"""
def setUp(self):
"""look, a list"""
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
), patch("bookwyrm.lists_stream.populate_lists_task.delay"):
self.local_user = models.User.objects.create_user(
"mouse", "mouse@mouse.mouse", "mouseword", local=True, localname="mouse"
)
work = models.Work.objects.create(title="hello")
self.book = models.Edition.objects.create(title="hi", parent_work=work)
def test_create_domain(self, _):
"""generated default name"""
domain = models.LinkDomain.objects.create(domain="beep.com")
self.assertEqual(domain.name, "beep.com")
self.assertEqual(domain.status, "pending")
def test_create_link_new_domain(self, _):
"""generates link and sets domain"""
link = models.Link.objects.create(url="https://www.hello.com/hi-there")
self.assertEqual(link.domain.domain, "www.hello.com")
self.assertEqual(link.name, "www.hello.com")
def test_create_link_existing_domain(self, _):
"""generate link with a known domain"""
domain = models.LinkDomain.objects.create(domain="www.hello.com", name="Hi")
link = models.Link.objects.create(url="https://www.hello.com/hi-there")
self.assertEqual(link.domain, domain)
self.assertEqual(link.name, "Hi")

View file

@ -0,0 +1,80 @@
""" 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 models, views
from bookwyrm.tests.validate_html import validate_html
class LinkDomainViews(TestCase):
"""every response to a get request, html or json"""
def setUp(self):
"""we need basic test data and mocks"""
self.factory = RequestFactory()
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
), patch("bookwyrm.lists_stream.populate_lists_task.delay"):
self.local_user = models.User.objects.create_user(
"mouse@local.com",
"mouse@mouse.mouse",
"password",
local=True,
localname="mouse",
)
self.book = models.Edition.objects.create(title="hello")
models.FileLink.objects.create(
book=self.book,
url="https://beep.com/book/1",
added_by=self.local_user,
)
models.SiteSettings.objects.create()
def test_domain_page_get(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.LinkDomain.as_view()
request = self.factory.get("")
request.user = self.local_user
request.user.is_superuser = True
result = view(request, "pending")
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_domain_page_post(self):
"""there are so many views, this just makes sure it LOADS"""
domain = models.LinkDomain.objects.get(domain="beep.com")
self.assertEqual(domain.name, "beep.com")
view = views.LinkDomain.as_view()
request = self.factory.post("", {"name": "ugh"})
request.user = self.local_user
request.user.is_superuser = True
result = view(request, "pending", domain.id)
self.assertEqual(result.status_code, 302)
domain.refresh_from_db()
self.assertEqual(domain.name, "ugh")
def test_domain_page_set_status(self):
"""there are so many views, this just makes sure it LOADS"""
domain = models.LinkDomain.objects.get(domain="beep.com")
self.assertEqual(domain.status, "pending")
view = views.update_domain_status
request = self.factory.post("")
request.user = self.local_user
request.user.is_superuser = True
result = view(request, domain.id, "approved")
self.assertEqual(result.status_code, 302)
domain.refresh_from_db()
self.assertEqual(domain.status, "approved")

View file

@ -37,7 +37,7 @@ class ReportViews(TestCase):
def test_reports_page(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.Reports.as_view()
view = views.ReportsAdmin.as_view()
request = self.factory.get("")
request.user = self.local_user
request.user.is_superuser = True
@ -49,7 +49,7 @@ class ReportViews(TestCase):
def test_reports_page_with_data(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.Reports.as_view()
view = views.ReportsAdmin.as_view()
request = self.factory.get("")
request.user = self.local_user
request.user.is_superuser = True
@ -62,7 +62,7 @@ class ReportViews(TestCase):
def test_report_page(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.Report.as_view()
view = views.ReportAdmin.as_view()
request = self.factory.get("")
request.user = self.local_user
request.user.is_superuser = True
@ -76,7 +76,7 @@ class ReportViews(TestCase):
def test_report_comment(self):
"""comment on a report"""
view = views.Report.as_view()
view = views.ReportAdmin.as_view()
request = self.factory.post("", {"note": "hi"})
request.user = self.local_user
request.user.is_superuser = True
@ -97,7 +97,7 @@ class ReportViews(TestCase):
request = self.factory.post("", form.data)
request.user = self.local_user
views.make_report(request)
views.Report.as_view()(request)
report = models.Report.objects.get()
self.assertEqual(report.reporter, self.local_user)

View file

@ -0,0 +1,89 @@
""" test for app action functionality """
import json
from unittest.mock import patch
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import forms, models, views
from bookwyrm.tests.validate_html import validate_html
class LinkViews(TestCase):
"""books books books"""
def setUp(self):
"""we need basic test data and mocks"""
self.factory = RequestFactory()
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
):
self.local_user = models.User.objects.create_user(
"mouse@local.com",
"mouse@mouse.com",
"mouseword",
local=True,
localname="mouse",
remote_id="https://example.com/users/mouse",
)
group = Group.objects.create(name="editor")
group.permissions.add(
Permission.objects.create(
name="edit_book",
codename="edit_book",
content_type=ContentType.objects.get_for_model(models.User),
).id
)
self.local_user.groups.add(group)
self.work = models.Work.objects.create(title="Test Work")
self.book = models.Edition.objects.create(
title="Example Edition",
remote_id="https://example.com/book/1",
parent_work=self.work,
)
models.SiteSettings.objects.create()
def test_add_link_page(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.AddFileLink.as_view()
request = self.factory.get("")
request.user = self.local_user
result = view(request, self.book.id)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_add_link_post(self, *_):
"""there are so many views, this just makes sure it LOADS"""
view = views.AddFileLink.as_view()
form = forms.FileLinkForm()
form.data["url"] = "https://www.example.com"
form.data["filetype"] = "HTML"
form.data["book"] = self.book.id
form.data["added_by"] = self.local_user.id
request = self.factory.post("", form.data)
request.user = self.local_user
with patch(
"bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"
) as mock:
view(request, self.book.id)
self.assertEqual(mock.call_count, 1)
activity = json.loads(mock.call_args[1]["args"][1])
self.assertEqual(activity["type"], "Update")
self.assertEqual(activity["object"]["type"], "Edition")
link = models.FileLink.objects.get()
self.assertEqual(link.name, "www.example.com")
self.assertEqual(link.url, "https://www.example.com")
self.assertEqual(link.filetype, "HTML")
self.book.refresh_from_db()
self.assertEqual(self.book.file_links.first(), link)

View file

@ -6,6 +6,7 @@ from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models, views
from bookwyrm.activitypub.base_activity import set_related_field
# pylint: disable=too-many-public-methods
@ -147,6 +148,48 @@ class InboxUpdate(TestCase):
self.assertEqual(book.title, "Piranesi")
self.assertEqual(book.last_edited_by, self.remote_user)
def test_update_edition_links(self):
"""add links to edition"""
datafile = pathlib.Path(__file__).parent.joinpath("../../data/bw_edition.json")
bookdata = json.loads(datafile.read_bytes())
del bookdata["authors"]
# pylint: disable=line-too-long
link_data = {
"href": "https://openlibrary.org/books/OL11645413M/Queen_Victoria/daisy",
"mediaType": "Daisy",
"attributedTo": self.remote_user.remote_id,
}
bookdata["fileLinks"] = [link_data]
models.Work.objects.create(
title="Test Work", remote_id="https://bookwyrm.social/book/5988"
)
book = models.Edition.objects.create(
title="Test Book", remote_id="https://bookwyrm.social/book/5989"
)
self.assertFalse(book.file_links.exists())
with patch(
"bookwyrm.activitypub.base_activity.set_related_field.delay"
) as mock:
views.inbox.activity_task(
{
"type": "Update",
"to": [],
"cc": [],
"actor": "hi",
"id": "sdkjf",
"object": bookdata,
}
)
args = mock.call_args[0]
self.assertEqual(args[0], "FileLink")
self.assertEqual(args[1], "Edition")
self.assertEqual(args[2], "book")
self.assertEqual(args[3], book.remote_id)
self.assertEqual(args[4], link_data)
# idk how to test that related name works, because of the transaction
def test_update_work(self):
"""update an existing edition"""
datafile = pathlib.Path(__file__).parent.joinpath("../../data/bw_work.json")

View file

@ -158,6 +158,27 @@ urlpatterns = [
views.EmailBlocklist.as_view(),
name="settings-email-blocks-delete",
),
re_path(
r"^setting/link-domains/?$",
views.LinkDomain.as_view(),
name="settings-link-domain",
),
re_path(
r"^setting/link-domains/(?P<status>(pending|approved|blocked))/?$",
views.LinkDomain.as_view(),
name="settings-link-domain",
),
# pylint: disable=line-too-long
re_path(
r"^setting/link-domains/(?P<status>(pending|approved|blocked))/(?P<domain_id>\d+)/?$",
views.LinkDomain.as_view(),
name="settings-link-domain",
),
re_path(
r"^setting/link-domains/(?P<domain_id>\d+)/(?P<status>(pending|approved|blocked))/?$",
views.update_domain_status,
name="settings-link-domain-status",
),
re_path(
r"^settings/ip-blocklist/?$",
views.IPBlocklist.as_view(),
@ -169,10 +190,12 @@ urlpatterns = [
name="settings-ip-blocks-delete",
),
# moderation
re_path(r"^settings/reports/?$", views.Reports.as_view(), name="settings-reports"),
re_path(
r"^settings/reports/?$", views.ReportsAdmin.as_view(), name="settings-reports"
),
re_path(
r"^settings/reports/(?P<report_id>\d+)/?$",
views.Report.as_view(),
views.ReportAdmin.as_view(),
name="settings-report",
),
re_path(
@ -195,7 +218,18 @@ urlpatterns = [
views.resolve_report,
name="settings-report-resolve",
),
re_path(r"^report/?$", views.make_report, name="report"),
re_path(r"^report/?$", views.Report.as_view(), name="report"),
re_path(r"^report/(?P<user_id>\d+)/?$", views.Report.as_view(), name="report"),
re_path(
r"^report/(?P<user_id>\d+)/status/(?P<status_id>\d+)?$",
views.Report.as_view(),
name="report-status",
),
re_path(
r"^report/(?P<user_id>\d+)/link/(?P<link_id>\d+)?$",
views.Report.as_view(),
name="report-link",
),
# landing pages
re_path(r"^about/?$", views.about, name="about"),
re_path(r"^privacy/?$", views.privacy, name="privacy"),
@ -430,7 +464,24 @@ urlpatterns = [
re_path(
r"^upload-cover/(?P<book_id>\d+)/?$", views.upload_cover, name="upload-cover"
),
re_path(r"^add-description/(?P<book_id>\d+)/?$", views.add_description),
re_path(
r"^add-description/(?P<book_id>\d+)/?$",
views.add_description,
name="add-description",
),
re_path(
rf"{BOOK_PATH}/filelink/?$", views.BookFileLinks.as_view(), name="file-link"
),
re_path(
rf"{BOOK_PATH}/filelink/(?P<link_id>\d+)/delete/?$",
views.BookFileLinks.as_view(),
name="file-link",
),
re_path(
rf"{BOOK_PATH}/filelink/add/?$",
views.AddFileLink.as_view(),
name="file-link-add",
),
re_path(r"^resolve-book/?$", views.resolve_book, name="resolve-book"),
re_path(r"^switch-edition/?$", views.switch_edition, name="switch-edition"),
re_path(

View file

@ -9,10 +9,10 @@ from .admin.email_blocklist import EmailBlocklist
from .admin.ip_blocklist import IPBlocklist
from .admin.invite import ManageInvites, Invite, InviteRequest
from .admin.invite import ManageInviteRequests, ignore_invite_request
from .admin.link_domains import LinkDomain, update_domain_status
from .admin.reports import (
Report,
Reports,
make_report,
ReportAdmin,
ReportsAdmin,
resolve_report,
suspend_user,
unsuspend_user,
@ -28,10 +28,16 @@ from .preferences.delete_user import DeleteUser
from .preferences.block import Block, unblock
# books
from .books.books import Book, upload_cover, add_description, resolve_book
from .books.books import (
Book,
upload_cover,
add_description,
resolve_book,
)
from .books.books import update_book_from_remote
from .books.edit_book import EditBook, ConfirmEditBook
from .books.editions import Editions, switch_edition
from .books.links import BookFileLinks, AddFileLink
# landing
from .landing.about import about, privacy, conduct
@ -90,6 +96,7 @@ from .notifications import Notifications
from .outbox import Outbox
from .reading import ReadThrough, delete_readthrough, delete_progressupdate
from .reading import ReadingStatus
from .report import Report
from .rss_feed import RssFeed
from .search import Search
from .status import CreateStatus, EditStatus, DeleteStatus, update_progress

View file

@ -96,6 +96,9 @@ class Dashboard(View):
"statuses": status_queryset.count(),
"works": models.Work.objects.count(),
"reports": models.Report.objects.filter(resolved=False).count(),
"pending_domains": models.LinkDomain.objects.filter(
status="pending"
).count(),
"invite_requests": models.InviteRequest.objects.filter(
ignored=False, invite_sent=False
).count(),

View file

@ -0,0 +1,55 @@
""" Manage link domains"""
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",
)
class LinkDomain(View):
"""Moderate links"""
def get(self, request, status="pending"):
"""view pending domains"""
data = {
"domains": models.LinkDomain.objects.filter(status=status).prefetch_related(
"links"
),
"counts": {
"pending": models.LinkDomain.objects.filter(status="pending").count(),
"approved": models.LinkDomain.objects.filter(status="approved").count(),
"blocked": models.LinkDomain.objects.filter(status="blocked").count(),
},
"form": forms.EmailBlocklistForm(),
"status": status,
}
return TemplateResponse(
request, "settings/link_domains/link_domains.html", data
)
def post(self, request, status, domain_id):
"""Set display name"""
domain = get_object_or_404(models.LinkDomain, id=domain_id)
form = forms.LinkDomainForm(request.POST, instance=domain)
form.save()
return redirect("settings-link-domain", status=status)
@require_POST
@login_required
def update_domain_status(request, domain_id, status):
"""This domain seems fine"""
domain = get_object_or_404(models.LinkDomain, id=domain_id)
domain.raise_not_editable(request.user)
domain.status = status
domain.save()
return redirect("settings-link-domain", status="pending")

View file

@ -5,9 +5,8 @@ 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 emailing, forms, models
from bookwyrm import forms, models
# pylint: disable=no-self-use
@ -20,7 +19,7 @@ from bookwyrm import emailing, forms, models
permission_required("bookwyrm.moderate_post", raise_exception=True),
name="dispatch",
)
class Reports(View):
class ReportsAdmin(View):
"""list of reports"""
def get(self, request):
@ -52,7 +51,7 @@ class Reports(View):
permission_required("bookwyrm.moderate_post", raise_exception=True),
name="dispatch",
)
class Report(View):
class ReportAdmin(View):
"""view a specific report"""
def get(self, request, report_id):
@ -132,16 +131,3 @@ def resolve_report(_, report_id):
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():
raise ValueError(form.errors)
report = form.save()
emailing.moderation_report_email(report)
return redirect(request.headers.get("Referer", "/"))

View file

@ -40,7 +40,7 @@ class Book(View):
.filter(Q(id=book_id) | Q(parent_work__id=book_id))
.order_by("-edition_rank")
.select_related("parent_work")
.prefetch_related("authors")
.prefetch_related("authors", "file_links")
.first()
)
@ -84,6 +84,7 @@ class Book(View):
}
if request.user.is_authenticated:
data["file_link_form"] = forms.FileLinkForm()
readthroughs = models.ReadThrough.objects.filter(
user=request.user,
book=book,

View file

@ -0,0 +1,62 @@
""" the good stuff! the books! """
from django.contrib.auth.decorators import login_required, permission_required
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.views import View
from django.utils.decorators import method_decorator
from bookwyrm import forms, models
# pylint: disable=no-self-use
class BookFileLinks(View):
"""View all links"""
def get(self, request, book_id):
"""view links"""
book = get_object_or_404(models.Edition, id=book_id)
return TemplateResponse(
request, "book/file_links/edit_links.html", {"book": book}
)
def post(self, request, book_id, link_id):
"""delete link"""
link = get_object_or_404(models.FileLink, id=link_id, book=book_id)
link.delete()
return self.get(request, book_id)
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.edit_book", raise_exception=True), name="dispatch"
)
class AddFileLink(View):
"""a book! this is the stuff"""
def get(self, request, book_id):
"""Create link form"""
book = get_object_or_404(models.Edition, id=book_id)
data = {
"file_link_form": forms.FileLinkForm(),
"book": book,
}
return TemplateResponse(request, "book/file_links/file_link_page.html", data)
@transaction.atomic
def post(self, request, book_id, link_id=None):
"""Add a link to a copy of the book you can read"""
book = get_object_or_404(models.Book.objects.select_subclasses(), id=book_id)
link = get_object_or_404(models.FileLink, id=link_id) if link_id else None
form = forms.FileLinkForm(request.POST, instance=link)
if not form.is_valid():
data = {"file_link_form": form, "book": book}
return TemplateResponse(
request, "book/file_links/file_link_page.html", data
)
link = form.save()
book.file_links.add(link)
book.last_edited_by = request.user
book.save()
return redirect("book", book.id)

39
bookwyrm/views/report.py Normal file
View file

@ -0,0 +1,39 @@
""" moderation via flagged posts and users """
from django.contrib.auth.decorators import login_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 bookwyrm import emailing, forms, models
# pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch")
class Report(View):
"""Make reports"""
def get(self, request, user_id, status_id=None, link_id=None):
"""static view of report modal"""
data = {"user": get_object_or_404(models.User, id=user_id)}
if status_id:
data["status"] = status_id
if link_id:
data["link"] = get_object_or_404(models.Link, id=link_id)
return TemplateResponse(request, "report.html", data)
def post(self, request):
"""a user reports something"""
form = forms.ReportForm(request.POST)
if not form.is_valid():
raise ValueError(form.errors)
report = form.save()
if report.links.exists():
# revert the domain to pending
domain = report.links.first().domain
domain.status = "pending"
domain.save()
emailing.moderation_report_email(report)
return redirect("/")

4
bw-dev
View file

@ -31,7 +31,7 @@ function execweb {
function initdb {
execweb python manage.py migrate
execweb python manage.py initdb
execweb python manage.py initdb "$@"
}
function makeitblack {
@ -65,7 +65,7 @@ case "$CMD" in
docker-compose run --rm --service-ports web
;;
initdb)
initdb
initdb "$@"
;;
resetdb)
clean