mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-11-25 11:01:12 +00:00
Merge branch 'main' into main-pilkit
This commit is contained in:
commit
26f92db5d8
52 changed files with 697 additions and 300 deletions
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
|
@ -40,7 +40,7 @@ jobs:
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
|
@ -51,7 +51,7 @@ jobs:
|
||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v2
|
uses: github/codeql-action/autobuild@v3
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 https://git.io/JvXDl
|
# 📚 https://git.io/JvXDl
|
||||||
|
@ -65,4 +65,4 @@ jobs:
|
||||||
# make release
|
# make release
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v3
|
||||||
|
|
3
.github/workflows/lint-frontend.yaml
vendored
3
.github/workflows/lint-frontend.yaml
vendored
|
@ -22,7 +22,8 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install modules
|
- name: Install modules
|
||||||
run: npm install stylelint stylelint-config-recommended stylelint-config-standard stylelint-order eslint
|
# run: npm install stylelint stylelint-config-recommended stylelint-config-standard stylelint-order eslint
|
||||||
|
run: npm install eslint@^8.9.0
|
||||||
|
|
||||||
# See .stylelintignore for files that are not linted.
|
# See .stylelintignore for files that are not linted.
|
||||||
# - name: Run stylelint
|
# - name: Run stylelint
|
||||||
|
|
|
@ -139,14 +139,14 @@ class ActivityStream(RedisStore):
|
||||||
| (
|
| (
|
||||||
Q(following=status.user) & Q(following=status.reply_parent.user)
|
Q(following=status.user) & Q(following=status.reply_parent.user)
|
||||||
) # if the user is following both authors
|
) # if the user is following both authors
|
||||||
).distinct()
|
)
|
||||||
|
|
||||||
# only visible to the poster's followers and tagged users
|
# only visible to the poster's followers and tagged users
|
||||||
elif status.privacy == "followers":
|
elif status.privacy == "followers":
|
||||||
audience = audience.filter(
|
audience = audience.filter(
|
||||||
Q(following=status.user) # if the user is following the author
|
Q(following=status.user) # if the user is following the author
|
||||||
)
|
)
|
||||||
return audience.distinct()
|
return audience.distinct("id")
|
||||||
|
|
||||||
@tracer.start_as_current_span("ActivityStream.get_audience")
|
@tracer.start_as_current_span("ActivityStream.get_audience")
|
||||||
def get_audience(self, status):
|
def get_audience(self, status):
|
||||||
|
@ -156,7 +156,7 @@ class ActivityStream(RedisStore):
|
||||||
status_author = models.User.objects.filter(
|
status_author = models.User.objects.filter(
|
||||||
is_active=True, local=True, id=status.user.id
|
is_active=True, local=True, id=status.user.id
|
||||||
).values_list("id", flat=True)
|
).values_list("id", flat=True)
|
||||||
return list(set(list(audience) + list(status_author)))
|
return list(set(audience) | set(status_author))
|
||||||
|
|
||||||
def get_stores_for_users(self, user_ids):
|
def get_stores_for_users(self, user_ids):
|
||||||
"""convert a list of user ids into redis store ids"""
|
"""convert a list of user ids into redis store ids"""
|
||||||
|
@ -183,15 +183,13 @@ class HomeStream(ActivityStream):
|
||||||
def get_audience(self, status):
|
def get_audience(self, status):
|
||||||
trace.get_current_span().set_attribute("stream_id", self.key)
|
trace.get_current_span().set_attribute("stream_id", self.key)
|
||||||
audience = super()._get_audience(status)
|
audience = super()._get_audience(status)
|
||||||
if not audience:
|
|
||||||
return []
|
|
||||||
# if the user is following the author
|
# if the user is following the author
|
||||||
audience = audience.filter(following=status.user).values_list("id", flat=True)
|
audience = audience.filter(following=status.user).values_list("id", flat=True)
|
||||||
# if the user is the post's author
|
# if the user is the post's author
|
||||||
status_author = models.User.objects.filter(
|
status_author = models.User.objects.filter(
|
||||||
is_active=True, local=True, id=status.user.id
|
is_active=True, local=True, id=status.user.id
|
||||||
).values_list("id", flat=True)
|
).values_list("id", flat=True)
|
||||||
return list(set(list(audience) + list(status_author)))
|
return list(set(audience) | set(status_author))
|
||||||
|
|
||||||
def get_statuses_for_user(self, user):
|
def get_statuses_for_user(self, user):
|
||||||
return models.Status.privacy_filter(
|
return models.Status.privacy_filter(
|
||||||
|
@ -239,9 +237,7 @@ class BooksStream(ActivityStream):
|
||||||
)
|
)
|
||||||
|
|
||||||
audience = super()._get_audience(status)
|
audience = super()._get_audience(status)
|
||||||
if not audience:
|
return audience.filter(shelfbook__book__parent_work=work)
|
||||||
return models.User.objects.none()
|
|
||||||
return audience.filter(shelfbook__book__parent_work=work).distinct()
|
|
||||||
|
|
||||||
def get_audience(self, status):
|
def get_audience(self, status):
|
||||||
# only show public statuses on the books feed,
|
# only show public statuses on the books feed,
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
""" PROCEED WITH CAUTION: uses deduplication fields to permanently
|
""" PROCEED WITH CAUTION: uses deduplication fields to permanently
|
||||||
merge book data objects """
|
merge book data objects """
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
from bookwyrm.management.merge import merge_objects
|
|
||||||
|
|
||||||
|
|
||||||
def dedupe_model(model):
|
def dedupe_model(model, dry_run=False):
|
||||||
"""combine duplicate editions and update related models"""
|
"""combine duplicate editions and update related models"""
|
||||||
|
print(f"deduplicating {model.__name__}:")
|
||||||
fields = model._meta.get_fields()
|
fields = model._meta.get_fields()
|
||||||
dedupe_fields = [
|
dedupe_fields = [
|
||||||
f for f in fields if hasattr(f, "deduplication_field") and f.deduplication_field
|
f for f in fields if hasattr(f, "deduplication_field") and f.deduplication_field
|
||||||
|
@ -16,30 +17,42 @@ def dedupe_model(model):
|
||||||
dupes = (
|
dupes = (
|
||||||
model.objects.values(field.name)
|
model.objects.values(field.name)
|
||||||
.annotate(Count(field.name))
|
.annotate(Count(field.name))
|
||||||
.filter(**{"%s__count__gt" % field.name: 1})
|
.filter(**{f"{field.name}__count__gt": 1})
|
||||||
|
.exclude(**{field.name: ""})
|
||||||
|
.exclude(**{f"{field.name}__isnull": True})
|
||||||
)
|
)
|
||||||
|
|
||||||
for dupe in dupes:
|
for dupe in dupes:
|
||||||
value = dupe[field.name]
|
value = dupe[field.name]
|
||||||
if not value or value == "":
|
|
||||||
continue
|
|
||||||
print("----------")
|
print("----------")
|
||||||
print(dupe)
|
|
||||||
objs = model.objects.filter(**{field.name: value}).order_by("id")
|
objs = model.objects.filter(**{field.name: value}).order_by("id")
|
||||||
canonical = objs.first()
|
canonical = objs.first()
|
||||||
print("keeping", canonical.remote_id)
|
action = "would merge" if dry_run else "merging"
|
||||||
|
print(
|
||||||
|
f"{action} into {model.__name__} {canonical.remote_id} based on {field.name} {value}:"
|
||||||
|
)
|
||||||
for obj in objs[1:]:
|
for obj in objs[1:]:
|
||||||
print(obj.remote_id)
|
print(f"- {obj.remote_id}")
|
||||||
merge_objects(canonical, obj)
|
absorbed_fields = obj.merge_into(canonical, dry_run=dry_run)
|
||||||
|
print(f" absorbed fields: {absorbed_fields}")
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""deduplicate allllll the book data models"""
|
"""deduplicate allllll the book data models"""
|
||||||
|
|
||||||
help = "merges duplicate book data"
|
help = "merges duplicate book data"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
"""add the arguments for this command"""
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry_run",
|
||||||
|
action="store_true",
|
||||||
|
help="don't actually merge, only print what would happen",
|
||||||
|
)
|
||||||
|
|
||||||
# pylint: disable=no-self-use,unused-argument
|
# pylint: disable=no-self-use,unused-argument
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
"""run deduplications"""
|
"""run deduplications"""
|
||||||
dedupe_model(models.Edition)
|
dedupe_model(models.Edition, dry_run=options["dry_run"])
|
||||||
dedupe_model(models.Work)
|
dedupe_model(models.Work, dry_run=options["dry_run"])
|
||||||
dedupe_model(models.Author)
|
dedupe_model(models.Author, dry_run=options["dry_run"])
|
||||||
|
|
|
@ -1,50 +0,0 @@
|
||||||
from django.db.models import ManyToManyField
|
|
||||||
|
|
||||||
|
|
||||||
def update_related(canonical, obj):
|
|
||||||
"""update all the models with fk to the object being removed"""
|
|
||||||
# move related models to canonical
|
|
||||||
related_models = [
|
|
||||||
(r.remote_field.name, r.related_model) for r in canonical._meta.related_objects
|
|
||||||
]
|
|
||||||
for (related_field, related_model) in related_models:
|
|
||||||
# Skip the ManyToMany fields that aren’t auto-created. These
|
|
||||||
# should have a corresponding OneToMany field in the model for
|
|
||||||
# the linking table anyway. If we update it through that model
|
|
||||||
# instead then we won’t lose the extra fields in the linking
|
|
||||||
# table.
|
|
||||||
related_field_obj = related_model._meta.get_field(related_field)
|
|
||||||
if isinstance(related_field_obj, ManyToManyField):
|
|
||||||
through = related_field_obj.remote_field.through
|
|
||||||
if not through._meta.auto_created:
|
|
||||||
continue
|
|
||||||
related_objs = related_model.objects.filter(**{related_field: obj})
|
|
||||||
for related_obj in related_objs:
|
|
||||||
print("replacing in", related_model.__name__, related_field, related_obj.id)
|
|
||||||
try:
|
|
||||||
setattr(related_obj, related_field, canonical)
|
|
||||||
related_obj.save()
|
|
||||||
except TypeError:
|
|
||||||
getattr(related_obj, related_field).add(canonical)
|
|
||||||
getattr(related_obj, related_field).remove(obj)
|
|
||||||
|
|
||||||
|
|
||||||
def copy_data(canonical, obj):
|
|
||||||
"""try to get the most data possible"""
|
|
||||||
for data_field in obj._meta.get_fields():
|
|
||||||
if not hasattr(data_field, "activitypub_field"):
|
|
||||||
continue
|
|
||||||
data_value = getattr(obj, data_field.name)
|
|
||||||
if not data_value:
|
|
||||||
continue
|
|
||||||
if not getattr(canonical, data_field.name):
|
|
||||||
print("setting data field", data_field.name, data_value)
|
|
||||||
setattr(canonical, data_field.name, data_value)
|
|
||||||
canonical.save()
|
|
||||||
|
|
||||||
|
|
||||||
def merge_objects(canonical, obj):
|
|
||||||
copy_data(canonical, obj)
|
|
||||||
update_related(canonical, obj)
|
|
||||||
# remove the outdated entry
|
|
||||||
obj.delete()
|
|
|
@ -1,4 +1,3 @@
|
||||||
from bookwyrm.management.merge import merge_objects
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
|
@ -9,6 +8,11 @@ class MergeCommand(BaseCommand):
|
||||||
"""add the arguments for this command"""
|
"""add the arguments for this command"""
|
||||||
parser.add_argument("--canonical", type=int, required=True)
|
parser.add_argument("--canonical", type=int, required=True)
|
||||||
parser.add_argument("--other", type=int, required=True)
|
parser.add_argument("--other", type=int, required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry_run",
|
||||||
|
action="store_true",
|
||||||
|
help="don't actually merge, only print what would happen",
|
||||||
|
)
|
||||||
|
|
||||||
# pylint: disable=no-self-use,unused-argument
|
# pylint: disable=no-self-use,unused-argument
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
|
@ -26,4 +30,8 @@ class MergeCommand(BaseCommand):
|
||||||
print("other book doesn’t exist!")
|
print("other book doesn’t exist!")
|
||||||
return
|
return
|
||||||
|
|
||||||
merge_objects(canonical, other)
|
absorbed_fields = other.merge_into(canonical, dry_run=options["dry_run"])
|
||||||
|
|
||||||
|
action = "would be" if options["dry_run"] else "has been"
|
||||||
|
print(f"{other.remote_id} {action} merged into {canonical.remote_id}")
|
||||||
|
print(f"absorbed fields: {absorbed_fields}")
|
||||||
|
|
48
bookwyrm/migrations/0197_mergedauthor_mergedbook.py
Normal file
48
bookwyrm/migrations/0197_mergedauthor_mergedbook.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
# Generated by Django 3.2.24 on 2024-02-28 21:30
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0196_merge_pr3134_into_main"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="MergedBook",
|
||||||
|
fields=[
|
||||||
|
("deleted_id", models.IntegerField(primary_key=True, serialize=False)),
|
||||||
|
(
|
||||||
|
"merged_into",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="absorbed",
|
||||||
|
to="bookwyrm.book",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="MergedAuthor",
|
||||||
|
fields=[
|
||||||
|
("deleted_id", models.IntegerField(primary_key=True, serialize=False)),
|
||||||
|
(
|
||||||
|
"merged_into",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="absorbed",
|
||||||
|
to="bookwyrm.author",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 3.2.25 on 2024-04-02 19:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0198_book_search_vector_author_aliases"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="status",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["remote_id"], name="bookwyrm_st_remote__06aeba_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 3.2.25 on 2024-04-03 19:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0199_status_bookwyrm_st_remote__06aeba_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="status",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["thread_id"], name="bookwyrm_st_thread__cf064f_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 3.2.25 on 2024-04-03 19:10
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0200_status_bookwyrm_st_thread__cf064f_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="keypair",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["remote_id"], name="bookwyrm_ke_remote__472927_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 3.2.25 on 2024-04-03 19:14
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0201_keypair_bookwyrm_ke_remote__472927_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="user",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["username"], name="bookwyrm_us_usernam_b2546d_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 3.2.25 on 2024-04-03 19:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0202_user_bookwyrm_us_usernam_b2546d_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="user",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["is_active", "local"], name="bookwyrm_us_is_acti_972dc4_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
13
bookwyrm/migrations/0204_merge_20240409_1042.py
Normal file
13
bookwyrm/migrations/0204_merge_20240409_1042.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
# Generated by Django 3.2.25 on 2024-04-09 10:42
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0197_mergedauthor_mergedbook"),
|
||||||
|
("bookwyrm", "0203_user_bookwyrm_us_is_acti_972dc4_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
|
@ -1,4 +1,5 @@
|
||||||
""" database schema for info about authors """
|
""" database schema for info about authors """
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Tuple, Any
|
from typing import Tuple, Any
|
||||||
|
|
||||||
|
@ -10,13 +11,15 @@ from bookwyrm import activitypub
|
||||||
from bookwyrm.settings import DOMAIN
|
from bookwyrm.settings import DOMAIN
|
||||||
from bookwyrm.utils.db import format_trigger
|
from bookwyrm.utils.db import format_trigger
|
||||||
|
|
||||||
from .book import BookDataModel
|
from .book import BookDataModel, MergedAuthor
|
||||||
from . import fields
|
from . import fields
|
||||||
|
|
||||||
|
|
||||||
class Author(BookDataModel):
|
class Author(BookDataModel):
|
||||||
"""basic biographic info"""
|
"""basic biographic info"""
|
||||||
|
|
||||||
|
merged_model = MergedAuthor
|
||||||
|
|
||||||
wikipedia_link = fields.CharField(
|
wikipedia_link = fields.CharField(
|
||||||
max_length=255, blank=True, null=True, deduplication_field=True
|
max_length=255, blank=True, null=True, deduplication_field=True
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
""" database schema for books and shelves """
|
""" database schema for books and shelves """
|
||||||
|
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any, Dict
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
from django.contrib.postgres.search import SearchVectorField
|
from django.contrib.postgres.search import SearchVectorField
|
||||||
from django.contrib.postgres.indexes import GinIndex
|
from django.contrib.postgres.indexes import GinIndex
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import Prefetch
|
from django.db.models import Prefetch, ManyToManyField
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from model_utils import FieldTracker
|
from model_utils import FieldTracker
|
||||||
|
@ -108,10 +110,115 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
||||||
"""only send book data updates to other bookwyrm instances"""
|
"""only send book data updates to other bookwyrm instances"""
|
||||||
super().broadcast(activity, sender, software=software, **kwargs)
|
super().broadcast(activity, sender, software=software, **kwargs)
|
||||||
|
|
||||||
|
def merge_into(self, canonical: Self, dry_run=False) -> Dict[str, Any]:
|
||||||
|
"""merge this entity into another entity"""
|
||||||
|
if canonical.id == self.id:
|
||||||
|
raise ValueError(f"Cannot merge {self} into itself")
|
||||||
|
|
||||||
|
absorbed_fields = canonical.absorb_data_from(self, dry_run=dry_run)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return absorbed_fields
|
||||||
|
|
||||||
|
canonical.save()
|
||||||
|
|
||||||
|
self.merged_model.objects.create(deleted_id=self.id, merged_into=canonical)
|
||||||
|
|
||||||
|
# move related models to canonical
|
||||||
|
related_models = [
|
||||||
|
(r.remote_field.name, r.related_model) for r in self._meta.related_objects
|
||||||
|
]
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
for related_field, related_model in related_models:
|
||||||
|
# Skip the ManyToMany fields that aren’t auto-created. These
|
||||||
|
# should have a corresponding OneToMany field in the model for
|
||||||
|
# the linking table anyway. If we update it through that model
|
||||||
|
# instead then we won’t lose the extra fields in the linking
|
||||||
|
# table.
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
related_field_obj = related_model._meta.get_field(related_field)
|
||||||
|
if isinstance(related_field_obj, ManyToManyField):
|
||||||
|
through = related_field_obj.remote_field.through
|
||||||
|
if not through._meta.auto_created:
|
||||||
|
continue
|
||||||
|
related_objs = related_model.objects.filter(**{related_field: self})
|
||||||
|
for related_obj in related_objs:
|
||||||
|
try:
|
||||||
|
setattr(related_obj, related_field, canonical)
|
||||||
|
related_obj.save()
|
||||||
|
except TypeError:
|
||||||
|
getattr(related_obj, related_field).add(canonical)
|
||||||
|
getattr(related_obj, related_field).remove(self)
|
||||||
|
|
||||||
|
self.delete()
|
||||||
|
return absorbed_fields
|
||||||
|
|
||||||
|
def absorb_data_from(self, other: Self, dry_run=False) -> Dict[str, Any]:
|
||||||
|
"""fill empty fields with values from another entity"""
|
||||||
|
absorbed_fields = {}
|
||||||
|
for data_field in self._meta.get_fields():
|
||||||
|
if not hasattr(data_field, "activitypub_field"):
|
||||||
|
continue
|
||||||
|
canonical_value = getattr(self, data_field.name)
|
||||||
|
other_value = getattr(other, data_field.name)
|
||||||
|
if not other_value:
|
||||||
|
continue
|
||||||
|
if isinstance(data_field, fields.ArrayField):
|
||||||
|
if new_values := list(set(other_value) - set(canonical_value)):
|
||||||
|
# append at the end (in no particular order)
|
||||||
|
if not dry_run:
|
||||||
|
setattr(self, data_field.name, canonical_value + new_values)
|
||||||
|
absorbed_fields[data_field.name] = new_values
|
||||||
|
elif isinstance(data_field, fields.PartialDateField):
|
||||||
|
if (
|
||||||
|
(not canonical_value)
|
||||||
|
or (other_value.has_day and not canonical_value.has_day)
|
||||||
|
or (other_value.has_month and not canonical_value.has_month)
|
||||||
|
):
|
||||||
|
if not dry_run:
|
||||||
|
setattr(self, data_field.name, other_value)
|
||||||
|
absorbed_fields[data_field.name] = other_value
|
||||||
|
else:
|
||||||
|
if not canonical_value:
|
||||||
|
if not dry_run:
|
||||||
|
setattr(self, data_field.name, other_value)
|
||||||
|
absorbed_fields[data_field.name] = other_value
|
||||||
|
return absorbed_fields
|
||||||
|
|
||||||
|
|
||||||
|
class MergedBookDataModel(models.Model):
|
||||||
|
"""a BookDataModel instance that has been merged into another instance. kept
|
||||||
|
to be able to redirect old URLs"""
|
||||||
|
|
||||||
|
deleted_id = models.IntegerField(primary_key=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""abstract just like BookDataModel"""
|
||||||
|
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class MergedBook(MergedBookDataModel):
|
||||||
|
"""an Book that has been merged into another one"""
|
||||||
|
|
||||||
|
merged_into = models.ForeignKey(
|
||||||
|
"Book", on_delete=models.PROTECT, related_name="absorbed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MergedAuthor(MergedBookDataModel):
|
||||||
|
"""an Author that has been merged into another one"""
|
||||||
|
|
||||||
|
merged_into = models.ForeignKey(
|
||||||
|
"Author", on_delete=models.PROTECT, related_name="absorbed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Book(BookDataModel):
|
class Book(BookDataModel):
|
||||||
"""a generic book, which can mean either an edition or a work"""
|
"""a generic book, which can mean either an edition or a work"""
|
||||||
|
|
||||||
|
merged_model = MergedBook
|
||||||
|
|
||||||
connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True)
|
connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True)
|
||||||
|
|
||||||
# book/work metadata
|
# book/work metadata
|
||||||
|
@ -192,9 +299,13 @@ class Book(BookDataModel):
|
||||||
"""properties of this edition, as a string"""
|
"""properties of this edition, as a string"""
|
||||||
items = [
|
items = [
|
||||||
self.physical_format if hasattr(self, "physical_format") else None,
|
self.physical_format if hasattr(self, "physical_format") else None,
|
||||||
f"{self.languages[0]} language"
|
(
|
||||||
if self.languages and self.languages[0] and self.languages[0] != "English"
|
f"{self.languages[0]} language"
|
||||||
else None,
|
if self.languages
|
||||||
|
and self.languages[0]
|
||||||
|
and self.languages[0] != "English"
|
||||||
|
else None
|
||||||
|
),
|
||||||
str(self.published_date.year) if self.published_date else None,
|
str(self.published_date.year) if self.published_date else None,
|
||||||
", ".join(self.publishers) if hasattr(self, "publishers") else None,
|
", ".join(self.publishers) if hasattr(self, "publishers") else None,
|
||||||
]
|
]
|
||||||
|
|
|
@ -80,6 +80,10 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
"""default sorting"""
|
"""default sorting"""
|
||||||
|
|
||||||
ordering = ("-published_date",)
|
ordering = ("-published_date",)
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["remote_id"]),
|
||||||
|
models.Index(fields=["thread_id"]),
|
||||||
|
]
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""save and notify"""
|
"""save and notify"""
|
||||||
|
|
|
@ -198,6 +198,14 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
hotp_secret = models.CharField(max_length=32, default=None, blank=True, null=True)
|
hotp_secret = models.CharField(max_length=32, default=None, blank=True, null=True)
|
||||||
hotp_count = models.IntegerField(default=0, blank=True, null=True)
|
hotp_count = models.IntegerField(default=0, blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta(AbstractUser.Meta):
|
||||||
|
"""indexes"""
|
||||||
|
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["username"]),
|
||||||
|
models.Index(fields=["is_active", "local"]),
|
||||||
|
]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active_follower_requests(self):
|
def active_follower_requests(self):
|
||||||
"""Follow requests from active users"""
|
"""Follow requests from active users"""
|
||||||
|
@ -509,6 +517,13 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||||
activity_serializer = activitypub.PublicKey
|
activity_serializer = activitypub.PublicKey
|
||||||
serialize_reverse_fields = [("owner", "owner", "id")]
|
serialize_reverse_fields = [("owner", "owner", "id")]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""indexes"""
|
||||||
|
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["remote_id"]),
|
||||||
|
]
|
||||||
|
|
||||||
def get_remote_id(self):
|
def get_remote_id(self):
|
||||||
# self.owner is set by the OneToOneField on User
|
# self.owner is set by the OneToOneField on User
|
||||||
return f"{self.owner.remote_id}/#main-key"
|
return f"{self.owner.remote_id}/#main-key"
|
||||||
|
|
|
@ -175,11 +175,13 @@ def generate_instance_layer(content_width):
|
||||||
site = models.SiteSettings.objects.get()
|
site = models.SiteSettings.objects.get()
|
||||||
|
|
||||||
if site.logo_small:
|
if site.logo_small:
|
||||||
logo_img = Image.open(site.logo_small)
|
with Image.open(site.logo_small) as logo_img:
|
||||||
|
logo_img.load()
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
static_path = os.path.join(settings.STATIC_ROOT, "images/logo-small.png")
|
static_path = os.path.join(settings.STATIC_ROOT, "images/logo-small.png")
|
||||||
logo_img = Image.open(static_path)
|
with Image.open(static_path) as logo_img:
|
||||||
|
logo_img.load()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logo_img = None
|
logo_img = None
|
||||||
|
|
||||||
|
@ -211,18 +213,9 @@ def generate_instance_layer(content_width):
|
||||||
|
|
||||||
def generate_rating_layer(rating, content_width):
|
def generate_rating_layer(rating, content_width):
|
||||||
"""Places components for rating preview"""
|
"""Places components for rating preview"""
|
||||||
try:
|
path_star_full = os.path.join(settings.STATIC_ROOT, "images/icons/star-full.png")
|
||||||
icon_star_full = Image.open(
|
path_star_empty = os.path.join(settings.STATIC_ROOT, "images/icons/star-empty.png")
|
||||||
os.path.join(settings.STATIC_ROOT, "images/icons/star-full.png")
|
path_star_half = os.path.join(settings.STATIC_ROOT, "images/icons/star-half.png")
|
||||||
)
|
|
||||||
icon_star_empty = Image.open(
|
|
||||||
os.path.join(settings.STATIC_ROOT, "images/icons/star-empty.png")
|
|
||||||
)
|
|
||||||
icon_star_half = Image.open(
|
|
||||||
os.path.join(settings.STATIC_ROOT, "images/icons/star-half.png")
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
icon_size = 64
|
icon_size = 64
|
||||||
icon_margin = 10
|
icon_margin = 10
|
||||||
|
@ -237,17 +230,23 @@ def generate_rating_layer(rating, content_width):
|
||||||
|
|
||||||
position_x = 0
|
position_x = 0
|
||||||
|
|
||||||
for _ in range(math.floor(rating)):
|
try:
|
||||||
rating_layer_mask.alpha_composite(icon_star_full, (position_x, 0))
|
with Image.open(path_star_full) as icon_star_full:
|
||||||
position_x = position_x + icon_size + icon_margin
|
for _ in range(math.floor(rating)):
|
||||||
|
rating_layer_mask.alpha_composite(icon_star_full, (position_x, 0))
|
||||||
|
position_x = position_x + icon_size + icon_margin
|
||||||
|
|
||||||
if math.floor(rating) != math.ceil(rating):
|
if math.floor(rating) != math.ceil(rating):
|
||||||
rating_layer_mask.alpha_composite(icon_star_half, (position_x, 0))
|
with Image.open(path_star_half) as icon_star_half:
|
||||||
position_x = position_x + icon_size + icon_margin
|
rating_layer_mask.alpha_composite(icon_star_half, (position_x, 0))
|
||||||
|
position_x = position_x + icon_size + icon_margin
|
||||||
|
|
||||||
for _ in range(5 - math.ceil(rating)):
|
with Image.open(path_star_empty) as icon_star_empty:
|
||||||
rating_layer_mask.alpha_composite(icon_star_empty, (position_x, 0))
|
for _ in range(5 - math.ceil(rating)):
|
||||||
position_x = position_x + icon_size + icon_margin
|
rating_layer_mask.alpha_composite(icon_star_empty, (position_x, 0))
|
||||||
|
position_x = position_x + icon_size + icon_margin
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
rating_layer_mask = rating_layer_mask.getchannel("A")
|
rating_layer_mask = rating_layer_mask.getchannel("A")
|
||||||
rating_layer_mask = ImageOps.invert(rating_layer_mask)
|
rating_layer_mask = ImageOps.invert(rating_layer_mask)
|
||||||
|
@ -290,7 +289,8 @@ def generate_preview_image(
|
||||||
texts = texts or {}
|
texts = texts or {}
|
||||||
# Cover
|
# Cover
|
||||||
try:
|
try:
|
||||||
inner_img_layer = Image.open(picture)
|
with Image.open(picture) as inner_img_layer:
|
||||||
|
inner_img_layer.load()
|
||||||
inner_img_layer.thumbnail(
|
inner_img_layer.thumbnail(
|
||||||
(inner_img_width, inner_img_height), Image.Resampling.LANCZOS
|
(inner_img_width, inner_img_height), Image.Resampling.LANCZOS
|
||||||
)
|
)
|
||||||
|
|
|
@ -19,7 +19,6 @@ DOMAIN = env("DOMAIN")
|
||||||
with open("VERSION", encoding="utf-8") as f:
|
with open("VERSION", encoding="utf-8") as f:
|
||||||
version = f.read()
|
version = f.read()
|
||||||
version = version.replace("\n", "")
|
version = version.replace("\n", "")
|
||||||
f.close()
|
|
||||||
|
|
||||||
VERSION = version
|
VERSION = version
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
""" tests the base functionality for activitypub dataclasses """
|
""" tests the base functionality for activitypub dataclasses """
|
||||||
from io import BytesIO
|
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from PIL import Image
|
|
||||||
import responses
|
import responses
|
||||||
|
|
||||||
from bookwyrm import activitypub
|
from bookwyrm import activitypub
|
||||||
|
@ -48,13 +46,11 @@ class BaseActivity(TestCase):
|
||||||
# don't try to load the user icon
|
# don't try to load the user icon
|
||||||
del self.userdata["icon"]
|
del self.userdata["icon"]
|
||||||
|
|
||||||
image_file = pathlib.Path(__file__).parent.joinpath(
|
image_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
"../../static/images/default_avi.jpg"
|
"../../static/images/default_avi.jpg"
|
||||||
)
|
)
|
||||||
image = Image.open(image_file)
|
with open(image_path, "rb") as image_file:
|
||||||
output = BytesIO()
|
self.image_data = image_file.read()
|
||||||
image.save(output, format=image.format)
|
|
||||||
self.image_data = output.getvalue()
|
|
||||||
|
|
||||||
def test_get_representative_not_existing(self, *_):
|
def test_get_representative_not_existing(self, *_):
|
||||||
"""test that an instance representative actor is created if it does not exist"""
|
"""test that an instance representative actor is created if it does not exist"""
|
||||||
|
|
|
@ -9,7 +9,6 @@ from bookwyrm.importers import CalibreImporter
|
||||||
from bookwyrm.models.import_job import handle_imported_book
|
from bookwyrm.models.import_job import handle_imported_book
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=consider-using-with
|
|
||||||
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
|
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
||||||
|
@ -20,8 +19,13 @@ class CalibreImport(TestCase):
|
||||||
"""use a test csv"""
|
"""use a test csv"""
|
||||||
self.importer = CalibreImporter()
|
self.importer = CalibreImporter()
|
||||||
datafile = pathlib.Path(__file__).parent.joinpath("../data/calibre.csv")
|
datafile = pathlib.Path(__file__).parent.joinpath("../data/calibre.csv")
|
||||||
|
# pylint: disable-next=consider-using-with
|
||||||
self.csv = open(datafile, "r", encoding=self.importer.encoding)
|
self.csv = open(datafile, "r", encoding=self.importer.encoding)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""close test csv"""
|
||||||
|
self.csv.close()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
"""populate database"""
|
"""populate database"""
|
||||||
|
|
|
@ -16,7 +16,6 @@ def make_date(*args):
|
||||||
return datetime.datetime(*args, tzinfo=pytz.UTC)
|
return datetime.datetime(*args, tzinfo=pytz.UTC)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=consider-using-with
|
|
||||||
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
|
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
||||||
|
@ -27,8 +26,13 @@ class GoodreadsImport(TestCase):
|
||||||
"""use a test csv"""
|
"""use a test csv"""
|
||||||
self.importer = GoodreadsImporter()
|
self.importer = GoodreadsImporter()
|
||||||
datafile = pathlib.Path(__file__).parent.joinpath("../data/goodreads.csv")
|
datafile = pathlib.Path(__file__).parent.joinpath("../data/goodreads.csv")
|
||||||
|
# pylint: disable-next=consider-using-with
|
||||||
self.csv = open(datafile, "r", encoding=self.importer.encoding)
|
self.csv = open(datafile, "r", encoding=self.importer.encoding)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""close test csv"""
|
||||||
|
self.csv.close()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
"""populate database"""
|
"""populate database"""
|
||||||
|
|
|
@ -19,7 +19,6 @@ def make_date(*args):
|
||||||
return datetime.datetime(*args, tzinfo=pytz.UTC)
|
return datetime.datetime(*args, tzinfo=pytz.UTC)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=consider-using-with
|
|
||||||
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
|
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
||||||
|
@ -30,8 +29,13 @@ class GenericImporter(TestCase):
|
||||||
"""use a test csv"""
|
"""use a test csv"""
|
||||||
self.importer = Importer()
|
self.importer = Importer()
|
||||||
datafile = pathlib.Path(__file__).parent.joinpath("../data/generic.csv")
|
datafile = pathlib.Path(__file__).parent.joinpath("../data/generic.csv")
|
||||||
|
# pylint: disable-next=consider-using-with
|
||||||
self.csv = open(datafile, "r", encoding=self.importer.encoding)
|
self.csv = open(datafile, "r", encoding=self.importer.encoding)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""close test csv"""
|
||||||
|
self.csv.close()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
"""populate database"""
|
"""populate database"""
|
||||||
|
|
|
@ -16,7 +16,6 @@ def make_date(*args):
|
||||||
return datetime.datetime(*args, tzinfo=pytz.UTC)
|
return datetime.datetime(*args, tzinfo=pytz.UTC)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=consider-using-with
|
|
||||||
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
|
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
||||||
|
@ -29,8 +28,13 @@ class LibrarythingImport(TestCase):
|
||||||
datafile = pathlib.Path(__file__).parent.joinpath("../data/librarything.tsv")
|
datafile = pathlib.Path(__file__).parent.joinpath("../data/librarything.tsv")
|
||||||
|
|
||||||
# Librarything generates latin encoded exports...
|
# Librarything generates latin encoded exports...
|
||||||
|
# pylint: disable-next=consider-using-with
|
||||||
self.csv = open(datafile, "r", encoding=self.importer.encoding)
|
self.csv = open(datafile, "r", encoding=self.importer.encoding)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""close test csv"""
|
||||||
|
self.csv.close()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
"""populate database"""
|
"""populate database"""
|
||||||
|
|
|
@ -16,7 +16,6 @@ def make_date(*args):
|
||||||
return datetime.datetime(*args, tzinfo=pytz.UTC)
|
return datetime.datetime(*args, tzinfo=pytz.UTC)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=consider-using-with
|
|
||||||
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
|
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
||||||
|
@ -27,8 +26,13 @@ class OpenLibraryImport(TestCase):
|
||||||
"""use a test csv"""
|
"""use a test csv"""
|
||||||
self.importer = OpenLibraryImporter()
|
self.importer = OpenLibraryImporter()
|
||||||
datafile = pathlib.Path(__file__).parent.joinpath("../data/openlibrary.csv")
|
datafile = pathlib.Path(__file__).parent.joinpath("../data/openlibrary.csv")
|
||||||
|
# pylint: disable-next=consider-using-with
|
||||||
self.csv = open(datafile, "r", encoding=self.importer.encoding)
|
self.csv = open(datafile, "r", encoding=self.importer.encoding)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""close test csv"""
|
||||||
|
self.csv.close()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
"""populate database"""
|
"""populate database"""
|
||||||
|
|
|
@ -16,7 +16,6 @@ def make_date(*args):
|
||||||
return datetime.datetime(*args, tzinfo=pytz.UTC)
|
return datetime.datetime(*args, tzinfo=pytz.UTC)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=consider-using-with
|
|
||||||
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
|
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
|
||||||
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
|
||||||
|
@ -27,8 +26,13 @@ class StorygraphImport(TestCase):
|
||||||
"""use a test csv"""
|
"""use a test csv"""
|
||||||
self.importer = StorygraphImporter()
|
self.importer = StorygraphImporter()
|
||||||
datafile = pathlib.Path(__file__).parent.joinpath("../data/storygraph.csv")
|
datafile = pathlib.Path(__file__).parent.joinpath("../data/storygraph.csv")
|
||||||
|
# pylint: disable-next=consider-using-with
|
||||||
self.csv = open(datafile, "r", encoding=self.importer.encoding)
|
self.csv = open(datafile, "r", encoding=self.importer.encoding)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""close test csv"""
|
||||||
|
self.csv.close()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
"""populate database"""
|
"""populate database"""
|
||||||
|
|
|
@ -1,12 +1,9 @@
|
||||||
""" testing models """
|
""" testing models """
|
||||||
from io import BytesIO
|
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from dateutil.parser import parse
|
from dateutil.parser import parse
|
||||||
from PIL import Image
|
|
||||||
from django.core.files.base import ContentFile
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
@ -130,15 +127,13 @@ class Book(TestCase):
|
||||||
)
|
)
|
||||||
def test_thumbnail_fields(self):
|
def test_thumbnail_fields(self):
|
||||||
"""Just hit them"""
|
"""Just hit them"""
|
||||||
image_file = pathlib.Path(__file__).parent.joinpath(
|
image_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
"../../static/images/default_avi.jpg"
|
"../../static/images/default_avi.jpg"
|
||||||
)
|
)
|
||||||
image = Image.open(image_file)
|
|
||||||
output = BytesIO()
|
|
||||||
image.save(output, format=image.format)
|
|
||||||
|
|
||||||
book = models.Edition.objects.create(title="hello")
|
book = models.Edition.objects.create(title="hello")
|
||||||
book.cover.save("test.jpg", ContentFile(output.getvalue()))
|
with open(image_path, "rb") as image_file:
|
||||||
|
book.cover.save("test.jpg", image_file)
|
||||||
|
|
||||||
self.assertIsNotNone(book.cover_bw_book_xsmall_webp.url)
|
self.assertIsNotNone(book.cover_bw_book_xsmall_webp.url)
|
||||||
self.assertIsNotNone(book.cover_bw_book_xsmall_jpg.url)
|
self.assertIsNotNone(book.cover_bw_book_xsmall_jpg.url)
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
""" testing models """
|
""" testing models """
|
||||||
from io import BytesIO
|
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import datetime
|
import datetime
|
||||||
|
@ -10,7 +9,6 @@ from typing import List
|
||||||
from unittest import expectedFailure
|
from unittest import expectedFailure
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from PIL import Image
|
|
||||||
import responses
|
import responses
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
@ -420,13 +418,11 @@ class ModelFields(TestCase):
|
||||||
user = User.objects.create_user(
|
user = User.objects.create_user(
|
||||||
"mouse", "mouse@mouse.mouse", "mouseword", local=True, localname="mouse"
|
"mouse", "mouse@mouse.mouse", "mouseword", local=True, localname="mouse"
|
||||||
)
|
)
|
||||||
image_file = pathlib.Path(__file__).parent.joinpath(
|
image_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
"../../static/images/default_avi.jpg"
|
"../../static/images/default_avi.jpg"
|
||||||
)
|
)
|
||||||
image = Image.open(image_file)
|
with open(image_path, "rb") as image_file:
|
||||||
output = BytesIO()
|
user.avatar.save("test.jpg", image_file)
|
||||||
image.save(output, format=image.format)
|
|
||||||
user.avatar.save("test.jpg", ContentFile(output.getvalue()))
|
|
||||||
|
|
||||||
instance = fields.ImageField()
|
instance = fields.ImageField()
|
||||||
|
|
||||||
|
@ -516,30 +512,25 @@ class ModelFields(TestCase):
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_image_field_set_field_from_activity_no_overwrite_with_cover(self, *_):
|
def test_image_field_set_field_from_activity_no_overwrite_with_cover(self, *_):
|
||||||
"""update a model instance from an activitypub object"""
|
"""update a model instance from an activitypub object"""
|
||||||
image_file = pathlib.Path(__file__).parent.joinpath(
|
image_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
"../../static/images/default_avi.jpg"
|
"../../static/images/default_avi.jpg"
|
||||||
)
|
)
|
||||||
image = Image.open(image_file)
|
another_image_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
output = BytesIO()
|
|
||||||
image.save(output, format=image.format)
|
|
||||||
|
|
||||||
another_image_file = pathlib.Path(__file__).parent.joinpath(
|
|
||||||
"../../static/images/logo.png"
|
"../../static/images/logo.png"
|
||||||
)
|
)
|
||||||
another_image = Image.open(another_image_file)
|
|
||||||
another_output = BytesIO()
|
|
||||||
another_image.save(another_output, format=another_image.format)
|
|
||||||
|
|
||||||
instance = fields.ImageField(activitypub_field="cover", name="cover")
|
instance = fields.ImageField(activitypub_field="cover", name="cover")
|
||||||
|
|
||||||
responses.add(
|
with open(another_image_path, "rb") as another_image_file:
|
||||||
responses.GET,
|
responses.add(
|
||||||
"http://www.example.com/image.jpg",
|
responses.GET,
|
||||||
body=another_image.tobytes(),
|
"http://www.example.com/image.jpg",
|
||||||
status=200,
|
body=another_image_file.read(),
|
||||||
)
|
status=200,
|
||||||
|
)
|
||||||
book = Edition.objects.create(title="hello")
|
book = Edition.objects.create(title="hello")
|
||||||
book.cover.save("test.jpg", ContentFile(output.getvalue()))
|
with open(image_path, "rb") as image_file:
|
||||||
|
book.cover.save("test.jpg", image_file)
|
||||||
cover_size = book.cover.size
|
cover_size = book.cover.size
|
||||||
self.assertIsNotNone(cover_size)
|
self.assertIsNotNone(cover_size)
|
||||||
|
|
||||||
|
@ -553,24 +544,22 @@ class ModelFields(TestCase):
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_image_field_set_field_from_activity_with_overwrite_with_cover(self, *_):
|
def test_image_field_set_field_from_activity_with_overwrite_with_cover(self, *_):
|
||||||
"""update a model instance from an activitypub object"""
|
"""update a model instance from an activitypub object"""
|
||||||
image_file = pathlib.Path(__file__).parent.joinpath(
|
image_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
"../../static/images/default_avi.jpg"
|
"../../static/images/default_avi.jpg"
|
||||||
)
|
)
|
||||||
image = Image.open(image_file)
|
|
||||||
output = BytesIO()
|
|
||||||
image.save(output, format=image.format)
|
|
||||||
book = Edition.objects.create(title="hello")
|
book = Edition.objects.create(title="hello")
|
||||||
book.cover.save("test.jpg", ContentFile(output.getvalue()))
|
with open(image_path, "rb") as image_file:
|
||||||
|
book.cover.save("test.jpg", image_file)
|
||||||
cover_size = book.cover.size
|
cover_size = book.cover.size
|
||||||
self.assertIsNotNone(cover_size)
|
self.assertIsNotNone(cover_size)
|
||||||
|
|
||||||
another_image_file = pathlib.Path(__file__).parent.joinpath(
|
another_image_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
"../../static/images/logo.png"
|
"../../static/images/logo.png"
|
||||||
)
|
)
|
||||||
|
|
||||||
instance = fields.ImageField(activitypub_field="cover", name="cover")
|
instance = fields.ImageField(activitypub_field="cover", name="cover")
|
||||||
|
|
||||||
with open(another_image_file, "rb") as another_image:
|
with open(another_image_path, "rb") as another_image:
|
||||||
responses.add(
|
responses.add(
|
||||||
responses.GET,
|
responses.GET,
|
||||||
"http://www.example.com/image.jpg",
|
"http://www.example.com/image.jpg",
|
||||||
|
|
|
@ -1,16 +1,13 @@
|
||||||
""" testing models """
|
""" testing models """
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from io import BytesIO
|
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.core.files.base import ContentFile
|
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from PIL import Image
|
|
||||||
import responses
|
import responses
|
||||||
|
|
||||||
from bookwyrm import activitypub, models, settings
|
from bookwyrm import activitypub, models, settings
|
||||||
|
@ -51,14 +48,14 @@ class Status(TestCase):
|
||||||
"""individual test setup"""
|
"""individual test setup"""
|
||||||
self.anonymous_user = AnonymousUser
|
self.anonymous_user = AnonymousUser
|
||||||
self.anonymous_user.is_authenticated = False
|
self.anonymous_user.is_authenticated = False
|
||||||
image_file = pathlib.Path(__file__).parent.joinpath(
|
image_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
"../../static/images/default_avi.jpg"
|
"../../static/images/default_avi.jpg"
|
||||||
)
|
)
|
||||||
image = Image.open(image_file)
|
with (
|
||||||
output = BytesIO()
|
patch("bookwyrm.models.Status.broadcast"),
|
||||||
with patch("bookwyrm.models.Status.broadcast"):
|
open(image_path, "rb") as image_file,
|
||||||
image.save(output, format=image.format)
|
):
|
||||||
self.book.cover.save("test.jpg", ContentFile(output.getvalue()))
|
self.book.cover.save("test.jpg", image_file)
|
||||||
|
|
||||||
def test_status_generated_fields(self, *_):
|
def test_status_generated_fields(self, *_):
|
||||||
"""setting remote id"""
|
"""setting remote id"""
|
||||||
|
|
97
bookwyrm/tests/test_merge.py
Normal file
97
bookwyrm/tests/test_merge.py
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
"""test merging Authors, Works and Editions"""
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.test.client import Client
|
||||||
|
|
||||||
|
from bookwyrm import models
|
||||||
|
|
||||||
|
|
||||||
|
class MergeBookDataModel(TestCase):
|
||||||
|
"""test merging of subclasses of BookDataModel"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls): # pylint: disable=invalid-name
|
||||||
|
"""shared data"""
|
||||||
|
models.SiteSettings.objects.create()
|
||||||
|
|
||||||
|
cls.jrr_tolkien = models.Author.objects.create(
|
||||||
|
name="J.R.R. Tolkien",
|
||||||
|
aliases=["JRR Tolkien", "Tolkien"],
|
||||||
|
bio="This guy wrote about hobbits and stuff.",
|
||||||
|
openlibrary_key="OL26320A",
|
||||||
|
isni="0000000121441970",
|
||||||
|
)
|
||||||
|
cls.jrr_tolkien_2 = models.Author.objects.create(
|
||||||
|
name="J.R.R. Tolkien",
|
||||||
|
aliases=["JRR Tolkien", "John Ronald Reuel Tolkien"],
|
||||||
|
openlibrary_key="OL26320A",
|
||||||
|
isni="wrong",
|
||||||
|
wikidata="Q892",
|
||||||
|
)
|
||||||
|
cls.jrr_tolkien_2_id = cls.jrr_tolkien_2.id
|
||||||
|
|
||||||
|
# perform merges
|
||||||
|
cls.jrr_tolkien_absorbed_fields = cls.jrr_tolkien_2.merge_into(cls.jrr_tolkien)
|
||||||
|
|
||||||
|
def test_merged_author(self):
|
||||||
|
"""verify merged author after merge"""
|
||||||
|
self.assertEqual(self.jrr_tolkien_2.id, None, msg="duplicate should be deleted")
|
||||||
|
|
||||||
|
def test_canonical_author(self):
|
||||||
|
"""verify canonical author data after merge"""
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
self.jrr_tolkien.id is None, msg="canonical should not be deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
# identical in canonical and duplicate; should be unchanged
|
||||||
|
self.assertEqual(self.jrr_tolkien.name, "J.R.R. Tolkien")
|
||||||
|
self.assertEqual(self.jrr_tolkien.openlibrary_key, "OL26320A")
|
||||||
|
|
||||||
|
# present in canonical and absent in duplicate; should be unchanged
|
||||||
|
self.assertEqual(
|
||||||
|
self.jrr_tolkien.bio, "This guy wrote about hobbits and stuff."
|
||||||
|
)
|
||||||
|
|
||||||
|
# absent in canonical and present in duplicate; should be absorbed
|
||||||
|
self.assertEqual(self.jrr_tolkien.wikidata, "Q892")
|
||||||
|
|
||||||
|
# scalar value that is different in canonical and duplicate; should be unchanged
|
||||||
|
self.assertEqual(self.jrr_tolkien.isni, "0000000121441970")
|
||||||
|
|
||||||
|
# set value with both matching and non-matching elements; should be the
|
||||||
|
# union of canonical and duplicate
|
||||||
|
self.assertEqual(
|
||||||
|
self.jrr_tolkien.aliases,
|
||||||
|
[
|
||||||
|
"JRR Tolkien",
|
||||||
|
"Tolkien",
|
||||||
|
"John Ronald Reuel Tolkien",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_merged_author_redirect(self):
|
||||||
|
"""a web request for a merged author should redirect to the canonical author"""
|
||||||
|
client = Client()
|
||||||
|
response = client.get(
|
||||||
|
f"/author/{self.jrr_tolkien_2_id}/s/jrr-tolkien", follow=True
|
||||||
|
)
|
||||||
|
self.assertEqual(response.redirect_chain, [(self.jrr_tolkien.local_path, 301)])
|
||||||
|
|
||||||
|
def test_merged_author_activitypub(self):
|
||||||
|
"""an activitypub request for a merged author should return the data for
|
||||||
|
the canonical author (including the canonical id)"""
|
||||||
|
client = Client(HTTP_ACCEPT="application/json")
|
||||||
|
response = client.get(f"/author/{self.jrr_tolkien_2_id}")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), self.jrr_tolkien.to_activity())
|
||||||
|
|
||||||
|
def test_absorbed_fields(self):
|
||||||
|
"""reported absorbed_fields should be accurate for --dry_run"""
|
||||||
|
self.assertEqual(
|
||||||
|
self.jrr_tolkien_absorbed_fields,
|
||||||
|
{
|
||||||
|
"aliases": ["John Ronald Reuel Tolkien"],
|
||||||
|
"wikidata": "Q892",
|
||||||
|
},
|
||||||
|
)
|
|
@ -21,20 +21,20 @@ from bookwyrm.preview_images import (
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
# pylint: disable=missing-function-docstring
|
# pylint: disable=missing-function-docstring
|
||||||
# pylint: disable=consider-using-with
|
|
||||||
class PreviewImages(TestCase):
|
class PreviewImages(TestCase):
|
||||||
"""every response to a get request, html or json"""
|
"""every response to a get request, html or json"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""we need basic test data and mocks"""
|
"""we need basic test data and mocks"""
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
avatar_file = pathlib.Path(__file__).parent.joinpath(
|
avatar_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
"../static/images/no_cover.jpg"
|
"../static/images/no_cover.jpg"
|
||||||
)
|
)
|
||||||
with (
|
with (
|
||||||
patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"),
|
patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"),
|
||||||
patch("bookwyrm.activitystreams.populate_stream_task.delay"),
|
patch("bookwyrm.activitystreams.populate_stream_task.delay"),
|
||||||
patch("bookwyrm.lists_stream.populate_lists_task.delay"),
|
patch("bookwyrm.lists_stream.populate_lists_task.delay"),
|
||||||
|
open(avatar_path, "rb") as avatar_file,
|
||||||
):
|
):
|
||||||
self.local_user = models.User.objects.create_user(
|
self.local_user = models.User.objects.create_user(
|
||||||
"possum@local.com",
|
"possum@local.com",
|
||||||
|
@ -43,8 +43,8 @@ class PreviewImages(TestCase):
|
||||||
local=True,
|
local=True,
|
||||||
localname="possum",
|
localname="possum",
|
||||||
avatar=SimpleUploadedFile(
|
avatar=SimpleUploadedFile(
|
||||||
avatar_file,
|
avatar_path,
|
||||||
open(avatar_file, "rb").read(),
|
avatar_file.read(),
|
||||||
content_type="image/jpeg",
|
content_type="image/jpeg",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -68,6 +68,7 @@ class PreviewImages(TestCase):
|
||||||
patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"),
|
patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"),
|
||||||
patch("bookwyrm.activitystreams.populate_stream_task.delay"),
|
patch("bookwyrm.activitystreams.populate_stream_task.delay"),
|
||||||
patch("bookwyrm.lists_stream.populate_lists_task.delay"),
|
patch("bookwyrm.lists_stream.populate_lists_task.delay"),
|
||||||
|
open(avatar_path, "rb") as avatar_file,
|
||||||
):
|
):
|
||||||
self.remote_user_with_preview = models.User.objects.create_user(
|
self.remote_user_with_preview = models.User.objects.create_user(
|
||||||
"badger@your.domain.here",
|
"badger@your.domain.here",
|
||||||
|
@ -78,8 +79,8 @@ class PreviewImages(TestCase):
|
||||||
inbox="https://example.com/users/badger/inbox",
|
inbox="https://example.com/users/badger/inbox",
|
||||||
outbox="https://example.com/users/badger/outbox",
|
outbox="https://example.com/users/badger/outbox",
|
||||||
avatar=SimpleUploadedFile(
|
avatar=SimpleUploadedFile(
|
||||||
avatar_file,
|
avatar_path,
|
||||||
open(avatar_file, "rb").read(),
|
avatar_file.read(),
|
||||||
content_type="image/jpeg",
|
content_type="image/jpeg",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -96,7 +97,7 @@ class PreviewImages(TestCase):
|
||||||
settings.ENABLE_PREVIEW_IMAGES = True
|
settings.ENABLE_PREVIEW_IMAGES = True
|
||||||
|
|
||||||
def test_generate_preview_image(self, *args, **kwargs):
|
def test_generate_preview_image(self, *args, **kwargs):
|
||||||
image_file = pathlib.Path(__file__).parent.joinpath(
|
image_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
"../static/images/no_cover.jpg"
|
"../static/images/no_cover.jpg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -105,7 +106,7 @@ class PreviewImages(TestCase):
|
||||||
"text_three": "@possum@local.com",
|
"text_three": "@possum@local.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
result = generate_preview_image(texts=texts, picture=image_file, rating=5)
|
result = generate_preview_image(texts=texts, picture=image_path, rating=5)
|
||||||
self.assertIsInstance(result, Image.Image)
|
self.assertIsInstance(result, Image.Image)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
result.size, (settings.PREVIEW_IMG_WIDTH, settings.PREVIEW_IMG_HEIGHT)
|
result.size, (settings.PREVIEW_IMG_WIDTH, settings.PREVIEW_IMG_HEIGHT)
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
""" test for app action functionality """
|
""" test for app action functionality """
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
@ -179,7 +178,6 @@ class FederationViews(TestCase):
|
||||||
self.assertEqual(server.application_type, "coolsoft")
|
self.assertEqual(server.application_type, "coolsoft")
|
||||||
self.assertEqual(server.status, "blocked")
|
self.assertEqual(server.status, "blocked")
|
||||||
|
|
||||||
# pylint: disable=consider-using-with
|
|
||||||
def test_import_blocklist(self):
|
def test_import_blocklist(self):
|
||||||
"""load a json file with a list of servers to block"""
|
"""load a json file with a list of servers to block"""
|
||||||
server = models.FederatedServer.objects.create(server_name="hi.there.com")
|
server = models.FederatedServer.objects.create(server_name="hi.there.com")
|
||||||
|
@ -191,14 +189,13 @@ class FederationViews(TestCase):
|
||||||
{"instance": "hi.there.com", "url": "https://explanation.url"}, # existing
|
{"instance": "hi.there.com", "url": "https://explanation.url"}, # existing
|
||||||
{"a": "b"}, # invalid
|
{"a": "b"}, # invalid
|
||||||
]
|
]
|
||||||
json.dump(data, open("file.json", "w")) # pylint: disable=unspecified-encoding
|
|
||||||
|
|
||||||
view = views.ImportServerBlocklist.as_view()
|
view = views.ImportServerBlocklist.as_view()
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"",
|
"",
|
||||||
{
|
{
|
||||||
"json_file": SimpleUploadedFile(
|
"json_file": SimpleUploadedFile(
|
||||||
"file.json", open("file.json", "rb").read()
|
"file.json", json.dumps(data).encode("utf-8")
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -214,6 +211,3 @@ class FederationViews(TestCase):
|
||||||
created = models.FederatedServer.objects.get(server_name="server.name")
|
created = models.FederatedServer.objects.get(server_name="server.name")
|
||||||
self.assertEqual(created.status, "blocked")
|
self.assertEqual(created.status, "blocked")
|
||||||
self.assertEqual(created.notes, "https://explanation.url")
|
self.assertEqual(created.notes, "https://explanation.url")
|
||||||
|
|
||||||
# remove file.json after test
|
|
||||||
os.remove("file.json")
|
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
""" test for app action functionality """
|
""" test for app action functionality """
|
||||||
from io import BytesIO
|
|
||||||
import pathlib
|
import pathlib
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
import responses
|
import responses
|
||||||
|
|
||||||
|
@ -161,15 +159,15 @@ class BookViews(TestCase):
|
||||||
def test_upload_cover_file(self):
|
def test_upload_cover_file(self):
|
||||||
"""add a cover via file upload"""
|
"""add a cover via file upload"""
|
||||||
self.assertFalse(self.book.cover)
|
self.assertFalse(self.book.cover)
|
||||||
image_file = pathlib.Path(__file__).parent.joinpath(
|
image_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
"../../../static/images/default_avi.jpg"
|
"../../../static/images/default_avi.jpg"
|
||||||
)
|
)
|
||||||
|
|
||||||
form = forms.CoverForm(instance=self.book)
|
form = forms.CoverForm(instance=self.book)
|
||||||
# pylint: disable=consider-using-with
|
with open(image_path, "rb") as image_file:
|
||||||
form.data["cover"] = SimpleUploadedFile(
|
form.data["cover"] = SimpleUploadedFile(
|
||||||
image_file, open(image_file, "rb").read(), content_type="image/jpeg"
|
image_path, image_file.read(), content_type="image/jpeg"
|
||||||
)
|
)
|
||||||
|
|
||||||
request = self.factory.post("", form.data)
|
request = self.factory.post("", form.data)
|
||||||
request.user = self.local_user
|
request.user = self.local_user
|
||||||
|
@ -296,16 +294,14 @@ class BookViews(TestCase):
|
||||||
def _setup_cover_url():
|
def _setup_cover_url():
|
||||||
"""creates cover url mock"""
|
"""creates cover url mock"""
|
||||||
cover_url = "http://example.com"
|
cover_url = "http://example.com"
|
||||||
image_file = pathlib.Path(__file__).parent.joinpath(
|
image_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
"../../../static/images/default_avi.jpg"
|
"../../../static/images/default_avi.jpg"
|
||||||
)
|
)
|
||||||
image = Image.open(image_file)
|
with open(image_path, "rb") as image_file:
|
||||||
output = BytesIO()
|
responses.add(
|
||||||
image.save(output, format=image.format)
|
responses.GET,
|
||||||
responses.add(
|
cover_url,
|
||||||
responses.GET,
|
body=image_file.read(),
|
||||||
cover_url,
|
status=200,
|
||||||
body=output.getvalue(),
|
)
|
||||||
status=200,
|
|
||||||
)
|
|
||||||
return cover_url
|
return cover_url
|
||||||
|
|
|
@ -81,13 +81,13 @@ class ImportViews(TestCase):
|
||||||
form.data["source"] = "Goodreads"
|
form.data["source"] = "Goodreads"
|
||||||
form.data["privacy"] = "public"
|
form.data["privacy"] = "public"
|
||||||
form.data["include_reviews"] = False
|
form.data["include_reviews"] = False
|
||||||
csv_file = pathlib.Path(__file__).parent.joinpath("../../data/goodreads.csv")
|
csv_path = pathlib.Path(__file__).parent.joinpath("../../data/goodreads.csv")
|
||||||
form.data["csv_file"] = SimpleUploadedFile(
|
with open(csv_path, "rb") as csv_file:
|
||||||
# pylint: disable=consider-using-with
|
form.data["csv_file"] = SimpleUploadedFile(
|
||||||
csv_file,
|
csv_path,
|
||||||
open(csv_file, "rb").read(),
|
csv_file.read(),
|
||||||
content_type="text/csv",
|
content_type="text/csv",
|
||||||
)
|
)
|
||||||
|
|
||||||
request = self.factory.post("", form.data)
|
request = self.factory.post("", form.data)
|
||||||
request.user = self.local_user
|
request.user = self.local_user
|
||||||
|
|
|
@ -47,16 +47,16 @@ class ImportUserViews(TestCase):
|
||||||
|
|
||||||
view = views.UserImport.as_view()
|
view = views.UserImport.as_view()
|
||||||
form = forms.ImportUserForm()
|
form = forms.ImportUserForm()
|
||||||
archive_file = pathlib.Path(__file__).parent.joinpath(
|
archive_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
"../../data/bookwyrm_account_export.tar.gz"
|
"../../data/bookwyrm_account_export.tar.gz"
|
||||||
)
|
)
|
||||||
|
|
||||||
form.data["archive_file"] = SimpleUploadedFile(
|
with open(archive_path, "rb") as archive_file:
|
||||||
# pylint: disable=consider-using-with
|
form.data["archive_file"] = SimpleUploadedFile(
|
||||||
archive_file,
|
archive_path,
|
||||||
open(archive_file, "rb").read(),
|
archive_file.read(),
|
||||||
content_type="application/gzip",
|
content_type="application/gzip",
|
||||||
)
|
)
|
||||||
|
|
||||||
form.data["include_user_settings"] = ""
|
form.data["include_user_settings"] = ""
|
||||||
form.data["include_goals"] = "on"
|
form.data["include_goals"] = "on"
|
||||||
|
|
|
@ -96,13 +96,13 @@ class EditUserViews(TestCase):
|
||||||
form.data["email"] = "wow@email.com"
|
form.data["email"] = "wow@email.com"
|
||||||
form.data["default_post_privacy"] = "public"
|
form.data["default_post_privacy"] = "public"
|
||||||
form.data["preferred_timezone"] = "UTC"
|
form.data["preferred_timezone"] = "UTC"
|
||||||
image_file = pathlib.Path(__file__).parent.joinpath(
|
image_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
"../../../static/images/no_cover.jpg"
|
"../../../static/images/no_cover.jpg"
|
||||||
)
|
)
|
||||||
# pylint: disable=consider-using-with
|
with open(image_path, "rb") as image_file:
|
||||||
form.data["avatar"] = SimpleUploadedFile(
|
form.data["avatar"] = SimpleUploadedFile(
|
||||||
image_file, open(image_file, "rb").read(), content_type="image/jpeg"
|
image_path, image_file.read(), content_type="image/jpeg"
|
||||||
)
|
)
|
||||||
request = self.factory.post("", form.data)
|
request = self.factory.post("", form.data)
|
||||||
request.user = self.local_user
|
request.user = self.local_user
|
||||||
|
|
||||||
|
@ -119,12 +119,12 @@ class EditUserViews(TestCase):
|
||||||
|
|
||||||
def test_crop_avatar(self, _):
|
def test_crop_avatar(self, _):
|
||||||
"""reduce that image size"""
|
"""reduce that image size"""
|
||||||
image_file = pathlib.Path(__file__).parent.joinpath(
|
image_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
"../../../static/images/no_cover.jpg"
|
"../../../static/images/no_cover.jpg"
|
||||||
)
|
)
|
||||||
image = Image.open(image_file)
|
|
||||||
|
|
||||||
result = views.preferences.edit_user.crop_avatar(image)
|
with Image.open(image_path) as image:
|
||||||
|
result = views.preferences.edit_user.crop_avatar(image)
|
||||||
self.assertIsInstance(result, ContentFile)
|
self.assertIsInstance(result, ContentFile)
|
||||||
image_result = Image.open(result)
|
with Image.open(result) as image_result:
|
||||||
self.assertEqual(image_result.size, (120, 120))
|
self.assertEqual(image_result.size, (120, 120))
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
""" test for app action functionality """
|
""" test for app action functionality """
|
||||||
from io import BytesIO
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
from PIL import Image
|
|
||||||
from django.core.files.base import ContentFile
|
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
@ -142,12 +139,9 @@ class FeedViews(TestCase):
|
||||||
"""there are so many views, this just makes sure it LOADS"""
|
"""there are so many views, this just makes sure it LOADS"""
|
||||||
view = views.Status.as_view()
|
view = views.Status.as_view()
|
||||||
|
|
||||||
image_file = pathlib.Path(__file__).parent.joinpath(
|
image_path = pathlib.Path(__file__).parent.joinpath(
|
||||||
"../../static/images/default_avi.jpg"
|
"../../static/images/default_avi.jpg"
|
||||||
)
|
)
|
||||||
image = Image.open(image_file)
|
|
||||||
output = BytesIO()
|
|
||||||
image.save(output, format=image.format)
|
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
|
||||||
status = models.Review.objects.create(
|
status = models.Review.objects.create(
|
||||||
content="hi",
|
content="hi",
|
||||||
|
@ -157,7 +151,8 @@ class FeedViews(TestCase):
|
||||||
attachment = models.Image.objects.create(
|
attachment = models.Image.objects.create(
|
||||||
status=status, caption="alt text here"
|
status=status, caption="alt text here"
|
||||||
)
|
)
|
||||||
attachment.image.save("test.jpg", ContentFile(output.getvalue()))
|
with open(image_path, "rb") as image_file:
|
||||||
|
attachment.image.save("test.jpg", image_file)
|
||||||
|
|
||||||
request = self.factory.get("")
|
request = self.factory.get("")
|
||||||
request.user = self.local_user
|
request.user = self.local_user
|
||||||
|
|
|
@ -67,6 +67,14 @@ class PartialDate(datetime):
|
||||||
# current_timezone and default_timezone.
|
# current_timezone and default_timezone.
|
||||||
return cls.from_datetime(datetime(year, month, day, tzinfo=_westmost_tz))
|
return cls.from_datetime(datetime(year, month, day, tzinfo=_westmost_tz))
|
||||||
|
|
||||||
|
def __eq__(self, other: object) -> bool:
|
||||||
|
if not isinstance(other, PartialDate):
|
||||||
|
return NotImplemented
|
||||||
|
return self.partial_isoformat() == other.partial_isoformat()
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<{self.__class__.__name__} object: {self.partial_isoformat()}>"
|
||||||
|
|
||||||
|
|
||||||
class MonthParts(PartialDate):
|
class MonthParts(PartialDate):
|
||||||
"""a date bound into month precision"""
|
"""a date bound into month precision"""
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" the good people stuff! the authors! """
|
""" the good people stuff! the authors! """
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required, permission_required
|
from django.contrib.auth.decorators import login_required, permission_required
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
@ -11,7 +12,11 @@ from bookwyrm import forms, models
|
||||||
from bookwyrm.activitypub import ActivitypubResponse
|
from bookwyrm.activitypub import ActivitypubResponse
|
||||||
from bookwyrm.connectors import connector_manager
|
from bookwyrm.connectors import connector_manager
|
||||||
from bookwyrm.settings import PAGE_LENGTH
|
from bookwyrm.settings import PAGE_LENGTH
|
||||||
from bookwyrm.views.helpers import is_api_request, maybe_redirect_local_path
|
from bookwyrm.views.helpers import (
|
||||||
|
is_api_request,
|
||||||
|
get_mergeable_object_or_404,
|
||||||
|
maybe_redirect_local_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable= no-self-use
|
# pylint: disable= no-self-use
|
||||||
|
@ -21,7 +26,7 @@ class Author(View):
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def get(self, request, author_id, slug=None):
|
def get(self, request, author_id, slug=None):
|
||||||
"""landing page for an author"""
|
"""landing page for an author"""
|
||||||
author = get_object_or_404(models.Author, id=author_id)
|
author = get_mergeable_object_or_404(models.Author, id=author_id)
|
||||||
|
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
return ActivitypubResponse(author.to_activity())
|
return ActivitypubResponse(author.to_activity())
|
||||||
|
@ -56,13 +61,13 @@ class EditAuthor(View):
|
||||||
|
|
||||||
def get(self, request, author_id):
|
def get(self, request, author_id):
|
||||||
"""info about a book"""
|
"""info about a book"""
|
||||||
author = get_object_or_404(models.Author, id=author_id)
|
author = get_mergeable_object_or_404(models.Author, id=author_id)
|
||||||
data = {"author": author, "form": forms.AuthorForm(instance=author)}
|
data = {"author": author, "form": forms.AuthorForm(instance=author)}
|
||||||
return TemplateResponse(request, "author/edit_author.html", data)
|
return TemplateResponse(request, "author/edit_author.html", data)
|
||||||
|
|
||||||
def post(self, request, author_id):
|
def post(self, request, author_id):
|
||||||
"""edit a author cool"""
|
"""edit a author cool"""
|
||||||
author = get_object_or_404(models.Author, id=author_id)
|
author = get_mergeable_object_or_404(models.Author, id=author_id)
|
||||||
|
|
||||||
form = forms.AuthorForm(request.POST, request.FILES, instance=author)
|
form = forms.AuthorForm(request.POST, request.FILES, instance=author)
|
||||||
if not form.is_valid():
|
if not form.is_valid():
|
||||||
|
@ -82,7 +87,7 @@ def update_author_from_remote(request, author_id, connector_identifier):
|
||||||
connector = connector_manager.load_connector(
|
connector = connector_manager.load_connector(
|
||||||
get_object_or_404(models.Connector, identifier=connector_identifier)
|
get_object_or_404(models.Connector, identifier=connector_identifier)
|
||||||
)
|
)
|
||||||
author = get_object_or_404(models.Author, id=author_id)
|
author = get_mergeable_object_or_404(models.Author, id=author_id)
|
||||||
|
|
||||||
connector.update_author_from_remote(author)
|
connector.update_author_from_remote(author)
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" the good stuff! the books! """
|
""" the good stuff! the books! """
|
||||||
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required, permission_required
|
from django.contrib.auth.decorators import login_required, permission_required
|
||||||
|
@ -15,7 +16,11 @@ from bookwyrm.activitypub import ActivitypubResponse
|
||||||
from bookwyrm.connectors import connector_manager, ConnectorException
|
from bookwyrm.connectors import connector_manager, ConnectorException
|
||||||
from bookwyrm.connectors.abstract_connector import get_image
|
from bookwyrm.connectors.abstract_connector import get_image
|
||||||
from bookwyrm.settings import PAGE_LENGTH
|
from bookwyrm.settings import PAGE_LENGTH
|
||||||
from bookwyrm.views.helpers import is_api_request, maybe_redirect_local_path
|
from bookwyrm.views.helpers import (
|
||||||
|
is_api_request,
|
||||||
|
maybe_redirect_local_path,
|
||||||
|
get_mergeable_object_or_404,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=no-self-use
|
# pylint: disable=no-self-use
|
||||||
|
@ -40,7 +45,11 @@ class Book(View):
|
||||||
# table, so they never have clashing IDs
|
# table, so they never have clashing IDs
|
||||||
book = (
|
book = (
|
||||||
models.Edition.viewer_aware_objects(request.user)
|
models.Edition.viewer_aware_objects(request.user)
|
||||||
.filter(Q(id=book_id) | Q(parent_work__id=book_id))
|
.filter(
|
||||||
|
Q(id=book_id)
|
||||||
|
| Q(parent_work__id=book_id)
|
||||||
|
| Q(absorbed__deleted_id=book_id)
|
||||||
|
)
|
||||||
.order_by("-edition_rank")
|
.order_by("-edition_rank")
|
||||||
.select_related("parent_work")
|
.select_related("parent_work")
|
||||||
.prefetch_related("authors", "file_links")
|
.prefetch_related("authors", "file_links")
|
||||||
|
@ -82,11 +91,13 @@ class Book(View):
|
||||||
"book": book,
|
"book": book,
|
||||||
"statuses": paginated.get_page(request.GET.get("page")),
|
"statuses": paginated.get_page(request.GET.get("page")),
|
||||||
"review_count": reviews.count(),
|
"review_count": reviews.count(),
|
||||||
"ratings": reviews.filter(
|
"ratings": (
|
||||||
Q(content__isnull=True) | Q(content="")
|
reviews.filter(Q(content__isnull=True) | Q(content="")).select_related(
|
||||||
).select_related("user")
|
"user"
|
||||||
if not user_statuses
|
)
|
||||||
else None,
|
if not user_statuses
|
||||||
|
else None
|
||||||
|
),
|
||||||
"rating": reviews.aggregate(Avg("rating"))["rating__avg"],
|
"rating": reviews.aggregate(Avg("rating"))["rating__avg"],
|
||||||
"lists": lists,
|
"lists": lists,
|
||||||
"update_error": kwargs.get("update_error", False),
|
"update_error": kwargs.get("update_error", False),
|
||||||
|
@ -130,7 +141,7 @@ class Book(View):
|
||||||
@require_POST
|
@require_POST
|
||||||
def upload_cover(request, book_id):
|
def upload_cover(request, book_id):
|
||||||
"""upload a new cover"""
|
"""upload a new cover"""
|
||||||
book = get_object_or_404(models.Edition, id=book_id)
|
book = get_mergeable_object_or_404(models.Edition, id=book_id)
|
||||||
book.last_edited_by = request.user
|
book.last_edited_by = request.user
|
||||||
|
|
||||||
url = request.POST.get("cover-url")
|
url = request.POST.get("cover-url")
|
||||||
|
@ -168,7 +179,7 @@ def set_cover_from_url(url):
|
||||||
@permission_required("bookwyrm.edit_book", raise_exception=True)
|
@permission_required("bookwyrm.edit_book", raise_exception=True)
|
||||||
def add_description(request, book_id):
|
def add_description(request, book_id):
|
||||||
"""upload a new cover"""
|
"""upload a new cover"""
|
||||||
book = get_object_or_404(models.Edition, id=book_id)
|
book = get_mergeable_object_or_404(models.Edition, id=book_id)
|
||||||
|
|
||||||
description = request.POST.get("description")
|
description = request.POST.get("description")
|
||||||
|
|
||||||
|
@ -199,7 +210,9 @@ def update_book_from_remote(request, book_id, connector_identifier):
|
||||||
connector = connector_manager.load_connector(
|
connector = connector_manager.load_connector(
|
||||||
get_object_or_404(models.Connector, identifier=connector_identifier)
|
get_object_or_404(models.Connector, identifier=connector_identifier)
|
||||||
)
|
)
|
||||||
book = get_object_or_404(models.Book.objects.select_subclasses(), id=book_id)
|
book = get_mergeable_object_or_404(
|
||||||
|
models.Book.objects.select_subclasses(), id=book_id
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
connector.update_book_from_remote(book)
|
connector.update_book_from_remote(book)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" the good stuff! the books! """
|
""" the good stuff! the books! """
|
||||||
|
|
||||||
from re import sub, findall
|
from re import sub, findall
|
||||||
from django.contrib.auth.decorators import login_required, permission_required
|
from django.contrib.auth.decorators import login_required, permission_required
|
||||||
from django.contrib.postgres.search import SearchRank, SearchVector
|
from django.contrib.postgres.search import SearchRank, SearchVector
|
||||||
|
@ -18,9 +19,10 @@ from bookwyrm.utils.isni import (
|
||||||
build_author_from_isni,
|
build_author_from_isni,
|
||||||
augment_author_metadata,
|
augment_author_metadata,
|
||||||
)
|
)
|
||||||
from bookwyrm.views.helpers import get_edition
|
from bookwyrm.views.helpers import get_edition, get_mergeable_object_or_404
|
||||||
from .books import set_cover_from_url
|
from .books import set_cover_from_url
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=no-self-use
|
# pylint: disable=no-self-use
|
||||||
@method_decorator(login_required, name="dispatch")
|
@method_decorator(login_required, name="dispatch")
|
||||||
@method_decorator(
|
@method_decorator(
|
||||||
|
@ -42,7 +44,7 @@ class EditBook(View):
|
||||||
|
|
||||||
def post(self, request, book_id):
|
def post(self, request, book_id):
|
||||||
"""edit a book cool"""
|
"""edit a book cool"""
|
||||||
book = get_object_or_404(models.Edition, id=book_id)
|
book = get_mergeable_object_or_404(models.Edition, id=book_id)
|
||||||
|
|
||||||
form = forms.EditionForm(request.POST, request.FILES, instance=book)
|
form = forms.EditionForm(request.POST, request.FILES, instance=book)
|
||||||
|
|
||||||
|
@ -130,7 +132,7 @@ class CreateBook(View):
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
book = form.save(request)
|
book = form.save(request)
|
||||||
parent_work = get_object_or_404(models.Work, id=parent_work_id)
|
parent_work = get_mergeable_object_or_404(models.Work, id=parent_work_id)
|
||||||
book.parent_work = parent_work
|
book.parent_work = parent_work
|
||||||
|
|
||||||
if authors:
|
if authors:
|
||||||
|
@ -295,7 +297,7 @@ class ConfirmEditBook(View):
|
||||||
if not book.parent_work:
|
if not book.parent_work:
|
||||||
work_match = request.POST.get("parent_work")
|
work_match = request.POST.get("parent_work")
|
||||||
if work_match and work_match != "0":
|
if work_match and work_match != "0":
|
||||||
work = get_object_or_404(models.Work, id=work_match)
|
work = get_mergeable_object_or_404(models.Work, id=work_match)
|
||||||
else:
|
else:
|
||||||
work = models.Work.objects.create(title=form.cleaned_data["title"])
|
work = models.Work.objects.create(title=form.cleaned_data["title"])
|
||||||
work.authors.set(book.authors.all())
|
work.authors.set(book.authors.all())
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" the good stuff! the books! """
|
""" the good stuff! the books! """
|
||||||
|
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
import operator
|
import operator
|
||||||
|
|
||||||
|
@ -7,7 +8,7 @@ from django.core.cache import cache as django_cache
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import redirect
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
@ -15,7 +16,7 @@ from django.views.decorators.http import require_POST
|
||||||
from bookwyrm import forms, models
|
from bookwyrm import forms, models
|
||||||
from bookwyrm.activitypub import ActivitypubResponse
|
from bookwyrm.activitypub import ActivitypubResponse
|
||||||
from bookwyrm.settings import PAGE_LENGTH
|
from bookwyrm.settings import PAGE_LENGTH
|
||||||
from bookwyrm.views.helpers import is_api_request
|
from bookwyrm.views.helpers import is_api_request, get_mergeable_object_or_404
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=no-self-use
|
# pylint: disable=no-self-use
|
||||||
|
@ -24,7 +25,7 @@ class Editions(View):
|
||||||
|
|
||||||
def get(self, request, book_id):
|
def get(self, request, book_id):
|
||||||
"""list of editions of a book"""
|
"""list of editions of a book"""
|
||||||
work = get_object_or_404(models.Work, id=book_id)
|
work = get_mergeable_object_or_404(models.Work, id=book_id)
|
||||||
|
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
return ActivitypubResponse(work.to_edition_list(**request.GET))
|
return ActivitypubResponse(work.to_edition_list(**request.GET))
|
||||||
|
@ -83,7 +84,7 @@ class Editions(View):
|
||||||
def switch_edition(request):
|
def switch_edition(request):
|
||||||
"""switch your copy of a book to a different edition"""
|
"""switch your copy of a book to a different edition"""
|
||||||
edition_id = request.POST.get("edition")
|
edition_id = request.POST.get("edition")
|
||||||
new_edition = get_object_or_404(models.Edition, id=edition_id)
|
new_edition = get_mergeable_object_or_404(models.Edition, id=edition_id)
|
||||||
shelfbooks = models.ShelfBook.objects.filter(
|
shelfbooks = models.ShelfBook.objects.filter(
|
||||||
book__parent_work=new_edition.parent_work, shelf__user=request.user
|
book__parent_work=new_edition.parent_work, shelf__user=request.user
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" the good stuff! the books! """
|
""" the good stuff! the books! """
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required, permission_required
|
from django.contrib.auth.decorators import login_required, permission_required
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
@ -8,6 +9,7 @@ from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from bookwyrm import forms, models
|
from bookwyrm import forms, models
|
||||||
|
from bookwyrm.views.helpers import get_mergeable_object_or_404
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=no-self-use
|
# pylint: disable=no-self-use
|
||||||
|
@ -20,7 +22,7 @@ class BookFileLinks(View):
|
||||||
|
|
||||||
def get(self, request, book_id):
|
def get(self, request, book_id):
|
||||||
"""view links"""
|
"""view links"""
|
||||||
book = get_object_or_404(models.Edition, id=book_id)
|
book = get_mergeable_object_or_404(models.Edition, id=book_id)
|
||||||
annotated_links = get_annotated_links(book)
|
annotated_links = get_annotated_links(book)
|
||||||
|
|
||||||
data = {"book": book, "links": annotated_links}
|
data = {"book": book, "links": annotated_links}
|
||||||
|
@ -36,7 +38,7 @@ class BookFileLinks(View):
|
||||||
|
|
||||||
# this form shouldn't ever really get here, since it's just a dropdown
|
# this form shouldn't ever really get here, since it's just a dropdown
|
||||||
# get the data again rather than redirecting
|
# get the data again rather than redirecting
|
||||||
book = get_object_or_404(models.Edition, id=book_id)
|
book = get_mergeable_object_or_404(models.Edition, id=book_id)
|
||||||
annotated_links = get_annotated_links(book, form=form)
|
annotated_links = get_annotated_links(book, form=form)
|
||||||
|
|
||||||
data = {"book": book, "links": annotated_links}
|
data = {"book": book, "links": annotated_links}
|
||||||
|
@ -75,7 +77,7 @@ class AddFileLink(View):
|
||||||
|
|
||||||
def get(self, request, book_id):
|
def get(self, request, book_id):
|
||||||
"""Create link form"""
|
"""Create link form"""
|
||||||
book = get_object_or_404(models.Edition, id=book_id)
|
book = get_mergeable_object_or_404(models.Edition, id=book_id)
|
||||||
data = {
|
data = {
|
||||||
"file_link_form": forms.FileLinkForm(),
|
"file_link_form": forms.FileLinkForm(),
|
||||||
"book": book,
|
"book": book,
|
||||||
|
@ -85,7 +87,9 @@ class AddFileLink(View):
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def post(self, request, book_id, link_id=None):
|
def post(self, request, book_id, link_id=None):
|
||||||
"""Add a link to a copy of the book you can read"""
|
"""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)
|
book = get_mergeable_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
|
link = get_object_or_404(models.FileLink, id=link_id) if link_id else None
|
||||||
form = forms.FileLinkForm(request.POST, instance=link)
|
form = forms.FileLinkForm(request.POST, instance=link)
|
||||||
if not form.is_valid():
|
if not form.is_valid():
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
""" books belonging to the same series """
|
""" books belonging to the same series """
|
||||||
|
|
||||||
from sys import float_info
|
from sys import float_info
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
|
|
||||||
from bookwyrm.views.helpers import is_api_request
|
from bookwyrm.views.helpers import is_api_request, get_mergeable_object_or_404
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
|
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ class BookSeriesBy(View):
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
author = get_object_or_404(models.Author, id=author_id)
|
author = get_mergeable_object_or_404(models.Author, id=author_id)
|
||||||
|
|
||||||
results = models.Edition.objects.filter(authors=author, series=series_name)
|
results = models.Edition.objects.filter(authors=author, series=series_name)
|
||||||
|
|
||||||
|
@ -56,9 +56,11 @@ class BookSeriesBy(View):
|
||||||
sorted(numbered_books, key=sort_by_series)
|
sorted(numbered_books, key=sort_by_series)
|
||||||
+ sorted(
|
+ sorted(
|
||||||
dated_books,
|
dated_books,
|
||||||
key=lambda book: book.first_published_date
|
key=lambda book: (
|
||||||
if book.first_published_date
|
book.first_published_date
|
||||||
else book.published_date,
|
if book.first_published_date
|
||||||
|
else book.published_date
|
||||||
|
),
|
||||||
)
|
)
|
||||||
+ sorted(
|
+ sorted(
|
||||||
unsortable_books,
|
unsortable_books,
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" Helping new users figure out the lay of the land """
|
""" Helping new users figure out the lay of the land """
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
@ -13,6 +14,7 @@ from django.views import View
|
||||||
from bookwyrm import book_search, forms, models
|
from bookwyrm import book_search, forms, models
|
||||||
from bookwyrm.settings import INSTANCE_ACTOR_USERNAME
|
from bookwyrm.settings import INSTANCE_ACTOR_USERNAME
|
||||||
from bookwyrm.suggested_users import suggested_users
|
from bookwyrm.suggested_users import suggested_users
|
||||||
|
from bookwyrm.views.helpers import get_mergeable_object_or_404
|
||||||
from .preferences.edit_user import save_user_form
|
from .preferences.edit_user import save_user_form
|
||||||
|
|
||||||
|
|
||||||
|
@ -80,8 +82,8 @@ class GetStartedBooks(View):
|
||||||
for k, v in request.POST.items()
|
for k, v in request.POST.items()
|
||||||
if re.match(r"\d+", k) and re.match(r"\d+", v)
|
if re.match(r"\d+", k) and re.match(r"\d+", v)
|
||||||
]
|
]
|
||||||
for (book_id, shelf_id) in shelve_actions:
|
for book_id, shelf_id in shelve_actions:
|
||||||
book = get_object_or_404(models.Edition, id=book_id)
|
book = get_mergeable_object_or_404(models.Edition, id=book_id)
|
||||||
shelf = get_object_or_404(models.Shelf, id=shelf_id)
|
shelf = get_object_or_404(models.Shelf, id=shelf_id)
|
||||||
|
|
||||||
models.ShelfBook.objects.create(book=book, shelf=shelf, user=request.user)
|
models.ShelfBook.objects.create(book=book, shelf=shelf, user=request.user)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" helper functions used in various views """
|
""" helper functions used in various views """
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
|
@ -8,7 +9,7 @@ from dateutil.parser import ParserError
|
||||||
from requests import HTTPError
|
from requests import HTTPError
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.conf import settings as django_settings
|
from django.conf import settings as django_settings
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect, _get_queryset
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
|
|
||||||
|
@ -232,3 +233,19 @@ def redirect_to_referer(request, *args, **kwargs):
|
||||||
|
|
||||||
# if not, use the args passed you'd normally pass to redirect()
|
# if not, use the args passed you'd normally pass to redirect()
|
||||||
return redirect(*args or "/", **kwargs)
|
return redirect(*args or "/", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=redefined-builtin,invalid-name
|
||||||
|
def get_mergeable_object_or_404(klass, id):
|
||||||
|
"""variant of get_object_or_404 that also redirects if id has been merged
|
||||||
|
into another object"""
|
||||||
|
queryset = _get_queryset(klass)
|
||||||
|
try:
|
||||||
|
return queryset.get(pk=id)
|
||||||
|
except queryset.model.DoesNotExist:
|
||||||
|
try:
|
||||||
|
return queryset.get(absorbed__deleted_id=id)
|
||||||
|
except queryset.model.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise Http404(f"No {queryset.model} with ID {id} exists")
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" the good stuff! the books! """
|
""" the good stuff! the books! """
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
@ -11,6 +12,7 @@ from django.views import View
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from bookwyrm import forms, models
|
from bookwyrm import forms, models
|
||||||
|
from bookwyrm.views.helpers import get_mergeable_object_or_404
|
||||||
from bookwyrm.views.shelf.shelf_actions import unshelve
|
from bookwyrm.views.shelf.shelf_actions import unshelve
|
||||||
from .status import CreateStatus
|
from .status import CreateStatus
|
||||||
from .helpers import get_edition, handle_reading_status, is_api_request
|
from .helpers import get_edition, handle_reading_status, is_api_request
|
||||||
|
@ -130,7 +132,7 @@ class ReadThrough(View):
|
||||||
|
|
||||||
def get(self, request, book_id, readthrough_id=None):
|
def get(self, request, book_id, readthrough_id=None):
|
||||||
"""standalone form in case of errors"""
|
"""standalone form in case of errors"""
|
||||||
book = get_object_or_404(models.Edition, id=book_id)
|
book = get_mergeable_object_or_404(models.Edition, id=book_id)
|
||||||
form = forms.ReadThroughForm()
|
form = forms.ReadThroughForm()
|
||||||
data = {"form": form, "book": book}
|
data = {"form": form, "book": book}
|
||||||
if readthrough_id:
|
if readthrough_id:
|
||||||
|
@ -152,7 +154,7 @@ class ReadThrough(View):
|
||||||
)
|
)
|
||||||
form = forms.ReadThroughForm(request.POST)
|
form = forms.ReadThroughForm(request.POST)
|
||||||
if not form.is_valid():
|
if not form.is_valid():
|
||||||
book = get_object_or_404(models.Edition, id=book_id)
|
book = get_mergeable_object_or_404(models.Edition, id=book_id)
|
||||||
data = {"form": form, "book": book}
|
data = {"form": form, "book": book}
|
||||||
if request.POST.get("id"):
|
if request.POST.get("id"):
|
||||||
data["readthrough"] = get_object_or_404(
|
data["readthrough"] = get_object_or_404(
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
""" shelf views """
|
""" shelf views """
|
||||||
|
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from bookwyrm import forms, models
|
from bookwyrm import forms, models
|
||||||
from bookwyrm.views.helpers import redirect_to_referer
|
from bookwyrm.views.helpers import redirect_to_referer, get_mergeable_object_or_404
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@ -36,7 +37,7 @@ def delete_shelf(request, shelf_id):
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def shelve(request):
|
def shelve(request):
|
||||||
"""put a book on a user's shelf"""
|
"""put a book on a user's shelf"""
|
||||||
book = get_object_or_404(models.Edition, id=request.POST.get("book"))
|
book = get_mergeable_object_or_404(models.Edition, id=request.POST.get("book"))
|
||||||
desired_shelf = get_object_or_404(
|
desired_shelf = get_object_or_404(
|
||||||
request.user.shelf_set, identifier=request.POST.get("shelf")
|
request.user.shelf_set, identifier=request.POST.get("shelf")
|
||||||
)
|
)
|
||||||
|
@ -97,7 +98,7 @@ def shelve(request):
|
||||||
def unshelve(request, book_id=False):
|
def unshelve(request, book_id=False):
|
||||||
"""remove a book from a user's shelf"""
|
"""remove a book from a user's shelf"""
|
||||||
identity = book_id if book_id else request.POST.get("book")
|
identity = book_id if book_id else request.POST.get("book")
|
||||||
book = get_object_or_404(models.Edition, id=identity)
|
book = get_mergeable_object_or_404(models.Edition, id=identity)
|
||||||
shelf_book = get_object_or_404(
|
shelf_book = get_object_or_404(
|
||||||
models.ShelfBook, book=book, shelf__id=request.POST["shelf"]
|
models.ShelfBook, book=book, shelf__id=request.POST["shelf"]
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" what are we here for if not for posting """
|
""" what are we here for if not for posting """
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
@ -19,6 +20,7 @@ from markdown import markdown
|
||||||
from bookwyrm import forms, models
|
from bookwyrm import forms, models
|
||||||
from bookwyrm.models.report import DELETE_ITEM
|
from bookwyrm.models.report import DELETE_ITEM
|
||||||
from bookwyrm.utils import regex, sanitizer
|
from bookwyrm.utils import regex, sanitizer
|
||||||
|
from bookwyrm.views.helpers import get_mergeable_object_or_404
|
||||||
from .helpers import handle_remote_webfinger, is_api_request
|
from .helpers import handle_remote_webfinger, is_api_request
|
||||||
from .helpers import load_date_in_user_tz_as_utc, redirect_to_referer
|
from .helpers import load_date_in_user_tz_as_utc, redirect_to_referer
|
||||||
|
|
||||||
|
@ -52,7 +54,7 @@ class CreateStatus(View):
|
||||||
|
|
||||||
def get(self, request, status_type): # pylint: disable=unused-argument
|
def get(self, request, status_type): # pylint: disable=unused-argument
|
||||||
"""compose view (...not used?)"""
|
"""compose view (...not used?)"""
|
||||||
book = get_object_or_404(models.Edition, id=request.GET.get("book"))
|
book = get_mergeable_object_or_404(models.Edition, id=request.GET.get("book"))
|
||||||
data = {"book": book}
|
data = {"book": book}
|
||||||
return TemplateResponse(request, "compose.html", data)
|
return TemplateResponse(request, "compose.html", data)
|
||||||
|
|
||||||
|
@ -98,7 +100,7 @@ class CreateStatus(View):
|
||||||
# inspect the text for user tags
|
# inspect the text for user tags
|
||||||
content = status.content
|
content = status.content
|
||||||
mentions = find_mentions(request.user, content)
|
mentions = find_mentions(request.user, content)
|
||||||
for (_, mention_user) in mentions.items():
|
for _, mention_user in mentions.items():
|
||||||
# add them to status mentions fk
|
# add them to status mentions fk
|
||||||
status.mention_users.add(mention_user)
|
status.mention_users.add(mention_user)
|
||||||
content = format_mentions(content, mentions)
|
content = format_mentions(content, mentions)
|
||||||
|
@ -109,7 +111,7 @@ class CreateStatus(View):
|
||||||
|
|
||||||
# inspect the text for hashtags
|
# inspect the text for hashtags
|
||||||
hashtags = find_or_create_hashtags(content)
|
hashtags = find_or_create_hashtags(content)
|
||||||
for (_, mention_hashtag) in hashtags.items():
|
for _, mention_hashtag in hashtags.items():
|
||||||
# add them to status mentions fk
|
# add them to status mentions fk
|
||||||
status.mention_hashtags.add(mention_hashtag)
|
status.mention_hashtags.add(mention_hashtag)
|
||||||
content = format_hashtags(content, hashtags)
|
content = format_hashtags(content, hashtags)
|
||||||
|
@ -140,7 +142,7 @@ class CreateStatus(View):
|
||||||
|
|
||||||
def format_mentions(content, mentions):
|
def format_mentions(content, mentions):
|
||||||
"""Detect @mentions and make them links"""
|
"""Detect @mentions and make them links"""
|
||||||
for (mention_text, mention_user) in mentions.items():
|
for mention_text, mention_user in mentions.items():
|
||||||
# turn the mention into a link
|
# turn the mention into a link
|
||||||
content = re.sub(
|
content = re.sub(
|
||||||
rf"(?<!/)\B{mention_text}\b(?!@)",
|
rf"(?<!/)\B{mention_text}\b(?!@)",
|
||||||
|
@ -152,7 +154,7 @@ def format_mentions(content, mentions):
|
||||||
|
|
||||||
def format_hashtags(content, hashtags):
|
def format_hashtags(content, hashtags):
|
||||||
"""Detect #hashtags and make them links"""
|
"""Detect #hashtags and make them links"""
|
||||||
for (mention_text, mention_hashtag) in hashtags.items():
|
for mention_text, mention_hashtag in hashtags.items():
|
||||||
# turn the mention into a link
|
# turn the mention into a link
|
||||||
content = re.sub(
|
content = re.sub(
|
||||||
rf"(?<!/)\B{mention_text}\b(?!@)",
|
rf"(?<!/)\B{mention_text}\b(?!@)",
|
||||||
|
|
|
@ -64,7 +64,7 @@ server {
|
||||||
# directly serve static files from the
|
# directly serve static files from the
|
||||||
# bookwyrm filesystem using sendfile.
|
# bookwyrm filesystem using sendfile.
|
||||||
# make the logs quieter by not reporting these requests
|
# make the logs quieter by not reporting these requests
|
||||||
location ~ ^/static/ {
|
location /static/ {
|
||||||
root /app;
|
root /app;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
add_header X-Cache-Status STATIC;
|
add_header X-Cache-Status STATIC;
|
||||||
|
@ -72,15 +72,14 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
# same with image files not in static folder
|
# same with image files not in static folder
|
||||||
location ~ \.(bmp|ico|jpg|jpeg|png|svg|tif|tiff|webp)$ {
|
location /images/ {
|
||||||
root /app;
|
location ~ \.(bmp|ico|jpg|jpeg|png|svg|tif|tiff|webp)$ {
|
||||||
try_files $uri =404;
|
root /app;
|
||||||
add_header X-Cache-Status STATIC;
|
try_files $uri =404;
|
||||||
access_log off;
|
add_header X-Cache-Status STATIC;
|
||||||
}
|
access_log off;
|
||||||
|
}
|
||||||
# block access to any non-image files from images
|
# block access to any non-image files from images
|
||||||
location ~ ^/images/ {
|
|
||||||
return 403;
|
return 403;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -96,23 +96,22 @@ server {
|
||||||
# # directly serve static files from the
|
# # directly serve static files from the
|
||||||
# # bookwyrm filesystem using sendfile.
|
# # bookwyrm filesystem using sendfile.
|
||||||
# # make the logs quieter by not reporting these requests
|
# # make the logs quieter by not reporting these requests
|
||||||
# location ~ ^/static/ {
|
# location /static/ {
|
||||||
# root /app;
|
# root /app;
|
||||||
# try_files $uri =404;
|
# try_files $uri =404;
|
||||||
# add_header X-Cache-Status STATIC;
|
# add_header X-Cache-Status STATIC;
|
||||||
# access_log off;
|
# access_log off;
|
||||||
# }
|
# }
|
||||||
|
#
|
||||||
# # same with image files not in static folder
|
# # same with image files not in static folder
|
||||||
# location ~ \.(bmp|ico|jpg|jpeg|png|svg|tif|tiff|webp)$ {
|
# location /images/ {
|
||||||
# root /app;
|
# location ~ \.(bmp|ico|jpg|jpeg|png|svg|tif|tiff|webp)$ {
|
||||||
# try_files $uri =404;
|
# root /app;
|
||||||
# add_header X-Cache-Status STATIC;
|
# try_files $uri =404;
|
||||||
# access_log off;
|
# add_header X-Cache-Status STATIC;
|
||||||
# }
|
# access_log off;
|
||||||
|
# }
|
||||||
# # block access to any non-image files from images
|
# # block access to any non-image files from images
|
||||||
# location ~ ^/images/ {
|
|
||||||
# return 403;
|
# return 403;
|
||||||
# }
|
# }
|
||||||
#
|
#
|
||||||
|
|
|
@ -26,7 +26,7 @@ opentelemetry-instrumentation-celery==0.37b0
|
||||||
opentelemetry-instrumentation-django==0.37b0
|
opentelemetry-instrumentation-django==0.37b0
|
||||||
opentelemetry-instrumentation-psycopg2==0.37b0
|
opentelemetry-instrumentation-psycopg2==0.37b0
|
||||||
opentelemetry-sdk==1.16.0
|
opentelemetry-sdk==1.16.0
|
||||||
Pillow==10.2.0
|
Pillow==10.3.0
|
||||||
pilkit>2.0 # dependency of django-imagekit, 2.0 is incompatible with Pillow>=10
|
pilkit>2.0 # dependency of django-imagekit, 2.0 is incompatible with Pillow>=10
|
||||||
protobuf==3.20.*
|
protobuf==3.20.*
|
||||||
psycopg2==2.9.5
|
psycopg2==2.9.5
|
||||||
|
|
Loading…
Reference in a new issue