Merge pull request #3341 from Minnozz/django-4.2

Upgrade to Django 4.2
This commit is contained in:
Bart Schuurmans 2024-05-31 17:04:17 +02:00 committed by GitHub
commit d90e8e56d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1202 additions and 358 deletions

View file

@ -400,11 +400,11 @@ def get_representative():
to sign outgoing HTTP GET requests""" to sign outgoing HTTP GET requests"""
return models.User.objects.get_or_create( return models.User.objects.get_or_create(
username=f"{INSTANCE_ACTOR_USERNAME}@{DOMAIN}", username=f"{INSTANCE_ACTOR_USERNAME}@{DOMAIN}",
defaults=dict( defaults={
email="bookwyrm@localhost", "email": "bookwyrm@localhost",
local=True, "local": True,
localname=INSTANCE_ACTOR_USERNAME, "localname": INSTANCE_ACTOR_USERNAME,
), },
)[0] )[0]

View file

@ -145,7 +145,9 @@ def load_more_data(connector_id: str, book_id: str) -> None:
"""background the work of getting all 10,000 editions of LoTR""" """background the work of getting all 10,000 editions of LoTR"""
connector_info = models.Connector.objects.get(id=connector_id) connector_info = models.Connector.objects.get(id=connector_id)
connector = load_connector(connector_info) connector = load_connector(connector_info)
book = models.Book.objects.select_subclasses().get(id=book_id) book = models.Book.objects.select_subclasses().get( # type: ignore[no-untyped-call]
id=book_id
)
connector.expand_book_data(book) connector.expand_book_data(book)
@ -156,7 +158,9 @@ def create_edition_task(
"""separate task for each of the 10,000 editions of LoTR""" """separate task for each of the 10,000 editions of LoTR"""
connector_info = models.Connector.objects.get(id=connector_id) connector_info = models.Connector.objects.get(id=connector_id)
connector = load_connector(connector_info) connector = load_connector(connector_info)
work = models.Work.objects.select_subclasses().get(id=work_id) work = models.Work.objects.select_subclasses().get( # type: ignore[no-untyped-call]
id=work_id
)
connector.create_edition_from_data(work, data) connector.create_edition_from_data(work, data)

View file

@ -229,7 +229,7 @@ class Connector(AbstractConnector):
data = get_data(url) data = get_data(url)
except ConnectorException: except ConnectorException:
return "" return ""
return data.get("extract", "") return str(data.get("extract", ""))
def get_remote_id_from_model(self, obj: models.BookDataModel) -> str: def get_remote_id_from_model(self, obj: models.BookDataModel) -> str:
"""use get_remote_id to figure out the link from a model obj""" """use get_remote_id to figure out the link from a model obj"""

View file

@ -14,15 +14,10 @@ class CalibreImporter(Importer):
def __init__(self, *args: Any, **kwargs: Any): def __init__(self, *args: Any, **kwargs: Any):
# Add timestamp to row_mappings_guesses for date_added to avoid # Add timestamp to row_mappings_guesses for date_added to avoid
# integrity error # integrity error
row_mappings_guesses = [] self.row_mappings_guesses = [
(field, mapping + (["timestamp"] if field == "date_added" else []))
for field, mapping in self.row_mappings_guesses: for field, mapping in self.row_mappings_guesses
if field in ("date_added",): ]
row_mappings_guesses.append((field, mapping + ["timestamp"]))
else:
row_mappings_guesses.append((field, mapping))
self.row_mappings_guesses = row_mappings_guesses
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def get_shelf(self, normalized_row: dict[str, Optional[str]]) -> Optional[str]: def get_shelf(self, normalized_row: dict[str, Optional[str]]) -> Optional[str]:

View file

@ -1,5 +1,5 @@
""" Makes the app aware of the users timezone """ """ Makes the app aware of the users timezone """
import pytz import zoneinfo
from django.utils import timezone from django.utils import timezone
@ -12,9 +12,7 @@ class TimezoneMiddleware:
def __call__(self, request): def __call__(self, request):
if request.user.is_authenticated: if request.user.is_authenticated:
timezone.activate(pytz.timezone(request.user.preferred_timezone)) timezone.activate(zoneinfo.ZoneInfo(request.user.preferred_timezone))
else: else:
timezone.activate(pytz.utc)
response = self.get_response(request)
timezone.deactivate() timezone.deactivate()
return response return self.get_response(request)

View file

@ -10,6 +10,7 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
# The new timezones are "Factory" and "localtime"
migrations.AlterField( migrations.AlterField(
model_name="user", model_name="user",
name="preferred_timezone", name="preferred_timezone",

View file

@ -1,9 +1,9 @@
# Generated by Django 3.2.23 on 2024-01-28 02:49 # Generated by Django 3.2.23 on 2024-01-28 02:49
import bookwyrm.storage_backends
import django.core.serializers.json import django.core.serializers.json
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.core.files.storage import storages
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -30,7 +30,7 @@ class Migration(migrations.Migration):
name="export_data", name="export_data",
field=models.FileField( field=models.FileField(
null=True, null=True,
storage=bookwyrm.storage_backends.ExportsFileStorage, storage=storages["exports"],
upload_to="", upload_to="",
), ),
), ),

View file

@ -0,0 +1,70 @@
# Generated by Django 4.2.11 on 2024-03-29 19:25
import bookwyrm.models.fields
from django.conf import settings
from django.db import migrations
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0198_book_search_vector_author_aliases"),
]
operations = [
migrations.AlterField(
model_name="userblocks",
name="user_object",
field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="%(class)s_user_object",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="userblocks",
name="user_subject",
field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="%(class)s_user_subject",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="userfollowrequest",
name="user_object",
field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="%(class)s_user_object",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="userfollowrequest",
name="user_subject",
field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="%(class)s_user_subject",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="userfollows",
name="user_object",
field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="%(class)s_user_object",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="userfollows",
name="user_subject",
field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="%(class)s_user_subject",
to=settings.AUTH_USER_MODEL,
),
),
]

View file

@ -0,0 +1,633 @@
# Generated by Django 4.2.11 on 2024-04-01 20:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0199_alter_userblocks_user_object_and_more"),
]
operations = [
migrations.AlterField(
model_name="user",
name="preferred_timezone",
field=models.CharField(
choices=[
("Africa/Abidjan", "Africa/Abidjan"),
("Africa/Accra", "Africa/Accra"),
("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
("Africa/Algiers", "Africa/Algiers"),
("Africa/Asmara", "Africa/Asmara"),
("Africa/Asmera", "Africa/Asmera"),
("Africa/Bamako", "Africa/Bamako"),
("Africa/Bangui", "Africa/Bangui"),
("Africa/Banjul", "Africa/Banjul"),
("Africa/Bissau", "Africa/Bissau"),
("Africa/Blantyre", "Africa/Blantyre"),
("Africa/Brazzaville", "Africa/Brazzaville"),
("Africa/Bujumbura", "Africa/Bujumbura"),
("Africa/Cairo", "Africa/Cairo"),
("Africa/Casablanca", "Africa/Casablanca"),
("Africa/Ceuta", "Africa/Ceuta"),
("Africa/Conakry", "Africa/Conakry"),
("Africa/Dakar", "Africa/Dakar"),
("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
("Africa/Djibouti", "Africa/Djibouti"),
("Africa/Douala", "Africa/Douala"),
("Africa/El_Aaiun", "Africa/El_Aaiun"),
("Africa/Freetown", "Africa/Freetown"),
("Africa/Gaborone", "Africa/Gaborone"),
("Africa/Harare", "Africa/Harare"),
("Africa/Johannesburg", "Africa/Johannesburg"),
("Africa/Juba", "Africa/Juba"),
("Africa/Kampala", "Africa/Kampala"),
("Africa/Khartoum", "Africa/Khartoum"),
("Africa/Kigali", "Africa/Kigali"),
("Africa/Kinshasa", "Africa/Kinshasa"),
("Africa/Lagos", "Africa/Lagos"),
("Africa/Libreville", "Africa/Libreville"),
("Africa/Lome", "Africa/Lome"),
("Africa/Luanda", "Africa/Luanda"),
("Africa/Lubumbashi", "Africa/Lubumbashi"),
("Africa/Lusaka", "Africa/Lusaka"),
("Africa/Malabo", "Africa/Malabo"),
("Africa/Maputo", "Africa/Maputo"),
("Africa/Maseru", "Africa/Maseru"),
("Africa/Mbabane", "Africa/Mbabane"),
("Africa/Mogadishu", "Africa/Mogadishu"),
("Africa/Monrovia", "Africa/Monrovia"),
("Africa/Nairobi", "Africa/Nairobi"),
("Africa/Ndjamena", "Africa/Ndjamena"),
("Africa/Niamey", "Africa/Niamey"),
("Africa/Nouakchott", "Africa/Nouakchott"),
("Africa/Ouagadougou", "Africa/Ouagadougou"),
("Africa/Porto-Novo", "Africa/Porto-Novo"),
("Africa/Sao_Tome", "Africa/Sao_Tome"),
("Africa/Timbuktu", "Africa/Timbuktu"),
("Africa/Tripoli", "Africa/Tripoli"),
("Africa/Tunis", "Africa/Tunis"),
("Africa/Windhoek", "Africa/Windhoek"),
("America/Adak", "America/Adak"),
("America/Anchorage", "America/Anchorage"),
("America/Anguilla", "America/Anguilla"),
("America/Antigua", "America/Antigua"),
("America/Araguaina", "America/Araguaina"),
(
"America/Argentina/Buenos_Aires",
"America/Argentina/Buenos_Aires",
),
("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
(
"America/Argentina/ComodRivadavia",
"America/Argentina/ComodRivadavia",
),
("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
(
"America/Argentina/Rio_Gallegos",
"America/Argentina/Rio_Gallegos",
),
("America/Argentina/Salta", "America/Argentina/Salta"),
("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
("America/Aruba", "America/Aruba"),
("America/Asuncion", "America/Asuncion"),
("America/Atikokan", "America/Atikokan"),
("America/Atka", "America/Atka"),
("America/Bahia", "America/Bahia"),
("America/Bahia_Banderas", "America/Bahia_Banderas"),
("America/Barbados", "America/Barbados"),
("America/Belem", "America/Belem"),
("America/Belize", "America/Belize"),
("America/Blanc-Sablon", "America/Blanc-Sablon"),
("America/Boa_Vista", "America/Boa_Vista"),
("America/Bogota", "America/Bogota"),
("America/Boise", "America/Boise"),
("America/Buenos_Aires", "America/Buenos_Aires"),
("America/Cambridge_Bay", "America/Cambridge_Bay"),
("America/Campo_Grande", "America/Campo_Grande"),
("America/Cancun", "America/Cancun"),
("America/Caracas", "America/Caracas"),
("America/Catamarca", "America/Catamarca"),
("America/Cayenne", "America/Cayenne"),
("America/Cayman", "America/Cayman"),
("America/Chicago", "America/Chicago"),
("America/Chihuahua", "America/Chihuahua"),
("America/Ciudad_Juarez", "America/Ciudad_Juarez"),
("America/Coral_Harbour", "America/Coral_Harbour"),
("America/Cordoba", "America/Cordoba"),
("America/Costa_Rica", "America/Costa_Rica"),
("America/Creston", "America/Creston"),
("America/Cuiaba", "America/Cuiaba"),
("America/Curacao", "America/Curacao"),
("America/Danmarkshavn", "America/Danmarkshavn"),
("America/Dawson", "America/Dawson"),
("America/Dawson_Creek", "America/Dawson_Creek"),
("America/Denver", "America/Denver"),
("America/Detroit", "America/Detroit"),
("America/Dominica", "America/Dominica"),
("America/Edmonton", "America/Edmonton"),
("America/Eirunepe", "America/Eirunepe"),
("America/El_Salvador", "America/El_Salvador"),
("America/Ensenada", "America/Ensenada"),
("America/Fort_Nelson", "America/Fort_Nelson"),
("America/Fort_Wayne", "America/Fort_Wayne"),
("America/Fortaleza", "America/Fortaleza"),
("America/Glace_Bay", "America/Glace_Bay"),
("America/Godthab", "America/Godthab"),
("America/Goose_Bay", "America/Goose_Bay"),
("America/Grand_Turk", "America/Grand_Turk"),
("America/Grenada", "America/Grenada"),
("America/Guadeloupe", "America/Guadeloupe"),
("America/Guatemala", "America/Guatemala"),
("America/Guayaquil", "America/Guayaquil"),
("America/Guyana", "America/Guyana"),
("America/Halifax", "America/Halifax"),
("America/Havana", "America/Havana"),
("America/Hermosillo", "America/Hermosillo"),
("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
("America/Indiana/Knox", "America/Indiana/Knox"),
("America/Indiana/Marengo", "America/Indiana/Marengo"),
("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
("America/Indiana/Vevay", "America/Indiana/Vevay"),
("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
("America/Indiana/Winamac", "America/Indiana/Winamac"),
("America/Indianapolis", "America/Indianapolis"),
("America/Inuvik", "America/Inuvik"),
("America/Iqaluit", "America/Iqaluit"),
("America/Jamaica", "America/Jamaica"),
("America/Jujuy", "America/Jujuy"),
("America/Juneau", "America/Juneau"),
("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
("America/Knox_IN", "America/Knox_IN"),
("America/Kralendijk", "America/Kralendijk"),
("America/La_Paz", "America/La_Paz"),
("America/Lima", "America/Lima"),
("America/Los_Angeles", "America/Los_Angeles"),
("America/Louisville", "America/Louisville"),
("America/Lower_Princes", "America/Lower_Princes"),
("America/Maceio", "America/Maceio"),
("America/Managua", "America/Managua"),
("America/Manaus", "America/Manaus"),
("America/Marigot", "America/Marigot"),
("America/Martinique", "America/Martinique"),
("America/Matamoros", "America/Matamoros"),
("America/Mazatlan", "America/Mazatlan"),
("America/Mendoza", "America/Mendoza"),
("America/Menominee", "America/Menominee"),
("America/Merida", "America/Merida"),
("America/Metlakatla", "America/Metlakatla"),
("America/Mexico_City", "America/Mexico_City"),
("America/Miquelon", "America/Miquelon"),
("America/Moncton", "America/Moncton"),
("America/Monterrey", "America/Monterrey"),
("America/Montevideo", "America/Montevideo"),
("America/Montreal", "America/Montreal"),
("America/Montserrat", "America/Montserrat"),
("America/Nassau", "America/Nassau"),
("America/New_York", "America/New_York"),
("America/Nipigon", "America/Nipigon"),
("America/Nome", "America/Nome"),
("America/Noronha", "America/Noronha"),
("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
("America/North_Dakota/Center", "America/North_Dakota/Center"),
(
"America/North_Dakota/New_Salem",
"America/North_Dakota/New_Salem",
),
("America/Nuuk", "America/Nuuk"),
("America/Ojinaga", "America/Ojinaga"),
("America/Panama", "America/Panama"),
("America/Pangnirtung", "America/Pangnirtung"),
("America/Paramaribo", "America/Paramaribo"),
("America/Phoenix", "America/Phoenix"),
("America/Port-au-Prince", "America/Port-au-Prince"),
("America/Port_of_Spain", "America/Port_of_Spain"),
("America/Porto_Acre", "America/Porto_Acre"),
("America/Porto_Velho", "America/Porto_Velho"),
("America/Puerto_Rico", "America/Puerto_Rico"),
("America/Punta_Arenas", "America/Punta_Arenas"),
("America/Rainy_River", "America/Rainy_River"),
("America/Rankin_Inlet", "America/Rankin_Inlet"),
("America/Recife", "America/Recife"),
("America/Regina", "America/Regina"),
("America/Resolute", "America/Resolute"),
("America/Rio_Branco", "America/Rio_Branco"),
("America/Rosario", "America/Rosario"),
("America/Santa_Isabel", "America/Santa_Isabel"),
("America/Santarem", "America/Santarem"),
("America/Santiago", "America/Santiago"),
("America/Santo_Domingo", "America/Santo_Domingo"),
("America/Sao_Paulo", "America/Sao_Paulo"),
("America/Scoresbysund", "America/Scoresbysund"),
("America/Shiprock", "America/Shiprock"),
("America/Sitka", "America/Sitka"),
("America/St_Barthelemy", "America/St_Barthelemy"),
("America/St_Johns", "America/St_Johns"),
("America/St_Kitts", "America/St_Kitts"),
("America/St_Lucia", "America/St_Lucia"),
("America/St_Thomas", "America/St_Thomas"),
("America/St_Vincent", "America/St_Vincent"),
("America/Swift_Current", "America/Swift_Current"),
("America/Tegucigalpa", "America/Tegucigalpa"),
("America/Thule", "America/Thule"),
("America/Thunder_Bay", "America/Thunder_Bay"),
("America/Tijuana", "America/Tijuana"),
("America/Toronto", "America/Toronto"),
("America/Tortola", "America/Tortola"),
("America/Vancouver", "America/Vancouver"),
("America/Virgin", "America/Virgin"),
("America/Whitehorse", "America/Whitehorse"),
("America/Winnipeg", "America/Winnipeg"),
("America/Yakutat", "America/Yakutat"),
("America/Yellowknife", "America/Yellowknife"),
("Antarctica/Casey", "Antarctica/Casey"),
("Antarctica/Davis", "Antarctica/Davis"),
("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
("Antarctica/Macquarie", "Antarctica/Macquarie"),
("Antarctica/Mawson", "Antarctica/Mawson"),
("Antarctica/McMurdo", "Antarctica/McMurdo"),
("Antarctica/Palmer", "Antarctica/Palmer"),
("Antarctica/Rothera", "Antarctica/Rothera"),
("Antarctica/South_Pole", "Antarctica/South_Pole"),
("Antarctica/Syowa", "Antarctica/Syowa"),
("Antarctica/Troll", "Antarctica/Troll"),
("Antarctica/Vostok", "Antarctica/Vostok"),
("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
("Asia/Aden", "Asia/Aden"),
("Asia/Almaty", "Asia/Almaty"),
("Asia/Amman", "Asia/Amman"),
("Asia/Anadyr", "Asia/Anadyr"),
("Asia/Aqtau", "Asia/Aqtau"),
("Asia/Aqtobe", "Asia/Aqtobe"),
("Asia/Ashgabat", "Asia/Ashgabat"),
("Asia/Ashkhabad", "Asia/Ashkhabad"),
("Asia/Atyrau", "Asia/Atyrau"),
("Asia/Baghdad", "Asia/Baghdad"),
("Asia/Bahrain", "Asia/Bahrain"),
("Asia/Baku", "Asia/Baku"),
("Asia/Bangkok", "Asia/Bangkok"),
("Asia/Barnaul", "Asia/Barnaul"),
("Asia/Beirut", "Asia/Beirut"),
("Asia/Bishkek", "Asia/Bishkek"),
("Asia/Brunei", "Asia/Brunei"),
("Asia/Calcutta", "Asia/Calcutta"),
("Asia/Chita", "Asia/Chita"),
("Asia/Choibalsan", "Asia/Choibalsan"),
("Asia/Chongqing", "Asia/Chongqing"),
("Asia/Chungking", "Asia/Chungking"),
("Asia/Colombo", "Asia/Colombo"),
("Asia/Dacca", "Asia/Dacca"),
("Asia/Damascus", "Asia/Damascus"),
("Asia/Dhaka", "Asia/Dhaka"),
("Asia/Dili", "Asia/Dili"),
("Asia/Dubai", "Asia/Dubai"),
("Asia/Dushanbe", "Asia/Dushanbe"),
("Asia/Famagusta", "Asia/Famagusta"),
("Asia/Gaza", "Asia/Gaza"),
("Asia/Harbin", "Asia/Harbin"),
("Asia/Hebron", "Asia/Hebron"),
("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
("Asia/Hong_Kong", "Asia/Hong_Kong"),
("Asia/Hovd", "Asia/Hovd"),
("Asia/Irkutsk", "Asia/Irkutsk"),
("Asia/Istanbul", "Asia/Istanbul"),
("Asia/Jakarta", "Asia/Jakarta"),
("Asia/Jayapura", "Asia/Jayapura"),
("Asia/Jerusalem", "Asia/Jerusalem"),
("Asia/Kabul", "Asia/Kabul"),
("Asia/Kamchatka", "Asia/Kamchatka"),
("Asia/Karachi", "Asia/Karachi"),
("Asia/Kashgar", "Asia/Kashgar"),
("Asia/Kathmandu", "Asia/Kathmandu"),
("Asia/Katmandu", "Asia/Katmandu"),
("Asia/Khandyga", "Asia/Khandyga"),
("Asia/Kolkata", "Asia/Kolkata"),
("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
("Asia/Kuching", "Asia/Kuching"),
("Asia/Kuwait", "Asia/Kuwait"),
("Asia/Macao", "Asia/Macao"),
("Asia/Macau", "Asia/Macau"),
("Asia/Magadan", "Asia/Magadan"),
("Asia/Makassar", "Asia/Makassar"),
("Asia/Manila", "Asia/Manila"),
("Asia/Muscat", "Asia/Muscat"),
("Asia/Nicosia", "Asia/Nicosia"),
("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
("Asia/Novosibirsk", "Asia/Novosibirsk"),
("Asia/Omsk", "Asia/Omsk"),
("Asia/Oral", "Asia/Oral"),
("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
("Asia/Pontianak", "Asia/Pontianak"),
("Asia/Pyongyang", "Asia/Pyongyang"),
("Asia/Qatar", "Asia/Qatar"),
("Asia/Qostanay", "Asia/Qostanay"),
("Asia/Qyzylorda", "Asia/Qyzylorda"),
("Asia/Rangoon", "Asia/Rangoon"),
("Asia/Riyadh", "Asia/Riyadh"),
("Asia/Saigon", "Asia/Saigon"),
("Asia/Sakhalin", "Asia/Sakhalin"),
("Asia/Samarkand", "Asia/Samarkand"),
("Asia/Seoul", "Asia/Seoul"),
("Asia/Shanghai", "Asia/Shanghai"),
("Asia/Singapore", "Asia/Singapore"),
("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
("Asia/Taipei", "Asia/Taipei"),
("Asia/Tashkent", "Asia/Tashkent"),
("Asia/Tbilisi", "Asia/Tbilisi"),
("Asia/Tehran", "Asia/Tehran"),
("Asia/Tel_Aviv", "Asia/Tel_Aviv"),
("Asia/Thimbu", "Asia/Thimbu"),
("Asia/Thimphu", "Asia/Thimphu"),
("Asia/Tokyo", "Asia/Tokyo"),
("Asia/Tomsk", "Asia/Tomsk"),
("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"),
("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
("Asia/Ulan_Bator", "Asia/Ulan_Bator"),
("Asia/Urumqi", "Asia/Urumqi"),
("Asia/Ust-Nera", "Asia/Ust-Nera"),
("Asia/Vientiane", "Asia/Vientiane"),
("Asia/Vladivostok", "Asia/Vladivostok"),
("Asia/Yakutsk", "Asia/Yakutsk"),
("Asia/Yangon", "Asia/Yangon"),
("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
("Asia/Yerevan", "Asia/Yerevan"),
("Atlantic/Azores", "Atlantic/Azores"),
("Atlantic/Bermuda", "Atlantic/Bermuda"),
("Atlantic/Canary", "Atlantic/Canary"),
("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
("Atlantic/Faeroe", "Atlantic/Faeroe"),
("Atlantic/Faroe", "Atlantic/Faroe"),
("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"),
("Atlantic/Madeira", "Atlantic/Madeira"),
("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
("Atlantic/St_Helena", "Atlantic/St_Helena"),
("Atlantic/Stanley", "Atlantic/Stanley"),
("Australia/ACT", "Australia/ACT"),
("Australia/Adelaide", "Australia/Adelaide"),
("Australia/Brisbane", "Australia/Brisbane"),
("Australia/Broken_Hill", "Australia/Broken_Hill"),
("Australia/Canberra", "Australia/Canberra"),
("Australia/Currie", "Australia/Currie"),
("Australia/Darwin", "Australia/Darwin"),
("Australia/Eucla", "Australia/Eucla"),
("Australia/Hobart", "Australia/Hobart"),
("Australia/LHI", "Australia/LHI"),
("Australia/Lindeman", "Australia/Lindeman"),
("Australia/Lord_Howe", "Australia/Lord_Howe"),
("Australia/Melbourne", "Australia/Melbourne"),
("Australia/NSW", "Australia/NSW"),
("Australia/North", "Australia/North"),
("Australia/Perth", "Australia/Perth"),
("Australia/Queensland", "Australia/Queensland"),
("Australia/South", "Australia/South"),
("Australia/Sydney", "Australia/Sydney"),
("Australia/Tasmania", "Australia/Tasmania"),
("Australia/Victoria", "Australia/Victoria"),
("Australia/West", "Australia/West"),
("Australia/Yancowinna", "Australia/Yancowinna"),
("Brazil/Acre", "Brazil/Acre"),
("Brazil/DeNoronha", "Brazil/DeNoronha"),
("Brazil/East", "Brazil/East"),
("Brazil/West", "Brazil/West"),
("CET", "CET"),
("CST6CDT", "CST6CDT"),
("Canada/Atlantic", "Canada/Atlantic"),
("Canada/Central", "Canada/Central"),
("Canada/Eastern", "Canada/Eastern"),
("Canada/Mountain", "Canada/Mountain"),
("Canada/Newfoundland", "Canada/Newfoundland"),
("Canada/Pacific", "Canada/Pacific"),
("Canada/Saskatchewan", "Canada/Saskatchewan"),
("Canada/Yukon", "Canada/Yukon"),
("Chile/Continental", "Chile/Continental"),
("Chile/EasterIsland", "Chile/EasterIsland"),
("Cuba", "Cuba"),
("EET", "EET"),
("EST", "EST"),
("EST5EDT", "EST5EDT"),
("Egypt", "Egypt"),
("Eire", "Eire"),
("Etc/GMT", "Etc/GMT"),
("Etc/GMT+0", "Etc/GMT+0"),
("Etc/GMT+1", "Etc/GMT+1"),
("Etc/GMT+10", "Etc/GMT+10"),
("Etc/GMT+11", "Etc/GMT+11"),
("Etc/GMT+12", "Etc/GMT+12"),
("Etc/GMT+2", "Etc/GMT+2"),
("Etc/GMT+3", "Etc/GMT+3"),
("Etc/GMT+4", "Etc/GMT+4"),
("Etc/GMT+5", "Etc/GMT+5"),
("Etc/GMT+6", "Etc/GMT+6"),
("Etc/GMT+7", "Etc/GMT+7"),
("Etc/GMT+8", "Etc/GMT+8"),
("Etc/GMT+9", "Etc/GMT+9"),
("Etc/GMT-0", "Etc/GMT-0"),
("Etc/GMT-1", "Etc/GMT-1"),
("Etc/GMT-10", "Etc/GMT-10"),
("Etc/GMT-11", "Etc/GMT-11"),
("Etc/GMT-12", "Etc/GMT-12"),
("Etc/GMT-13", "Etc/GMT-13"),
("Etc/GMT-14", "Etc/GMT-14"),
("Etc/GMT-2", "Etc/GMT-2"),
("Etc/GMT-3", "Etc/GMT-3"),
("Etc/GMT-4", "Etc/GMT-4"),
("Etc/GMT-5", "Etc/GMT-5"),
("Etc/GMT-6", "Etc/GMT-6"),
("Etc/GMT-7", "Etc/GMT-7"),
("Etc/GMT-8", "Etc/GMT-8"),
("Etc/GMT-9", "Etc/GMT-9"),
("Etc/GMT0", "Etc/GMT0"),
("Etc/Greenwich", "Etc/Greenwich"),
("Etc/UCT", "Etc/UCT"),
("Etc/UTC", "Etc/UTC"),
("Etc/Universal", "Etc/Universal"),
("Etc/Zulu", "Etc/Zulu"),
("Europe/Amsterdam", "Europe/Amsterdam"),
("Europe/Andorra", "Europe/Andorra"),
("Europe/Astrakhan", "Europe/Astrakhan"),
("Europe/Athens", "Europe/Athens"),
("Europe/Belfast", "Europe/Belfast"),
("Europe/Belgrade", "Europe/Belgrade"),
("Europe/Berlin", "Europe/Berlin"),
("Europe/Bratislava", "Europe/Bratislava"),
("Europe/Brussels", "Europe/Brussels"),
("Europe/Bucharest", "Europe/Bucharest"),
("Europe/Budapest", "Europe/Budapest"),
("Europe/Busingen", "Europe/Busingen"),
("Europe/Chisinau", "Europe/Chisinau"),
("Europe/Copenhagen", "Europe/Copenhagen"),
("Europe/Dublin", "Europe/Dublin"),
("Europe/Gibraltar", "Europe/Gibraltar"),
("Europe/Guernsey", "Europe/Guernsey"),
("Europe/Helsinki", "Europe/Helsinki"),
("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
("Europe/Istanbul", "Europe/Istanbul"),
("Europe/Jersey", "Europe/Jersey"),
("Europe/Kaliningrad", "Europe/Kaliningrad"),
("Europe/Kiev", "Europe/Kiev"),
("Europe/Kirov", "Europe/Kirov"),
("Europe/Kyiv", "Europe/Kyiv"),
("Europe/Lisbon", "Europe/Lisbon"),
("Europe/Ljubljana", "Europe/Ljubljana"),
("Europe/London", "Europe/London"),
("Europe/Luxembourg", "Europe/Luxembourg"),
("Europe/Madrid", "Europe/Madrid"),
("Europe/Malta", "Europe/Malta"),
("Europe/Mariehamn", "Europe/Mariehamn"),
("Europe/Minsk", "Europe/Minsk"),
("Europe/Monaco", "Europe/Monaco"),
("Europe/Moscow", "Europe/Moscow"),
("Europe/Nicosia", "Europe/Nicosia"),
("Europe/Oslo", "Europe/Oslo"),
("Europe/Paris", "Europe/Paris"),
("Europe/Podgorica", "Europe/Podgorica"),
("Europe/Prague", "Europe/Prague"),
("Europe/Riga", "Europe/Riga"),
("Europe/Rome", "Europe/Rome"),
("Europe/Samara", "Europe/Samara"),
("Europe/San_Marino", "Europe/San_Marino"),
("Europe/Sarajevo", "Europe/Sarajevo"),
("Europe/Saratov", "Europe/Saratov"),
("Europe/Simferopol", "Europe/Simferopol"),
("Europe/Skopje", "Europe/Skopje"),
("Europe/Sofia", "Europe/Sofia"),
("Europe/Stockholm", "Europe/Stockholm"),
("Europe/Tallinn", "Europe/Tallinn"),
("Europe/Tirane", "Europe/Tirane"),
("Europe/Tiraspol", "Europe/Tiraspol"),
("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
("Europe/Uzhgorod", "Europe/Uzhgorod"),
("Europe/Vaduz", "Europe/Vaduz"),
("Europe/Vatican", "Europe/Vatican"),
("Europe/Vienna", "Europe/Vienna"),
("Europe/Vilnius", "Europe/Vilnius"),
("Europe/Volgograd", "Europe/Volgograd"),
("Europe/Warsaw", "Europe/Warsaw"),
("Europe/Zagreb", "Europe/Zagreb"),
("Europe/Zaporozhye", "Europe/Zaporozhye"),
("Europe/Zurich", "Europe/Zurich"),
("Factory", "Factory"),
("GB", "GB"),
("GB-Eire", "GB-Eire"),
("GMT", "GMT"),
("GMT+0", "GMT+0"),
("GMT-0", "GMT-0"),
("GMT0", "GMT0"),
("Greenwich", "Greenwich"),
("HST", "HST"),
("Hongkong", "Hongkong"),
("Iceland", "Iceland"),
("Indian/Antananarivo", "Indian/Antananarivo"),
("Indian/Chagos", "Indian/Chagos"),
("Indian/Christmas", "Indian/Christmas"),
("Indian/Cocos", "Indian/Cocos"),
("Indian/Comoro", "Indian/Comoro"),
("Indian/Kerguelen", "Indian/Kerguelen"),
("Indian/Mahe", "Indian/Mahe"),
("Indian/Maldives", "Indian/Maldives"),
("Indian/Mauritius", "Indian/Mauritius"),
("Indian/Mayotte", "Indian/Mayotte"),
("Indian/Reunion", "Indian/Reunion"),
("Iran", "Iran"),
("Israel", "Israel"),
("Jamaica", "Jamaica"),
("Japan", "Japan"),
("Kwajalein", "Kwajalein"),
("Libya", "Libya"),
("MET", "MET"),
("MST", "MST"),
("MST7MDT", "MST7MDT"),
("Mexico/BajaNorte", "Mexico/BajaNorte"),
("Mexico/BajaSur", "Mexico/BajaSur"),
("Mexico/General", "Mexico/General"),
("NZ", "NZ"),
("NZ-CHAT", "NZ-CHAT"),
("Navajo", "Navajo"),
("PRC", "PRC"),
("PST8PDT", "PST8PDT"),
("Pacific/Apia", "Pacific/Apia"),
("Pacific/Auckland", "Pacific/Auckland"),
("Pacific/Bougainville", "Pacific/Bougainville"),
("Pacific/Chatham", "Pacific/Chatham"),
("Pacific/Chuuk", "Pacific/Chuuk"),
("Pacific/Easter", "Pacific/Easter"),
("Pacific/Efate", "Pacific/Efate"),
("Pacific/Enderbury", "Pacific/Enderbury"),
("Pacific/Fakaofo", "Pacific/Fakaofo"),
("Pacific/Fiji", "Pacific/Fiji"),
("Pacific/Funafuti", "Pacific/Funafuti"),
("Pacific/Galapagos", "Pacific/Galapagos"),
("Pacific/Gambier", "Pacific/Gambier"),
("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
("Pacific/Guam", "Pacific/Guam"),
("Pacific/Honolulu", "Pacific/Honolulu"),
("Pacific/Johnston", "Pacific/Johnston"),
("Pacific/Kanton", "Pacific/Kanton"),
("Pacific/Kiritimati", "Pacific/Kiritimati"),
("Pacific/Kosrae", "Pacific/Kosrae"),
("Pacific/Kwajalein", "Pacific/Kwajalein"),
("Pacific/Majuro", "Pacific/Majuro"),
("Pacific/Marquesas", "Pacific/Marquesas"),
("Pacific/Midway", "Pacific/Midway"),
("Pacific/Nauru", "Pacific/Nauru"),
("Pacific/Niue", "Pacific/Niue"),
("Pacific/Norfolk", "Pacific/Norfolk"),
("Pacific/Noumea", "Pacific/Noumea"),
("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
("Pacific/Palau", "Pacific/Palau"),
("Pacific/Pitcairn", "Pacific/Pitcairn"),
("Pacific/Pohnpei", "Pacific/Pohnpei"),
("Pacific/Ponape", "Pacific/Ponape"),
("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
("Pacific/Rarotonga", "Pacific/Rarotonga"),
("Pacific/Saipan", "Pacific/Saipan"),
("Pacific/Samoa", "Pacific/Samoa"),
("Pacific/Tahiti", "Pacific/Tahiti"),
("Pacific/Tarawa", "Pacific/Tarawa"),
("Pacific/Tongatapu", "Pacific/Tongatapu"),
("Pacific/Truk", "Pacific/Truk"),
("Pacific/Wake", "Pacific/Wake"),
("Pacific/Wallis", "Pacific/Wallis"),
("Pacific/Yap", "Pacific/Yap"),
("Poland", "Poland"),
("Portugal", "Portugal"),
("ROC", "ROC"),
("ROK", "ROK"),
("Singapore", "Singapore"),
("Turkey", "Turkey"),
("UCT", "UCT"),
("US/Alaska", "US/Alaska"),
("US/Aleutian", "US/Aleutian"),
("US/Arizona", "US/Arizona"),
("US/Central", "US/Central"),
("US/East-Indiana", "US/East-Indiana"),
("US/Eastern", "US/Eastern"),
("US/Hawaii", "US/Hawaii"),
("US/Indiana-Starke", "US/Indiana-Starke"),
("US/Michigan", "US/Michigan"),
("US/Mountain", "US/Mountain"),
("US/Pacific", "US/Pacific"),
("US/Samoa", "US/Samoa"),
("UTC", "UTC"),
("Universal", "Universal"),
("W-SU", "W-SU"),
("WET", "WET"),
("Zulu", "Zulu"),
("localtime", "localtime"),
],
default="UTC",
max_length=255,
),
),
]

View file

@ -0,0 +1,39 @@
# Generated by Django 4.2.11 on 2024-04-01 21:09
import bookwyrm.models.fields
from django.db import migrations, models
from django.contrib.postgres.operations import CreateCollation
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0200_alter_user_preferred_timezone"),
]
operations = [
CreateCollation(
"case_insensitive",
provider="icu",
locale="und-u-ks-level2",
deterministic=False,
),
migrations.AlterField(
model_name="hashtag",
name="name",
field=bookwyrm.models.fields.CharField(
db_collation="case_insensitive", max_length=256
),
),
migrations.AlterField(
model_name="user",
name="localname",
field=models.CharField(
db_collation="case_insensitive",
max_length=255,
null=True,
unique=True,
validators=[bookwyrm.models.fields.validate_localname],
),
),
]

View file

@ -0,0 +1,13 @@
# Generated by Django 4.2.11 on 2024-04-10 20:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0201_alter_hashtag_name_alter_user_localname"),
("bookwyrm", "0204_merge_20240409_1042"),
]
operations = []

View file

@ -0,0 +1,13 @@
# Generated by Django 4.2.11 on 2024-04-15 15:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0205_merge_20240410_2022"),
("bookwyrm", "0205_merge_20240413_0232"),
]
operations = []

View file

@ -169,7 +169,7 @@ class ActivitypubMixin:
# filter users first by whether they're using the desired software # filter users first by whether they're using the desired software
# this lets us send book updates only to other bw servers # this lets us send book updates only to other bw servers
if software: if software:
queryset = queryset.filter(bookwyrm_user=(software == "bookwyrm")) queryset = queryset.filter(bookwyrm_user=software == "bookwyrm")
# if there's a user, we only want to send to the user's followers # if there's a user, we only want to send to the user's followers
if user: if user:
queryset = queryset.filter(following=user) queryset = queryset.filter(following=user)
@ -206,14 +206,10 @@ class ObjectMixin(ActivitypubMixin):
created: Optional[bool] = None, created: Optional[bool] = None,
software: Any = None, software: Any = None,
priority: str = BROADCAST, priority: str = BROADCAST,
broadcast: bool = True,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
"""broadcast created/updated/deleted objects as appropriate""" """broadcast created/updated/deleted objects as appropriate"""
broadcast = kwargs.get("broadcast", True)
# this bonus kwarg would cause an error in the base save method
if "broadcast" in kwargs:
del kwargs["broadcast"]
created = created or not bool(self.id) created = created or not bool(self.id)
# first off, we want to save normally no matter what # first off, we want to save normally no matter what
super().save(*args, **kwargs) super().save(*args, **kwargs)

View file

@ -1,7 +1,7 @@
""" database schema for info about authors """ """ database schema for info about authors """
import re import re
from typing import Tuple, Any from typing import Any
from django.db import models from django.db import models
from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.indexes import GinIndex
@ -45,12 +45,12 @@ class Author(BookDataModel):
) )
bio = fields.HtmlField(null=True, blank=True) bio = fields.HtmlField(null=True, blank=True)
def save(self, *args: Tuple[Any, ...], **kwargs: dict[str, Any]) -> None: def save(self, *args: Any, **kwargs: Any) -> None:
"""normalize isni format""" """normalize isni format"""
if self.isni: if self.isni is not None:
self.isni = re.sub(r"\s", "", self.isni) self.isni = re.sub(r"\s", "", self.isni)
return super().save(*args, **kwargs) super().save(*args, **kwargs)
@property @property
def isni_link(self): def isni_link(self):

View file

@ -2,7 +2,7 @@
from itertools import chain from itertools import chain
import re import re
from typing import Any, Dict from typing import Any, Dict, Optional, Iterable
from typing_extensions import Self from typing_extensions import Self
from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.search import SearchVectorField
@ -27,7 +27,7 @@ from bookwyrm.settings import (
ENABLE_PREVIEW_IMAGES, ENABLE_PREVIEW_IMAGES,
ENABLE_THUMBNAIL_GENERATION, ENABLE_THUMBNAIL_GENERATION,
) )
from bookwyrm.utils.db import format_trigger from bookwyrm.utils.db import format_trigger, add_update_fields
from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
from .base_model import BookWyrmModel from .base_model import BookWyrmModel
@ -96,14 +96,19 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
abstract = True abstract = True
def save(self, *args: Any, **kwargs: Any) -> None: def save(
self, *args: Any, update_fields: Optional[Iterable[str]] = None, **kwargs: Any
) -> None:
"""ensure that the remote_id is within this instance""" """ensure that the remote_id is within this instance"""
if self.id: if self.id:
self.remote_id = self.get_remote_id() self.remote_id = self.get_remote_id()
update_fields = add_update_fields(update_fields, "remote_id")
else: else:
self.origin_id = self.remote_id self.origin_id = self.remote_id
self.remote_id = None self.remote_id = None
return super().save(*args, **kwargs) update_fields = add_update_fields(update_fields, "origin_id", "remote_id")
super().save(*args, update_fields=update_fields, **kwargs)
# pylint: disable=arguments-differ # pylint: disable=arguments-differ
def broadcast(self, activity, sender, software="bookwyrm", **kwargs): def broadcast(self, activity, sender, software="bookwyrm", **kwargs):
@ -323,7 +328,7 @@ class Book(BookDataModel):
if not isinstance(self, (Edition, Work)): if not isinstance(self, (Edition, Work)):
raise ValueError("Books should be added as Editions or Works") raise ValueError("Books should be added as Editions or Works")
return super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_remote_id(self): def get_remote_id(self):
"""editions and works both use "book" instead of model_name""" """editions and works both use "book" instead of model_name"""
@ -400,10 +405,11 @@ class Work(OrderedCollectionPageMixin, Book):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""set some fields on the edition object""" """set some fields on the edition object"""
super().save(*args, **kwargs)
# set rank # set rank
for edition in self.editions.all(): for edition in self.editions.all():
edition.save() edition.save()
return super().save(*args, **kwargs)
@property @property
def default_edition(self): def default_edition(self):
@ -509,33 +515,48 @@ class Edition(Book):
# max rank is 9 # max rank is 9
return rank return rank
def save(self, *args: Any, **kwargs: Any) -> None: def save(
self, *args: Any, update_fields: Optional[Iterable[str]] = None, **kwargs: Any
) -> None:
"""set some fields on the edition object""" """set some fields on the edition object"""
# calculate isbn 10/13 # calculate isbn 10/13
if self.isbn_13 and self.isbn_13[:3] == "978" and not self.isbn_10: if (
self.isbn_10 is None
and self.isbn_13 is not None
and self.isbn_13[:3] == "978"
):
self.isbn_10 = isbn_13_to_10(self.isbn_13) self.isbn_10 = isbn_13_to_10(self.isbn_13)
if self.isbn_10 and not self.isbn_13: update_fields = add_update_fields(update_fields, "isbn_10")
if self.isbn_13 is None and self.isbn_10 is not None:
self.isbn_13 = isbn_10_to_13(self.isbn_10) self.isbn_13 = isbn_10_to_13(self.isbn_10)
update_fields = add_update_fields(update_fields, "isbn_13")
# normalize isbn format # normalize isbn format
if self.isbn_10: if self.isbn_10 is not None:
self.isbn_10 = normalize_isbn(self.isbn_10) self.isbn_10 = normalize_isbn(self.isbn_10)
if self.isbn_13: if self.isbn_13 is not None:
self.isbn_13 = normalize_isbn(self.isbn_13) self.isbn_13 = normalize_isbn(self.isbn_13)
# set rank # set rank
self.edition_rank = self.get_rank() if (new := self.get_rank()) != self.edition_rank:
self.edition_rank = new
# clear author cache update_fields = add_update_fields(update_fields, "edition_rank")
if self.id:
for author_id in self.authors.values_list("id", flat=True):
cache.delete(f"author-books-{author_id}")
# Create sort title by removing articles from title # Create sort title by removing articles from title
if self.sort_title in [None, ""]: if self.sort_title in [None, ""]:
self.sort_title = self.guess_sort_title() self.sort_title = self.guess_sort_title()
update_fields = add_update_fields(update_fields, "sort_title")
return super().save(*args, **kwargs) super().save(*args, update_fields=update_fields, **kwargs)
# clear author cache
if self.id:
cache.delete_many(
[
f"author-books-{author_id}"
for author_id in self.authors.values_list("id", flat=True)
]
)
@transaction.atomic @transaction.atomic
def repair(self): def repair(self):

View file

@ -10,9 +10,9 @@ from django.db.models import BooleanField, FileField, JSONField
from django.db.models import Q from django.db.models import Q
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.utils.module_loading import import_string from django.core.files.storage import storages
from bookwyrm import settings, storage_backends from bookwyrm import settings
from bookwyrm.models import AnnualGoal, ReadThrough, ShelfBook, ListItem from bookwyrm.models import AnnualGoal, ReadThrough, ShelfBook, ListItem
from bookwyrm.models import Review, Comment, Quotation from bookwyrm.models import Review, Comment, Quotation
@ -35,8 +35,7 @@ class BookwyrmAwsSession(BotoSession):
def select_exports_storage(): def select_exports_storage():
"""callable to allow for dependency on runtime configuration""" """callable to allow for dependency on runtime configuration"""
cls = import_string(settings.EXPORTS_STORAGE) return storages["exports"]
return cls()
class BookwyrmExportJob(ParentJob): class BookwyrmExportJob(ParentJob):
@ -116,7 +115,7 @@ def create_archive_task(job_id):
if settings.USE_S3: if settings.USE_S3:
# Storage for writing temporary files # Storage for writing temporary files
exports_storage = storage_backends.ExportsS3Storage() exports_storage = storages["exports"]
# Handle for creating the final archive # Handle for creating the final archive
s3_tar = S3Tar( s3_tar = S3Tar(
@ -136,7 +135,7 @@ def create_archive_task(job_id):
) )
# Add images to TAR # Add images to TAR
images_storage = storage_backends.ImagesStorage() images_storage = storages["default"]
if user.avatar: if user.avatar:
add_file_to_s3_tar(s3_tar, images_storage, user.avatar) add_file_to_s3_tar(s3_tar, images_storage, user.avatar)

View file

@ -2,18 +2,19 @@
from bookwyrm import activitypub from bookwyrm import activitypub
from .activitypub_mixin import ActivitypubMixin from .activitypub_mixin import ActivitypubMixin
from .base_model import BookWyrmModel from .base_model import BookWyrmModel
from .fields import CICharField from .fields import CharField
class Hashtag(ActivitypubMixin, BookWyrmModel): class Hashtag(ActivitypubMixin, BookWyrmModel):
"a hashtag which can be used in statuses" "a hashtag which can be used in statuses"
name = CICharField( name = CharField(
max_length=256, max_length=256,
blank=False, blank=False,
null=False, null=False,
activitypub_field="name", activitypub_field="name",
deduplication_field=True, deduplication_field=True,
db_collation="case_insensitive",
) )
name_field = "name" name_field = "name"

View file

@ -1,4 +1,5 @@
""" outlink data """ """ outlink data """
from typing import Optional, Iterable
from urllib.parse import urlparse from urllib.parse import urlparse
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
@ -6,6 +7,7 @@ from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.utils.db import add_update_fields
from .activitypub_mixin import ActivitypubMixin from .activitypub_mixin import ActivitypubMixin
from .base_model import BookWyrmModel from .base_model import BookWyrmModel
from . import fields from . import fields
@ -34,17 +36,19 @@ class Link(ActivitypubMixin, BookWyrmModel):
"""link name via the associated domain""" """link name via the associated domain"""
return self.domain.name return self.domain.name
def save(self, *args, **kwargs): def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""create a link""" """create a link"""
# get or create the associated domain # get or create the associated domain
if not self.domain: if not self.domain:
domain = urlparse(self.url).hostname domain = urlparse(self.url).hostname
self.domain, _ = LinkDomain.objects.get_or_create(domain=domain) self.domain, _ = LinkDomain.objects.get_or_create(domain=domain)
update_fields = add_update_fields(update_fields, "domain")
# this is never broadcast, the owning model broadcasts an update # this is never broadcast, the owning model broadcasts an update
if "broadcast" in kwargs: if "broadcast" in kwargs:
del kwargs["broadcast"] del kwargs["broadcast"]
return super().save(*args, **kwargs)
super().save(*args, update_fields=update_fields, **kwargs)
AvailabilityChoices = [ AvailabilityChoices = [
@ -88,8 +92,10 @@ class LinkDomain(BookWyrmModel):
return return
raise PermissionDenied() raise PermissionDenied()
def save(self, *args, **kwargs): def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""set a default name""" """set a default name"""
if not self.name: if not self.name:
self.name = self.domain self.name = self.domain
super().save(*args, **kwargs) update_fields = add_update_fields(update_fields, "name")
super().save(*args, update_fields=update_fields, **kwargs)

View file

@ -1,4 +1,5 @@
""" make a list of books!! """ """ make a list of books!! """
from typing import Optional, Iterable
import uuid import uuid
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
@ -8,6 +9,7 @@ from django.utils import timezone
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.settings import BASE_URL from bookwyrm.settings import BASE_URL
from bookwyrm.utils.db import add_update_fields
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel from .base_model import BookWyrmModel
@ -124,11 +126,13 @@ class List(OrderedCollectionMixin, BookWyrmModel):
group=None, curation="closed" group=None, curation="closed"
) )
def save(self, *args, **kwargs): def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""on save, update embed_key and avoid clash with existing code""" """on save, update embed_key and avoid clash with existing code"""
if not self.embed_key: if not self.embed_key:
self.embed_key = uuid.uuid4() self.embed_key = uuid.uuid4()
super().save(*args, **kwargs) update_fields = add_update_fields(update_fields, "embed_key")
super().save(*args, update_fields=update_fields, **kwargs)
class ListItem(CollectionItemMixin, BookWyrmModel): class ListItem(CollectionItemMixin, BookWyrmModel):

View file

@ -48,16 +48,16 @@ class MoveUser(Move):
"""update user info and broadcast it""" """update user info and broadcast it"""
# only allow if the source is listed in the target's alsoKnownAs # only allow if the source is listed in the target's alsoKnownAs
if self.user in self.target.also_known_as.all(): if self.user not in self.target.also_known_as.all():
raise PermissionDenied()
self.user.also_known_as.add(self.target.id) self.user.also_known_as.add(self.target.id)
self.user.update_active_date() self.user.update_active_date()
self.user.moved_to = self.target.remote_id self.user.moved_to = self.target.remote_id
self.user.save(update_fields=["moved_to"]) self.user.save(update_fields=["moved_to"])
if self.user.local: if self.user.local:
kwargs[ kwargs["broadcast"] = True # Only broadcast if we are initiating the Move
"broadcast"
] = True # Only broadcast if we are initiating the Move
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -66,6 +66,3 @@ class MoveUser(Move):
Notification.notify( Notification.notify(
follower, self.user, notification_type=NotificationType.MOVE follower, self.user, notification_type=NotificationType.MOVE
) )
else:
raise PermissionDenied()

View file

@ -1,9 +1,13 @@
""" progress in a book """ """ progress in a book """
from typing import Optional, Iterable
from django.core import validators from django.core import validators
from django.core.cache import cache from django.core.cache import cache
from django.db import models from django.db import models
from django.db.models import F, Q from django.db.models import F, Q
from bookwyrm.utils.db import add_update_fields
from .base_model import BookWyrmModel from .base_model import BookWyrmModel
@ -30,14 +34,17 @@ class ReadThrough(BookWyrmModel):
stopped_date = models.DateTimeField(blank=True, null=True) stopped_date = models.DateTimeField(blank=True, null=True)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
def save(self, *args, **kwargs): def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""update user active time""" """update user active time"""
cache.delete(f"latest_read_through-{self.user_id}-{self.book_id}")
self.user.update_active_date()
# an active readthrough must have an unset finish date # an active readthrough must have an unset finish date
if self.finish_date or self.stopped_date: if self.finish_date or self.stopped_date:
self.is_active = False self.is_active = False
super().save(*args, **kwargs) update_fields = add_update_fields(update_fields, "is_active")
super().save(*args, update_fields=update_fields, **kwargs)
cache.delete(f"latest_read_through-{self.user_id}-{self.book_id}")
self.user.update_active_date()
def create_update(self): def create_update(self):
"""add update to the readthrough""" """add update to the readthrough"""

View file

@ -38,14 +38,16 @@ class UserRelationship(BookWyrmModel):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""clear the template cache""" """clear the template cache"""
clear_cache(self.user_subject, self.user_object)
super().save(*args, **kwargs) super().save(*args, **kwargs)
clear_cache(self.user_subject, self.user_object)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
"""clear the template cache""" """clear the template cache"""
clear_cache(self.user_subject, self.user_object)
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
clear_cache(self.user_subject, self.user_object)
class Meta: class Meta:
"""relationships should be unique""" """relationships should be unique"""

View file

@ -1,5 +1,6 @@
""" puttin' books on shelves """ """ puttin' books on shelves """
import re import re
from typing import Optional, Iterable
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import models from django.db import models
@ -8,6 +9,7 @@ from django.utils import timezone
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.settings import BASE_URL from bookwyrm.settings import BASE_URL
from bookwyrm.tasks import BROADCAST from bookwyrm.tasks import BROADCAST
from bookwyrm.utils.db import add_update_fields
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel from .base_model import BookWyrmModel
from . import fields from . import fields
@ -44,8 +46,9 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
"""set the identifier""" """set the identifier"""
super().save(*args, priority=priority, **kwargs) super().save(*args, priority=priority, **kwargs)
if not self.identifier: if not self.identifier:
# this needs the auto increment ID from the save() above
self.identifier = self.get_identifier() self.identifier = self.get_identifier()
super().save(*args, **kwargs, broadcast=False) super().save(*args, **kwargs, broadcast=False, update_fields={"identifier"})
def get_identifier(self): def get_identifier(self):
"""custom-shelf-123 for the url""" """custom-shelf-123 for the url"""
@ -100,10 +103,21 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
activity_serializer = activitypub.ShelfItem activity_serializer = activitypub.ShelfItem
collection_field = "shelf" collection_field = "shelf"
def save(self, *args, priority=BROADCAST, **kwargs): def save(
self,
*args,
priority=BROADCAST,
update_fields: Optional[Iterable[str]] = None,
**kwargs,
):
if not self.user: if not self.user:
self.user = self.shelf.user self.user = self.shelf.user
if self.id and self.user.local: update_fields = add_update_fields(update_fields, "user")
is_update = self.id is not None
super().save(*args, priority=priority, update_fields=update_fields, **kwargs)
if is_update and self.user.local:
# remove all caches related to all editions of this book # remove all caches related to all editions of this book
cache.delete_many( cache.delete_many(
[ [
@ -111,7 +125,6 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
for book in self.book.parent_work.editions.all() for book in self.book.parent_work.editions.all()
] ]
) )
super().save(*args, priority=priority, **kwargs)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
if self.id and self.user.local: if self.id and self.user.local:

View file

@ -1,5 +1,6 @@
""" the particulars for this instance of BookWyrm """ """ the particulars for this instance of BookWyrm """
import datetime import datetime
from typing import Optional, Iterable
from urllib.parse import urljoin from urllib.parse import urljoin
import uuid import uuid
@ -15,6 +16,7 @@ from bookwyrm.preview_images import generate_site_preview_image_task
from bookwyrm.settings import BASE_URL, ENABLE_PREVIEW_IMAGES, STATIC_FULL_URL from bookwyrm.settings import BASE_URL, ENABLE_PREVIEW_IMAGES, STATIC_FULL_URL
from bookwyrm.settings import RELEASE_API from bookwyrm.settings import RELEASE_API
from bookwyrm.tasks import app, MISC from bookwyrm.tasks import app, MISC
from bookwyrm.utils.db import add_update_fields
from .base_model import BookWyrmModel, new_access_code from .base_model import BookWyrmModel, new_access_code
from .user import User from .user import User
from .fields import get_absolute_url from .fields import get_absolute_url
@ -136,16 +138,19 @@ class SiteSettings(SiteModel):
return get_absolute_url(uploaded) return get_absolute_url(uploaded)
return urljoin(STATIC_FULL_URL, default_path) return urljoin(STATIC_FULL_URL, default_path)
def save(self, *args, **kwargs): def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""if require_confirm_email is disabled, make sure no users are pending, """if require_confirm_email is disabled, make sure no users are pending,
if enabled, make sure invite_question_text is not empty""" if enabled, make sure invite_question_text is not empty"""
if not self.invite_question_text:
self.invite_question_text = "What is your favourite book?"
update_fields = add_update_fields(update_fields, "invite_question_text")
super().save(*args, update_fields=update_fields, **kwargs)
if not self.require_confirm_email: if not self.require_confirm_email:
User.objects.filter(is_active=False, deactivation_reason="pending").update( User.objects.filter(is_active=False, deactivation_reason="pending").update(
is_active=True, deactivation_reason=None is_active=True, deactivation_reason=None
) )
if not self.invite_question_text:
self.invite_question_text = "What is your favourite book?"
super().save(*args, **kwargs)
class Theme(SiteModel): class Theme(SiteModel):

View file

@ -1,6 +1,6 @@
""" models for storing different kinds of Activities """ """ models for storing different kinds of Activities """
from dataclasses import MISSING from dataclasses import MISSING
from typing import Optional from typing import Optional, Iterable
import re import re
from django.apps import apps from django.apps import apps
@ -20,6 +20,7 @@ from model_utils.managers import InheritanceManager
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.preview_images import generate_edition_preview_image_task from bookwyrm.preview_images import generate_edition_preview_image_task
from bookwyrm.settings import ENABLE_PREVIEW_IMAGES from bookwyrm.settings import ENABLE_PREVIEW_IMAGES
from bookwyrm.utils.db import add_update_fields
from .activitypub_mixin import ActivitypubMixin, ActivityMixin from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import OrderedCollectionPageMixin from .activitypub_mixin import OrderedCollectionPageMixin
from .base_model import BookWyrmModel from .base_model import BookWyrmModel
@ -85,12 +86,13 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
models.Index(fields=["thread_id"]), models.Index(fields=["thread_id"]),
] ]
def save(self, *args, **kwargs): def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""save and notify""" """save and notify"""
if self.reply_parent: if self.thread_id is None and self.reply_parent:
self.thread_id = self.reply_parent.thread_id or self.reply_parent_id self.thread_id = self.reply_parent.thread_id or self.reply_parent_id
update_fields = add_update_fields(update_fields, "thread_id")
super().save(*args, **kwargs) super().save(*args, update_fields=update_fields, **kwargs)
if not self.reply_parent: if not self.reply_parent:
self.thread_id = self.id self.thread_id = self.id
@ -459,9 +461,10 @@ class Review(BookStatus):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""clear rating caches""" """clear rating caches"""
super().save(*args, **kwargs)
if self.book.parent_work: if self.book.parent_work:
cache.delete(f"book-rating-{self.book.parent_work.id}") cache.delete(f"book-rating-{self.book.parent_work.id}")
super().save(*args, **kwargs)
class ReviewRating(Review): class ReviewRating(Review):

View file

@ -1,18 +1,20 @@
""" database schema for user data """ """ database schema for user data """
import datetime
import re import re
import zoneinfo
from typing import Optional, Iterable
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import uuid4 from uuid import uuid4
from django.apps import apps from django.apps import apps
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.postgres.fields import ArrayField, CICharField from django.contrib.postgres.fields import ArrayField as DjangoArrayField
from django.core.exceptions import PermissionDenied, ObjectDoesNotExist from django.core.exceptions import PermissionDenied, ObjectDoesNotExist
from django.dispatch import receiver from django.dispatch import receiver
from django.db import models, transaction, IntegrityError from django.db import models, transaction, IntegrityError
from django.utils import timezone from django.utils import timezone
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
import pytz
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.connectors import get_data, ConnectorException from bookwyrm.connectors import get_data, ConnectorException
@ -23,6 +25,7 @@ from bookwyrm.settings import BASE_URL, ENABLE_PREVIEW_IMAGES, LANGUAGES
from bookwyrm.signatures import create_key_pair from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app, MISC from bookwyrm.tasks import app, MISC
from bookwyrm.utils import regex from bookwyrm.utils import regex
from bookwyrm.utils.db import add_update_fields
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
from .base_model import BookWyrmModel, DeactivationReason, new_access_code from .base_model import BookWyrmModel, DeactivationReason, new_access_code
from .federated_server import FederatedServer from .federated_server import FederatedServer
@ -75,11 +78,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
summary = fields.HtmlField(null=True, blank=True) summary = fields.HtmlField(null=True, blank=True)
local = models.BooleanField(default=False) local = models.BooleanField(default=False)
bookwyrm_user = fields.BooleanField(default=True) bookwyrm_user = fields.BooleanField(default=True)
localname = CICharField( localname = models.CharField(
max_length=255, max_length=255,
null=True, null=True,
unique=True, unique=True,
validators=[fields.validate_localname], validators=[fields.validate_localname],
db_collation="case_insensitive",
) )
# name is your display name, which you can change at will # name is your display name, which you can change at will
name = fields.CharField(max_length=100, null=True, blank=True) name = fields.CharField(max_length=100, null=True, blank=True)
@ -156,7 +160,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
show_guided_tour = models.BooleanField(default=True) show_guided_tour = models.BooleanField(default=True)
# feed options # feed options
feed_status_types = ArrayField( feed_status_types = DjangoArrayField(
models.CharField(max_length=10, blank=False, choices=FeedFilterChoices), models.CharField(max_length=10, blank=False, choices=FeedFilterChoices),
size=8, size=8,
default=get_feed_filter_choices, default=get_feed_filter_choices,
@ -165,8 +169,8 @@ class User(OrderedCollectionPageMixin, AbstractUser):
summary_keys = models.JSONField(null=True) summary_keys = models.JSONField(null=True)
preferred_timezone = models.CharField( preferred_timezone = models.CharField(
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones], choices=[(str(tz), str(tz)) for tz in sorted(zoneinfo.available_timezones())],
default=str(pytz.utc), default=str(datetime.timezone.utc),
max_length=255, max_length=255,
) )
preferred_language = models.CharField( preferred_language = models.CharField(
@ -336,13 +340,14 @@ class User(OrderedCollectionPageMixin, AbstractUser):
] ]
return activity_object return activity_object
def save(self, *args, **kwargs): def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""populate fields for new local users""" """populate fields for new local users"""
created = not bool(self.id) created = not bool(self.id)
if not self.local and not re.match(regex.FULL_USERNAME, self.username): if not self.local and not re.match(regex.FULL_USERNAME, self.username):
# generate a username that uses the domain (webfinger format) # generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id) actor_parts = urlparse(self.remote_id)
self.username = f"{self.username}@{actor_parts.hostname}" self.username = f"{self.username}@{actor_parts.hostname}"
update_fields = add_update_fields(update_fields, "username")
# this user already exists, no need to populate fields # this user already exists, no need to populate fields
if not created: if not created:
@ -351,12 +356,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
elif not self.deactivation_date: elif not self.deactivation_date:
self.deactivation_date = timezone.now() self.deactivation_date = timezone.now()
super().save(*args, **kwargs) super().save(*args, update_fields=update_fields, **kwargs)
return return
# this is a new remote user, we need to set their remote server field # this is a new remote user, we need to set their remote server field
if not self.local: if not self.local:
super().save(*args, **kwargs) super().save(*args, update_fields=update_fields, **kwargs)
transaction.on_commit(lambda: set_remote_server(self.id)) transaction.on_commit(lambda: set_remote_server(self.id))
return return
@ -368,8 +373,17 @@ class User(OrderedCollectionPageMixin, AbstractUser):
self.shared_inbox = f"{BASE_URL}/inbox" self.shared_inbox = f"{BASE_URL}/inbox"
self.outbox = f"{self.remote_id}/outbox" self.outbox = f"{self.remote_id}/outbox"
update_fields = add_update_fields(
update_fields,
"remote_id",
"followers_url",
"inbox",
"shared_inbox",
"outbox",
)
# an id needs to be set before we can proceed with related models # an id needs to be set before we can proceed with related models
super().save(*args, **kwargs) super().save(*args, update_fields=update_fields, **kwargs)
# make users editors by default # make users editors by default
try: try:
@ -520,14 +534,19 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
# 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"
def save(self, *args, **kwargs): def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""create a key pair""" """create a key pair"""
# no broadcasting happening here # no broadcasting happening here
if "broadcast" in kwargs: if "broadcast" in kwargs:
del kwargs["broadcast"] del kwargs["broadcast"]
if not self.public_key: if not self.public_key:
self.private_key, self.public_key = create_key_pair() self.private_key, self.public_key = create_key_pair()
return super().save(*args, **kwargs) update_fields = add_update_fields(
update_fields, "private_key", "public_key"
)
super().save(*args, update_fields=update_fields, **kwargs)
@app.task(queue=MISC) @app.task(queue=MISC)

View file

@ -257,11 +257,8 @@ if env.bool("USE_DUMMY_CACHE", False):
else: else:
CACHES = { CACHES = {
"default": { "default": {
"BACKEND": "django_redis.cache.RedisCache", "BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": REDIS_ACTIVITY_URL, "LOCATION": REDIS_ACTIVITY_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}, },
"file_resubmit": { "file_resubmit": {
"BACKEND": "django.core.cache.backends.filebased.FileBasedCache", "BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
@ -347,8 +344,6 @@ TIME_ZONE = "UTC"
USE_I18N = True USE_I18N = True
USE_L10N = True
USE_TZ = True USE_TZ = True
# Imagekit generated thumbnails # Imagekit generated thumbnails
@ -371,6 +366,7 @@ if (USE_HTTPS and PORT == 443) or (not USE_HTTPS and PORT == 80):
else: else:
NETLOC = f"{DOMAIN}:{PORT}" NETLOC = f"{DOMAIN}:{PORT}"
BASE_URL = f"{PROTOCOL}://{NETLOC}" BASE_URL = f"{PROTOCOL}://{NETLOC}"
CSRF_TRUSTED_ORIGINS = [BASE_URL]
USER_AGENT = f"BookWyrm (BookWyrm/{VERSION}; +{BASE_URL})" USER_AGENT = f"BookWyrm (BookWyrm/{VERSION}; +{BASE_URL})"
@ -391,18 +387,40 @@ if USE_S3:
AWS_DEFAULT_ACL = "public-read" AWS_DEFAULT_ACL = "public-read"
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"} AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}
AWS_S3_URL_PROTOCOL = env("AWS_S3_URL_PROTOCOL", f"{PROTOCOL}:") AWS_S3_URL_PROTOCOL = env("AWS_S3_URL_PROTOCOL", f"{PROTOCOL}:")
# Storages
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"location": "images",
"default_acl": "public-read",
"file_overwrite": False,
},
},
"staticfiles": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"location": "static",
"default_acl": "public-read",
},
},
"exports": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"location": "images",
"default_acl": None,
"file_overwrite": False,
},
},
}
# S3 Static settings # S3 Static settings
STATIC_LOCATION = "static" STATIC_LOCATION = "static"
STATIC_URL = f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/" STATIC_URL = f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/"
STATIC_FULL_URL = STATIC_URL STATIC_FULL_URL = STATIC_URL
STATICFILES_STORAGE = "bookwyrm.storage_backends.StaticStorage"
# S3 Media settings # S3 Media settings
MEDIA_LOCATION = "images" MEDIA_LOCATION = "images"
MEDIA_URL = f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/" MEDIA_URL = f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/"
MEDIA_FULL_URL = MEDIA_URL MEDIA_FULL_URL = MEDIA_URL
DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage"
# S3 Exports settings
EXPORTS_STORAGE = "bookwyrm.storage_backends.ExportsS3Storage"
# Content Security Policy # Content Security Policy
CSP_DEFAULT_SRC = [ CSP_DEFAULT_SRC = [
"'self'", "'self'",
@ -422,36 +440,62 @@ elif USE_AZURE:
AZURE_ACCOUNT_KEY = env("AZURE_ACCOUNT_KEY") AZURE_ACCOUNT_KEY = env("AZURE_ACCOUNT_KEY")
AZURE_CONTAINER = env("AZURE_CONTAINER") AZURE_CONTAINER = env("AZURE_CONTAINER")
AZURE_CUSTOM_DOMAIN = env("AZURE_CUSTOM_DOMAIN") AZURE_CUSTOM_DOMAIN = env("AZURE_CUSTOM_DOMAIN")
# Storages
STORAGES = {
"default": {
"BACKEND": "storages.backends.azure_storage.AzureStorage",
"OPTIONS": {
"location": "images",
"overwrite_files": False,
},
},
"staticfiles": {
"BACKEND": "storages.backends.azure_storage.AzureStorage",
"OPTIONS": {
"location": "static",
},
},
"exports": {
"BACKEND": None, # not implemented yet
},
}
# Azure Static settings # Azure Static settings
STATIC_LOCATION = "static" STATIC_LOCATION = "static"
STATIC_URL = ( STATIC_URL = (
f"{PROTOCOL}://{AZURE_CUSTOM_DOMAIN}/{AZURE_CONTAINER}/{STATIC_LOCATION}/" f"{PROTOCOL}://{AZURE_CUSTOM_DOMAIN}/{AZURE_CONTAINER}/{STATIC_LOCATION}/"
) )
STATIC_FULL_URL = STATIC_URL STATIC_FULL_URL = STATIC_URL
STATICFILES_STORAGE = "bookwyrm.storage_backends.AzureStaticStorage"
# Azure Media settings # Azure Media settings
MEDIA_LOCATION = "images" MEDIA_LOCATION = "images"
MEDIA_URL = ( MEDIA_URL = (
f"{PROTOCOL}://{AZURE_CUSTOM_DOMAIN}/{AZURE_CONTAINER}/{MEDIA_LOCATION}/" f"{PROTOCOL}://{AZURE_CUSTOM_DOMAIN}/{AZURE_CONTAINER}/{MEDIA_LOCATION}/"
) )
MEDIA_FULL_URL = MEDIA_URL MEDIA_FULL_URL = MEDIA_URL
DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.AzureImagesStorage"
# Azure Exports settings
EXPORTS_STORAGE = None # not implemented yet
# Content Security Policy # Content Security Policy
CSP_DEFAULT_SRC = ["'self'", AZURE_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS CSP_DEFAULT_SRC = ["'self'", AZURE_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS
CSP_SCRIPT_SRC = ["'self'", AZURE_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS CSP_SCRIPT_SRC = ["'self'", AZURE_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS
else: else:
# Storages
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
"exports": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
"OPTIONS": {
"location": "exports",
},
},
}
# Static settings # Static settings
STATIC_URL = "/static/" STATIC_URL = "/static/"
STATIC_FULL_URL = BASE_URL + STATIC_URL STATIC_FULL_URL = BASE_URL + STATIC_URL
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
# Media settings # Media settings
MEDIA_URL = "/images/" MEDIA_URL = "/images/"
MEDIA_FULL_URL = BASE_URL + MEDIA_URL MEDIA_FULL_URL = BASE_URL + MEDIA_URL
DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage"
# Exports settings
EXPORTS_STORAGE = "bookwyrm.storage_backends.ExportsFileStorage"
# Content Security Policy # Content Security Policy
CSP_DEFAULT_SRC = ["'self'"] + CSP_ADDITIONAL_HOSTS CSP_DEFAULT_SRC = ["'self'"] + CSP_ADDITIONAL_HOSTS
CSP_SCRIPT_SRC = ["'self'"] + CSP_ADDITIONAL_HOSTS CSP_SCRIPT_SRC = ["'self'"] + CSP_ADDITIONAL_HOSTS

View file

@ -1,79 +0,0 @@
"""Handles backends for storages"""
import os
from tempfile import SpooledTemporaryFile
from django.core.files.storage import FileSystemStorage
from storages.backends.s3boto3 import S3Boto3Storage
from storages.backends.azure_storage import AzureStorage
class StaticStorage(S3Boto3Storage): # pylint: disable=abstract-method
"""Storage class for Static contents"""
location = "static"
default_acl = "public-read"
class ImagesStorage(S3Boto3Storage): # pylint: disable=abstract-method
"""Storage class for Image files"""
location = "images"
default_acl = "public-read"
file_overwrite = False
"""
This is our custom version of S3Boto3Storage that fixes a bug in
boto3 where the passed in file is closed upon upload.
From:
https://github.com/matthewwithanm/django-imagekit/issues/391#issuecomment-275367006
https://github.com/boto/boto3/issues/929
https://github.com/matthewwithanm/django-imagekit/issues/391
"""
def _save(self, name, content):
"""
We create a clone of the content file as when this is passed to
boto3 it wrongly closes the file upon upload where as the storage
backend expects it to still be open
"""
# Seek our content back to the start
content.seek(0, os.SEEK_SET)
# Create a temporary file that will write to disk after a specified
# size. This file will be automatically deleted when closed by
# boto3 or after exiting the `with` statement if the boto3 is fixed
with SpooledTemporaryFile() as content_autoclose:
# Write our original content into our copy that will be closed by boto3
content_autoclose.write(content.read())
# Upload the object which will auto close the
# content_autoclose instance
return super()._save(name, content_autoclose)
class AzureStaticStorage(AzureStorage): # pylint: disable=abstract-method
"""Storage class for Static contents"""
location = "static"
class AzureImagesStorage(AzureStorage): # pylint: disable=abstract-method
"""Storage class for Image files"""
location = "images"
overwrite_files = False
class ExportsFileStorage(FileSystemStorage): # pylint: disable=abstract-method
"""Storage class for exports contents with local files"""
location = "exports"
overwrite_files = False
class ExportsS3Storage(S3Boto3Storage): # pylint: disable=abstract-method
"""Storage class for exports contents with S3"""
location = "exports"
default_acl = None
overwrite_files = False

View file

@ -137,7 +137,7 @@ def get_file_size(nbytes):
raw_size = float(nbytes) raw_size = float(nbytes)
except (ValueError, TypeError): except (ValueError, TypeError):
return repr(nbytes) return repr(nbytes)
else:
if raw_size < 1024: if raw_size < 1024:
return f"{raw_size} bytes" return f"{raw_size} bytes"
if raw_size < 1024**2: if raw_size < 1024**2:

View file

@ -1,8 +1,7 @@
""" testing activitystreams """ """ testing activitystreams """
from datetime import datetime from datetime import datetime, timezone
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from django.utils import timezone
from bookwyrm import activitystreams, models from bookwyrm import activitystreams, models

View file

@ -1,5 +1,5 @@
""" testing activitystreams """ """ testing activitystreams """
from datetime import datetime, timedelta import datetime
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
@ -71,8 +71,8 @@ class ActivitystreamsSignals(TestCase):
user=self.remote_user, user=self.remote_user,
content="hi", content="hi",
privacy="public", privacy="public",
created_date=datetime(2022, 5, 16, tzinfo=timezone.utc), created_date=datetime.datetime(2022, 5, 16, tzinfo=datetime.timezone.utc),
published_date=datetime(2022, 5, 14, tzinfo=timezone.utc), published_date=datetime.datetime(2022, 5, 14, tzinfo=datetime.timezone.utc),
) )
with patch("bookwyrm.activitystreams.add_status_task.apply_async") as mock: with patch("bookwyrm.activitystreams.add_status_task.apply_async") as mock:
activitystreams.add_status_on_create_command(models.Status, status, False) activitystreams.add_status_on_create_command(models.Status, status, False)
@ -87,7 +87,7 @@ class ActivitystreamsSignals(TestCase):
user=self.remote_user, user=self.remote_user,
content="hi", content="hi",
privacy="public", privacy="public",
published_date=timezone.now() - timedelta(days=1), published_date=timezone.now() - datetime.timedelta(days=1),
) )
with patch("bookwyrm.activitystreams.add_status_task.apply_async") as mock: with patch("bookwyrm.activitystreams.add_status_task.apply_async") as mock:
activitystreams.add_status_on_create_command(models.Status, status, False) activitystreams.add_status_on_create_command(models.Status, status, False)

View file

@ -2,7 +2,6 @@
import pathlib import pathlib
from unittest.mock import patch from unittest.mock import patch
import datetime import datetime
import pytz
from django.test import TestCase from django.test import TestCase
@ -13,7 +12,7 @@ from bookwyrm.models.import_job import handle_imported_book
def make_date(*args): def make_date(*args):
"""helper function to easily generate a date obj""" """helper function to easily generate a date obj"""
return datetime.datetime(*args, tzinfo=pytz.UTC) return datetime.datetime(*args, tzinfo=datetime.timezone.utc)
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay") @patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")

View file

@ -3,7 +3,6 @@ from collections import namedtuple
import pathlib import pathlib
from unittest.mock import patch from unittest.mock import patch
import datetime import datetime
import pytz
from django.test import TestCase from django.test import TestCase
import responses import responses
@ -16,7 +15,7 @@ from bookwyrm.models.import_job import handle_imported_book
def make_date(*args): def make_date(*args):
"""helper function to easily generate a date obj""" """helper function to easily generate a date obj"""
return datetime.datetime(*args, tzinfo=pytz.UTC) return datetime.datetime(*args, tzinfo=datetime.timezone.utc)
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay") @patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")

View file

@ -2,7 +2,6 @@
import pathlib import pathlib
from unittest.mock import patch from unittest.mock import patch
import datetime import datetime
import pytz
from django.test import TestCase from django.test import TestCase
@ -13,7 +12,7 @@ from bookwyrm.models.import_job import handle_imported_book
def make_date(*args): def make_date(*args):
"""helper function to easily generate a date obj""" """helper function to easily generate a date obj"""
return datetime.datetime(*args, tzinfo=pytz.UTC) return datetime.datetime(*args, tzinfo=datetime.timezone.utc)
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay") @patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")

View file

@ -2,7 +2,6 @@
import pathlib import pathlib
from unittest.mock import patch from unittest.mock import patch
import datetime import datetime
import pytz
from django.test import TestCase from django.test import TestCase
@ -13,7 +12,7 @@ from bookwyrm.models.import_job import handle_imported_book
def make_date(*args): def make_date(*args):
"""helper function to easily generate a date obj""" """helper function to easily generate a date obj"""
return datetime.datetime(*args, tzinfo=pytz.UTC) return datetime.datetime(*args, tzinfo=datetime.timezone.utc)
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay") @patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")

View file

@ -2,7 +2,6 @@
import pathlib import pathlib
from unittest.mock import patch from unittest.mock import patch
import datetime import datetime
import pytz
from django.test import TestCase from django.test import TestCase
@ -13,7 +12,7 @@ from bookwyrm.models.import_job import handle_imported_book
def make_date(*args): def make_date(*args):
"""helper function to easily generate a date obj""" """helper function to easily generate a date obj"""
return datetime.datetime(*args, tzinfo=pytz.UTC) return datetime.datetime(*args, tzinfo=datetime.timezone.utc)
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay") @patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")

View file

@ -1,10 +1,10 @@
""" testing models """ """ testing models """
import datetime import datetime
from datetime import timezone
import json import json
import pathlib import pathlib
from unittest.mock import patch from unittest.mock import patch
from django.utils import timezone
from django.test import TestCase from django.test import TestCase
import responses import responses

View file

@ -1,5 +1,5 @@
""" style fixes and lookups for templates """ """ style fixes and lookups for templates """
from datetime import datetime import datetime
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
@ -95,14 +95,18 @@ class StatusDisplayTags(TestCase):
def test_get_published_date(self, *_): def test_get_published_date(self, *_):
"""date formatting""" """date formatting"""
date = datetime(2020, 1, 1, 0, 0, tzinfo=timezone.utc) date = datetime.datetime(2020, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
with patch("django.utils.timezone.now") as timezone_mock: with patch("django.utils.timezone.now") as timezone_mock:
timezone_mock.return_value = datetime(2022, 1, 1, 0, 0, tzinfo=timezone.utc) timezone_mock.return_value = datetime.datetime(
2022, 1, 1, 0, 0, tzinfo=datetime.timezone.utc
)
result = status_display.get_published_date(date) result = status_display.get_published_date(date)
self.assertEqual(result, "Jan. 1, 2020") self.assertEqual(result, "Jan. 1, 2020")
date = datetime(2022, 1, 1, 0, 0, tzinfo=timezone.utc) date = datetime.datetime(2022, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
with patch("django.utils.timezone.now") as timezone_mock: with patch("django.utils.timezone.now") as timezone_mock:
timezone_mock.return_value = datetime(2022, 1, 8, 0, 0, tzinfo=timezone.utc) timezone_mock.return_value = datetime.datetime(
2022, 1, 8, 0, 0, tzinfo=datetime.timezone.utc
)
result = status_display.get_published_date(date) result = status_display.get_published_date(date)
self.assertEqual(result, "Jan 1") self.assertEqual(result, "Jan 1")

View file

@ -1,8 +1,9 @@
""" test searching for books """ """ test searching for books """
import datetime import datetime
from datetime import timezone
from django.db import connection from django.db import connection
from django.test import TestCase from django.test import TestCase
from django.utils import timezone
from bookwyrm import book_search, models from bookwyrm import book_search, models
from bookwyrm.connectors.abstract_connector import AbstractMinimalConnector from bookwyrm.connectors.abstract_connector import AbstractMinimalConnector

View file

@ -1,10 +1,10 @@
""" test partial_date module """ """ test partial_date module """
import datetime import datetime
from datetime import timezone
import unittest import unittest
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils import timezone
from django.utils import translation from django.utils import translation
from bookwyrm.utils import partial_date from bookwyrm.utils import partial_date

View file

@ -72,12 +72,12 @@ class Signature(TestCase):
urlsplit(self.rat.inbox).path, urlsplit(self.rat.inbox).path,
data=data, data=data,
content_type="application/json", content_type="application/json",
**{ headers={
"HTTP_DATE": now, "date": now,
"HTTP_SIGNATURE": signature, "signature": signature,
"HTTP_DIGEST": digest, "digest": digest,
"HTTP_CONTENT_TYPE": "application/activity+json; charset=utf-8", "content-type": "application/activity+json; charset=utf-8",
"HTTP_HOST": NETLOC, "host": NETLOC,
}, },
) )

View file

@ -35,7 +35,7 @@ def validate_html(html):
e for e in errors.split("\n") if not any(exclude in e for exclude in excluded) e for e in errors.split("\n") if not any(exclude in e for exclude in excluded)
) )
if errors: if errors:
raise Exception(errors) raise ValueError(errors)
validator = HtmlValidator() validator = HtmlValidator()
# will raise exceptions # will raise exceptions
@ -62,6 +62,6 @@ class HtmlValidator(HTMLParser): # pylint: disable=abstract-method
and "noreferrer" in value and "noreferrer" in value
): ):
return return
raise Exception( raise ValueError(
'Links to a new tab must have rel="nofollow noopener noreferrer"' 'Links to a new tab must have rel="nofollow noopener noreferrer"'
) )

View file

@ -123,8 +123,8 @@ class ImportViews(TestCase):
"""Give people a sense of the timing""" """Give people a sense of the timing"""
models.ImportJob.objects.create( models.ImportJob.objects.create(
user=self.local_user, user=self.local_user,
created_date=datetime.datetime(2000, 1, 1), created_date=datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc),
updated_date=datetime.datetime(2001, 1, 1), updated_date=datetime.datetime(2001, 1, 1, tzinfo=datetime.timezone.utc),
status="complete", status="complete",
complete=True, complete=True,
mappings={}, mappings={},

View file

@ -134,7 +134,10 @@ class Inbox(TestCase):
"""check for blocked servers""" """check for blocked servers"""
request = self.factory.post( request = self.factory.post(
"", "",
HTTP_USER_AGENT="http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)", headers={
# pylint: disable-next=line-too-long
"user-agent": "http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)",
},
) )
self.assertIsNone(views.inbox.raise_is_blocked_user_agent(request)) self.assertIsNone(views.inbox.raise_is_blocked_user_agent(request))

View file

@ -78,7 +78,7 @@ class DeleteUserViews(TestCase):
form.data["password"] = "password" form.data["password"] = "password"
request = self.factory.post("", form.data) request = self.factory.post("", form.data)
request.user = self.local_user request.user = self.local_user
middleware = SessionMiddleware() middleware = SessionMiddleware(request)
middleware.process_request(request) middleware.process_request(request)
request.session.save() request.session.save()
@ -105,7 +105,7 @@ class DeleteUserViews(TestCase):
view = views.DeactivateUser.as_view() view = views.DeactivateUser.as_view()
request = self.factory.post("") request = self.factory.post("")
request.user = self.local_user request.user = self.local_user
middleware = SessionMiddleware() middleware = SessionMiddleware(request)
middleware.process_request(request) middleware.process_request(request)
request.session.save() request.session.save()
@ -137,7 +137,7 @@ class DeleteUserViews(TestCase):
form.data["password"] = "password" form.data["password"] = "password"
request = self.factory.post("", form.data) request = self.factory.post("", form.data)
request.user = self.local_user request.user = self.local_user
middleware = SessionMiddleware() middleware = SessionMiddleware(request)
middleware.process_request(request) middleware.process_request(request)
request.session.save() request.session.save()
@ -159,7 +159,7 @@ class DeleteUserViews(TestCase):
form.data["password"] = "password" form.data["password"] = "password"
request = self.factory.post("", form.data) request = self.factory.post("", form.data)
request.user = self.local_user request.user = self.local_user
middleware = SessionMiddleware() middleware = SessionMiddleware(request)
middleware.process_request(request) middleware.process_request(request)
request.session.save() request.session.save()

View file

@ -101,7 +101,7 @@ class ViewsHelpers(TestCase):
request = self.factory.post("", form.data) request = self.factory.post("", form.data)
request.user = self.local_user request.user = self.local_user
middleware = SessionMiddleware() middleware = SessionMiddleware(request)
middleware.process_request(request) middleware.process_request(request)
request.session.save() request.session.save()

View file

@ -1,7 +1,6 @@
"""testing the annual summary page""" """testing the annual summary page"""
from datetime import datetime import datetime
from unittest.mock import patch from unittest.mock import patch
import pytz
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.http import Http404 from django.http import Http404
@ -15,7 +14,7 @@ from bookwyrm.tests.validate_html import validate_html
def make_date(*args): def make_date(*args):
"""helper function to easily generate a date obj""" """helper function to easily generate a date obj"""
return datetime(*args, tzinfo=pytz.UTC) return datetime.datetime(*args, tzinfo=datetime.timezone.utc)
class AnnualSummary(TestCase): class AnnualSummary(TestCase):

View file

@ -113,11 +113,20 @@ class ViewsHelpers(TestCase): # pylint: disable=too-many-public-methods
request = self.factory.get( request = self.factory.get(
"", "",
{"q": "Test Book"}, {"q": "Test Book"},
HTTP_USER_AGENT="http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)", headers={
# pylint: disable-next=line-too-long
"user-agent": "http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)",
},
) )
self.assertFalse(views.helpers.is_bookwyrm_request(request)) self.assertFalse(views.helpers.is_bookwyrm_request(request))
request = self.factory.get("", {"q": "Test Book"}, HTTP_USER_AGENT=USER_AGENT) request = self.factory.get(
"",
{"q": "Test Book"},
headers={
"user-agent": USER_AGENT,
},
)
self.assertTrue(views.helpers.is_bookwyrm_request(request)) self.assertTrue(views.helpers.is_bookwyrm_request(request))
def test_handle_remote_webfinger_invalid(self, *_): def test_handle_remote_webfinger_invalid(self, *_):
@ -271,8 +280,12 @@ class ViewsHelpers(TestCase): # pylint: disable=too-many-public-methods
def test_redirect_to_referer_outside_domain(self, *_): def test_redirect_to_referer_outside_domain(self, *_):
"""safely send people on their way""" """safely send people on their way"""
request = self.factory.get("/path") request = self.factory.get(
request.META = {"HTTP_REFERER": "http://outside.domain/name"} "/path",
headers={
"referer": "http://outside.domain/name",
},
)
result = views.helpers.redirect_to_referer( result = views.helpers.redirect_to_referer(
request, "user-feed", self.local_user.localname request, "user-feed", self.local_user.localname
) )
@ -280,21 +293,33 @@ class ViewsHelpers(TestCase): # pylint: disable=too-many-public-methods
def test_redirect_to_referer_outside_domain_with_fallback(self, *_): def test_redirect_to_referer_outside_domain_with_fallback(self, *_):
"""invalid domain with regular params for the redirect function""" """invalid domain with regular params for the redirect function"""
request = self.factory.get("/path") request = self.factory.get(
request.META = {"HTTP_REFERER": "https://outside.domain/name"} "/path",
headers={
"referer": "http://outside.domain/name",
},
)
result = views.helpers.redirect_to_referer(request) result = views.helpers.redirect_to_referer(request)
self.assertEqual(result.url, "/") self.assertEqual(result.url, "/")
def test_redirect_to_referer_valid_domain(self, *_): def test_redirect_to_referer_valid_domain(self, *_):
"""redirect to within the app""" """redirect to within the app"""
request = self.factory.get("/path") request = self.factory.get(
request.META = {"HTTP_REFERER": f"{BASE_URL}/and/a/path"} "/path",
headers={
"referer": f"{BASE_URL}/and/a/path",
},
)
result = views.helpers.redirect_to_referer(request) result = views.helpers.redirect_to_referer(request)
self.assertEqual(result.url, f"{BASE_URL}/and/a/path") self.assertEqual(result.url, f"{BASE_URL}/and/a/path")
def test_redirect_to_referer_with_get_args(self, *_): def test_redirect_to_referer_with_get_args(self, *_):
"""if the path has get params (like sort) they are preserved""" """if the path has get params (like sort) they are preserved"""
request = self.factory.get("/path") request = self.factory.get(
request.META = {"HTTP_REFERER": f"{BASE_URL}/and/a/path?sort=hello"} "/path",
headers={
"referer": f"{BASE_URL}/and/a/path?sort=hello",
},
)
result = views.helpers.redirect_to_referer(request) result = views.helpers.redirect_to_referer(request)
self.assertEqual(result.url, f"{BASE_URL}/and/a/path?sort=hello") self.assertEqual(result.url, f"{BASE_URL}/and/a/path?sort=hello")

View file

@ -122,7 +122,7 @@ class OutboxView(TestCase):
privacy="public", privacy="public",
) )
request = self.factory.get("", {"page": 1}, HTTP_USER_AGENT=USER_AGENT) request = self.factory.get("", {"page": 1}, headers={"user-agent": USER_AGENT})
result = views.Outbox.as_view()(request, "mouse") result = views.Outbox.as_view()(request, "mouse")
data = json.loads(result.content) data = json.loads(result.content)

View file

@ -1,8 +1,7 @@
""" tests updating reading progress """ """ tests updating reading progress """
from datetime import datetime from datetime import datetime, timezone
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase, Client from django.test import TestCase, Client
from django.utils import timezone
from bookwyrm import models from bookwyrm import models

View file

@ -1,7 +1,7 @@
""" Database utilities """ """ Database utilities """
from typing import cast from typing import Optional, Iterable, Set, cast
import sqlparse # type: ignore import sqlparse # type: ignore[import-untyped]
def format_trigger(sql: str) -> str: def format_trigger(sql: str) -> str:
@ -21,3 +21,15 @@ def format_trigger(sql: str) -> str:
identifier_case="lower", identifier_case="lower",
), ),
) )
def add_update_fields(
update_fields: Optional[Iterable[str]], *fields: str
) -> Optional[Set[str]]:
"""
Helper for adding fields to the update_fields kwarg when modifying an object
in a model's save() method.
https://docs.djangoproject.com/en/5.0/releases/4.2/#setting-update-fields-in-model-save-may-now-be-required
"""
return set(fields).union(update_fields) if update_fields is not None else None

View file

@ -222,17 +222,17 @@ class PartialDateDescriptor:
return [("DAY", "Day prec."), ("MONTH", "Month prec."), ("YEAR", "Year prec.")] return [("DAY", "Day prec."), ("MONTH", "Month prec."), ("YEAR", "Year prec.")]
class PartialDateModel(models.DateTimeField): # type: ignore class PartialDateModel(models.DateTimeField): # type: ignore[type-arg]
"""a date field for Django models, using PartialDate as values""" """a date field for Django models, using PartialDate as values"""
descriptor_class = PartialDateDescriptor descriptor_class = PartialDateDescriptor
def formfield(self, **kwargs): # type: ignore def formfield(self, **kwargs): # type: ignore[no-untyped-def]
kwargs.setdefault("form_class", PartialDateFormField) kwargs.setdefault("form_class", PartialDateFormField)
return super().formfield(**kwargs) return super().formfield(**kwargs)
# pylint: disable-next=arguments-renamed # pylint: disable-next=arguments-renamed,line-too-long
def contribute_to_class(self, model, our_name_in_model, **kwargs): # type: ignore def contribute_to_class(self, model, our_name_in_model, **kwargs): # type: ignore[no-untyped-def]
# Define precision field. # Define precision field.
descriptor = self.descriptor_class(self) descriptor = self.descriptor_class(self)
precision: models.Field[Optional[str], Optional[str]] = models.CharField( precision: models.Field[Optional[str], Optional[str]] = models.CharField(

View file

@ -6,7 +6,7 @@ def clean(input_text: str) -> str:
"""Run through "bleach" """ """Run through "bleach" """
return bleach.clean( return bleach.clean(
input_text, input_text,
tags=[ tags={
"p", "p",
"blockquote", "blockquote",
"br", "br",
@ -20,7 +20,7 @@ def clean(input_text: str) -> str:
"ul", "ul",
"ol", "ol",
"li", "li",
], },
attributes=["href", "rel", "src", "alt", "data-mention"], attributes=["href", "rel", "src", "alt", "data-mention"],
strip=True, strip=True,
) )

View file

@ -225,4 +225,4 @@ def get_goal_status(user, year):
if goal.privacy != "public": if goal.privacy != "public":
return None return None
return dict(**goal.progress, **{"goal": goal.goal}) return {**goal.progress, **{"goal": goal.goal}}

View file

@ -231,7 +231,7 @@ def maybe_redirect_local_path(request, model):
def redirect_to_referer(request, *args, **kwargs): def redirect_to_referer(request, *args, **kwargs):
"""Redirect to the referrer, if it's in our domain, with get params""" """Redirect to the referrer, if it's in our domain, with get params"""
# make sure the refer is part of this instance # make sure the refer is part of this instance
validated = validate_url_domain(request.META.get("HTTP_REFERER")) validated = validate_url_domain(request.headers.get("referer", ""))
if validated: if validated:
return redirect(validated) return redirect(validated)

View file

@ -1,5 +1,5 @@
""" class views for login/register views """ """ class views for login/register views """
import pytz import zoneinfo
from django.contrib.auth import login from django.contrib.auth import login
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
@ -57,9 +57,11 @@ class Register(View):
email = form.data["email"] email = form.data["email"]
password = form.data["password"] password = form.data["password"]
try: try:
preferred_timezone = pytz.timezone(form.data.get("preferred_timezone")) preferred_timezone = zoneinfo.ZoneInfo(
except pytz.exceptions.UnknownTimeZoneError: form.data.get("preferred_timezone", "")
preferred_timezone = pytz.utc )
except (ValueError, zoneinfo.ZoneInfoNotFoundError):
preferred_timezone = zoneinfo.ZoneInfo("UTC")
# make sure the email isn't blocked as spam # make sure the email isn't blocked as spam
email_domain = email.split("@")[-1] email_domain = email.split("@")[-1]

View file

@ -14,9 +14,9 @@ from django.urls import reverse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.shortcuts import redirect from django.shortcuts import redirect
from storages.backends.s3boto3 import S3Boto3Storage from storages.backends.s3 import S3Storage
from bookwyrm import models, storage_backends from bookwyrm import models
from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob
from bookwyrm import settings from bookwyrm import settings
@ -220,17 +220,16 @@ class ExportUser(View):
class ExportArchive(View): class ExportArchive(View):
"""Serve the archive file""" """Serve the archive file"""
# TODO: how do we serve s3 files?
def get(self, request, archive_id): def get(self, request, archive_id):
"""download user export file""" """download user export file"""
export = BookwyrmExportJob.objects.get(task_id=archive_id, user=request.user) export = BookwyrmExportJob.objects.get(task_id=archive_id, user=request.user)
if isinstance(export.export_data.storage, storage_backends.ExportsS3Storage): if settings.USE_S3:
# make custom_domain None so we can sign the url # make custom_domain None so we can sign the url
# see https://github.com/jschneier/django-storages/issues/944 # see https://github.com/jschneier/django-storages/issues/944
storage = S3Boto3Storage(querystring_auth=True, custom_domain=None) storage = S3Storage(querystring_auth=True, custom_domain=None)
try: try:
url = S3Boto3Storage.url( url = S3Storage.url(
storage, storage,
f"/exports/{export.task_id}.tar.gz", f"/exports/{export.task_id}.tar.gz",
expire=settings.S3_SIGNED_URL_EXPIRY, expire=settings.S3_SIGNED_URL_EXPIRY,
@ -239,16 +238,18 @@ class ExportArchive(View):
raise Http404() raise Http404()
return redirect(url) return redirect(url)
if isinstance(export.export_data.storage, storage_backends.ExportsFileStorage): if settings.USE_AZURE:
# not implemented
return HttpResponseServerError()
try: try:
return HttpResponse( return HttpResponse(
export.export_data, export.export_data,
content_type="application/gzip", content_type="application/gzip",
headers={ headers={
"Content-Disposition": 'attachment; filename="bookwyrm-account-export.tar.gz"' # pylint: disable=line-too-long # pylint: disable=line-too-long
"Content-Disposition": 'attachment; filename="bookwyrm-account-export.tar.gz"'
}, },
) )
except FileNotFoundError: except FileNotFoundError:
raise Http404() raise Http404()
return HttpResponseServerError()

View file

@ -1,64 +1,64 @@
aiohttp==3.9.4 aiohttp==3.9.4
bleach==5.0.1 bleach==6.1.0
boto3==1.26.57 boto3==1.34.74
bw-file-resubmit==0.6.0rc2 bw-file-resubmit==0.6.0rc2
celery==5.3.1 celery==5.3.6
colorthief==0.2.1 colorthief==0.2.1
Django==3.2.25 Django==4.2.11
django-celery-beat==2.5.0 django-celery-beat==2.6.0
django-compressor==4.4 django-compressor==4.4
django-csp==3.7 django-csp==3.8
django-imagekit==4.1.0 django-imagekit==5.0.0
django-model-utils==4.3.1 django-model-utils==4.4.0
django-oauth-toolkit==2.3.0 django-oauth-toolkit==2.3.0
django-pgtrigger==4.11.0 django-pgtrigger==4.11.0
django-redis==5.2.0 django-sass-processor==1.4
django-sass-processor==1.2.2 django-storages==1.14.2
django-storages==1.13.2
django-storages[azure] django-storages[azure]
environs==9.5.0 environs==11.0.0
flower==2.0.1 flower==2.0.1
grpcio==1.57.0 # Not a direct dependency, pinned to get a security fix hiredis==2.3.2
libsass==0.22.0 libsass==0.23.0
Markdown==3.4.1 Markdown==3.6
opentelemetry-api==1.16.0 opentelemetry-api==1.24.0
opentelemetry-exporter-otlp-proto-grpc==1.16.0 opentelemetry-exporter-otlp-proto-grpc==1.24.0
opentelemetry-instrumentation-celery==0.37b0 opentelemetry-instrumentation-celery==0.45b0
opentelemetry-instrumentation-django==0.37b0 opentelemetry-instrumentation-django==0.45b0
opentelemetry-instrumentation-psycopg2==0.37b0 opentelemetry-instrumentation-psycopg2==0.45b0
opentelemetry-sdk==1.16.0 opentelemetry-sdk==1.24.0
Pillow==10.3.0 Pillow==10.3.0
pilkit>=3.0 # dependency of django-imagekit, 2.0 is incompatible with Pillow>=10 pilkit>=3.0 # dependency of django-imagekit, 2.0 is incompatible with Pillow>=10
protobuf==3.20.* psycopg2==2.9.9
psycopg2==2.9.5 pycryptodome==3.20.0
pycryptodome==3.19.1 pyotp==2.9.0
pyotp==2.8.0 python-dateutil==2.9.0.post0
python-dateutil==2.8.2 qrcode==7.4.2
pytz>=2022.7 redis==5.0.3
qrcode==7.3.1
redis==4.5.4
requests==2.32.0 requests==2.32.0
responses==0.22.0 responses==0.25.0
s3-tar==0.1.13 s3-tar==0.1.13
setuptools>=65.5.1 # Not a direct dependency, pinned to get a security fix
tornado==6.3.3 # Not a direct dependency, pinned to get a security fix # Indirect dependencies with version constraints for security fixes
grpcio>=1.57.0
setuptools>=65.5.1
tornado>=6.3.3
# Dev # Dev
black==22.* black==22.*
celery-types==0.18.0 celery-types==0.22.0
django-stubs[compatible-mypy]==4.2.4 django-stubs[compatible-mypy]==4.2.7
mypy==1.5.1 mypy==1.7.1
pylint==2.15.0 pylint==2.17.7
pytest==6.2.5 pytest==8.1.1
pytest-cov==2.10.1 pytest-cov==5.0.0
pytest-django==4.1.0 pytest-django==4.8.0
pytest-env==0.6.2 pytest-env==1.1.3
pytest-xdist==2.3.0 pytest-xdist==3.5.0
pytidylib==0.3.2 pytidylib==0.3.2
types-bleach==6.0.0.4 types-bleach==6.1.0.20240331
types-dataclasses==0.6.6 types-dataclasses==0.6.6
types-Markdown==3.4.2.10 types-Markdown==3.6.0.20240316
types-Pillow==10.2.0.20240311 types-Pillow==10.2.0.20240331
types-psycopg2==2.9.21.11 types-psycopg2==2.9.21.20240311
types-python-dateutil==2.8.19.14 types-python-dateutil==2.9.0.20240316
types-requests==2.31.0.2 types-requests==2.31.0.20240311