Merge pull request #1490 from hughrun/bookwyrm-groups

Bookwyrm groups
This commit is contained in:
Mouse Reeve 2021-10-17 07:54:59 -07:00 committed by GitHub
commit a27a55b40a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 2475 additions and 32 deletions

View file

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

View file

@ -0,0 +1,871 @@
# Generated by Django 3.2.5 on 2021-10-16 06:39
import bookwyrm.models.fields
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0106_user_preferred_language"),
]
operations = [
migrations.CreateModel(
name="Group",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
(
"remote_id",
bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
),
("name", bookwyrm.models.fields.CharField(max_length=100)),
(
"description",
bookwyrm.models.fields.TextField(blank=True, null=True),
),
(
"privacy",
bookwyrm.models.fields.PrivacyField(
choices=[
("public", "Public"),
("unlisted", "Unlisted"),
("followers", "Followers"),
("direct", "Direct"),
],
default="public",
max_length=255,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="GroupMember",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name="GroupMemberInvitation",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
],
),
migrations.RemoveConstraint(
model_name="notification",
name="notification_type_valid",
),
migrations.AlterField(
model_name="list",
name="curation",
field=bookwyrm.models.fields.CharField(
choices=[
("closed", "Closed"),
("open", "Open"),
("curated", "Curated"),
("group", "Group"),
],
default="closed",
max_length=255,
),
),
migrations.AlterField(
model_name="notification",
name="notification_type",
field=models.CharField(
choices=[
("FAVORITE", "Favorite"),
("REPLY", "Reply"),
("MENTION", "Mention"),
("TAG", "Tag"),
("FOLLOW", "Follow"),
("FOLLOW_REQUEST", "Follow Request"),
("BOOST", "Boost"),
("IMPORT", "Import"),
("ADD", "Add"),
("REPORT", "Report"),
("INVITE", "Invite"),
("ACCEPT", "Accept"),
("JOIN", "Join"),
("LEAVE", "Leave"),
("REMOVE", "Remove"),
],
max_length=255,
),
),
migrations.AlterField(
model_name="user",
name="preferred_language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("de-de", "Deutsch (German)"),
("es", "Español (Spanish)"),
("fr-fr", "Français (French)"),
("zh-hans", "简体中文 (Simplified Chinese)"),
("zh-hant", "繁體中文 (Traditional Chinese)"),
],
max_length=255,
null=True,
),
),
migrations.AlterField(
model_name="user",
name="preferred_timezone",
field=models.CharField(
choices=[
("Africa/Abidjan", "Africa/Abidjan"),
("Africa/Accra", "Africa/Accra"),
("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
("Africa/Algiers", "Africa/Algiers"),
("Africa/Asmara", "Africa/Asmara"),
("Africa/Asmera", "Africa/Asmera"),
("Africa/Bamako", "Africa/Bamako"),
("Africa/Bangui", "Africa/Bangui"),
("Africa/Banjul", "Africa/Banjul"),
("Africa/Bissau", "Africa/Bissau"),
("Africa/Blantyre", "Africa/Blantyre"),
("Africa/Brazzaville", "Africa/Brazzaville"),
("Africa/Bujumbura", "Africa/Bujumbura"),
("Africa/Cairo", "Africa/Cairo"),
("Africa/Casablanca", "Africa/Casablanca"),
("Africa/Ceuta", "Africa/Ceuta"),
("Africa/Conakry", "Africa/Conakry"),
("Africa/Dakar", "Africa/Dakar"),
("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
("Africa/Djibouti", "Africa/Djibouti"),
("Africa/Douala", "Africa/Douala"),
("Africa/El_Aaiun", "Africa/El_Aaiun"),
("Africa/Freetown", "Africa/Freetown"),
("Africa/Gaborone", "Africa/Gaborone"),
("Africa/Harare", "Africa/Harare"),
("Africa/Johannesburg", "Africa/Johannesburg"),
("Africa/Juba", "Africa/Juba"),
("Africa/Kampala", "Africa/Kampala"),
("Africa/Khartoum", "Africa/Khartoum"),
("Africa/Kigali", "Africa/Kigali"),
("Africa/Kinshasa", "Africa/Kinshasa"),
("Africa/Lagos", "Africa/Lagos"),
("Africa/Libreville", "Africa/Libreville"),
("Africa/Lome", "Africa/Lome"),
("Africa/Luanda", "Africa/Luanda"),
("Africa/Lubumbashi", "Africa/Lubumbashi"),
("Africa/Lusaka", "Africa/Lusaka"),
("Africa/Malabo", "Africa/Malabo"),
("Africa/Maputo", "Africa/Maputo"),
("Africa/Maseru", "Africa/Maseru"),
("Africa/Mbabane", "Africa/Mbabane"),
("Africa/Mogadishu", "Africa/Mogadishu"),
("Africa/Monrovia", "Africa/Monrovia"),
("Africa/Nairobi", "Africa/Nairobi"),
("Africa/Ndjamena", "Africa/Ndjamena"),
("Africa/Niamey", "Africa/Niamey"),
("Africa/Nouakchott", "Africa/Nouakchott"),
("Africa/Ouagadougou", "Africa/Ouagadougou"),
("Africa/Porto-Novo", "Africa/Porto-Novo"),
("Africa/Sao_Tome", "Africa/Sao_Tome"),
("Africa/Timbuktu", "Africa/Timbuktu"),
("Africa/Tripoli", "Africa/Tripoli"),
("Africa/Tunis", "Africa/Tunis"),
("Africa/Windhoek", "Africa/Windhoek"),
("America/Adak", "America/Adak"),
("America/Anchorage", "America/Anchorage"),
("America/Anguilla", "America/Anguilla"),
("America/Antigua", "America/Antigua"),
("America/Araguaina", "America/Araguaina"),
(
"America/Argentina/Buenos_Aires",
"America/Argentina/Buenos_Aires",
),
("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
(
"America/Argentina/ComodRivadavia",
"America/Argentina/ComodRivadavia",
),
("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
(
"America/Argentina/Rio_Gallegos",
"America/Argentina/Rio_Gallegos",
),
("America/Argentina/Salta", "America/Argentina/Salta"),
("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
("America/Aruba", "America/Aruba"),
("America/Asuncion", "America/Asuncion"),
("America/Atikokan", "America/Atikokan"),
("America/Atka", "America/Atka"),
("America/Bahia", "America/Bahia"),
("America/Bahia_Banderas", "America/Bahia_Banderas"),
("America/Barbados", "America/Barbados"),
("America/Belem", "America/Belem"),
("America/Belize", "America/Belize"),
("America/Blanc-Sablon", "America/Blanc-Sablon"),
("America/Boa_Vista", "America/Boa_Vista"),
("America/Bogota", "America/Bogota"),
("America/Boise", "America/Boise"),
("America/Buenos_Aires", "America/Buenos_Aires"),
("America/Cambridge_Bay", "America/Cambridge_Bay"),
("America/Campo_Grande", "America/Campo_Grande"),
("America/Cancun", "America/Cancun"),
("America/Caracas", "America/Caracas"),
("America/Catamarca", "America/Catamarca"),
("America/Cayenne", "America/Cayenne"),
("America/Cayman", "America/Cayman"),
("America/Chicago", "America/Chicago"),
("America/Chihuahua", "America/Chihuahua"),
("America/Coral_Harbour", "America/Coral_Harbour"),
("America/Cordoba", "America/Cordoba"),
("America/Costa_Rica", "America/Costa_Rica"),
("America/Creston", "America/Creston"),
("America/Cuiaba", "America/Cuiaba"),
("America/Curacao", "America/Curacao"),
("America/Danmarkshavn", "America/Danmarkshavn"),
("America/Dawson", "America/Dawson"),
("America/Dawson_Creek", "America/Dawson_Creek"),
("America/Denver", "America/Denver"),
("America/Detroit", "America/Detroit"),
("America/Dominica", "America/Dominica"),
("America/Edmonton", "America/Edmonton"),
("America/Eirunepe", "America/Eirunepe"),
("America/El_Salvador", "America/El_Salvador"),
("America/Ensenada", "America/Ensenada"),
("America/Fort_Nelson", "America/Fort_Nelson"),
("America/Fort_Wayne", "America/Fort_Wayne"),
("America/Fortaleza", "America/Fortaleza"),
("America/Glace_Bay", "America/Glace_Bay"),
("America/Godthab", "America/Godthab"),
("America/Goose_Bay", "America/Goose_Bay"),
("America/Grand_Turk", "America/Grand_Turk"),
("America/Grenada", "America/Grenada"),
("America/Guadeloupe", "America/Guadeloupe"),
("America/Guatemala", "America/Guatemala"),
("America/Guayaquil", "America/Guayaquil"),
("America/Guyana", "America/Guyana"),
("America/Halifax", "America/Halifax"),
("America/Havana", "America/Havana"),
("America/Hermosillo", "America/Hermosillo"),
("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
("America/Indiana/Knox", "America/Indiana/Knox"),
("America/Indiana/Marengo", "America/Indiana/Marengo"),
("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
("America/Indiana/Vevay", "America/Indiana/Vevay"),
("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
("America/Indiana/Winamac", "America/Indiana/Winamac"),
("America/Indianapolis", "America/Indianapolis"),
("America/Inuvik", "America/Inuvik"),
("America/Iqaluit", "America/Iqaluit"),
("America/Jamaica", "America/Jamaica"),
("America/Jujuy", "America/Jujuy"),
("America/Juneau", "America/Juneau"),
("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
("America/Knox_IN", "America/Knox_IN"),
("America/Kralendijk", "America/Kralendijk"),
("America/La_Paz", "America/La_Paz"),
("America/Lima", "America/Lima"),
("America/Los_Angeles", "America/Los_Angeles"),
("America/Louisville", "America/Louisville"),
("America/Lower_Princes", "America/Lower_Princes"),
("America/Maceio", "America/Maceio"),
("America/Managua", "America/Managua"),
("America/Manaus", "America/Manaus"),
("America/Marigot", "America/Marigot"),
("America/Martinique", "America/Martinique"),
("America/Matamoros", "America/Matamoros"),
("America/Mazatlan", "America/Mazatlan"),
("America/Mendoza", "America/Mendoza"),
("America/Menominee", "America/Menominee"),
("America/Merida", "America/Merida"),
("America/Metlakatla", "America/Metlakatla"),
("America/Mexico_City", "America/Mexico_City"),
("America/Miquelon", "America/Miquelon"),
("America/Moncton", "America/Moncton"),
("America/Monterrey", "America/Monterrey"),
("America/Montevideo", "America/Montevideo"),
("America/Montreal", "America/Montreal"),
("America/Montserrat", "America/Montserrat"),
("America/Nassau", "America/Nassau"),
("America/New_York", "America/New_York"),
("America/Nipigon", "America/Nipigon"),
("America/Nome", "America/Nome"),
("America/Noronha", "America/Noronha"),
("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
("America/North_Dakota/Center", "America/North_Dakota/Center"),
(
"America/North_Dakota/New_Salem",
"America/North_Dakota/New_Salem",
),
("America/Nuuk", "America/Nuuk"),
("America/Ojinaga", "America/Ojinaga"),
("America/Panama", "America/Panama"),
("America/Pangnirtung", "America/Pangnirtung"),
("America/Paramaribo", "America/Paramaribo"),
("America/Phoenix", "America/Phoenix"),
("America/Port-au-Prince", "America/Port-au-Prince"),
("America/Port_of_Spain", "America/Port_of_Spain"),
("America/Porto_Acre", "America/Porto_Acre"),
("America/Porto_Velho", "America/Porto_Velho"),
("America/Puerto_Rico", "America/Puerto_Rico"),
("America/Punta_Arenas", "America/Punta_Arenas"),
("America/Rainy_River", "America/Rainy_River"),
("America/Rankin_Inlet", "America/Rankin_Inlet"),
("America/Recife", "America/Recife"),
("America/Regina", "America/Regina"),
("America/Resolute", "America/Resolute"),
("America/Rio_Branco", "America/Rio_Branco"),
("America/Rosario", "America/Rosario"),
("America/Santa_Isabel", "America/Santa_Isabel"),
("America/Santarem", "America/Santarem"),
("America/Santiago", "America/Santiago"),
("America/Santo_Domingo", "America/Santo_Domingo"),
("America/Sao_Paulo", "America/Sao_Paulo"),
("America/Scoresbysund", "America/Scoresbysund"),
("America/Shiprock", "America/Shiprock"),
("America/Sitka", "America/Sitka"),
("America/St_Barthelemy", "America/St_Barthelemy"),
("America/St_Johns", "America/St_Johns"),
("America/St_Kitts", "America/St_Kitts"),
("America/St_Lucia", "America/St_Lucia"),
("America/St_Thomas", "America/St_Thomas"),
("America/St_Vincent", "America/St_Vincent"),
("America/Swift_Current", "America/Swift_Current"),
("America/Tegucigalpa", "America/Tegucigalpa"),
("America/Thule", "America/Thule"),
("America/Thunder_Bay", "America/Thunder_Bay"),
("America/Tijuana", "America/Tijuana"),
("America/Toronto", "America/Toronto"),
("America/Tortola", "America/Tortola"),
("America/Vancouver", "America/Vancouver"),
("America/Virgin", "America/Virgin"),
("America/Whitehorse", "America/Whitehorse"),
("America/Winnipeg", "America/Winnipeg"),
("America/Yakutat", "America/Yakutat"),
("America/Yellowknife", "America/Yellowknife"),
("Antarctica/Casey", "Antarctica/Casey"),
("Antarctica/Davis", "Antarctica/Davis"),
("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
("Antarctica/Macquarie", "Antarctica/Macquarie"),
("Antarctica/Mawson", "Antarctica/Mawson"),
("Antarctica/McMurdo", "Antarctica/McMurdo"),
("Antarctica/Palmer", "Antarctica/Palmer"),
("Antarctica/Rothera", "Antarctica/Rothera"),
("Antarctica/South_Pole", "Antarctica/South_Pole"),
("Antarctica/Syowa", "Antarctica/Syowa"),
("Antarctica/Troll", "Antarctica/Troll"),
("Antarctica/Vostok", "Antarctica/Vostok"),
("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
("Asia/Aden", "Asia/Aden"),
("Asia/Almaty", "Asia/Almaty"),
("Asia/Amman", "Asia/Amman"),
("Asia/Anadyr", "Asia/Anadyr"),
("Asia/Aqtau", "Asia/Aqtau"),
("Asia/Aqtobe", "Asia/Aqtobe"),
("Asia/Ashgabat", "Asia/Ashgabat"),
("Asia/Ashkhabad", "Asia/Ashkhabad"),
("Asia/Atyrau", "Asia/Atyrau"),
("Asia/Baghdad", "Asia/Baghdad"),
("Asia/Bahrain", "Asia/Bahrain"),
("Asia/Baku", "Asia/Baku"),
("Asia/Bangkok", "Asia/Bangkok"),
("Asia/Barnaul", "Asia/Barnaul"),
("Asia/Beirut", "Asia/Beirut"),
("Asia/Bishkek", "Asia/Bishkek"),
("Asia/Brunei", "Asia/Brunei"),
("Asia/Calcutta", "Asia/Calcutta"),
("Asia/Chita", "Asia/Chita"),
("Asia/Choibalsan", "Asia/Choibalsan"),
("Asia/Chongqing", "Asia/Chongqing"),
("Asia/Chungking", "Asia/Chungking"),
("Asia/Colombo", "Asia/Colombo"),
("Asia/Dacca", "Asia/Dacca"),
("Asia/Damascus", "Asia/Damascus"),
("Asia/Dhaka", "Asia/Dhaka"),
("Asia/Dili", "Asia/Dili"),
("Asia/Dubai", "Asia/Dubai"),
("Asia/Dushanbe", "Asia/Dushanbe"),
("Asia/Famagusta", "Asia/Famagusta"),
("Asia/Gaza", "Asia/Gaza"),
("Asia/Harbin", "Asia/Harbin"),
("Asia/Hebron", "Asia/Hebron"),
("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
("Asia/Hong_Kong", "Asia/Hong_Kong"),
("Asia/Hovd", "Asia/Hovd"),
("Asia/Irkutsk", "Asia/Irkutsk"),
("Asia/Istanbul", "Asia/Istanbul"),
("Asia/Jakarta", "Asia/Jakarta"),
("Asia/Jayapura", "Asia/Jayapura"),
("Asia/Jerusalem", "Asia/Jerusalem"),
("Asia/Kabul", "Asia/Kabul"),
("Asia/Kamchatka", "Asia/Kamchatka"),
("Asia/Karachi", "Asia/Karachi"),
("Asia/Kashgar", "Asia/Kashgar"),
("Asia/Kathmandu", "Asia/Kathmandu"),
("Asia/Katmandu", "Asia/Katmandu"),
("Asia/Khandyga", "Asia/Khandyga"),
("Asia/Kolkata", "Asia/Kolkata"),
("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
("Asia/Kuching", "Asia/Kuching"),
("Asia/Kuwait", "Asia/Kuwait"),
("Asia/Macao", "Asia/Macao"),
("Asia/Macau", "Asia/Macau"),
("Asia/Magadan", "Asia/Magadan"),
("Asia/Makassar", "Asia/Makassar"),
("Asia/Manila", "Asia/Manila"),
("Asia/Muscat", "Asia/Muscat"),
("Asia/Nicosia", "Asia/Nicosia"),
("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
("Asia/Novosibirsk", "Asia/Novosibirsk"),
("Asia/Omsk", "Asia/Omsk"),
("Asia/Oral", "Asia/Oral"),
("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
("Asia/Pontianak", "Asia/Pontianak"),
("Asia/Pyongyang", "Asia/Pyongyang"),
("Asia/Qatar", "Asia/Qatar"),
("Asia/Qostanay", "Asia/Qostanay"),
("Asia/Qyzylorda", "Asia/Qyzylorda"),
("Asia/Rangoon", "Asia/Rangoon"),
("Asia/Riyadh", "Asia/Riyadh"),
("Asia/Saigon", "Asia/Saigon"),
("Asia/Sakhalin", "Asia/Sakhalin"),
("Asia/Samarkand", "Asia/Samarkand"),
("Asia/Seoul", "Asia/Seoul"),
("Asia/Shanghai", "Asia/Shanghai"),
("Asia/Singapore", "Asia/Singapore"),
("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
("Asia/Taipei", "Asia/Taipei"),
("Asia/Tashkent", "Asia/Tashkent"),
("Asia/Tbilisi", "Asia/Tbilisi"),
("Asia/Tehran", "Asia/Tehran"),
("Asia/Tel_Aviv", "Asia/Tel_Aviv"),
("Asia/Thimbu", "Asia/Thimbu"),
("Asia/Thimphu", "Asia/Thimphu"),
("Asia/Tokyo", "Asia/Tokyo"),
("Asia/Tomsk", "Asia/Tomsk"),
("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"),
("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
("Asia/Ulan_Bator", "Asia/Ulan_Bator"),
("Asia/Urumqi", "Asia/Urumqi"),
("Asia/Ust-Nera", "Asia/Ust-Nera"),
("Asia/Vientiane", "Asia/Vientiane"),
("Asia/Vladivostok", "Asia/Vladivostok"),
("Asia/Yakutsk", "Asia/Yakutsk"),
("Asia/Yangon", "Asia/Yangon"),
("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
("Asia/Yerevan", "Asia/Yerevan"),
("Atlantic/Azores", "Atlantic/Azores"),
("Atlantic/Bermuda", "Atlantic/Bermuda"),
("Atlantic/Canary", "Atlantic/Canary"),
("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
("Atlantic/Faeroe", "Atlantic/Faeroe"),
("Atlantic/Faroe", "Atlantic/Faroe"),
("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"),
("Atlantic/Madeira", "Atlantic/Madeira"),
("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
("Atlantic/St_Helena", "Atlantic/St_Helena"),
("Atlantic/Stanley", "Atlantic/Stanley"),
("Australia/ACT", "Australia/ACT"),
("Australia/Adelaide", "Australia/Adelaide"),
("Australia/Brisbane", "Australia/Brisbane"),
("Australia/Broken_Hill", "Australia/Broken_Hill"),
("Australia/Canberra", "Australia/Canberra"),
("Australia/Currie", "Australia/Currie"),
("Australia/Darwin", "Australia/Darwin"),
("Australia/Eucla", "Australia/Eucla"),
("Australia/Hobart", "Australia/Hobart"),
("Australia/LHI", "Australia/LHI"),
("Australia/Lindeman", "Australia/Lindeman"),
("Australia/Lord_Howe", "Australia/Lord_Howe"),
("Australia/Melbourne", "Australia/Melbourne"),
("Australia/NSW", "Australia/NSW"),
("Australia/North", "Australia/North"),
("Australia/Perth", "Australia/Perth"),
("Australia/Queensland", "Australia/Queensland"),
("Australia/South", "Australia/South"),
("Australia/Sydney", "Australia/Sydney"),
("Australia/Tasmania", "Australia/Tasmania"),
("Australia/Victoria", "Australia/Victoria"),
("Australia/West", "Australia/West"),
("Australia/Yancowinna", "Australia/Yancowinna"),
("Brazil/Acre", "Brazil/Acre"),
("Brazil/DeNoronha", "Brazil/DeNoronha"),
("Brazil/East", "Brazil/East"),
("Brazil/West", "Brazil/West"),
("CET", "CET"),
("CST6CDT", "CST6CDT"),
("Canada/Atlantic", "Canada/Atlantic"),
("Canada/Central", "Canada/Central"),
("Canada/Eastern", "Canada/Eastern"),
("Canada/Mountain", "Canada/Mountain"),
("Canada/Newfoundland", "Canada/Newfoundland"),
("Canada/Pacific", "Canada/Pacific"),
("Canada/Saskatchewan", "Canada/Saskatchewan"),
("Canada/Yukon", "Canada/Yukon"),
("Chile/Continental", "Chile/Continental"),
("Chile/EasterIsland", "Chile/EasterIsland"),
("Cuba", "Cuba"),
("EET", "EET"),
("EST", "EST"),
("EST5EDT", "EST5EDT"),
("Egypt", "Egypt"),
("Eire", "Eire"),
("Etc/GMT", "Etc/GMT"),
("Etc/GMT+0", "Etc/GMT+0"),
("Etc/GMT+1", "Etc/GMT+1"),
("Etc/GMT+10", "Etc/GMT+10"),
("Etc/GMT+11", "Etc/GMT+11"),
("Etc/GMT+12", "Etc/GMT+12"),
("Etc/GMT+2", "Etc/GMT+2"),
("Etc/GMT+3", "Etc/GMT+3"),
("Etc/GMT+4", "Etc/GMT+4"),
("Etc/GMT+5", "Etc/GMT+5"),
("Etc/GMT+6", "Etc/GMT+6"),
("Etc/GMT+7", "Etc/GMT+7"),
("Etc/GMT+8", "Etc/GMT+8"),
("Etc/GMT+9", "Etc/GMT+9"),
("Etc/GMT-0", "Etc/GMT-0"),
("Etc/GMT-1", "Etc/GMT-1"),
("Etc/GMT-10", "Etc/GMT-10"),
("Etc/GMT-11", "Etc/GMT-11"),
("Etc/GMT-12", "Etc/GMT-12"),
("Etc/GMT-13", "Etc/GMT-13"),
("Etc/GMT-14", "Etc/GMT-14"),
("Etc/GMT-2", "Etc/GMT-2"),
("Etc/GMT-3", "Etc/GMT-3"),
("Etc/GMT-4", "Etc/GMT-4"),
("Etc/GMT-5", "Etc/GMT-5"),
("Etc/GMT-6", "Etc/GMT-6"),
("Etc/GMT-7", "Etc/GMT-7"),
("Etc/GMT-8", "Etc/GMT-8"),
("Etc/GMT-9", "Etc/GMT-9"),
("Etc/GMT0", "Etc/GMT0"),
("Etc/Greenwich", "Etc/Greenwich"),
("Etc/UCT", "Etc/UCT"),
("Etc/UTC", "Etc/UTC"),
("Etc/Universal", "Etc/Universal"),
("Etc/Zulu", "Etc/Zulu"),
("Europe/Amsterdam", "Europe/Amsterdam"),
("Europe/Andorra", "Europe/Andorra"),
("Europe/Astrakhan", "Europe/Astrakhan"),
("Europe/Athens", "Europe/Athens"),
("Europe/Belfast", "Europe/Belfast"),
("Europe/Belgrade", "Europe/Belgrade"),
("Europe/Berlin", "Europe/Berlin"),
("Europe/Bratislava", "Europe/Bratislava"),
("Europe/Brussels", "Europe/Brussels"),
("Europe/Bucharest", "Europe/Bucharest"),
("Europe/Budapest", "Europe/Budapest"),
("Europe/Busingen", "Europe/Busingen"),
("Europe/Chisinau", "Europe/Chisinau"),
("Europe/Copenhagen", "Europe/Copenhagen"),
("Europe/Dublin", "Europe/Dublin"),
("Europe/Gibraltar", "Europe/Gibraltar"),
("Europe/Guernsey", "Europe/Guernsey"),
("Europe/Helsinki", "Europe/Helsinki"),
("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
("Europe/Istanbul", "Europe/Istanbul"),
("Europe/Jersey", "Europe/Jersey"),
("Europe/Kaliningrad", "Europe/Kaliningrad"),
("Europe/Kiev", "Europe/Kiev"),
("Europe/Kirov", "Europe/Kirov"),
("Europe/Lisbon", "Europe/Lisbon"),
("Europe/Ljubljana", "Europe/Ljubljana"),
("Europe/London", "Europe/London"),
("Europe/Luxembourg", "Europe/Luxembourg"),
("Europe/Madrid", "Europe/Madrid"),
("Europe/Malta", "Europe/Malta"),
("Europe/Mariehamn", "Europe/Mariehamn"),
("Europe/Minsk", "Europe/Minsk"),
("Europe/Monaco", "Europe/Monaco"),
("Europe/Moscow", "Europe/Moscow"),
("Europe/Nicosia", "Europe/Nicosia"),
("Europe/Oslo", "Europe/Oslo"),
("Europe/Paris", "Europe/Paris"),
("Europe/Podgorica", "Europe/Podgorica"),
("Europe/Prague", "Europe/Prague"),
("Europe/Riga", "Europe/Riga"),
("Europe/Rome", "Europe/Rome"),
("Europe/Samara", "Europe/Samara"),
("Europe/San_Marino", "Europe/San_Marino"),
("Europe/Sarajevo", "Europe/Sarajevo"),
("Europe/Saratov", "Europe/Saratov"),
("Europe/Simferopol", "Europe/Simferopol"),
("Europe/Skopje", "Europe/Skopje"),
("Europe/Sofia", "Europe/Sofia"),
("Europe/Stockholm", "Europe/Stockholm"),
("Europe/Tallinn", "Europe/Tallinn"),
("Europe/Tirane", "Europe/Tirane"),
("Europe/Tiraspol", "Europe/Tiraspol"),
("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
("Europe/Uzhgorod", "Europe/Uzhgorod"),
("Europe/Vaduz", "Europe/Vaduz"),
("Europe/Vatican", "Europe/Vatican"),
("Europe/Vienna", "Europe/Vienna"),
("Europe/Vilnius", "Europe/Vilnius"),
("Europe/Volgograd", "Europe/Volgograd"),
("Europe/Warsaw", "Europe/Warsaw"),
("Europe/Zagreb", "Europe/Zagreb"),
("Europe/Zaporozhye", "Europe/Zaporozhye"),
("Europe/Zurich", "Europe/Zurich"),
("GB", "GB"),
("GB-Eire", "GB-Eire"),
("GMT", "GMT"),
("GMT+0", "GMT+0"),
("GMT-0", "GMT-0"),
("GMT0", "GMT0"),
("Greenwich", "Greenwich"),
("HST", "HST"),
("Hongkong", "Hongkong"),
("Iceland", "Iceland"),
("Indian/Antananarivo", "Indian/Antananarivo"),
("Indian/Chagos", "Indian/Chagos"),
("Indian/Christmas", "Indian/Christmas"),
("Indian/Cocos", "Indian/Cocos"),
("Indian/Comoro", "Indian/Comoro"),
("Indian/Kerguelen", "Indian/Kerguelen"),
("Indian/Mahe", "Indian/Mahe"),
("Indian/Maldives", "Indian/Maldives"),
("Indian/Mauritius", "Indian/Mauritius"),
("Indian/Mayotte", "Indian/Mayotte"),
("Indian/Reunion", "Indian/Reunion"),
("Iran", "Iran"),
("Israel", "Israel"),
("Jamaica", "Jamaica"),
("Japan", "Japan"),
("Kwajalein", "Kwajalein"),
("Libya", "Libya"),
("MET", "MET"),
("MST", "MST"),
("MST7MDT", "MST7MDT"),
("Mexico/BajaNorte", "Mexico/BajaNorte"),
("Mexico/BajaSur", "Mexico/BajaSur"),
("Mexico/General", "Mexico/General"),
("NZ", "NZ"),
("NZ-CHAT", "NZ-CHAT"),
("Navajo", "Navajo"),
("PRC", "PRC"),
("PST8PDT", "PST8PDT"),
("Pacific/Apia", "Pacific/Apia"),
("Pacific/Auckland", "Pacific/Auckland"),
("Pacific/Bougainville", "Pacific/Bougainville"),
("Pacific/Chatham", "Pacific/Chatham"),
("Pacific/Chuuk", "Pacific/Chuuk"),
("Pacific/Easter", "Pacific/Easter"),
("Pacific/Efate", "Pacific/Efate"),
("Pacific/Enderbury", "Pacific/Enderbury"),
("Pacific/Fakaofo", "Pacific/Fakaofo"),
("Pacific/Fiji", "Pacific/Fiji"),
("Pacific/Funafuti", "Pacific/Funafuti"),
("Pacific/Galapagos", "Pacific/Galapagos"),
("Pacific/Gambier", "Pacific/Gambier"),
("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
("Pacific/Guam", "Pacific/Guam"),
("Pacific/Honolulu", "Pacific/Honolulu"),
("Pacific/Johnston", "Pacific/Johnston"),
("Pacific/Kanton", "Pacific/Kanton"),
("Pacific/Kiritimati", "Pacific/Kiritimati"),
("Pacific/Kosrae", "Pacific/Kosrae"),
("Pacific/Kwajalein", "Pacific/Kwajalein"),
("Pacific/Majuro", "Pacific/Majuro"),
("Pacific/Marquesas", "Pacific/Marquesas"),
("Pacific/Midway", "Pacific/Midway"),
("Pacific/Nauru", "Pacific/Nauru"),
("Pacific/Niue", "Pacific/Niue"),
("Pacific/Norfolk", "Pacific/Norfolk"),
("Pacific/Noumea", "Pacific/Noumea"),
("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
("Pacific/Palau", "Pacific/Palau"),
("Pacific/Pitcairn", "Pacific/Pitcairn"),
("Pacific/Pohnpei", "Pacific/Pohnpei"),
("Pacific/Ponape", "Pacific/Ponape"),
("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
("Pacific/Rarotonga", "Pacific/Rarotonga"),
("Pacific/Saipan", "Pacific/Saipan"),
("Pacific/Samoa", "Pacific/Samoa"),
("Pacific/Tahiti", "Pacific/Tahiti"),
("Pacific/Tarawa", "Pacific/Tarawa"),
("Pacific/Tongatapu", "Pacific/Tongatapu"),
("Pacific/Truk", "Pacific/Truk"),
("Pacific/Wake", "Pacific/Wake"),
("Pacific/Wallis", "Pacific/Wallis"),
("Pacific/Yap", "Pacific/Yap"),
("Poland", "Poland"),
("Portugal", "Portugal"),
("ROC", "ROC"),
("ROK", "ROK"),
("Singapore", "Singapore"),
("Turkey", "Turkey"),
("UCT", "UCT"),
("US/Alaska", "US/Alaska"),
("US/Aleutian", "US/Aleutian"),
("US/Arizona", "US/Arizona"),
("US/Central", "US/Central"),
("US/East-Indiana", "US/East-Indiana"),
("US/Eastern", "US/Eastern"),
("US/Hawaii", "US/Hawaii"),
("US/Indiana-Starke", "US/Indiana-Starke"),
("US/Michigan", "US/Michigan"),
("US/Mountain", "US/Mountain"),
("US/Pacific", "US/Pacific"),
("US/Samoa", "US/Samoa"),
("UTC", "UTC"),
("Universal", "Universal"),
("W-SU", "W-SU"),
("WET", "WET"),
("Zulu", "Zulu"),
],
default="UTC",
max_length=255,
),
),
migrations.AddConstraint(
model_name="notification",
constraint=models.CheckConstraint(
check=models.Q(
(
"notification_type__in",
[
"FAVORITE",
"REPLY",
"MENTION",
"TAG",
"FOLLOW",
"FOLLOW_REQUEST",
"BOOST",
"IMPORT",
"ADD",
"REPORT",
"INVITE",
"ACCEPT",
"JOIN",
"LEAVE",
"REMOVE",
],
)
),
name="notification_type_valid",
),
),
migrations.AddField(
model_name="groupmemberinvitation",
name="group",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_invitations",
to="bookwyrm.group",
),
),
migrations.AddField(
model_name="groupmemberinvitation",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="group_invitations",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="groupmember",
name="group",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="memberships",
to="bookwyrm.group",
),
),
migrations.AddField(
model_name="groupmember",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="memberships",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="group",
name="user",
field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
migrations.AddField(
model_name="list",
name="group",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="bookwyrm.group",
),
),
migrations.AddField(
model_name="notification",
name="related_group",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to="bookwyrm.group",
),
),
migrations.AddConstraint(
model_name="groupmemberinvitation",
constraint=models.UniqueConstraint(
fields=("group", "user"), name="unique_invitation"
),
),
migrations.AddConstraint(
model_name="groupmember",
constraint=models.UniqueConstraint(
fields=("group", "user"), name="unique_membership"
),
),
]

View file

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

View file

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

View file

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

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

@ -0,0 +1,182 @@
""" do book related things with other users """
from django.apps import apps
from django.db import models, IntegrityError, transaction
from django.db.models import Q
from bookwyrm.settings import DOMAIN
from .base_model import BookWyrmModel
from . import fields
from .relationship import UserBlocks
class Group(BookWyrmModel):
"""A group of users"""
name = fields.CharField(max_length=100)
user = fields.ForeignKey("User", on_delete=models.CASCADE)
description = fields.TextField(blank=True, null=True)
privacy = fields.PrivacyField()
def get_remote_id(self):
"""don't want the user to be in there in this case"""
return f"https://{DOMAIN}/group/{self.id}"
@classmethod
def followers_filter(cls, queryset, viewer):
"""Override filter for "followers" privacy level to allow non-following
group members to see the existence of groups and group 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",
)
@ -19,6 +19,9 @@ class Notification(BookWyrmModel):
related_user = models.ForeignKey(
"User", on_delete=models.CASCADE, null=True, related_name="related_user"
)
related_group = models.ForeignKey(
"Group", on_delete=models.CASCADE, null=True, related_name="notifications"
)
related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
related_list_item = models.ForeignKey(
@ -37,6 +40,7 @@ class Notification(BookWyrmModel):
user=self.user,
related_book=self.related_book,
related_user=self.related_user,
related_group=self.related_group,
related_status=self.related_status,
related_import=self.related_import,
related_list_item=self.related_list_item,

View file

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

View file

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

View file

@ -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,35 @@
{% load i18n %}
{% csrf_token %}
<div class="columns">
<div class="column is-two-thirds">
<input type="hidden" name="user" value="{{ request.user.id }}" />
<input type="hidden" name="privacy" value="public" />
<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.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,42 @@
{% 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>No potential members found for "{{ user_query }}"</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

@ -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,14 @@
{% 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' %}
{% 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 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

@ -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

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

View file

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

View file

@ -0,0 +1,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 }}">
{% blocktrans with username=user.localname %} Confirm {% endblocktrans %}
</button>
<button id="hide_submit_button" data-controls="submit_button" class="button is-small" type="button" aria-pressed="false">
{% blocktrans with username=user.localname %} Remove {% endblocktrans %}
</button>
</form>
</div>
</div>
{% endif %}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,135 @@
""" 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_followers_only_groups(self, _):
"""follower-only 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.followers_only_group in rat_groups)
self.assertTrue(self.followers_only_group in badger_groups)
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

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

View file

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

View file

@ -47,6 +47,16 @@ from .follow import follow, unfollow
from .follow import accept_follow_request, delete_follow_request
from .get_started import GetStartedBooks, GetStartedProfile, GetStartedUsers
from .goal import Goal, hide_goal
from .group import (
Group,
UserGroups,
FindUsers,
delete_group,
invite_member,
remove_member,
accept_membership,
reject_membership,
)
from .import_data import Import, ImportStatus
from .inbox import Inbox
from .interaction import Favorite, Unfavorite, Boost, Unboost

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

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

View file

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

View file

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

View file

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