2
0
Fork 0

Merge branch 'production' into nix

This commit is contained in:
D Anzorge 2021-11-06 22:28:08 +01:00
commit 372efe7d89
139 changed files with 9344 additions and 5175 deletions

View file

@ -35,6 +35,7 @@ class Note(ActivityObject):
tag: List[Link] = field(default_factory=lambda: [])
attachment: List[Document] = field(default_factory=lambda: [])
sensitive: bool = False
updated: str = None
type: str = "Note"

View file

@ -69,8 +69,9 @@ class Update(Verb):
def action(self):
"""update a model instance from the dataclass"""
if self.object:
self.object.to_model(allow_create=False)
if not self.object:
return
self.object.to_model(allow_create=False)
@dataclass(init=False)

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,31 @@
# Generated by Django 3.2.5 on 2021-10-11 16:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0106_user_preferred_language"),
]
operations = [
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)"),
("pt-br", "Português - Brasil (Brazilian Portugues)"),
("zh-hans", "简体中文 (Simplified Chinese)"),
("zh-hant", "繁體中文 (Traditional Chinese)"),
],
max_length=255,
null=True,
),
),
]

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,31 @@
# Generated by Django 3.2.5 on 2021-10-11 17:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0107_alter_user_preferred_language"),
]
operations = [
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,
),
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.2.5 on 2021-10-15 15:54
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0108_alter_user_preferred_language"),
]
operations = [
migrations.AddField(
model_name="status",
name="edited_date",
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.5 on 2021-10-15 17:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0109_status_edited_date"),
]
operations = [
migrations.AddField(
model_name="quotation",
name="raw_quote",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="status",
name="raw_content",
field=models.TextField(blank=True, null=True),
),
]

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

@ -67,19 +67,35 @@ class BookWyrmModel(models.Model):
return
# you can see the followers only posts of people you follow
if (
self.privacy == "followers"
and self.user.followers.filter(id=viewer.id).first()
if self.privacy == "followers" and (
self.user.followers.filter(id=viewer.id).first()
):
return
# you can see dms you are tagged in
if hasattr(self, "mention_users"):
if (
self.privacy == "direct"
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

@ -31,6 +31,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"User", on_delete=models.PROTECT, activitypub_field="attributedTo"
)
content = fields.HtmlField(blank=True, null=True)
raw_content = models.TextField(blank=True, null=True)
mention_users = fields.TagField("User", related_name="mention_user")
mention_books = fields.TagField("Edition", related_name="mention_book")
local = models.BooleanField(default=True)
@ -43,6 +44,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
published_date = fields.DateTimeField(
default=timezone.now, activitypub_field="published"
)
edited_date = fields.DateTimeField(
blank=True, null=True, activitypub_field="updated"
)
deleted = models.BooleanField(default=False)
deleted_date = models.DateTimeField(blank=True, null=True)
favorites = models.ManyToManyField(
@ -220,6 +224,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
~Q(Q(user=viewer) | Q(mention_users=viewer)), privacy="direct"
)
@classmethod
def followers_filter(cls, queryset, viewer):
"""Override-able filter for "followers" privacy level"""
return queryset.exclude(
~Q( # not yourself, a follower, or someone who is tagged
Q(user__followers=viewer) | Q(user=viewer) | Q(mention_users=viewer)
),
privacy="followers", # and the status is followers only
)
class GeneratedNote(Status):
"""these are app-generated messages about user activity"""
@ -292,6 +306,7 @@ class Quotation(BookStatus):
"""like a review but without a rating and transient"""
quote = fields.HtmlField()
raw_quote = models.TextField(blank=True, null=True)
position = models.IntegerField(
validators=[MinValueValidator(0)], null=True, blank=True
)

View file

@ -7,13 +7,14 @@ from django.utils.translation import gettext_lazy as _
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
VERSION = "0.0.1"
VERSION = "0.1.0"
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "c02929b1"
JS_CACHE = "3eb4edb1"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@ -163,11 +164,12 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = "en-us"
LANGUAGES = [
("en-us", _("English")),
("de-de", _("Deutsch (German)")), # German
("es", _("Español (Spanish)")), # Spanish
("fr-fr", _("Français (French)")), # French
("zh-hans", _("简体中文 (Simplified Chinese)")), # Simplified Chinese
("zh-hant", _("繁體中文 (Traditional Chinese)")), # Traditional Chinese
("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)")),
]

View file

@ -509,6 +509,20 @@ ol.ordered-list li::before {
border-left: 2px solid #e0e0e0;
}
/* Breadcrumbs
******************************************************************************/
.breadcrumb li:first-child * {
padding-left: 0;
}
.breadcrumb li > * {
align-items: center;
display: flex;
justify-content: center;
padding: 0 0.75em;
}
/* Dimensions
* @todo These could be in rem.
******************************************************************************/

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

@ -1,7 +1,7 @@
{% spaceless %}
{% load i18n %}
{% if book.isbn13 or book.oclc_number or book.asin %}
{% if book.isbn_13 or book.oclc_number or book.asin %}
<dl>
{% if book.isbn_13 %}
<div class="is-flex">

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

@ -2,10 +2,10 @@
{% load i18n %}
{% load utilities %}
{% block title %}{% trans "Compose status" %}{% endblock %}
{% block title %}{% trans "Edit status" %}{% endblock %}
{% block content %}
<header class="block content">
<h1>{% trans "Compose status" %}</h1>
<h1>{% trans "Edit status" %}</h1>
</header>
{% with 0|uuid as uuid %}
@ -22,6 +22,10 @@
<div class="column">
{% if draft.reply_parent %}
{% include 'snippets/status/status.html' with status=draft.reply_parent no_interact=True %}
{% else %}
<div class="block">
{% include "snippets/status/header.html" with status=draft %}
</div>
{% endif %}
{% if not draft %}

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' %}
@ -124,14 +137,16 @@
<table class="table is-striped is-fullwidth is-mobile">
<thead>
<tr>
<th>{% trans "Cover" %}</th>
<th>{% trans "Title" %}</th>
<th>{% trans "Author" %}</th>
<th>{% trans "Shelved" %}</th>
<th>{% trans "Started" %}</th>
<th>{% trans "Finished" %}</th>
<th>{% trans "Cover"%}</th>
<th>{% trans "Title" as text %}{% include 'snippets/table-sort-header.html' with field="title" sort=sort text=text %}</th>
<th>{% trans "Author" as text %}{% include 'snippets/table-sort-header.html' with field="author" sort=sort text=text %}</th>
{% if request.user.is_authenticated %}
<th>{% trans "Rating" %}</th>
{% if is_self %}
<th>{% trans "Shelved" as text %}{% include 'snippets/table-sort-header.html' with field="shelved_date" sort=sort text=text %}</th>
<th>{% trans "Started" as text %}{% include 'snippets/table-sort-header.html' with field="start_date" sort=sort text=text %}</th>
<th>{% trans "Finished" as text %}{% include 'snippets/table-sort-header.html' with field="finish_date" sort=sort text=text %}</th>
{% endif %}
<th>{% trans "Rating" as text %}{% include 'snippets/table-sort-header.html' with field="rating" sort=sort text=text %}</th>
{% endif %}
{% if shelf.user == request.user %}
<th aria-hidden="true"></th>
@ -151,17 +166,18 @@
<td data-title="{% trans "Author" %}">
{% include 'snippets/authors.html' %}
</td>
{% if request.user.is_authenticated %}
{% if is_self %}
<td data-title="{% trans "Shelved" %}">
{{ book.shelved_date|naturalday }}
</td>
{% latest_read_through book user as read_through %}
<td data-title="{% trans "Started" %}">
{{ read_through.start_date|naturalday|default_if_none:""}}
{{ book.start_date|naturalday|default_if_none:""}}
</td>
<td data-title="{% trans "Finished" %}">
{{ read_through.finish_date|naturalday|default_if_none:""}}
{{ book.finish_date|naturalday|default_if_none:""}}
</td>
{% if request.user.is_authenticated %}
{% endif %}
<td data-title="{% trans "Rating" %}">
{% include 'snippets/stars.html' with rating=book.rating %}
</td>

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

@ -15,6 +15,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
{% trans "Some thoughts on the book" as placeholder %}
{% block post_content_additions %}
{% if not draft.reading_status %}
{# Supplemental fields #}
<div>
{% active_shelf book as active_shelf %}
@ -35,7 +36,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
size="3"
value="{% firstof draft.progress readthrough.progress '' %}"
id="progress_{{ uuid }}"
data-cache-draft="id_progress_comment_{{ book.id }}"
{% if not draft %}data-cache-draft="id_progress_comment_{{ book.id }}"{% endif %}
>
</div>
<div class="control">
@ -43,7 +44,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
<select
name="progress_mode"
aria-label="Progress mode"
data-cache-draft="id_progress_mode_comment_{{ book.id }}"
{% if not draft %}data-cache-draft="id_progress_mode_comment_{{ book.id }}"{% endif %}
>
<option
value="PG"
@ -68,6 +69,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
{% endwith %}
{% endif %}
</div>
{% endif %}
{% endblock %}
{% endwith %}

View file

@ -11,10 +11,10 @@ draft: an existing Status object that is providing default values for input fiel
<textarea
name="content"
class="textarea save-draft"
data-cache-draft="id_content_{{ type }}_{{ book.id }}{{ reply_parent.id }}"
{% if not draft %}data-cache-draft="id_content_{{ type }}_{{ book.id }}{{ reply_parent.id }}"{% endif %}
id="id_content_{{ type }}_{{ book.id }}{{ reply_parent.id }}{{ uuid }}"
placeholder="{{ placeholder }}"
aria-label="{% if reply_parent %}{% trans 'Reply' %}{% else %}{% trans 'Content' %}{% endif %}"
{% if not optional and type != "quotation" and type != "review" %}required{% endif %}
>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mention %}@{{ mention|username }} {% endif %}{{ draft.content|default:'' }}</textarea>
>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mention %}@{{ mention|username }} {% endif %}{% firstof draft.raw_content draft.content '' %}</textarea>

View file

@ -17,6 +17,6 @@
id="id_content_warning_{{ uuid }}{{ local_uuid }}"
placeholder="{% trans 'Spoilers ahead!' %}"
value="{% firstof draft.content_warning reply_parent.content_warning '' %}"
data-cache-draft="id_content_warning_{{ book.id }}_{{ type }}"
{% if not draft %}data-cache-draft="id_content_warning_{{ book.id }}_{{ type }}"{% endif %}
>
</div>

View file

@ -8,7 +8,7 @@
id="id_show_spoilers_{{ uuid }}{{ local_uuid }}"
{% if draft.content_warning or status.content_warning %}checked{% endif %}
aria-hidden="true"
data-cache-draft="id_sensitive_{{ book.id }}_{{ type }}{{ reply_parent.id }}"
{% if not draft %}data-cache-draft="id_sensitive_{{ book.id }}_{{ type }}{{ reply_parent.id }}"{% endif %}
>
{% trans "Include spoiler alert" as button_text %}
{% firstof draft.content_warning status.content_warning as pressed %}

View file

@ -17,7 +17,11 @@ reply_parent: the Status object this post will be in reply to, if applicable
<form
class="is-flex-grow-1{% if not no_script %} submit-status{% endif %}"
name="{{ type }}"
action="/post/{{ type }}"
{% if draft %}
action="{% url 'create-status' type draft.id %}"
{% else %}
action="{% url 'create-status' type %}"
{% endif %}
method="post"
id="form_{{ type }}_{{ book.id }}{{ reply_parent.id }}"
>
@ -29,6 +33,9 @@ reply_parent: the Status object this post will be in reply to, if applicable
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="user" value="{{ request.user.id }}">
<input type="hidden" name="reply_parent" value="{% firstof draft.reply_parent.id reply_parent.id %}">
{% if draft %}
<input type="hidden" name="reading_status" value="{{ draft.reading_status|default:'' }}">
{% endif %}
{% endblock %}
{% include "snippets/create_status/content_warning_field.html" %}

View file

@ -24,8 +24,8 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
id="id_quote_{{ book.id }}_{{ type }}"
placeholder="{% blocktrans with book_title=book.title %}An excerpt from '{{ book_title }}'{% endblocktrans %}"
required
data-cache-draft="id_quote_{{ book.id }}_{{ type }}"
>{{ draft.quote|default:'' }}</textarea>
{% if not draft %}data-cache-draft="id_quote_{{ book.id }}_{{ type }}"{% endif %}
>{% firstof draft.raw_quote draft.quote '' %}</textarea>
</div>
</div>
<div class="field">
@ -36,7 +36,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
<select
name="position_mode"
aria-label="Position mode"
data-cache-draft="id_position_mode_{{ book.id }}_{{ type }}"
{% if not draft %}data-cache-draft="id_position_mode_{{ book.id }}_{{ type }}"{% endif %}
>
<option
value="PG"
@ -63,7 +63,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
size="3"
value="{% firstof draft.position '' %}"
id="position_{{ uuid }}"
data-cache-draft="id_position_{{ book.id }}_{{ type }}"
{% if not draft %}data-cache-draft="id_position_{{ book.id }}_{{ type }}"{% endif %}
>
</div>
</div>

View file

@ -24,7 +24,7 @@ uuid: a unique identifier used to make html "id" attributes unique and clarify j
id="id_name_{{ book.id }}"
placeholder="{% blocktrans with book_title=book.title %}Your review of '{{ book_title }}'{% endblocktrans %}"
value="{% firstof draft.name ''%}"
data-cache-draft="id_name_{{ book.id }}_{{ type }}"
{% if not draft %}data-cache-draft="id_name_{{ book.id }}_{{ type }}"{% endif %}
>
</div>
</div>

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

@ -1,7 +1,19 @@
{% spaceless %}
{% load humanize %}
{% load i18n %}
{% if total_pages %}
{% blocktrans with page=page|intcomma total_pages=total_pages|intcomma %}page {{ page }} of {{ total_pages }}{% endblocktrans %}
{% blocktrans trimmed with page=page|intcomma total_pages=total_pages|intcomma %}
page {{ page }} of {{ total_pages }}
{% endblocktrans %}
{% else %}
{% blocktrans with page=page|intcomma %}page {{ page }}{% endblocktrans %}
{% blocktrans trimmed with page=page|intcomma %}
page {{ page }}
{% endblocktrans %}
{% endif %}
{% endspaceless %}

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

@ -3,7 +3,7 @@
{% if request.user.is_authenticated %}
<span class="is-sr-only">{% trans "Leave a rating" %}</span>
<div class="block">
<form class="hidden-form" name="rate" action="/post/rating" method="POST">
<form class="hidden-form" name="rate" action="{% url 'create-status' 'rating' %}" method="POST">
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
<input type="hidden" name="book" value="{{ book.id }}">

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

@ -31,18 +31,38 @@
{% include "snippets/status/header_content.html" %}
</h3>
<p class="is-size-7 is-flex is-align-items-center">
<a href="{{ status.remote_id }}{% if status.user.local %}#anchor-{{ status.id }}{% endif %}">{{ status.published_date|published_date }}</a>
{% if status.progress %}
<span class="ml-1">
{% if status.progress_mode == 'PG' %}
({% include 'snippets/page_text.html' with page=status.progress total_pages=status.book.pages %})
{% else %}
({{ status.progress }}%)
{% endif %}
</span>
{% endif %}
{% include 'snippets/privacy-icons.html' with item=status %}
</p>
<div class="breadcrumb has-dot-separator is-small">
<ul class="is-flex is-align-items-center">
<li>
<a href="{{ status.remote_id }}{% if status.user.local %}#anchor-{{ status.id }}{% endif %}">
{{ status.published_date|published_date }}
</a>
</li>
{% if status.edited_date %}
<li>
<span>
{% blocktrans with date=status.edited_date|published_date %}edited {{ date }}{% endblocktrans %}
</span>
</li>
{% endif %}
{% if status.progress %}
<li class="ml-1">
<span>
{% if status.progress_mode == 'PG' %}
{% include 'snippets/page_text.html' with page=status.progress total_pages=status.book.pages %}
{% else %}
{{ status.progress }}%
{% endif %}
</span>
</li>
{% endif %}
<li>
{% include 'snippets/privacy-icons.html' with item=status %}
</li>
</ul>
</div>
</div>
</div>

View file

@ -67,7 +67,7 @@
{% endblock %}
{% block card-bonus %}
{% if request.user.is_authenticated and not moderation_mode %}
{% if request.user.is_authenticated and not moderation_mode and not no_interact %}
{% with status.id|uuid as uuid %}
<section class="reply-panel is-hidden" id="show_comment_{{ status.id }}">
<div class="card-footer">

View file

@ -20,12 +20,9 @@
</li>
{% if status.status_type != 'GeneratedNote' and status.status_type != 'Rating' %}
<li role="menuitem" class="dropdown-item p-0">
<form class="" name="delete-{{ status.id }}" action="{% url 'redraft' status.id %}" method="post">
{% csrf_token %}
<button class="button is-radiusless is-danger is-light is-fullwidth is-small" type="submit">
{% trans "Delete & re-draft" %}
</button>
</form>
<a href="{% url 'edit-status' status.id %}" class="button is-radiusless is-fullwidth is-small" type="submit">
{% trans "Edit" %}
</a>
</li>
{% endif %}
{% else %}

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

@ -3,11 +3,13 @@ from unittest.mock import patch
from io import BytesIO
import pathlib
from PIL import Image
from django.http import Http404
from django.core.files.base import ContentFile
from django.db import IntegrityError
from django.contrib.auth.models import AnonymousUser
from django.test import TestCase
from django.utils import timezone
from PIL import Image
import responses
from bookwyrm import activitypub, models, settings
@ -50,6 +52,9 @@ class Status(TestCase):
image.save(output, format=image.format)
self.book.cover.save("test.jpg", ContentFile(output.getvalue()))
self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False
def test_status_generated_fields(self, *_):
"""setting remote id"""
status = models.Status.objects.create(content="bleh", user=self.local_user)
@ -460,3 +465,60 @@ class Status(TestCase):
responses.add(responses.GET, "http://fish.com/nothing", status=404)
self.assertTrue(models.Status.ignore_activity(activity))
def test_raise_visible_to_user_public(self, *_):
"""privacy settings"""
status = models.Status.objects.create(
content="bleh", user=self.local_user, privacy="public"
)
self.assertIsNone(status.raise_visible_to_user(self.remote_user))
self.assertIsNone(status.raise_visible_to_user(self.local_user))
self.assertIsNone(status.raise_visible_to_user(self.anonymous_user))
def test_raise_visible_to_user_unlisted(self, *_):
"""privacy settings"""
status = models.Status.objects.create(
content="bleh", user=self.local_user, privacy="unlisted"
)
self.assertIsNone(status.raise_visible_to_user(self.remote_user))
self.assertIsNone(status.raise_visible_to_user(self.local_user))
self.assertIsNone(status.raise_visible_to_user(self.anonymous_user))
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
def test_raise_visible_to_user_followers(self, *_):
"""privacy settings"""
status = models.Status.objects.create(
content="bleh", user=self.local_user, privacy="followers"
)
status.raise_visible_to_user(self.local_user)
with self.assertRaises(Http404):
status.raise_visible_to_user(self.remote_user)
with self.assertRaises(Http404):
status.raise_visible_to_user(self.anonymous_user)
self.local_user.followers.add(self.remote_user)
self.assertIsNone(status.raise_visible_to_user(self.remote_user))
def test_raise_visible_to_user_followers_mentioned(self, *_):
"""privacy settings"""
status = models.Status.objects.create(
content="bleh", user=self.local_user, privacy="followers"
)
status.mention_users.set([self.remote_user])
self.assertIsNone(status.raise_visible_to_user(self.remote_user))
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
def test_raise_visible_to_user_direct(self, *_):
"""privacy settings"""
status = models.Status.objects.create(
content="bleh", user=self.local_user, privacy="direct"
)
status.raise_visible_to_user(self.local_user)
with self.assertRaises(Http404):
status.raise_visible_to_user(self.remote_user)
with self.assertRaises(Http404):
status.raise_visible_to_user(self.anonymous_user)
# mentioned user
status.mention_users.set([self.remote_user])
self.assertIsNone(status.raise_visible_to_user(self.remote_user))

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

@ -37,9 +37,9 @@ class InboxUpdate(TestCase):
outbox="https://example.com/users/rat/outbox",
)
self.create_json = {
self.update_json = {
"id": "hi",
"type": "Create",
"type": "Update",
"actor": "hi",
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
@ -54,26 +54,20 @@ class InboxUpdate(TestCase):
book_list = models.List.objects.create(
name="hi", remote_id="https://example.com/list/22", user=self.local_user
)
activity = {
"type": "Update",
"to": [],
"cc": [],
"actor": "hi",
"id": "sdkjf",
"object": {
"id": "https://example.com/list/22",
"type": "BookList",
"totalItems": 1,
"first": "https://example.com/list/22?page=1",
"last": "https://example.com/list/22?page=1",
"name": "Test List",
"owner": "https://example.com/user/mouse",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
"summary": "summary text",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams",
},
activity = self.update_json
activity["object"] = {
"id": "https://example.com/list/22",
"type": "BookList",
"totalItems": 1,
"first": "https://example.com/list/22?page=1",
"last": "https://example.com/list/22?page=1",
"name": "Test List",
"owner": "https://example.com/user/mouse",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
"summary": "summary text",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams",
}
views.inbox.activity_task(activity)
book_list.refresh_from_db()
@ -176,3 +170,26 @@ class InboxUpdate(TestCase):
)
book = models.Work.objects.get(id=book.id)
self.assertEqual(book.title, "Piranesi")
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
@patch("bookwyrm.activitystreams.add_status_task.delay")
def test_update_status(self, *_):
"""edit a status"""
status = models.Status.objects.create(user=self.remote_user, content="hi")
datafile = pathlib.Path(__file__).parent.joinpath("../../data/ap_note.json")
status_data = json.loads(datafile.read_bytes())
status_data["id"] = status.remote_id
status_data["updated"] = "2021-12-13T05:09:29Z"
activity = self.update_json
activity["object"] = status_data
with patch("bookwyrm.activitypub.base_activity.set_related_field.delay"):
views.inbox.activity_task(activity)
status.refresh_from_db()
self.assertEqual(status.content, "test content in note")
self.assertEqual(status.edited_date.year, 2021)
self.assertEqual(status.edited_date.month, 12)
self.assertEqual(status.edited_date.day, 13)

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")

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