forked from mirrors/bookwyrm
Compare commits
651 commits
Author | SHA1 | Date | |
---|---|---|---|
|
e3471fcc35 | ||
|
2993989d27 | ||
|
7f5d47a36f | ||
|
3aa159bc89 | ||
|
8d082bc189 | ||
|
08231f52ff | ||
|
6584cb6404 | ||
|
b3603c04c5 | ||
|
6d6ab9a531 | ||
|
b744ff7836 | ||
|
482005f304 | ||
|
4de9989d8e | ||
|
d5bbb759e0 | ||
|
077c9bfe46 | ||
|
9d5e113b92 | ||
|
4c050d0999 | ||
|
20f452ebf4 | ||
|
374fdcf467 | ||
|
355e7039f0 | ||
|
c3b35760a2 | ||
|
969db13ff2 | ||
|
05fd30cfcf | ||
|
5e99002aad | ||
|
a053f20961 | ||
|
98ed03b6b4 | ||
|
83ee5a756f | ||
|
af19d728d2 | ||
|
87fe984462 | ||
|
525e2a591d | ||
|
45f2199c71 | ||
|
5e81ec75fb | ||
|
9a9cef7766 | ||
|
0adda36da7 | ||
|
9c03bf782e | ||
|
7905be7de2 | ||
|
fb3c7205af | ||
|
fc3b609ada | ||
|
4e3c346780 | ||
|
74925a379a | ||
|
4e0e6ed5a4 | ||
|
09db4e48f4 | ||
|
c5f5d4d994 | ||
|
4905652e22 | ||
|
4c5d2570ab | ||
|
dfe0656eb4 | ||
|
375c5a8789 | ||
|
1f6fbd8d29 | ||
|
9b4a498661 | ||
|
92dbfec5f8 | ||
|
6848616ff1 | ||
|
007751c8cb | ||
|
23c6019340 | ||
|
77a7dfa924 | ||
|
88b2cffcf2 | ||
|
9d275db322 | ||
|
3e54a5f4a3 | ||
|
0bfe1e9dfc | ||
|
f4226b050f | ||
|
b8ddafffbe | ||
|
0f7317f8fe | ||
|
867981b2a4 | ||
|
6d5923bb8f | ||
|
3ed685e341 | ||
|
9172d7ff4e | ||
|
69f192e78c | ||
|
b2c587e082 | ||
|
efd1fd82a9 | ||
|
ae2006c726 | ||
|
1843959d10 | ||
|
212bd49e6c | ||
|
d837146b66 | ||
|
b564e514fd | ||
|
12541d5f1c | ||
|
d8b2ab74d1 | ||
|
065095776f | ||
|
6d7bb33683 | ||
|
cbd43c42a9 | ||
|
8d2da587d9 | ||
|
39b6364e62 | ||
|
b2775c5160 | ||
|
fd43b56d31 | ||
|
17864da8a2 | ||
|
fdd4691e00 | ||
|
876d9c2695 | ||
|
fd66961ab8 | ||
|
ae8edce197 | ||
|
241169650d | ||
|
23eb1c1b10 | ||
|
643a3509dd | ||
|
a5f9efc2b5 | ||
|
8c0ad7e73d | ||
|
d0b7474744 | ||
|
49e6eb8f68 | ||
|
ba7c39404b | ||
|
80b0206e0d | ||
|
62c7661fb9 | ||
|
22fcb61fb2 | ||
|
6bd9b725e2 | ||
|
eeb1cc7197 | ||
|
3626db3c1a | ||
|
95c043cc92 | ||
|
a4a06fa32c | ||
|
966bec1d18 | ||
|
708dc4d613 | ||
|
a6cb46356f | ||
|
34be995125 | ||
|
676a51411f | ||
|
93ec53f523 | ||
|
3559bb5630 | ||
|
358c507839 | ||
|
64b623df32 | ||
|
d3992802f2 | ||
|
b0d3eaeb40 | ||
|
5a2bf64864 | ||
|
300eea3b94 | ||
|
8b7f664da3 | ||
|
2c394a2518 | ||
|
8ea1171764 | ||
|
9921a1e754 | ||
|
a92bf785dd | ||
|
3c1f95a83a | ||
|
6455476df7 | ||
|
e0fa0b859a | ||
|
ae8fed3e82 | ||
|
51f5c9562d | ||
|
1a6e98b546 | ||
|
fe85784ceb | ||
|
9e803043b2 | ||
|
bada50cee9 | ||
|
b718e01a5c | ||
|
4c09477aa2 | ||
|
ae86829a7e | ||
|
c7261780a8 | ||
|
71cbe611de | ||
|
ec21d20b90 | ||
|
701a644c31 | ||
|
0728864fe0 | ||
|
3ebc800a9b | ||
|
23ff58a62b | ||
|
0666a2d02f | ||
|
b23f4a7e18 | ||
|
7cbf78c5fd | ||
|
00c36de745 | ||
|
85f507d6b9 | ||
|
5cf52cff54 | ||
|
c527e0e411 | ||
|
a1487ccae5 | ||
|
2d7902ff89 | ||
|
dc171776f8 | ||
|
44af09336c | ||
|
566182c046 | ||
|
90277a1697 | ||
|
a6ae55608a | ||
|
27e23e76ae | ||
|
4f24b05d60 | ||
|
6808b70d3f | ||
|
a3b9c621af | ||
|
0166cca0b7 | ||
|
82f87a3ff5 | ||
|
533642bf7e | ||
|
951b611881 | ||
|
88e915409b | ||
|
43cc017b44 | ||
|
9e792a8901 | ||
|
34166b8a2f | ||
|
3f7afc9014 | ||
|
a29db4840c | ||
|
1b53c81351 | ||
|
78ac252dae | ||
|
f2ab890b5a | ||
|
4386d2ddb9 | ||
|
5655b94bad | ||
|
b134c0c2fd | ||
|
f0a87e2a20 | ||
|
7f6a98e764 | ||
|
1cfe3b3f94 | ||
|
45672c2b70 | ||
|
68e3a71b18 | ||
|
09e040ec11 | ||
|
dc9f8fccb7 | ||
|
a701bfcf8e | ||
|
db5f509475 | ||
|
55dc998d03 | ||
|
287b5603d6 | ||
|
f52b8fc028 | ||
|
713391f468 | ||
|
576d0ee189 | ||
|
184a463097 | ||
|
ee78cc7393 | ||
|
ee1850ed15 | ||
|
20e71dc0cd | ||
|
117db78983 | ||
|
a584f077b7 | ||
|
c8fa031c23 | ||
|
2047365d31 | ||
|
ef4e06ad52 | ||
|
9e0d6ed512 | ||
|
0101d2561a | ||
|
a684d86d15 | ||
|
26f0501e2f | ||
|
997a671cfb | ||
|
3ca611fb7c | ||
|
cf58d0ad5c | ||
|
3050b33084 | ||
|
5255abb2af | ||
|
0617b9424b | ||
|
178f26192b | ||
|
0688dfa3aa | ||
|
8b061f9432 | ||
|
7b3b357756 | ||
|
44e68cd0a4 | ||
|
922cc61a5f | ||
|
108981a226 | ||
|
0cf2c07069 | ||
|
ffec47ad9a | ||
|
68dc5962ee | ||
|
da100cd114 | ||
|
159b73d860 | ||
|
819458e82a | ||
|
f2b0b306e9 | ||
|
eee325b662 | ||
|
ea69f9087f | ||
|
8969958e51 | ||
|
2b2aa078ad | ||
|
f4e828e2fb | ||
|
ee973c7d72 | ||
|
78b03efe45 | ||
|
820279166a | ||
|
b3f03164cc | ||
|
ee414598bf | ||
|
81b7dca4b9 | ||
|
13b82c2740 | ||
|
a37f83c458 | ||
|
716e357060 | ||
|
35e6dede09 | ||
|
a2f2104a08 | ||
|
916d6a417d | ||
|
486f70c7fb | ||
|
d848b950dc | ||
|
19202e2cd7 | ||
|
d3f723a07d | ||
|
7169f7ba20 | ||
|
c0db081120 | ||
|
bfb8fc800a | ||
|
e0c0bebf65 | ||
|
6ddf1aad91 | ||
|
f42e863434 | ||
|
488d702473 | ||
|
bf68b70fba | ||
|
e4f94780eb | ||
|
771fa5a00a | ||
|
a0ae96923c | ||
|
74a1697cda | ||
|
3e9cb2acb1 | ||
|
57cba4eb7a | ||
|
c08459cf5d | ||
|
37beb5a8f4 | ||
|
3885ae789b | ||
|
753cd36f86 | ||
|
c7efa23405 | ||
|
739b394ccc | ||
|
b32f3c1b7b | ||
|
5bd25ba740 | ||
|
2f124e00d1 | ||
|
a69a9a401b | ||
|
0c87ee1d4b | ||
|
9f19fb698b | ||
|
bcdee8071c | ||
|
c99fe2bdc3 | ||
|
d2e6dfc07b | ||
|
4fb3cbfc29 | ||
|
d542be943f | ||
|
974c569fc1 | ||
|
306f177d55 | ||
|
4cfa4046a1 | ||
|
3358c233ea | ||
|
4530d4917a | ||
|
822868bf87 | ||
|
5d8404f797 | ||
|
9e6dfb4706 | ||
|
1b4e532f90 | ||
|
5dbee33185 | ||
|
7337f378c0 | ||
|
a4391f35c1 | ||
|
d6767e42fc | ||
|
cf53134577 | ||
|
598a0587cf | ||
|
f2d7bdbf27 | ||
|
594fa5d058 | ||
|
9fa8caba45 | ||
|
02a93bd730 | ||
|
72d6a4ce52 | ||
|
5d25da93d5 | ||
|
d9ac326c29 | ||
|
e0ffcddd3c | ||
|
f2bf52ccb9 | ||
|
dbf925f176 | ||
|
9977b33a8d | ||
|
d4be0ca58b | ||
|
20453a9977 | ||
|
8b4c9483ea | ||
|
cbcd5c7a57 | ||
|
672eee9c9c | ||
|
d7eb118a07 | ||
|
1657f28c5e | ||
|
cc2b774fb5 | ||
|
bcd83ee802 | ||
|
a922b8fd04 | ||
|
e2476d1ad3 | ||
|
3868421bed | ||
|
0c429ee6d7 | ||
|
0c0d0b6299 | ||
|
ad1969162f | ||
|
a751884762 | ||
|
bb7d080f65 | ||
|
35f115bc0a | ||
|
e1e03ebd22 | ||
|
5fbb5c655b | ||
|
34a4c18397 | ||
|
723ec8d461 | ||
|
f26106fffd | ||
|
b0c0af9617 | ||
|
8838875879 | ||
|
81594892ef | ||
|
05f11e68c5 | ||
|
440e2f8806 | ||
|
2b483488aa | ||
|
846963ad18 | ||
|
d8181d6d66 | ||
|
ebf463fc91 | ||
|
3ee3e9a13c | ||
|
0751a56474 | ||
|
a99d482167 | ||
|
f7c6c70c5e | ||
|
12ad88ac29 | ||
|
886448efc4 | ||
|
04ab584082 | ||
|
07daa24a72 | ||
|
89c8aa83f4 | ||
|
38535f811c | ||
|
41ea7db8b6 | ||
|
55f1ce12cf | ||
|
4cdbdd8d0b | ||
|
9422a07414 | ||
|
5d7e6b872a | ||
|
d1d743281a | ||
|
c7c90f9e9b | ||
|
39fb402456 | ||
|
f4dc07b6b9 | ||
|
8e9bacc527 | ||
|
b4222bead4 | ||
|
c0fed31fb0 | ||
|
40bb9112fd | ||
|
62b4133e58 | ||
|
8c92869fc0 | ||
|
2c8fa5cd9b | ||
|
a6883b5b87 | ||
|
043fd54d70 | ||
|
e1ea847441 | ||
|
142ecdf6aa | ||
|
9efd67f6bf | ||
|
81cfcff939 | ||
|
c91b08303b | ||
|
f4aa202292 | ||
|
991461221d | ||
|
1888e8c8ed | ||
|
00d0d9d5de | ||
|
3dbbe0089c | ||
|
ec93d1812a | ||
|
1e3f9246d6 | ||
|
5837c37a32 | ||
|
e90cb52f23 | ||
|
539775f370 | ||
|
6c17aa7630 | ||
|
5cdcac0682 | ||
|
b7111589c3 | ||
|
7d6032e110 | ||
|
ffb4098cfb | ||
|
0f5fd6be15 | ||
|
374dd24fa8 | ||
|
142cc5437a | ||
|
99fc3aaf25 | ||
|
b2b3ba653e | ||
|
202696f913 | ||
|
c82042f506 | ||
|
106ef2e3a4 | ||
|
295d9c42d7 | ||
|
a00ee8a706 | ||
|
f54d4863fe | ||
|
2d516812b4 | ||
|
f5fb5ae045 | ||
|
4d3e709b2a | ||
|
3283302093 | ||
|
3a9ff2c2ea | ||
|
c0380cca5a | ||
|
1d4539c4c0 | ||
|
fbe7e860e8 | ||
|
8259d16ee9 | ||
|
005b69177c | ||
|
c8d3222c33 | ||
|
fd0f739418 | ||
|
8850b68b52 | ||
|
cc015536fa | ||
|
3dfbb3272e | ||
|
6e96c1eee7 | ||
|
e4d7dd7ee4 | ||
|
40319302b7 | ||
|
789626a9da | ||
|
f5c66b5b4a | ||
|
43f62ef5d7 | ||
|
9f67a74340 | ||
|
e71a5e3bdf | ||
|
f4d5b7b4d2 | ||
|
9b0874f889 | ||
|
cdddf73e29 | ||
|
464050deaa | ||
|
fee6ffcbd8 | ||
|
8d0e549480 | ||
|
649ffe571a | ||
|
fcc8b6aaab | ||
|
340b306d2e | ||
|
48c8166e58 | ||
|
43269429ac | ||
|
e15193e100 | ||
|
6b5bebdf78 | ||
|
0870eccad9 | ||
|
2a436800c4 | ||
|
95e9119817 | ||
|
a5571c65bc | ||
|
b511928400 | ||
|
eea7d9d4e5 | ||
|
29a6d74ff2 | ||
|
b001c31f97 | ||
|
c67f92af46 | ||
|
1d99e455e8 | ||
|
8deee2220e | ||
|
ab1c7c6d0a | ||
|
5eb113af6b | ||
|
e9dfa42e11 | ||
|
02808f88e6 | ||
|
65bd3945e7 | ||
|
cec7625e1e | ||
|
5d4efd457a | ||
|
ac36aa9327 | ||
|
ad1d768be9 | ||
|
ee8c1659ab | ||
|
8ca2b55e7e | ||
|
6d1d62cf2f | ||
|
f1f7b21d43 | ||
|
689be8c94b | ||
|
84ef214ca1 | ||
|
eb8b9fdaed | ||
|
1aa6b99d1f | ||
|
f446828175 | ||
|
84b9a19339 | ||
|
93f82fbf18 | ||
|
ad41f19dc5 | ||
|
3ce8b3390e | ||
|
e837da37db | ||
|
12f67dc0ce | ||
|
c8b4d5ecf1 | ||
|
20df7cbdd6 | ||
|
2457315ed8 | ||
|
70601612f8 | ||
|
4672294d7c | ||
|
c77e5a1a90 | ||
|
6daaffeaa7 | ||
|
c5f8715c59 | ||
|
57fd675857 | ||
|
275f3cbedb | ||
|
cd65cfafce | ||
|
7d68c23ce5 | ||
|
1d44c2bf1f | ||
|
3d2ea40ad4 | ||
|
731ab88604 | ||
|
98736925f7 | ||
|
9eb798e932 | ||
|
edf3b61602 | ||
|
1aac665094 | ||
|
64a83b38b5 | ||
|
1b63c19a9c | ||
|
5d098b3c10 | ||
|
31a61713d9 | ||
|
d510299ae4 | ||
|
561eaeaf54 | ||
|
98cad7c51a | ||
|
3869f0cc1a | ||
|
9132c054f2 | ||
|
ebf905e20b | ||
|
92f3357977 | ||
|
31d362d715 | ||
|
3b0fc9785e | ||
|
08e378a539 | ||
|
be479fe4cb | ||
|
f6e2ec02aa | ||
|
1b9688832a | ||
|
bf75dff338 | ||
|
637f7c9cb9 | ||
|
679b55d9ad | ||
|
63558bb75e | ||
|
54b0bf02b5 | ||
|
2883c42534 | ||
|
23d0d3e2b7 | ||
|
ed536e6b41 | ||
|
b4e0749f73 | ||
|
8e3c39d319 | ||
|
4eb4efee9d | ||
|
c31ec7dbd5 | ||
|
e9397eaedd | ||
|
1a8f4a916e | ||
|
0da759bfd5 | ||
|
c04d2d285b | ||
|
d67dac4519 | ||
|
88999168bc | ||
|
a2c4dd4f9f | ||
|
39691bed3a | ||
|
839d91e4d4 | ||
|
cfc1302b23 | ||
|
954e914638 | ||
|
7afb5bc493 | ||
|
862b6f49bd | ||
|
4b2ac4fa10 | ||
|
e23e108e3a | ||
|
c054ccc84b | ||
|
60b2453d4d | ||
|
9038afd7f1 | ||
|
c1853e03ab | ||
|
6f90c80494 | ||
|
1412fa507c | ||
|
d593a3a503 | ||
|
55177990e3 | ||
|
c19b9d7575 | ||
|
f28d60b94f | ||
|
450d4cdace | ||
|
7ff1ad7c83 | ||
|
4428c0f14d | ||
|
09978fc195 | ||
|
714bb081ea | ||
|
a07239c6a9 | ||
|
6fa29a6293 | ||
|
82cb170a91 | ||
|
1e04385f0c | ||
|
d63e5ab2d2 | ||
|
03ff8c248d | ||
|
5ae4eb9b8f | ||
|
0b02287378 | ||
|
526a1c6ef4 | ||
|
54eeeb5798 | ||
|
3c05cecb50 | ||
|
a4b08d7213 | ||
|
5801ef011f | ||
|
27c26b4d16 | ||
|
b601ac6f91 | ||
|
1a2c85a327 | ||
|
5df8bf03e6 | ||
|
7fdf07c6ec | ||
|
c88b34814f | ||
|
164e0686b9 | ||
|
9827cef9a9 | ||
|
1761db5444 | ||
|
72818d4ab2 | ||
|
a2d9bf50c5 | ||
|
df0467a662 | ||
|
6bf6b118bf | ||
|
d49e9b4dcd | ||
|
30ad3bba0b | ||
|
67ea18c840 | ||
|
c33cf60624 | ||
|
7877524116 | ||
|
f742cc023b | ||
|
b27b6a5980 | ||
|
bbb89605a5 | ||
|
6cb480d111 | ||
|
bc89dd7041 | ||
|
89de03bffe | ||
|
b95f0ed287 | ||
|
01b52f023a | ||
|
cfa91e2570 | ||
|
ee23aba994 | ||
|
62741a5d2e | ||
|
059fd84d06 | ||
|
2b27889457 | ||
|
046f516091 | ||
|
542957364c | ||
|
bd846bba34 | ||
|
c06817e9ff | ||
|
6323b0e700 | ||
|
e9e4f70ae4 | ||
|
74fd13fb22 | ||
|
4b88ea142f | ||
|
73b611d68d | ||
|
d65a80d9ea | ||
|
a73960a0da | ||
|
49ceb2a978 | ||
|
a9a6fd1242 | ||
|
28a8edfdc4 | ||
|
bc7aa91b97 | ||
|
28fe0b0bc6 | ||
|
7811a9920e | ||
|
d8c3699adc | ||
|
0601f68685 | ||
|
ea035b9fbe | ||
|
70bd6b9a65 | ||
|
18768a23f3 | ||
|
0c3b6e6938 | ||
|
547d246375 | ||
|
0683ce1c33 | ||
|
fefb7e582a | ||
|
719df5621c | ||
|
82aacf8f2a | ||
|
e80a4c16f0 | ||
|
495af09c4c | ||
|
a3e2cd77a1 | ||
|
da8e07057c | ||
|
7c0d51ed14 | ||
|
3e635f497e | ||
|
58fb9ba0d4 | ||
|
ebc3f14f22 | ||
|
f545184c5b | ||
|
9013b1417a | ||
|
3b12af63b6 | ||
|
582b84ecaa | ||
|
8518b4f877 | ||
|
85aad7c219 | ||
|
cae7191a2b | ||
|
2c7a6e8518 | ||
|
1f6ecc39ac | ||
|
7b5bee8d7b | ||
|
3b48d986d5 | ||
|
5a3ce5e328 | ||
|
a370602903 | ||
|
c58a3ac114 | ||
|
e4b53266b3 | ||
|
a46ab96d9b | ||
|
baf28c523a | ||
|
1b313c2b62 | ||
|
c2c33fe1e8 | ||
|
4503dd6864 | ||
|
e37982d285 | ||
|
060f515aea | ||
|
d6abd9b32d | ||
|
9611815b44 | ||
|
44dad43f36 | ||
|
c4b8e7949d | ||
|
224dc4100a | ||
|
0c53f4e003 | ||
|
a1a3aa45f4 | ||
|
9e6390662b | ||
|
766a0cc652 | ||
|
6f5115c716 | ||
|
284eb620dd |
469 changed files with 38909 additions and 13339 deletions
21
.env.example
21
.env.example
|
@ -41,7 +41,7 @@ REDIS_BROKER_PASSWORD=redispassword123
|
||||||
|
|
||||||
# Monitoring for celery
|
# Monitoring for celery
|
||||||
FLOWER_PORT=8888
|
FLOWER_PORT=8888
|
||||||
FLOWER_USER=mouse
|
FLOWER_USER=admin
|
||||||
FLOWER_PASSWORD=changeme
|
FLOWER_PASSWORD=changeme
|
||||||
|
|
||||||
# Email config
|
# Email config
|
||||||
|
@ -89,3 +89,22 @@ PREVIEW_TEXT_COLOR=#363636
|
||||||
PREVIEW_IMG_WIDTH=1200
|
PREVIEW_IMG_WIDTH=1200
|
||||||
PREVIEW_IMG_HEIGHT=630
|
PREVIEW_IMG_HEIGHT=630
|
||||||
PREVIEW_DEFAULT_COVER_COLOR=#002549
|
PREVIEW_DEFAULT_COVER_COLOR=#002549
|
||||||
|
|
||||||
|
# Below are example keys if you want to enable automatically
|
||||||
|
# sending telemetry to an OTLP-compatible service. Many of
|
||||||
|
# the main monitoring apps have OLTP collectors, including
|
||||||
|
# NewRelic, DataDog, and Honeycomb.io - consult their
|
||||||
|
# documentation for setup instructions, and what exactly to
|
||||||
|
# put below!
|
||||||
|
#
|
||||||
|
# Service name is an arbitrary tag that is attached to any
|
||||||
|
# data sent, used to distinguish different sources. Useful
|
||||||
|
# for sending prod and dev metrics to the same place and
|
||||||
|
# keeping them separate, for instance!
|
||||||
|
|
||||||
|
# API endpoint for your provider
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||||
|
# Any headers required, usually authentication info
|
||||||
|
OTEL_EXPORTER_OTLP_HEADERS=
|
||||||
|
# Service name to identify your app
|
||||||
|
OTEL_SERVICE_NAME=
|
||||||
|
|
13
.github/workflows/lint-frontend.yaml
vendored
13
.github/workflows/lint-frontend.yaml
vendored
|
@ -1,5 +1,5 @@
|
||||||
# @url https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
|
# @url https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
|
||||||
name: Lint Frontend
|
name: Lint Frontend (run `./bw-dev stylelint` to fix css errors)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
@ -8,7 +8,7 @@ on:
|
||||||
- '.github/workflows/**'
|
- '.github/workflows/**'
|
||||||
- 'static/**'
|
- 'static/**'
|
||||||
- '.eslintrc'
|
- '.eslintrc'
|
||||||
- '.stylelintrc'
|
- '.stylelintrc.js'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main, ci, frontend ]
|
branches: [ main, ci, frontend ]
|
||||||
|
|
||||||
|
@ -22,17 +22,16 @@ jobs:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Install modules
|
- name: Install modules
|
||||||
run: yarn
|
run: npm install stylelint stylelint-config-recommended stylelint-config-standard stylelint-order eslint
|
||||||
|
|
||||||
# See .stylelintignore for files that are not linted.
|
# See .stylelintignore for files that are not linted.
|
||||||
- name: Run stylelint
|
- name: Run stylelint
|
||||||
run: >
|
run: >
|
||||||
yarn stylelint bookwyrm/static/**/*.css \
|
npx stylelint bookwyrm/static/css/*.scss bookwyrm/static/css/bookwyrm/**/*.scss \
|
||||||
--report-needless-disables \
|
--config dev-tools/.stylelintrc.js
|
||||||
--report-invalid-scope-disables
|
|
||||||
|
|
||||||
# See .eslintignore for files that are not linted.
|
# See .eslintignore for files that are not linted.
|
||||||
- name: Run ESLint
|
- name: Run ESLint
|
||||||
run: >
|
run: >
|
||||||
yarn eslint bookwyrm/static \
|
npx eslint bookwyrm/static \
|
||||||
--ext .js,.jsx,.ts,.tsx
|
--ext .js,.jsx,.ts,.tsx
|
||||||
|
|
21
.github/workflows/lint-global.yaml
vendored
21
.github/workflows/lint-global.yaml
vendored
|
@ -1,21 +0,0 @@
|
||||||
# @url https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
|
|
||||||
name: Lint project globally
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main, ci ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main, ci ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
name: Lint with EditorConfig.
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
|
|
||||||
# Steps represent a sequence of tasks that will be executed as part of the job
|
|
||||||
steps:
|
|
||||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: EditorConfig
|
|
||||||
uses: greut/eclint-action@v0
|
|
3
.github/workflows/prettier.yaml
vendored
3
.github/workflows/prettier.yaml
vendored
|
@ -17,8 +17,7 @@ jobs:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Install modules
|
- name: Install modules
|
||||||
run: npm install .
|
run: npm install prettier
|
||||||
|
|
||||||
# See .stylelintignore for files that are not linted.
|
|
||||||
- name: Run Prettier
|
- name: Run Prettier
|
||||||
run: npx prettier --check bookwyrm/static/js/*.js
|
run: npx prettier --check bookwyrm/static/js/*.js
|
||||||
|
|
3
.github/workflows/pylint.yml
vendored
3
.github/workflows/pylint.yml
vendored
|
@ -21,8 +21,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
pip install pylint
|
|
||||||
- name: Analysing the code with pylint
|
- name: Analysing the code with pylint
|
||||||
run: |
|
run: |
|
||||||
pylint bookwyrm/ --ignore=migrations,tests --disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801
|
pylint bookwyrm/
|
||||||
|
|
||||||
|
|
7
.gitignore
vendored
7
.gitignore
vendored
|
@ -16,6 +16,9 @@
|
||||||
# BookWyrm
|
# BookWyrm
|
||||||
.env
|
.env
|
||||||
/images/
|
/images/
|
||||||
|
bookwyrm/static/css/bookwyrm.css
|
||||||
|
bookwyrm/static/css/themes/
|
||||||
|
!bookwyrm/static/css/themes/bookwyrm-*.scss
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
.coverage
|
.coverage
|
||||||
|
@ -24,7 +27,9 @@
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
#Node tools
|
#Node tools
|
||||||
/node_modules/
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
#nginx
|
#nginx
|
||||||
nginx/default.conf
|
nginx/default.conf
|
||||||
|
|
1
.prettierignore
Normal file
1
.prettierignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
**/vendor/*
|
6
.pylintrc
Normal file
6
.pylintrc
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
[MAIN]
|
||||||
|
ignore=migrations
|
||||||
|
load-plugins=pylint.extensions.no_self_use
|
||||||
|
|
||||||
|
[MESSAGES CONTROL]
|
||||||
|
disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801,C3001
|
|
@ -6,6 +6,7 @@ RUN mkdir /app /app/static /app/images
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean
|
||||||
|
|
||||||
COPY requirements.txt /app/
|
COPY requirements.txt /app/
|
||||||
RUN pip install -r requirements.txt --no-cache-dir
|
RUN pip install -r requirements.txt --no-cache-dir
|
||||||
RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean
|
|
||||||
|
|
18
README.md
18
README.md
|
@ -9,21 +9,18 @@ Social reading and reviewing, decentralized with ActivityPub
|
||||||
- [What it is and isn't](#what-it-is-and-isnt)
|
- [What it is and isn't](#what-it-is-and-isnt)
|
||||||
- [The role of federation](#the-role-of-federation)
|
- [The role of federation](#the-role-of-federation)
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Book data](#book-data)
|
- [Set up BookWyrm](#set-up-bookwyrm)
|
||||||
- [Set up Bookwyrm](#set-up-bookwyrm)
|
|
||||||
|
|
||||||
## Joining BookWyrm
|
## Joining BookWyrm
|
||||||
BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://docs.joinbookwyrm.com/instances.html) list.
|
If you'd like to join an instance, you can check out the [instances](https://joinbookwyrm.com/instances/) list.
|
||||||
|
|
||||||
You can request an invite by entering your email address at https://bookwyrm.social.
|
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
See [contributing](https://docs.joinbookwyrm.com/how-to-contribute.html) for code, translation or monetary contributions.
|
See [contributing](https://docs.joinbookwyrm.com/contributing.html) for code, translation or monetary contributions.
|
||||||
|
|
||||||
## About BookWyrm
|
## About BookWyrm
|
||||||
### What it is and isn't
|
### What it is and isn't
|
||||||
BookWyrm is a platform for social reading! You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a data-source for books, but it does do both of those things to some degree.
|
BookWyrm is a platform for social reading. You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a data-source for books, but it does do both of those things to some degree.
|
||||||
|
|
||||||
### The role of federation
|
### The role of federation
|
||||||
BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance.
|
BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance.
|
||||||
|
@ -78,8 +75,5 @@ Deployment
|
||||||
- [Nginx](https://nginx.org/en/) HTTP server
|
- [Nginx](https://nginx.org/en/) HTTP server
|
||||||
|
|
||||||
|
|
||||||
## Book data
|
## Set up BookWyrm
|
||||||
The application is set up to share book and author data between instances, and get book data from arbitrary outside sources. Right now, the only connector is to OpenLibrary, but other connectors could be written.
|
The [documentation website](https://docs.joinbookwyrm.com/) has instruction on how to set up BookWyrm in a [developer environment](https://docs.joinbookwyrm.com/install-dev.html) or [production](https://docs.joinbookwyrm.com/install-prod.html).
|
||||||
|
|
||||||
## Set up Bookwyrm
|
|
||||||
The [documentation website](https://docs.joinbookwyrm.com/) has instruction on how to set up Bookwyrm in a [developer environment](https://docs.joinbookwyrm.com/developer-environment.html) or [production](https://docs.joinbookwyrm.com/installing-in-production.html).
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
""" basics for an activitypub serializer """
|
""" basics for an activitypub serializer """
|
||||||
from dataclasses import dataclass, fields, MISSING
|
from dataclasses import dataclass, fields, MISSING
|
||||||
from json import JSONEncoder
|
from json import JSONEncoder
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
|
@ -8,6 +9,8 @@ from django.db import IntegrityError, transaction
|
||||||
from bookwyrm.connectors import ConnectorException, get_data
|
from bookwyrm.connectors import ConnectorException, get_data
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ActivitySerializerError(ValueError):
|
class ActivitySerializerError(ValueError):
|
||||||
"""routine problems serializing activitypub json"""
|
"""routine problems serializing activitypub json"""
|
||||||
|
@ -39,12 +42,12 @@ def naive_parse(activity_objects, activity_json, serializer=None):
|
||||||
activity_json["type"] = "PublicKey"
|
activity_json["type"] = "PublicKey"
|
||||||
|
|
||||||
activity_type = activity_json.get("type")
|
activity_type = activity_json.get("type")
|
||||||
|
if activity_type in ["Question", "Article"]:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
serializer = activity_objects[activity_type]
|
serializer = activity_objects[activity_type]
|
||||||
except KeyError as err:
|
except KeyError as err:
|
||||||
# we know this exists and that we can't handle it
|
# we know this exists and that we can't handle it
|
||||||
if activity_type in ["Question"]:
|
|
||||||
return None
|
|
||||||
raise ActivitySerializerError(err)
|
raise ActivitySerializerError(err)
|
||||||
|
|
||||||
return serializer(activity_objects=activity_objects, **activity_json)
|
return serializer(activity_objects=activity_objects, **activity_json)
|
||||||
|
@ -65,7 +68,7 @@ class ActivityObject:
|
||||||
try:
|
try:
|
||||||
value = kwargs[field.name]
|
value = kwargs[field.name]
|
||||||
if value in (None, MISSING, {}):
|
if value in (None, MISSING, {}):
|
||||||
raise KeyError()
|
raise KeyError("Missing required field", field.name)
|
||||||
try:
|
try:
|
||||||
is_subclass = issubclass(field.type, ActivityObject)
|
is_subclass = issubclass(field.type, ActivityObject)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
|
@ -227,7 +230,7 @@ def set_related_field(
|
||||||
model_field = getattr(model, related_field_name)
|
model_field = getattr(model, related_field_name)
|
||||||
if hasattr(model_field, "activitypub_field"):
|
if hasattr(model_field, "activitypub_field"):
|
||||||
setattr(activity, getattr(model_field, "activitypub_field"), instance.remote_id)
|
setattr(activity, getattr(model_field, "activitypub_field"), instance.remote_id)
|
||||||
item = activity.to_model()
|
item = activity.to_model(model=model)
|
||||||
|
|
||||||
# if the related field isn't serialized (attachments on Status), then
|
# if the related field isn't serialized (attachments on Status), then
|
||||||
# we have to set it post-creation
|
# we have to set it post-creation
|
||||||
|
@ -268,9 +271,9 @@ def resolve_remote_id(
|
||||||
try:
|
try:
|
||||||
data = get_data(remote_id)
|
data = get_data(remote_id)
|
||||||
except ConnectorException:
|
except ConnectorException:
|
||||||
raise ActivitySerializerError(
|
logger.exception("Could not connect to host for remote_id: %s", remote_id)
|
||||||
f"Could not connect to host for remote_id: {remote_id}"
|
return None
|
||||||
)
|
|
||||||
# determine the model implicitly, if not provided
|
# determine the model implicitly, if not provided
|
||||||
# or if it's a model with subclasses like Status, check again
|
# or if it's a model with subclasses like Status, check again
|
||||||
if not model or hasattr(model.objects, "select_subclasses"):
|
if not model or hasattr(model.objects, "select_subclasses"):
|
||||||
|
@ -298,6 +301,7 @@ class Link(ActivityObject):
|
||||||
mediaType: str = None
|
mediaType: str = None
|
||||||
id: str = None
|
id: str = None
|
||||||
attributedTo: str = None
|
attributedTo: str = None
|
||||||
|
availability: str = None
|
||||||
type: str = "Link"
|
type: str = "Link"
|
||||||
|
|
||||||
def serialize(self, **kwargs):
|
def serialize(self, **kwargs):
|
||||||
|
|
|
@ -16,6 +16,9 @@ class BookData(ActivityObject):
|
||||||
librarythingKey: str = None
|
librarythingKey: str = None
|
||||||
goodreadsKey: str = None
|
goodreadsKey: str = None
|
||||||
bnfId: str = None
|
bnfId: str = None
|
||||||
|
viaf: str = None
|
||||||
|
wikidata: str = None
|
||||||
|
asin: str = None
|
||||||
lastEditedBy: str = None
|
lastEditedBy: str = None
|
||||||
links: List[str] = field(default_factory=lambda: [])
|
links: List[str] = field(default_factory=lambda: [])
|
||||||
fileLinks: List[str] = field(default_factory=lambda: [])
|
fileLinks: List[str] = field(default_factory=lambda: [])
|
||||||
|
@ -27,8 +30,8 @@ class Book(BookData):
|
||||||
"""serializes an edition or work, abstract"""
|
"""serializes an edition or work, abstract"""
|
||||||
|
|
||||||
title: str
|
title: str
|
||||||
sortTitle: str = ""
|
sortTitle: str = None
|
||||||
subtitle: str = ""
|
subtitle: str = None
|
||||||
description: str = ""
|
description: str = ""
|
||||||
languages: List[str] = field(default_factory=lambda: [])
|
languages: List[str] = field(default_factory=lambda: [])
|
||||||
series: str = ""
|
series: str = ""
|
||||||
|
@ -53,7 +56,6 @@ class Edition(Book):
|
||||||
isbn10: str = ""
|
isbn10: str = ""
|
||||||
isbn13: str = ""
|
isbn13: str = ""
|
||||||
oclcNumber: str = ""
|
oclcNumber: str = ""
|
||||||
asin: str = ""
|
|
||||||
pages: int = None
|
pages: int = None
|
||||||
physicalFormat: str = ""
|
physicalFormat: str = ""
|
||||||
physicalFormatDetail: str = ""
|
physicalFormatDetail: str = ""
|
||||||
|
|
|
@ -39,4 +39,5 @@ class Person(ActivityObject):
|
||||||
bookwyrmUser: bool = False
|
bookwyrmUser: bool = False
|
||||||
manuallyApprovesFollowers: str = False
|
manuallyApprovesFollowers: str = False
|
||||||
discoverable: str = False
|
discoverable: str = False
|
||||||
|
hideFollows: str = False
|
||||||
type: str = "Person"
|
type: str = "Person"
|
||||||
|
|
|
@ -298,8 +298,9 @@ def add_status_on_create_command(sender, instance, created):
|
||||||
priority = HIGH
|
priority = HIGH
|
||||||
# check if this is an old status, de-prioritize if so
|
# check if this is an old status, de-prioritize if so
|
||||||
# (this will happen if federation is very slow, or, more expectedly, on csv import)
|
# (this will happen if federation is very slow, or, more expectedly, on csv import)
|
||||||
one_day = 60 * 60 * 24
|
if instance.published_date < timezone.now() - timedelta(
|
||||||
if (instance.created_date - instance.published_date).seconds > one_day:
|
days=1
|
||||||
|
) or instance.created_date < instance.published_date - timedelta(days=1):
|
||||||
priority = LOW
|
priority = LOW
|
||||||
|
|
||||||
add_status_task.apply_async(
|
add_status_task.apply_async(
|
||||||
|
|
54
bookwyrm/apps.py
Normal file
54
bookwyrm/apps.py
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
"""Do further startup configuration and initialization"""
|
||||||
|
import os
|
||||||
|
import urllib
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
from bookwyrm import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def download_file(url, destination):
|
||||||
|
"""Downloads a file to the given path"""
|
||||||
|
try:
|
||||||
|
# Ensure our destination directory exists
|
||||||
|
os.makedirs(os.path.dirname(destination))
|
||||||
|
with urllib.request.urlopen(url) as stream:
|
||||||
|
with open(destination, "b+w") as outfile:
|
||||||
|
outfile.write(stream.read())
|
||||||
|
except (urllib.error.HTTPError, urllib.error.URLError):
|
||||||
|
logger.info("Failed to download file %s", url)
|
||||||
|
except OSError:
|
||||||
|
logger.info("Couldn't open font file %s for writing", destination)
|
||||||
|
except: # pylint: disable=bare-except
|
||||||
|
logger.info("Unknown error in file download")
|
||||||
|
|
||||||
|
|
||||||
|
class BookwyrmConfig(AppConfig):
|
||||||
|
"""Handles additional configuration"""
|
||||||
|
|
||||||
|
name = "bookwyrm"
|
||||||
|
verbose_name = "BookWyrm"
|
||||||
|
|
||||||
|
# pylint: disable=no-self-use
|
||||||
|
def ready(self):
|
||||||
|
"""set up OTLP and preview image files, if desired"""
|
||||||
|
if settings.OTEL_EXPORTER_OTLP_ENDPOINT:
|
||||||
|
# pylint: disable=import-outside-toplevel
|
||||||
|
from bookwyrm.telemetry import open_telemetry
|
||||||
|
|
||||||
|
open_telemetry.instrumentDjango()
|
||||||
|
|
||||||
|
if settings.ENABLE_PREVIEW_IMAGES and settings.FONTS:
|
||||||
|
# Download any fonts that we don't have yet
|
||||||
|
logger.debug("Downloading fonts..")
|
||||||
|
for name, config in settings.FONTS.items():
|
||||||
|
font_path = os.path.join(
|
||||||
|
settings.FONT_DIR, config["directory"], config["filename"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if "url" in config and not os.path.exists(font_path):
|
||||||
|
logger.info("Just a sec, downloading %s", name)
|
||||||
|
download_file(config["url"], font_path)
|
|
@ -148,8 +148,8 @@ class SearchResult:
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
# pylint: disable=consider-using-f-string
|
# pylint: disable=consider-using-f-string
|
||||||
return "<SearchResult key={!r} title={!r} author={!r}>".format(
|
return "<SearchResult key={!r} title={!r} author={!r} confidence={!r}>".format(
|
||||||
self.key, self.title, self.author
|
self.key, self.title, self.author, self.confidence
|
||||||
)
|
)
|
||||||
|
|
||||||
def json(self):
|
def json(self):
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
import imghdr
|
import imghdr
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
@ -9,7 +10,7 @@ import requests
|
||||||
from requests.exceptions import RequestException
|
from requests.exceptions import RequestException
|
||||||
|
|
||||||
from bookwyrm import activitypub, models, settings
|
from bookwyrm import activitypub, models, settings
|
||||||
from .connector_manager import load_more_data, ConnectorException
|
from .connector_manager import load_more_data, ConnectorException, raise_not_valid_url
|
||||||
from .format_mappings import format_mappings
|
from .format_mappings import format_mappings
|
||||||
|
|
||||||
|
|
||||||
|
@ -37,62 +38,34 @@ class AbstractMinimalConnector(ABC):
|
||||||
for field in self_fields:
|
for field in self_fields:
|
||||||
setattr(self, field, getattr(info, field))
|
setattr(self, field, getattr(info, field))
|
||||||
|
|
||||||
def search(self, query, min_confidence=None, timeout=settings.QUERY_TIMEOUT):
|
def get_search_url(self, query):
|
||||||
"""free text search"""
|
"""format the query url"""
|
||||||
params = {}
|
# Check if the query resembles an ISBN
|
||||||
if min_confidence:
|
if maybe_isbn(query) and self.isbn_search_url and self.isbn_search_url != "":
|
||||||
params["min_confidence"] = min_confidence
|
return f"{self.isbn_search_url}{query}"
|
||||||
|
|
||||||
data = self.get_search_data(
|
# NOTE: previously, we tried searching isbn and if that produces no results,
|
||||||
f"{self.search_url}{query}",
|
# searched as free text. This, instead, only searches isbn if it's isbn-y
|
||||||
params=params,
|
return f"{self.search_url}{query}"
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
results = []
|
|
||||||
|
|
||||||
for doc in self.parse_search_data(data)[:10]:
|
def process_search_response(self, query, data, min_confidence):
|
||||||
results.append(self.format_search_result(doc))
|
"""Format the search results based on the formt of the query"""
|
||||||
return results
|
if maybe_isbn(query):
|
||||||
|
return list(self.parse_isbn_search_data(data))[:10]
|
||||||
def isbn_search(self, query, timeout=settings.QUERY_TIMEOUT):
|
return list(self.parse_search_data(data, min_confidence))[:10]
|
||||||
"""isbn search"""
|
|
||||||
params = {}
|
|
||||||
data = self.get_search_data(
|
|
||||||
f"{self.isbn_search_url}{query}",
|
|
||||||
params=params,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
results = []
|
|
||||||
|
|
||||||
# this shouldn't be returning mutliple results, but just in case
|
|
||||||
for doc in self.parse_isbn_search_data(data)[:10]:
|
|
||||||
results.append(self.format_isbn_search_result(doc))
|
|
||||||
return results
|
|
||||||
|
|
||||||
def get_search_data(self, remote_id, **kwargs): # pylint: disable=no-self-use
|
|
||||||
"""this allows connectors to override the default behavior"""
|
|
||||||
return get_data(remote_id, **kwargs)
|
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_or_create_book(self, remote_id):
|
def get_or_create_book(self, remote_id):
|
||||||
"""pull up a book record by whatever means possible"""
|
"""pull up a book record by whatever means possible"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def parse_search_data(self, data):
|
def parse_search_data(self, data, min_confidence):
|
||||||
"""turn the result json from a search into a list"""
|
"""turn the result json from a search into a list"""
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def format_search_result(self, search_result):
|
|
||||||
"""create a SearchResult obj from json"""
|
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def parse_isbn_search_data(self, data):
|
def parse_isbn_search_data(self, data):
|
||||||
"""turn the result json from a search into a list"""
|
"""turn the result json from a search into a list"""
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def format_isbn_search_result(self, search_result):
|
|
||||||
"""create a SearchResult obj from json"""
|
|
||||||
|
|
||||||
|
|
||||||
class AbstractConnector(AbstractMinimalConnector):
|
class AbstractConnector(AbstractMinimalConnector):
|
||||||
"""generic book data connector"""
|
"""generic book data connector"""
|
||||||
|
@ -129,7 +102,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
||||||
try:
|
try:
|
||||||
work_data = self.get_work_from_edition_data(data)
|
work_data = self.get_work_from_edition_data(data)
|
||||||
except (KeyError, ConnectorException) as err:
|
except (KeyError, ConnectorException) as err:
|
||||||
logger.exception(err)
|
logger.info(err)
|
||||||
work_data = data
|
work_data = data
|
||||||
|
|
||||||
if not work_data or not edition_data:
|
if not work_data or not edition_data:
|
||||||
|
@ -250,8 +223,7 @@ def dict_from_mappings(data, mappings):
|
||||||
def get_data(url, params=None, timeout=10):
|
def get_data(url, params=None, timeout=10):
|
||||||
"""wrapper for request.get"""
|
"""wrapper for request.get"""
|
||||||
# check if the url is blocked
|
# check if the url is blocked
|
||||||
if models.FederatedServer.is_blocked(url):
|
raise_not_valid_url(url)
|
||||||
raise ConnectorException(f"Attempting to load data from blocked url: {url}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
|
@ -266,7 +238,7 @@ def get_data(url, params=None, timeout=10):
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
except RequestException as err:
|
except RequestException as err:
|
||||||
logger.exception(err)
|
logger.info(err)
|
||||||
raise ConnectorException(err)
|
raise ConnectorException(err)
|
||||||
|
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
|
@ -274,7 +246,7 @@ def get_data(url, params=None, timeout=10):
|
||||||
try:
|
try:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
except ValueError as err:
|
except ValueError as err:
|
||||||
logger.exception(err)
|
logger.info(err)
|
||||||
raise ConnectorException(err)
|
raise ConnectorException(err)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
@ -282,6 +254,7 @@ def get_data(url, params=None, timeout=10):
|
||||||
|
|
||||||
def get_image(url, timeout=10):
|
def get_image(url, timeout=10):
|
||||||
"""wrapper for requesting an image"""
|
"""wrapper for requesting an image"""
|
||||||
|
raise_not_valid_url(url)
|
||||||
try:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
url,
|
url,
|
||||||
|
@ -291,7 +264,7 @@ def get_image(url, timeout=10):
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
except RequestException as err:
|
except RequestException as err:
|
||||||
logger.exception(err)
|
logger.info(err)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
|
@ -300,7 +273,7 @@ def get_image(url, timeout=10):
|
||||||
image_content = ContentFile(resp.content)
|
image_content = ContentFile(resp.content)
|
||||||
extension = imghdr.what(None, image_content.read())
|
extension = imghdr.what(None, image_content.read())
|
||||||
if not extension:
|
if not extension:
|
||||||
logger.exception("File requested was not an image: %s", url)
|
logger.info("File requested was not an image: %s", url)
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
return image_content, extension
|
return image_content, extension
|
||||||
|
@ -347,3 +320,9 @@ def unique_physical_format(format_text):
|
||||||
# try a direct match, so saving this would be redundant
|
# try a direct match, so saving this would be redundant
|
||||||
return None
|
return None
|
||||||
return format_text
|
return format_text
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_isbn(query):
|
||||||
|
"""check if a query looks like an isbn"""
|
||||||
|
isbn = re.sub(r"[\W_]", "", query) # removes filler characters
|
||||||
|
return len(isbn) in [10, 13] # ISBN10 or ISBN13
|
||||||
|
|
|
@ -10,15 +10,12 @@ class Connector(AbstractMinimalConnector):
|
||||||
def get_or_create_book(self, remote_id):
|
def get_or_create_book(self, remote_id):
|
||||||
return activitypub.resolve_remote_id(remote_id, model=models.Edition)
|
return activitypub.resolve_remote_id(remote_id, model=models.Edition)
|
||||||
|
|
||||||
def parse_search_data(self, data):
|
def parse_search_data(self, data, min_confidence):
|
||||||
return data
|
for search_result in data:
|
||||||
|
search_result["connector"] = self
|
||||||
def format_search_result(self, search_result):
|
yield SearchResult(**search_result)
|
||||||
search_result["connector"] = self
|
|
||||||
return SearchResult(**search_result)
|
|
||||||
|
|
||||||
def parse_isbn_search_data(self, data):
|
def parse_isbn_search_data(self, data):
|
||||||
return data
|
for search_result in data:
|
||||||
|
search_result["connector"] = self
|
||||||
def format_isbn_search_result(self, search_result):
|
yield SearchResult(**search_result)
|
||||||
return self.format_search_result(search_result)
|
|
||||||
|
|
|
@ -1,17 +1,18 @@
|
||||||
""" interface with whatever connectors the app has """
|
""" interface with whatever connectors the app has """
|
||||||
from datetime import datetime
|
import asyncio
|
||||||
import importlib
|
import importlib
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.db.models import signals
|
from django.db.models import signals
|
||||||
|
|
||||||
from requests import HTTPError
|
from requests import HTTPError
|
||||||
|
|
||||||
from bookwyrm import book_search, models
|
from bookwyrm import book_search, models
|
||||||
from bookwyrm.settings import SEARCH_TIMEOUT
|
from bookwyrm.settings import SEARCH_TIMEOUT, USER_AGENT
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -21,53 +22,85 @@ class ConnectorException(HTTPError):
|
||||||
"""when the connector can't do what was asked"""
|
"""when the connector can't do what was asked"""
|
||||||
|
|
||||||
|
|
||||||
|
async def get_results(session, url, min_confidence, query, connector):
|
||||||
|
"""try this specific connector"""
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
headers = {
|
||||||
|
"Accept": (
|
||||||
|
'application/json, application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'
|
||||||
|
),
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
}
|
||||||
|
params = {"min_confidence": min_confidence}
|
||||||
|
try:
|
||||||
|
async with session.get(url, headers=headers, params=params) as response:
|
||||||
|
if not response.ok:
|
||||||
|
logger.info("Unable to connect to %s: %s", url, response.reason)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_data = await response.json()
|
||||||
|
except aiohttp.client_exceptions.ContentTypeError as err:
|
||||||
|
logger.exception(err)
|
||||||
|
return
|
||||||
|
|
||||||
|
return {
|
||||||
|
"connector": connector,
|
||||||
|
"results": connector.process_search_response(
|
||||||
|
query, raw_data, min_confidence
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.info("Connection timed out for url: %s", url)
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
logger.exception(err)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_connector_search(query, items, min_confidence):
|
||||||
|
"""Try a number of requests simultaneously"""
|
||||||
|
timeout = aiohttp.ClientTimeout(total=SEARCH_TIMEOUT)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
tasks = []
|
||||||
|
for url, connector in items:
|
||||||
|
tasks.append(
|
||||||
|
asyncio.ensure_future(
|
||||||
|
get_results(session, url, min_confidence, query, connector)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def search(query, min_confidence=0.1, return_first=False):
|
def search(query, min_confidence=0.1, return_first=False):
|
||||||
"""find books based on arbitary keywords"""
|
"""find books based on arbitary keywords"""
|
||||||
if not query:
|
if not query:
|
||||||
return []
|
return []
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Have we got a ISBN ?
|
items = []
|
||||||
isbn = re.sub(r"[\W_]", "", query)
|
|
||||||
maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13
|
|
||||||
|
|
||||||
start_time = datetime.now()
|
|
||||||
for connector in get_connectors():
|
for connector in get_connectors():
|
||||||
result_set = None
|
# get the search url from the connector before sending
|
||||||
if maybe_isbn and connector.isbn_search_url and connector.isbn_search_url != "":
|
url = connector.get_search_url(query)
|
||||||
# Search on ISBN
|
try:
|
||||||
try:
|
raise_not_valid_url(url)
|
||||||
result_set = connector.isbn_search(isbn)
|
except ConnectorException:
|
||||||
except Exception as err: # pylint: disable=broad-except
|
# if this URL is invalid we should skip it and move on
|
||||||
logger.exception(err)
|
logger.info("Request denied to blocked domain: %s", url)
|
||||||
# if this fails, we can still try regular search
|
continue
|
||||||
|
items.append((url, connector))
|
||||||
|
|
||||||
# if no isbn search results, we fallback to generic search
|
# load as many results as we can
|
||||||
if not result_set:
|
results = asyncio.run(async_connector_search(query, items, min_confidence))
|
||||||
try:
|
results = [r for r in results if r]
|
||||||
result_set = connector.search(query, min_confidence=min_confidence)
|
|
||||||
except Exception as err: # pylint: disable=broad-except
|
|
||||||
# we don't want *any* error to crash the whole search page
|
|
||||||
logger.exception(err)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if return_first and result_set:
|
|
||||||
# if we found anything, return it
|
|
||||||
return result_set[0]
|
|
||||||
|
|
||||||
if result_set:
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"connector": connector,
|
|
||||||
"results": result_set,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (datetime.now() - start_time).seconds >= SEARCH_TIMEOUT:
|
|
||||||
break
|
|
||||||
|
|
||||||
if return_first:
|
if return_first:
|
||||||
return None
|
# find the best result from all the responses and return that
|
||||||
|
all_results = [r for con in results for r in con["results"]]
|
||||||
|
all_results = sorted(all_results, key=lambda r: r.confidence, reverse=True)
|
||||||
|
return all_results[0] if all_results else None
|
||||||
|
|
||||||
|
# failed requests will return None, so filter those out
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@ -133,3 +166,20 @@ def create_connector(sender, instance, created, *args, **kwargs):
|
||||||
"""create a connector to an external bookwyrm server"""
|
"""create a connector to an external bookwyrm server"""
|
||||||
if instance.application_type == "bookwyrm":
|
if instance.application_type == "bookwyrm":
|
||||||
get_or_create_connector(f"https://{instance.server_name}")
|
get_or_create_connector(f"https://{instance.server_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def raise_not_valid_url(url):
|
||||||
|
"""do some basic reality checks on the url"""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if not parsed.scheme in ["http", "https"]:
|
||||||
|
raise ConnectorException("Invalid scheme: ", url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(parsed.netloc)
|
||||||
|
raise ConnectorException("Provided url is an IP address: ", url)
|
||||||
|
except ValueError:
|
||||||
|
# it's not an IP address, which is good
|
||||||
|
pass
|
||||||
|
|
||||||
|
if models.FederatedServer.is_blocked(url):
|
||||||
|
raise ConnectorException(f"Attempting to load data from blocked url: {url}")
|
||||||
|
|
|
@ -77,53 +77,42 @@ class Connector(AbstractConnector):
|
||||||
**{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks", "type"]},
|
**{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks", "type"]},
|
||||||
}
|
}
|
||||||
|
|
||||||
def search(self, query, min_confidence=None): # pylint: disable=arguments-differ
|
def parse_search_data(self, data, min_confidence):
|
||||||
"""overrides default search function with confidence ranking"""
|
for search_result in data.get("results", []):
|
||||||
results = super().search(query)
|
images = search_result.get("image")
|
||||||
if min_confidence:
|
cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None
|
||||||
# filter the search results after the fact
|
# a deeply messy translation of inventaire's scores
|
||||||
return [r for r in results if r.confidence >= min_confidence]
|
confidence = float(search_result.get("_score", 0.1))
|
||||||
return results
|
confidence = 0.1 if confidence < 150 else 0.999
|
||||||
|
if confidence < min_confidence:
|
||||||
def parse_search_data(self, data):
|
continue
|
||||||
return data.get("results")
|
yield SearchResult(
|
||||||
|
title=search_result.get("label"),
|
||||||
def format_search_result(self, search_result):
|
key=self.get_remote_id(search_result.get("uri")),
|
||||||
images = search_result.get("image")
|
author=search_result.get("description"),
|
||||||
cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None
|
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
|
||||||
# a deeply messy translation of inventaire's scores
|
cover=cover,
|
||||||
confidence = float(search_result.get("_score", 0.1))
|
confidence=confidence,
|
||||||
confidence = 0.1 if confidence < 150 else 0.999
|
connector=self,
|
||||||
return SearchResult(
|
)
|
||||||
title=search_result.get("label"),
|
|
||||||
key=self.get_remote_id(search_result.get("uri")),
|
|
||||||
author=search_result.get("description"),
|
|
||||||
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
|
|
||||||
cover=cover,
|
|
||||||
confidence=confidence,
|
|
||||||
connector=self,
|
|
||||||
)
|
|
||||||
|
|
||||||
def parse_isbn_search_data(self, data):
|
def parse_isbn_search_data(self, data):
|
||||||
"""got some daaaata"""
|
"""got some daaaata"""
|
||||||
results = data.get("entities")
|
results = data.get("entities")
|
||||||
if not results:
|
if not results:
|
||||||
return []
|
return
|
||||||
return list(results.values())
|
for search_result in list(results.values()):
|
||||||
|
title = search_result.get("claims", {}).get("wdt:P1476", [])
|
||||||
def format_isbn_search_result(self, search_result):
|
if not title:
|
||||||
"""totally different format than a regular search result"""
|
continue
|
||||||
title = search_result.get("claims", {}).get("wdt:P1476", [])
|
yield SearchResult(
|
||||||
if not title:
|
title=title[0],
|
||||||
return None
|
key=self.get_remote_id(search_result.get("uri")),
|
||||||
return SearchResult(
|
author=search_result.get("description"),
|
||||||
title=title[0],
|
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
|
||||||
key=self.get_remote_id(search_result.get("uri")),
|
cover=self.get_cover_url(search_result.get("image")),
|
||||||
author=search_result.get("description"),
|
connector=self,
|
||||||
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
|
)
|
||||||
cover=self.get_cover_url(search_result.get("image")),
|
|
||||||
connector=self,
|
|
||||||
)
|
|
||||||
|
|
||||||
def is_work_data(self, data):
|
def is_work_data(self, data):
|
||||||
return data.get("type") == "work"
|
return data.get("type") == "work"
|
||||||
|
|
|
@ -68,7 +68,30 @@ class Connector(AbstractConnector):
|
||||||
Mapping("born", remote_field="birth_date"),
|
Mapping("born", remote_field="birth_date"),
|
||||||
Mapping("died", remote_field="death_date"),
|
Mapping("died", remote_field="death_date"),
|
||||||
Mapping("bio", formatter=get_description),
|
Mapping("bio", formatter=get_description),
|
||||||
Mapping("isni", remote_field="remote_ids", formatter=get_isni),
|
Mapping(
|
||||||
|
"isni",
|
||||||
|
remote_field="remote_ids",
|
||||||
|
formatter=lambda b: get_dict_field(b, "isni"),
|
||||||
|
),
|
||||||
|
Mapping(
|
||||||
|
"asin",
|
||||||
|
remote_field="remote_ids",
|
||||||
|
formatter=lambda b: get_dict_field(b, "amazon"),
|
||||||
|
),
|
||||||
|
Mapping(
|
||||||
|
"viaf",
|
||||||
|
remote_field="remote_ids",
|
||||||
|
formatter=lambda b: get_dict_field(b, "viaf"),
|
||||||
|
),
|
||||||
|
Mapping(
|
||||||
|
"wikidata",
|
||||||
|
remote_field="remote_ids",
|
||||||
|
formatter=lambda b: get_dict_field(b, "wikidata"),
|
||||||
|
),
|
||||||
|
Mapping(
|
||||||
|
"wikipedia_link", remote_field="links", formatter=get_wikipedia_link
|
||||||
|
),
|
||||||
|
Mapping("inventaire_id", remote_field="links", formatter=get_inventaire_id),
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_book_data(self, remote_id):
|
def get_book_data(self, remote_id):
|
||||||
|
@ -129,39 +152,41 @@ class Connector(AbstractConnector):
|
||||||
image_name = f"{cover_id}-{size}.jpg"
|
image_name = f"{cover_id}-{size}.jpg"
|
||||||
return f"{self.covers_url}/b/id/{image_name}"
|
return f"{self.covers_url}/b/id/{image_name}"
|
||||||
|
|
||||||
def parse_search_data(self, data):
|
def parse_search_data(self, data, min_confidence):
|
||||||
return data.get("docs")
|
for idx, search_result in enumerate(data.get("docs")):
|
||||||
|
# build the remote id from the openlibrary key
|
||||||
|
key = self.books_url + search_result["key"]
|
||||||
|
author = search_result.get("author_name") or ["Unknown"]
|
||||||
|
cover_blob = search_result.get("cover_i")
|
||||||
|
cover = self.get_cover_url([cover_blob], size="M") if cover_blob else None
|
||||||
|
|
||||||
def format_search_result(self, search_result):
|
# OL doesn't provide confidence, but it does sort by an internal ranking, so
|
||||||
# build the remote id from the openlibrary key
|
# this confidence value is relative to the list position
|
||||||
key = self.books_url + search_result["key"]
|
confidence = 1 / (idx + 1)
|
||||||
author = search_result.get("author_name") or ["Unknown"]
|
|
||||||
cover_blob = search_result.get("cover_i")
|
yield SearchResult(
|
||||||
cover = self.get_cover_url([cover_blob], size="M") if cover_blob else None
|
title=search_result.get("title"),
|
||||||
return SearchResult(
|
key=key,
|
||||||
title=search_result.get("title"),
|
author=", ".join(author),
|
||||||
key=key,
|
connector=self,
|
||||||
author=", ".join(author),
|
year=search_result.get("first_publish_year"),
|
||||||
connector=self,
|
cover=cover,
|
||||||
year=search_result.get("first_publish_year"),
|
confidence=confidence,
|
||||||
cover=cover,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
def parse_isbn_search_data(self, data):
|
def parse_isbn_search_data(self, data):
|
||||||
return list(data.values())
|
for search_result in list(data.values()):
|
||||||
|
# build the remote id from the openlibrary key
|
||||||
def format_isbn_search_result(self, search_result):
|
key = self.books_url + search_result["key"]
|
||||||
# build the remote id from the openlibrary key
|
authors = search_result.get("authors") or [{"name": "Unknown"}]
|
||||||
key = self.books_url + search_result["key"]
|
author_names = [author.get("name") for author in authors]
|
||||||
authors = search_result.get("authors") or [{"name": "Unknown"}]
|
yield SearchResult(
|
||||||
author_names = [author.get("name") for author in authors]
|
title=search_result.get("title"),
|
||||||
return SearchResult(
|
key=key,
|
||||||
title=search_result.get("title"),
|
author=", ".join(author_names),
|
||||||
key=key,
|
connector=self,
|
||||||
author=", ".join(author_names),
|
year=search_result.get("publish_date"),
|
||||||
connector=self,
|
)
|
||||||
year=search_result.get("publish_date"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def load_edition_data(self, olkey):
|
def load_edition_data(self, olkey):
|
||||||
"""query openlibrary for editions of a work"""
|
"""query openlibrary for editions of a work"""
|
||||||
|
@ -227,11 +252,38 @@ def get_languages(language_blob):
|
||||||
return langs
|
return langs
|
||||||
|
|
||||||
|
|
||||||
def get_isni(remote_ids_blob):
|
def get_dict_field(blob, field_name):
|
||||||
"""extract the isni from the remote id data for the author"""
|
"""extract the isni from the remote id data for the author"""
|
||||||
if not remote_ids_blob or not isinstance(remote_ids_blob, dict):
|
if not blob or not isinstance(blob, dict):
|
||||||
return None
|
return None
|
||||||
return remote_ids_blob.get("isni")
|
return blob.get(field_name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_wikipedia_link(links):
|
||||||
|
"""extract wikipedia links"""
|
||||||
|
if not isinstance(links, list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
for link in links:
|
||||||
|
if not isinstance(link, dict):
|
||||||
|
continue
|
||||||
|
if link.get("title") == "wikipedia":
|
||||||
|
return link.get("url")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_inventaire_id(links):
|
||||||
|
"""extract and format inventaire ids"""
|
||||||
|
if not isinstance(links, list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
for link in links:
|
||||||
|
if not isinstance(link, dict):
|
||||||
|
continue
|
||||||
|
if link.get("title") == "inventaire.io":
|
||||||
|
iv_link = link.get("url")
|
||||||
|
return iv_link.split("/")[-1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def pick_default_edition(options):
|
def pick_default_edition(options):
|
||||||
|
|
|
@ -8,8 +8,20 @@ def site_settings(request): # pylint: disable=unused-argument
|
||||||
if not request.is_secure():
|
if not request.is_secure():
|
||||||
request_protocol = "http://"
|
request_protocol = "http://"
|
||||||
|
|
||||||
|
site = models.SiteSettings.objects.get()
|
||||||
|
theme = "css/themes/bookwyrm-light.scss"
|
||||||
|
if (
|
||||||
|
hasattr(request, "user")
|
||||||
|
and request.user.is_authenticated
|
||||||
|
and request.user.theme
|
||||||
|
):
|
||||||
|
theme = request.user.theme.path
|
||||||
|
elif site.default_theme:
|
||||||
|
theme = site.default_theme.path
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"site": models.SiteSettings.objects.get(),
|
"site": site,
|
||||||
|
"site_theme": theme,
|
||||||
"active_announcements": models.Announcement.active_announcements(),
|
"active_announcements": models.Announcement.active_announcements(),
|
||||||
"thumbnail_generation_enabled": settings.ENABLE_THUMBNAIL_GENERATION,
|
"thumbnail_generation_enabled": settings.ENABLE_THUMBNAIL_GENERATION,
|
||||||
"media_full_url": settings.MEDIA_FULL_URL,
|
"media_full_url": settings.MEDIA_FULL_URL,
|
||||||
|
|
|
@ -1,516 +0,0 @@
|
||||||
""" using django model forms """
|
|
||||||
import datetime
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
from django import forms
|
|
||||||
from django.forms import ModelForm, PasswordInput, widgets, ChoiceField
|
|
||||||
from django.forms.widgets import Textarea
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from bookwyrm import models
|
|
||||||
from bookwyrm.models.fields import ClearableFileInputWithWarning
|
|
||||||
from bookwyrm.models.user import FeedFilterChoices
|
|
||||||
|
|
||||||
|
|
||||||
class CustomForm(ModelForm):
|
|
||||||
"""add css classes to the forms"""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
css_classes = defaultdict(lambda: "")
|
|
||||||
css_classes["text"] = "input"
|
|
||||||
css_classes["password"] = "input"
|
|
||||||
css_classes["email"] = "input"
|
|
||||||
css_classes["number"] = "input"
|
|
||||||
css_classes["checkbox"] = "checkbox"
|
|
||||||
css_classes["textarea"] = "textarea"
|
|
||||||
# pylint: disable=super-with-arguments
|
|
||||||
super(CustomForm, self).__init__(*args, **kwargs)
|
|
||||||
for visible in self.visible_fields():
|
|
||||||
if hasattr(visible.field.widget, "input_type"):
|
|
||||||
input_type = visible.field.widget.input_type
|
|
||||||
if isinstance(visible.field.widget, Textarea):
|
|
||||||
input_type = "textarea"
|
|
||||||
visible.field.widget.attrs["rows"] = 5
|
|
||||||
visible.field.widget.attrs["class"] = css_classes[input_type]
|
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=missing-class-docstring
|
|
||||||
class LoginForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.User
|
|
||||||
fields = ["localname", "password"]
|
|
||||||
help_texts = {f: None for f in fields}
|
|
||||||
widgets = {
|
|
||||||
"password": PasswordInput(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.User
|
|
||||||
fields = ["localname", "email", "password"]
|
|
||||||
help_texts = {f: None for f in fields}
|
|
||||||
widgets = {"password": PasswordInput()}
|
|
||||||
|
|
||||||
|
|
||||||
class RatingForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.ReviewRating
|
|
||||||
fields = ["user", "book", "rating", "privacy"]
|
|
||||||
|
|
||||||
|
|
||||||
class ReviewForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.Review
|
|
||||||
fields = [
|
|
||||||
"user",
|
|
||||||
"book",
|
|
||||||
"name",
|
|
||||||
"content",
|
|
||||||
"rating",
|
|
||||||
"content_warning",
|
|
||||||
"sensitive",
|
|
||||||
"privacy",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class CommentForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.Comment
|
|
||||||
fields = [
|
|
||||||
"user",
|
|
||||||
"book",
|
|
||||||
"content",
|
|
||||||
"content_warning",
|
|
||||||
"sensitive",
|
|
||||||
"privacy",
|
|
||||||
"progress",
|
|
||||||
"progress_mode",
|
|
||||||
"reading_status",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class QuotationForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.Quotation
|
|
||||||
fields = [
|
|
||||||
"user",
|
|
||||||
"book",
|
|
||||||
"quote",
|
|
||||||
"content",
|
|
||||||
"content_warning",
|
|
||||||
"sensitive",
|
|
||||||
"privacy",
|
|
||||||
"position",
|
|
||||||
"position_mode",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class ReplyForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.Status
|
|
||||||
fields = [
|
|
||||||
"user",
|
|
||||||
"content",
|
|
||||||
"content_warning",
|
|
||||||
"sensitive",
|
|
||||||
"reply_parent",
|
|
||||||
"privacy",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class StatusForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.Status
|
|
||||||
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
|
|
||||||
|
|
||||||
|
|
||||||
class DirectForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.Status
|
|
||||||
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
|
|
||||||
|
|
||||||
|
|
||||||
class EditUserForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.User
|
|
||||||
fields = [
|
|
||||||
"avatar",
|
|
||||||
"name",
|
|
||||||
"email",
|
|
||||||
"summary",
|
|
||||||
"show_goal",
|
|
||||||
"show_suggested_users",
|
|
||||||
"manually_approves_followers",
|
|
||||||
"default_post_privacy",
|
|
||||||
"discoverable",
|
|
||||||
"preferred_timezone",
|
|
||||||
"preferred_language",
|
|
||||||
]
|
|
||||||
help_texts = {f: None for f in fields}
|
|
||||||
widgets = {
|
|
||||||
"avatar": ClearableFileInputWithWarning(
|
|
||||||
attrs={"aria-describedby": "desc_avatar"}
|
|
||||||
),
|
|
||||||
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
|
||||||
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
|
|
||||||
"email": forms.EmailInput(attrs={"aria-describedby": "desc_email"}),
|
|
||||||
"discoverable": forms.CheckboxInput(
|
|
||||||
attrs={"aria-describedby": "desc_discoverable"}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class LimitedEditUserForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.User
|
|
||||||
fields = [
|
|
||||||
"avatar",
|
|
||||||
"name",
|
|
||||||
"summary",
|
|
||||||
"manually_approves_followers",
|
|
||||||
"discoverable",
|
|
||||||
]
|
|
||||||
help_texts = {f: None for f in fields}
|
|
||||||
widgets = {
|
|
||||||
"avatar": ClearableFileInputWithWarning(
|
|
||||||
attrs={"aria-describedby": "desc_avatar"}
|
|
||||||
),
|
|
||||||
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
|
||||||
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
|
|
||||||
"discoverable": forms.CheckboxInput(
|
|
||||||
attrs={"aria-describedby": "desc_discoverable"}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class DeleteUserForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.User
|
|
||||||
fields = ["password"]
|
|
||||||
|
|
||||||
|
|
||||||
class UserGroupForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.User
|
|
||||||
fields = ["groups"]
|
|
||||||
|
|
||||||
|
|
||||||
class FeedStatusTypesForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.User
|
|
||||||
fields = ["feed_status_types"]
|
|
||||||
help_texts = {f: None for f in fields}
|
|
||||||
widgets = {
|
|
||||||
"feed_status_types": widgets.CheckboxSelectMultiple(
|
|
||||||
choices=FeedFilterChoices,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class CoverForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.Book
|
|
||||||
fields = ["cover"]
|
|
||||||
help_texts = {f: None for f in fields}
|
|
||||||
|
|
||||||
|
|
||||||
class LinkDomainForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.LinkDomain
|
|
||||||
fields = ["name"]
|
|
||||||
|
|
||||||
|
|
||||||
class FileLinkForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.FileLink
|
|
||||||
fields = ["url", "filetype", "availability", "book", "added_by"]
|
|
||||||
|
|
||||||
|
|
||||||
class EditionForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.Edition
|
|
||||||
exclude = [
|
|
||||||
"remote_id",
|
|
||||||
"origin_id",
|
|
||||||
"created_date",
|
|
||||||
"updated_date",
|
|
||||||
"edition_rank",
|
|
||||||
"authors",
|
|
||||||
"parent_work",
|
|
||||||
"shelves",
|
|
||||||
"connector",
|
|
||||||
"search_vector",
|
|
||||||
"links",
|
|
||||||
"file_links",
|
|
||||||
]
|
|
||||||
widgets = {
|
|
||||||
"title": forms.TextInput(attrs={"aria-describedby": "desc_title"}),
|
|
||||||
"subtitle": forms.TextInput(attrs={"aria-describedby": "desc_subtitle"}),
|
|
||||||
"description": forms.Textarea(
|
|
||||||
attrs={"aria-describedby": "desc_description"}
|
|
||||||
),
|
|
||||||
"series": forms.TextInput(attrs={"aria-describedby": "desc_series"}),
|
|
||||||
"series_number": forms.TextInput(
|
|
||||||
attrs={"aria-describedby": "desc_series_number"}
|
|
||||||
),
|
|
||||||
"languages": forms.TextInput(
|
|
||||||
attrs={"aria-describedby": "desc_languages_help desc_languages"}
|
|
||||||
),
|
|
||||||
"publishers": forms.TextInput(
|
|
||||||
attrs={"aria-describedby": "desc_publishers_help desc_publishers"}
|
|
||||||
),
|
|
||||||
"first_published_date": forms.SelectDateWidget(
|
|
||||||
attrs={"aria-describedby": "desc_first_published_date"}
|
|
||||||
),
|
|
||||||
"published_date": forms.SelectDateWidget(
|
|
||||||
attrs={"aria-describedby": "desc_published_date"}
|
|
||||||
),
|
|
||||||
"cover": ClearableFileInputWithWarning(
|
|
||||||
attrs={"aria-describedby": "desc_cover"}
|
|
||||||
),
|
|
||||||
"physical_format": forms.Select(
|
|
||||||
attrs={"aria-describedby": "desc_physical_format"}
|
|
||||||
),
|
|
||||||
"physical_format_detail": forms.TextInput(
|
|
||||||
attrs={"aria-describedby": "desc_physical_format_detail"}
|
|
||||||
),
|
|
||||||
"pages": forms.NumberInput(attrs={"aria-describedby": "desc_pages"}),
|
|
||||||
"isbn_13": forms.TextInput(attrs={"aria-describedby": "desc_isbn_13"}),
|
|
||||||
"isbn_10": forms.TextInput(attrs={"aria-describedby": "desc_isbn_10"}),
|
|
||||||
"openlibrary_key": forms.TextInput(
|
|
||||||
attrs={"aria-describedby": "desc_openlibrary_key"}
|
|
||||||
),
|
|
||||||
"inventaire_id": forms.TextInput(
|
|
||||||
attrs={"aria-describedby": "desc_inventaire_id"}
|
|
||||||
),
|
|
||||||
"oclc_number": forms.TextInput(
|
|
||||||
attrs={"aria-describedby": "desc_oclc_number"}
|
|
||||||
),
|
|
||||||
"ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.Author
|
|
||||||
fields = [
|
|
||||||
"last_edited_by",
|
|
||||||
"name",
|
|
||||||
"aliases",
|
|
||||||
"bio",
|
|
||||||
"wikipedia_link",
|
|
||||||
"born",
|
|
||||||
"died",
|
|
||||||
"openlibrary_key",
|
|
||||||
"inventaire_id",
|
|
||||||
"librarything_key",
|
|
||||||
"goodreads_key",
|
|
||||||
"isni",
|
|
||||||
]
|
|
||||||
widgets = {
|
|
||||||
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
|
||||||
"aliases": forms.TextInput(attrs={"aria-describedby": "desc_aliases"}),
|
|
||||||
"bio": forms.Textarea(attrs={"aria-describedby": "desc_bio"}),
|
|
||||||
"wikipedia_link": forms.TextInput(
|
|
||||||
attrs={"aria-describedby": "desc_wikipedia_link"}
|
|
||||||
),
|
|
||||||
"born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}),
|
|
||||||
"died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}),
|
|
||||||
"oepnlibrary_key": forms.TextInput(
|
|
||||||
attrs={"aria-describedby": "desc_oepnlibrary_key"}
|
|
||||||
),
|
|
||||||
"inventaire_id": forms.TextInput(
|
|
||||||
attrs={"aria-describedby": "desc_inventaire_id"}
|
|
||||||
),
|
|
||||||
"librarything_key": forms.TextInput(
|
|
||||||
attrs={"aria-describedby": "desc_librarything_key"}
|
|
||||||
),
|
|
||||||
"goodreads_key": forms.TextInput(
|
|
||||||
attrs={"aria-describedby": "desc_goodreads_key"}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ImportForm(forms.Form):
|
|
||||||
csv_file = forms.FileField()
|
|
||||||
|
|
||||||
|
|
||||||
class ExpiryWidget(widgets.Select):
|
|
||||||
def value_from_datadict(self, data, files, name):
|
|
||||||
"""human-readable exiration time buckets"""
|
|
||||||
selected_string = super().value_from_datadict(data, files, name)
|
|
||||||
|
|
||||||
if selected_string == "day":
|
|
||||||
interval = datetime.timedelta(days=1)
|
|
||||||
elif selected_string == "week":
|
|
||||||
interval = datetime.timedelta(days=7)
|
|
||||||
elif selected_string == "month":
|
|
||||||
interval = datetime.timedelta(days=31) # Close enough?
|
|
||||||
elif selected_string == "forever":
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return selected_string # This will raise
|
|
||||||
|
|
||||||
return timezone.now() + interval
|
|
||||||
|
|
||||||
|
|
||||||
class InviteRequestForm(CustomForm):
|
|
||||||
def clean(self):
|
|
||||||
"""make sure the email isn't in use by a registered user"""
|
|
||||||
cleaned_data = super().clean()
|
|
||||||
email = cleaned_data.get("email")
|
|
||||||
if email and models.User.objects.filter(email=email).exists():
|
|
||||||
self.add_error("email", _("A user with this email already exists."))
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.InviteRequest
|
|
||||||
fields = ["email"]
|
|
||||||
|
|
||||||
|
|
||||||
class CreateInviteForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.SiteInvite
|
|
||||||
exclude = ["code", "user", "times_used", "invitees"]
|
|
||||||
widgets = {
|
|
||||||
"expiry": ExpiryWidget(
|
|
||||||
choices=[
|
|
||||||
("day", _("One Day")),
|
|
||||||
("week", _("One Week")),
|
|
||||||
("month", _("One Month")),
|
|
||||||
("forever", _("Does Not Expire")),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
"use_limit": widgets.Select(
|
|
||||||
choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]]
|
|
||||||
+ [(None, _("Unlimited"))]
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ShelfForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.Shelf
|
|
||||||
fields = ["user", "name", "privacy", "description"]
|
|
||||||
|
|
||||||
|
|
||||||
class GoalForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.AnnualGoal
|
|
||||||
fields = ["user", "year", "goal", "privacy"]
|
|
||||||
|
|
||||||
|
|
||||||
class SiteForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.SiteSettings
|
|
||||||
exclude = []
|
|
||||||
widgets = {
|
|
||||||
"instance_short_description": forms.TextInput(
|
|
||||||
attrs={"aria-describedby": "desc_instance_short_description"}
|
|
||||||
),
|
|
||||||
"require_confirm_email": forms.CheckboxInput(
|
|
||||||
attrs={"aria-describedby": "desc_require_confirm_email"}
|
|
||||||
),
|
|
||||||
"invite_request_text": forms.Textarea(
|
|
||||||
attrs={"aria-describedby": "desc_invite_request_text"}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class AnnouncementForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.Announcement
|
|
||||||
exclude = ["remote_id"]
|
|
||||||
widgets = {
|
|
||||||
"preview": forms.TextInput(attrs={"aria-describedby": "desc_preview"}),
|
|
||||||
"content": forms.Textarea(attrs={"aria-describedby": "desc_content"}),
|
|
||||||
"event_date": forms.SelectDateWidget(
|
|
||||||
attrs={"aria-describedby": "desc_event_date"}
|
|
||||||
),
|
|
||||||
"start_date": forms.SelectDateWidget(
|
|
||||||
attrs={"aria-describedby": "desc_start_date"}
|
|
||||||
),
|
|
||||||
"end_date": forms.SelectDateWidget(
|
|
||||||
attrs={"aria-describedby": "desc_end_date"}
|
|
||||||
),
|
|
||||||
"active": forms.CheckboxInput(attrs={"aria-describedby": "desc_active"}),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ListForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.List
|
|
||||||
fields = ["user", "name", "description", "curation", "privacy", "group"]
|
|
||||||
|
|
||||||
|
|
||||||
class ListItemForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.ListItem
|
|
||||||
fields = ["user", "book", "book_list", "notes"]
|
|
||||||
|
|
||||||
|
|
||||||
class GroupForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.Group
|
|
||||||
fields = ["user", "privacy", "name", "description"]
|
|
||||||
|
|
||||||
|
|
||||||
class ReportForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.Report
|
|
||||||
fields = ["user", "reporter", "statuses", "links", "note"]
|
|
||||||
|
|
||||||
|
|
||||||
class EmailBlocklistForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.EmailBlocklist
|
|
||||||
fields = ["domain"]
|
|
||||||
widgets = {
|
|
||||||
"avatar": forms.TextInput(attrs={"aria-describedby": "desc_domain"}),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class IPBlocklistForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.IPBlocklist
|
|
||||||
fields = ["address"]
|
|
||||||
|
|
||||||
|
|
||||||
class ServerForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.FederatedServer
|
|
||||||
exclude = ["remote_id"]
|
|
||||||
|
|
||||||
|
|
||||||
class SortListForm(forms.Form):
|
|
||||||
sort_by = ChoiceField(
|
|
||||||
choices=(
|
|
||||||
("order", _("List Order")),
|
|
||||||
("title", _("Book Title")),
|
|
||||||
("rating", _("Rating")),
|
|
||||||
),
|
|
||||||
label=_("Sort By"),
|
|
||||||
)
|
|
||||||
direction = ChoiceField(
|
|
||||||
choices=(
|
|
||||||
("ascending", _("Ascending")),
|
|
||||||
("descending", _("Descending")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ReadThroughForm(CustomForm):
|
|
||||||
def clean(self):
|
|
||||||
"""make sure the email isn't in use by a registered user"""
|
|
||||||
cleaned_data = super().clean()
|
|
||||||
start_date = cleaned_data.get("start_date")
|
|
||||||
finish_date = cleaned_data.get("finish_date")
|
|
||||||
if start_date and finish_date and start_date > finish_date:
|
|
||||||
self.add_error(
|
|
||||||
"finish_date", _("Reading finish date cannot be before start date.")
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.ReadThrough
|
|
||||||
fields = ["user", "book", "start_date", "finish_date"]
|
|
12
bookwyrm/forms/__init__.py
Normal file
12
bookwyrm/forms/__init__.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
""" make forms available to the app """
|
||||||
|
# site admin
|
||||||
|
from .admin import *
|
||||||
|
from .author import *
|
||||||
|
from .books import *
|
||||||
|
from .edit_user import *
|
||||||
|
from .forms import *
|
||||||
|
from .groups import *
|
||||||
|
from .landing import *
|
||||||
|
from .links import *
|
||||||
|
from .lists import *
|
||||||
|
from .status import *
|
141
bookwyrm/forms/admin.py
Normal file
141
bookwyrm/forms/admin.py
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
""" using django model forms """
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.forms import widgets
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django_celery_beat.models import IntervalSchedule
|
||||||
|
|
||||||
|
from bookwyrm import models
|
||||||
|
from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=missing-class-docstring
|
||||||
|
class ExpiryWidget(widgets.Select):
|
||||||
|
def value_from_datadict(self, data, files, name):
|
||||||
|
"""human-readable exiration time buckets"""
|
||||||
|
selected_string = super().value_from_datadict(data, files, name)
|
||||||
|
|
||||||
|
if selected_string == "day":
|
||||||
|
interval = datetime.timedelta(days=1)
|
||||||
|
elif selected_string == "week":
|
||||||
|
interval = datetime.timedelta(days=7)
|
||||||
|
elif selected_string == "month":
|
||||||
|
interval = datetime.timedelta(days=31) # Close enough?
|
||||||
|
elif selected_string == "forever":
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return selected_string # This will raise
|
||||||
|
|
||||||
|
return timezone.now() + interval
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInviteForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.SiteInvite
|
||||||
|
exclude = ["code", "user", "times_used", "invitees"]
|
||||||
|
widgets = {
|
||||||
|
"expiry": ExpiryWidget(
|
||||||
|
choices=[
|
||||||
|
("day", _("One Day")),
|
||||||
|
("week", _("One Week")),
|
||||||
|
("month", _("One Month")),
|
||||||
|
("forever", _("Does Not Expire")),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
"use_limit": widgets.Select(
|
||||||
|
choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]]
|
||||||
|
+ [(None, _("Unlimited"))]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SiteForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.SiteSettings
|
||||||
|
exclude = ["admin_code", "install_mode"]
|
||||||
|
widgets = {
|
||||||
|
"instance_short_description": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_instance_short_description"}
|
||||||
|
),
|
||||||
|
"require_confirm_email": forms.CheckboxInput(
|
||||||
|
attrs={"aria-describedby": "desc_require_confirm_email"}
|
||||||
|
),
|
||||||
|
"invite_request_text": forms.Textarea(
|
||||||
|
attrs={"aria-describedby": "desc_invite_request_text"}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ThemeForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Theme
|
||||||
|
fields = ["name", "path"]
|
||||||
|
widgets = {
|
||||||
|
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
||||||
|
"path": forms.TextInput(
|
||||||
|
attrs={
|
||||||
|
"aria-describedby": "desc_path",
|
||||||
|
"placeholder": "css/themes/theme-name.scss",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Announcement
|
||||||
|
exclude = ["remote_id"]
|
||||||
|
widgets = {
|
||||||
|
"preview": forms.TextInput(attrs={"aria-describedby": "desc_preview"}),
|
||||||
|
"content": forms.Textarea(attrs={"aria-describedby": "desc_content"}),
|
||||||
|
"event_date": forms.SelectDateWidget(
|
||||||
|
attrs={"aria-describedby": "desc_event_date"}
|
||||||
|
),
|
||||||
|
"start_date": forms.SelectDateWidget(
|
||||||
|
attrs={"aria-describedby": "desc_start_date"}
|
||||||
|
),
|
||||||
|
"end_date": forms.SelectDateWidget(
|
||||||
|
attrs={"aria-describedby": "desc_end_date"}
|
||||||
|
),
|
||||||
|
"active": forms.CheckboxInput(attrs={"aria-describedby": "desc_active"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EmailBlocklistForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.EmailBlocklist
|
||||||
|
fields = ["domain"]
|
||||||
|
widgets = {
|
||||||
|
"avatar": forms.TextInput(attrs={"aria-describedby": "desc_domain"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class IPBlocklistForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.IPBlocklist
|
||||||
|
fields = ["address"]
|
||||||
|
|
||||||
|
|
||||||
|
class ServerForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.FederatedServer
|
||||||
|
exclude = ["remote_id"]
|
||||||
|
|
||||||
|
|
||||||
|
class AutoModRuleForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.AutoMod
|
||||||
|
fields = ["string_match", "flag_users", "flag_statuses", "created_by"]
|
||||||
|
|
||||||
|
|
||||||
|
class IntervalScheduleForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = IntervalSchedule
|
||||||
|
fields = ["every", "period"]
|
||||||
|
|
||||||
|
widgets = {
|
||||||
|
"every": forms.NumberInput(attrs={"aria-describedby": "desc_every"}),
|
||||||
|
"period": forms.Select(attrs={"aria-describedby": "desc_period"}),
|
||||||
|
}
|
47
bookwyrm/forms/author.py
Normal file
47
bookwyrm/forms/author.py
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
""" using django model forms """
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
from bookwyrm import models
|
||||||
|
from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=missing-class-docstring
|
||||||
|
class AuthorForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Author
|
||||||
|
fields = [
|
||||||
|
"last_edited_by",
|
||||||
|
"name",
|
||||||
|
"aliases",
|
||||||
|
"bio",
|
||||||
|
"wikipedia_link",
|
||||||
|
"born",
|
||||||
|
"died",
|
||||||
|
"openlibrary_key",
|
||||||
|
"inventaire_id",
|
||||||
|
"librarything_key",
|
||||||
|
"goodreads_key",
|
||||||
|
"isni",
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
||||||
|
"aliases": forms.TextInput(attrs={"aria-describedby": "desc_aliases"}),
|
||||||
|
"bio": forms.Textarea(attrs={"aria-describedby": "desc_bio"}),
|
||||||
|
"wikipedia_link": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_wikipedia_link"}
|
||||||
|
),
|
||||||
|
"born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}),
|
||||||
|
"died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}),
|
||||||
|
"oepnlibrary_key": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_oepnlibrary_key"}
|
||||||
|
),
|
||||||
|
"inventaire_id": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_inventaire_id"}
|
||||||
|
),
|
||||||
|
"librarything_key": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_librarything_key"}
|
||||||
|
),
|
||||||
|
"goodreads_key": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_goodreads_key"}
|
||||||
|
),
|
||||||
|
}
|
104
bookwyrm/forms/books.py
Normal file
104
bookwyrm/forms/books.py
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
""" using django model forms """
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
from bookwyrm import models
|
||||||
|
from bookwyrm.models.fields import ClearableFileInputWithWarning
|
||||||
|
from .custom_form import CustomForm
|
||||||
|
from .widgets import ArrayWidget, SelectDateWidget, Select
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=missing-class-docstring
|
||||||
|
class CoverForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Book
|
||||||
|
fields = ["cover"]
|
||||||
|
help_texts = {f: None for f in fields}
|
||||||
|
|
||||||
|
|
||||||
|
class EditionForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Edition
|
||||||
|
exclude = [
|
||||||
|
"remote_id",
|
||||||
|
"origin_id",
|
||||||
|
"created_date",
|
||||||
|
"updated_date",
|
||||||
|
"edition_rank",
|
||||||
|
"authors",
|
||||||
|
"parent_work",
|
||||||
|
"shelves",
|
||||||
|
"connector",
|
||||||
|
"search_vector",
|
||||||
|
"links",
|
||||||
|
"file_links",
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
"title": forms.TextInput(attrs={"aria-describedby": "desc_title"}),
|
||||||
|
"subtitle": forms.TextInput(attrs={"aria-describedby": "desc_subtitle"}),
|
||||||
|
"description": forms.Textarea(
|
||||||
|
attrs={"aria-describedby": "desc_description"}
|
||||||
|
),
|
||||||
|
"series": forms.TextInput(attrs={"aria-describedby": "desc_series"}),
|
||||||
|
"series_number": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_series_number"}
|
||||||
|
),
|
||||||
|
"subjects": ArrayWidget(),
|
||||||
|
"languages": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_languages_help desc_languages"}
|
||||||
|
),
|
||||||
|
"publishers": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_publishers_help desc_publishers"}
|
||||||
|
),
|
||||||
|
"first_published_date": SelectDateWidget(
|
||||||
|
attrs={"aria-describedby": "desc_first_published_date"}
|
||||||
|
),
|
||||||
|
"published_date": SelectDateWidget(
|
||||||
|
attrs={"aria-describedby": "desc_published_date"}
|
||||||
|
),
|
||||||
|
"cover": ClearableFileInputWithWarning(
|
||||||
|
attrs={"aria-describedby": "desc_cover"}
|
||||||
|
),
|
||||||
|
"physical_format": Select(
|
||||||
|
attrs={"aria-describedby": "desc_physical_format"}
|
||||||
|
),
|
||||||
|
"physical_format_detail": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_physical_format_detail"}
|
||||||
|
),
|
||||||
|
"pages": forms.NumberInput(attrs={"aria-describedby": "desc_pages"}),
|
||||||
|
"isbn_13": forms.TextInput(attrs={"aria-describedby": "desc_isbn_13"}),
|
||||||
|
"isbn_10": forms.TextInput(attrs={"aria-describedby": "desc_isbn_10"}),
|
||||||
|
"openlibrary_key": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_openlibrary_key"}
|
||||||
|
),
|
||||||
|
"inventaire_id": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_inventaire_id"}
|
||||||
|
),
|
||||||
|
"oclc_number": forms.TextInput(
|
||||||
|
attrs={"aria-describedby": "desc_oclc_number"}
|
||||||
|
),
|
||||||
|
"ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EditionFromWorkForm(CustomForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# make all fields hidden
|
||||||
|
for visible in self.visible_fields():
|
||||||
|
visible.field.widget = forms.HiddenInput()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Work
|
||||||
|
fields = [
|
||||||
|
"title",
|
||||||
|
"subtitle",
|
||||||
|
"authors",
|
||||||
|
"description",
|
||||||
|
"languages",
|
||||||
|
"series",
|
||||||
|
"series_number",
|
||||||
|
"subjects",
|
||||||
|
"subject_places",
|
||||||
|
"cover",
|
||||||
|
"first_published_date",
|
||||||
|
]
|
26
bookwyrm/forms/custom_form.py
Normal file
26
bookwyrm/forms/custom_form.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
""" Overrides django's default form class """
|
||||||
|
from collections import defaultdict
|
||||||
|
from django.forms import ModelForm
|
||||||
|
from django.forms.widgets import Textarea
|
||||||
|
|
||||||
|
|
||||||
|
class CustomForm(ModelForm):
|
||||||
|
"""add css classes to the forms"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
css_classes = defaultdict(lambda: "")
|
||||||
|
css_classes["text"] = "input"
|
||||||
|
css_classes["password"] = "input"
|
||||||
|
css_classes["email"] = "input"
|
||||||
|
css_classes["number"] = "input"
|
||||||
|
css_classes["checkbox"] = "checkbox"
|
||||||
|
css_classes["textarea"] = "textarea"
|
||||||
|
# pylint: disable=super-with-arguments
|
||||||
|
super(CustomForm, self).__init__(*args, **kwargs)
|
||||||
|
for visible in self.visible_fields():
|
||||||
|
if hasattr(visible.field.widget, "input_type"):
|
||||||
|
input_type = visible.field.widget.input_type
|
||||||
|
if isinstance(visible.field.widget, Textarea):
|
||||||
|
input_type = "textarea"
|
||||||
|
visible.field.widget.attrs["rows"] = 5
|
||||||
|
visible.field.widget.attrs["class"] = css_classes[input_type]
|
68
bookwyrm/forms/edit_user.py
Normal file
68
bookwyrm/forms/edit_user.py
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
""" using django model forms """
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
from bookwyrm import models
|
||||||
|
from bookwyrm.models.fields import ClearableFileInputWithWarning
|
||||||
|
from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=missing-class-docstring
|
||||||
|
class EditUserForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = [
|
||||||
|
"avatar",
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"summary",
|
||||||
|
"show_goal",
|
||||||
|
"show_suggested_users",
|
||||||
|
"manually_approves_followers",
|
||||||
|
"default_post_privacy",
|
||||||
|
"discoverable",
|
||||||
|
"hide_follows",
|
||||||
|
"preferred_timezone",
|
||||||
|
"preferred_language",
|
||||||
|
"theme",
|
||||||
|
]
|
||||||
|
help_texts = {f: None for f in fields}
|
||||||
|
widgets = {
|
||||||
|
"avatar": ClearableFileInputWithWarning(
|
||||||
|
attrs={"aria-describedby": "desc_avatar"}
|
||||||
|
),
|
||||||
|
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
||||||
|
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
|
||||||
|
"email": forms.EmailInput(attrs={"aria-describedby": "desc_email"}),
|
||||||
|
"discoverable": forms.CheckboxInput(
|
||||||
|
attrs={"aria-describedby": "desc_discoverable"}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LimitedEditUserForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = [
|
||||||
|
"avatar",
|
||||||
|
"name",
|
||||||
|
"summary",
|
||||||
|
"manually_approves_followers",
|
||||||
|
"discoverable",
|
||||||
|
]
|
||||||
|
help_texts = {f: None for f in fields}
|
||||||
|
widgets = {
|
||||||
|
"avatar": ClearableFileInputWithWarning(
|
||||||
|
attrs={"aria-describedby": "desc_avatar"}
|
||||||
|
),
|
||||||
|
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
||||||
|
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
|
||||||
|
"discoverable": forms.CheckboxInput(
|
||||||
|
attrs={"aria-describedby": "desc_discoverable"}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteUserForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = ["password"]
|
64
bookwyrm/forms/forms.py
Normal file
64
bookwyrm/forms/forms.py
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
""" using django model forms """
|
||||||
|
from django import forms
|
||||||
|
from django.forms import widgets
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from bookwyrm import models
|
||||||
|
from bookwyrm.models.user import FeedFilterChoices
|
||||||
|
from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=missing-class-docstring
|
||||||
|
class FeedStatusTypesForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = ["feed_status_types"]
|
||||||
|
help_texts = {f: None for f in fields}
|
||||||
|
widgets = {
|
||||||
|
"feed_status_types": widgets.CheckboxSelectMultiple(
|
||||||
|
choices=FeedFilterChoices,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ImportForm(forms.Form):
|
||||||
|
csv_file = forms.FileField()
|
||||||
|
|
||||||
|
|
||||||
|
class ShelfForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Shelf
|
||||||
|
fields = ["user", "name", "privacy", "description"]
|
||||||
|
|
||||||
|
|
||||||
|
class GoalForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.AnnualGoal
|
||||||
|
fields = ["user", "year", "goal", "privacy"]
|
||||||
|
|
||||||
|
|
||||||
|
class ReportForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Report
|
||||||
|
fields = ["user", "reporter", "status", "links", "note"]
|
||||||
|
|
||||||
|
|
||||||
|
class ReadThroughForm(CustomForm):
|
||||||
|
def clean(self):
|
||||||
|
"""don't let readthroughs end before they start"""
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
start_date = cleaned_data.get("start_date")
|
||||||
|
finish_date = cleaned_data.get("finish_date")
|
||||||
|
if start_date and finish_date and start_date > finish_date:
|
||||||
|
self.add_error(
|
||||||
|
"finish_date", _("Reading finish date cannot be before start date.")
|
||||||
|
)
|
||||||
|
stopped_date = cleaned_data.get("stopped_date")
|
||||||
|
if start_date and stopped_date and start_date > stopped_date:
|
||||||
|
self.add_error(
|
||||||
|
"stopped_date", _("Reading stopped date cannot be before start date.")
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.ReadThrough
|
||||||
|
fields = ["user", "book", "start_date", "finish_date", "stopped_date"]
|
16
bookwyrm/forms/groups.py
Normal file
16
bookwyrm/forms/groups.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
""" using django model forms """
|
||||||
|
from bookwyrm import models
|
||||||
|
from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=missing-class-docstring
|
||||||
|
class UserGroupForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = ["groups"]
|
||||||
|
|
||||||
|
|
||||||
|
class GroupForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Group
|
||||||
|
fields = ["user", "privacy", "name", "description"]
|
45
bookwyrm/forms/landing.py
Normal file
45
bookwyrm/forms/landing.py
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
""" Forms for the landing pages """
|
||||||
|
from django.forms import PasswordInput
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from bookwyrm import models
|
||||||
|
from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=missing-class-docstring
|
||||||
|
class LoginForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = ["localname", "password"]
|
||||||
|
help_texts = {f: None for f in fields}
|
||||||
|
widgets = {
|
||||||
|
"password": PasswordInput(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = ["localname", "email", "password"]
|
||||||
|
help_texts = {f: None for f in fields}
|
||||||
|
widgets = {"password": PasswordInput()}
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Check if the username is taken"""
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
localname = cleaned_data.get("localname").strip()
|
||||||
|
if models.User.objects.filter(localname=localname).first():
|
||||||
|
self.add_error("localname", _("User with this username already exists"))
|
||||||
|
|
||||||
|
|
||||||
|
class InviteRequestForm(CustomForm):
|
||||||
|
def clean(self):
|
||||||
|
"""make sure the email isn't in use by a registered user"""
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
email = cleaned_data.get("email")
|
||||||
|
if email and models.User.objects.filter(email=email).exists():
|
||||||
|
self.add_error("email", _("A user with this email already exists."))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.InviteRequest
|
||||||
|
fields = ["email", "answer"]
|
48
bookwyrm/forms/links.py
Normal file
48
bookwyrm/forms/links.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
""" using django model forms """
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from bookwyrm import models
|
||||||
|
from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=missing-class-docstring
|
||||||
|
class LinkDomainForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.LinkDomain
|
||||||
|
fields = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
class FileLinkForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.FileLink
|
||||||
|
fields = ["url", "filetype", "availability", "book", "added_by"]
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""make sure the domain isn't blocked or pending"""
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
url = cleaned_data.get("url")
|
||||||
|
filetype = cleaned_data.get("filetype")
|
||||||
|
book = cleaned_data.get("book")
|
||||||
|
domain = urlparse(url).netloc
|
||||||
|
if models.LinkDomain.objects.filter(domain=domain).exists():
|
||||||
|
status = models.LinkDomain.objects.get(domain=domain).status
|
||||||
|
if status == "blocked":
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
self.add_error(
|
||||||
|
"url",
|
||||||
|
_(
|
||||||
|
"This domain is blocked. Please contact your administrator if you think this is an error."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif models.FileLink.objects.filter(
|
||||||
|
url=url, book=book, filetype=filetype
|
||||||
|
).exists():
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
self.add_error(
|
||||||
|
"url",
|
||||||
|
_(
|
||||||
|
"This link with file type has already been added for this book. If it is not visible, the domain is still pending."
|
||||||
|
),
|
||||||
|
)
|
37
bookwyrm/forms/lists.py
Normal file
37
bookwyrm/forms/lists.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
""" using django model forms """
|
||||||
|
from django import forms
|
||||||
|
from django.forms import ChoiceField
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from bookwyrm import models
|
||||||
|
from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=missing-class-docstring
|
||||||
|
class ListForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.List
|
||||||
|
fields = ["user", "name", "description", "curation", "privacy", "group"]
|
||||||
|
|
||||||
|
|
||||||
|
class ListItemForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.ListItem
|
||||||
|
fields = ["user", "book", "book_list", "notes"]
|
||||||
|
|
||||||
|
|
||||||
|
class SortListForm(forms.Form):
|
||||||
|
sort_by = ChoiceField(
|
||||||
|
choices=(
|
||||||
|
("order", _("List Order")),
|
||||||
|
("title", _("Book Title")),
|
||||||
|
("rating", _("Rating")),
|
||||||
|
),
|
||||||
|
label=_("Sort By"),
|
||||||
|
)
|
||||||
|
direction = ChoiceField(
|
||||||
|
choices=(
|
||||||
|
("ascending", _("Ascending")),
|
||||||
|
("descending", _("Descending")),
|
||||||
|
),
|
||||||
|
)
|
82
bookwyrm/forms/status.py
Normal file
82
bookwyrm/forms/status.py
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
""" using django model forms """
|
||||||
|
from bookwyrm import models
|
||||||
|
from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=missing-class-docstring
|
||||||
|
class RatingForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.ReviewRating
|
||||||
|
fields = ["user", "book", "rating", "privacy"]
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Review
|
||||||
|
fields = [
|
||||||
|
"user",
|
||||||
|
"book",
|
||||||
|
"name",
|
||||||
|
"content",
|
||||||
|
"rating",
|
||||||
|
"content_warning",
|
||||||
|
"sensitive",
|
||||||
|
"privacy",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CommentForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Comment
|
||||||
|
fields = [
|
||||||
|
"user",
|
||||||
|
"book",
|
||||||
|
"content",
|
||||||
|
"content_warning",
|
||||||
|
"sensitive",
|
||||||
|
"privacy",
|
||||||
|
"progress",
|
||||||
|
"progress_mode",
|
||||||
|
"reading_status",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class QuotationForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Quotation
|
||||||
|
fields = [
|
||||||
|
"user",
|
||||||
|
"book",
|
||||||
|
"quote",
|
||||||
|
"content",
|
||||||
|
"content_warning",
|
||||||
|
"sensitive",
|
||||||
|
"privacy",
|
||||||
|
"position",
|
||||||
|
"position_mode",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ReplyForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Status
|
||||||
|
fields = [
|
||||||
|
"user",
|
||||||
|
"content",
|
||||||
|
"content_warning",
|
||||||
|
"sensitive",
|
||||||
|
"reply_parent",
|
||||||
|
"privacy",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class StatusForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Status
|
||||||
|
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
|
||||||
|
|
||||||
|
|
||||||
|
class DirectForm(CustomForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.Status
|
||||||
|
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
|
70
bookwyrm/forms/widgets.py
Normal file
70
bookwyrm/forms/widgets.py
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
""" using django model forms """
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
|
||||||
|
class ArrayWidget(forms.widgets.TextInput):
|
||||||
|
"""Inputs for postgres array fields"""
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
# pylint: disable=no-self-use
|
||||||
|
def value_from_datadict(self, data, files, name):
|
||||||
|
"""get all values for this name"""
|
||||||
|
return [i for i in data.getlist(name) if i]
|
||||||
|
|
||||||
|
|
||||||
|
class Select(forms.Select):
|
||||||
|
"""custom template for select widget"""
|
||||||
|
|
||||||
|
template_name = "widgets/select.html"
|
||||||
|
|
||||||
|
|
||||||
|
class SelectDateWidget(forms.SelectDateWidget):
|
||||||
|
"""
|
||||||
|
A widget that splits date input into two <select> boxes and a numerical year.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "widgets/addon_multiwidget.html"
|
||||||
|
select_widget = Select
|
||||||
|
|
||||||
|
def get_context(self, name, value, attrs):
|
||||||
|
"""sets individual widgets"""
|
||||||
|
context = super().get_context(name, value, attrs)
|
||||||
|
date_context = {}
|
||||||
|
year_name = self.year_field % name
|
||||||
|
date_context["year"] = forms.NumberInput().get_context(
|
||||||
|
name=year_name,
|
||||||
|
value=context["widget"]["value"]["year"],
|
||||||
|
attrs={
|
||||||
|
**context["widget"]["attrs"],
|
||||||
|
"id": f"id_{year_name}",
|
||||||
|
"class": "input",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
month_choices = list(self.months.items())
|
||||||
|
if not self.is_required:
|
||||||
|
month_choices.insert(0, self.month_none_value)
|
||||||
|
month_name = self.month_field % name
|
||||||
|
date_context["month"] = self.select_widget(
|
||||||
|
attrs, choices=month_choices
|
||||||
|
).get_context(
|
||||||
|
name=month_name,
|
||||||
|
value=context["widget"]["value"]["month"],
|
||||||
|
attrs={**context["widget"]["attrs"], "id": f"id_{month_name}"},
|
||||||
|
)
|
||||||
|
day_choices = [(i, i) for i in range(1, 32)]
|
||||||
|
if not self.is_required:
|
||||||
|
day_choices.insert(0, self.day_none_value)
|
||||||
|
day_name = self.day_field % name
|
||||||
|
date_context["day"] = self.select_widget(
|
||||||
|
attrs,
|
||||||
|
choices=day_choices,
|
||||||
|
).get_context(
|
||||||
|
name=day_name,
|
||||||
|
value=context["widget"]["value"]["day"],
|
||||||
|
attrs={**context["widget"]["attrs"], "id": f"id_{day_name}"},
|
||||||
|
)
|
||||||
|
subwidgets = []
|
||||||
|
for field in self._parse_date_fmt():
|
||||||
|
subwidgets.append(date_context[field]["widget"])
|
||||||
|
context["widget"]["subwidgets"] = subwidgets
|
||||||
|
return context
|
|
@ -1,6 +1,7 @@
|
||||||
""" import classes """
|
""" import classes """
|
||||||
|
|
||||||
from .importer import Importer
|
from .importer import Importer
|
||||||
|
from .calibre_import import CalibreImporter
|
||||||
from .goodreads_import import GoodreadsImporter
|
from .goodreads_import import GoodreadsImporter
|
||||||
from .librarything_import import LibrarythingImporter
|
from .librarything_import import LibrarythingImporter
|
||||||
from .openlibrary_import import OpenLibraryImporter
|
from .openlibrary_import import OpenLibraryImporter
|
||||||
|
|
28
bookwyrm/importers/calibre_import.py
Normal file
28
bookwyrm/importers/calibre_import.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
""" handle reading a csv from calibre """
|
||||||
|
from bookwyrm.models import Shelf
|
||||||
|
|
||||||
|
from . import Importer
|
||||||
|
|
||||||
|
|
||||||
|
class CalibreImporter(Importer):
|
||||||
|
"""csv downloads from Calibre"""
|
||||||
|
|
||||||
|
service = "Calibre"
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
# Add timestamp to row_mappings_guesses for date_added to avoid
|
||||||
|
# integrity error
|
||||||
|
row_mappings_guesses = []
|
||||||
|
|
||||||
|
for field, mapping in self.row_mappings_guesses:
|
||||||
|
if field in ("date_added",):
|
||||||
|
row_mappings_guesses.append((field, mapping + ["timestamp"]))
|
||||||
|
else:
|
||||||
|
row_mappings_guesses.append((field, mapping))
|
||||||
|
|
||||||
|
self.row_mappings_guesses = row_mappings_guesses
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_shelf(self, normalized_row):
|
||||||
|
# Calibre export does not indicate which shelf to use. Go with a default one for now
|
||||||
|
return Shelf.TO_READ
|
|
@ -1,5 +1,8 @@
|
||||||
""" handle reading a tsv from librarything """
|
""" handle reading a tsv from librarything """
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from bookwyrm.models import Shelf
|
||||||
|
|
||||||
from . import Importer
|
from . import Importer
|
||||||
|
|
||||||
|
|
||||||
|
@ -21,7 +24,7 @@ class LibrarythingImporter(Importer):
|
||||||
|
|
||||||
def get_shelf(self, normalized_row):
|
def get_shelf(self, normalized_row):
|
||||||
if normalized_row["date_finished"]:
|
if normalized_row["date_finished"]:
|
||||||
return "read"
|
return Shelf.READ_FINISHED
|
||||||
if normalized_row["date_started"]:
|
if normalized_row["date_started"]:
|
||||||
return "reading"
|
return Shelf.READING
|
||||||
return "to-read"
|
return Shelf.TO_READ
|
||||||
|
|
23
bookwyrm/management/commands/admin_code.py
Normal file
23
bookwyrm/management/commands/admin_code.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
""" Get your admin code to allow install """
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from bookwyrm import models
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_code():
|
||||||
|
"""get that code"""
|
||||||
|
return models.SiteSettings.objects.get().admin_code
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""command-line options"""
|
||||||
|
|
||||||
|
help = "Gets admin code for configuring BookWyrm"
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
"""execute init"""
|
||||||
|
self.stdout.write("*******************************************")
|
||||||
|
self.stdout.write("Use this code to create your admin account:")
|
||||||
|
self.stdout.write(get_admin_code())
|
||||||
|
self.stdout.write("*******************************************")
|
|
@ -7,6 +7,7 @@ from bookwyrm import settings
|
||||||
r = redis.Redis(
|
r = redis.Redis(
|
||||||
host=settings.REDIS_ACTIVITY_HOST,
|
host=settings.REDIS_ACTIVITY_HOST,
|
||||||
port=settings.REDIS_ACTIVITY_PORT,
|
port=settings.REDIS_ACTIVITY_PORT,
|
||||||
|
password=settings.REDIS_ACTIVITY_PASSWORD,
|
||||||
db=settings.REDIS_ACTIVITY_DB_INDEX,
|
db=settings.REDIS_ACTIVITY_DB_INDEX,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,9 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
help = "Generate preview images"
|
help = "Generate preview images"
|
||||||
|
|
||||||
|
# pylint: disable=no-self-use
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
|
"""options for how the command is run"""
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--all",
|
"--all",
|
||||||
"-a",
|
"-a",
|
||||||
|
@ -38,6 +40,7 @@ class Command(BaseCommand):
|
||||||
preview_images.generate_site_preview_image_task.delay()
|
preview_images.generate_site_preview_image_task.delay()
|
||||||
self.stdout.write(" OK 🖼")
|
self.stdout.write(" OK 🖼")
|
||||||
|
|
||||||
|
# pylint: disable=consider-using-f-string
|
||||||
if options["all"]:
|
if options["all"]:
|
||||||
# Users
|
# Users
|
||||||
users = models.User.objects.filter(
|
users = models.User.objects.filter(
|
||||||
|
@ -53,12 +56,17 @@ class Command(BaseCommand):
|
||||||
self.stdout.write(" OK 🖼")
|
self.stdout.write(" OK 🖼")
|
||||||
|
|
||||||
# Books
|
# Books
|
||||||
books = models.Book.objects.select_subclasses().filter()
|
book_ids = (
|
||||||
self.stdout.write(
|
models.Book.objects.select_subclasses()
|
||||||
" → Book preview images ({}): ".format(len(books)), ending=""
|
.filter()
|
||||||
|
.values_list("id", flat=True)
|
||||||
)
|
)
|
||||||
for book in books:
|
|
||||||
preview_images.generate_edition_preview_image_task.delay(book.id)
|
self.stdout.write(
|
||||||
|
" → Book preview images ({}): ".format(len(book_ids)), ending=""
|
||||||
|
)
|
||||||
|
for book_id in book_ids:
|
||||||
|
preview_images.generate_edition_preview_image_task.delay(book_id)
|
||||||
self.stdout.write(".", ending="")
|
self.stdout.write(".", ending="")
|
||||||
self.stdout.write(" OK 🖼")
|
self.stdout.write(" OK 🖼")
|
||||||
|
|
||||||
|
|
|
@ -89,7 +89,7 @@ def init_connectors():
|
||||||
covers_url="https://inventaire.io",
|
covers_url="https://inventaire.io",
|
||||||
search_url="https://inventaire.io/api/search?types=works&types=works&search=",
|
search_url="https://inventaire.io/api/search?types=works&types=works&search=",
|
||||||
isbn_search_url="https://inventaire.io/api/entities?action=by-uris&uris=isbn%3A",
|
isbn_search_url="https://inventaire.io/api/entities?action=by-uris&uris=isbn%3A",
|
||||||
priority=3,
|
priority=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
models.Connector.objects.create(
|
models.Connector.objects.create(
|
||||||
|
@ -101,25 +101,16 @@ def init_connectors():
|
||||||
covers_url="https://covers.openlibrary.org",
|
covers_url="https://covers.openlibrary.org",
|
||||||
search_url="https://openlibrary.org/search?q=",
|
search_url="https://openlibrary.org/search?q=",
|
||||||
isbn_search_url="https://openlibrary.org/api/books?jscmd=data&format=json&bibkeys=ISBN:",
|
isbn_search_url="https://openlibrary.org/api/books?jscmd=data&format=json&bibkeys=ISBN:",
|
||||||
priority=3,
|
priority=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def init_federated_servers():
|
|
||||||
"""big no to nazis"""
|
|
||||||
built_in_blocks = ["gab.ai", "gab.com"]
|
|
||||||
for server in built_in_blocks:
|
|
||||||
models.FederatedServer.objects.create(
|
|
||||||
server_name=server,
|
|
||||||
status="blocked",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def init_settings():
|
def init_settings():
|
||||||
"""info about the instance"""
|
"""info about the instance"""
|
||||||
models.SiteSettings.objects.create(
|
models.SiteSettings.objects.create(
|
||||||
support_link="https://www.patreon.com/bookwyrm",
|
support_link="https://www.patreon.com/bookwyrm",
|
||||||
support_title="Patreon",
|
support_title="Patreon",
|
||||||
|
install_mode=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -162,7 +153,6 @@ class Command(BaseCommand):
|
||||||
"group",
|
"group",
|
||||||
"permission",
|
"permission",
|
||||||
"connector",
|
"connector",
|
||||||
"federatedserver",
|
|
||||||
"settings",
|
"settings",
|
||||||
"linkdomain",
|
"linkdomain",
|
||||||
]
|
]
|
||||||
|
@ -175,8 +165,6 @@ class Command(BaseCommand):
|
||||||
init_permissions()
|
init_permissions()
|
||||||
if not limit or limit == "connector":
|
if not limit or limit == "connector":
|
||||||
init_connectors()
|
init_connectors()
|
||||||
if not limit or limit == "federatedserver":
|
|
||||||
init_federated_servers()
|
|
||||||
if not limit or limit == "settings":
|
if not limit or limit == "settings":
|
||||||
init_settings()
|
init_settings()
|
||||||
if not limit or limit == "linkdomain":
|
if not limit or limit == "linkdomain":
|
||||||
|
|
54
bookwyrm/management/commands/instance_version.py
Normal file
54
bookwyrm/management/commands/instance_version.py
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
""" Get your admin code to allow install """
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from bookwyrm import models
|
||||||
|
from bookwyrm.settings import VERSION
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=no-self-use
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""command-line options"""
|
||||||
|
|
||||||
|
help = "What version is this?"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
"""specify which function to run"""
|
||||||
|
parser.add_argument(
|
||||||
|
"--current",
|
||||||
|
action="store_true",
|
||||||
|
help="Version stored in database",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--target",
|
||||||
|
action="store_true",
|
||||||
|
help="Version stored in settings",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--update",
|
||||||
|
action="store_true",
|
||||||
|
help="Update database version",
|
||||||
|
)
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
"""execute init"""
|
||||||
|
site = models.SiteSettings.objects.get()
|
||||||
|
current = site.version or "0.0.1"
|
||||||
|
target = VERSION
|
||||||
|
if options.get("current"):
|
||||||
|
print(current)
|
||||||
|
return
|
||||||
|
|
||||||
|
if options.get("target"):
|
||||||
|
print(target)
|
||||||
|
return
|
||||||
|
|
||||||
|
if options.get("update"):
|
||||||
|
site.version = target
|
||||||
|
site.save()
|
||||||
|
return
|
||||||
|
|
||||||
|
if current != target:
|
||||||
|
print(f"{current}/{target}")
|
||||||
|
else:
|
||||||
|
print(current)
|
21
bookwyrm/migrations/0133_alter_listitem_notes.py
Normal file
21
bookwyrm/migrations/0133_alter_listitem_notes.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
# Generated by Django 3.2.11 on 2022-02-04 20:06
|
||||||
|
|
||||||
|
import bookwyrm.models.fields
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0132_alter_user_preferred_language"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="listitem",
|
||||||
|
name="notes",
|
||||||
|
field=bookwyrm.models.fields.HtmlField(
|
||||||
|
blank=True, max_length=300, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
29
bookwyrm/migrations/0134_announcement_display_type.py
Normal file
29
bookwyrm/migrations/0134_announcement_display_type.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Django 3.2.11 on 2022-02-11 18:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0133_alter_listitem_notes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="announcement",
|
||||||
|
name="display_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("white-ter", "None"),
|
||||||
|
("primary-light", "Primary"),
|
||||||
|
("success-light", "Success"),
|
||||||
|
("link-light", "Link"),
|
||||||
|
("warning-light", "Warning"),
|
||||||
|
("danger-light", "Danger"),
|
||||||
|
],
|
||||||
|
default="white-ter",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
58
bookwyrm/migrations/0135_auto_20220217_1624.py
Normal file
58
bookwyrm/migrations/0135_auto_20220217_1624.py
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-02-17 16:24
|
||||||
|
|
||||||
|
import bookwyrm.models.fields
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0134_announcement_display_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="author",
|
||||||
|
old_name="viaf_id",
|
||||||
|
new_name="viaf",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="edition",
|
||||||
|
name="asin",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="author",
|
||||||
|
name="asin",
|
||||||
|
field=bookwyrm.models.fields.CharField(
|
||||||
|
blank=True, max_length=255, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="author",
|
||||||
|
name="wikidata",
|
||||||
|
field=bookwyrm.models.fields.CharField(
|
||||||
|
blank=True, max_length=255, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="asin",
|
||||||
|
field=bookwyrm.models.fields.CharField(
|
||||||
|
blank=True, max_length=255, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="viaf",
|
||||||
|
field=bookwyrm.models.fields.CharField(
|
||||||
|
blank=True, max_length=255, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="wikidata",
|
||||||
|
field=bookwyrm.models.fields.CharField(
|
||||||
|
blank=True, max_length=255, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
24
bookwyrm/migrations/0136_auto_20220217_1708.py
Normal file
24
bookwyrm/migrations/0136_auto_20220217_1708.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-02-17 17:08
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0135_auto_20220217_1624"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sitesettings",
|
||||||
|
name="admin_code",
|
||||||
|
field=models.CharField(default=uuid.uuid4, max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sitesettings",
|
||||||
|
name="install_mode",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-02-17 19:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0136_auto_20220217_1708"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sitesettings",
|
||||||
|
name="allow_registration",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
39
bookwyrm/migrations/0138_automod.py
Normal file
39
bookwyrm/migrations/0138_automod.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-02-24 18:59
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0137_alter_sitesettings_allow_registration"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="AutoMod",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("string_match", models.CharField(max_length=200, unique=True)),
|
||||||
|
("flag_users", models.BooleanField(default=True)),
|
||||||
|
("flag_statuses", models.BooleanField(default=True)),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
45
bookwyrm/migrations/0139_report_status.py
Normal file
45
bookwyrm/migrations/0139_report_status.py
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-02-24 20:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def set_report_statuses(apps, schema_editor):
|
||||||
|
"""copy over status fields"""
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
report_model = apps.get_model("bookwyrm", "Report")
|
||||||
|
reports = report_model.objects.using(db_alias).filter(statuses__isnull=False)
|
||||||
|
for report in reports:
|
||||||
|
report.status = report.statuses.first()
|
||||||
|
report.save()
|
||||||
|
|
||||||
|
|
||||||
|
def set_reverse(apps, schema_editor):
|
||||||
|
"""copy over status fields"""
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
report_model = apps.get_model("bookwyrm", "Report")
|
||||||
|
reports = report_model.objects.using(db_alias).filter(status__isnull=False)
|
||||||
|
for report in reports:
|
||||||
|
report.statuses.set(report.status)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0138_automod"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="report",
|
||||||
|
name="status",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="reports",
|
||||||
|
to="bookwyrm.status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(set_report_statuses, reverse_code=set_reverse),
|
||||||
|
]
|
17
bookwyrm/migrations/0140_remove_report_statuses.py
Normal file
17
bookwyrm/migrations/0140_remove_report_statuses.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-02-24 20:43
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0139_report_status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="report",
|
||||||
|
name="statuses",
|
||||||
|
),
|
||||||
|
]
|
24
bookwyrm/migrations/0141_alter_report_status.py
Normal file
24
bookwyrm/migrations/0141_alter_report_status.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-02-24 20:50
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0140_remove_report_statuses"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="report",
|
||||||
|
name="status",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to="bookwyrm.status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
68
bookwyrm/migrations/0142_auto_20220227_1752.py
Normal file
68
bookwyrm/migrations/0142_auto_20220227_1752.py
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-02-27 17:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def add_default_themes(apps, schema_editor):
|
||||||
|
"""add light and dark themes"""
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
theme_model = apps.get_model("bookwyrm", "Theme")
|
||||||
|
theme_model.objects.using(db_alias).create(
|
||||||
|
name="BookWyrm Light",
|
||||||
|
path="css/themes/bookwyrm-light.scss",
|
||||||
|
)
|
||||||
|
theme_model.objects.using(db_alias).create(
|
||||||
|
name="BookWyrm Dark",
|
||||||
|
path="css/themes/bookwyrm-dark.scss",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0141_alter_report_status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Theme",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_date", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("name", models.CharField(max_length=50, unique=True)),
|
||||||
|
("path", models.CharField(max_length=50, unique=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sitesettings",
|
||||||
|
name="default_theme",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="bookwyrm.theme",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="theme",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="bookwyrm.theme",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
add_default_themes, reverse_code=migrations.RunPython.noop
|
||||||
|
),
|
||||||
|
]
|
19
bookwyrm/migrations/0142_user_hide_follows.py
Normal file
19
bookwyrm/migrations/0142_user_hide_follows.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-02-28 19:44
|
||||||
|
|
||||||
|
import bookwyrm.models.fields
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0141_alter_report_status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="hide_follows",
|
||||||
|
field=bookwyrm.models.fields.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-02-28 21:28
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0142_auto_20220227_1752"),
|
||||||
|
("bookwyrm", "0142_user_hide_follows"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
39
bookwyrm/migrations/0144_alter_announcement_display_type.py
Normal file
39
bookwyrm/migrations/0144_alter_announcement_display_type.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-03-01 18:46
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def remove_white(apps, schema_editor):
|
||||||
|
"""don't hardcode white announcements"""
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
announcement_model = apps.get_model("bookwyrm", "Announcement")
|
||||||
|
announcement_model.objects.using(db_alias).filter(display_type="white-ter").update(
|
||||||
|
display_type=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0143_merge_0142_auto_20220227_1752_0142_user_hide_follows"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="announcement",
|
||||||
|
name="display_type",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("primary-light", "Primary"),
|
||||||
|
("success-light", "Success"),
|
||||||
|
("link-light", "Link"),
|
||||||
|
("warning-light", "Warning"),
|
||||||
|
("danger-light", "Danger"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(remove_white, reverse_code=migrations.RunPython.noop),
|
||||||
|
]
|
18
bookwyrm/migrations/0145_sitesettings_version.py
Normal file
18
bookwyrm/migrations/0145_sitesettings_version.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-03-16 18:10
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0144_alter_announcement_display_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sitesettings",
|
||||||
|
name="version",
|
||||||
|
field=models.CharField(blank=True, max_length=10, null=True),
|
||||||
|
),
|
||||||
|
]
|
80
bookwyrm/migrations/0146_auto_20220316_2320.py
Normal file
80
bookwyrm/migrations/0146_auto_20220316_2320.py
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-03-16 23:20
|
||||||
|
|
||||||
|
import bookwyrm.models.fields
|
||||||
|
from django.db import migrations
|
||||||
|
from bookwyrm.models import Shelf
|
||||||
|
|
||||||
|
|
||||||
|
def add_shelves(apps, schema_editor):
|
||||||
|
"""add any superusers to the "admin" group"""
|
||||||
|
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
shelf_model = apps.get_model("bookwyrm", "Shelf")
|
||||||
|
|
||||||
|
users = apps.get_model("bookwyrm", "User")
|
||||||
|
local_users = users.objects.using(db_alias).filter(local=True)
|
||||||
|
for user in local_users:
|
||||||
|
remote_id = f"{user.remote_id}/books/stopped"
|
||||||
|
shelf_model.objects.using(db_alias).create(
|
||||||
|
name="Stopped reading",
|
||||||
|
identifier=Shelf.STOPPED_READING,
|
||||||
|
user=user,
|
||||||
|
editable=False,
|
||||||
|
remote_id=remote_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0145_sitesettings_version"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="comment",
|
||||||
|
name="reading_status",
|
||||||
|
field=bookwyrm.models.fields.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("to-read", "To-Read"),
|
||||||
|
("reading", "Reading"),
|
||||||
|
("read", "Read"),
|
||||||
|
("stopped-reading", "Stopped-Reading"),
|
||||||
|
],
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="quotation",
|
||||||
|
name="reading_status",
|
||||||
|
field=bookwyrm.models.fields.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("to-read", "To-Read"),
|
||||||
|
("reading", "Reading"),
|
||||||
|
("read", "Read"),
|
||||||
|
("stopped-reading", "Stopped-Reading"),
|
||||||
|
],
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="review",
|
||||||
|
name="reading_status",
|
||||||
|
field=bookwyrm.models.fields.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("to-read", "To-Read"),
|
||||||
|
("reading", "Reading"),
|
||||||
|
("read", "Read"),
|
||||||
|
("stopped-reading", "Stopped-Reading"),
|
||||||
|
],
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(add_shelves, reverse_code=migrations.RunPython.noop),
|
||||||
|
]
|
30
bookwyrm/migrations/0146_auto_20220316_2352.py
Normal file
30
bookwyrm/migrations/0146_auto_20220316_2352.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-03-16 23:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0145_sitesettings_version"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="inviterequest",
|
||||||
|
name="answer",
|
||||||
|
field=models.TextField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sitesettings",
|
||||||
|
name="invite_question_text",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, default="What is your favourite book?", max_length=255
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sitesettings",
|
||||||
|
name="invite_request_question",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
38
bookwyrm/migrations/0147_alter_user_preferred_language.py
Normal file
38
bookwyrm/migrations/0147_alter_user_preferred_language.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-03-26 16:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0146_auto_20220316_2352"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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)"),
|
||||||
|
("gl-es", "Galego (Galician)"),
|
||||||
|
("it-it", "Italiano (Italian)"),
|
||||||
|
("fr-fr", "Français (French)"),
|
||||||
|
("lt-lt", "Lietuvių (Lithuanian)"),
|
||||||
|
("no-no", "Norsk (Norwegian)"),
|
||||||
|
("pt-br", "Português do Brasil (Brazilian Portuguese)"),
|
||||||
|
("pt-pt", "Português Europeu (European Portuguese)"),
|
||||||
|
("ro-ro", "Română (Romanian)"),
|
||||||
|
("sv-se", "Svenska (Swedish)"),
|
||||||
|
("zh-hans", "简体中文 (Simplified Chinese)"),
|
||||||
|
("zh-hant", "繁體中文 (Traditional Chinese)"),
|
||||||
|
],
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
39
bookwyrm/migrations/0148_alter_user_preferred_language.py
Normal file
39
bookwyrm/migrations/0148_alter_user_preferred_language.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-03-31 14:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0147_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)"),
|
||||||
|
("gl-es", "Galego (Galician)"),
|
||||||
|
("it-it", "Italiano (Italian)"),
|
||||||
|
("fi-fi", "Suomi (Finnish)"),
|
||||||
|
("fr-fr", "Français (French)"),
|
||||||
|
("lt-lt", "Lietuvių (Lithuanian)"),
|
||||||
|
("no-no", "Norsk (Norwegian)"),
|
||||||
|
("pt-br", "Português do Brasil (Brazilian Portuguese)"),
|
||||||
|
("pt-pt", "Português Europeu (European Portuguese)"),
|
||||||
|
("ro-ro", "Română (Romanian)"),
|
||||||
|
("sv-se", "Svenska (Swedish)"),
|
||||||
|
("zh-hans", "简体中文 (Simplified Chinese)"),
|
||||||
|
("zh-hant", "繁體中文 (Traditional Chinese)"),
|
||||||
|
],
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
13
bookwyrm/migrations/0148_merge_20220326_2006.py
Normal file
13
bookwyrm/migrations/0148_merge_20220326_2006.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
# Generated by Django 3.2.12 on 2022-03-26 20:06
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0146_auto_20220316_2320"),
|
||||||
|
("bookwyrm", "0147_alter_user_preferred_language"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
13
bookwyrm/migrations/0149_merge_20220526_1716.py
Normal file
13
bookwyrm/migrations/0149_merge_20220526_1716.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
# Generated by Django 3.2.13 on 2022-05-26 17:16
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0148_alter_user_preferred_language"),
|
||||||
|
("bookwyrm", "0148_merge_20220326_2006"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
18
bookwyrm/migrations/0150_readthrough_stopped_date.py
Normal file
18
bookwyrm/migrations/0150_readthrough_stopped_date.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 3.2.13 on 2022-05-26 18:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0149_merge_20220526_1716"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="readthrough",
|
||||||
|
name="stopped_date",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -26,10 +26,10 @@ from .group import Group, GroupMember, GroupMemberInvitation
|
||||||
|
|
||||||
from .import_job import ImportJob, ImportItem
|
from .import_job import ImportJob, ImportItem
|
||||||
|
|
||||||
from .site import SiteSettings, SiteInvite
|
from .site import SiteSettings, Theme, SiteInvite
|
||||||
from .site import PasswordReset, InviteRequest
|
from .site import PasswordReset, InviteRequest
|
||||||
from .announcement import Announcement
|
from .announcement import Announcement
|
||||||
from .antispam import EmailBlocklist, IPBlocklist
|
from .antispam import EmailBlocklist, IPBlocklist, AutoMod, automod_task
|
||||||
|
|
||||||
from .notification import Notification
|
from .notification import Notification
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,20 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from .base_model import BookWyrmModel
|
from .base_model import BookWyrmModel
|
||||||
|
|
||||||
|
|
||||||
|
DisplayTypes = [
|
||||||
|
("primary-light", _("Primary")),
|
||||||
|
("success-light", _("Success")),
|
||||||
|
("link-light", _("Link")),
|
||||||
|
("warning-light", _("Warning")),
|
||||||
|
("danger-light", _("Danger")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Announcement(BookWyrmModel):
|
class Announcement(BookWyrmModel):
|
||||||
"""The admin has something to say"""
|
"""The admin has something to say"""
|
||||||
|
|
||||||
|
@ -16,6 +26,9 @@ class Announcement(BookWyrmModel):
|
||||||
start_date = models.DateTimeField(blank=True, null=True)
|
start_date = models.DateTimeField(blank=True, null=True)
|
||||||
end_date = models.DateTimeField(blank=True, null=True)
|
end_date = models.DateTimeField(blank=True, null=True)
|
||||||
active = models.BooleanField(default=True)
|
active = models.BooleanField(default=True)
|
||||||
|
display_type = models.CharField(
|
||||||
|
max_length=20, choices=DisplayTypes, null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def active_announcements(cls):
|
def active_announcements(cls):
|
||||||
|
|
|
@ -1,6 +1,13 @@
|
||||||
""" Lets try NOT to sell viagra """
|
""" Lets try NOT to sell viagra """
|
||||||
from django.db import models
|
from functools import reduce
|
||||||
|
import operator
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from bookwyrm.tasks import app
|
||||||
from .user import User
|
from .user import User
|
||||||
|
|
||||||
|
|
||||||
|
@ -33,3 +40,107 @@ class IPBlocklist(models.Model):
|
||||||
"""default sorting"""
|
"""default sorting"""
|
||||||
|
|
||||||
ordering = ("-created_date",)
|
ordering = ("-created_date",)
|
||||||
|
|
||||||
|
|
||||||
|
class AutoMod(models.Model):
|
||||||
|
"""rules to automatically flag suspicious activity"""
|
||||||
|
|
||||||
|
string_match = models.CharField(max_length=200, unique=True)
|
||||||
|
flag_users = models.BooleanField(default=True)
|
||||||
|
flag_statuses = models.BooleanField(default=True)
|
||||||
|
created_by = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(queue="low_priority")
|
||||||
|
def automod_task():
|
||||||
|
"""Create reports"""
|
||||||
|
if not AutoMod.objects.exists():
|
||||||
|
return
|
||||||
|
reporter = AutoMod.objects.first().created_by
|
||||||
|
reports = automod_users(reporter) + automod_statuses(reporter)
|
||||||
|
if reports:
|
||||||
|
admins = User.objects.filter(
|
||||||
|
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
||||||
|
| models.Q(is_superuser=True)
|
||||||
|
).all()
|
||||||
|
notification_model = apps.get_model(
|
||||||
|
"bookwyrm", "Notification", require_ready=True
|
||||||
|
)
|
||||||
|
for admin in admins:
|
||||||
|
notification_model.objects.bulk_create(
|
||||||
|
[
|
||||||
|
notification_model(
|
||||||
|
user=admin,
|
||||||
|
related_report=r,
|
||||||
|
notification_type="REPORT",
|
||||||
|
)
|
||||||
|
for r in reports
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def automod_users(reporter):
|
||||||
|
"""check users for moderation flags"""
|
||||||
|
user_rules = AutoMod.objects.filter(flag_users=True).values_list(
|
||||||
|
"string_match", flat=True
|
||||||
|
)
|
||||||
|
if not user_rules:
|
||||||
|
return []
|
||||||
|
|
||||||
|
filters = []
|
||||||
|
for field in ["username", "summary", "name"]:
|
||||||
|
filters += [{f"{field}__icontains": r} for r in user_rules]
|
||||||
|
users = User.objects.filter(
|
||||||
|
reduce(operator.or_, (Q(**f) for f in filters)),
|
||||||
|
is_active=True,
|
||||||
|
local=True,
|
||||||
|
report__isnull=True, # don't flag users that already have reports
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
report_model = apps.get_model("bookwyrm", "Report", require_ready=True)
|
||||||
|
|
||||||
|
return report_model.objects.bulk_create(
|
||||||
|
[
|
||||||
|
report_model(
|
||||||
|
reporter=reporter,
|
||||||
|
note=_("Automatically generated report"),
|
||||||
|
user=u,
|
||||||
|
)
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def automod_statuses(reporter):
|
||||||
|
"""check statues for moderation flags"""
|
||||||
|
status_rules = AutoMod.objects.filter(flag_statuses=True).values_list(
|
||||||
|
"string_match", flat=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not status_rules:
|
||||||
|
return []
|
||||||
|
|
||||||
|
filters = []
|
||||||
|
for field in ["content", "content_warning", "quotation__quote", "review__name"]:
|
||||||
|
filters += [{f"{field}__icontains": r} for r in status_rules]
|
||||||
|
|
||||||
|
status_model = apps.get_model("bookwyrm", "Status", require_ready=True)
|
||||||
|
statuses = status_model.objects.filter(
|
||||||
|
reduce(operator.or_, (Q(**f) for f in filters)),
|
||||||
|
deleted=False,
|
||||||
|
local=True,
|
||||||
|
report__isnull=True, # don't flag statuses that already have reports
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
report_model = apps.get_model("bookwyrm", "Report", require_ready=True)
|
||||||
|
return report_model.objects.bulk_create(
|
||||||
|
[
|
||||||
|
report_model(
|
||||||
|
reporter=reporter,
|
||||||
|
note=_("Automatically generated report"),
|
||||||
|
user=s.user,
|
||||||
|
status=s,
|
||||||
|
)
|
||||||
|
for s in statuses
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
|
@ -21,9 +21,6 @@ class Author(BookDataModel):
|
||||||
isni = fields.CharField(
|
isni = fields.CharField(
|
||||||
max_length=255, blank=True, null=True, deduplication_field=True
|
max_length=255, blank=True, null=True, deduplication_field=True
|
||||||
)
|
)
|
||||||
viaf_id = fields.CharField(
|
|
||||||
max_length=255, blank=True, null=True, deduplication_field=True
|
|
||||||
)
|
|
||||||
gutenberg_id = fields.CharField(
|
gutenberg_id = fields.CharField(
|
||||||
max_length=255, blank=True, null=True, deduplication_field=True
|
max_length=255, blank=True, null=True, deduplication_field=True
|
||||||
)
|
)
|
||||||
|
|
|
@ -8,6 +8,7 @@ from django.db.models import Q
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
from bookwyrm.settings import DOMAIN
|
from bookwyrm.settings import DOMAIN
|
||||||
from .fields import RemoteIdField
|
from .fields import RemoteIdField
|
||||||
|
@ -35,10 +36,11 @@ class BookWyrmModel(models.Model):
|
||||||
remote_id = RemoteIdField(null=True, activitypub_field="id")
|
remote_id = RemoteIdField(null=True, activitypub_field="id")
|
||||||
|
|
||||||
def get_remote_id(self):
|
def get_remote_id(self):
|
||||||
"""generate a url that resolves to the local object"""
|
"""generate the url that resolves to the local object, without a slug"""
|
||||||
base_path = f"https://{DOMAIN}"
|
base_path = f"https://{DOMAIN}"
|
||||||
if hasattr(self, "user"):
|
if hasattr(self, "user"):
|
||||||
base_path = f"{base_path}{self.user.local_path}"
|
base_path = f"{base_path}{self.user.local_path}"
|
||||||
|
|
||||||
model_name = type(self).__name__.lower()
|
model_name = type(self).__name__.lower()
|
||||||
return f"{base_path}/{model_name}/{self.id}"
|
return f"{base_path}/{model_name}/{self.id}"
|
||||||
|
|
||||||
|
@ -49,8 +51,20 @@ class BookWyrmModel(models.Model):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def local_path(self):
|
def local_path(self):
|
||||||
"""how to link to this object in the local app"""
|
"""how to link to this object in the local app, with a slug"""
|
||||||
return self.get_remote_id().replace(f"https://{DOMAIN}", "")
|
local = self.get_remote_id().replace(f"https://{DOMAIN}", "")
|
||||||
|
|
||||||
|
name = None
|
||||||
|
if hasattr(self, "name_field"):
|
||||||
|
name = getattr(self, self.name_field)
|
||||||
|
elif hasattr(self, "name"):
|
||||||
|
name = self.name
|
||||||
|
|
||||||
|
if name:
|
||||||
|
slug = slugify(name)
|
||||||
|
local = f"{local}/s/{slug}"
|
||||||
|
|
||||||
|
return local
|
||||||
|
|
||||||
def raise_visible_to_user(self, viewer):
|
def raise_visible_to_user(self, viewer):
|
||||||
"""is a user authorized to view an object?"""
|
"""is a user authorized to view an object?"""
|
||||||
|
|
|
@ -46,6 +46,15 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
||||||
bnf_id = fields.CharField( # Bibliothèque nationale de France
|
bnf_id = fields.CharField( # Bibliothèque nationale de France
|
||||||
max_length=255, blank=True, null=True, deduplication_field=True
|
max_length=255, blank=True, null=True, deduplication_field=True
|
||||||
)
|
)
|
||||||
|
viaf = fields.CharField(
|
||||||
|
max_length=255, blank=True, null=True, deduplication_field=True
|
||||||
|
)
|
||||||
|
wikidata = fields.CharField(
|
||||||
|
max_length=255, blank=True, null=True, deduplication_field=True
|
||||||
|
)
|
||||||
|
asin = fields.CharField(
|
||||||
|
max_length=255, blank=True, null=True, deduplication_field=True
|
||||||
|
)
|
||||||
search_vector = SearchVectorField(null=True)
|
search_vector = SearchVectorField(null=True)
|
||||||
|
|
||||||
last_edited_by = fields.ForeignKey(
|
last_edited_by = fields.ForeignKey(
|
||||||
|
@ -167,8 +176,8 @@ class Book(BookDataModel):
|
||||||
"""properties of this edition, as a string"""
|
"""properties of this edition, as a string"""
|
||||||
items = [
|
items = [
|
||||||
self.physical_format if hasattr(self, "physical_format") else None,
|
self.physical_format if hasattr(self, "physical_format") else None,
|
||||||
self.languages[0] + " language"
|
f"{self.languages[0]} language"
|
||||||
if self.languages and self.languages[0] != "English"
|
if self.languages and self.languages[0] and self.languages[0] != "English"
|
||||||
else None,
|
else None,
|
||||||
str(self.published_date.year) if self.published_date else None,
|
str(self.published_date.year) if self.published_date else None,
|
||||||
", ".join(self.publishers) if hasattr(self, "publishers") else None,
|
", ".join(self.publishers) if hasattr(self, "publishers") else None,
|
||||||
|
@ -271,9 +280,6 @@ class Edition(Book):
|
||||||
oclc_number = fields.CharField(
|
oclc_number = fields.CharField(
|
||||||
max_length=255, blank=True, null=True, deduplication_field=True
|
max_length=255, blank=True, null=True, deduplication_field=True
|
||||||
)
|
)
|
||||||
asin = fields.CharField(
|
|
||||||
max_length=255, blank=True, null=True, deduplication_field=True
|
|
||||||
)
|
|
||||||
pages = fields.IntegerField(blank=True, null=True)
|
pages = fields.IntegerField(blank=True, null=True)
|
||||||
physical_format = fields.CharField(
|
physical_format = fields.CharField(
|
||||||
max_length=255, choices=FormatChoices, null=True, blank=True
|
max_length=255, choices=FormatChoices, null=True, blank=True
|
||||||
|
|
|
@ -125,7 +125,7 @@ class ActivitypubFieldMixin:
|
||||||
"""model_field_name to activitypubFieldName"""
|
"""model_field_name to activitypubFieldName"""
|
||||||
if self.activitypub_field:
|
if self.activitypub_field:
|
||||||
return self.activitypub_field
|
return self.activitypub_field
|
||||||
name = self.name.split(".")[-1]
|
name = self.name.rsplit(".", maxsplit=1)[-1]
|
||||||
components = name.split("_")
|
components = name.split("_")
|
||||||
return components[0] + "".join(x.title() for x in components[1:])
|
return components[0] + "".join(x.title() for x in components[1:])
|
||||||
|
|
||||||
|
@ -389,7 +389,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||||
self.alt_field = alt_field
|
self.alt_field = alt_field
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# pylint: disable=arguments-differ
|
# pylint: disable=arguments-differ,arguments-renamed
|
||||||
def set_field_from_activity(self, instance, data, save=True, overwrite=True):
|
def set_field_from_activity(self, instance, data, save=True, overwrite=True):
|
||||||
"""helper function for assinging a value to the field"""
|
"""helper function for assinging a value to the field"""
|
||||||
value = getattr(data, self.get_activitypub_field())
|
value = getattr(data, self.get_activitypub_field())
|
||||||
|
|
|
@ -175,9 +175,15 @@ class ImportItem(models.Model):
|
||||||
def date_added(self):
|
def date_added(self):
|
||||||
"""when the book was added to this dataset"""
|
"""when the book was added to this dataset"""
|
||||||
if self.normalized_data.get("date_added"):
|
if self.normalized_data.get("date_added"):
|
||||||
return timezone.make_aware(
|
parsed_date_added = dateutil.parser.parse(
|
||||||
dateutil.parser.parse(self.normalized_data.get("date_added"))
|
self.normalized_data.get("date_added")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if timezone.is_aware(parsed_date_added):
|
||||||
|
# Keep timezone if import already had one
|
||||||
|
return parsed_date_added
|
||||||
|
|
||||||
|
return timezone.make_aware(parsed_date_added)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
@ -142,7 +142,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
||||||
user = fields.ForeignKey(
|
user = fields.ForeignKey(
|
||||||
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
||||||
)
|
)
|
||||||
notes = fields.TextField(blank=True, null=True, max_length=300)
|
notes = fields.HtmlField(blank=True, null=True, max_length=300)
|
||||||
approved = models.BooleanField(default=True)
|
approved = models.BooleanField(default=True)
|
||||||
order = fields.IntegerField()
|
order = fields.IntegerField()
|
||||||
endorsement = models.ManyToManyField("User", related_name="endorsers")
|
endorsement = models.ManyToManyField("User", related_name="endorsers")
|
||||||
|
|
|
@ -27,6 +27,7 @@ class ReadThrough(BookWyrmModel):
|
||||||
)
|
)
|
||||||
start_date = models.DateTimeField(blank=True, null=True)
|
start_date = models.DateTimeField(blank=True, null=True)
|
||||||
finish_date = models.DateTimeField(blank=True, null=True)
|
finish_date = models.DateTimeField(blank=True, null=True)
|
||||||
|
stopped_date = models.DateTimeField(blank=True, null=True)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
@ -34,7 +35,7 @@ class ReadThrough(BookWyrmModel):
|
||||||
cache.delete(f"latest_read_through-{self.user.id}-{self.book.id}")
|
cache.delete(f"latest_read_through-{self.user.id}-{self.book.id}")
|
||||||
self.user.update_active_date()
|
self.user.update_active_date()
|
||||||
# an active readthrough must have an unset finish date
|
# an active readthrough must have an unset finish date
|
||||||
if self.finish_date:
|
if self.finish_date or self.stopped_date:
|
||||||
self.is_active = False
|
self.is_active = False
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
|
@ -39,15 +39,14 @@ class UserRelationship(BookWyrmModel):
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""clear the template cache"""
|
"""clear the template cache"""
|
||||||
# invalidate the template cache
|
clear_cache(self.user_subject, self.user_object)
|
||||||
cache.delete_many(
|
|
||||||
[
|
|
||||||
f"relationship-{self.user_subject.id}-{self.user_object.id}",
|
|
||||||
f"relationship-{self.user_object.id}-{self.user_subject.id}",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
"""clear the template cache"""
|
||||||
|
clear_cache(self.user_subject, self.user_object)
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""relationships should be unique"""
|
"""relationships should be unique"""
|
||||||
|
|
||||||
|
@ -90,7 +89,9 @@ class UserFollows(ActivityMixin, UserRelationship):
|
||||||
user_object=self.user_subject,
|
user_object=self.user_subject,
|
||||||
)
|
)
|
||||||
).exists():
|
).exists():
|
||||||
raise IntegrityError()
|
raise IntegrityError(
|
||||||
|
"Attempting to follow blocked user", self.user_subject, self.user_object
|
||||||
|
)
|
||||||
# don't broadcast this type of relationship -- accepts and requests
|
# don't broadcast this type of relationship -- accepts and requests
|
||||||
# are handled by the UserFollowRequest model
|
# are handled by the UserFollowRequest model
|
||||||
super().save(*args, broadcast=False, **kwargs)
|
super().save(*args, broadcast=False, **kwargs)
|
||||||
|
@ -98,11 +99,12 @@ class UserFollows(ActivityMixin, UserRelationship):
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_request(cls, follow_request):
|
def from_request(cls, follow_request):
|
||||||
"""converts a follow request into a follow relationship"""
|
"""converts a follow request into a follow relationship"""
|
||||||
return cls.objects.create(
|
obj, _ = cls.objects.get_or_create(
|
||||||
user_subject=follow_request.user_subject,
|
user_subject=follow_request.user_subject,
|
||||||
user_object=follow_request.user_object,
|
user_object=follow_request.user_object,
|
||||||
remote_id=follow_request.remote_id,
|
remote_id=follow_request.remote_id,
|
||||||
)
|
)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
||||||
|
@ -133,7 +135,9 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
||||||
user_object=self.user_subject,
|
user_object=self.user_subject,
|
||||||
)
|
)
|
||||||
).exists():
|
).exists():
|
||||||
raise IntegrityError()
|
raise IntegrityError(
|
||||||
|
"Attempting to follow blocked user", self.user_subject, self.user_object
|
||||||
|
)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
if broadcast and self.user_subject.local and not self.user_object.local:
|
if broadcast and self.user_subject.local and not self.user_object.local:
|
||||||
|
@ -174,7 +178,8 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
UserFollows.from_request(self)
|
UserFollows.from_request(self)
|
||||||
self.delete()
|
if self.id:
|
||||||
|
self.delete()
|
||||||
|
|
||||||
def reject(self):
|
def reject(self):
|
||||||
"""generate a Reject for this follow request"""
|
"""generate a Reject for this follow request"""
|
||||||
|
@ -207,3 +212,13 @@ class UserBlocks(ActivityMixin, UserRelationship):
|
||||||
Q(user_subject=self.user_subject, user_object=self.user_object)
|
Q(user_subject=self.user_subject, user_object=self.user_object)
|
||||||
| Q(user_subject=self.user_object, user_object=self.user_subject)
|
| Q(user_subject=self.user_object, user_object=self.user_subject)
|
||||||
).delete()
|
).delete()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cache(user_subject, user_object):
|
||||||
|
"""clear relationship cache"""
|
||||||
|
cache.delete_many(
|
||||||
|
[
|
||||||
|
f"relationship-{user_subject.id}-{user_object.id}",
|
||||||
|
f"relationship-{user_object.id}-{user_subject.id}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
|
@ -12,7 +12,12 @@ class Report(BookWyrmModel):
|
||||||
)
|
)
|
||||||
note = models.TextField(null=True, blank=True)
|
note = models.TextField(null=True, blank=True)
|
||||||
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||||
statuses = models.ManyToManyField("Status", blank=True)
|
status = models.ForeignKey(
|
||||||
|
"Status",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
)
|
||||||
links = models.ManyToManyField("Link", blank=True)
|
links = models.ManyToManyField("Link", blank=True)
|
||||||
resolved = models.BooleanField(default=False)
|
resolved = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookwyrm import activitypub
|
from bookwyrm import activitypub
|
||||||
|
from bookwyrm.settings import DOMAIN
|
||||||
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
|
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
|
||||||
from .base_model import BookWyrmModel
|
from .base_model import BookWyrmModel
|
||||||
from . import fields
|
from . import fields
|
||||||
|
@ -17,8 +18,9 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
||||||
TO_READ = "to-read"
|
TO_READ = "to-read"
|
||||||
READING = "reading"
|
READING = "reading"
|
||||||
READ_FINISHED = "read"
|
READ_FINISHED = "read"
|
||||||
|
STOPPED_READING = "stopped-reading"
|
||||||
|
|
||||||
READ_STATUS_IDENTIFIERS = (TO_READ, READING, READ_FINISHED)
|
READ_STATUS_IDENTIFIERS = (TO_READ, READING, READ_FINISHED, STOPPED_READING)
|
||||||
|
|
||||||
name = fields.CharField(max_length=100)
|
name = fields.CharField(max_length=100)
|
||||||
identifier = models.CharField(max_length=100)
|
identifier = models.CharField(max_length=100)
|
||||||
|
@ -65,6 +67,11 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
||||||
identifier = self.identifier or self.get_identifier()
|
identifier = self.identifier or self.get_identifier()
|
||||||
return f"{base_path}/books/{identifier}"
|
return f"{base_path}/books/{identifier}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def local_path(self):
|
||||||
|
"""No slugs"""
|
||||||
|
return self.get_remote_id().replace(f"https://{DOMAIN}", "")
|
||||||
|
|
||||||
def raise_not_deletable(self, viewer):
|
def raise_not_deletable(self, viewer):
|
||||||
"""don't let anyone delete a default shelf"""
|
"""don't let anyone delete a default shelf"""
|
||||||
super().raise_not_deletable(viewer)
|
super().raise_not_deletable(viewer)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
""" the particulars for this instance of BookWyrm """
|
""" the particulars for this instance of BookWyrm """
|
||||||
import datetime
|
import datetime
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
import uuid
|
||||||
|
|
||||||
from django.db import models, IntegrityError
|
from django.db import models, IntegrityError
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
@ -23,6 +24,14 @@ class SiteSettings(models.Model):
|
||||||
)
|
)
|
||||||
instance_description = models.TextField(default="This instance has no description.")
|
instance_description = models.TextField(default="This instance has no description.")
|
||||||
instance_short_description = models.CharField(max_length=255, blank=True, null=True)
|
instance_short_description = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
default_theme = models.ForeignKey(
|
||||||
|
"Theme", null=True, blank=True, on_delete=models.SET_NULL
|
||||||
|
)
|
||||||
|
version = models.CharField(null=True, blank=True, max_length=10)
|
||||||
|
|
||||||
|
# admin setup options
|
||||||
|
install_mode = models.BooleanField(default=False)
|
||||||
|
admin_code = models.CharField(max_length=50, default=uuid.uuid4)
|
||||||
|
|
||||||
# about page
|
# about page
|
||||||
registration_closed_text = models.TextField(
|
registration_closed_text = models.TextField(
|
||||||
|
@ -38,10 +47,14 @@ class SiteSettings(models.Model):
|
||||||
privacy_policy = models.TextField(default="Add a privacy policy here.")
|
privacy_policy = models.TextField(default="Add a privacy policy here.")
|
||||||
|
|
||||||
# registration
|
# registration
|
||||||
allow_registration = models.BooleanField(default=True)
|
allow_registration = models.BooleanField(default=False)
|
||||||
allow_invite_requests = models.BooleanField(default=True)
|
allow_invite_requests = models.BooleanField(default=True)
|
||||||
|
invite_request_question = models.BooleanField(default=False)
|
||||||
require_confirm_email = models.BooleanField(default=True)
|
require_confirm_email = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
invite_question_text = models.CharField(
|
||||||
|
max_length=255, blank=True, default="What is your favourite book?"
|
||||||
|
)
|
||||||
# images
|
# images
|
||||||
logo = models.ImageField(upload_to="logos/", null=True, blank=True)
|
logo = models.ImageField(upload_to="logos/", null=True, blank=True)
|
||||||
logo_small = models.ImageField(upload_to="logos/", null=True, blank=True)
|
logo_small = models.ImageField(upload_to="logos/", null=True, blank=True)
|
||||||
|
@ -91,14 +104,29 @@ class SiteSettings(models.Model):
|
||||||
return urljoin(STATIC_FULL_URL, default_path)
|
return urljoin(STATIC_FULL_URL, default_path)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""if require_confirm_email is disabled, make sure no users are pending"""
|
"""if require_confirm_email is disabled, make sure no users are pending,
|
||||||
|
if enabled, make sure invite_question_text is not empty"""
|
||||||
if not self.require_confirm_email:
|
if not self.require_confirm_email:
|
||||||
User.objects.filter(is_active=False, deactivation_reason="pending").update(
|
User.objects.filter(is_active=False, deactivation_reason="pending").update(
|
||||||
is_active=True, deactivation_reason=None
|
is_active=True, deactivation_reason=None
|
||||||
)
|
)
|
||||||
|
if not self.invite_question_text:
|
||||||
|
self.invite_question_text = "What is your favourite book?"
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class Theme(models.Model):
|
||||||
|
"""Theme files"""
|
||||||
|
|
||||||
|
created_date = models.DateTimeField(auto_now_add=True)
|
||||||
|
name = models.CharField(max_length=50, unique=True)
|
||||||
|
path = models.CharField(max_length=50, unique=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
# pylint: disable=invalid-str-returned
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class SiteInvite(models.Model):
|
class SiteInvite(models.Model):
|
||||||
"""gives someone access to create an account on the instance"""
|
"""gives someone access to create an account on the instance"""
|
||||||
|
|
||||||
|
@ -129,6 +157,7 @@ class InviteRequest(BookWyrmModel):
|
||||||
invite = models.ForeignKey(
|
invite = models.ForeignKey(
|
||||||
SiteInvite, on_delete=models.SET_NULL, null=True, blank=True
|
SiteInvite, on_delete=models.SET_NULL, null=True, blank=True
|
||||||
)
|
)
|
||||||
|
answer = models.TextField(max_length=50, unique=False, null=True, blank=True)
|
||||||
invite_sent = models.BooleanField(default=False)
|
invite_sent = models.BooleanField(default=False)
|
||||||
ignored = models.BooleanField(default=False)
|
ignored = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
|
|
@ -116,11 +116,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
|
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
|
||||||
"""keep notes if they are replies to existing statuses"""
|
"""keep notes if they are replies to existing statuses"""
|
||||||
if activity.type == "Announce":
|
if activity.type == "Announce":
|
||||||
try:
|
boosted = activitypub.resolve_remote_id(activity.object, get_activity=True)
|
||||||
boosted = activitypub.resolve_remote_id(
|
if not boosted:
|
||||||
activity.object, get_activity=True
|
|
||||||
)
|
|
||||||
except activitypub.ActivitySerializerError:
|
|
||||||
# if we can't load the status, definitely ignore it
|
# if we can't load the status, definitely ignore it
|
||||||
return True
|
return True
|
||||||
# keep the boost if we would keep the status
|
# keep the boost if we would keep the status
|
||||||
|
@ -227,7 +224,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
@classmethod
|
@classmethod
|
||||||
def privacy_filter(cls, viewer, privacy_levels=None):
|
def privacy_filter(cls, viewer, privacy_levels=None):
|
||||||
queryset = super().privacy_filter(viewer, privacy_levels=privacy_levels)
|
queryset = super().privacy_filter(viewer, privacy_levels=privacy_levels)
|
||||||
return queryset.filter(deleted=False)
|
return queryset.filter(deleted=False, user__is_active=True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def direct_filter(cls, queryset, viewer):
|
def direct_filter(cls, queryset, viewer):
|
||||||
|
@ -265,7 +262,7 @@ class GeneratedNote(Status):
|
||||||
|
|
||||||
|
|
||||||
ReadingStatusChoices = models.TextChoices(
|
ReadingStatusChoices = models.TextChoices(
|
||||||
"ReadingStatusChoices", ["to-read", "reading", "read"]
|
"ReadingStatusChoices", ["to-read", "reading", "read", "stopped-reading"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -306,10 +303,17 @@ class Comment(BookStatus):
|
||||||
@property
|
@property
|
||||||
def pure_content(self):
|
def pure_content(self):
|
||||||
"""indicate the book in question for mastodon (or w/e) users"""
|
"""indicate the book in question for mastodon (or w/e) users"""
|
||||||
return (
|
if self.progress_mode == "PG" and self.progress and (self.progress > 0):
|
||||||
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
|
return_value = (
|
||||||
f'"{self.book.title}"</a>)</p>'
|
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
|
||||||
)
|
f'"{self.book.title}"</a>, page {self.progress})</p>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return_value = (
|
||||||
|
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
|
||||||
|
f'"{self.book.title}"</a>)</p>'
|
||||||
|
)
|
||||||
|
return return_value
|
||||||
|
|
||||||
activity_serializer = activitypub.Comment
|
activity_serializer = activitypub.Comment
|
||||||
|
|
||||||
|
@ -335,10 +339,17 @@ class Quotation(BookStatus):
|
||||||
"""indicate the book in question for mastodon (or w/e) users"""
|
"""indicate the book in question for mastodon (or w/e) users"""
|
||||||
quote = re.sub(r"^<p>", '<p>"', self.quote)
|
quote = re.sub(r"^<p>", '<p>"', self.quote)
|
||||||
quote = re.sub(r"</p>$", '"</p>', quote)
|
quote = re.sub(r"</p>$", '"</p>', quote)
|
||||||
return (
|
if self.position_mode == "PG" and self.position and (self.position > 0):
|
||||||
f'{quote} <p>-- <a href="{self.book.remote_id}">'
|
return_value = (
|
||||||
f'"{self.book.title}"</a></p>{self.content}'
|
f'{quote} <p>-- <a href="{self.book.remote_id}">'
|
||||||
)
|
f'"{self.book.title}"</a>, page {self.position}</p>{self.content}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return_value = (
|
||||||
|
f'{quote} <p>-- <a href="{self.book.remote_id}">'
|
||||||
|
f'"{self.book.title}"</a></p>{self.content}'
|
||||||
|
)
|
||||||
|
return return_value
|
||||||
|
|
||||||
activity_serializer = activitypub.Quotation
|
activity_serializer = activitypub.Quotation
|
||||||
|
|
||||||
|
@ -377,7 +388,7 @@ class Review(BookStatus):
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""clear rating caches"""
|
"""clear rating caches"""
|
||||||
if self.book.parent_work:
|
if self.book.parent_work:
|
||||||
cache.delete(f"book-rating-{self.book.parent_work.id}-*")
|
cache.delete(f"book-rating-{self.book.parent_work.id}")
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -136,6 +136,8 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
updated_date = models.DateTimeField(auto_now=True)
|
updated_date = models.DateTimeField(auto_now=True)
|
||||||
last_active_date = models.DateTimeField(default=timezone.now)
|
last_active_date = models.DateTimeField(default=timezone.now)
|
||||||
manually_approves_followers = fields.BooleanField(default=False)
|
manually_approves_followers = fields.BooleanField(default=False)
|
||||||
|
theme = models.ForeignKey("Theme", null=True, blank=True, on_delete=models.SET_NULL)
|
||||||
|
hide_follows = fields.BooleanField(default=False)
|
||||||
|
|
||||||
# options to turn features on and off
|
# options to turn features on and off
|
||||||
show_goal = models.BooleanField(default=True)
|
show_goal = models.BooleanField(default=True)
|
||||||
|
@ -372,6 +374,10 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
"name": "Read",
|
"name": "Read",
|
||||||
"identifier": "read",
|
"identifier": "read",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Stopped Reading",
|
||||||
|
"identifier": "stopped-reading",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
for shelf in shelves:
|
for shelf in shelves:
|
||||||
|
@ -478,10 +484,13 @@ def set_remote_server(user_id):
|
||||||
get_remote_reviews.delay(user.outbox)
|
get_remote_reviews.delay(user.outbox)
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_remote_server(domain):
|
def get_or_create_remote_server(domain, refresh=False):
|
||||||
"""get info on a remote server"""
|
"""get info on a remote server"""
|
||||||
|
server = FederatedServer()
|
||||||
try:
|
try:
|
||||||
return FederatedServer.objects.get(server_name=domain)
|
server = FederatedServer.objects.get(server_name=domain)
|
||||||
|
if not refresh:
|
||||||
|
return server
|
||||||
except FederatedServer.DoesNotExist:
|
except FederatedServer.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -496,13 +505,15 @@ def get_or_create_remote_server(domain):
|
||||||
application_type = data.get("software", {}).get("name")
|
application_type = data.get("software", {}).get("name")
|
||||||
application_version = data.get("software", {}).get("version")
|
application_version = data.get("software", {}).get("version")
|
||||||
except ConnectorException:
|
except ConnectorException:
|
||||||
|
if server.id:
|
||||||
|
return server
|
||||||
application_type = application_version = None
|
application_type = application_version = None
|
||||||
|
|
||||||
server = FederatedServer.objects.create(
|
server.server_name = domain
|
||||||
server_name=domain,
|
server.application_type = application_type
|
||||||
application_type=application_type,
|
server.application_version = application_version
|
||||||
application_version=application_version,
|
|
||||||
)
|
server.save()
|
||||||
return server
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import os
|
||||||
import textwrap
|
import textwrap
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
import logging
|
||||||
|
|
||||||
import colorsys
|
import colorsys
|
||||||
from colorthief import ColorThief
|
from colorthief import ColorThief
|
||||||
|
@ -17,34 +18,49 @@ from django.db.models import Avg
|
||||||
from bookwyrm import models, settings
|
from bookwyrm import models, settings
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
IMG_WIDTH = settings.PREVIEW_IMG_WIDTH
|
IMG_WIDTH = settings.PREVIEW_IMG_WIDTH
|
||||||
IMG_HEIGHT = settings.PREVIEW_IMG_HEIGHT
|
IMG_HEIGHT = settings.PREVIEW_IMG_HEIGHT
|
||||||
BG_COLOR = settings.PREVIEW_BG_COLOR
|
BG_COLOR = settings.PREVIEW_BG_COLOR
|
||||||
TEXT_COLOR = settings.PREVIEW_TEXT_COLOR
|
TEXT_COLOR = settings.PREVIEW_TEXT_COLOR
|
||||||
DEFAULT_COVER_COLOR = settings.PREVIEW_DEFAULT_COVER_COLOR
|
DEFAULT_COVER_COLOR = settings.PREVIEW_DEFAULT_COVER_COLOR
|
||||||
|
DEFAULT_FONT = settings.PREVIEW_DEFAULT_FONT
|
||||||
TRANSPARENT_COLOR = (0, 0, 0, 0)
|
TRANSPARENT_COLOR = (0, 0, 0, 0)
|
||||||
|
|
||||||
margin = math.floor(IMG_HEIGHT / 10)
|
margin = math.floor(IMG_HEIGHT / 10)
|
||||||
gutter = math.floor(margin / 2)
|
gutter = math.floor(margin / 2)
|
||||||
inner_img_height = math.floor(IMG_HEIGHT * 0.8)
|
inner_img_height = math.floor(IMG_HEIGHT * 0.8)
|
||||||
inner_img_width = math.floor(inner_img_height * 0.7)
|
inner_img_width = math.floor(inner_img_height * 0.7)
|
||||||
font_dir = os.path.join(settings.STATIC_ROOT, "fonts/public_sans")
|
|
||||||
|
|
||||||
|
|
||||||
def get_font(font_name, size=28):
|
def get_imagefont(name, size):
|
||||||
"""Loads custom font"""
|
"""Loads an ImageFont based on config"""
|
||||||
if font_name == "light":
|
try:
|
||||||
font_path = os.path.join(font_dir, "PublicSans-Light.ttf")
|
config = settings.FONTS[name]
|
||||||
if font_name == "regular":
|
path = os.path.join(settings.FONT_DIR, config["directory"], config["filename"])
|
||||||
font_path = os.path.join(font_dir, "PublicSans-Regular.ttf")
|
return ImageFont.truetype(path, size)
|
||||||
elif font_name == "bold":
|
except KeyError:
|
||||||
font_path = os.path.join(font_dir, "PublicSans-Bold.ttf")
|
logger.error("Font %s not found in config", name)
|
||||||
|
except OSError:
|
||||||
|
logger.error("Could not load font %s from file", name)
|
||||||
|
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
def get_font(weight, size=28):
|
||||||
|
"""Gets a custom font with the given weight and size"""
|
||||||
|
font = get_imagefont(DEFAULT_FONT, size)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
font = ImageFont.truetype(font_path, size)
|
if weight == "light":
|
||||||
except OSError:
|
font.set_variation_by_name("Light")
|
||||||
font = ImageFont.load_default()
|
if weight == "bold":
|
||||||
|
font.set_variation_by_name("Bold")
|
||||||
|
if weight == "regular":
|
||||||
|
font.set_variation_by_name("Regular")
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
return font
|
return font
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,7 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
|
||||||
"ol",
|
"ol",
|
||||||
"li",
|
"li",
|
||||||
]
|
]
|
||||||
|
self.allowed_attrs = ["href", "rel", "src", "alt"]
|
||||||
self.tag_stack = []
|
self.tag_stack = []
|
||||||
self.output = []
|
self.output = []
|
||||||
# if the html appears invalid, we just won't allow any at all
|
# if the html appears invalid, we just won't allow any at all
|
||||||
|
@ -30,7 +31,14 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
|
||||||
def handle_starttag(self, tag, attrs):
|
def handle_starttag(self, tag, attrs):
|
||||||
"""check if the tag is valid"""
|
"""check if the tag is valid"""
|
||||||
if self.allow_html and tag in self.allowed_tags:
|
if self.allow_html and tag in self.allowed_tags:
|
||||||
self.output.append(("tag", self.get_starttag_text()))
|
allowed_attrs = " ".join(
|
||||||
|
f'{a}="{v}"' for a, v in attrs if a in self.allowed_attrs
|
||||||
|
)
|
||||||
|
reconstructed = f"<{tag}"
|
||||||
|
if allowed_attrs:
|
||||||
|
reconstructed += " " + allowed_attrs
|
||||||
|
reconstructed += ">"
|
||||||
|
self.output.append(("tag", reconstructed))
|
||||||
self.tag_stack.append(tag)
|
self.tag_stack.append(tag)
|
||||||
else:
|
else:
|
||||||
self.output.append(("data", ""))
|
self.output.append(("data", ""))
|
||||||
|
|
|
@ -6,15 +6,22 @@ import requests
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
|
||||||
env = Env()
|
env = Env()
|
||||||
env.read_env()
|
env.read_env()
|
||||||
DOMAIN = env("DOMAIN")
|
DOMAIN = env("DOMAIN")
|
||||||
VERSION = "0.2.1"
|
VERSION = "0.4.0"
|
||||||
|
|
||||||
|
RELEASE_API = env(
|
||||||
|
"RELEASE_API",
|
||||||
|
"https://api.github.com/repos/bookwyrm-social/bookwyrm/releases/latest",
|
||||||
|
)
|
||||||
|
|
||||||
PAGE_LENGTH = env("PAGE_LENGTH", 15)
|
PAGE_LENGTH = env("PAGE_LENGTH", 15)
|
||||||
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
||||||
|
|
||||||
JS_CACHE = "7b5303af"
|
JS_CACHE = "e678183b"
|
||||||
|
|
||||||
# email
|
# email
|
||||||
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
||||||
|
@ -35,6 +42,9 @@ LOCALE_PATHS = [
|
||||||
]
|
]
|
||||||
LANGUAGE_COOKIE_NAME = env.str("LANGUAGE_COOKIE_NAME", "django_language")
|
LANGUAGE_COOKIE_NAME = env.str("LANGUAGE_COOKIE_NAME", "django_language")
|
||||||
|
|
||||||
|
STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
|
||||||
|
MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||||
|
|
||||||
# Preview image
|
# Preview image
|
||||||
|
@ -44,6 +54,16 @@ PREVIEW_TEXT_COLOR = env.str("PREVIEW_TEXT_COLOR", "#363636")
|
||||||
PREVIEW_IMG_WIDTH = env.int("PREVIEW_IMG_WIDTH", 1200)
|
PREVIEW_IMG_WIDTH = env.int("PREVIEW_IMG_WIDTH", 1200)
|
||||||
PREVIEW_IMG_HEIGHT = env.int("PREVIEW_IMG_HEIGHT", 630)
|
PREVIEW_IMG_HEIGHT = env.int("PREVIEW_IMG_HEIGHT", 630)
|
||||||
PREVIEW_DEFAULT_COVER_COLOR = env.str("PREVIEW_DEFAULT_COVER_COLOR", "#002549")
|
PREVIEW_DEFAULT_COVER_COLOR = env.str("PREVIEW_DEFAULT_COVER_COLOR", "#002549")
|
||||||
|
PREVIEW_DEFAULT_FONT = env.str("PREVIEW_DEFAULT_FONT", "Source Han Sans")
|
||||||
|
|
||||||
|
FONTS = {
|
||||||
|
"Source Han Sans": {
|
||||||
|
"directory": "source_han_sans",
|
||||||
|
"filename": "SourceHanSans-VF.ttf.ttc",
|
||||||
|
"url": "https://github.com/adobe-fonts/source-han-sans/raw/release/Variable/OTC/SourceHanSans-VF.ttf.ttc",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FONT_DIR = os.path.join(STATIC_ROOT, "fonts")
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
|
||||||
|
@ -53,7 +73,7 @@ SECRET_KEY = env("SECRET_KEY")
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = env.bool("DEBUG", True)
|
DEBUG = env.bool("DEBUG", True)
|
||||||
USE_HTTPS = env.bool("USE_HTTPS", False)
|
USE_HTTPS = env.bool("USE_HTTPS", not DEBUG)
|
||||||
|
|
||||||
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", ["*"])
|
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", ["*"])
|
||||||
|
|
||||||
|
@ -67,9 +87,10 @@ INSTALLED_APPS = [
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"django.contrib.humanize",
|
"django.contrib.humanize",
|
||||||
"django_rename_app",
|
"sass_processor",
|
||||||
"bookwyrm",
|
"bookwyrm",
|
||||||
"celery",
|
"celery",
|
||||||
|
"django_celery_beat",
|
||||||
"imagekit",
|
"imagekit",
|
||||||
"storages",
|
"storages",
|
||||||
]
|
]
|
||||||
|
@ -150,6 +171,9 @@ LOGGING = {
|
||||||
"handlers": ["console", "mail_admins"],
|
"handlers": ["console", "mail_admins"],
|
||||||
"level": LOG_LEVEL,
|
"level": LOG_LEVEL,
|
||||||
},
|
},
|
||||||
|
"django.utils.autoreload": {
|
||||||
|
"level": "INFO",
|
||||||
|
},
|
||||||
# Add a bookwyrm-specific logger
|
# Add a bookwyrm-specific logger
|
||||||
"bookwyrm": {
|
"bookwyrm": {
|
||||||
"handlers": ["console"],
|
"handlers": ["console"],
|
||||||
|
@ -158,6 +182,18 @@ LOGGING = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
STATICFILES_FINDERS = [
|
||||||
|
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||||
|
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||||
|
"sass_processor.finders.CssFinder",
|
||||||
|
]
|
||||||
|
|
||||||
|
SASS_PROCESSOR_INCLUDE_FILE_PATTERN = r"^.+\.[s]{0,1}(?:a|c)ss$"
|
||||||
|
SASS_PROCESSOR_ENABLED = True
|
||||||
|
|
||||||
|
# minify css is production but not dev
|
||||||
|
if not DEBUG:
|
||||||
|
SASS_OUTPUT_STYLE = "compressed"
|
||||||
|
|
||||||
WSGI_APPLICATION = "bookwyrm.wsgi.application"
|
WSGI_APPLICATION = "bookwyrm.wsgi.application"
|
||||||
|
|
||||||
|
@ -176,7 +212,7 @@ STREAMS = [
|
||||||
|
|
||||||
# Search configuration
|
# Search configuration
|
||||||
# total time in seconds that the instance will spend searching connectors
|
# total time in seconds that the instance will spend searching connectors
|
||||||
SEARCH_TIMEOUT = int(env("SEARCH_TIMEOUT", 15))
|
SEARCH_TIMEOUT = int(env("SEARCH_TIMEOUT", 8))
|
||||||
# timeout for a query to an individual connector
|
# timeout for a query to an individual connector
|
||||||
QUERY_TIMEOUT = int(env("QUERY_TIMEOUT", 5))
|
QUERY_TIMEOUT = int(env("QUERY_TIMEOUT", 5))
|
||||||
|
|
||||||
|
@ -188,7 +224,6 @@ if env("USE_DUMMY_CACHE", False):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# pylint: disable=line-too-long
|
|
||||||
CACHES = {
|
CACHES = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "django_redis.cache.RedisCache",
|
"BACKEND": "django_redis.cache.RedisCache",
|
||||||
|
@ -223,7 +258,6 @@ AUTH_USER_MODEL = "bookwyrm.User"
|
||||||
# Password validation
|
# Password validation
|
||||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
# pylint: disable=line-too-long
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||||
|
@ -250,11 +284,13 @@ LANGUAGES = [
|
||||||
("es-es", _("Español (Spanish)")),
|
("es-es", _("Español (Spanish)")),
|
||||||
("gl-es", _("Galego (Galician)")),
|
("gl-es", _("Galego (Galician)")),
|
||||||
("it-it", _("Italiano (Italian)")),
|
("it-it", _("Italiano (Italian)")),
|
||||||
|
("fi-fi", _("Suomi (Finnish)")),
|
||||||
("fr-fr", _("Français (French)")),
|
("fr-fr", _("Français (French)")),
|
||||||
("lt-lt", _("Lietuvių (Lithuanian)")),
|
("lt-lt", _("Lietuvių (Lithuanian)")),
|
||||||
("no-no", _("Norsk (Norwegian)")),
|
("no-no", _("Norsk (Norwegian)")),
|
||||||
("pt-br", _("Português do Brasil (Brazilian Portuguese)")),
|
("pt-br", _("Português do Brasil (Brazilian Portuguese)")),
|
||||||
("pt-pt", _("Português Europeu (European Portuguese)")),
|
("pt-pt", _("Português Europeu (European Portuguese)")),
|
||||||
|
("ro-ro", _("Română (Romanian)")),
|
||||||
("sv-se", _("Svenska (Swedish)")),
|
("sv-se", _("Svenska (Swedish)")),
|
||||||
("zh-hans", _("简体中文 (Simplified Chinese)")),
|
("zh-hans", _("简体中文 (Simplified Chinese)")),
|
||||||
("zh-hant", _("繁體中文 (Traditional Chinese)")),
|
("zh-hant", _("繁體中文 (Traditional Chinese)")),
|
||||||
|
@ -311,13 +347,12 @@ if USE_S3:
|
||||||
MEDIA_FULL_URL = MEDIA_URL
|
MEDIA_FULL_URL = MEDIA_URL
|
||||||
STATIC_FULL_URL = STATIC_URL
|
STATIC_FULL_URL = STATIC_URL
|
||||||
DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage"
|
DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage"
|
||||||
# I don't know if it's used, but the site crashes without it
|
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
|
|
||||||
MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))
|
|
||||||
else:
|
else:
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
|
|
||||||
MEDIA_URL = "/images/"
|
MEDIA_URL = "/images/"
|
||||||
MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}"
|
MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}"
|
||||||
STATIC_FULL_URL = f"{PROTOCOL}://{DOMAIN}{STATIC_URL}"
|
STATIC_FULL_URL = f"{PROTOCOL}://{DOMAIN}{STATIC_URL}"
|
||||||
MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))
|
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT = env("OTEL_EXPORTER_OTLP_ENDPOINT", None)
|
||||||
|
OTEL_EXPORTER_OTLP_HEADERS = env("OTEL_EXPORTER_OTLP_HEADERS", None)
|
||||||
|
OTEL_SERVICE_NAME = env("OTEL_SERVICE_NAME", None)
|
||||||
|
|
File diff suppressed because it is too large
Load diff
4
bookwyrm/static/css/bookwyrm.scss
Normal file
4
bookwyrm/static/css/bookwyrm.scss
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
@charset "utf-8";
|
||||||
|
|
||||||
|
@import "vendor/bulma/bulma.sass";
|
||||||
|
@import "bookwyrm/all.scss";
|
164
bookwyrm/static/css/bookwyrm/_all.scss
Normal file
164
bookwyrm/static/css/bookwyrm/_all.scss
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
/** Imports
|
||||||
|
******************************************************************************/
|
||||||
|
@import "components/avatar";
|
||||||
|
@import "components/barcode";
|
||||||
|
@import "components/book_cover";
|
||||||
|
@import "components/book_grid";
|
||||||
|
@import "components/book_list";
|
||||||
|
@import "components/book_preview_table";
|
||||||
|
@import "components/breadcrumbs";
|
||||||
|
@import "components/copy";
|
||||||
|
@import "components/details";
|
||||||
|
@import "components/file_input";
|
||||||
|
@import "components/live_message";
|
||||||
|
@import "components/shelving";
|
||||||
|
@import "components/stars";
|
||||||
|
@import "components/status";
|
||||||
|
@import "components/tabs";
|
||||||
|
@import "components/toggle";
|
||||||
|
|
||||||
|
@import "overrides/bulma_overrides";
|
||||||
|
|
||||||
|
@import "utilities/a11y";
|
||||||
|
@import "utilities/alignments";
|
||||||
|
@import "utilities/colors";
|
||||||
|
@import "utilities/size";
|
||||||
|
@import "utilities/spacings";
|
||||||
|
@import "utilities/transitions";
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: $scrollbar-thumb;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: $scrollbar-track;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: auto;
|
||||||
|
overflow: visible;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
/* inherit font, color & alignment from ancestor */
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: inherit;
|
||||||
|
|
||||||
|
/* Normalize `line-height`. Cannot be changed from `normal` in Firefox 4+. */
|
||||||
|
line-height: normal;
|
||||||
|
|
||||||
|
/* Corrects font smoothing for webkit */
|
||||||
|
-webkit-font-smoothing: inherit;
|
||||||
|
-moz-osx-font-smoothing: inherit;
|
||||||
|
|
||||||
|
/* Corrects inability to style clickable `input` types in iOS */
|
||||||
|
-webkit-appearance: none;
|
||||||
|
|
||||||
|
/* Generalizes pointer cursor */
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button::-moz-focus-inner {
|
||||||
|
/* Remove excess padding and border in Firefox 4+ */
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better accessibility for keyboard users */
|
||||||
|
*:focus-visible {
|
||||||
|
outline-style: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Utilities not covered by Bulma
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
|
||||||
|
.tag.is-small {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.is-transparent {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.is-stretchable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.is-stretchable .card-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preserve-whitespace p {
|
||||||
|
white-space: pre-wrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-inline p {
|
||||||
|
display: inline !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button .button-invisible-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
background: $invisible-overlay-background-color;
|
||||||
|
color: white;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover .button-invisible-overlay,
|
||||||
|
button:active .button-invisible-overlay,
|
||||||
|
button:focus-visible .button-invisible-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** States
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
/* "disabled" for non-buttons */
|
||||||
|
|
||||||
|
.is-disabled {
|
||||||
|
background-color: $pagination-disabled-background-color;
|
||||||
|
border-color: $pagination-disabled-border-color;
|
||||||
|
box-shadow: none;
|
||||||
|
color: $pagination-disabled-color;
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Notifications page
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
.notification a.icon {
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
7
bookwyrm/static/css/bookwyrm/components/_avatar.css
Normal file
7
bookwyrm/static/css/bookwyrm/components/_avatar.css
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
/** Avatars
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
vertical-align: middle;
|
||||||
|
display: inline;
|
||||||
|
}
|
26
bookwyrm/static/css/bookwyrm/components/_barcode.scss
Normal file
26
bookwyrm/static/css/bookwyrm/components/_barcode.scss
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
/* Barcode scanner CSS */
|
||||||
|
#barcode-scanner {
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
height: calc(70vh - 200px);
|
||||||
|
|
||||||
|
video {
|
||||||
|
height: calc(70vh - 200px);
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: auto;
|
||||||
|
height: calc(70vh - 200px);
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#barcode-camera-list {
|
||||||
|
float: right;
|
||||||
|
}
|
70
bookwyrm/static/css/bookwyrm/components/_book_cover.scss
Normal file
70
bookwyrm/static/css/bookwyrm/components/_book_cover.scss
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
/** Book covers
|
||||||
|
*
|
||||||
|
* - .is-cover gives the behaviour of the cover and its surrounding. (optional)
|
||||||
|
* - .cover-container gives the dimensions and position (for borders, image and other elements).
|
||||||
|
* - .book-cover is positioned and sized based on its container.
|
||||||
|
*
|
||||||
|
* To have the cover within specific dimensions, specify a width or height for
|
||||||
|
* standard bulma’s named breapoints:
|
||||||
|
*
|
||||||
|
* `is-(w|h)-(auto|xs|s|m|l|xl|xxl)[-(mobile|tablet|desktop)]`
|
||||||
|
*
|
||||||
|
* The cover will be centered horizontally and vertically within those dimensions.
|
||||||
|
*
|
||||||
|
* When using `.column.is-N`, add `.is-w-auto` to the container so that the flex
|
||||||
|
* calculations are not biased by the default `max-content`.
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
.column.is-cover {
|
||||||
|
flex-grow: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column.is-cover,
|
||||||
|
.column.is-cover + .column {
|
||||||
|
flex-basis: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Book cover
|
||||||
|
* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.book-cover {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
|
||||||
|
/* Useful when stretching under-sized images. */
|
||||||
|
image-rendering: optimizequality;
|
||||||
|
image-rendering: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cover caption
|
||||||
|
* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.no-cover .cover-caption {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
padding: 0.5em;
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: white;
|
||||||
|
background-color: $no-cover-color;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1em;
|
||||||
|
white-space: initial;
|
||||||
|
text-align: center;
|
||||||
|
}
|
36
bookwyrm/static/css/bookwyrm/components/_book_grid.scss
Normal file
36
bookwyrm/static/css/bookwyrm/components/_book_grid.scss
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
/* Books grid
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
.books-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: end;
|
||||||
|
justify-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.books-grid > .is-big {
|
||||||
|
grid-column: span 2;
|
||||||
|
grid-row: span 2;
|
||||||
|
justify-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.books-grid .book-cover {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.books-grid .book-title {
|
||||||
|
--height-basis: 1.35rem;
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
line-height: var(--height-basis);
|
||||||
|
min-height: calc(2 * var(--height-basis));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (min-width: 769px) {
|
||||||
|
.books-grid {
|
||||||
|
gap: 1.5rem;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(8em, 1fr));
|
||||||
|
}
|
||||||
|
}
|
47
bookwyrm/static/css/bookwyrm/components/_book_list.scss
Normal file
47
bookwyrm/static/css/bookwyrm/components/_book_list.scss
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
/* Book list
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
ol.ordered-list {
|
||||||
|
list-style: none;
|
||||||
|
counter-reset: list-counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol.ordered-list li {
|
||||||
|
counter-increment: list-counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol.ordered-list li::before {
|
||||||
|
content: counter(list-counter);
|
||||||
|
position: absolute;
|
||||||
|
left: -20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 24px;
|
||||||
|
background-color: $scheme-main;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-right: 0;
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
border-top-right-radius: 2px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: $text-light;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 768px) {
|
||||||
|
ol.ordered-list li::before {
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid $border;
|
||||||
|
border-bottom: 1px solid $border;
|
||||||
|
border-radius: 0;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-wrap-anywhere {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
min-width: 10em;
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
/* Book preview table
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
.book-preview td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 768px) {
|
||||||
|
table.is-mobile,
|
||||||
|
table.is-mobile tbody {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.is-mobile tr {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.is-mobile td {
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1 0 100%;
|
||||||
|
order: 2;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.is-mobile td.book-preview-top-row {
|
||||||
|
order: 1;
|
||||||
|
flex-basis: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.is-mobile td[data-title]:not(:empty)::before {
|
||||||
|
content: attr(data-title);
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.is-mobile td:empty {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.is-mobile th,
|
||||||
|
table.is-mobile thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
13
bookwyrm/static/css/bookwyrm/components/_breadcrumbs.scss
Normal file
13
bookwyrm/static/css/bookwyrm/components/_breadcrumbs.scss
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
/* Breadcrumbs
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
.breadcrumb li:first-child * {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb li > * {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 0.75em;
|
||||||
|
}
|
30
bookwyrm/static/css/bookwyrm/components/_copy.scss
Normal file
30
bookwyrm/static/css/bookwyrm/components/_copy.scss
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
/* Copy
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
.horizontal-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-copy textarea {
|
||||||
|
min-width: initial;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-copy button {
|
||||||
|
align-self: stretch;
|
||||||
|
height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-copy button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
130
bookwyrm/static/css/bookwyrm/components/_details.scss
Normal file
130
bookwyrm/static/css/bookwyrm/components/_details.scss
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
/** General `details` element styles
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
details summary::marker {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.detail-pinned-button summary {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.detail-pinned-button form {
|
||||||
|
float: left;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dropdown w/ Details element
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
details.dropdown[open] summary.dropdown-trigger::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.dropdown .dropdown-menu {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.dropdown .dropdown-menu button {
|
||||||
|
/* Fix weird Safari defaults */
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.dropdown .dropdown-menu button:focus-visible,
|
||||||
|
details.dropdown .dropdown-menu a:focus-visible {
|
||||||
|
outline-style: auto;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 768px) {
|
||||||
|
details.dropdown[open] summary.dropdown-trigger::before {
|
||||||
|
background-color: $modal-background-background-color;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
details .dropdown-menu {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
details .dropdown-menu > * {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Details panel
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
details.details-panel {
|
||||||
|
box-shadow: 0 0 0 1px $border;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open].details-panel,
|
||||||
|
details.details-panel:hover {
|
||||||
|
box-shadow: 0 0 0 1px $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.details-panel summary {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
details summary .details-close {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] summary .details-close {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (min-width: 769px) {
|
||||||
|
.details-panel .filters-field:not(:last-child) {
|
||||||
|
border-right: 1px solid $border;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navbar details
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
#navbar-dropdown .navbar-item {
|
||||||
|
color: $text;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.375rem 3rem 0.375rem 1rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navbar-dropdown .navbar-item:hover {
|
||||||
|
background-color: $background-secondary;
|
||||||
|
}
|
28
bookwyrm/static/css/bookwyrm/components/_file_input.scss
Normal file
28
bookwyrm/static/css/bookwyrm/components/_file_input.scss
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
/** File input styles
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
input[type="file"]::file-selector-button {
|
||||||
|
-moz-appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-color: $scheme-main;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid $border;
|
||||||
|
box-shadow: none;
|
||||||
|
color: $text;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
height: 2.5em;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding-bottom: calc(0.5em - 1px);
|
||||||
|
padding-left: 1em;
|
||||||
|
padding-right: 1em;
|
||||||
|
padding-top: calc(0.5em - 1px);
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"]::file-selector-button:hover {
|
||||||
|
border-color: $border-hover;
|
||||||
|
color: text;
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
/** Transient notification
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
#live-messages {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1em;
|
||||||
|
right: 1em;
|
||||||
|
}
|
10
bookwyrm/static/css/bookwyrm/components/_shelving.scss
Normal file
10
bookwyrm/static/css/bookwyrm/components/_shelving.scss
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
/** Shelving
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
/** @todo Replace icons with SVG symbols.
|
||||||
|
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
|
||||||
|
.shelf-option:disabled > *::after {
|
||||||
|
font-family: icomoon; /* stylelint-disable font-family-no-missing-generic-family-keyword */
|
||||||
|
content: "\e919"; /* icon-check */
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
52
bookwyrm/static/css/bookwyrm/components/_stars.scss
Normal file
52
bookwyrm/static/css/bookwyrm/components/_stars.scss
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
/** Stars
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
.stars {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stars in a review form
|
||||||
|
*
|
||||||
|
* Specificity makes hovering taking over checked inputs.
|
||||||
|
*
|
||||||
|
* \e9d9: filled star
|
||||||
|
* \e9d7: empty star;
|
||||||
|
* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.form-rate-stars {
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All stars are visually filled by default. */
|
||||||
|
.form-rate-stars .icon::before {
|
||||||
|
content: "\e9d9"; /* icon-star-full */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icons directly following half star inputs are marked as half */
|
||||||
|
.form-rate-stars input.half:checked ~ .icon::before {
|
||||||
|
content: "\e9d8"; /* icon-star-half */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* stylelint-disable no-descending-specificity */
|
||||||
|
.form-rate-stars input.half:checked + input + .icon:hover::before {
|
||||||
|
content: "\e9d8" !important; /* icon-star-half */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icons directly following half check inputs that follow the checked input are emptied. */
|
||||||
|
.form-rate-stars input.half:checked + input + .icon ~ .icon::before {
|
||||||
|
content: "\e9d7"; /* icon-star-empty */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icons directly following inputs that follow the checked input are emptied. */
|
||||||
|
.form-rate-stars input:checked ~ input + .icon::before {
|
||||||
|
content: "\e9d7"; /* icon-star-empty */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When a label is hovered, repeat the fill-all-then-empty-following pattern. */
|
||||||
|
.form-rate-stars:hover .icon.icon::before {
|
||||||
|
content: "\e9d9" !important; /* icon-star-full */
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-rate-stars .icon:hover ~ .icon::before {
|
||||||
|
content: "\e9d7" !important; /* icon-star-empty */
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue