Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2021-10-25 10:59:46 -07:00
commit 327a616779
109 changed files with 4638 additions and 1501 deletions

View file

@ -293,7 +293,13 @@ class AnnouncementForm(CustomForm):
class ListForm(CustomForm):
class Meta:
model = models.List
fields = ["user", "name", "description", "curation", "privacy"]
fields = ["user", "name", "description", "curation", "privacy", "group"]
class GroupForm(CustomForm):
class Meta:
model = models.Group
fields = ["user", "privacy", "name", "description"]
class ReportForm(CustomForm):

View file

@ -3,10 +3,10 @@ from . import Importer
class GoodreadsImporter(Importer):
"""GoodReads is the default importer, thus Importer follows its structure.
"""Goodreads is the default importer, thus Importer follows its structure.
For a more complete example of overriding see librarything_import.py"""
service = "GoodReads"
service = "Goodreads"
def parse_fields(self, entry):
"""handle the specific fields in goodreads csvs"""

View file

@ -1,4 +1,4 @@
""" handle reading a csv from an external service, defaults are from GoodReads """
""" handle reading a csv from an external service, defaults are from Goodreads """
import csv
import logging

View file

@ -0,0 +1,871 @@
# Generated by Django 3.2.5 on 2021-10-16 06:39
import bookwyrm.models.fields
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0106_user_preferred_language"),
]
operations = [
migrations.CreateModel(
name="Group",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
(
"remote_id",
bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
),
("name", bookwyrm.models.fields.CharField(max_length=100)),
(
"description",
bookwyrm.models.fields.TextField(blank=True, null=True),
),
(
"privacy",
bookwyrm.models.fields.PrivacyField(
choices=[
("public", "Public"),
("unlisted", "Unlisted"),
("followers", "Followers"),
("direct", "Direct"),
],
default="public",
max_length=255,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="GroupMember",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name="GroupMemberInvitation",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
],
),
migrations.RemoveConstraint(
model_name="notification",
name="notification_type_valid",
),
migrations.AlterField(
model_name="list",
name="curation",
field=bookwyrm.models.fields.CharField(
choices=[
("closed", "Closed"),
("open", "Open"),
("curated", "Curated"),
("group", "Group"),
],
default="closed",
max_length=255,
),
),
migrations.AlterField(
model_name="notification",
name="notification_type",
field=models.CharField(
choices=[
("FAVORITE", "Favorite"),
("REPLY", "Reply"),
("MENTION", "Mention"),
("TAG", "Tag"),
("FOLLOW", "Follow"),
("FOLLOW_REQUEST", "Follow Request"),
("BOOST", "Boost"),
("IMPORT", "Import"),
("ADD", "Add"),
("REPORT", "Report"),
("INVITE", "Invite"),
("ACCEPT", "Accept"),
("JOIN", "Join"),
("LEAVE", "Leave"),
("REMOVE", "Remove"),
],
max_length=255,
),
),
migrations.AlterField(
model_name="user",
name="preferred_language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("de-de", "Deutsch (German)"),
("es", "Español (Spanish)"),
("fr-fr", "Français (French)"),
("zh-hans", "简体中文 (Simplified Chinese)"),
("zh-hant", "繁體中文 (Traditional Chinese)"),
],
max_length=255,
null=True,
),
),
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/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/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"),
("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"),
],
default="UTC",
max_length=255,
),
),
migrations.AddConstraint(
model_name="notification",
constraint=models.CheckConstraint(
check=models.Q(
(
"notification_type__in",
[
"FAVORITE",
"REPLY",
"MENTION",
"TAG",
"FOLLOW",
"FOLLOW_REQUEST",
"BOOST",
"IMPORT",
"ADD",
"REPORT",
"INVITE",
"ACCEPT",
"JOIN",
"LEAVE",
"REMOVE",
],
)
),
name="notification_type_valid",
),
),
migrations.AddField(
model_name="groupmemberinvitation",
name="group",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_invitations",
to="bookwyrm.group",
),
),
migrations.AddField(
model_name="groupmemberinvitation",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="group_invitations",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="groupmember",
name="group",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="memberships",
to="bookwyrm.group",
),
),
migrations.AddField(
model_name="groupmember",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="memberships",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="group",
name="user",
field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
migrations.AddField(
model_name="list",
name="group",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="bookwyrm.group",
),
),
migrations.AddField(
model_name="notification",
name="related_group",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to="bookwyrm.group",
),
),
migrations.AddConstraint(
model_name="groupmemberinvitation",
constraint=models.UniqueConstraint(
fields=("group", "user"), name="unique_invitation"
),
),
migrations.AddConstraint(
model_name="groupmember",
constraint=models.UniqueConstraint(
fields=("group", "user"), name="unique_membership"
),
),
]

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.5 on 2021-10-16 19:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0107_auto_20211016_0639"),
("bookwyrm", "0110_auto_20211015_1734"),
]
operations = []

View file

@ -0,0 +1,93 @@
# Generated by Django 3.2.5 on 2021-10-22 08:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0111_merge_0107_auto_20211016_0639_0110_auto_20211015_1734"),
]
operations = [
migrations.RemoveConstraint(
model_name="notification",
name="notification_type_valid",
),
migrations.AlterField(
model_name="notification",
name="notification_type",
field=models.CharField(
choices=[
("FAVORITE", "Favorite"),
("REPLY", "Reply"),
("MENTION", "Mention"),
("TAG", "Tag"),
("FOLLOW", "Follow"),
("FOLLOW_REQUEST", "Follow Request"),
("BOOST", "Boost"),
("IMPORT", "Import"),
("ADD", "Add"),
("REPORT", "Report"),
("INVITE", "Invite"),
("ACCEPT", "Accept"),
("JOIN", "Join"),
("LEAVE", "Leave"),
("REMOVE", "Remove"),
("GROUP_PRIVACY", "Group Privacy"),
("GROUP_NAME", "Group Name"),
("GROUP_DESCRIPTION", "Group Description"),
],
max_length=255,
),
),
migrations.AlterField(
model_name="user",
name="preferred_language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("de-de", "Deutsch (German)"),
("es-es", "Español (Spanish)"),
("fr-fr", "Français (French)"),
("pt-br", "Português - Brasil (Brazilian Portuguese)"),
("zh-hans", "简体中文 (Simplified Chinese)"),
("zh-hant", "繁體中文 (Traditional Chinese)"),
],
max_length=255,
null=True,
),
),
migrations.AddConstraint(
model_name="notification",
constraint=models.CheckConstraint(
check=models.Q(
(
"notification_type__in",
[
"FAVORITE",
"REPLY",
"MENTION",
"TAG",
"FOLLOW",
"FOLLOW_REQUEST",
"BOOST",
"IMPORT",
"ADD",
"REPORT",
"INVITE",
"ACCEPT",
"JOIN",
"LEAVE",
"REMOVE",
"GROUP_PRIVACY",
"GROUP_NAME",
"GROUP_DESCRIPTION",
],
)
),
name="notification_type_valid",
),
),
]

View file

@ -21,6 +21,8 @@ from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .report import Report, ReportComment
from .federated_server import FederatedServer
from .group import Group, GroupMember, GroupMemberInvitation
from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite

View file

@ -78,7 +78,24 @@ class BookWyrmModel(models.Model):
self.privacy in ["direct", "followers"]
and self.mention_users.filter(id=viewer.id).first()
):
return
# you can see groups of which you are a member
if (
hasattr(self, "memberships")
and self.memberships.filter(user=viewer).exists()
):
return
# you can see objects which have a group of which you are a member
if hasattr(self, "group"):
if (
hasattr(self.group, "memberships")
and self.group.memberships.filter(user=viewer).exists()
):
return
raise Http404()
def raise_not_editable(self, viewer):

182
bookwyrm/models/group.py Normal file
View file

@ -0,0 +1,182 @@
""" do book related things with other users """
from django.apps import apps
from django.db import models, IntegrityError, transaction
from django.db.models import Q
from bookwyrm.settings import DOMAIN
from .base_model import BookWyrmModel
from . import fields
from .relationship import UserBlocks
class Group(BookWyrmModel):
"""A group of users"""
name = fields.CharField(max_length=100)
user = fields.ForeignKey("User", on_delete=models.CASCADE)
description = fields.TextField(blank=True, null=True)
privacy = fields.PrivacyField()
def get_remote_id(self):
"""don't want the user to be in there in this case"""
return f"https://{DOMAIN}/group/{self.id}"
@classmethod
def followers_filter(cls, queryset, viewer):
"""Override filter for "followers" privacy level to allow non-following
group members to see the existence of group-curated lists"""
return queryset.exclude(
~Q( # user is not a group member
Q(user__followers=viewer) | Q(user=viewer) | Q(memberships__user=viewer)
),
privacy="followers", # and the status of the group is followers only
)
@classmethod
def direct_filter(cls, queryset, viewer):
"""Override filter for "direct" privacy level to allow group members
to see the existence of groups and group lists"""
return queryset.exclude(~Q(memberships__user=viewer), privacy="direct")
class GroupMember(models.Model):
"""Users who are members of a group"""
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
group = models.ForeignKey(
"Group", on_delete=models.CASCADE, related_name="memberships"
)
user = models.ForeignKey(
"User", on_delete=models.CASCADE, related_name="memberships"
)
class Meta:
"""Users can only have one membership per group"""
constraints = [
models.UniqueConstraint(fields=["group", "user"], name="unique_membership")
]
def save(self, *args, **kwargs):
"""don't let a user invite someone who blocked them"""
# blocking in either direction is a no-go
if UserBlocks.objects.filter(
Q(
user_subject=self.group.user,
user_object=self.user,
)
| Q(
user_subject=self.user,
user_object=self.group.user,
)
).exists():
raise IntegrityError()
# accepts and requests are handled by the GroupInvitation model
super().save(*args, **kwargs)
@classmethod
def from_request(cls, join_request):
"""converts a join request into a member relationship"""
# remove the invite
join_request.delete()
# make a group member
return cls.objects.create(
user=join_request.user,
group=join_request.group,
)
@classmethod
def remove(cls, owner, user):
"""remove a user from a group"""
memberships = cls.objects.filter(group__user=owner, user=user).all()
for member in memberships:
member.delete()
class GroupMemberInvitation(models.Model):
"""adding a user to a group requires manual confirmation"""
created_date = models.DateTimeField(auto_now_add=True)
group = models.ForeignKey(
"Group", on_delete=models.CASCADE, related_name="user_invitations"
)
user = models.ForeignKey(
"User", on_delete=models.CASCADE, related_name="group_invitations"
)
class Meta:
"""Users can only have one outstanding invitation per group"""
constraints = [
models.UniqueConstraint(fields=["group", "user"], name="unique_invitation")
]
def save(self, *args, **kwargs):
"""make sure the membership doesn't already exist"""
# if there's an invitation for a membership that already exists, accept it
# without changing the local database state
if GroupMember.objects.filter(user=self.user, group=self.group).exists():
self.accept()
return
# blocking in either direction is a no-go
if UserBlocks.objects.filter(
Q(
user_subject=self.group.user,
user_object=self.user,
)
| Q(
user_subject=self.user,
user_object=self.group.user,
)
).exists():
raise IntegrityError()
# make an invitation
super().save(*args, **kwargs)
# now send the invite
model = apps.get_model("bookwyrm.Notification", require_ready=True)
notification_type = "INVITE"
model.objects.create(
user=self.user,
related_user=self.group.user,
related_group=self.group,
notification_type=notification_type,
)
def accept(self):
"""turn this request into the real deal"""
with transaction.atomic():
GroupMember.from_request(self)
model = apps.get_model("bookwyrm.Notification", require_ready=True)
# tell the group owner
model.objects.create(
user=self.group.user,
related_user=self.user,
related_group=self.group,
notification_type="ACCEPT",
)
# let the other members know about it
for membership in self.group.memberships.all():
member = membership.user
if member not in (self.user, self.group.user):
model.objects.create(
user=member,
related_user=self.user,
related_group=self.group,
notification_type="JOIN",
)
def reject(self):
"""generate a Reject for this membership request"""
self.delete()

View file

@ -1,22 +1,20 @@
""" make a list of books!! """
from django.apps import apps
from django.db import models
from django.db.models import Q
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
from .group import GroupMember
from . import fields
CurationType = models.TextChoices(
"Curation",
[
"closed",
"open",
"curated",
],
["closed", "open", "curated", "group"],
)
@ -32,6 +30,13 @@ class List(OrderedCollectionMixin, BookWyrmModel):
curation = fields.CharField(
max_length=255, default="closed", choices=CurationType.choices
)
group = models.ForeignKey(
"Group",
on_delete=models.SET_NULL,
default=None,
blank=True,
null=True,
)
books = models.ManyToManyField(
"Edition",
symmetrical=False,
@ -54,6 +59,52 @@ class List(OrderedCollectionMixin, BookWyrmModel):
ordering = ("-updated_date",)
def raise_not_editable(self, viewer):
"""the associated user OR the list owner can edit"""
if self.user == viewer:
return
# group members can edit items in group lists
is_group_member = GroupMember.objects.filter(
group=self.group, user=viewer
).exists()
if is_group_member:
return
super().raise_not_editable(viewer)
@classmethod
def followers_filter(cls, queryset, viewer):
"""Override filter for "followers" privacy level to allow non-following
group members to see the existence of group lists"""
return queryset.exclude(
~Q( # user isn't following or group member
Q(user__followers=viewer)
| Q(user=viewer)
| Q(group__memberships__user=viewer)
),
privacy="followers", # and the status (of the list) is followers only
)
@classmethod
def direct_filter(cls, queryset, viewer):
"""Override filter for "direct" privacy level to allow
group members to see the existence of group lists"""
return queryset.exclude(
~Q( # user not self and not in the group if this is a group list
Q(user=viewer) | Q(group__memberships__user=viewer)
),
privacy="direct",
)
@classmethod
def remove_from_group(cls, owner, user):
"""remove a list from a group"""
cls.objects.filter(group__user=owner, user=user).all().update(
group=None, curation="closed"
)
class ListItem(CollectionItemMixin, BookWyrmModel):
"""ok"""
@ -82,9 +133,9 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
self.book_list.save(broadcast=False)
list_owner = self.book_list.user
model = apps.get_model("bookwyrm.Notification", require_ready=True)
# create a notification if somoene ELSE added to a local user's list
if created and list_owner.local and list_owner != self.user:
model = apps.get_model("bookwyrm.Notification", require_ready=True)
model.objects.create(
user=list_owner,
related_user=self.user,
@ -92,10 +143,26 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
notification_type="ADD",
)
if self.book_list.group:
for membership in self.book_list.group.memberships.all():
if membership.user != self.user:
model.objects.create(
user=membership.user,
related_user=self.user,
related_list_item=self,
notification_type="ADD",
)
def raise_not_deletable(self, viewer):
"""the associated user OR the list owner can delete"""
if self.book_list.user == viewer:
return
# group members can delete items in group lists
is_group_member = GroupMember.objects.filter(
group=self.book_list.group, user=viewer
).exists()
if is_group_member:
return
super().raise_not_deletable(viewer)
class Meta:

View file

@ -4,10 +4,10 @@ from django.dispatch import receiver
from .base_model import BookWyrmModel
from . import Boost, Favorite, ImportJob, Report, Status, User
# pylint: disable=line-too-long
NotificationType = models.TextChoices(
"NotificationType",
"FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT",
"FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT INVITE ACCEPT JOIN LEAVE REMOVE GROUP_PRIVACY GROUP_NAME GROUP_DESCRIPTION",
)
@ -19,6 +19,9 @@ class Notification(BookWyrmModel):
related_user = models.ForeignKey(
"User", on_delete=models.CASCADE, null=True, related_name="related_user"
)
related_group = models.ForeignKey(
"Group", on_delete=models.CASCADE, null=True, related_name="notifications"
)
related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
related_list_item = models.ForeignKey(
@ -37,6 +40,7 @@ class Notification(BookWyrmModel):
user=self.user,
related_book=self.related_book,
related_user=self.related_user,
related_group=self.related_group,
related_status=self.related_status,
related_import=self.related_import,
related_list_item=self.related_list_item,

View file

@ -28,6 +28,12 @@ let BookWyrm = new class {
this.revealForm.bind(this))
);
document.querySelectorAll('[data-hides]')
.forEach(button => button.addEventListener(
'change',
this.hideForm.bind(this))
);
document.querySelectorAll('[data-back]')
.forEach(button => button.addEventListener(
'click',
@ -119,8 +125,8 @@ let BookWyrm = new class {
}
/**
* Toggle form.
*
* Show form.
*
* @param {Event} event
* @return {undefined}
*/
@ -128,7 +134,23 @@ let BookWyrm = new class {
let trigger = event.currentTarget;
let hidden = trigger.closest('.hidden-form').querySelectorAll('.is-hidden')[0];
this.addRemoveClass(hidden, 'is-hidden', !hidden);
if (hidden) {
this.addRemoveClass(hidden, 'is-hidden', !hidden);
}
}
/**
* Hide form.
*
* @param {Event} event
* @return {undefined}
*/
hideForm(event) {
let trigger = event.currentTarget;
let targetId = trigger.dataset.hides
let visible = document.getElementById(targetId)
this.addRemoveClass(visible, 'is-hidden', true);
}
/**
@ -227,7 +249,7 @@ let BookWyrm = new class {
}
/**
* Check or uncheck a checbox.
* Check or uncheck a checkbox.
*
* @param {string} checkbox - id of the checkbox
* @param {boolean} pressed - Is the trigger pressed?

View file

@ -81,7 +81,7 @@ class SuggestedUsers(RedisStore):
"""take a user out of someone's suggestions"""
self.bulk_remove_objects_from_store([suggested_user], self.store_id(user))
def get_suggestions(self, user):
def get_suggestions(self, user, local=False):
"""get suggestions"""
values = self.get_store(self.store_id(user), withscores=True)
results = []
@ -97,8 +97,8 @@ class SuggestedUsers(RedisStore):
logger.exception(err)
continue
user.mutuals = counts["mutuals"]
# user.shared_books = counts["shared_books"]
results.append(user)
if (local and user.local) or not local:
results.append(user)
if len(results) >= 5:
break
return results

View file

@ -110,8 +110,14 @@
{% for book in books %}
<div class="column is-one-fifth">
{% include 'landing/small-book.html' with book=book %}
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
{% endfor %}
</div>
</div>
<div>
{% include 'snippets/pagination.html' with page=books %}
</div>
{% endblock %}

View file

@ -12,7 +12,9 @@
<div>
<p>{% trans "Added:" %} {{ author.created_date | naturaltime }}</p>
<p>{% trans "Updated:" %} {{ author.updated_date | naturaltime }}</p>
{% if author.last_edited_by %}
<p>{% trans "Last edited by:" %} <a href="{{ author.last_edited_by.remote_id }}">{{ author.last_edited_by.display_name }}</a></p>
{% endif %}
</div>
</header>

View file

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

View file

@ -3,7 +3,7 @@
{% load i18n %}
{% load humanize %}
{% firstof book.physical_format_detail book.physical_format as format %}
{% firstof book.physical_format_detail book.get_physical_format_display as format %}
{% firstof book.physical_format book.physical_format_detail as format_property %}
{% with pages=book.pages %}
{% if format or pages %}
@ -18,7 +18,7 @@
<p>
{% if format and not pages %}
{% blocktrans %}{{ format }}{% endblocktrans %}
{{ format }}
{% elif format and pages %}
{% blocktrans %}{{ format }}, {{ pages }} pages{% endblocktrans %}
{% elif pages %}

View file

@ -0,0 +1,26 @@
{% load i18n %}
{% load utilities %}
{% with user_path=status.user.local_path username=status.user.display_name book_path=status.book.local_poth book_title=book|book_title %}
{% if status.status_type == 'GeneratedNote' %}
{{ status.content|safe }}
{% elif status.status_type == 'Rating' %}
{% blocktrans trimmed %}
<a href="{{ user_path}}">{{ username }}</a> rated <a href="{{ book_path }}">{{ book_title }}</a>
{% endblocktrans %}
{% elif status.status_type == 'Review' %}
{% blocktrans trimmed %}
<a href="{{ user_path}}">{{ username }}</a> reviewed <a href="{{ book_path }}">{{ book_title }}</a>
{% endblocktrans %}
{% elif status.status_type == 'Comment' %}
{% blocktrans trimmed %}
<a href="{{ user_path}}">{{ username }}</a> commented on <a href="{{ book_path }}">{{ book_title }}</a>
{% endblocktrans %}
{% elif status.status_type == 'Quotation' %}
{% blocktrans trimmed %}
<a href="{{ user_path}}">{{ username }}</a> quoted <a href="{{ book_path }}">{{ book_title }}</a>
{% endblocktrans %}
{% endif %}
{% endwith %}

View file

@ -36,23 +36,7 @@
</figure>
<div class="media-content">
<h3 class="title is-6">
<a href="{{ status.user.local_path }}">
<span>{{ status.user.display_name }}</span>
</a>
{% if status.status_type == 'GeneratedNote' %}
{{ status.content|safe }}
{% elif status.status_type == 'Rating' %}
{% trans "rated" %}
{% elif status.status_type == 'Review' %}
{% trans "reviewed" %}
{% elif status.status_type == 'Comment' %}
{% trans "commented on" %}
{% elif status.status_type == 'Quotation' %}
{% trans "quoted" %}
{% endif %}
<a href="{{ book.local_path }}">{{ book.title }}</a>
{% include "discover/card-header.html" %}
</h3>
</div>
</div>

View file

@ -22,23 +22,7 @@
<div class="media-content">
<h3 class="title is-6">
<a href="{{ status.user.local_path }}">
<span>{{ status.user.display_name }}</span>
</a>
{% if status.status_type == 'GeneratedNote' %}
{{ status.content|safe }}
{% elif status.status_type == 'Rating' %}
{% trans "rated" %}
{% elif status.status_type == 'Review' %}
{% trans "reviewed" %}
{% elif status.status_type == 'Comment' %}
{% trans "commented on" %}
{% elif status.status_type == 'Quotation' %}
{% trans "quoted" %}
{% endif %}
<a href="{{ book.local_path }}">{{ book.title }}</a>
{% include "discover/card-header.html" %}
</h3>
{% if status.rating %}
<p class="subtitle is-6">

View file

@ -12,6 +12,6 @@
<p>
{% url 'code-of-conduct' as coc_path %}
{% url 'about' as about_path %}
{% blocktrans %}Learn more <a href="https://{{ domain }}{{ about_path }}">about this instance</a>.{% endblocktrans %}
{% blocktrans %}Learn more <a href="https://{{ domain }}{{ about_path }}">about {{ site_name }}</a>.{% endblocktrans %}
</p>
{% endblock %}

View file

@ -5,6 +5,6 @@
{{ invite_link }}
{% trans "Learn more about this instance:" %} https://{{ domain }}{% url 'about' %}
{% blocktrans %}Learn more about {{ site_name }}:{% endblocktrans %} https://{{ domain }}{% url 'about' %}
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends 'components/inline_form.html' %}
{% load i18n %}
{% block header %}
{% trans "Create Group" %}
{% endblock %}
{% block form %}
<form name="create-group" method="post" action="{% url 'user-groups' request.user.username %}">
{% include 'groups/form.html' with group_form=group_form %}
</form>
{% endblock %}

View file

@ -0,0 +1,6 @@
{% load i18n %}
{% spaceless %}
{% blocktrans with username=group.user.display_name path=group.user.local_path %}Managed by <a href="{{ path }}">{{ username }}</a>{% endblocktrans %}
{% endspaceless %}

View file

@ -0,0 +1,21 @@
{% extends 'components/modal.html' %}
{% load i18n %}
{% block modal-title %}{% trans "Delete this group?" %}{% endblock %}
{% block modal-body %}
{% trans "This action cannot be un-done" %}
{% endblock %}
{% block modal-footer %}
<form name="delete-group-{{ group.id }}" action="{% url 'delete-group' group.id %}" method="POST">
{% csrf_token %}
<input type="hidden" name="id" value="{{ group.id }}">
<button class="button is-danger" type="submit">
{% trans "Delete" %}
</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="delete_group" controls_uid=group.id %}
</form>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends 'components/inline_form.html' %}
{% load i18n %}
{% block header %}
{% trans "Edit Group" %}
{% endblock %}
{% block form %}
<form name="edit-group" method="post" action="{% url 'group' group.id %}">
{% include 'groups/form.html' %}
</form>
{% include "groups/delete_group_modal.html" with controls_text="delete_group" controls_uid=group.id %}
{% endblock %}

View file

@ -0,0 +1,9 @@
{% extends 'groups/group.html' %}
{% load i18n %}
{% block searchresults %}
<h2 class="title is-5">
{% trans "Add new members!" %}
</h2>
{% include 'groups/suggested_users.html' with suggested_users=suggested_users %}
{% endblock %}

View file

@ -0,0 +1,34 @@
{% load i18n %}
{% csrf_token %}
<div class="columns">
<div class="column is-two-thirds">
<input type="hidden" name="user" value="{{ request.user.id }}" />
<div class="field">
<label class="label" for="id_name">{% trans "Group Name:" %}</label>
{{ group_form.name }}
</div>
<div class="field">
<label class="label" for="id_description">{% trans "Group Description:" %}</label>
{{ group_form.description }}
</div>
</div>
</div>
<div class="columns is-mobile">
<div class="column">
<div class="field has-addons">
<div class="control">
{% include 'snippets/privacy_select_no_followers.html' with current=group.privacy %}
</div>
<div class="control">
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
</div>
</div>
</div>
{% if group.id %}
<div class="column is-narrow">
{% trans "Delete group" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class="is-danger" text=button_text icon_with_text="x" controls_text="delete_group" controls_uid=group.id focus="modal_title_delete_group" %}
</div>
{% endif %}
</div>

View file

@ -0,0 +1,82 @@
{% extends 'groups/layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load markdown %}
{% block panel %}
<div class="columns mt-3">
<section class="column is-three-quarters">
{% if group.user == request.user %}
<div class="block">
<form class="field has-addons" method="get" action="{% url 'group-find-users' group.id %}">
<div class="control">
<input type="text" name="user_query" value="{{ request.GET.user_query }}" class="input" placeholder="{% trans 'Search to add a user' %}" aria-label="{% trans 'Search to add a user' %}">
</div>
<div class="control">
<button class="button" type="submit">
<span class="icon icon-search" title="{% trans 'Search' %}">
<span class="is-sr-only">{% trans "Search" %}</span>
</span>
</button>
</div>
</form>
</div>
{% endif %}
{% block searchresults %}
{% endblock %}
<div class="mb-2">
{% include "groups/members.html" with group=group %}
</div>
<h2 class="title is-5">Lists</h2>
{% if not lists %}
<p>{% trans "This group has no lists" %}</p>
{% else %}
<div class="columns is-multiline">
{% for list in lists %}
<div class="column is-one-third">
<div class="card is-stretchable">
<header class="card-header">
<h4 class="card-header-title">
<a href="{{ list.local_path }}">{{ list.name }}</a> <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>
</h4>
</header>
{% with list_books=list.listitem_set.all|slice:5 %}
{% if list_books %}
<div class="card-image columns is-mobile is-gapless is-clipped">
{% for book in list_books %}
<a class="column is-cover" href="{{ book.book.local_path }}">
{% include 'snippets/book_cover.html' with book=book.book cover_class='is-h-s' size='small' aria='show' %}
</a>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="card-content is-flex-grow-0">
<div class="is-clipped" {% if list.description %}title="{{ list.description }}"{% endif %}>
{% if list.description %}
{{ list.description|to_markdown|safe|truncatechars_html:30 }}
{% else %}
&nbsp;
{% endif %}
</div>
<p class="subtitle help">
{% include 'lists/created_text.html' with list=list %}
</p>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% include "snippets/pagination.html" with page=items %}
</section>
</div>
{% endblock %}

View file

@ -0,0 +1,32 @@
{% extends 'layout.html' %}
{% load i18n %}
{% block title %}{{ group.name }}{% endblock %}
{% block content %}
<header class="columns content is-mobile">
<div class="column">
<h1 class="title">{{ group.name }} <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=group %}</span></h1>
<p class="subtitle help">
{% include 'groups/created_text.html' with group=group %}
</p>
</div>
<div class="column is-narrow is-flex">
{% if request.user == group.user %}
{% trans "Edit group" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="pencil" controls_text="edit_group" focus="edit_group_header" %}
{% endif %}
</div>
</header>
<div class="block content">
{% include 'snippets/trimmed_text.html' with full=group.description %}
</div>
<div class="block">
{% include 'groups/edit_form.html' with controls_text="edit_group" %}
</div>
{% block panel %}{% endblock %}
{% endblock %}

View file

@ -0,0 +1,47 @@
{% load i18n %}
{% load utilities %}
{% load humanize %}
{% load bookwyrm_tags %}
{% load bookwyrm_group_tags %}
<h2 class="title is-5">Group Members</h2>
<p class="subtitle is-6">{% trans "Members can add and remove books on a group's book lists" %}</p>
{% if group.user != request.user and group|is_member:request.user %}
<form action="{% url 'remove-group-member' %}" method="POST" class="my-4">
{% csrf_token %}
<input type="hidden" name="group" value="{{ group.id }}">
<input type="hidden" name="user" value="{{ user.username }}">
<button id="remove_self_button" class="button is-small is-danger is-light is-hidden" type="submit">
{% trans "Confirm" %}
</button>
<button id="hide_remove_self_button" data-controls="remove_self_button" class="button is-small" type="button" aria-pressed="false">
{% trans "Leave group" %}
</button>
</form>
{% endif %}
<div class="is-multiline is-flex is-flex-grow-0 is-flex-wrap-wrap">
{% for membership in group.memberships.all %}
{% with member=membership.user %}
<div class="box has-text-centered is-shadowless has-background-white-bis my-2 mx-2 member_{{ member.id }}">
<a href="{{ member.local_path }}" class="has-text-black">
{% include 'snippets/avatar.html' with user=member large=True %}
<span title="{{ member.display_name }}" class="is-block is-6 has-text-weight-bold">{{ member.display_name|truncatechars:10 }}</span>
<span title="@{{ member|username }}" class="is-block pb-3">@{{ member|username|truncatechars:8 }}</span>
</a>
{% if group.user == member %}
<span class="icon icon-star-full" title="Manager">
<span class="is-sr-only">Manager</span>
</span>
{% endif %}
{% include 'snippets/remove_from_group_button.html' with user=member group=group %}
{% if request.user in member.following.all %}
<p class="help">
{% trans "Follows you" %}
</p>
{% endif %}
</div>
{% endwith %}
{% endfor %}
</div>

View file

@ -0,0 +1,46 @@
{% load i18n %}
{% load utilities %}
{% load humanize %}
{% if suggested_users %}
<div class="column is-flex is-flex-grow-0">
{% for user in suggested_users %}
<div class="box has-text-centered is-shadowless has-background-white-bis m-2">
<a href="{{ user.local_path }}" class="has-text-black">
{% include 'snippets/avatar.html' with user=user large=True %}
<span title="{{ user.display_name }}" class="is-block is-6 has-text-weight-bold">{{ user.display_name|truncatechars:10 }}</span>
<span title="@{{ user|username }}" class="is-block pb-3">@{{ user|username|truncatechars:8 }}</span>
</a>
{% include 'snippets/add_to_group_button.html' with user=user group=group %}
{% if user.mutuals %}
<p class="help">
{% blocktrans trimmed with mutuals=user.mutuals|intcomma count counter=user.mutuals %}
{{ mutuals }} follower you follow
{% plural %}
{{ mutuals }} followers you follow{% endblocktrans %}
</p>
{% elif user.shared_books %}
<p class="help">
{% blocktrans trimmed with shared_books=user.shared_books|intcomma count counter=user.shared_books %}
{{ shared_books }} book on your shelves
{% plural %}
{{ shared_books }} books on your shelves
{% endblocktrans %}
</p>
{% elif request.user in user.following.all %}
<p class="help">
{% trans "Follows you" %}
</p>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p>
{% blocktrans trimmed %}
No potential members found for "{{ user_query }}"
{% endblocktrans %}
</p>
<br/>
{% endif %}

View file

@ -0,0 +1,35 @@
{% load i18n %}
{% load markdown %}
{% load interaction %}
<div class="columns is-multiline">
{% for group in groups %}
<div class="column is-one-quarter">
<div class="card is-stretchable">
<header class="card-header">
<h4 class="card-header-title">
<a href="{{ group.local_path }}">{{ group.name }}</a> <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=group %}</span>
</h4>
{% if group.user == user %}
<div class="card-header-icon">
{% trans "Manager" as text %}
<span class="icon icon-star-full has-text-grey" title="{{ text }}">
<span class="is-sr-only">{{ text }}</span>
</span>
</div>
{% endif %}
</header>
<div class="card-content is-flex-grow-0">
<div class="is-clipped" {% if group.description %}title="{{ group.description }}"{% endif %}>
{% if group.description %}
{{ group.description|to_markdown|safe|truncatechars_html:30 }}
{% else %}
&nbsp;
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>

View file

@ -22,8 +22,8 @@
<div class="select block">
<select name="source" id="source">
<option value="GoodReads" {% if current == 'GoodReads' %}selected{% endif %}>
GoodReads (CSV)
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
Goodreads (CSV)
</option>
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
Storygraph (CSV)

View file

@ -3,6 +3,6 @@
{% block tooltip_content %}
{% trans 'You can download your GoodReads data from the <a href="https://www.goodreads.com/review/import" target="_blank" rel="noopener">Import/Export page</a> of your GoodReads account.' %}
{% trans 'You can download your Goodreads data from the <a href="https://www.goodreads.com/review/import" target="_blank" rel="noopener">Import/Export page</a> of your Goodreads account.' %}
{% endblock %}

View file

@ -12,7 +12,7 @@
{% if valid %}
<div>
<form name="register" method="post" action="/register">
<input type=hidden name="invite_code" value="{{ invite.code }}">
<input type="hidden" name="invite_code" value="{{ invite.code }}">
{% include 'snippets/register_form.html' %}
</form>
</div>

View file

@ -14,19 +14,19 @@
{% if show_confirmed_email %}
<p class="notification is-success">{% trans "Success! Email address confirmed." %}</p>
{% endif %}
<form name="login" method="post" action="/login">
<form name="login-confirm" method="post" action="/login">
{% csrf_token %}
{% if show_confirmed_email %}<input type="hidden" name="first_login" value="true">{% endif %}
<div class="field">
<label class="label" for="id_localname">{% trans "Username:" %}</label>
<label class="label" for="id_localname_confirm">{% trans "Username:" %}</label>
<div class="control">
{{ login_form.localname }}
<input type="text" name="localname" maxlength="255" class="input" required="" id="id_localname_confirm" value="{{ login_form.localname.value|default:'' }}">
</div>
</div>
<div class="field">
<label class="label" for="id_password">{% trans "Password:" %}</label>
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
<div class="control">
{{ login_form.password }}
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password_confirm">
</div>
{% for error in login_form.password.errors %}
<p class="help is-danger">{{ error | escape }}</p>

View file

@ -14,9 +14,9 @@
<form name="password-reset" method="post" action="/password-reset/{{ code }}">
{% csrf_token %}
<div class="field">
<label class="label" for="id_password">{% trans "Password:" %}</label>
<label class="label" for="id_new_password">{% trans "Password:" %}</label>
<div class="control">
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password">
<input type="password" name="password" maxlength="128" class="input" required="" id="id_new_password">
</div>
</div>
<div class="field">

View file

@ -171,7 +171,7 @@
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="{% trans 'username' %}">
</div>
<div class="column">
<label class="is-sr-only" for="id_password">{% trans "Username:" %}</label>
<label class="is-sr-only" for="id_password">{% trans "Password:" %}</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="{% trans 'password' %}">
<p class="help"><a href="{% url 'password-reset' %}">{% trans "Forgot your password?" %}</a></p>
</div>
@ -227,7 +227,7 @@
<div class="columns">
<div class="column is-one-fifth">
<p>
<a href="{% url 'about' %}">{% trans "About this instance" %}</a>
<a href="{% url 'about' %}">{% blocktrans with site_name=site.name %}About {{ site_name }}{% endblocktrans %}</a>
</p>
{% if site.admin_email %}
<p>

View file

@ -6,7 +6,7 @@
{% endblock %}
{% block form %}
<form name="create-list" method="post" action="{% url 'lists' %}">
<form name="create-list" method="post" action="{% url 'lists' %}">
{% include 'lists/form.html' %}
</form>
{% endblock %}

View file

@ -1,7 +1,9 @@
{% load i18n %}
{% spaceless %}
{% if list.curation != 'open' %}
{% if list.curation == 'group' %}
{% blocktrans with username=list.user.display_name userpath=list.user.local_path groupname=list.group.name grouppath=list.group.local_path %}Created by <a href="{{ userpath }}">{{ username }}</a> and managed by <a href="{{ grouppath }}">{{ groupname }}</a>{% endblocktrans %}
{% elif list.curation != 'open' %}
{% blocktrans with username=list.user.display_name path=list.user.local_path %}Created and curated by <a href="{{ path }}">{{ username }}</a>{% endblocktrans %}
{% else %}
{% blocktrans with username=list.user.display_name path=list.user.local_path %}Created by <a href="{{ path }}">{{ username }}</a>{% endblocktrans %}

View file

@ -1,5 +1,6 @@
{% load i18n %}
{% csrf_token %}
{% load utilities %}
<input type="hidden" name="user" value="{{ request.user.id }}">
<div class="columns">
@ -17,20 +18,50 @@
<fieldset class="field">
<legend class="label">{% trans "List curation:" %}</legend>
<label class="field">
<label class="field" data-hides="list_group_selector">
<input type="radio" name="curation" value="closed"{% if not list or list.curation == 'closed' %} checked{% endif %}> {% trans "Closed" %}
<p class="help mb-2">{% trans "Only you can add and remove books to this list" %}</p>
</label>
<label class="field">
<label class="field" data-hides="list_group_selector">
<input type="radio" name="curation" value="curated"{% if list.curation == 'curated' %} checked{% endif %}> {% trans "Curated" %}
<p class="help mb-2">{% trans "Anyone can suggest books, subject to your approval" %}</p>
</label>
<label class="field">
<label class="field" data-hides="list_group_selector">
<input type="radio" name="curation" value="open"{% if list.curation == 'open' %} checked{% endif %}> {% trans "Open" context "curation type" %}
<p class="help mb-2">{% trans "Anyone can add books to this list" %}</p>
</label>
<label class="field hidden-form">
<input type="radio" name="curation" value="group"{% if list.curation == 'group' %} checked{% endif %} > {% trans "Group" %}
<p class="help mb-2">{% trans "Group members can add to and remove from this list" %}</p>
<fieldset class="{% if list.curation != 'group' %}is-hidden{% endif %}" id="list_group_selector">
{% if user.memberships.exists %}
<label class="label" for="id_group" id="group">{% trans "Select Group" %}</label>
<div class="field has-addons">
<div class="select control">
<select name="group" id="id_group">
<option value="" disabled {% if not list.group %} selected{% endif %}>{% trans "Select a group" %}</option>
{% for membership in user.memberships.all %}
<option value="{{ membership.group.id }}" {% if list.group.id == membership.group.id %} selected{% endif %}>{{ membership.group.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% else %}
{% with user|username as username %}
{% url 'user-groups' user|username as url %}
<div>
<p>{% trans "You don't have any Groups yet!" %}</p>
<p>
<a class="help has-text-weight-normal" href="{{ url }}">{% trans "Create a Group" %}</a>
</p>
</div>
{% endwith %}
{% endif %}
</fieldset>
</label>
</fieldset>
</div>
</div>

View file

@ -25,7 +25,9 @@
</div>
<div class="block">
{% include 'lists/edit_form.html' with controls_text="edit_list" %}
{% if request.user == list.user %}
{% include 'lists/edit_form.html' with controls_text="edit_list" user_groups=user_groups %}
{% endif %}
</div>
{% block panel %}{% endblock %}

View file

@ -1,6 +1,7 @@
{% extends 'lists/layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load bookwyrm_group_tags %}
{% load markdown %}
{% block panel %}
@ -16,7 +17,7 @@
<section class="column is-three-quarters">
{% if request.GET.updated %}
<div class="notification is-primary">
{% if list.curation != "open" and request.user != list.user %}
{% if list.curation != "open" and request.user != list.user and not list.group|is_member:request.user %}
{% trans "You successfully suggested a book for this list!" %}
{% else %}
{% trans "You successfully added a book to this list!" %}
@ -66,7 +67,7 @@
<p>{% blocktrans with username=item.user.display_name user_path=item.user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p>
</div>
</div>
{% if list.user == request.user %}
{% if list.user == request.user or list.group|is_member:request.user %}
<div class="card-footer-item">
<form name="set-position" method="post" action="{% url 'list-set-book-position' item.id %}">
{% csrf_token %}
@ -84,7 +85,7 @@
</form>
</div>
{% endif %}
{% if list.user == request.user or list.curation == 'open' and item.user == request.user %}
{% if list.user == request.user or list.curation == 'open' and item.user == request.user or list.group|is_member:request.user %}
<form name="remove-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item">
{% csrf_token %}
<input type="hidden" name="item" value="{{ item.id }}">
@ -125,7 +126,7 @@
</form>
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
<h2 class="title is-5 mt-6">
{% if list.curation == 'open' or request.user == list.user %}
{% if list.curation == 'open' or request.user == list.user or list.group|is_member:request.user %}
{% trans "Add Books" %}
{% else %}
{% trans "Suggest Books" %}
@ -178,7 +179,7 @@
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="list" value="{{ list.id }}">
<button type="submit" class="button is-small is-link">{% if list.curation == 'open' or request.user == list.user %}{% trans "Add" %}{% else %}{% trans "Suggest" %}{% endif %}</button>
<button type="submit" class="button is-small is-link">{% if list.curation == 'open' or request.user == list.user or list.group|is_member:request.user %}{% trans "Add" %}{% else %}{% trans "Suggest" %}{% endif %}</button>
</form>
</div>
</div>

View file

@ -22,10 +22,11 @@
</div>
{% endif %}
</header>
{% if request.user.is_authenticated %}
<div class="block">
{% include 'lists/create_form.html' with controls_text="create_list" %}
</div>
{% endif %}
{% if request.user.is_authenticated %}
<nav class="tabs">

View file

@ -17,4 +17,20 @@
{% include 'notifications/items/add.html' %}
{% elif notification.notification_type == 'REPORT' %}
{% include 'notifications/items/report.html' %}
{% elif notification.notification_type == 'INVITE' %}
{% include 'notifications/items/invite.html' %}
{% elif notification.notification_type == 'ACCEPT' %}
{% include 'notifications/items/accept.html' %}
{% elif notification.notification_type == 'JOIN' %}
{% include 'notifications/items/join.html' %}
{% elif notification.notification_type == 'LEAVE' %}
{% include 'notifications/items/leave.html' %}
{% elif notification.notification_type == 'REMOVE' %}
{% include 'notifications/items/remove.html' %}
{% elif notification.notification_type == 'GROUP_PRIVACY' %}
{% include 'notifications/items/update.html' %}
{% elif notification.notification_type == 'GROUP_NAME' %}
{% include 'notifications/items/update.html' %}
{% elif notification.notification_type == 'GROUP_DESCRIPTION' %}
{% include 'notifications/items/update.html' %}
{% endif %}

View file

@ -0,0 +1,20 @@
{% extends 'notifications/items/item_layout.html' %}
{% load i18n %}
{% load utilities %}
{% block primary_link %}{% spaceless %}
{{ notification.related_group.local_path }}
{% endspaceless %}{% endblock %}
{% block icon %}
<span class="icon icon-local"></span>
{% endblock %}
{% block description %}
{% blocktrans trimmed with group_name=notification.related_group.name group_path=notification.related_group.local_path %}
accepted your invitation to join group "<a href="{{ group_path }}">{{ group_name }}</a>"
{% endblocktrans %}
{% endblock %}

View file

@ -18,25 +18,25 @@
{% if related_status.status_type == 'Review' %}
{% blocktrans trimmed %}
favorited your <a href="{{ related_path }}">review of <em>{{ book_title }}</em></a>
liked your <a href="{{ related_path }}">review of <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% elif related_status.status_type == 'Comment' %}
{% blocktrans trimmed %}
favorited your <a href="{{ related_path }}">comment on<em>{{ book_title }}</em></a>
liked your <a href="{{ related_path }}">comment on <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% elif related_status.status_type == 'Quotation' %}
{% blocktrans trimmed %}
favorited your <a href="{{ related_path }}">quote from <em>{{ book_title }}</em></a>
liked your <a href="{{ related_path }}">quote from <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
favorited your <a href="{{ related_path }}">status</a>
liked your <a href="{{ related_path }}">status</a>
{% endblocktrans %}
{% endif %}

View file

@ -0,0 +1,22 @@
{% extends 'notifications/items/item_layout.html' %}
{% load i18n %}
{% load utilities %}
{% block primary_link %}{% spaceless %}
{{ notification.related_group.local_path }}
{% endspaceless %}{% endblock %}
{% block icon %}
<span class="icon icon-local"></span>
{% endblock %}
{% block description %}
{% blocktrans trimmed with group_name=notification.related_group.name group_path=notification.related_group.local_path %}
invited you to join the group "<a href="{{ group_path }}">{{ group_name }}</a>"
{% endblocktrans %}
<div class="row shrink">
{% include 'snippets/join_invitation_buttons.html' with group=notification.related_group %}
</div>
{% endblock %}

View file

@ -1,4 +1,3 @@
{% load humanize %}
{% load bookwyrm_tags %}
{% related_status notification as related_status %}
<div class="notification is-clickable {% if notification.id in unread %} is-primary{% endif %}" data-href="{% block primary_link %}{% endblock %}">
@ -10,10 +9,8 @@
<div class="block">
<p>
{% if notification.related_user %}
<a href="{{ notification.related_user.local_path }}">
{% include 'snippets/avatar.html' with user=notification.related_user %}
{{ notification.related_user.display_name }}
</a>
<a href="{{ notification.related_user.local_path }}">{% include 'snippets/avatar.html' with user=notification.related_user %}
{{ notification.related_user.display_name }}</a>
{% endif %}
{% block description %}{% endblock %}
</p>

View file

@ -0,0 +1,20 @@
{% extends 'notifications/items/item_layout.html' %}
{% load i18n %}
{% load utilities %}
{% block primary_link %}{% spaceless %}
{{ notification.related_group.local_path }}
{% endspaceless %}{% endblock %}
{% block icon %}
<span class="icon icon-local"></span>
{% endblock %}
{% block description %}
{% blocktrans trimmed with group_name=notification.related_group.name group_path=notification.related_group.local_path %}
has joined your group "<a href="{{ group_path }}">{{ group_name }}</a>"
{% endblocktrans %}
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends 'notifications/items/item_layout.html' %}
{% load i18n %}
{% load utilities %}
{% block primary_link %}{% spaceless %}
{{ notification.related_group.local_path }}
{% endspaceless %}{% endblock %}
{% block icon %}
<span class="icon icon-local"></span>
{% endblock %}
{% block description %}
{% blocktrans trimmed with group_name=notification.related_group.name group_path=notification.related_group.local_path %}
has left your group "<a href="{{ group_path }}">{{ group_name }}</a>"
{% endblocktrans %}
{% endblock %}

View file

@ -0,0 +1,29 @@
{% extends 'notifications/items/item_layout.html' %}
{% load i18n %}
{% load utilities %}
{% block primary_link %}{% spaceless %}
{{ notification.related_group.local_path }}
{% endspaceless %}{% endblock %}
{% block icon %}
<span class="icon icon-local"></span>
{% endblock %}
{% block description %}
{% if notification.related_user %}
{% blocktrans trimmed with group_name=notification.related_group.name group_path=notification.related_group.local_path %}
has been removed from your group "<a href="{{ group_path }}">{{ group_name }}</a>"
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with group_name=notification.related_group.name group_path=notification.related_group.local_path %}
You have been removed from the "<a href="{{ group_path }}">{{ group_name }}</a>" group
{% endblocktrans %}
{% endif %}
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends 'notifications/items/item_layout.html' %}
{% load i18n %}
{% load utilities %}
{% block primary_link %}{% spaceless %}
{{ notification.related_group.local_path }}
{% endspaceless %}{% endblock %}
{% block icon %}
<span class="icon icon-local"></span>
{% endblock %}
{% block description %}
{% if notification.notification_type == 'GROUP_PRIVACY' %}
{% blocktrans trimmed with group_name=notification.related_group.name group_path=notification.related_group.local_path %}
has changed the privacy level for <a href="{{ group_path }}">{{ group_name }}</a>
{% endblocktrans %}
{% elif notification.notification_type == 'GROUP_NAME' %}
{% blocktrans trimmed with group_name=notification.related_group.name group_path=notification.related_group.local_path %}
has changed the name of <a href="{{ group_path }}">{{ group_name }}</a>
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with group_name=notification.related_group.name group_path=notification.related_group.local_path %}
has changed the description of <a href="{{ group_path }}">{{ group_name }}</a>
{% endblocktrans %}
{% endif %}
{% endblock %}

View file

@ -34,7 +34,7 @@
</div>
<div class="field">
<label class="label mb-0" for="id_short_description">{% trans "Short description:" %}</label>
<p class="help">{% trans "Used when the instance is previewed on joinbookwyrm.com. Does not support html or markdown." %}</p>
<p class="help">{% trans "Used when the instance is previewed on joinbookwyrm.com. Does not support HTML or Markdown." %}</p>
{{ site_form.instance_short_description }}
</div>
<div class="field">

View file

@ -19,45 +19,58 @@
</h1>
</header>
<div class="block columns">
<div class="column">
<nav class="block columns is-mobile scroll-x">
<div class="column pr-0">
<div class="tabs">
<ul>
<li class="{% if shelf.identifier == 'all' %}is-active{% endif %}">
<a href="{% url 'user-shelves' user|username %}"{% if shelf.identifier == 'all' %} aria-current="page"{% endif %}>
{% trans "All books" %}
</a>
</li>
{% for shelf_tab in shelves %}
<li class="{% if shelf_tab.identifier == shelf.identifier %}is-active{% endif %}">
<a
href="{{ shelf_tab.local_path }}"
{% if shelf_tab.identifier == shelf.identifier %} aria-current="page"{% endif %}
>
{% if shelf_tab.identifier == 'to-read' %}
{% trans "To Read" %}
{% elif shelf_tab.identifier == 'reading' %}
{% trans "Currently Reading" %}
{% elif shelf_tab.identifier == 'read' %}
{% trans "Read" %}
{% else %}
{{ shelf_tab.name }}
{% endif %}
</a>
</li>
{% endfor %}
<li class="{% if shelf.identifier == 'all' %}is-active{% endif %}">
<a href="{% url 'user-shelves' user|username %}"{% if shelf.identifier == 'all' %} aria-current="page"{% endif %}>
{% trans "All books" %}
</a>
</li>
{% for shelf_tab in shelves %}
<li class="{% if shelf_tab.identifier == shelf.identifier %}is-active{% endif %}">
<a
href="{{ shelf_tab.local_path }}"
{% if shelf_tab.identifier == shelf.identifier %} aria-current="page"{% endif %}
>
{% if shelf_tab.identifier == 'to-read' %}
{% trans "To Read" %}
{% elif shelf_tab.identifier == 'reading' %}
{% trans "Currently Reading" %}
{% elif shelf_tab.identifier == 'read' %}
{% trans "Read" %}
{% else %}
{{ shelf_tab.name }}
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
{% if is_self %}
<div class="column is-narrow pl-0">
<div class="tabs">
<ul>
<li>
<a href="{% url 'import' %}">
<span class="icon icon-list" aria-hidden="true"></span>
<span>{% trans "Import Books" %}</span>
</a>
</li>
</ul>
</div>
</div>
<div class="column is-narrow">
{% trans "Create shelf" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="plus" controls_text="create_shelf_form" focus="create_shelf_form_header" %}
<a class="button" href="{% url 'import' %}">{% trans "Import Books" %}</a>
</div>
{% endif %}
</div>
</nav>
<div class="block">
{% include 'shelf/create_shelf_form.html' with controls_text='create_shelf_form' %}

View file

@ -0,0 +1,34 @@
{% load i18n %}
{% load bookwyrm_group_tags %}
{% if request.user == user or not request.user == group.user or not request.user.is_authenticated %}
{% elif user in request.user.blocks.all %}
{% include 'snippets/block_button.html' with blocks=True %}
{% else %}
<div class="field mb-0">
<div class="control">
<form action="{% url 'invite-group-member' %}" method="POST" class="interaction add_{{ user.id }} {% if group|is_member:user or group|is_invited:user %}is-hidden{%endif %}" data-id="add_{{ user.id }}">
{% csrf_token %}
<input type="hidden" name="group" value="{{ group.id }}">
<input type="hidden" name="user" value="{{ user.username }}">
<button class="button is-small" type="submit">
{% trans "Invite" %}
</button>
</form>
<form action="{% url 'remove-group-member' %}" method="POST" class="interaction add_{{ user.id }} {% if not group|is_member:user and not group|is_invited:user %}is-hidden{% endif %}" data-id="add_{{ user.id }}">
{% csrf_token %}
<input type="hidden" name="group" value="{{ group.id }}">
<input type="hidden" name="user" value="{{ user.username }}">
{% if not group|is_member:user %}
<button class="button is-small is-danger is-light" type="submit">
{% trans "Uninvite" %}
</button>
{% else %}
<button class="button is-small is-danger is-light" type="submit">
{% blocktrans with username=user.localname %}Remove @{{ username }}{% endblocktrans %}
</button>
{% endif %}
</form>
</div>
</div>
{% endif %}

View file

@ -0,0 +1,16 @@
{% load i18n %}
{% load bookwyrm_group_tags %}
{% if group|is_invited:request.user %}
<div class="field is-grouped">
<form action="/accept-group-invitation/" method="POST">
{% csrf_token %}
<input type="hidden" name="group" value="{{ group.id }}">
<button class="button is-link is-small" type="submit">{% trans "Accept" %}</button>
</form>
<form action="/reject-group-invitation/" method="POST">
{% csrf_token %}
<input type="hidden" name="group" value="{{ group.id }}">
<button class="button is-danger is-light is-small" type="submit" class="warning">{% trans "Delete" %}</button>
</form>
</div>
{% endif %}

View file

@ -0,0 +1,21 @@
{% load i18n %}
{% load utilities %}
<div class="select {{ class }}">
{% firstof privacy_uuid 0|uuid as uuid %}
{% if not no_label %}
<label class="is-sr-only" for="privacy_{{ uuid }}">{% trans "Post privacy" %}</label>
{% endif %}
{% firstof current user.default_post_privacy "public" as privacy %}
<select name="privacy" id="privacy_{{ uuid }}">
<option value="public" {% if privacy == 'public' %}selected{% endif %}>
{% trans "Public" %}
</option>
<option value="unlisted" {% if privacy == 'unlisted' %}selected{% endif %}>
{% trans "Unlisted" %}
</option>
<option value="direct" {% if privacy == 'direct' %}selected{% endif %}>
{% trans "Private" %}
</option>
</select>
</div>

View file

@ -0,0 +1,24 @@
{% load i18n %}
{% load bookwyrm_group_tags %}
{% if request.user == user or not request.user == group.user or not request.user.is_authenticated %}
{% else %}
{% if user in request.user.blocks.all %}
{% include 'snippets/block_button.html' with blocks=True %}
<br/>
{% endif %}
<div class="fieldmb-0">
<div class="control">
<form action="{% url 'remove-group-member' %}" method="POST" class="has-text-centered">
{% csrf_token %}
<input type="hidden" name="group" value="{{ group.id }}">
<input type="hidden" name="user" value="{{ user.username }}">
<button id="submit_button" class="button is-small is-danger is-light is-hidden" type="submit" data-id="member_{{ member.id }}">
{% trans "Confirm" %}
</button>
<button id="hide_submit_button" data-controls="submit_button" class="button is-small" type="button" aria-pressed="false">
{% trans "Remove" %}
</button>
</form>
</div>
</div>
{% endif %}

View file

@ -0,0 +1,37 @@
{% extends 'user/layout.html' %}
{% load i18n %}
{% block header %}
<div class="columns is-mobile">
<div class="column">
<h1 class="title">
{% if is_self %}
{% trans "Your Groups" %}
{% else %}
{% blocktrans with username=user.display_name %}Groups: {{ username }}{% endblocktrans %}
{% endif %}
</h1>
</div>
{% if is_self %}
<div class="column is-narrow">
{% trans "Create group" as button_text %}
{% include 'snippets/toggle/open_button.html' with controls_text="create_group" icon_with_text="plus" text=button_text %}
</div>
{% endif %}
</div>
{% endblock %}
{% block panel %}
<section class="block">
<div class="block">
{% include 'groups/create_form.html' with controls_text="create_group" %}
</div>
{% include 'groups/user_groups.html' with memberships=memberships %}
</section>
<div>
{% include 'snippets/pagination.html' with page=user.memberships path=path %}
</div>
{% endblock %}

View file

@ -4,6 +4,7 @@
{% load utilities %}
{% load markdown %}
{% load layout %}
{% load bookwyrm_group_tags %}
{% block title %}{{ user.display_name }}{% endblock %}
@ -69,6 +70,12 @@
<a href="{{ url }}">{% trans "Reading Goal" %}</a>
</li>
{% endif %}
{% if is_self or user|has_groups %}
{% url 'user-groups' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %}>
<a href="{{ url }}">{% trans "Groups" %}</a>
</li>
{% endif %}
{% if is_self or user.list_set.exists %}
{% url 'user-lists' user|username as url %}
<li{% if url in request.path %} class="is-active"{% endif %}>

View file

@ -0,0 +1,27 @@
""" template filters """
from django import template
from bookwyrm import models
register = template.Library()
@register.filter(name="has_groups")
def has_groups(user):
"""whether or not the user has a pending invitation to join this group"""
return models.GroupMember.objects.filter(user=user).exists()
@register.filter(name="is_member")
def is_member(group, user):
"""whether or not the user is a member of this group"""
return models.GroupMember.objects.filter(group=group, user=user).exists()
@register.filter(name="is_invited")
def is_invited(group, user):
"""whether or not the user has a pending invitation to join this group"""
return models.GroupMemberInvitation.objects.filter(group=group, user=user).exists()

View file

@ -0,0 +1,126 @@
""" testing models """
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models, settings
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
class Group(TestCase):
"""some activitypub oddness ahead"""
def setUp(self):
"""Set up for tests"""
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
):
self.owner_user = models.User.objects.create_user(
"mouse", "mouse@mouse.mouse", "mouseword", local=True, localname="mouse"
)
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
):
self.rat = models.User.objects.create_user(
"rat", "rat@rat.rat", "ratword", local=True, localname="rat"
)
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
):
self.badger = models.User.objects.create_user(
"badger",
"badger@badger.badger",
"badgerword",
local=True,
localname="badger",
)
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
):
self.capybara = models.User.objects.create_user(
"capybara",
"capybara@capybara.capybara",
"capybaraword",
local=True,
localname="capybara",
)
self.public_group = models.Group.objects.create(
name="Public Group",
description="Initial description",
user=self.owner_user,
privacy="public",
)
self.private_group = models.Group.objects.create(
name="Private Group",
description="Top secret",
user=self.owner_user,
privacy="direct",
)
self.followers_only_group = models.Group.objects.create(
name="Followers Group",
description="No strangers",
user=self.owner_user,
privacy="followers",
)
models.GroupMember.objects.create(group=self.private_group, user=self.badger)
models.GroupMember.objects.create(
group=self.followers_only_group, user=self.badger
)
models.GroupMember.objects.create(group=self.public_group, user=self.capybara)
def test_group_members_can_see_private_groups(self, _):
"""direct privacy group should not be excluded from group listings for group members viewing"""
rat_groups = models.Group.privacy_filter(self.rat).all()
badger_groups = models.Group.privacy_filter(self.badger).all()
self.assertFalse(self.private_group in rat_groups)
self.assertTrue(self.private_group in badger_groups)
def test_group_members_can_see_followers_only_lists(self, _):
"""follower-only group booklists should not be excluded from group booklist listing for group members who do not follower list owner"""
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
followers_list = models.List.objects.create(
name="Followers List",
curation="group",
privacy="followers",
group=self.public_group,
user=self.owner_user,
)
rat_lists = models.List.privacy_filter(self.rat).all()
badger_lists = models.List.privacy_filter(self.badger).all()
capybara_lists = models.List.privacy_filter(self.capybara).all()
self.assertFalse(followers_list in rat_lists)
self.assertFalse(followers_list in badger_lists)
self.assertTrue(followers_list in capybara_lists)
def test_group_members_can_see_private_lists(self, _):
"""private group booklists should not be excluded from group booklist listing for group members"""
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
private_list = models.List.objects.create(
name="Private List",
privacy="direct",
curation="group",
group=self.public_group,
user=self.owner_user,
)
rat_lists = models.List.privacy_filter(self.rat).all()
badger_lists = models.List.privacy_filter(self.badger).all()
capybara_lists = models.List.privacy_filter(self.capybara).all()
self.assertFalse(private_list in rat_lists)
self.assertFalse(private_list in badger_lists)
self.assertTrue(private_list in capybara_lists)

View file

@ -35,11 +35,18 @@ class SuggestedUsers(TestCase):
rank = suggested_users.get_rank(annotated_user_mock)
self.assertEqual(rank, 3) # 3.9642857142857144)
def test_store_id(self, *_):
"""redis key generation"""
def test_store_id_from_obj(self, *_):
"""redis key generation by user obj"""
self.assertEqual(
suggested_users.store_id(self.local_user),
"{:d}-suggestions".format(self.local_user.id),
f"{self.local_user.id}-suggestions",
)
def test_store_id_from_id(self, *_):
"""redis key generation by user id"""
self.assertEqual(
suggested_users.store_id(self.local_user.id),
f"{self.local_user.id}-suggestions",
)
def test_get_counts_from_rank(self, *_):
@ -69,21 +76,74 @@ class SuggestedUsers(TestCase):
suggestable_user.followers.add(mutual_user)
results = suggested_users.get_objects_for_store(
"{:d}-suggestions".format(self.local_user.id)
f"{self.local_user.id}-suggestions"
)
self.assertEqual(results.count(), 1)
match = results.first()
self.assertEqual(match.id, suggestable_user.id)
self.assertEqual(match.mutuals, 1)
def test_create_user_signal(self, *_):
"""build suggestions for new users"""
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay") as mock:
models.User.objects.create_user(
"nutria", "nutria@nu.tria", "password", local=True, localname="nutria"
)
def test_get_stores_for_object(self, *_):
"""possible follows"""
mutual_user = models.User.objects.create_user(
"rat", "rat@local.rat", "password", local=True, localname="rat"
)
suggestable_user = models.User.objects.create_user(
"nutria",
"nutria@nutria.nutria",
"password",
local=True,
localname="nutria",
discoverable=True,
)
self.assertEqual(mock.call_count, 1)
# you follow rat
mutual_user.followers.add(self.local_user)
# rat follows the suggested user
suggestable_user.followers.add(mutual_user)
results = suggested_users.get_stores_for_object(self.local_user)
self.assertEqual(len(results), 1)
self.assertEqual(results[0], f"{suggestable_user.id}-suggestions")
def test_get_users_for_object(self, *_):
"""given a user, who might want to follow them"""
mutual_user = models.User.objects.create_user(
"rat", "rat@local.rat", "password", local=True, localname="rat"
)
suggestable_user = models.User.objects.create_user(
"nutria",
"nutria@nutria.nutria",
"password",
local=True,
localname="nutria",
discoverable=True,
)
# you follow rat
mutual_user.followers.add(self.local_user)
# rat follows the suggested user
suggestable_user.followers.add(mutual_user)
results = suggested_users.get_users_for_object(self.local_user)
self.assertEqual(len(results), 1)
self.assertEqual(results[0], suggestable_user)
def test_rerank_user_suggestions(self, *_):
"""does it call the populate store function correctly"""
with patch(
"bookwyrm.suggested_users.SuggestedUsers.populate_store"
) as store_mock:
suggested_users.rerank_user_suggestions(self.local_user)
args = store_mock.call_args[0]
self.assertEqual(args[0], f"{self.local_user.id}-suggestions")
def test_get_suggestions(self, *_):
"""load from store"""
with patch("bookwyrm.suggested_users.SuggestedUsers.get_store") as mock:
mock.return_value = [(self.local_user.id, 7.9)]
results = suggested_users.get_suggestions(self.local_user)
self.assertEqual(results[0], self.local_user)
self.assertEqual(results[0].mutuals, 7)
def test_get_annotated_users(self, *_):
"""list of people you might know"""
@ -144,8 +204,8 @@ class SuggestedUsers(TestCase):
)
for i in range(3):
user = models.User.objects.create_user(
"{:d}@local.com".format(i),
"{:d}@nutria.com".format(i),
f"{i}@local.com",
f"{i}@nutria.com",
"password",
local=True,
localname=i,
@ -175,3 +235,12 @@ class SuggestedUsers(TestCase):
)
user_1_annotated = result.get(id=user_1.id)
self.assertEqual(user_1_annotated.mutuals, 3)
def test_create_user_signal(self, *_):
"""build suggestions for new users"""
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay") as mock:
models.User.objects.create_user(
"nutria", "nutria@nu.tria", "password", local=True, localname="nutria"
)
self.assertEqual(mock.call_count, 1)

View file

@ -58,6 +58,17 @@ class EditBookViews(TestCase):
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_edit_book_create_page(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.EditBook.as_view()
request = self.factory.get("")
request.user = self.local_user
request.user.is_superuser = True
result = view(request)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_edit_book(self):
"""lets a user edit a book"""
view = views.EditBook.as_view()

View file

@ -0,0 +1 @@
from . import *

View file

@ -8,6 +8,7 @@ from django.test.client import RequestFactory
from bookwyrm import forms, models
from bookwyrm import views
from bookwyrm.tests.validate_html import validate_html
class InviteViews(TestCase):
@ -40,7 +41,7 @@ class InviteViews(TestCase):
invite.return_value = True
result = view(request, "hi")
self.assertIsInstance(result, TemplateResponse)
result.render()
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_manage_invites(self):
@ -51,7 +52,7 @@ class InviteViews(TestCase):
request.user.is_superuser = True
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_manage_invites_post(self):
@ -67,7 +68,7 @@ class InviteViews(TestCase):
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
validate_html(result.render())
self.assertEqual(result.status_code, 200)
invite = models.SiteInvite.objects.get()
@ -83,7 +84,7 @@ class InviteViews(TestCase):
request = self.factory.post("", form.data)
result = view(request)
result.render()
validate_html(result.render())
req = models.InviteRequest.objects.get()
self.assertEqual(req.email, "new@user.email")
@ -97,7 +98,7 @@ class InviteViews(TestCase):
request = self.factory.post("", form.data)
result = view(request)
result.render()
validate_html(result.render())
# no request created
self.assertFalse(models.InviteRequest.objects.exists())
@ -110,14 +111,14 @@ class InviteViews(TestCase):
request.user.is_superuser = True
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
validate_html(result.render())
self.assertEqual(result.status_code, 200)
# now with data
models.InviteRequest.objects.create(email="fish@example.com")
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_manage_invite_requests_send(self):

View file

@ -7,6 +7,7 @@ from django.test.client import RequestFactory
from bookwyrm import models
from bookwyrm import views
from bookwyrm.tests.validate_html import validate_html
class LandingViews(TestCase):
@ -38,13 +39,13 @@ class LandingViews(TestCase):
with patch("bookwyrm.activitystreams.ActivityStream.get_activity_stream"):
result = view(request)
self.assertEqual(result.status_code, 200)
result.render()
validate_html(result.render())
request.user = self.anonymous_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.status_code, 200)
result.render()
validate_html(result.render())
def test_about_page(self):
"""there are so many views, this just makes sure it LOADS"""
@ -53,7 +54,7 @@ class LandingViews(TestCase):
request.user = self.local_user
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_landing(self):

View file

@ -7,6 +7,7 @@ from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import forms, models, views
from bookwyrm.tests.validate_html import validate_html
# pylint: disable=too-many-public-methods
@ -41,7 +42,7 @@ class LoginViews(TestCase):
result = login(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
validate_html(result.render())
self.assertEqual(result.status_code, 200)
request.user = self.local_user
@ -58,7 +59,7 @@ class LoginViews(TestCase):
request = self.factory.post("", form.data)
request.user = self.anonymous_user
with patch("bookwyrm.views.login.login"):
with patch("bookwyrm.views.landing.login.login"):
result = view(request)
self.assertEqual(result.url, "/")
self.assertEqual(result.status_code, 302)
@ -72,7 +73,7 @@ class LoginViews(TestCase):
request = self.factory.post("", form.data)
request.user = self.anonymous_user
with patch("bookwyrm.views.login.login"):
with patch("bookwyrm.views.landing.login.login"):
result = view(request)
self.assertEqual(result.url, "/")
self.assertEqual(result.status_code, 302)
@ -86,7 +87,7 @@ class LoginViews(TestCase):
request = self.factory.post("", form.data)
request.user = self.anonymous_user
with patch("bookwyrm.views.login.login"):
with patch("bookwyrm.views.landing.login.login"):
result = view(request)
self.assertEqual(result.url, "/")
self.assertEqual(result.status_code, 302)
@ -100,9 +101,9 @@ class LoginViews(TestCase):
request = self.factory.post("", form.data)
request.user = self.anonymous_user
with patch("bookwyrm.views.login.login"):
with patch("bookwyrm.views.landing.login.login"):
result = view(request)
result.render()
validate_html(result.render())
self.assertEqual(result.status_code, 200)
self.assertEqual(
result.context_data["login_form"].non_field_errors,

View file

@ -1,12 +1,16 @@
""" test for app action functionality """
from datetime import timedelta
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import PermissionDenied
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from django.utils import timezone
from bookwyrm import models, views
from bookwyrm.tests.validate_html import validate_html
class PasswordViews(TestCase):
@ -37,7 +41,7 @@ class PasswordViews(TestCase):
result = view(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_password_reset_request_post(self):
@ -47,13 +51,13 @@ class PasswordViews(TestCase):
view = views.PasswordResetRequest.as_view()
resp = view(request)
self.assertEqual(resp.status_code, 200)
resp.render()
validate_html(resp.render())
request = self.factory.post("", {"email": "mouse@mouse.com"})
request.user = self.anonymous_user
with patch("bookwyrm.emailing.send_email.delay"):
resp = view(request)
resp.render()
validate_html(resp.render())
self.assertEqual(models.PasswordReset.objects.get().user, self.local_user)
@ -65,15 +69,43 @@ class PasswordViews(TestCase):
request.user = self.anonymous_user
result = view(request, code.code)
self.assertIsInstance(result, TemplateResponse)
result.render()
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_password_reset_nonexistant_code(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.PasswordReset.as_view()
request = self.factory.get("")
request.user = self.anonymous_user
with self.assertRaises(PermissionDenied):
view(request, "beep")
def test_password_reset_invalid_code(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.PasswordReset.as_view()
code = models.PasswordReset.objects.create(
user=self.local_user, expiry=timezone.now() - timedelta(days=2)
)
request = self.factory.get("")
request.user = self.anonymous_user
with self.assertRaises(PermissionDenied):
view(request, code.code)
def test_password_reset_logged_in(self):
"""redirect logged in users"""
view = views.PasswordReset.as_view()
code = models.PasswordReset.objects.create(user=self.local_user)
request = self.factory.get("")
request.user = self.local_user
result = view(request, code.code)
self.assertEqual(result.status_code, 302)
def test_password_reset_post(self):
"""reset from code"""
view = views.PasswordReset.as_view()
code = models.PasswordReset.objects.create(user=self.local_user)
request = self.factory.post("", {"password": "hi", "confirm-password": "hi"})
with patch("bookwyrm.views.password.login"):
with patch("bookwyrm.views.landing.password.login"):
resp = view(request, code.code)
self.assertEqual(resp.status_code, 302)
self.assertFalse(models.PasswordReset.objects.exists())
@ -84,7 +116,7 @@ class PasswordViews(TestCase):
models.PasswordReset.objects.create(user=self.local_user)
request = self.factory.post("", {"password": "hi", "confirm-password": "hi"})
resp = view(request, "jhgdkfjgdf")
resp.render()
validate_html(resp.render())
self.assertTrue(models.PasswordReset.objects.exists())
def test_password_reset_mismatch(self):
@ -93,5 +125,5 @@ class PasswordViews(TestCase):
code = models.PasswordReset.objects.create(user=self.local_user)
request = self.factory.post("", {"password": "hi", "confirm-password": "hihi"})
resp = view(request, code.code)
resp.render()
validate_html(resp.render())
self.assertTrue(models.PasswordReset.objects.exists())

View file

@ -10,6 +10,7 @@ from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.settings import DOMAIN
from bookwyrm.tests.validate_html import validate_html
# pylint: disable=too-many-public-methods
@ -38,6 +39,13 @@ class RegisterViews(TestCase):
id=1, require_confirm_email=False
)
def test_get_redirect(self, *_):
"""there's no dedicated registration page"""
view = views.Register.as_view()
request = self.factory.get("register/")
response = view(request)
self.assertEqual(response.status_code, 302)
def test_register(self, *_):
"""create a user"""
view = views.Register.as_view()
@ -50,12 +58,12 @@ class RegisterViews(TestCase):
"email": "aa@bb.cccc",
},
)
with patch("bookwyrm.views.register.login"):
with patch("bookwyrm.views.landing.register.login"):
response = view(request)
self.assertEqual(models.User.objects.count(), 2)
self.assertEqual(response.status_code, 302)
nutria = models.User.objects.last()
self.assertEqual(nutria.username, "nutria-user.user_nutria@%s" % DOMAIN)
self.assertEqual(nutria.username, f"nutria-user.user_nutria@{DOMAIN}")
self.assertEqual(nutria.localname, "nutria-user.user_nutria")
self.assertEqual(nutria.local, True)
@ -75,11 +83,11 @@ class RegisterViews(TestCase):
"email": "aa@bb.cccc",
},
)
with patch("bookwyrm.views.register.login"):
with patch("bookwyrm.views.landing.register.login"):
response = view(request)
self.assertEqual(response.status_code, 302)
nutria = models.User.objects.get(localname="nutria")
self.assertEqual(nutria.username, "nutria@%s" % DOMAIN)
self.assertEqual(nutria.username, f"nutria@{DOMAIN}")
self.assertEqual(nutria.local, True)
self.assertFalse(nutria.is_active)
@ -93,12 +101,12 @@ class RegisterViews(TestCase):
"register/",
{"localname": "nutria ", "password": "mouseword", "email": "aa@bb.ccc"},
)
with patch("bookwyrm.views.register.login"):
with patch("bookwyrm.views.landing.register.login"):
response = view(request)
self.assertEqual(models.User.objects.count(), 2)
self.assertEqual(response.status_code, 302)
nutria = models.User.objects.last()
self.assertEqual(nutria.username, "nutria@%s" % DOMAIN)
self.assertEqual(nutria.username, f"nutria@{DOMAIN}")
self.assertEqual(nutria.localname, "nutria")
self.assertEqual(nutria.local, True)
@ -111,7 +119,43 @@ class RegisterViews(TestCase):
)
response = view(request)
self.assertEqual(models.User.objects.count(), 1)
response.render()
validate_html(response.render())
def test_register_error_and_invite(self, *_):
"""redirect to the invite page"""
view = views.Register.as_view()
self.settings.allow_registration = False
self.settings.save()
models.SiteInvite.objects.create(
code="testcode", user=self.local_user, use_limit=1
)
self.assertEqual(models.SiteInvite.objects.get().times_used, 0)
request = self.factory.post(
"register/",
{
"localname": "nutria",
"password": "mouseword",
"email": "",
"invite_code": "testcode",
},
)
with patch("bookwyrm.views.landing.register.login"):
response = view(request)
response = view(request)
validate_html(response.render())
def test_register_username_in_use(self, *_):
"""that username is taken"""
view = views.Register.as_view()
self.assertEqual(models.User.objects.count(), 1)
request = self.factory.post(
"register/",
{"localname": "mouse", "password": "mouseword", "email": "aa@bb.ccc"},
)
response = view(request)
self.assertEqual(models.User.objects.count(), 1)
validate_html(response.render())
def test_register_invalid_username(self, *_):
"""gotta have an email"""
@ -123,7 +167,7 @@ class RegisterViews(TestCase):
)
response = view(request)
self.assertEqual(models.User.objects.count(), 1)
response.render()
validate_html(response.render())
request = self.factory.post(
"register/",
@ -131,7 +175,7 @@ class RegisterViews(TestCase):
)
response = view(request)
self.assertEqual(models.User.objects.count(), 1)
response.render()
validate_html(response.render())
request = self.factory.post(
"register/",
@ -139,7 +183,7 @@ class RegisterViews(TestCase):
)
response = view(request)
self.assertEqual(models.User.objects.count(), 1)
response.render()
validate_html(response.render())
def test_register_closed_instance(self, *_):
"""you can't just register"""
@ -172,7 +216,7 @@ class RegisterViews(TestCase):
"register/",
{"localname": "nutria ", "password": "mouseword", "email": "aa@bleep.com"},
)
with patch("bookwyrm.views.register.login"):
with patch("bookwyrm.views.landing.register.login"):
result = view(request)
self.assertEqual(result.status_code, 302)
self.assertTrue(models.User.objects.filter(email="aa@bleep.com").exists())
@ -196,7 +240,7 @@ class RegisterViews(TestCase):
"invite_code": "testcode",
},
)
with patch("bookwyrm.views.register.login"):
with patch("bookwyrm.views.landing.register.login"):
response = view(request)
self.assertEqual(models.User.objects.count(), 2)
self.assertEqual(response.status_code, 302)
@ -277,7 +321,7 @@ class RegisterViews(TestCase):
result = view(request, "abcde")
self.assertIsInstance(result, TemplateResponse)
result.render()
validate_html(result.render())
self.assertEqual(result.status_code, 200)
self.assertFalse(self.local_user.is_active)
self.assertEqual(self.local_user.deactivation_reason, "pending")
@ -293,10 +337,32 @@ class RegisterViews(TestCase):
result = login(request)
self.assertIsInstance(result, TemplateResponse)
result.render()
validate_html(result.render())
self.assertEqual(result.status_code, 200)
request.user = self.local_user
result = login(request)
self.assertEqual(result.url, "/")
self.assertEqual(result.status_code, 302)
def test_confirm_email_post(self, *_):
"""send the email"""
self.settings.require_confirm_email = True
self.settings.save()
view = views.ConfirmEmail.as_view()
models.SiteInvite.objects.create(
code="testcode", user=self.local_user, use_limit=1
)
request = self.factory.post("", {"code": "testcode"})
request.user = self.anonymous_user
result = view(request)
validate_html(result.render())
def test_resend_link(self, *_):
"""try again"""
request = self.factory.post("", {"email": "mouse@mouse.com"})
request.user = self.anonymous_user
with patch("bookwyrm.emailing.send_email.delay") as mock:
views.resend_link(request)
self.assertEqual(mock.call_count, 1)

View file

@ -0,0 +1 @@
from . import *

View file

@ -0,0 +1,165 @@
""" test for app action functionality """
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.tests.validate_html import validate_html
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
@patch("bookwyrm.activitystreams.remove_book_statuses_task.delay")
class ShelfViews(TestCase):
"""tag views"""
def setUp(self):
"""we need basic test data and mocks"""
self.factory = RequestFactory()
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
):
self.local_user = models.User.objects.create_user(
"mouse@local.com",
"mouse@mouse.com",
"mouseword",
local=True,
localname="mouse",
remote_id="https://example.com/users/mouse",
)
self.work = models.Work.objects.create(title="Test Work")
self.book = models.Edition.objects.create(
title="Example Edition",
remote_id="https://example.com/book/1",
parent_work=self.work,
)
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
self.shelf = models.Shelf.objects.create(
name="Test Shelf", identifier="test-shelf", user=self.local_user
)
models.SiteSettings.objects.create()
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
def test_shelf_page_all_books(self, *_):
"""there are so many views, this just makes sure it LOADS"""
view = views.Shelf.as_view()
request = self.factory.get("")
request.user = self.local_user
with patch("bookwyrm.views.shelf.shelf.is_api_request") as is_api:
is_api.return_value = False
result = view(request, self.local_user.username)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_shelf_page_all_books_anonymous(self, *_):
"""there are so many views, this just makes sure it LOADS"""
view = views.Shelf.as_view()
request = self.factory.get("")
request.user = self.anonymous_user
with patch("bookwyrm.views.shelf.shelf.is_api_request") as is_api:
is_api.return_value = False
result = view(request, self.local_user.username)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_shelf_page_sorted(self, *_):
"""there are so many views, this just makes sure it LOADS"""
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.first()
request = self.factory.get("", {"sort": "author"})
request.user = self.local_user
with patch("bookwyrm.views.shelf.shelf.is_api_request") as is_api:
is_api.return_value = False
result = view(request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_shelf_page(self, *_):
"""there are so many views, this just makes sure it LOADS"""
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.first()
request = self.factory.get("")
request.user = self.local_user
with patch("bookwyrm.views.shelf.shelf.is_api_request") as is_api:
is_api.return_value = False
result = view(request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
with patch("bookwyrm.views.shelf.shelf.is_api_request") as is_api:
is_api.return_value = True
result = view(request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
request = self.factory.get("/?page=1")
request.user = self.local_user
with patch("bookwyrm.views.shelf.shelf.is_api_request") as is_api:
is_api.return_value = True
result = view(request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_edit_shelf_privacy(self, *_):
"""set name or privacy on shelf"""
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.get(identifier="to-read")
self.assertEqual(shelf.privacy, "public")
request = self.factory.post(
"",
{
"privacy": "unlisted",
"user": self.local_user.id,
"name": "To Read",
},
)
request.user = self.local_user
view(request, self.local_user.username, shelf.identifier)
shelf.refresh_from_db()
self.assertEqual(shelf.privacy, "unlisted")
def test_edit_shelf_name(self, *_):
"""change the name of an editable shelf"""
view = views.Shelf.as_view()
shelf = models.Shelf.objects.create(name="Test Shelf", user=self.local_user)
self.assertEqual(shelf.privacy, "public")
request = self.factory.post(
"", {"privacy": "public", "user": self.local_user.id, "name": "cool name"}
)
request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
view(request, request.user.username, shelf.identifier)
shelf.refresh_from_db()
self.assertEqual(shelf.name, "cool name")
self.assertEqual(shelf.identifier, f"testshelf-{shelf.id}")
def test_edit_shelf_name_not_editable(self, *_):
"""can't change the name of an non-editable shelf"""
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.get(identifier="to-read")
self.assertEqual(shelf.privacy, "public")
request = self.factory.post(
"", {"privacy": "public", "user": self.local_user.id, "name": "cool name"}
)
request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
view(request, request.user.username, shelf.identifier)
self.assertEqual(shelf.name, "To Read")

View file

@ -3,13 +3,10 @@ import json
from unittest.mock import patch
from django.core.exceptions import PermissionDenied
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import forms, models, views
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.tests.validate_html import validate_html
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
@ -17,7 +14,7 @@ from bookwyrm.tests.validate_html import validate_html
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
@patch("bookwyrm.activitystreams.remove_book_statuses_task.delay")
class ShelfViews(TestCase):
class ShelfActionViews(TestCase):
"""tag views"""
def setUp(self):
@ -46,85 +43,6 @@ class ShelfViews(TestCase):
)
models.SiteSettings.objects.create()
def test_shelf_page(self, *_):
"""there are so many views, this just makes sure it LOADS"""
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.first()
request = self.factory.get("")
request.user = self.local_user
with patch("bookwyrm.views.shelf.is_api_request") as is_api:
is_api.return_value = False
result = view(request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
with patch("bookwyrm.views.shelf.is_api_request") as is_api:
is_api.return_value = True
result = view(request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
request = self.factory.get("/?page=1")
request.user = self.local_user
with patch("bookwyrm.views.shelf.is_api_request") as is_api:
is_api.return_value = True
result = view(request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_edit_shelf_privacy(self, *_):
"""set name or privacy on shelf"""
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.get(identifier="to-read")
self.assertEqual(shelf.privacy, "public")
request = self.factory.post(
"",
{
"privacy": "unlisted",
"user": self.local_user.id,
"name": "To Read",
},
)
request.user = self.local_user
view(request, self.local_user.username, shelf.identifier)
shelf.refresh_from_db()
self.assertEqual(shelf.privacy, "unlisted")
def test_edit_shelf_name(self, *_):
"""change the name of an editable shelf"""
view = views.Shelf.as_view()
shelf = models.Shelf.objects.create(name="Test Shelf", user=self.local_user)
self.assertEqual(shelf.privacy, "public")
request = self.factory.post(
"", {"privacy": "public", "user": self.local_user.id, "name": "cool name"}
)
request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
view(request, request.user.username, shelf.identifier)
shelf.refresh_from_db()
self.assertEqual(shelf.name, "cool name")
self.assertEqual(shelf.identifier, f"testshelf-{shelf.id}")
def test_edit_shelf_name_not_editable(self, *_):
"""can't change the name of an non-editable shelf"""
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.get(identifier="to-read")
self.assertEqual(shelf.privacy, "public")
request = self.factory.post(
"", {"privacy": "public", "user": self.local_user.id, "name": "cool name"}
)
request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
view(request, request.user.username, shelf.identifier)
self.assertEqual(shelf.name, "To Read")
def test_shelve(self, *_):
"""shelve a book"""
request = self.factory.post(
@ -182,6 +100,30 @@ class ShelfViews(TestCase):
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_shelve_read_with_change_shelf(self, *_):
"""special behavior for the read shelf"""
previous_shelf = models.Shelf.objects.get(identifier="reading")
models.ShelfBook.objects.create(
shelf=previous_shelf, user=self.local_user, book=self.book
)
shelf = models.Shelf.objects.get(identifier="read")
request = self.factory.post(
"",
{
"book": self.book.id,
"shelf": shelf.identifier,
"change-shelf-from": previous_shelf.identifier,
},
)
request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
self.assertEqual(list(previous_shelf.books.all()), [])
def test_unshelve(self, *_):
"""remove a book from a shelf"""
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):

View file

@ -1,6 +1,7 @@
""" test for app action functionality """
from unittest.mock import patch
from django.contrib.auth.models import Group, Permission
from django.contrib.auth.models import AnonymousUser, Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.template.response import TemplateResponse
@ -9,6 +10,7 @@ from django.test.client import RequestFactory
from bookwyrm import forms, models, views
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.tests.validate_html import validate_html
class AuthorViews(TestCase):
@ -43,6 +45,8 @@ class AuthorViews(TestCase):
parent_work=self.work,
)
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
models.SiteSettings.objects.create()
def test_author_page(self):
@ -50,15 +54,33 @@ class AuthorViews(TestCase):
view = views.Author.as_view()
author = models.Author.objects.create(name="Jessica")
request = self.factory.get("")
request.user = self.local_user
with patch("bookwyrm.views.author.is_api_request") as is_api:
is_api.return_value = False
result = view(request, author.id)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_author_page_logged_out(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.Author.as_view()
author = models.Author.objects.create(name="Jessica")
request = self.factory.get("")
request.user = self.anonymous_user
with patch("bookwyrm.views.author.is_api_request") as is_api:
is_api.return_value = False
result = view(request, author.id)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_author_page_api_response(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.Author.as_view()
author = models.Author.objects.create(name="Jessica")
request = self.factory.get("")
request.user = self.local_user
with patch("bookwyrm.views.author.is_api_request") as is_api:
is_api.return_value = True
result = view(request, author.id)
@ -75,8 +97,7 @@ class AuthorViews(TestCase):
result = view(request, author.id)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_edit_author(self):
@ -125,5 +146,5 @@ class AuthorViews(TestCase):
resp = view(request, author.id)
author.refresh_from_db()
self.assertEqual(author.name, "Test Author")
resp.render()
validate_html(resp.render())
self.assertEqual(resp.status_code, 200)

View file

@ -0,0 +1,119 @@
""" test for app action functionality """
from unittest.mock import patch
from django.contrib.auth import decorators
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views, forms
from bookwyrm.tests.validate_html import validate_html
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
class GroupViews(TestCase):
"""view group and edit details"""
def setUp(self):
"""we need basic test data and mocks"""
self.factory = RequestFactory()
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
):
self.local_user = models.User.objects.create_user(
"mouse@local.com",
"mouse@mouse.mouse",
"password",
local=True,
localname="mouse",
)
self.testgroup = models.Group.objects.create(
name="Test Group",
description="Initial description",
user=self.local_user,
privacy="public",
)
self.membership = models.GroupMember.objects.create(
group=self.testgroup, user=self.local_user
)
models.SiteSettings.objects.create()
def test_group_get(self, _):
"""there are so many views, this just makes sure it LOADS"""
view = views.Group.as_view()
request = self.factory.get("")
request.user = self.local_user
result = view(request, group_id=self.testgroup.id)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_usergroups_get(self, _):
"""there are so many views, this just makes sure it LOADS"""
view = views.UserGroups.as_view()
request = self.factory.get("")
request.user = self.local_user
result = view(request, username="mouse@local.com")
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
@patch("bookwyrm.suggested_users.SuggestedUsers.get_suggestions")
def test_findusers_get(self, *_):
"""there are so many views, this just makes sure it LOADS"""
view = views.FindUsers.as_view()
request = self.factory.get("")
request.user = self.local_user
result = view(request, group_id=self.testgroup.id)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_group_create(self, _):
"""create group view"""
view = views.UserGroups.as_view()
request = self.factory.post(
"",
{
"name": "A group",
"description": "wowzers",
"privacy": "unlisted",
"user": self.local_user.id,
},
)
request.user = self.local_user
result = view(request, "username")
self.assertEqual(result.status_code, 302)
new_group = models.Group.objects.filter(name="A group").get()
self.assertEqual(new_group.description, "wowzers")
self.assertEqual(new_group.privacy, "unlisted")
self.assertTrue(
models.GroupMember.objects.filter(
group=new_group, user=self.local_user
).exists()
)
def test_group_edit(self, _):
"""test editing a "group" database entry"""
view = views.Group.as_view()
request = self.factory.post(
"",
{
"name": "Updated Group name",
"description": "wow",
"privacy": "direct",
"user": self.local_user.id,
},
)
request.user = self.local_user
result = view(request, group_id=self.testgroup.id)
self.assertEqual(result.status_code, 302)
self.testgroup.refresh_from_db()
self.assertEqual(self.testgroup.name, "Updated Group name")
self.assertEqual(self.testgroup.description, "wow")
self.assertEqual(self.testgroup.privacy, "direct")

View file

@ -253,6 +253,33 @@ urlpatterns = [
name="user-following",
),
re_path(r"^hide-suggestions/?$", views.hide_suggestions, name="hide-suggestions"),
# groups
re_path(rf"{USER_PATH}/groups/?$", views.UserGroups.as_view(), name="user-groups"),
re_path(
r"^group/(?P<group_id>\d+)(.json)?/?$", views.Group.as_view(), name="group"
),
re_path(
r"^group/delete/(?P<group_id>\d+)/?$", views.delete_group, name="delete-group"
),
re_path(
r"^group/(?P<group_id>\d+)/add-users/?$",
views.FindUsers.as_view(),
name="group-find-users",
),
re_path(r"^add-group-member/?$", views.invite_member, name="invite-group-member"),
re_path(
r"^remove-group-member/?$", views.remove_member, name="remove-group-member"
),
re_path(
r"^accept-group-invitation/?$",
views.accept_membership,
name="accept-group-invitation",
),
re_path(
r"^reject-group-invitation/?$",
views.reject_membership,
name="reject-group-invitation",
),
# lists
re_path(rf"{USER_PATH}/lists/?$", views.UserLists.as_view(), name="user-lists"),
re_path(r"^list/?$", views.Lists.as_view(), name="lists"),

View file

@ -32,6 +32,17 @@ from .books.books import Book, upload_cover, add_description, resolve_book
from .books.edit_book import EditBook, ConfirmEditBook
from .books.editions import Editions, switch_edition
# landing
from .landing.landing import About, Home, Landing
from .landing.login import Login, Logout
from .landing.register import Register, ConfirmEmail, ConfirmEmailCode, resend_link
from .landing.password import PasswordResetRequest, PasswordReset
# shelves
from .shelf.shelf import Shelf
from .shelf.shelf_actions import create_shelf, delete_shelf
from .shelf.shelf_actions import shelve, unshelve
# misc views
from .author import Author, EditAuthor
from .directory import Directory
@ -41,25 +52,28 @@ from .follow import follow, unfollow
from .follow import accept_follow_request, delete_follow_request
from .get_started import GetStartedBooks, GetStartedProfile, GetStartedUsers
from .goal import Goal, hide_goal
from .group import (
Group,
UserGroups,
FindUsers,
delete_group,
invite_member,
remove_member,
accept_membership,
reject_membership,
)
from .import_data import Import, ImportStatus
from .inbox import Inbox
from .interaction import Favorite, Unfavorite, Boost, Unboost
from .isbn import Isbn
from .landing import About, Home, Landing
from .list import Lists, SavedLists, List, Curate, UserLists
from .list import save_list, unsave_list, delete_list
from .login import Login, Logout
from .notifications import Notifications
from .outbox import Outbox
from .reading import create_readthrough, delete_readthrough, delete_progressupdate
from .reading import ReadingStatus
from .register import Register, ConfirmEmail, ConfirmEmailCode, resend_link
from .rss_feed import RssFeed
from .password import PasswordResetRequest, PasswordReset
from .search import Search
from .shelf import Shelf
from .shelf import create_shelf, delete_shelf
from .shelf import shelve, unshelve
from .status import CreateStatus, EditStatus, DeleteStatus, update_progress
from .status import edit_readthrough
from .updates import get_notification_count, get_unread_status_count

View file

@ -81,7 +81,7 @@ class Invite(View):
"invite": invite,
"valid": invite.valid() if invite else True,
}
return TemplateResponse(request, "invite.html", data)
return TemplateResponse(request, "landing/invite.html", data)
# post handling is in views.register.Register

View file

@ -1,6 +1,7 @@
""" the good people stuff! the authors! """
from django.contrib.auth.decorators import login_required, permission_required
from django.db.models import Q
from django.core.paginator import Paginator
from django.db.models import OuterRef, Subquery, F, Q
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
@ -8,7 +9,8 @@ from django.views import View
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from .helpers import is_api_request
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.views.helpers import is_api_request
# pylint: disable= no-self-use
@ -22,12 +24,27 @@ class Author(View):
if is_api_request(request):
return ActivitypubResponse(author.to_activity())
books = models.Work.objects.filter(
Q(authors=author) | Q(editions__authors=author)
).distinct()
default_editions = models.Edition.objects.filter(
parent_work=OuterRef("parent_work")
).order_by("-edition_rank")
books = (
models.Edition.viewer_aware_objects(request.user)
.filter(Q(authors=author) | Q(parent_work__authors=author))
.annotate(default_id=Subquery(default_editions.values("id")[:1]))
.filter(default_id=F("id"))
.order_by("-first_published_date", "-published_date", "-created_date")
.prefetch_related("authors")
)
paginated = Paginator(books, PAGE_LENGTH)
page = paginated.get_page(request.GET.get("page"))
data = {
"author": author,
"books": [b.default_edition for b in books],
"books": page,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
}
return TemplateResponse(request, "author/author.html", data)

311
bookwyrm/views/group.py Normal file
View file

@ -0,0 +1,311 @@
"""group views"""
from django.apps import apps
from django.contrib.auth.decorators import login_required
from django.db import IntegrityError
from django.core.paginator import Paginator
from django.http import HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models.functions import Greatest
from bookwyrm import forms, models
from bookwyrm.suggested_users import suggested_users
from .helpers import get_user_from_username
# pylint: disable=no-self-use
class Group(View):
"""group page"""
def get(self, request, group_id):
"""display a group"""
group = get_object_or_404(models.Group, id=group_id)
group.raise_visible_to_user(request.user)
lists = (
models.List.privacy_filter(request.user)
.filter(group=group)
.order_by("-updated_date")
)
data = {
"group": group,
"lists": lists,
"group_form": forms.GroupForm(instance=group),
"path": "/group",
}
return TemplateResponse(request, "groups/group.html", data)
@method_decorator(login_required, name="dispatch")
def post(self, request, group_id):
"""edit a group"""
user_group = get_object_or_404(models.Group, id=group_id)
form = forms.GroupForm(request.POST, instance=user_group)
if not form.is_valid():
return redirect("group", user_group.id)
user_group = form.save()
# let the other members know something about the group changed
memberships = models.GroupMember.objects.filter(group=user_group)
model = apps.get_model("bookwyrm.Notification", require_ready=True)
for field in form.changed_data:
notification_type = (
"GROUP_PRIVACY"
if field == "privacy"
else "GROUP_NAME"
if field == "name"
else "GROUP_DESCRIPTION"
if field == "description"
else None
)
if notification_type:
for membership in memberships:
member = membership.user
if member != request.user:
model.objects.create(
user=member,
related_user=request.user,
related_group=user_group,
notification_type=notification_type,
)
return redirect("group", user_group.id)
@method_decorator(login_required, name="dispatch")
class UserGroups(View):
"""a user's groups page"""
def get(self, request, username):
"""display a group"""
user = get_user_from_username(request.user, username)
groups = (
models.Group.privacy_filter(request.user)
.filter(memberships__user=user)
.order_by("-updated_date")
)
paginated = Paginator(groups, 12)
data = {
"groups": paginated.get_page(request.GET.get("page")),
"is_self": request.user.id == user.id,
"user": user,
"group_form": forms.GroupForm(),
"path": user.local_path + "/group",
}
return TemplateResponse(request, "user/groups.html", data)
@method_decorator(login_required, name="dispatch")
# pylint: disable=unused-argument
def post(self, request, username):
"""create a user group"""
form = forms.GroupForm(request.POST)
if not form.is_valid():
return redirect(request.user.local_path + "/groups")
group = form.save()
# add the creator as a group member
models.GroupMember.objects.create(group=group, user=request.user)
return redirect("group", group.id)
@method_decorator(login_required, name="dispatch")
class FindUsers(View):
"""find friends to add to your group"""
# this is mostly borrowed from the Get Started friend finder
def get(self, request, group_id):
"""basic profile info"""
user_query = request.GET.get("user_query")
group = get_object_or_404(models.Group, id=group_id)
if not group:
return HttpResponseBadRequest()
if not group.user == request.user:
return HttpResponseBadRequest()
user_results = (
models.User.viewer_aware_objects(request.user)
.exclude(
memberships__in=group.memberships.all()
) # don't suggest users who are already members
.annotate(
similarity=Greatest(
TrigramSimilarity("username", user_query),
TrigramSimilarity("localname", user_query),
)
)
.filter(similarity__gt=0.5, local=True)
.order_by("-similarity")[:5]
)
data = {"no_results": not user_results}
if user_results.count() < 5:
user_results = list(user_results) + suggested_users.get_suggestions(
request.user, local=True
)
data = {
"suggested_users": user_results,
"group": group,
"group_form": forms.GroupForm(instance=group),
"user_query": user_query,
"requestor_is_manager": request.user == group.user,
}
return TemplateResponse(request, "groups/find_users.html", data)
@require_POST
@login_required
def delete_group(request, group_id):
"""delete a group"""
group = get_object_or_404(models.Group, id=group_id)
# only the owner can delete a group
group.raise_not_deletable(request.user)
# deal with any group lists
models.List.objects.filter(group=group).update(curation="closed", group=None)
group.delete()
return redirect(request.user.local_path + "/groups")
@require_POST
@login_required
def invite_member(request):
"""invite a member to the group"""
group = get_object_or_404(models.Group, id=request.POST.get("group"))
if not group:
return HttpResponseBadRequest()
user = get_user_from_username(request.user, request.POST["user"])
if not user:
return HttpResponseBadRequest()
if not group.user == request.user:
return HttpResponseBadRequest()
try:
models.GroupMemberInvitation.objects.create(user=user, group=group)
except IntegrityError:
pass
return redirect(user.local_path)
@require_POST
@login_required
def remove_member(request):
"""remove a member from the group"""
group = get_object_or_404(models.Group, id=request.POST.get("group"))
if not group:
return HttpResponseBadRequest()
user = get_user_from_username(request.user, request.POST["user"])
if not user:
return HttpResponseBadRequest()
# you can't be removed from your own group
if request.POST["user"] == group.user:
return HttpResponseBadRequest()
is_member = models.GroupMember.objects.filter(group=group, user=user).exists()
is_invited = models.GroupMemberInvitation.objects.filter(
group=group, user=user
).exists()
if is_invited:
try:
invitation = models.GroupMemberInvitation.objects.get(
user=user, group=group
)
invitation.reject()
except IntegrityError:
pass
if is_member:
try:
models.List.remove_from_group(group.user, user)
models.GroupMember.remove(group.user, user)
except IntegrityError:
pass
memberships = models.GroupMember.objects.filter(group=group)
model = apps.get_model("bookwyrm.Notification", require_ready=True)
notification_type = "LEAVE" if user == request.user else "REMOVE"
# let the other members know about it
for membership in memberships:
member = membership.user
if member != request.user:
model.objects.create(
user=member,
related_user=user,
related_group=group,
notification_type=notification_type,
)
# let the user (now ex-member) know as well, if they were removed
if notification_type == "REMOVE":
model.objects.create(
user=user,
related_group=group,
notification_type=notification_type,
)
return redirect(group.local_path)
@require_POST
@login_required
def accept_membership(request):
"""accept an invitation to join a group"""
group = models.Group.objects.get(id=request.POST["group"])
if not group:
return HttpResponseBadRequest()
invite = models.GroupMemberInvitation.objects.get(group=group, user=request.user)
if not invite:
return HttpResponseBadRequest()
try:
invite.accept()
except IntegrityError:
pass
return redirect(group.local_path)
@require_POST
@login_required
def reject_membership(request):
"""reject an invitation to join a group"""
group = models.Group.objects.get(id=request.POST["group"])
if not group:
return HttpResponseBadRequest()
invite = models.GroupMemberInvitation.objects.get(group=group, user=request.user)
if not invite:
return HttpResponseBadRequest()
try:
invite.reject()
except IntegrityError:
pass
return redirect(request.user.local_path)

View file

@ -51,7 +51,7 @@ class Import(View):
elif source == "Storygraph":
importer = StorygraphImporter()
else:
# Default : GoodReads
# Default : Goodreads
importer = GoodreadsImporter()
try:

View file

View file

@ -3,8 +3,8 @@ from django.template.response import TemplateResponse
from django.views import View
from bookwyrm import forms
from .feed import Feed
from . import helpers
from bookwyrm.views import helpers
from bookwyrm.views.feed import Feed
# pylint: disable= no-self-use

View file

@ -29,7 +29,7 @@ class Login(View):
"login_form": forms.LoginForm(),
"register_form": forms.RegisterForm(),
}
return TemplateResponse(request, "login.html", data)
return TemplateResponse(request, "landing/login.html", data)
@sensitive_variables("password")
@method_decorator(sensitive_post_parameters("password"))
@ -69,7 +69,7 @@ class Login(View):
login_form.non_field_errors = _("Username or password are incorrect")
register_form = forms.RegisterForm()
data = {"login_form": login_form, "register_form": register_form}
return TemplateResponse(request, "login.html", data)
return TemplateResponse(request, "landing/login.html", data)
@method_decorator(login_required, name="dispatch")

View file

@ -18,7 +18,7 @@ class PasswordResetRequest(View):
"""password reset page"""
return TemplateResponse(
request,
"password_reset_request.html",
"landing/password_reset_request.html",
)
def post(self, request):
@ -30,7 +30,9 @@ class PasswordResetRequest(View):
)
except models.User.DoesNotExist:
data = {"error": _("No user with that email address was found.")}
return TemplateResponse(request, "password_reset_request.html", data)
return TemplateResponse(
request, "landing/password_reset_request.html", data
)
# remove any existing password reset cods for this user
models.PasswordReset.objects.filter(user=user).all().delete()
@ -39,7 +41,7 @@ class PasswordResetRequest(View):
code = models.PasswordReset.objects.create(user=user)
password_reset_email(code)
data = {"message": _(f"A password reset link was sent to {email}")}
return TemplateResponse(request, "password_reset_request.html", data)
return TemplateResponse(request, "landing/password_reset_request.html", data)
class PasswordReset(View):
@ -56,7 +58,7 @@ class PasswordReset(View):
except models.PasswordReset.DoesNotExist:
raise PermissionDenied()
return TemplateResponse(request, "password_reset.html", {"code": code})
return TemplateResponse(request, "landing/password_reset.html", {"code": code})
def post(self, request, code):
"""allow a user to change their password through an emailed token"""
@ -64,7 +66,7 @@ class PasswordReset(View):
reset_code = models.PasswordReset.objects.get(code=code)
except models.PasswordReset.DoesNotExist:
data = {"errors": ["Invalid password reset link"]}
return TemplateResponse(request, "password_reset.html", data)
return TemplateResponse(request, "landing/password_reset.html", data)
user = reset_code.user
@ -73,7 +75,7 @@ class PasswordReset(View):
if new_password != confirm_password:
data = {"errors": ["Passwords do not match"]}
return TemplateResponse(request, "password_reset.html", data)
return TemplateResponse(request, "landing/password_reset.html", data)
user.set_password(new_password)
user.save(broadcast=False, update_fields=["password"])

View file

@ -65,8 +65,8 @@ class Register(View):
"valid": invite.valid() if invite else True,
}
if invite:
return TemplateResponse(request, "invite.html", data)
return TemplateResponse(request, "login.html", data)
return TemplateResponse(request, "landing/invite.html", data)
return TemplateResponse(request, "landing/login.html", data)
username = f"{localname}@{DOMAIN}"
user = models.User.objects.create_user(

View file

@ -40,7 +40,6 @@ class Lists(View):
.order_by("-updated_date")
.distinct()
)
paginated = Paginator(lists, 12)
data = {
"lists": paginated.get_page(request.GET.get("page")),
@ -57,6 +56,10 @@ class Lists(View):
if not form.is_valid():
return redirect("lists")
book_list = form.save()
# list should not have a group if it is not group curated
if not book_list.curation == "group":
book_list.group = None
book_list.save(broadcast=False)
return redirect(book_list.local_path)
@ -181,7 +184,6 @@ class List(View):
return TemplateResponse(request, "lists/list.html", data)
@method_decorator(login_required, name="dispatch")
# pylint: disable=unused-argument
def post(self, request, list_id):
"""edit a list"""
book_list = get_object_or_404(models.List, id=list_id)
@ -191,6 +193,10 @@ class List(View):
if not form.is_valid():
return redirect("list", book_list.id)
book_list = form.save()
if not book_list.curation == "group":
book_list.group = None
book_list.save(broadcast=False)
return redirect(book_list.local_path)
@ -275,12 +281,22 @@ def delete_list(request, list_id):
def add_book(request):
"""put a book on a list"""
book_list = get_object_or_404(models.List, id=request.POST.get("list"))
is_group_member = False
if book_list.curation == "group":
is_group_member = models.GroupMember.objects.filter(
group=book_list.group, user=request.user
).exists()
book_list.raise_visible_to_user(request.user)
book = get_object_or_404(models.Edition, id=request.POST.get("book"))
# do you have permission to add to the list?
try:
if request.user == book_list.user or book_list.curation == "open":
if (
request.user == book_list.user
or is_group_member
or book_list.curation == "open"
):
# add the book at the latest order of approved books, before pending books
order_max = (
book_list.listitem_set.filter(approved=True).aggregate(Max("order"))[
@ -323,14 +339,17 @@ def add_book(request):
@login_required
def remove_book(request, list_id):
"""remove a book from a list"""
book_list = get_object_or_404(models.List, id=list_id)
item = get_object_or_404(models.ListItem, id=request.POST.get("item"))
item.raise_not_deletable(request.user)
with transaction.atomic():
deleted_order = item.order
item.delete()
normalize_book_list_ordering(book_list.id, start=deleted_order)
return redirect("list", list_id)

View file

@ -23,6 +23,10 @@ class Block(View):
models.UserBlocks.objects.create(
user_subject=request.user, user_object=to_block
)
# remove the blocked users's lists from the groups
models.List.remove_from_group(request.user, to_block)
# remove the blocked user from all blocker's owned groups
models.GroupMember.remove(request.user, to_block)
return redirect("prefs-block")

View file

View file

@ -1,7 +1,6 @@
""" shelf views """
from collections import namedtuple
from django.db import IntegrityError, transaction
from django.db.models import OuterRef, Subquery, F
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
@ -11,12 +10,11 @@ from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.decorators.http import require_POST
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH
from .helpers import is_api_request, get_user_from_username
from bookwyrm.views.helpers import is_api_request, get_user_from_username
# pylint: disable=no-self-use
@ -128,102 +126,6 @@ class Shelf(View):
return redirect(shelf.local_path)
@login_required
@require_POST
def create_shelf(request):
"""user generated shelves"""
form = forms.ShelfForm(request.POST)
if not form.is_valid():
return redirect(request.headers.get("Referer", "/"))
shelf = form.save()
return redirect(shelf.local_path)
@login_required
@require_POST
def delete_shelf(request, shelf_id):
"""user generated shelves"""
shelf = get_object_or_404(models.Shelf, id=shelf_id)
shelf.raise_not_deletable(request.user)
shelf.delete()
return redirect("user-shelves", request.user.localname)
@login_required
@require_POST
@transaction.atomic
def shelve(request):
"""put a book on a user's shelf"""
book = get_object_or_404(models.Edition, id=request.POST.get("book"))
desired_shelf = get_object_or_404(
request.user.shelf_set, identifier=request.POST.get("shelf")
)
# first we need to remove from the specified shelf
change_from_current_identifier = request.POST.get("change-shelf-from")
if change_from_current_identifier:
# find the shelfbook obj and delete it
get_object_or_404(
models.ShelfBook,
book=book,
user=request.user,
shelf__identifier=change_from_current_identifier,
).delete()
# A book can be on multiple shelves, but only on one read status shelf at a time
if desired_shelf.identifier in models.Shelf.READ_STATUS_IDENTIFIERS:
# figure out where state shelf it's currently on (if any)
current_read_status_shelfbook = (
models.ShelfBook.objects.select_related("shelf")
.filter(
shelf__identifier__in=models.Shelf.READ_STATUS_IDENTIFIERS,
user=request.user,
book=book,
)
.first()
)
if current_read_status_shelfbook is not None:
if (
current_read_status_shelfbook.shelf.identifier
!= desired_shelf.identifier
):
current_read_status_shelfbook.delete()
else: # It is already on the shelf
return redirect(request.headers.get("Referer", "/"))
# create the new shelf-book entry
models.ShelfBook.objects.create(
book=book, shelf=desired_shelf, user=request.user
)
else:
# we're putting it on a custom shelf
try:
models.ShelfBook.objects.create(
book=book, shelf=desired_shelf, user=request.user
)
# The book is already on this shelf.
# Might be good to alert, or reject the action?
except IntegrityError:
pass
return redirect(request.headers.get("Referer", "/"))
@login_required
@require_POST
def unshelve(request):
"""put a on a user's shelf"""
book = get_object_or_404(models.Edition, id=request.POST.get("book"))
shelf_book = get_object_or_404(
models.ShelfBook, book=book, shelf__id=request.POST["shelf"]
)
shelf_book.raise_not_deletable(request.user)
shelf_book.delete()
return redirect(request.headers.get("Referer", "/"))
def sort_books(books, sort):
"""Books in shelf sorting"""
sort_fields = [

View file

@ -0,0 +1,103 @@
""" shelf views """
from django.db import IntegrityError, transaction
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect
from django.views.decorators.http import require_POST
from bookwyrm import forms, models
@login_required
@require_POST
def create_shelf(request):
"""user generated shelves"""
form = forms.ShelfForm(request.POST)
if not form.is_valid():
return redirect(request.headers.get("Referer", "/"))
shelf = form.save()
return redirect(shelf.local_path)
@login_required
@require_POST
def delete_shelf(request, shelf_id):
"""user generated shelves"""
shelf = get_object_or_404(models.Shelf, id=shelf_id)
shelf.raise_not_deletable(request.user)
shelf.delete()
return redirect("user-shelves", request.user.localname)
@login_required
@require_POST
@transaction.atomic
def shelve(request):
"""put a book on a user's shelf"""
book = get_object_or_404(models.Edition, id=request.POST.get("book"))
desired_shelf = get_object_or_404(
request.user.shelf_set, identifier=request.POST.get("shelf")
)
# first we need to remove from the specified shelf
change_from_current_identifier = request.POST.get("change-shelf-from")
if change_from_current_identifier:
# find the shelfbook obj and delete it
get_object_or_404(
models.ShelfBook,
book=book,
user=request.user,
shelf__identifier=change_from_current_identifier,
).delete()
# A book can be on multiple shelves, but only on one read status shelf at a time
if desired_shelf.identifier in models.Shelf.READ_STATUS_IDENTIFIERS:
# figure out where state shelf it's currently on (if any)
current_read_status_shelfbook = (
models.ShelfBook.objects.select_related("shelf")
.filter(
shelf__identifier__in=models.Shelf.READ_STATUS_IDENTIFIERS,
user=request.user,
book=book,
)
.first()
)
if current_read_status_shelfbook is not None:
if (
current_read_status_shelfbook.shelf.identifier
!= desired_shelf.identifier
):
current_read_status_shelfbook.delete()
else: # It is already on the shelf
return redirect(request.headers.get("Referer", "/"))
# create the new shelf-book entry
models.ShelfBook.objects.create(
book=book, shelf=desired_shelf, user=request.user
)
else:
# we're putting it on a custom shelf
try:
models.ShelfBook.objects.create(
book=book, shelf=desired_shelf, user=request.user
)
# The book is already on this shelf.
# Might be good to alert, or reject the action?
except IntegrityError:
pass
return redirect(request.headers.get("Referer", "/"))
@login_required
@require_POST
def unshelve(request):
"""remove a book from a user's shelf"""
book = get_object_or_404(models.Edition, id=request.POST.get("book"))
shelf_book = get_object_or_404(
models.ShelfBook, book=book, shelf__id=request.POST["shelf"]
)
shelf_book.raise_not_deletable(request.user)
shelf_book.delete()
return redirect(request.headers.get("Referer", "/"))

View file

@ -137,6 +137,25 @@ class Following(View):
return TemplateResponse(request, "user/relationships/following.html", data)
class Groups(View):
"""list of user's groups view"""
def get(self, request, username):
"""list of groups"""
user = get_user_from_username(request.user, username)
paginated = Paginator(
models.Group.memberships.filter(user=user).order_by("-created_date"),
PAGE_LENGTH,
)
data = {
"user": user,
"is_self": request.user.id == user.id,
"group_list": paginated.get_page(request.GET.get("page")),
}
return TemplateResponse(request, "user/groups.html", data)
@require_POST
@login_required
def hide_suggestions(request):

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: bookwyrm\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-10-06 23:57+0000\n"
"PO-Revision-Date: 2021-10-08 00:04\n"
"POT-Creation-Date: 2021-10-15 22:03+0000\n"
"PO-Revision-Date: 2021-10-21 21:00\n"
"Last-Translator: Mouse Reeve <mousereeve@riseup.net>\n"
"Language-Team: Spanish\n"
"Language: es\n"
@ -54,8 +54,8 @@ msgstr "Orden de la lista"
msgid "Book Title"
msgstr "Título"
#: bookwyrm/forms.py:328 bookwyrm/templates/shelf/shelf.html:134
#: bookwyrm/templates/shelf/shelf.html:165
#: bookwyrm/forms.py:328 bookwyrm/templates/shelf/shelf.html:136
#: bookwyrm/templates/shelf/shelf.html:168
#: bookwyrm/templates/snippets/create_status/review.html:33
msgid "Rating"
msgstr "Calificación"
@ -151,45 +151,49 @@ msgstr "nombre de usuario"
msgid "A user with that username already exists."
msgstr "Ya existe un usuario con ese nombre."
#: bookwyrm/settings.py:117
#: bookwyrm/settings.py:118
msgid "Home Timeline"
msgstr "Línea temporal de hogar"
#: bookwyrm/settings.py:117
#: bookwyrm/settings.py:118
msgid "Home"
msgstr "Hogar"
#: bookwyrm/settings.py:118
#: bookwyrm/settings.py:119
msgid "Books Timeline"
msgstr "Línea temporal de libros"
#: bookwyrm/settings.py:118 bookwyrm/templates/search/layout.html:21
#: bookwyrm/settings.py:119 bookwyrm/templates/search/layout.html:21
#: bookwyrm/templates/search/layout.html:42
#: bookwyrm/templates/user/layout.html:81
msgid "Books"
msgstr "Libros"
#: bookwyrm/settings.py:164
#: bookwyrm/settings.py:165
msgid "English"
msgstr "Inglés"
#: bookwyrm/settings.py:165
#: bookwyrm/settings.py:166
msgid "Deutsch (German)"
msgstr "Deutsch (Alemán)"
#: bookwyrm/settings.py:166
#: bookwyrm/settings.py:167
msgid "Español (Spanish)"
msgstr "Español"
#: bookwyrm/settings.py:167
#: bookwyrm/settings.py:168
msgid "Français (French)"
msgstr "Français (Francés)"
#: bookwyrm/settings.py:168
#: bookwyrm/settings.py:169
msgid "Português - Brasil (Brazilian Portuguese)"
msgstr "Português - Brasil (Portugués Brasileño)"
#: bookwyrm/settings.py:170
msgid "简体中文 (Simplified Chinese)"
msgstr "简体中文 (Chino simplificado)"
#: bookwyrm/settings.py:169
#: bookwyrm/settings.py:171
msgid "繁體中文 (Traditional Chinese)"
msgstr "繁體中文 (Chino tradicional)"
@ -221,7 +225,7 @@ msgstr "Editar Autor/Autora"
#: bookwyrm/templates/author/author.html:34
#: bookwyrm/templates/author/edit_author.html:41
msgid "Aliases:"
msgstr ""
msgstr "Alias:"
#: bookwyrm/templates/author/author.html:45
msgid "Born:"
@ -233,7 +237,7 @@ msgstr "Muerto:"
#: bookwyrm/templates/author/author.html:61
msgid "Wikipedia"
msgstr ""
msgstr "Wikipedia"
#: bookwyrm/templates/author/author.html:69
#: bookwyrm/templates/book/book.html:94
@ -296,7 +300,7 @@ msgstr "Separar varios valores con comas."
#: bookwyrm/templates/author/edit_author.html:50
msgid "Bio:"
msgstr ""
msgstr "Biografía:"
#: bookwyrm/templates/author/edit_author.html:57
msgid "Wikipedia link:"
@ -484,7 +488,7 @@ msgstr "Número OCLC:"
#: bookwyrm/templates/book/book_identifiers.html:22
#: bookwyrm/templates/book/edit/edit_book_form.html:240
msgid "ASIN:"
msgstr ""
msgstr "ASIN:"
#: bookwyrm/templates/book/cover_modal.html:17
#: bookwyrm/templates/book/edit/edit_book_form.html:143
@ -571,7 +575,7 @@ msgstr "Idiomas:"
#: bookwyrm/templates/book/edit/edit_book_form.html:74
msgid "Publication"
msgstr ""
msgstr "Publicación"
#: bookwyrm/templates/book/edit/edit_book_form.html:77
msgid "Publisher:"
@ -635,11 +639,11 @@ msgstr "Identificadores de libro"
#: bookwyrm/templates/book/edit/edit_book_form.html:200
msgid "ISBN 13:"
msgstr ""
msgstr "ISBN 13:"
#: bookwyrm/templates/book/edit/edit_book_form.html:208
msgid "ISBN 10:"
msgstr ""
msgstr "ISBN 10:"
#: bookwyrm/templates/book/edit/edit_book_form.html:216
msgid "Openlibrary ID:"
@ -669,11 +673,6 @@ msgstr "Idioma:"
msgid "Search editions"
msgstr "Buscar ediciones"
#: bookwyrm/templates/book/publisher_info.html:21
#, python-format
msgid "%(format)s"
msgstr ""
#: bookwyrm/templates/book/publisher_info.html:23
#, python-format
msgid "%(format)s, %(pages)s pages"
@ -753,8 +752,8 @@ msgid "Help"
msgstr "Ayuda"
#: bookwyrm/templates/compose.html:5 bookwyrm/templates/compose.html:8
msgid "Compose status"
msgstr "Componer status"
msgid "Edit status"
msgstr "Editar estado"
#: bookwyrm/templates/confirm_email/confirm_email.html:4
msgid "Confirm email"
@ -888,6 +887,26 @@ msgstr "Usuarios de BookWyrm"
msgid "All known users"
msgstr "Todos los usuarios conocidos"
#: bookwyrm/templates/discover/card-header.html:9
#, python-format
msgid "<a href=\"%(user_path)s\">%(username)s</a> rated <a href=\"%(book_path)s\">%(book_title)s</a>"
msgstr "<a href=\"%(user_path)s\">%(username)s</a> calificó <a href=\"%(book_path)s\">%(book_title)s</a>"
#: bookwyrm/templates/discover/card-header.html:13
#, python-format
msgid "<a href=\"%(user_path)s\">%(username)s</a> reviewed <a href=\"%(book_path)s\">%(book_title)s</a>"
msgstr "<a href=\"%(user_path)s\">%(username)s</a> reseñó <a href=\"%(book_path)s\">%(book_title)s</a>"
#: bookwyrm/templates/discover/card-header.html:17
#, python-format
msgid "<a href=\"%(user_path)s\">%(username)s</a> commented on <a href=\"%(book_path)s\">%(book_title)s</a>"
msgstr "<a href=\"%(user_path)s\">%(username)s</a> comentó en <a href=\"%(book_path)s\">%(book_title)s</a>"
#: bookwyrm/templates/discover/card-header.html:21
#, python-format
msgid "<a href=\"%(user_path)s\">%(username)s</a> quoted <a href=\"%(book_path)s\">%(book_title)s</a>"
msgstr "<a href=\"%(user_path)s\">%(username)s</a> citó <a href=\"%(book_path)s\">%(book_title)s</a>"
#: bookwyrm/templates/discover/discover.html:4
#: bookwyrm/templates/discover/discover.html:10
#: bookwyrm/templates/layout.html:78
@ -899,28 +918,8 @@ msgstr "Descubrir"
msgid "See what's new in the local %(site_name)s community"
msgstr "Ver que es nuevo en la comunidad local de %(site_name)s"
#: bookwyrm/templates/discover/large-book.html:46
#: bookwyrm/templates/discover/small-book.html:32
msgid "rated"
msgstr "calificó"
#: bookwyrm/templates/discover/large-book.html:48
#: bookwyrm/templates/discover/small-book.html:34
msgid "reviewed"
msgstr "reseñó"
#: bookwyrm/templates/discover/large-book.html:50
#: bookwyrm/templates/discover/small-book.html:36
msgid "commented on"
msgstr "comentó en"
#: bookwyrm/templates/discover/large-book.html:52
#: bookwyrm/templates/discover/small-book.html:38
msgid "quoted"
msgstr "citó"
#: bookwyrm/templates/discover/large-book.html:68
#: bookwyrm/templates/discover/small-book.html:52
#: bookwyrm/templates/discover/small-book.html:36
msgid "View status"
msgstr "Ver status"
@ -974,8 +973,8 @@ msgstr "Únete ahora"
#: bookwyrm/templates/email/invite/html_content.html:15
#, python-format
msgid "Learn more <a href=\"https://%(domain)s%(about_path)s\">about this instance</a>."
msgstr "Aprenda más <a href=\"https://%(domain)s%(about_path)s\">sobre esta instancia</a>."
msgid "Learn more <a href=\"https://%(domain)s%(about_path)s\">about %(site_name)s</a>."
msgstr "Más información <a href=\"https://%(domain)s%(about_path)s\">sobre %(site_name)s</a>."
#: bookwyrm/templates/email/invite/text_content.html:4
#, python-format
@ -983,8 +982,9 @@ msgid "You're invited to join %(site_name)s! Click the link below to create an a
msgstr "Estás invitado a unirte con %(site_name)s! Haz clic en el enlace a continuación para crear una cuenta."
#: bookwyrm/templates/email/invite/text_content.html:8
msgid "Learn more about this instance:"
msgstr "Aprende más sobre esta intancia:"
#, python-format
msgid "Learn more about %(site_name)s:"
msgstr "Más información sobre %(site_name)s:"
#: bookwyrm/templates/email/password_reset/html_content.html:6
#: bookwyrm/templates/email/password_reset/text_content.html:4
@ -1198,7 +1198,7 @@ msgstr "Un poco sobre ti"
#: bookwyrm/templates/get_started/profile.html:32
#: bookwyrm/templates/preferences/edit_user.html:27
msgid "Avatar:"
msgstr ""
msgstr "Avatar:"
#: bookwyrm/templates/get_started/profile.html:42
#: bookwyrm/templates/preferences/edit_user.html:110
@ -1323,13 +1323,13 @@ msgstr "Libro"
#: bookwyrm/templates/import/import_status.html:122
#: bookwyrm/templates/shelf/shelf.html:128
#: bookwyrm/templates/shelf/shelf.html:148
#: bookwyrm/templates/shelf/shelf.html:150
msgid "Title"
msgstr "Título"
#: bookwyrm/templates/import/import_status.html:125
#: bookwyrm/templates/shelf/shelf.html:129
#: bookwyrm/templates/shelf/shelf.html:151
#: bookwyrm/templates/shelf/shelf.html:153
msgid "Author"
msgstr "Autor/Autora"
@ -1338,8 +1338,8 @@ msgid "Imported"
msgstr "Importado"
#: bookwyrm/templates/import/tooltip.html:6
msgid "You can download your GoodReads data from the <a href=\"https://www.goodreads.com/review/import\" target=\"_blank\" rel=\"noopener\">Import/Export page</a> of your GoodReads account."
msgstr "Puedes descargar tus datos de GoodReads de la <a href=\"https://www.goodreads.com/review/import\" target=\"_blank\" rel=\"noopener\">Página de Exportación/Importación</a> de tu cuenta de GoodReads."
msgid "You can download your Goodreads data from the <a href=\"https://www.goodreads.com/review/import\" target=\"_blank\" rel=\"noopener\">Import/Export page</a> of your Goodreads account."
msgstr "Puede descargar sus datos de Goodreads desde la <a href=\"https://www.goodreads.com/review/import\" target=\"_blank\" rel=\"noopener\">página de Importación/Exportación</a> de su cuenta de Goodreads."
#: bookwyrm/templates/invite.html:4 bookwyrm/templates/invite.html:8
#: bookwyrm/templates/login.html:49
@ -1354,7 +1354,7 @@ msgstr "Permiso denegado"
msgid "Sorry! This invite code is no longer valid."
msgstr "¡Disculpa! Este código de invitación no queda válido."
#: bookwyrm/templates/landing/about.html:7
#: bookwyrm/templates/landing/about.html:7 bookwyrm/templates/layout.html:230
#, python-format
msgid "About %(site_name)s"
msgstr "Sobre %(site_name)s"
@ -1442,7 +1442,7 @@ msgstr "Invitaciones"
#: bookwyrm/templates/layout.html:132
msgid "Admin"
msgstr ""
msgstr "Administrador"
#: bookwyrm/templates/layout.html:139
msgid "Log out"
@ -1485,10 +1485,6 @@ msgstr "Status publicado exitosamente"
msgid "Error posting status"
msgstr "Error en publicar status"
#: bookwyrm/templates/layout.html:230
msgid "About this instance"
msgstr "Sobre esta instancia"
#: bookwyrm/templates/layout.html:234
msgid "Contact site admin"
msgstr "Contactarse con administradores del sitio"
@ -1594,7 +1590,7 @@ msgstr "Cualquier usuario puede sugerir libros, en cuanto lo hayas aprobado"
#: bookwyrm/templates/lists/form.html:31
msgctxt "curation type"
msgid "Open"
msgstr ""
msgstr "Abrir"
#: bookwyrm/templates/lists/form.html:32
msgid "Anyone can add books to this list"
@ -1704,12 +1700,12 @@ msgstr "Más sobre este sitio"
#: bookwyrm/templates/notifications/items/add.html:24
#, python-format
msgid "added <em><a href=\"%(book_path)s\">%(book_title)s</a></em> to your list \"<a href=\"%(list_path)s\">%(list_name)s</a>\""
msgstr ""
msgstr "agregó <em><a href=\"%(book_path)s\">%(book_title)s</a></em> a su lista «<a href=\"%(list_path)s\">%(list_name)s</a>»"
#: bookwyrm/templates/notifications/items/add.html:31
#, python-format
msgid "suggested adding <em><a href=\"%(book_path)s\">%(book_title)s</a></em> to your list \"<a href=\"%(list_path)s\">%(list_name)s</a>\""
msgstr ""
msgstr "sugirió agregar <em><a href=\"%(book_path)s\">%(book_title)s</a></em> a su lista «<a href=\"%(list_path)s\">%(list_name)s</a>»"
#: bookwyrm/templates/notifications/items/boost.html:19
#, python-format
@ -1733,23 +1729,23 @@ msgstr "respaldó tu <a href=\"%(related_path)s\">status</a>"
#: bookwyrm/templates/notifications/items/fav.html:19
#, python-format
msgid "favorited your <a href=\"%(related_path)s\">review of <em>%(book_title)s</em></a>"
msgid "liked your <a href=\"%(related_path)s\">review of <em>%(book_title)s</em></a>"
msgstr "le gustó tu <a href=\"%(related_path)s\">reseña de <em>%(book_title)s</em></a>"
#: bookwyrm/templates/notifications/items/fav.html:25
#, python-format
msgid "favorited your <a href=\"%(related_path)s\">comment on<em>%(book_title)s</em></a>"
msgstr ""
msgid "liked your <a href=\"%(related_path)s\">comment on<em>%(book_title)s</em></a>"
msgstr "le gustó tu <a href=\"%(related_path)s\">comentario sobre <em>%(book_title)s</em></a>"
#: bookwyrm/templates/notifications/items/fav.html:31
#, python-format
msgid "favorited your <a href=\"%(related_path)s\">quote from <em>%(book_title)s</em></a>"
msgid "liked your <a href=\"%(related_path)s\">quote from <em>%(book_title)s</em></a>"
msgstr "le gustó tu <a href=\"%(related_path)s\">cita de <em>%(book_title)s</em></a>"
#: bookwyrm/templates/notifications/items/fav.html:37
#, python-format
msgid "favorited your <a href=\"%(related_path)s\">status</a>"
msgstr "le gustó tu <a href=\"%(related_path)s\">status</a>"
msgid "liked your <a href=\"%(related_path)s\">status</a>"
msgstr "le gustó tu <a href=\"%(related_path)s\">estado</a>"
#: bookwyrm/templates/notifications/items/follow.html:15
msgid "followed you"
@ -2004,7 +2000,7 @@ msgstr "Editar anuncio"
#: bookwyrm/templates/settings/announcements/announcement.html:35
msgid "Visible:"
msgstr ""
msgstr "Visible:"
#: bookwyrm/templates/settings/announcements/announcement.html:38
msgid "True"
@ -2078,7 +2074,7 @@ msgstr "Fecha final"
#: bookwyrm/templates/settings/users/user_admin.html:34
#: bookwyrm/templates/settings/users/user_info.html:20
msgid "Status"
msgstr ""
msgstr "Estado"
#: bookwyrm/templates/settings/announcements/announcements.html:48
msgid "active"
@ -2110,7 +2106,7 @@ msgstr "Activos este mes"
#: bookwyrm/templates/settings/dashboard/dashboard.html:27
msgid "Statuses"
msgstr ""
msgstr "Estados"
#: bookwyrm/templates/settings/dashboard/dashboard.html:33
#: bookwyrm/templates/settings/dashboard/works_chart.html:11
@ -2157,11 +2153,11 @@ msgstr "Actividad de status"
#: bookwyrm/templates/settings/dashboard/dashboard.html:118
msgid "Works created"
msgstr ""
msgstr "Obras creadas"
#: bookwyrm/templates/settings/dashboard/registration_chart.html:10
msgid "Registrations"
msgstr ""
msgstr "Inscripciones"
#: bookwyrm/templates/settings/dashboard/status_chart.html:11
msgid "Statuses posted"
@ -2238,13 +2234,13 @@ msgstr "Instancia:"
#: bookwyrm/templates/settings/federation/instance.html:28
#: bookwyrm/templates/settings/users/user_info.html:106
msgid "Status:"
msgstr ""
msgstr "Estado:"
#: bookwyrm/templates/settings/federation/edit_instance.html:52
#: bookwyrm/templates/settings/federation/instance.html:22
#: bookwyrm/templates/settings/users/user_info.html:100
msgid "Software:"
msgstr ""
msgstr "Software:"
#: bookwyrm/templates/settings/federation/edit_instance.html:61
#: bookwyrm/templates/settings/federation/instance.html:25
@ -2297,6 +2293,7 @@ msgid "Notes"
msgstr "Notas"
#: bookwyrm/templates/settings/federation/instance.html:75
#: bookwyrm/templates/snippets/status/status_options.html:24
msgid "Edit"
msgstr "Editar"
@ -2357,7 +2354,7 @@ msgstr "Nombre de instancia"
#: bookwyrm/templates/settings/federation/instance_list.html:40
msgid "Software"
msgstr ""
msgstr "Software"
#: bookwyrm/templates/settings/federation/instance_list.html:63
msgid "No instances found"
@ -2636,8 +2633,8 @@ msgid "Short description:"
msgstr "Descripción corta:"
#: bookwyrm/templates/settings/site.html:37
msgid "Used when the instance is previewed on joinbookwyrm.com. Does not support html or markdown."
msgstr "Utilizado cuando la instancia se ve de una vista previa en joinbookwyrm.com. No es compatible con html o markdown."
msgid "Used when the instance is previewed on joinbookwyrm.com. Does not support HTML or Markdown."
msgstr "Se utiliza cuando se obtiene una vista previa de la instancia en joinbookwyrm.com. No es compatible con HTML ni Markdown."
#: bookwyrm/templates/settings/site.html:41
msgid "Code of conduct:"
@ -2649,7 +2646,7 @@ msgstr "Política de privacidad:"
#: bookwyrm/templates/settings/site.html:57
msgid "Logo:"
msgstr ""
msgstr "Logo:"
#: bookwyrm/templates/settings/site.html:61
msgid "Logo small:"
@ -2657,7 +2654,7 @@ msgstr "Logo pequeño:"
#: bookwyrm/templates/settings/site.html:65
msgid "Favicon:"
msgstr ""
msgstr "Favicon:"
#: bookwyrm/templates/settings/site.html:77
msgid "Support link:"
@ -2760,7 +2757,7 @@ msgstr "Ver perfil de usuario"
#: bookwyrm/templates/settings/users/user_info.html:36
msgid "Local"
msgstr ""
msgstr "Local"
#: bookwyrm/templates/settings/users/user_info.html:38
msgid "Remote"
@ -2811,7 +2808,7 @@ msgid "Permanently deleted"
msgstr "Eliminado permanentemente"
#: bookwyrm/templates/settings/users/user_moderation_actions.html:13
#: bookwyrm/templates/snippets/status/status_options.html:35
#: bookwyrm/templates/snippets/status/status_options.html:32
#: bookwyrm/templates/snippets/user_options.html:13
msgid "Send direct message"
msgstr "Enviar mensaje directo"
@ -2848,8 +2845,8 @@ msgstr "Crear estante"
#, python-format
msgid "%(formatted_count)s book"
msgid_plural "%(formatted_count)s books"
msgstr[0] ""
msgstr[1] ""
msgstr[0] "%(formatted_count)s libro"
msgstr[1] "%(formatted_count)s libros"
#: bookwyrm/templates/shelf/shelf.html:84
#, python-format
@ -2864,22 +2861,22 @@ msgstr "Editar estante"
msgid "Delete shelf"
msgstr "Eliminar estante"
#: bookwyrm/templates/shelf/shelf.html:130
#: bookwyrm/templates/shelf/shelf.html:154
#: bookwyrm/templates/shelf/shelf.html:132
#: bookwyrm/templates/shelf/shelf.html:158
msgid "Shelved"
msgstr "Archivado"
#: bookwyrm/templates/shelf/shelf.html:131
#: bookwyrm/templates/shelf/shelf.html:158
#: bookwyrm/templates/shelf/shelf.html:133
#: bookwyrm/templates/shelf/shelf.html:161
msgid "Started"
msgstr "Empezado"
#: bookwyrm/templates/shelf/shelf.html:132
#: bookwyrm/templates/shelf/shelf.html:161
#: bookwyrm/templates/shelf/shelf.html:134
#: bookwyrm/templates/shelf/shelf.html:164
msgid "Finished"
msgstr "Terminado"
#: bookwyrm/templates/shelf/shelf.html:187
#: bookwyrm/templates/shelf/shelf.html:190
msgid "This shelf is empty."
msgstr "Este estante está vacio."
@ -2892,8 +2889,8 @@ msgstr "Publicado por <a href=\"%(user_path)s\">%(username)s</a>"
#, python-format
msgid "and %(remainder_count_display)s other"
msgid_plural "and %(remainder_count_display)s others"
msgstr[0] ""
msgstr[1] ""
msgstr[0] "y %(remainder_count_display)s otro"
msgstr[1] "y %(remainder_count_display)s otros"
#: bookwyrm/templates/snippets/book_cover.html:61
msgid "No cover"
@ -2926,22 +2923,22 @@ msgstr "Cita"
msgid "Some thoughts on the book"
msgstr "Algunos pensamientos sobre el libro"
#: bookwyrm/templates/snippets/create_status/comment.html:26
#: bookwyrm/templates/snippets/create_status/comment.html:27
#: bookwyrm/templates/snippets/reading_modals/progress_update_modal.html:15
msgid "Progress:"
msgstr "Progreso:"
#: bookwyrm/templates/snippets/create_status/comment.html:52
#: bookwyrm/templates/snippets/create_status/comment.html:53
#: bookwyrm/templates/snippets/progress_field.html:18
msgid "pages"
msgstr "páginas"
#: bookwyrm/templates/snippets/create_status/comment.html:58
#: bookwyrm/templates/snippets/create_status/comment.html:59
#: bookwyrm/templates/snippets/progress_field.html:23
msgid "percent"
msgstr "por ciento"
#: bookwyrm/templates/snippets/create_status/comment.html:65
#: bookwyrm/templates/snippets/create_status/comment.html:66
#, python-format
msgid "of %(pages)s pages"
msgstr "de %(pages)s páginas"
@ -2969,7 +2966,7 @@ msgstr "¡Advertencia, ya vienen spoilers!"
msgid "Include spoiler alert"
msgstr "Incluir alerta de spoiler"
#: bookwyrm/templates/snippets/create_status/layout.html:41
#: bookwyrm/templates/snippets/create_status/layout.html:48
#: bookwyrm/templates/snippets/reading_modals/form.html:7
msgid "Comment:"
msgstr "Comentario:"
@ -3163,12 +3160,12 @@ msgstr "Has leído <a href=\"%(path)s\">%(read_count)s de %(goal_count)s libros<
msgid "%(username)s has read <a href=\"%(path)s\">%(read_count)s of %(goal_count)s books</a>."
msgstr "%(username)s ha leído <a href=\"%(path)s\">%(read_count)s de %(goal_count)s libros</a>."
#: bookwyrm/templates/snippets/page_text.html:4
#: bookwyrm/templates/snippets/page_text.html:8
#, python-format
msgid "page %(page)s of %(total_pages)s"
msgstr "página %(page)s de %(total_pages)s"
#: bookwyrm/templates/snippets/page_text.html:6
#: bookwyrm/templates/snippets/page_text.html:14
#, python-format
msgid "page %(page)s"
msgstr "página %(page)s"
@ -3319,7 +3316,7 @@ msgstr "(Página %(page)s)"
#: bookwyrm/templates/snippets/status/content_status.html:103
#, python-format
msgid "(%(percent)s%%)"
msgstr ""
msgstr "(%(percent)s%%)"
#: bookwyrm/templates/snippets/status/content_status.html:125
msgid "Open image in new window"
@ -3329,6 +3326,11 @@ msgstr "Abrir imagen en una nueva ventana"
msgid "Hide status"
msgstr "Ocultar status"
#: bookwyrm/templates/snippets/status/header.html:45
#, python-format
msgid "edited %(date)s"
msgstr "editado %(date)s"
#: bookwyrm/templates/snippets/status/headers/comment.html:2
#, python-format
msgid "commented on <a href=\"%(book_path)s\">%(book)s</a>"
@ -3393,10 +3395,6 @@ msgstr "respaldó"
msgid "More options"
msgstr "Más opciones"
#: bookwyrm/templates/snippets/status/status_options.html:26
msgid "Delete & re-draft"
msgstr "Eliminar y recomponer"
#: bookwyrm/templates/snippets/suggested_users.html:16
#, python-format
msgid "%(mutuals)s follower you follow"
@ -3561,7 +3559,7 @@ msgstr "Archivo excede el tamaño máximo: 10MB"
#: bookwyrm/templatetags/utilities.py:31
#, python-format
msgid "%(title)s: %(subtitle)s"
msgstr ""
msgstr "%(title)s: %(subtitle)s"
#: bookwyrm/views/import_data.py:67
msgid "Not a valid csv file"

Some files were not shown because too many files have changed in this diff Show more