Compare commits
2177 commits
Author | SHA1 | Date | |
---|---|---|---|
e150453801 | |||
e2582ffec5 | |||
a6508154cb | |||
7348eec671 | |||
4221fa50dd | |||
e0ebd43e7a | |||
c7ee9de9eb | |||
950db697a0 | |||
358b3b75a5 | |||
7e559a01b3 | |||
23bdde245a | |||
2c269b640b | |||
![]() |
f1c8ffedda | ||
![]() |
aace6033c5 | ||
![]() |
7171c7fb06 | ||
![]() |
993f066f2c | ||
![]() |
980031f524 | ||
![]() |
bcaf0d08bc | ||
![]() |
ac439c949b | ||
![]() |
20b3f87dbe | ||
![]() |
9e55717041 | ||
![]() |
772b6610cb | ||
![]() |
3f27f626df | ||
![]() |
29743e70b7 | ||
![]() |
54220601ed | ||
![]() |
9a6f8970ae | ||
![]() |
28f90ad6b5 | ||
![]() |
535317e4f7 | ||
![]() |
072db39233 | ||
![]() |
c6365f2631 | ||
![]() |
d520f64fee | ||
![]() |
2308d2e240 | ||
![]() |
0b4efae392 | ||
![]() |
0b646d2d18 | ||
![]() |
a4d81a6e3c | ||
![]() |
5e742eab17 | ||
![]() |
b9b8bbd2ea | ||
![]() |
8df52bf2a2 | ||
![]() |
55f45ae8a0 | ||
![]() |
2219cf8198 | ||
![]() |
9be2f63475 | ||
![]() |
812d8d2349 | ||
![]() |
20dcdd8b86 | ||
![]() |
bc399182ba | ||
![]() |
71d63f6b93 | ||
![]() |
0b6cfaa9c5 | ||
![]() |
b81914fbf5 | ||
![]() |
b30f066c41 | ||
![]() |
2e20fc5b75 | ||
![]() |
ca05e68890 | ||
![]() |
7a9d5772db | ||
![]() |
dffd951369 | ||
![]() |
d67eb2f1cc | ||
![]() |
3a9bf69aa7 | ||
![]() |
d1f4c0f150 | ||
![]() |
b7991b5dc6 | ||
![]() |
c1a2c9cc70 | ||
![]() |
37f760959d | ||
![]() |
cea3e4b927 | ||
![]() |
29531c83c4 | ||
![]() |
4c3e3aeb6a | ||
![]() |
c176d97870 | ||
![]() |
7d6f75bb05 | ||
![]() |
7b40c527c8 | ||
![]() |
f292850d05 | ||
![]() |
8d5427e92f | ||
![]() |
b8131c8393 | ||
![]() |
e52a83751e | ||
![]() |
ffa724ef37 | ||
![]() |
1d00fe994a | ||
![]() |
71abbe06da | ||
![]() |
f755460242 | ||
![]() |
2ffc067097 | ||
![]() |
b6a8e508bf | ||
![]() |
1def26a35b | ||
![]() |
07871188aa | ||
![]() |
c8dc60cb68 | ||
![]() |
526c84dfa6 | ||
![]() |
21f90f3f32 | ||
![]() |
83586ef90f | ||
![]() |
59bd58aca7 | ||
![]() |
1ec1eba496 | ||
![]() |
d29b840343 | ||
![]() |
b762a0782a | ||
![]() |
15ab0c9592 | ||
![]() |
41945c5e37 | ||
![]() |
f5661fe349 | ||
![]() |
0eeeb4bd35 | ||
![]() |
1d56a4c0d0 | ||
![]() |
b642c98d40 | ||
![]() |
0fb3c0f3d2 | ||
![]() |
b7955a5871 | ||
![]() |
290f8fd51e | ||
![]() |
ec36df4a34 | ||
![]() |
c95e42bf82 | ||
![]() |
5e82fe3946 | ||
![]() |
f4c8176d83 | ||
![]() |
9da2a148c6 | ||
![]() |
2a0b6da2f9 | ||
![]() |
f7641218cb | ||
![]() |
1b78bd617c | ||
![]() |
09612b1921 | ||
![]() |
bbd98e7b2f | ||
![]() |
da0f6bd5e1 | ||
![]() |
bbc2c584ec | ||
![]() |
7f0c571a44 | ||
![]() |
53040dc6be | ||
![]() |
1cacfab2a6 | ||
![]() |
bab09e3fe7 | ||
![]() |
dd176a5e9e | ||
![]() |
a6ce5eb21d | ||
![]() |
b53479f8e4 | ||
![]() |
1f752530d2 | ||
![]() |
2c46fde742 | ||
![]() |
d57efba381 | ||
![]() |
f2fce2e305 | ||
![]() |
b5f0ecb165 | ||
![]() |
7e683dfc4a | ||
![]() |
0b8315fc78 | ||
![]() |
ffd694e7b7 | ||
![]() |
80dc4eb7a9 | ||
![]() |
518c108c88 | ||
![]() |
bd1993f440 | ||
![]() |
91ea9021d7 | ||
![]() |
2903b376b5 | ||
![]() |
9d2684046f | ||
![]() |
3b92bb3a9e | ||
![]() |
5ec899cf08 | ||
![]() |
458c95696a | ||
![]() |
08a89c490a | ||
![]() |
a9495b6a70 | ||
![]() |
1bba6d9947 | ||
![]() |
f4f79f170a | ||
![]() |
9c466796da | ||
![]() |
e88b8fc9bc | ||
![]() |
3aafe578f0 | ||
![]() |
af0f84762c | ||
![]() |
be6eb5f815 | ||
![]() |
57fdacdb83 | ||
![]() |
ece29d7b6c | ||
![]() |
5e1c0a5187 | ||
![]() |
25e62fe6ef | ||
![]() |
d70bac74f0 | ||
![]() |
fd1ec01128 | ||
![]() |
0b4629ea29 | ||
![]() |
27214cc62f | ||
![]() |
d2d0206b45 | ||
![]() |
eede274529 | ||
![]() |
ee781ec489 | ||
![]() |
ca660f4087 | ||
![]() |
ce156d6278 | ||
![]() |
e531f98079 | ||
![]() |
09ce2d5a40 | ||
![]() |
ae8212069c | ||
![]() |
4eb5866379 | ||
![]() |
a86a33445e | ||
![]() |
12f8b7bdf7 | ||
![]() |
2f2ebd0f07 | ||
![]() |
2917463bb6 | ||
![]() |
16bf13787d | ||
![]() |
b7d26b6b8c | ||
![]() |
19e65f5bb9 | ||
![]() |
735327e46b | ||
![]() |
2988ff3ee9 | ||
![]() |
431a4d7433 | ||
![]() |
58be7e9d5b | ||
![]() |
ddec77c37f | ||
![]() |
89d7009a18 | ||
![]() |
793a15883e | ||
![]() |
76897c24de | ||
![]() |
5e11a2ecf6 | ||
![]() |
e23193b730 | ||
![]() |
9146cdc835 | ||
![]() |
1f38894f02 | ||
![]() |
d72d6f8c7c | ||
![]() |
aab4dec27e | ||
![]() |
db67630363 | ||
![]() |
c887412825 | ||
![]() |
2feb07e1d3 | ||
![]() |
6f8b825b0b | ||
![]() |
cad50c9149 | ||
![]() |
d6939e52b4 | ||
![]() |
3f7de5872e | ||
![]() |
1dc632174e | ||
![]() |
eff5341335 | ||
![]() |
83e4d95741 | ||
![]() |
9b6447c4cb | ||
![]() |
ec5ed490d9 | ||
![]() |
d17bd35909 | ||
![]() |
3b7cc19faa | ||
![]() |
067ca5bd43 | ||
![]() |
525a28f3fe | ||
![]() |
a0cd8835e0 | ||
![]() |
231ca0363a | ||
![]() |
88e7d86087 | ||
![]() |
0212e52b66 | ||
![]() |
da4450b574 | ||
![]() |
ab4dbbedf0 | ||
![]() |
6e741f6156 | ||
![]() |
fb0c538a2b | ||
![]() |
f9cb6cb59b | ||
![]() |
1402d437b5 | ||
![]() |
dd58c640fa | ||
![]() |
2c2727bf66 | ||
![]() |
b8ace1eb98 | ||
![]() |
7c3d5b46f3 | ||
![]() |
a849d8452b | ||
![]() |
610e1666c0 | ||
![]() |
94d7836321 | ||
![]() |
0491d8517c | ||
![]() |
f6f2a53a0c | ||
![]() |
ce290f5f8b | ||
![]() |
d9911cf23d | ||
![]() |
1afc70e788 | ||
![]() |
c189273471 | ||
![]() |
22aceb4d67 | ||
![]() |
da6ccf4425 | ||
![]() |
d02bf0e5c7 | ||
![]() |
10aac388f0 | ||
![]() |
00e2af1561 | ||
![]() |
6a7c06d26e | ||
![]() |
efe477d0db | ||
![]() |
e17ef2edd8 | ||
![]() |
30a8b8e5e4 | ||
![]() |
2498da3909 | ||
![]() |
2791e1c385 | ||
![]() |
0303014acb | ||
![]() |
9243edf7af | ||
![]() |
ab523719a6 | ||
![]() |
b27987f1d1 | ||
![]() |
30238528fe | ||
![]() |
29c9ea1a2b | ||
![]() |
58f9588261 | ||
![]() |
3dc8deef67 | ||
![]() |
fa25857680 | ||
![]() |
254df6d6f2 | ||
![]() |
40edde2694 | ||
![]() |
9258237b85 | ||
![]() |
1bf28eb286 | ||
![]() |
77eeb63b62 | ||
![]() |
43db60bbee | ||
![]() |
6b1c313efd | ||
![]() |
d05458c7fb | ||
![]() |
b87b1a3801 | ||
![]() |
ba519334d1 | ||
![]() |
3ac131cb51 | ||
![]() |
9b88f01378 | ||
![]() |
49cd050272 | ||
![]() |
0d8928bdf5 | ||
![]() |
54b75dbe1a | ||
![]() |
b98d651144 | ||
![]() |
9a841ba5e2 | ||
![]() |
4ccdf99a43 | ||
![]() |
f8ab8d462c | ||
![]() |
fb9bc01939 | ||
![]() |
ec61444b3d | ||
![]() |
66304a418e | ||
![]() |
6bb6c16bc7 | ||
![]() |
c43deb1307 | ||
![]() |
b65b514270 | ||
![]() |
9b65e18261 | ||
![]() |
b40423fc2d | ||
![]() |
28fb3f44a7 | ||
![]() |
d607ab2981 | ||
![]() |
9cd648f78f | ||
![]() |
703d583f6f | ||
![]() |
f0d694cfe5 | ||
![]() |
3d319cbd09 | ||
![]() |
e4c4259674 | ||
![]() |
15fedf5976 | ||
![]() |
68384a00dc | ||
![]() |
e9ddd6dc36 | ||
![]() |
6ce65badeb | ||
![]() |
9ee6521d6a | ||
![]() |
72f48fa963 | ||
![]() |
b3784dcc4a | ||
![]() |
34878f9293 | ||
![]() |
adaa39f572 | ||
![]() |
1d5a0630ef | ||
![]() |
855fa7e1e2 | ||
![]() |
f2f023e7b3 | ||
![]() |
33251e880e | ||
![]() |
358816d9e7 | ||
![]() |
362d545f34 | ||
![]() |
fb81a8302c | ||
![]() |
e7a44d9979 | ||
![]() |
2eaeb1891d | ||
![]() |
5aa8d1f9a3 | ||
![]() |
098ed5b1cf | ||
![]() |
890ec64f3c | ||
![]() |
ba32422059 | ||
![]() |
8b3a9c9dad | ||
![]() |
2a22e8939c | ||
![]() |
6bee65780c | ||
![]() |
e030dc841d | ||
![]() |
25a27af29c | ||
![]() |
daf68cad01 | ||
![]() |
ab57fb3f0f | ||
![]() |
f43259fbc1 | ||
![]() |
bfe6b5bc25 | ||
![]() |
23e6eef604 | ||
![]() |
d2aa91502a | ||
![]() |
4f6ee1fb22 | ||
![]() |
ddafa9ed97 | ||
![]() |
0ca3b31b2e | ||
![]() |
9f984241c4 | ||
![]() |
d6fa83cd87 | ||
![]() |
8781e34c98 | ||
![]() |
49da9776e7 | ||
![]() |
36b9e00dc9 | ||
![]() |
5cb643a32a | ||
![]() |
1fa6e35663 | ||
![]() |
e82f0f37d8 | ||
![]() |
7fa39d42e2 | ||
![]() |
a95cc2b9e8 | ||
![]() |
e7b8b6e818 | ||
![]() |
5a7deadba2 | ||
![]() |
9065f42195 | ||
![]() |
b37981e83f | ||
![]() |
0d9c5a078b | ||
![]() |
c35c0f8b61 | ||
![]() |
8b4b3de336 | ||
![]() |
85d62a8e38 | ||
![]() |
52c8f3e12c | ||
![]() |
d0d568b3a5 | ||
![]() |
666c16b74e | ||
![]() |
2f115c0717 | ||
![]() |
d4089fbc6e | ||
![]() |
ba521abf4f | ||
![]() |
c036932ce4 | ||
![]() |
96ba039299 | ||
![]() |
1103b09a76 | ||
![]() |
cd7c1bba21 | ||
![]() |
1973614840 | ||
![]() |
cbbd77c49c | ||
![]() |
aa500351ed | ||
![]() |
de8751b86c | ||
![]() |
a1b05540be | ||
![]() |
e0dc858451 | ||
![]() |
1889f7d269 | ||
![]() |
cdc857065b | ||
![]() |
dfdb7a9b59 | ||
![]() |
4363b7c5d7 | ||
![]() |
27fce173ce | ||
![]() |
0b7d2f5aed | ||
![]() |
25c48a97c5 | ||
![]() |
a40add8f41 | ||
![]() |
3bdc7175a3 | ||
![]() |
90e35ee3db | ||
![]() |
90630fe852 | ||
![]() |
b6618c8ee5 | ||
![]() |
98fc82acfd | ||
![]() |
91e7001963 | ||
![]() |
d154986128 | ||
![]() |
3e4bbf7092 | ||
![]() |
faeb2cb7e2 | ||
![]() |
35131c8732 | ||
![]() |
2a9d5f74ce | ||
![]() |
f4cb1cb097 | ||
![]() |
e23998a88b | ||
![]() |
e39581695f | ||
![]() |
dd9e41f651 | ||
![]() |
97e7026cc9 | ||
![]() |
853cc871f7 | ||
![]() |
fc96fb40fb | ||
![]() |
172fe6c49c | ||
![]() |
9fa592c5d6 | ||
![]() |
bbffe1dc82 | ||
![]() |
55a115e57a | ||
![]() |
7ab3d2b635 | ||
![]() |
51d7c10bc5 | ||
![]() |
b13fc99e95 | ||
![]() |
b231c194a4 | ||
![]() |
b5da5a46de | ||
![]() |
8522123cd3 | ||
![]() |
bae6bc2133 | ||
![]() |
2f70ce2d5c | ||
![]() |
7ac505f1f4 | ||
![]() |
f47e45a928 | ||
![]() |
a9ab59eb92 | ||
![]() |
a0075f6f78 | ||
![]() |
8b07289452 | ||
![]() |
c1f2f84c7f | ||
![]() |
da13254caa | ||
![]() |
fe4a178d43 | ||
![]() |
1fc17658ff | ||
![]() |
a5c1cba81b | ||
![]() |
4809cf039e | ||
![]() |
a812181466 | ||
![]() |
441a6e5e0c | ||
![]() |
b5c68831b5 | ||
![]() |
cf1ef23996 | ||
![]() |
70cc754f3e | ||
![]() |
72dda3771e | ||
![]() |
4247804707 | ||
![]() |
e308108bf7 | ||
![]() |
f708cb0b25 | ||
![]() |
1f3877b7cb | ||
![]() |
72e4daafc1 | ||
![]() |
549976dcfb | ||
![]() |
756b4b9d18 | ||
![]() |
f70772fabc | ||
![]() |
ec8a8d5ddc | ||
![]() |
6d79766b24 | ||
![]() |
421266e70c | ||
![]() |
d87de1dc4f | ||
![]() |
dc99828b66 | ||
![]() |
5e8ea67773 | ||
![]() |
0d30247353 | ||
![]() |
aaf6f05820 | ||
![]() |
954a2b78be | ||
![]() |
230a54cb99 | ||
![]() |
13565d1c45 | ||
![]() |
919d8d109f | ||
![]() |
659f5a8fe1 | ||
![]() |
f86cc83996 | ||
![]() |
7525aaaa87 | ||
![]() |
115e95b9a8 | ||
![]() |
5940778189 | ||
![]() |
1a15d70568 | ||
![]() |
d66dd5f199 | ||
![]() |
507a9ffc71 | ||
![]() |
cd82f8927b | ||
![]() |
cddec51582 | ||
![]() |
78deb5d09a | ||
![]() |
4328b9e385 | ||
![]() |
44112a3a4b | ||
![]() |
9efe767654 | ||
![]() |
edb5393cdc | ||
![]() |
6d7754cf2a | ||
![]() |
4beca7af20 | ||
![]() |
a201072a9d | ||
![]() |
07b1d0841e | ||
![]() |
eccb855d09 | ||
![]() |
2f4877a264 | ||
![]() |
d84b98041f | ||
![]() |
2ae2cdc4bd | ||
![]() |
d1d781966f | ||
![]() |
53cf771c81 | ||
![]() |
d45ee34b0c | ||
![]() |
e1a64de205 | ||
![]() |
b30f6cdf3a | ||
![]() |
3dfab8e42d | ||
![]() |
7bae01f03c | ||
![]() |
f3dddf0e40 | ||
![]() |
0b7791070f | ||
![]() |
4125be7e8d | ||
![]() |
746e13d134 | ||
![]() |
b7ccc6ea07 | ||
![]() |
a6bc3fb793 | ||
![]() |
9f7e70f240 | ||
![]() |
0ee6725188 | ||
![]() |
f572757f00 | ||
![]() |
abcf1e1895 | ||
![]() |
a9e9474f5c | ||
![]() |
a11be5a1e1 | ||
![]() |
e23b2f8711 | ||
![]() |
032d37194f | ||
![]() |
3e56950872 | ||
![]() |
5a2612acab | ||
![]() |
bda05aed86 | ||
![]() |
91ac1a9031 | ||
![]() |
53e8c15267 | ||
![]() |
d329b2945c | ||
![]() |
abca0115a6 | ||
![]() |
3d6cc8a490 | ||
![]() |
836fc0bf5b | ||
![]() |
510b8383a4 | ||
![]() |
8b15f1304f | ||
![]() |
6274e33a8c | ||
![]() |
1f97d4f5e5 | ||
![]() |
b566549d15 | ||
![]() |
4d8c8b199c | ||
![]() |
d1d69e9488 | ||
![]() |
a01fd62899 | ||
![]() |
70956a2c47 | ||
![]() |
e894d1d1f4 | ||
![]() |
cc7b9ccb86 | ||
![]() |
a807a0f50c | ||
![]() |
99065548ff | ||
![]() |
df897aef13 | ||
![]() |
1cfc275eae | ||
![]() |
3968e40a0b | ||
![]() |
ac6106ca69 | ||
![]() |
eed73eca81 | ||
![]() |
608da824d9 | ||
![]() |
03fc301dec | ||
![]() |
1cad8b2481 | ||
![]() |
e930199f83 | ||
![]() |
60044d5cdf | ||
![]() |
e793ba6630 | ||
![]() |
67ec6f7773 | ||
![]() |
ddb8e3656f | ||
![]() |
e49e0edc57 | ||
![]() |
e255c35e86 | ||
![]() |
48daa042d1 | ||
![]() |
64c58a3cf8 | ||
![]() |
a9fbf48053 | ||
![]() |
ccb4661b39 | ||
![]() |
c5344d2df6 | ||
![]() |
669e50e406 | ||
![]() |
7221400b88 | ||
![]() |
6e50288bd4 | ||
![]() |
84de5e09a2 | ||
![]() |
f717bc47e5 | ||
![]() |
f732e04f49 | ||
![]() |
ecf46fa6fe | ||
![]() |
b1ec1b8817 | ||
![]() |
bd7e6f9f8a | ||
![]() |
75caface6b | ||
![]() |
de373a683b | ||
![]() |
5ab47aeead | ||
![]() |
62aa0c5965 | ||
![]() |
625982d639 | ||
![]() |
9f65de2ba6 | ||
![]() |
f4267737c3 | ||
![]() |
74678882ee | ||
![]() |
4e2125d613 | ||
![]() |
12e4779093 | ||
![]() |
844c629a6a | ||
![]() |
a40b44b6e3 | ||
![]() |
bc8b5a8d32 | ||
![]() |
8be7dac33b | ||
![]() |
b2aea57da6 | ||
![]() |
a007606863 | ||
![]() |
90075b3b65 | ||
![]() |
4ecea891b3 | ||
![]() |
845b5cda1a | ||
![]() |
f2915afda4 | ||
![]() |
d504da19c5 | ||
![]() |
ec7b0cdda1 | ||
![]() |
9f0cfc68c1 | ||
![]() |
1f3b5a49c4 | ||
![]() |
a84bcf688b | ||
![]() |
4729785b05 | ||
![]() |
6b6e358dbe | ||
![]() |
58f9b3ce2a | ||
![]() |
8742a03e18 | ||
![]() |
1be26b7f33 | ||
![]() |
08a75f6e9f | ||
![]() |
8cc6def93e | ||
![]() |
70ee784818 | ||
![]() |
8932b51216 | ||
![]() |
69bda79baf | ||
![]() |
214f3d9b1e | ||
![]() |
58354e7adf | ||
![]() |
aa5e44efb5 | ||
![]() |
9572fbf584 | ||
![]() |
b6cb119e89 | ||
![]() |
12eeb5df97 | ||
![]() |
d77de76c97 | ||
![]() |
105dab7a3d | ||
![]() |
ba2b4bf12c | ||
![]() |
b1489c56e2 | ||
![]() |
c601d46970 | ||
![]() |
51cad13f5a | ||
![]() |
17ae06f9c1 | ||
![]() |
5a03f5c23e | ||
![]() |
bf1726a52b | ||
![]() |
c1f72e0d11 | ||
![]() |
c2227b306b | ||
![]() |
961cf803f2 | ||
![]() |
eab3b75ae5 | ||
![]() |
92538b87ad | ||
![]() |
5f4d393db3 | ||
![]() |
edd5d49e36 | ||
![]() |
0d52d554e7 | ||
![]() |
f1a8b8df7f | ||
![]() |
9e1b83cbbe | ||
![]() |
40ae14bd7a | ||
![]() |
e2b91dca23 | ||
![]() |
3fde80f991 | ||
![]() |
afd5c3a5fd | ||
![]() |
cfdb492349 | ||
![]() |
816e652357 | ||
![]() |
027d44e04a | ||
![]() |
c38dc8b842 | ||
![]() |
0d97ff2936 | ||
![]() |
9b59b44609 | ||
![]() |
6f02e1b18e | ||
![]() |
488126b92c | ||
![]() |
4318f03bd6 | ||
![]() |
13ac33bb27 | ||
![]() |
93b03c9562 | ||
![]() |
e1685231c2 | ||
![]() |
90cb25446b | ||
![]() |
fd2b290fd0 | ||
![]() |
b4816c6289 | ||
![]() |
0d9a502801 | ||
![]() |
b840ae7513 | ||
![]() |
29767dfcfb | ||
![]() |
dd3f91cf0c | ||
![]() |
ae38e09d1b | ||
![]() |
de13e48aa5 | ||
![]() |
05bb3849a2 | ||
![]() |
af405cfd10 | ||
![]() |
8d880fc9dd | ||
![]() |
c18367739f | ||
![]() |
26a6a4d991 | ||
![]() |
93bce57888 | ||
![]() |
5f6b389556 | ||
![]() |
d90cab40a6 | ||
![]() |
c002d3d182 | ||
![]() |
85947878c4 | ||
![]() |
a991dc0684 | ||
![]() |
29817653ed | ||
![]() |
f5f973dc3a | ||
![]() |
f49b4d1b8b | ||
![]() |
82656f263d | ||
![]() |
f942716bf9 | ||
![]() |
dcc7819466 | ||
![]() |
8fcef1fb4d | ||
![]() |
2f5e01c9e9 | ||
![]() |
50d1bbbe4d | ||
![]() |
62bdf82627 | ||
![]() |
2ed63b1c1a | ||
![]() |
026d98551c | ||
![]() |
f913ed8332 | ||
![]() |
2863ff7a5c | ||
![]() |
4993b349ef | ||
![]() |
eb31fa9ab7 | ||
![]() |
18f8577005 | ||
![]() |
6ab3898f27 | ||
![]() |
efb8f8f315 | ||
![]() |
e96f8844e2 | ||
![]() |
45b8d9fb84 | ||
![]() |
2f8411ba2f | ||
![]() |
714c0a6cfd | ||
![]() |
9125d7ef74 | ||
![]() |
1ce67953df | ||
![]() |
e19adf8907 | ||
![]() |
9ee46107d2 | ||
![]() |
2ebe0401c3 | ||
![]() |
5aa982c95f | ||
![]() |
46c7ef42de | ||
![]() |
a9c4d37819 | ||
![]() |
e8f235e4f7 | ||
![]() |
ad311e9e7e | ||
![]() |
01af73502a | ||
![]() |
a81e121ffd | ||
![]() |
cf7e3c2302 | ||
![]() |
743a2ccd07 | ||
![]() |
e77650c997 | ||
![]() |
d1fc5d5c38 | ||
![]() |
2fa62acbbd | ||
![]() |
ad4ec41e15 | ||
![]() |
539f4a5c31 | ||
![]() |
7b2faf90f2 | ||
![]() |
ac57ddbb16 | ||
![]() |
b434fa108d | ||
![]() |
f1496c771e | ||
![]() |
f611a5a521 | ||
![]() |
81aa0ae109 | ||
![]() |
5736faf24c | ||
![]() |
c87c50bfb9 | ||
![]() |
10162b378a | ||
![]() |
e879102768 | ||
![]() |
de4667cc71 | ||
![]() |
8fc3a71e0f | ||
![]() |
2d2c94e4d7 | ||
![]() |
21669b5f4a | ||
![]() |
ad5dec3dc6 | ||
![]() |
32fc0415da | ||
![]() |
5f70a524e9 | ||
![]() |
9263dd4ddb | ||
![]() |
f17ff59ba6 | ||
![]() |
15fb7f45b8 | ||
![]() |
f71eadd409 | ||
![]() |
49122d940d | ||
![]() |
eb1351d108 | ||
![]() |
b67df1328b | ||
![]() |
976a5836a9 | ||
![]() |
2ebae17839 | ||
![]() |
eddbfcab36 | ||
![]() |
320aaab4b3 | ||
![]() |
f0880785a9 | ||
![]() |
9faaea881d | ||
![]() |
01e5eee981 | ||
![]() |
94a0a57cfe | ||
![]() |
265c7ad76f | ||
![]() |
36a902398a | ||
![]() |
506de0383f | ||
![]() |
9b67010f2c | ||
![]() |
f7f8f8dabf | ||
![]() |
01182ef752 | ||
![]() |
8410419717 | ||
![]() |
5f7fa33eb2 | ||
![]() |
a1d88a5e6b | ||
![]() |
a3723e4879 | ||
![]() |
b7f3a67cd0 | ||
![]() |
c880065da8 | ||
![]() |
86af4baef5 | ||
![]() |
8cdfe4a22c | ||
![]() |
d6f05684be | ||
![]() |
17251b2c88 | ||
![]() |
64acfbcb4e | ||
![]() |
55a3f9669b | ||
![]() |
884f136e99 | ||
![]() |
dc6bd4d4a7 | ||
![]() |
1e5b7e7ee7 | ||
![]() |
c874d97507 | ||
![]() |
3f61c9ee18 | ||
![]() |
eece358e20 | ||
![]() |
2b1fd9e986 | ||
![]() |
79e4e596e8 | ||
![]() |
23dea7bced | ||
![]() |
e4c2336659 | ||
![]() |
98fa6eea05 | ||
![]() |
9b21d52206 | ||
![]() |
00548a259b | ||
![]() |
f4bc280da7 | ||
![]() |
68ed5942e6 | ||
![]() |
9d2bcff96b | ||
![]() |
39d53617bd | ||
![]() |
cfdaa1e927 | ||
![]() |
aef679c030 | ||
![]() |
dec0ebba30 | ||
![]() |
e82e27acd7 | ||
![]() |
23358d9c5d | ||
![]() |
80989cc84f | ||
![]() |
d8bd4bd847 | ||
![]() |
f18f24962e | ||
![]() |
0753e956f9 | ||
![]() |
83f9a3faa7 | ||
![]() |
cac005f993 | ||
![]() |
fb7368993c | ||
![]() |
38f88407ff | ||
![]() |
d842a3d8e0 | ||
![]() |
2163522e7c | ||
![]() |
225e13f43b | ||
![]() |
fa1cf353b8 | ||
![]() |
4746b6fae9 | ||
![]() |
2c7f2c0fcd | ||
![]() |
b8389c72bb | ||
![]() |
dfa4178204 | ||
![]() |
2b7ebedb22 | ||
![]() |
33ffd7e855 | ||
![]() |
b11f9f62b7 | ||
![]() |
c6765fd9a9 | ||
![]() |
8c201dced7 | ||
![]() |
71851e1a05 | ||
![]() |
db62bd20b3 | ||
![]() |
31b213610f | ||
![]() |
d0881cbd09 | ||
![]() |
7e4bd851f1 | ||
![]() |
ab80aedb63 | ||
![]() |
c7537e7994 | ||
![]() |
9f763b46eb | ||
![]() |
d21826c70d | ||
![]() |
a061e362c3 | ||
![]() |
7e852c1836 | ||
![]() |
a01982ae55 | ||
![]() |
884f960d3b | ||
![]() |
0c6bfcbee6 | ||
![]() |
03639d73fa | ||
![]() |
cfc92ac9e7 | ||
![]() |
dc90abcf09 | ||
![]() |
b985124bef | ||
![]() |
b653351f71 | ||
![]() |
0a0b471a03 | ||
![]() |
c389ebabd0 | ||
![]() |
8264a69cec | ||
![]() |
cd466a64e5 | ||
![]() |
b04c1054fc | ||
![]() |
3befdc09e3 | ||
![]() |
9fe9983bf9 | ||
![]() |
ed54092268 | ||
![]() |
50dafc91d4 | ||
![]() |
d409e1d088 | ||
![]() |
64c8768314 | ||
![]() |
c5bd40793b | ||
![]() |
8a6fdb5ea5 | ||
![]() |
6fbc79fe5e | ||
![]() |
7ccd9ad896 | ||
![]() |
ef9dc9ff6d | ||
![]() |
ed0a1f2740 | ||
![]() |
871ea84f96 | ||
![]() |
e427e50d67 | ||
![]() |
99a5615e91 | ||
![]() |
c8201de2ff | ||
![]() |
3c54960612 | ||
![]() |
5045df0b57 | ||
![]() |
f388f84b07 | ||
![]() |
f8f6b76657 | ||
![]() |
cb6c25f829 | ||
![]() |
05a3e3f805 | ||
![]() |
273fa7eb55 | ||
![]() |
2278082a4d | ||
![]() |
d5d9c644a2 | ||
![]() |
1a51f3d854 | ||
![]() |
f80d3cd530 | ||
![]() |
cea06c9673 | ||
![]() |
22176e89dd | ||
![]() |
2b220459c7 | ||
![]() |
c1b2b7e177 | ||
![]() |
6ac07e1255 | ||
![]() |
1509b6fce5 | ||
![]() |
ebe2013849 | ||
![]() |
cceb66e500 | ||
![]() |
36a5f2ab49 | ||
![]() |
9c54a4ada1 | ||
![]() |
2e3823364c | ||
![]() |
ec71f532a1 | ||
![]() |
4030904d40 | ||
![]() |
9f62c280de | ||
![]() |
94fa0380ba | ||
![]() |
b3bdee60bb | ||
![]() |
434633924a | ||
![]() |
88aeaf31c2 | ||
![]() |
604420c7d4 | ||
![]() |
b64f6c7884 | ||
![]() |
db9b3617a4 | ||
![]() |
42888c0983 | ||
![]() |
9abbc001b3 | ||
![]() |
4741ee0a7b | ||
![]() |
de5a8fae7c | ||
![]() |
a63d7e9b64 | ||
![]() |
194f49c561 | ||
![]() |
922b550c17 | ||
![]() |
7f0305fb7a | ||
![]() |
d4801f58e3 | ||
![]() |
e4392cd00a | ||
![]() |
163c65600d | ||
![]() |
3c740549e2 | ||
![]() |
3178894e4f | ||
![]() |
deed2111fb | ||
![]() |
3e8924e7cc | ||
![]() |
fec259629e | ||
![]() |
3b64950a38 | ||
![]() |
be533922a2 | ||
![]() |
38e6441b61 | ||
![]() |
c2b2d11141 | ||
![]() |
22c33b58c7 | ||
![]() |
9b101963e5 | ||
![]() |
620447f029 | ||
![]() |
733e7ee00c | ||
![]() |
3877346b3a | ||
![]() |
e67cde4255 | ||
![]() |
e46f4bf01e | ||
![]() |
f7a019ed83 | ||
![]() |
2950827c63 | ||
![]() |
b37f63a231 | ||
![]() |
365e4a4194 | ||
![]() |
c43a4edec7 | ||
![]() |
b5a519d132 | ||
![]() |
35728e20be | ||
![]() |
960d6279a9 | ||
![]() |
9ea103c0eb | ||
![]() |
12e4b0a139 | ||
![]() |
731c2168b0 | ||
![]() |
ef045607d9 | ||
![]() |
bb4e98af8d | ||
![]() |
6ea8a02b57 | ||
![]() |
187fea6d1b | ||
![]() |
36ba6f1463 | ||
![]() |
f005ef4d52 | ||
![]() |
6a0a4627b4 | ||
![]() |
2dbba970b9 | ||
![]() |
bcedc58d9f | ||
![]() |
78500770d9 | ||
![]() |
488696cb39 | ||
![]() |
6dfda20116 | ||
![]() |
e50356d276 | ||
![]() |
7b2fef5f09 | ||
![]() |
87cced1637 | ||
![]() |
2ce242ba42 | ||
![]() |
bdbbe990dd | ||
![]() |
2ca93a07e9 | ||
![]() |
0a113611e8 | ||
![]() |
e93063a344 | ||
![]() |
18cec49a86 | ||
![]() |
db3f215ebe | ||
![]() |
8470126918 | ||
![]() |
9566a882b5 | ||
![]() |
7d72a43ecd | ||
![]() |
8afc376636 | ||
![]() |
89da6ae501 | ||
![]() |
d23e5d169a | ||
![]() |
9de35a6e8b | ||
![]() |
d8de36b5ac | ||
![]() |
2fde1db83c | ||
![]() |
5fb99c54c9 | ||
![]() |
ed55fbca9e | ||
![]() |
2375733d0f | ||
![]() |
065f845707 | ||
![]() |
839c4e0c28 | ||
![]() |
a20eb468df | ||
![]() |
303eba6bca | ||
![]() |
bc51a868ce | ||
![]() |
f2c73acd3b | ||
![]() |
2f5de67ee7 | ||
![]() |
db3ea2e34a | ||
![]() |
2388ab88b6 | ||
![]() |
e49a31df6a | ||
![]() |
d5a9aa6925 | ||
![]() |
409a49ba20 | ||
![]() |
4c29a667cb | ||
![]() |
8d70107b5d | ||
![]() |
51aeb50d39 | ||
![]() |
0e8f383c14 | ||
![]() |
ca5e2c1ff9 | ||
![]() |
a6d5b262f9 | ||
![]() |
5952df82ff | ||
![]() |
8f1f8abf42 | ||
![]() |
3edbe96968 | ||
![]() |
d6aeb1d10f | ||
![]() |
5334cf1871 | ||
![]() |
a999b996fb | ||
![]() |
903afc111e | ||
![]() |
3413d7c6f6 | ||
![]() |
fe4c3d4942 | ||
![]() |
6352a6dc9a | ||
![]() |
172dbba8aa | ||
![]() |
1152fba067 | ||
![]() |
d74025318e | ||
![]() |
dd2631d27c | ||
![]() |
d52a186e12 | ||
![]() |
7d3f2e6bdf | ||
![]() |
8776cd19dd | ||
![]() |
9c31e92c01 | ||
![]() |
cd9004b32b | ||
![]() |
ba8faacbd0 | ||
![]() |
927470db72 | ||
![]() |
4ff0450632 | ||
![]() |
862198cf82 | ||
![]() |
3726a2685a | ||
![]() |
17810d9cae | ||
![]() |
92a52133de | ||
![]() |
9589606fb5 | ||
![]() |
ad7b347e16 | ||
![]() |
f33d7b7f90 | ||
![]() |
36d4f0a5f7 | ||
![]() |
0dc344b821 | ||
![]() |
25f39f4173 | ||
![]() |
e656f769b1 | ||
![]() |
28238c6fb5 | ||
![]() |
e77ca93d80 | ||
![]() |
da3aaafbcd | ||
![]() |
10628eeb91 | ||
![]() |
20aa6a3fbb | ||
![]() |
e9edf205d9 | ||
![]() |
6397a93f97 | ||
![]() |
9c5f3a3b64 | ||
![]() |
5e0253927c | ||
![]() |
d16290cb70 | ||
![]() |
c6df827311 | ||
![]() |
496e03a3ec | ||
![]() |
7e0e881017 | ||
![]() |
11cda10ca5 | ||
![]() |
a289216eac | ||
![]() |
c79ecab719 | ||
![]() |
4fb226ad98 | ||
![]() |
a28d9b9748 | ||
![]() |
6b466bb90f | ||
![]() |
cb6499522e | ||
![]() |
78a9ba5084 | ||
![]() |
cff4942769 | ||
![]() |
e3b1be5835 | ||
![]() |
983a06abe3 | ||
![]() |
75319c0d6a | ||
![]() |
18c3c57930 | ||
![]() |
c371db3534 | ||
![]() |
a49aa77ec0 | ||
![]() |
129455a31f | ||
![]() |
10a801aa10 | ||
![]() |
aea7f01047 | ||
![]() |
56c5c4e540 | ||
![]() |
d48a92c88d | ||
![]() |
aa37fc3add | ||
![]() |
1bb41b21af | ||
![]() |
4e25e87bfb | ||
![]() |
80b9593651 | ||
![]() |
edef084121 | ||
![]() |
fc32542f55 | ||
![]() |
efcfd787af | ||
![]() |
700b5f0b91 | ||
![]() |
dfc88193b2 | ||
![]() |
b4d5d70e4c | ||
![]() |
1bad1cd3e7 | ||
![]() |
f0b6b62791 | ||
![]() |
ae1e9dba0f | ||
![]() |
777a7afd46 | ||
![]() |
ab3a66542d | ||
![]() |
c72d99794e | ||
![]() |
fc5b931007 | ||
![]() |
cdae4bf8da | ||
![]() |
71d8d5a70d | ||
![]() |
322335f4ab | ||
![]() |
0904cda2c6 | ||
![]() |
fad8b44be2 | ||
![]() |
da910b1414 | ||
![]() |
6037519fbe | ||
![]() |
7e15f75d44 | ||
![]() |
25ecade1e6 | ||
![]() |
69161b7037 | ||
![]() |
4e892d09ec | ||
![]() |
e284370c4b | ||
![]() |
01b78d7513 | ||
![]() |
b9fa324bb4 | ||
![]() |
0a42ec77b2 | ||
![]() |
a9e64e931e | ||
![]() |
caa13f5a75 | ||
![]() |
9d5adf7793 | ||
![]() |
8f4b223125 | ||
![]() |
e38cfda076 | ||
![]() |
511e185f33 | ||
![]() |
7c4e9b56c7 | ||
![]() |
d18bade951 | ||
![]() |
c4e872c94c | ||
![]() |
57f3b942e5 | ||
![]() |
37d4ef751c | ||
![]() |
b5effaa01b | ||
![]() |
66a15fb9a1 | ||
![]() |
33abeb1aca | ||
![]() |
128657810b | ||
![]() |
f5d24133f7 | ||
![]() |
a28a801a62 | ||
![]() |
738d5d94e0 | ||
![]() |
3fae9e6270 | ||
![]() |
691a5e84f9 | ||
![]() |
18625efa87 | ||
![]() |
d99f2541df | ||
![]() |
72177aef0a | ||
![]() |
a3195267c9 | ||
![]() |
8104657ae9 | ||
![]() |
78fb38e072 | ||
![]() |
206d51f59b | ||
![]() |
2e0bc63e20 | ||
![]() |
031d97aea3 | ||
![]() |
59a9d2cf86 | ||
![]() |
ee961edf94 | ||
![]() |
7b485d5ad2 | ||
![]() |
ec2600ddf7 | ||
![]() |
63fef16c37 | ||
![]() |
74fecf553e | ||
![]() |
587a4daf7a | ||
![]() |
2290d9f990 | ||
![]() |
3553f23eab | ||
![]() |
0c5992ad75 | ||
![]() |
8ae1b87a1e | ||
![]() |
4d404cb20b | ||
![]() |
5b697cdf26 | ||
![]() |
ceceb3f030 | ||
![]() |
66fd2ff5e6 | ||
![]() |
7f06b3e53b | ||
![]() |
e990be3570 | ||
![]() |
57e22c9ff5 | ||
![]() |
b6bd095d8e | ||
![]() |
778578292f | ||
![]() |
8744ee74b3 | ||
![]() |
47bfcc23cb | ||
![]() |
962d31c4c2 | ||
![]() |
6093be43c9 | ||
![]() |
753daa55e8 | ||
![]() |
09227fa30a | ||
![]() |
4e3aa1af83 | ||
![]() |
a6d97538af | ||
![]() |
85ef73dcb9 | ||
![]() |
0ead06106c | ||
![]() |
86a42064ea | ||
![]() |
9584fb57b0 | ||
![]() |
e852613567 | ||
![]() |
065ad9e422 | ||
![]() |
f1c2fd399e | ||
![]() |
8cc54b6106 | ||
![]() |
072f5da69d | ||
![]() |
025cabd1ad | ||
![]() |
b261e8bb9b | ||
![]() |
a36f775752 | ||
![]() |
091b479a02 | ||
![]() |
9c75d7b560 | ||
![]() |
ceb70eec4c | ||
![]() |
ea180ca107 | ||
![]() |
1117893900 | ||
![]() |
b22e7fd077 | ||
![]() |
d677cb1bc8 | ||
![]() |
15fc82fc34 | ||
![]() |
4716545b7e | ||
![]() |
16a4fe1a4f | ||
![]() |
8a08b3f7c7 | ||
![]() |
999bb29499 | ||
![]() |
1575cad447 | ||
![]() |
cdcb106f2d | ||
![]() |
af14216eea | ||
![]() |
b9c5f6a869 | ||
![]() |
7e071a7daf | ||
![]() |
db3cd4ec6e | ||
![]() |
ae27c110ab | ||
![]() |
f83fc18ebc | ||
![]() |
23fb5e09d1 | ||
![]() |
fe7612c885 | ||
![]() |
517dd4ad9e | ||
![]() |
e672e9670f | ||
![]() |
0b25469f33 | ||
![]() |
765b7b4957 | ||
![]() |
9eeb921915 | ||
![]() |
9045505153 | ||
![]() |
eb221417e5 | ||
![]() |
cabe422508 | ||
![]() |
8579b89002 | ||
![]() |
0545099a2b | ||
![]() |
94fc5c1859 | ||
![]() |
6af5157b4e | ||
![]() |
dc28b1337d | ||
![]() |
2ce7c93aeb | ||
![]() |
88b3279e63 | ||
![]() |
ab61778d35 | ||
![]() |
f89dc88c0e | ||
![]() |
ad110c2ce2 | ||
![]() |
5e0ba81b21 | ||
![]() |
a0bb481a43 | ||
![]() |
3aac855fa1 | ||
![]() |
94883c1433 | ||
![]() |
7b7eee92cd | ||
![]() |
c2d76966a3 | ||
![]() |
82dfce6f81 | ||
![]() |
1b0d6581db | ||
![]() |
819ae22b0e | ||
![]() |
33af2e6fa1 | ||
![]() |
4396c4c628 | ||
![]() |
daa5126c21 | ||
![]() |
494b1384c4 | ||
![]() |
c0db03bc28 | ||
![]() |
408bffb775 | ||
![]() |
a6f608e8cc | ||
![]() |
e97b8a9f7e | ||
![]() |
31dff0d353 | ||
![]() |
30f95e2f08 | ||
![]() |
099b6915f4 | ||
![]() |
da6c782ac3 | ||
![]() |
e99c001673 | ||
![]() |
c7d587b4cb | ||
![]() |
ff348a2aa0 | ||
![]() |
bc7ccb6a9f | ||
![]() |
40d36f9808 | ||
![]() |
f49fdebd98 | ||
![]() |
ca57bd3572 | ||
![]() |
6f62f141d2 | ||
![]() |
197d3de74a | ||
![]() |
1244659064 | ||
![]() |
16bc3076ad | ||
![]() |
1fbe429a08 | ||
![]() |
340a177a29 | ||
![]() |
2f676774e9 | ||
![]() |
a2032a7be2 | ||
![]() |
f549858c5d | ||
![]() |
9d02180c92 | ||
![]() |
e906c01e64 | ||
![]() |
be92075abb | ||
![]() |
10e34b83ed | ||
![]() |
ae76ceea04 | ||
![]() |
6f60387f30 | ||
![]() |
60222c4977 | ||
![]() |
ff588b6a5c | ||
![]() |
871dd35a3a | ||
![]() |
f687078bbf | ||
![]() |
6fc666e221 | ||
![]() |
095afcde24 | ||
![]() |
1353f6ed3c | ||
![]() |
a7c6380a3a | ||
![]() |
5a4abbb163 | ||
![]() |
092f1cda0c | ||
![]() |
cc4b2278e7 | ||
![]() |
282185c5af | ||
![]() |
95da490f9a | ||
![]() |
760fbc57bc | ||
![]() |
47f6c941ec | ||
![]() |
4229798c7b | ||
![]() |
8aff5d519d | ||
![]() |
bb0666b77d | ||
![]() |
dbd00291b3 | ||
![]() |
c1f9190613 | ||
![]() |
ce354d5bc3 | ||
![]() |
b9037111a4 | ||
![]() |
03dad82663 | ||
![]() |
e8828efae3 | ||
![]() |
b8f1b7bd84 | ||
![]() |
0fa888efaf | ||
![]() |
f385aab44a | ||
![]() |
a7b91b5b31 | ||
![]() |
901dacf038 | ||
![]() |
426ba0ea34 | ||
![]() |
3a10a4bcb7 | ||
![]() |
6e15d59a84 | ||
![]() |
f1fd003dca | ||
![]() |
1ceb1e4434 | ||
![]() |
5f9d311cdb | ||
![]() |
7630f504b0 | ||
![]() |
e7871380a9 | ||
![]() |
85166d5beb | ||
![]() |
90cc8e5370 | ||
![]() |
a12318246f | ||
![]() |
eb28fc2e3c | ||
![]() |
fec7c3b3ee | ||
![]() |
23d38604c4 | ||
![]() |
67c1adcc75 | ||
![]() |
3990854d42 | ||
![]() |
5d875bc731 | ||
![]() |
ddb05afe6b | ||
![]() |
43bbc2a29e | ||
![]() |
7a5ba0503a | ||
![]() |
28e9085249 | ||
![]() |
5ff57ae7d2 | ||
![]() |
df8778f85d | ||
![]() |
2be1d12116 | ||
![]() |
eb76d868ca | ||
![]() |
b34d88d704 | ||
![]() |
0758ca09e6 | ||
![]() |
1bdb845032 | ||
![]() |
4d33e3dcbe | ||
![]() |
b0fa559760 | ||
![]() |
8a378317c0 | ||
![]() |
a6b7056f2a | ||
![]() |
9fef4c2601 | ||
![]() |
209b4b4de3 | ||
![]() |
7651efff9d | ||
![]() |
7b5e2d17f3 | ||
![]() |
4dfc29768c | ||
![]() |
2d87ce5c29 | ||
![]() |
2d0a922cff | ||
![]() |
a553a26644 | ||
![]() |
4a383709bd | ||
![]() |
8d16a5f110 | ||
![]() |
8b044dbb22 | ||
![]() |
93b752f436 | ||
![]() |
87374d5647 | ||
![]() |
ab33b49218 | ||
![]() |
52fbe73893 | ||
![]() |
232a02b944 | ||
![]() |
53fc1508f3 | ||
![]() |
1b33c8a2b7 | ||
![]() |
dd6c9cc8ce | ||
![]() |
d61fa7b6b9 | ||
![]() |
22aa55c24b | ||
![]() |
80589cde2f | ||
![]() |
1463c09385 | ||
![]() |
e3cad91be0 | ||
![]() |
aeace0c7cf | ||
![]() |
20492410ad | ||
![]() |
66bc775e14 | ||
![]() |
3e796e9164 | ||
![]() |
ffb33d00c8 | ||
![]() |
7fabef6004 | ||
![]() |
ce969306f7 | ||
![]() |
a919bfb6c5 | ||
![]() |
b9b5a0e79b | ||
![]() |
284078ff71 | ||
![]() |
5e339bb7ea | ||
![]() |
c611eb3787 | ||
![]() |
d933dd2723 | ||
![]() |
25a019cc12 | ||
![]() |
9b40096bb7 | ||
![]() |
2fa7857daf | ||
![]() |
0237d8c31a | ||
![]() |
9b6113a4c8 | ||
![]() |
def8ea7c15 | ||
![]() |
d7c145ce39 | ||
![]() |
e7fb1559f5 | ||
![]() |
6386b34516 | ||
![]() |
48864ab611 | ||
![]() |
8e4079224f | ||
![]() |
d4aef9ceac | ||
![]() |
1884edb334 | ||
![]() |
711e526822 | ||
![]() |
272b0fd071 | ||
![]() |
a7f4b2e6ef | ||
![]() |
e0dff55ffa | ||
![]() |
b2e2b2e85e | ||
![]() |
bbfffd45fc | ||
![]() |
ed705ff867 | ||
![]() |
03a569d9a3 | ||
![]() |
a6c89d7998 | ||
![]() |
ad6562558d | ||
![]() |
82074a37ba | ||
![]() |
ab517d1199 | ||
![]() |
7c6c2f7ded | ||
![]() |
65ac7e0c15 | ||
![]() |
a52b5ec380 | ||
![]() |
12310da09e | ||
![]() |
8095f2c9ea | ||
![]() |
3ece3303db | ||
![]() |
9fe1d4c596 | ||
![]() |
0dc9793772 | ||
![]() |
ec5ff8a788 | ||
![]() |
3b6b1aa5b6 | ||
![]() |
57cb787b30 | ||
![]() |
fbd12c7dfc | ||
![]() |
e6a92c5667 | ||
![]() |
9365dd7b1a | ||
![]() |
af8bd246a9 | ||
![]() |
87d8322b85 | ||
![]() |
b229b409b0 | ||
![]() |
d0a7a241b4 | ||
![]() |
8af247a7f6 | ||
![]() |
d295cf04af | ||
![]() |
2188e91fae | ||
![]() |
884b1e02a7 | ||
![]() |
7e0713e22b | ||
![]() |
25c1ae3c41 | ||
![]() |
177286533d | ||
![]() |
83c354b983 | ||
![]() |
1ce60821bd | ||
![]() |
97bdc3f785 | ||
![]() |
82e730c18e | ||
![]() |
4474f30718 | ||
![]() |
fa700d53ad | ||
![]() |
d671b18215 | ||
![]() |
8169160b57 | ||
![]() |
560575e53f | ||
![]() |
54f1a52ed0 | ||
![]() |
c2ea1be83f | ||
![]() |
4d742bacb1 | ||
![]() |
fe584f193f | ||
![]() |
897bb177bc | ||
![]() |
445862d48d | ||
![]() |
c3079fe899 | ||
![]() |
3cf4c0f8e4 | ||
![]() |
a881b310bc | ||
![]() |
ac133ce830 | ||
![]() |
cf32d4235e | ||
![]() |
5836099746 | ||
![]() |
a10de791a1 | ||
![]() |
90af8f91b8 | ||
![]() |
8884d28306 | ||
![]() |
4addedef6e | ||
![]() |
8eee4a1cf0 | ||
![]() |
fb156d2e29 | ||
![]() |
35aab87fdc | ||
![]() |
5cdd09020d | ||
![]() |
2e561f1a4a | ||
![]() |
a1d6403b1b | ||
![]() |
b2bda5e31d | ||
![]() |
add4337d11 | ||
![]() |
31941c00bf | ||
![]() |
d1a35a4d58 | ||
![]() |
949b9d64bf | ||
![]() |
00615bea97 | ||
![]() |
91db10b10c | ||
![]() |
eede391be8 | ||
![]() |
544f05a5a8 | ||
![]() |
661d536e9d | ||
![]() |
60fe7cf29c | ||
![]() |
2d75409757 | ||
![]() |
c48371ca2a | ||
![]() |
6c5377fadc | ||
![]() |
4cf61a92cf | ||
![]() |
2332cae09b | ||
![]() |
ee65d08d81 | ||
![]() |
e19119194d | ||
![]() |
e4e0d81f6e | ||
![]() |
70c5e36ccb | ||
![]() |
7532dc5117 | ||
![]() |
059b24fac7 | ||
![]() |
97e1700cf9 | ||
![]() |
241747b967 | ||
![]() |
a933fc836f | ||
![]() |
492546d0f6 | ||
![]() |
ba790823ed | ||
![]() |
637c249c36 | ||
![]() |
abfe8bc648 | ||
![]() |
216807503a | ||
![]() |
89bb0aa56d | ||
![]() |
26d7ab080f | ||
![]() |
9ad64ba5e1 | ||
![]() |
793022b92f | ||
![]() |
ff904d840f | ||
![]() |
34623a7307 | ||
![]() |
89f0336af9 | ||
![]() |
1420a33649 | ||
![]() |
a23eb3f32d | ||
![]() |
eaf929474f | ||
![]() |
f58b065316 | ||
![]() |
e462e41ae1 | ||
![]() |
5969515f25 | ||
![]() |
cc2308c399 | ||
![]() |
85403dfa5e | ||
![]() |
8f69b07ee2 | ||
![]() |
562d7b48bc | ||
![]() |
63350469d0 | ||
![]() |
f93fd7aefa | ||
![]() |
0128690da8 | ||
![]() |
9b76e23354 | ||
![]() |
e3bf7f2bb2 | ||
![]() |
0209957def | ||
![]() |
1cdb11c88c | ||
![]() |
8e9c66c0ea | ||
![]() |
fe80028c07 | ||
![]() |
329e75ee82 | ||
![]() |
801c56f06e | ||
![]() |
a2b7f882bc | ||
![]() |
ff2e39f67a | ||
![]() |
708641a8f1 | ||
![]() |
fac00e6ecd | ||
![]() |
e1e3301fc1 | ||
![]() |
1a18147971 | ||
![]() |
719e7c8441 | ||
![]() |
3ad19d05e5 | ||
![]() |
fb7a572519 | ||
![]() |
b3867d9c89 | ||
![]() |
797a65e9c8 | ||
![]() |
40b4596df4 | ||
![]() |
5e27ceedce | ||
![]() |
a927827e33 | ||
![]() |
d1d64ec96c | ||
![]() |
480d878db8 | ||
![]() |
475ab3013f | ||
![]() |
b55ecc3898 | ||
![]() |
a327dfab7c | ||
![]() |
649ac12cdd | ||
![]() |
dde6195f38 | ||
![]() |
0035a4129a | ||
![]() |
523ea6e0df | ||
![]() |
59167278d4 | ||
![]() |
f480c046f6 | ||
![]() |
af99ca7905 | ||
![]() |
850b6f71dd | ||
![]() |
ca602ff845 | ||
![]() |
ce629c91bb | ||
![]() |
a3cbb24892 | ||
![]() |
db7d021133 | ||
![]() |
5e9264bbef | ||
![]() |
758d5e6f4c | ||
![]() |
4d8e29c892 | ||
![]() |
fd1342c605 | ||
![]() |
e548b72323 | ||
![]() |
ad859d4bef | ||
![]() |
483a47ed43 | ||
![]() |
9c026c1dd9 | ||
![]() |
6a0bcdaa82 | ||
![]() |
cc833c52b6 | ||
![]() |
a801672821 | ||
![]() |
20f3d001c4 | ||
![]() |
058677adec | ||
![]() |
95dd8d83dc | ||
![]() |
8ff590e43f | ||
![]() |
42eb72422d | ||
![]() |
ac5139b7c4 | ||
![]() |
3250347df1 | ||
![]() |
2d8d4659b3 | ||
![]() |
efbc6df199 | ||
![]() |
3ae47ba1e5 | ||
![]() |
a204e78e3a | ||
![]() |
0220e401cd | ||
![]() |
2ad0223e9a | ||
![]() |
04ba14fcd7 | ||
![]() |
e5d5850327 | ||
![]() |
c87a452471 | ||
![]() |
3cd5fa7f4a | ||
![]() |
ee3d32d60a | ||
![]() |
d80844c1ed | ||
![]() |
9af7e38219 | ||
![]() |
dc1f613bc2 | ||
![]() |
e0d1e39824 | ||
![]() |
bcb4bda7e6 | ||
![]() |
37a05155e5 | ||
![]() |
18b9f43eaa | ||
![]() |
20c31cbb07 | ||
![]() |
5b05f9426f | ||
![]() |
9dc9bd162f | ||
![]() |
c79b63e270 | ||
![]() |
2d699b3e43 | ||
![]() |
24cc4b4272 | ||
![]() |
746db72046 | ||
![]() |
77fa2a78d4 | ||
![]() |
af11511d24 | ||
![]() |
6709d97abc | ||
![]() |
4b4faae009 | ||
![]() |
f37a9963f6 | ||
![]() |
2d29245037 | ||
![]() |
d146514c39 | ||
![]() |
149ae4b71c | ||
![]() |
711ed947a3 | ||
![]() |
37a60592f6 | ||
![]() |
3ac8ca90ce | ||
![]() |
cc5d0ed3c6 | ||
![]() |
652e951f89 | ||
![]() |
dd2b634ed2 | ||
![]() |
32cfe58601 | ||
![]() |
e6da1152ca | ||
![]() |
3eb929aa13 | ||
![]() |
7df5838bc0 | ||
![]() |
f8d9b0803c | ||
![]() |
cf613ab34a | ||
![]() |
cebe2f8adc | ||
![]() |
bd19d7c231 | ||
![]() |
1283a794df | ||
![]() |
fdcf23f65f | ||
![]() |
24516b81cb | ||
![]() |
527bc04998 | ||
![]() |
72177e8ab5 | ||
![]() |
d2d632092b | ||
![]() |
7a0f975b31 | ||
![]() |
026dc6309c | ||
![]() |
5af26a57f6 | ||
![]() |
922cbe4451 | ||
![]() |
43472c7eb6 | ||
![]() |
1b7612ffb0 | ||
![]() |
7d8c57170f | ||
![]() |
32b98ae818 | ||
![]() |
7f8271e215 | ||
![]() |
58362ae858 | ||
![]() |
7a01cb8873 | ||
![]() |
374f20ff1a | ||
![]() |
9620fc5a83 | ||
![]() |
cfa9c95814 | ||
![]() |
96185d17bd | ||
![]() |
b5028ab2d0 | ||
![]() |
a038f2a98d | ||
![]() |
7924502b65 | ||
![]() |
aac0e7d35c | ||
![]() |
dca890f169 | ||
![]() |
d0e7f7dda2 | ||
![]() |
b4ea1489a7 | ||
![]() |
ca31af196f | ||
![]() |
163134326a | ||
![]() |
4371574403 | ||
![]() |
7d158e58b5 | ||
![]() |
808e737202 | ||
![]() |
c32f47ba95 | ||
![]() |
493785591c | ||
![]() |
9a2a6bbc9f | ||
![]() |
6bd049e0bb | ||
![]() |
8ea379bbff | ||
![]() |
dfeb14e7a8 | ||
![]() |
cf8072e402 | ||
![]() |
e0ce7e8505 | ||
![]() |
d196044d11 | ||
![]() |
0798102ba5 | ||
![]() |
4c3112b85b | ||
![]() |
925e5e0731 | ||
![]() |
3819dd9469 | ||
![]() |
0dfe52a42d | ||
![]() |
ca64d52021 | ||
![]() |
793d80f092 | ||
![]() |
4f2f192783 | ||
![]() |
6577b3752f | ||
![]() |
66bf11e893 | ||
![]() |
aac9bad7ec | ||
![]() |
bea671987c | ||
![]() |
e943a1cd44 | ||
![]() |
c1a2bb978c | ||
![]() |
c7c3dea6b2 | ||
![]() |
bb11263bad | ||
![]() |
e5f0831369 | ||
![]() |
6463df7224 | ||
![]() |
dc81e5b5c5 | ||
![]() |
31df41283c | ||
![]() |
abea50427e | ||
![]() |
a8a79ee326 | ||
![]() |
8683e2a4c2 | ||
![]() |
3bb0c8468b | ||
![]() |
b5f9c8e358 | ||
![]() |
2139fea3d0 | ||
![]() |
b13cae11fa | ||
![]() |
2ac2a98727 | ||
![]() |
5f2dd31485 | ||
![]() |
f0224144b7 | ||
![]() |
3a6ced388a | ||
![]() |
4c3b189108 | ||
![]() |
cc96d9877b | ||
![]() |
f2b5e2302a | ||
![]() |
d9f6a7201e | ||
![]() |
d2c4791611 | ||
![]() |
0fbc8c9247 | ||
![]() |
e9fc9ccbf7 | ||
![]() |
71a9010579 | ||
![]() |
0e7835e2d9 | ||
![]() |
069eac1cf6 | ||
![]() |
dc4531f545 | ||
![]() |
6a58f5f5d3 | ||
![]() |
12b567d3d2 | ||
![]() |
2532fcbea2 | ||
![]() |
0704717ec5 | ||
![]() |
242e14e8a9 | ||
![]() |
35bef2c3dd | ||
![]() |
65f41480eb | ||
![]() |
aaabde5c5a | ||
![]() |
89ffbd6efc | ||
![]() |
2a4832f9b9 | ||
![]() |
c14ecd2948 | ||
![]() |
febe651e31 | ||
![]() |
af07b433ad | ||
![]() |
13802c49a8 | ||
![]() |
eaeda6ca36 | ||
![]() |
af4be59fe0 | ||
![]() |
917d5ab3fa | ||
![]() |
cd019fb05b | ||
![]() |
e04e67774e | ||
![]() |
51e1a85f0b | ||
![]() |
297ca3fe11 | ||
![]() |
1570871884 | ||
![]() |
a721ec4a43 | ||
![]() |
ad9c193061 | ||
![]() |
3223a77cb1 | ||
![]() |
e57010cd3d | ||
![]() |
0ea4b98b1f | ||
![]() |
eb57ebe62b | ||
![]() |
72796d1e04 | ||
![]() |
970b5871e5 | ||
![]() |
8ac0bb2334 | ||
![]() |
ff3e83b1c5 | ||
![]() |
60157abd46 | ||
![]() |
907a356bea | ||
![]() |
7994c7d770 | ||
![]() |
4fe885995f | ||
![]() |
da4f2b2081 | ||
![]() |
9b00e829b8 | ||
![]() |
964671fcbf | ||
![]() |
edd48ef667 | ||
![]() |
413e9b0f1e | ||
![]() |
59cae7d207 | ||
![]() |
9a61f55f76 | ||
![]() |
136d181363 | ||
![]() |
d8b9ae9ff1 | ||
![]() |
d72f61a98d | ||
![]() |
12b0ac1037 | ||
![]() |
1db6d642e7 | ||
![]() |
cd0703ba12 | ||
![]() |
0f5999c8d8 | ||
![]() |
11cc9a752a | ||
![]() |
0483f47b26 | ||
![]() |
2605f5ab79 | ||
![]() |
2100f0461d | ||
![]() |
0e46b25f6e | ||
![]() |
c55830e533 | ||
![]() |
113c0af49d | ||
![]() |
df00dd600a | ||
![]() |
86617e410f | ||
![]() |
815cdbdd0a | ||
![]() |
a2277feb10 | ||
![]() |
6d929dd95a | ||
![]() |
7b43164831 | ||
![]() |
c145d077cd | ||
![]() |
a79bf3f055 | ||
![]() |
77eead761e | ||
![]() |
cd8d70de0e | ||
![]() |
5f8dc20312 | ||
![]() |
2b70ed1407 | ||
![]() |
fc830f60e8 | ||
![]() |
c3f4a3d9ea | ||
![]() |
6c5cc95e51 | ||
![]() |
877e6088e2 | ||
![]() |
c96ab426a4 | ||
![]() |
5e028ce547 | ||
![]() |
da16f25cf2 | ||
![]() |
c9cf59762a | ||
![]() |
c95008703c | ||
![]() |
a6f80e07e0 | ||
![]() |
5faced8d22 | ||
![]() |
4a35620820 | ||
![]() |
76839c48cf | ||
![]() |
6925c460c5 | ||
![]() |
6a8f64a9e8 | ||
![]() |
f1dc773bfd | ||
![]() |
d00449465f | ||
![]() |
77f26f01d4 | ||
![]() |
b633c91b66 | ||
![]() |
132b2b9ec7 | ||
![]() |
b875540397 | ||
![]() |
201f7cc21e | ||
![]() |
6e7ee99b47 | ||
![]() |
99f1e000bf | ||
![]() |
35875b7826 | ||
![]() |
e9533727db | ||
![]() |
842882e766 | ||
![]() |
09e18b064d | ||
![]() |
0e4b33be96 | ||
![]() |
91c1c1c5c8 | ||
![]() |
09a383f89c | ||
![]() |
133ca622a0 | ||
![]() |
0fbe3380cd | ||
![]() |
8f07f27a61 | ||
![]() |
bbd462c85a | ||
![]() |
234fd8b2e1 | ||
![]() |
02649709aa | ||
![]() |
910e82a795 | ||
![]() |
3fc8254219 | ||
![]() |
4c5b01f287 | ||
![]() |
03c8d3409a | ||
![]() |
7dce154cc3 | ||
![]() |
8947a4d14f | ||
![]() |
3895734c32 | ||
![]() |
a96c5712ab | ||
![]() |
7b5ac7eba4 | ||
![]() |
6c029382d9 | ||
![]() |
31ae68f96e | ||
![]() |
8cbabfbb95 | ||
![]() |
675660e130 | ||
![]() |
3e1409afc5 | ||
![]() |
c14cf3022c | ||
![]() |
b7c710cddd | ||
![]() |
bed9ad76f9 | ||
![]() |
d9fecd8eb5 | ||
![]() |
d256e2014a | ||
![]() |
0715bd6321 | ||
![]() |
337422a619 | ||
![]() |
6a98dcc169 | ||
![]() |
d42c2fabb9 | ||
![]() |
a096ce565e | ||
![]() |
a9b740dcaa | ||
![]() |
3514c4050e | ||
![]() |
afdd294c60 | ||
![]() |
bd09acd0fd | ||
![]() |
c520dc23ba | ||
![]() |
c70dedd94f | ||
![]() |
0877cfc3c9 | ||
![]() |
8dcec94aec | ||
![]() |
4afbb350ce | ||
![]() |
99f69c13d2 | ||
![]() |
93a44d83d2 | ||
![]() |
86695c9dc7 | ||
![]() |
e153e530a8 | ||
![]() |
e99f225def | ||
![]() |
7f94e3fc77 | ||
![]() |
8af3d53a3c | ||
![]() |
bcfb4f257d | ||
![]() |
a857d31776 | ||
![]() |
ebc22d845a | ||
![]() |
847136b69c | ||
![]() |
4a35c231f8 | ||
![]() |
8ff69e8eda | ||
![]() |
2e92f561d8 | ||
![]() |
0c96062618 | ||
![]() |
6536926f3c | ||
![]() |
39b1a78b89 | ||
![]() |
15f7018aab | ||
![]() |
9606b08c89 | ||
![]() |
b311c6be7d | ||
![]() |
65bcd8da2a | ||
![]() |
85e67a974a | ||
![]() |
4afe8e900e | ||
![]() |
c525b16581 | ||
![]() |
0de34bfec1 | ||
![]() |
9fe585bede | ||
![]() |
3b65b06a3d | ||
![]() |
fa52ff5545 | ||
![]() |
c0219938e3 | ||
![]() |
ec8ce36bd5 | ||
![]() |
6c228a59f2 | ||
![]() |
1e0f707a6d | ||
![]() |
acda689b15 | ||
![]() |
3dd70926b9 | ||
![]() |
adf377c41d | ||
![]() |
9a35a31261 | ||
![]() |
f0a3607fb7 | ||
![]() |
b451f4af55 | ||
![]() |
18c30fcb05 | ||
![]() |
b14a4987d2 | ||
![]() |
700813fa57 | ||
![]() |
c812519931 | ||
![]() |
fbeb48a021 | ||
![]() |
43f366d955 | ||
![]() |
ace18e86ff | ||
![]() |
08f86a3a7f | ||
![]() |
47669a23bc | ||
![]() |
4d1fa4f2d6 | ||
![]() |
c8e689712a | ||
![]() |
59d3a18b3f | ||
![]() |
3199b4ee6c | ||
![]() |
fdc687ed45 | ||
![]() |
ff9700e23a | ||
![]() |
f0a5265a65 | ||
![]() |
64f81e3396 | ||
![]() |
c16349d5c3 | ||
![]() |
fe413ba2f5 | ||
![]() |
0d2f6e060f | ||
![]() |
a972fb7359 | ||
![]() |
99cd9f9450 | ||
![]() |
1165fa8cdb | ||
![]() |
ab1ff48527 | ||
![]() |
1a6f9c2159 | ||
![]() |
4c42ccc7d7 | ||
![]() |
cb4e9e9eda | ||
![]() |
192c3c201d | ||
![]() |
2185182eee | ||
![]() |
79be69f8c1 | ||
![]() |
c874e879c1 | ||
![]() |
ed53bd487b | ||
![]() |
c41a7303df | ||
![]() |
c1c37aad85 | ||
![]() |
7aa5c8e724 | ||
![]() |
56974ce30f | ||
![]() |
de46dfc4a2 | ||
![]() |
47efc88228 | ||
![]() |
19c734683b | ||
![]() |
d97f95fb92 | ||
![]() |
14778757d9 | ||
![]() |
300efe4877 | ||
![]() |
3d3ace1c2a | ||
![]() |
7c0d9c4f93 | ||
![]() |
9081985b08 | ||
![]() |
5cfe69d24b | ||
![]() |
937c2920ac | ||
![]() |
fd700e06f4 | ||
![]() |
e6dff16550 | ||
![]() |
f2c06042cd | ||
![]() |
a49d107a82 | ||
![]() |
b0af78f3b2 | ||
![]() |
dade379dcf | ||
![]() |
f3ac3ca25e | ||
![]() |
243c69b231 | ||
![]() |
fda7230bce | ||
![]() |
287464362e | ||
![]() |
222e909686 | ||
![]() |
1b1d37b9df | ||
![]() |
5a25ffe6e4 | ||
![]() |
6d846ab0db | ||
![]() |
47c2742878 | ||
![]() |
69eb54abf6 | ||
![]() |
1bb0330ab5 | ||
![]() |
c64fca852c | ||
![]() |
01fcd3175f | ||
![]() |
c04c0e29bb | ||
![]() |
9daf1dea31 | ||
![]() |
4a175c76f9 | ||
![]() |
4d3ff6ed20 | ||
![]() |
6e1f925944 | ||
![]() |
e756ae3c8f | ||
![]() |
b07365b487 | ||
![]() |
f1b6f8a3e4 | ||
![]() |
ad3b660bc0 | ||
![]() |
dd773d4e5e | ||
![]() |
61df7745c6 | ||
![]() |
aeaef04fac | ||
![]() |
8c2287a1e8 | ||
![]() |
fa825da404 | ||
![]() |
839f6affe2 | ||
![]() |
0d7492f6be | ||
![]() |
c09437880f | ||
![]() |
069ccab0ae | ||
![]() |
b8274d92db | ||
![]() |
4499a872d8 | ||
![]() |
94d7e01bd5 | ||
![]() |
993ce9289d | ||
![]() |
c8d6361c36 | ||
![]() |
8c610e2142 | ||
![]() |
bb0e2fb9e9 | ||
![]() |
e9e4d65c78 | ||
![]() |
ddc8bd2028 | ||
![]() |
bf9fff6065 | ||
![]() |
744347c269 | ||
![]() |
d087071fc9 | ||
![]() |
a4d6c6694a | ||
![]() |
ff3ee351d1 | ||
![]() |
b14e8daa1a | ||
![]() |
3a53ffcc23 | ||
![]() |
2abe589ef6 | ||
![]() |
f81e4fac79 | ||
![]() |
a4b27115ac | ||
![]() |
43a210cac4 | ||
![]() |
355a49e463 | ||
![]() |
975aa0a3cc | ||
![]() |
a99b8c6aaf | ||
![]() |
8a968a1f89 | ||
![]() |
c7eeaffec9 | ||
![]() |
cc79fe76fd | ||
![]() |
4cadeb8e5d | ||
![]() |
76a19ebe5b | ||
![]() |
b5613ec6dc | ||
![]() |
26137ec81e | ||
![]() |
0e67c62c86 | ||
![]() |
90bf4edf0d | ||
![]() |
d51fe8483a | ||
![]() |
3ddde1a1ca | ||
![]() |
48e28a1ba4 | ||
![]() |
558e127caa | ||
![]() |
63807e71fd | ||
![]() |
f684c38958 | ||
![]() |
4b2abf791c | ||
![]() |
a8b83d9fe1 | ||
![]() |
4ce695d933 | ||
![]() |
44aa54f247 | ||
![]() |
25e5739b34 | ||
![]() |
33e1bd567d | ||
![]() |
f727c87b56 | ||
![]() |
3775c53df3 | ||
![]() |
e715794f04 | ||
![]() |
796170100f | ||
![]() |
f25e4fab28 | ||
![]() |
c44c6c79f9 | ||
![]() |
3f6d5daa1e | ||
![]() |
7224be9de2 | ||
![]() |
2b6d88105c | ||
![]() |
d7e19865de | ||
![]() |
643d29ba57 | ||
![]() |
4b6038c50c | ||
![]() |
f10a80333b | ||
![]() |
0a80e01d0b | ||
![]() |
93a3da2335 | ||
![]() |
96c5bd0b69 | ||
![]() |
1ee76878d9 | ||
![]() |
6749604210 | ||
![]() |
e097f526bb | ||
![]() |
ea0aff1a3e | ||
![]() |
40ab3cda9c | ||
![]() |
c24098a117 | ||
![]() |
5c28f10921 | ||
![]() |
1c07508f39 | ||
![]() |
e5472a6fae | ||
![]() |
e06f8c16df | ||
![]() |
a1d7059c0b | ||
![]() |
bbe2efa4b3 | ||
![]() |
1fb121fb6d | ||
![]() |
6be4964221 | ||
![]() |
5907973d42 | ||
![]() |
a37b0229a0 | ||
![]() |
d7c8b80da5 | ||
![]() |
5998941741 | ||
![]() |
5bd4f84389 | ||
![]() |
8a47ab2dde | ||
![]() |
dda790b5c4 | ||
![]() |
b829cd260c | ||
![]() |
7b1947914e | ||
![]() |
6c3722737d | ||
![]() |
eea3f671af | ||
![]() |
f4f435c682 | ||
![]() |
b16a81cf6e | ||
![]() |
be6a1d916f | ||
![]() |
ef7b2ddbdd | ||
![]() |
d3471c945b | ||
![]() |
47b2c603ef | ||
![]() |
9a48a60d28 | ||
![]() |
678c966113 | ||
![]() |
d5d04b7dac | ||
![]() |
0f0b32d797 | ||
![]() |
fbf3ee5cd1 | ||
![]() |
40e957fff2 | ||
![]() |
3c8d16a368 | ||
![]() |
dfe0f49655 | ||
![]() |
a125e381a9 | ||
![]() |
93d0cfcfeb | ||
![]() |
6ea4d9c413 | ||
![]() |
e6301f0d06 | ||
![]() |
f61cf318ae | ||
![]() |
13db4861e1 | ||
![]() |
684363bcde | ||
![]() |
a8db5db308 | ||
![]() |
4a198ce473 | ||
![]() |
e9976635ba | ||
![]() |
079680d72e | ||
![]() |
4c3dc6362c | ||
![]() |
98428bf8c2 | ||
![]() |
73eec8f112 | ||
![]() |
789512de55 | ||
![]() |
070d4fc43e | ||
![]() |
fadf540422 | ||
![]() |
3cb803ffe3 | ||
![]() |
6ef217c546 | ||
![]() |
118f22c164 | ||
![]() |
b2b4e1bfbc | ||
![]() |
9d6cc86e60 | ||
![]() |
a3ca6abb7a | ||
![]() |
35158204c5 | ||
![]() |
4c4cefde6d | ||
![]() |
ff9554adc1 | ||
![]() |
303c741a10 | ||
![]() |
6b2ba3a285 | ||
![]() |
06bedf6cb4 | ||
![]() |
a5f04b6c7f | ||
![]() |
364257fe05 | ||
![]() |
0d00bd746e | ||
![]() |
5c86ab38a4 | ||
![]() |
cb67a23d0a | ||
![]() |
25c8edd81c | ||
![]() |
798a9893e9 | ||
![]() |
f8d26b4f8f | ||
![]() |
2c1985bef3 | ||
![]() |
4a5f1ce19a | ||
![]() |
efb1a73e88 | ||
![]() |
ea54ca6c11 | ||
![]() |
1016b46243 | ||
![]() |
a66ea53743 | ||
![]() |
95fb78f645 | ||
![]() |
6d68b56c56 | ||
![]() |
fcfc8b56bb | ||
![]() |
e1ff4578e9 | ||
![]() |
e45dfd7351 | ||
![]() |
4a92b05b57 | ||
![]() |
a0cd1f4cd0 | ||
![]() |
23c38e33d4 | ||
![]() |
80158ffa95 | ||
![]() |
97345c9710 | ||
![]() |
fdb76fc56c | ||
![]() |
df43abf9d3 | ||
![]() |
6ae703dfd9 | ||
![]() |
ec70d85638 | ||
![]() |
2bdcc4fe47 | ||
![]() |
c26af4758b | ||
![]() |
511ba61b1c | ||
![]() |
bf189bb704 | ||
![]() |
49017fda39 | ||
![]() |
53917e9bf5 | ||
![]() |
503d508a86 | ||
![]() |
8ee20e52f8 | ||
![]() |
05b8ed7153 | ||
![]() |
24547b4fc5 | ||
![]() |
18ad664acb | ||
![]() |
d60679adfd | ||
![]() |
9ace36c459 | ||
![]() |
e9c9772c58 | ||
![]() |
d20d22ffb6 | ||
![]() |
a139d9c844 | ||
![]() |
13bba63382 | ||
![]() |
8d6ecc3ec7 | ||
![]() |
3cef591719 | ||
![]() |
34a3aa0e3d | ||
![]() |
1938884656 | ||
![]() |
30ed84fd1d | ||
![]() |
b67414bb84 | ||
![]() |
5b9e97b4eb | ||
![]() |
c869516449 | ||
![]() |
7fab472fc4 | ||
![]() |
f755aefbfa | ||
![]() |
43122381f5 | ||
![]() |
d0b1cb527e | ||
![]() |
c78d6f2104 | ||
![]() |
eac2c2ddb2 | ||
![]() |
9b1efc3e45 | ||
![]() |
512084194d | ||
![]() |
8bb09f5739 | ||
![]() |
0a68ff6dd0 | ||
![]() |
e18e2492af | ||
![]() |
5d04de936b | ||
![]() |
9a85bd0edb | ||
![]() |
20b97a88c0 | ||
![]() |
eafe3737dc | ||
![]() |
9f743daf07 | ||
![]() |
291ec3aa04 | ||
![]() |
84f25ae91e | ||
![]() |
5516a11012 | ||
![]() |
316ed83047 | ||
![]() |
75bddc8777 | ||
![]() |
d096909a95 | ||
![]() |
15c47fb593 | ||
![]() |
d337defb09 | ||
![]() |
3760c3239f | ||
![]() |
4a9b528c47 | ||
![]() |
60334229d5 | ||
![]() |
40c7e34014 | ||
![]() |
75bea75dce | ||
![]() |
d5efc51d61 | ||
![]() |
3789e4b3bd | ||
![]() |
ef7466e0d5 | ||
![]() |
006a7096ed | ||
![]() |
b7a026a7e8 | ||
![]() |
a2965d83af | ||
![]() |
05481f7828 | ||
![]() |
b1c77afc81 | ||
![]() |
a5df9a2b3d | ||
![]() |
0f5d668f86 | ||
![]() |
4b3e1c7b1b | ||
![]() |
4b97b403d3 | ||
![]() |
145e7f5529 | ||
![]() |
19080924d5 | ||
![]() |
6a57e51f6b | ||
![]() |
e916d4f71f | ||
![]() |
b0b551af82 | ||
![]() |
37a8bfd6f5 | ||
![]() |
489619c337 | ||
![]() |
bd43884a1d | ||
![]() |
d693c00c47 | ||
![]() |
caec354092 | ||
![]() |
d2629e8925 | ||
![]() |
a45ce2ced2 | ||
![]() |
4af971b83c | ||
![]() |
6cfc72c875 | ||
![]() |
10c30cd21a | ||
![]() |
13ec46b145 | ||
![]() |
05bb8a2df0 | ||
![]() |
2f048b45c9 | ||
![]() |
38d0ef8542 | ||
![]() |
d67a2e60fe | ||
![]() |
22c71d832e | ||
![]() |
84fc3e7d50 | ||
![]() |
5ec11cf5b8 | ||
![]() |
933ee88172 | ||
![]() |
d18302314d | ||
![]() |
eb78d79bb3 | ||
![]() |
90e1baef50 | ||
![]() |
d028679077 | ||
![]() |
0c57183a2d | ||
![]() |
4b9394fe6b | ||
![]() |
595f857aa1 | ||
![]() |
ede9bfb17a | ||
![]() |
91bb71dc29 | ||
![]() |
318e8645b2 | ||
![]() |
5a96672d79 | ||
![]() |
1d744d4c26 | ||
![]() |
8945dd75aa | ||
![]() |
4413a61513 | ||
![]() |
1dd42e1ab2 | ||
![]() |
1dc21d846e | ||
![]() |
0e0b125d99 | ||
![]() |
051cb71956 | ||
![]() |
98fc4608da | ||
![]() |
5bdacc70e5 | ||
![]() |
d659e62fda | ||
![]() |
7bf509097f | ||
![]() |
b333b3f083 | ||
![]() |
6277e0e372 | ||
![]() |
10f594c774 | ||
![]() |
c53170fe84 | ||
![]() |
1e42fe9de5 | ||
![]() |
c064bb275f | ||
![]() |
1fb9ef4d58 | ||
![]() |
0ea0ca240a | ||
![]() |
512e74c493 | ||
![]() |
c9d40abe96 | ||
![]() |
2ed34dda15 | ||
![]() |
5151e2dd96 | ||
![]() |
a637ba1e6b | ||
![]() |
3e9fdbacad | ||
![]() |
8d534691ac | ||
![]() |
c7496d7018 | ||
![]() |
d61d9cc574 | ||
![]() |
6a643411a4 | ||
![]() |
d369693f9f | ||
![]() |
b4d1666bdf | ||
![]() |
2f2b36c91d | ||
![]() |
10a8babed7 | ||
![]() |
86117944e4 | ||
![]() |
841dda903f | ||
![]() |
6907fbe844 | ||
![]() |
bef7a2af36 | ||
![]() |
8192b19858 | ||
![]() |
fe35986432 | ||
![]() |
358ac1592b | ||
![]() |
e100e0ea72 | ||
![]() |
61fa77b752 | ||
![]() |
3bc0ba73ee | ||
![]() |
6022ef9be3 | ||
![]() |
c1eaf28812 | ||
![]() |
0eb394fb86 | ||
![]() |
291128b96f | ||
![]() |
c88e1cca68 | ||
![]() |
b5083d32db | ||
![]() |
a76a7dd54c | ||
![]() |
0ba1d65b11 | ||
![]() |
1fa56aa683 | ||
![]() |
103f006cc0 | ||
![]() |
9913586155 | ||
![]() |
3a982f6e38 | ||
![]() |
23ce2fb33c | ||
![]() |
36f786f0eb | ||
![]() |
c56eadc49b | ||
![]() |
508359a939 | ||
![]() |
acaa83c31a | ||
![]() |
ea0dc1ea19 | ||
![]() |
f05d50bce3 | ||
![]() |
b7319fd152 | ||
![]() |
93aa96a339 | ||
![]() |
875f520710 | ||
![]() |
02528aecc7 | ||
![]() |
993d8c3b4e | ||
![]() |
25e61cc8d5 | ||
![]() |
d773043429 | ||
![]() |
3b54ab3e0b | ||
![]() |
d9e5eff23d | ||
![]() |
4fa9ab3c6e | ||
![]() |
0375d66b91 | ||
![]() |
4ad958b9d2 | ||
![]() |
de788423e1 | ||
![]() |
6b7631013d | ||
![]() |
5c66eb5f4f | ||
![]() |
81db564e34 | ||
![]() |
46501b7caa | ||
![]() |
3e8d6a27f1 | ||
![]() |
4806d7e5fe | ||
![]() |
342c7c3854 | ||
![]() |
4a36ab827c | ||
![]() |
fded97d586 | ||
![]() |
de6275003e | ||
![]() |
f7e549b5fd | ||
![]() |
e27debd452 | ||
![]() |
e3afb2c52a | ||
![]() |
fed42d4898 | ||
![]() |
b33c2fd0d0 |
498 changed files with 76239 additions and 21493 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,5 +1,8 @@
|
|||
*~
|
||||
*.pyc
|
||||
.coverage
|
||||
.tox/
|
||||
dist/
|
||||
docs/_build/
|
||||
htmlcov/
|
||||
Tailbone.egg-info/
|
||||
|
|
683
CHANGELOG.md
Normal file
683
CHANGELOG.md
Normal file
|
@ -0,0 +1,683 @@
|
|||
|
||||
# Changelog
|
||||
All notable changes to Tailbone will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## v0.22.7 (2025-02-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- stop using old config for logo image url on login page
|
||||
- fix warning msg for deprecated Grid param
|
||||
|
||||
## v0.22.6 (2025-02-01)
|
||||
|
||||
### Fix
|
||||
|
||||
- register vue3 form component for products -> make batch
|
||||
|
||||
## v0.22.5 (2024-12-16)
|
||||
|
||||
### Fix
|
||||
|
||||
- whoops this is latest rattail
|
||||
- require newer rattail lib
|
||||
- require newer wuttaweb
|
||||
- let caller request safe HTML literal for rendered grid table
|
||||
|
||||
## v0.22.4 (2024-11-22)
|
||||
|
||||
### Fix
|
||||
|
||||
- avoid error in product search for duplicated key
|
||||
- use vmodel for confirm password widget input
|
||||
|
||||
## v0.22.3 (2024-11-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- avoid error for trainwreck query when not a customer
|
||||
|
||||
## v0.22.2 (2024-11-18)
|
||||
|
||||
### Fix
|
||||
|
||||
- use local/custom enum for continuum operations
|
||||
- add basic master view for Product Costs
|
||||
- show continuum operation type when viewing version history
|
||||
- always define `app` attr for ViewSupplement
|
||||
- avoid deprecated import
|
||||
|
||||
## v0.22.1 (2024-11-02)
|
||||
|
||||
### Fix
|
||||
|
||||
- fix submit button for running problem report
|
||||
- avoid deprecated grid method
|
||||
|
||||
## v0.22.0 (2024-10-22)
|
||||
|
||||
### Feat
|
||||
|
||||
- add support for new ordering batch from parsed file
|
||||
|
||||
### Fix
|
||||
|
||||
- avoid deprecated method to suggest username
|
||||
|
||||
## v0.21.11 (2024-10-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- custom method for adding grid action
|
||||
- become/stop root should redirect to previous url
|
||||
|
||||
## v0.21.10 (2024-09-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- update project repo links, kallithea -> forgejo
|
||||
- use better icon for submit button on login page
|
||||
- wrap notes text for batch view
|
||||
- expose datasync consumer batch size via configure page
|
||||
|
||||
## v0.21.9 (2024-08-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- render custom attrs in form component tag
|
||||
|
||||
## v0.21.8 (2024-08-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- ignore session kwarg for `MasterView.make_row_grid()`
|
||||
|
||||
## v0.21.7 (2024-08-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- avoid error when form value cannot be obtained
|
||||
|
||||
## v0.21.6 (2024-08-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- avoid error when grid value cannot be obtained
|
||||
|
||||
## v0.21.5 (2024-08-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- set empty string for "-new-" file configure option
|
||||
|
||||
## v0.21.4 (2024-08-26)
|
||||
|
||||
### Fix
|
||||
|
||||
- handle differing email profile keys for appinfo/configure
|
||||
|
||||
## v0.21.3 (2024-08-26)
|
||||
|
||||
### Fix
|
||||
|
||||
- show non-standard config values for app info configure email
|
||||
|
||||
## v0.21.2 (2024-08-26)
|
||||
|
||||
### Fix
|
||||
|
||||
- refactor waterpark base template to use wutta feedback component
|
||||
- fix input/output file upload feature for configure pages, per oruga
|
||||
- tweak how grid data translates to Vue template context
|
||||
- merge filters into main grid template
|
||||
- add basic wutta view for users
|
||||
- some fixes for wutta people view
|
||||
- various fixes for waterpark theme
|
||||
- avoid deprecated `component` form kwarg
|
||||
|
||||
## v0.21.1 (2024-08-22)
|
||||
|
||||
### Fix
|
||||
|
||||
- misc. bugfixes per recent changes
|
||||
|
||||
## v0.21.0 (2024-08-22)
|
||||
|
||||
### Feat
|
||||
|
||||
- move "most" filtering logic for grid class to wuttaweb
|
||||
- inherit from wuttaweb templates for home, login pages
|
||||
- inherit from wuttaweb for AppInfoView, appinfo/configure template
|
||||
- add "has output file templates" config option for master view
|
||||
|
||||
### Fix
|
||||
|
||||
- change grid reset-view param name to match wuttaweb
|
||||
- move "searchable columns" grid feature to wuttaweb
|
||||
- use wuttaweb to get/render csrf token
|
||||
- inherit from wuttaweb for appinfo/index template
|
||||
- prefer wuttaweb config for "home redirect to login" feature
|
||||
- fix master/index template rendering for waterpark theme
|
||||
- fix spacing for navbar logo/title in waterpark theme
|
||||
|
||||
## v0.20.1 (2024-08-20)
|
||||
|
||||
### Fix
|
||||
|
||||
- fix default filter verbs logic for workorder status
|
||||
|
||||
## v0.20.0 (2024-08-20)
|
||||
|
||||
### Feat
|
||||
|
||||
- add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy
|
||||
- refactor templates to simplify base/page/form structure
|
||||
|
||||
### Fix
|
||||
|
||||
- avoid deprecated reference to app db engine
|
||||
|
||||
## v0.19.3 (2024-08-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- add pager stats to all grid vue data (fixes view history)
|
||||
|
||||
## v0.19.2 (2024-08-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- sort on frontend for appinfo package listing grid
|
||||
- prefer attr over key lookup when getting model values
|
||||
- replace all occurrences of `component_studly` => `vue_component`
|
||||
|
||||
## v0.19.1 (2024-08-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- fix broken user auth for web API app
|
||||
|
||||
## v0.19.0 (2024-08-18)
|
||||
|
||||
### Feat
|
||||
|
||||
- move multi-column grid sorting logic to wuttaweb
|
||||
- move single-column grid sorting logic to wuttaweb
|
||||
|
||||
### Fix
|
||||
|
||||
- fix misc. errors in grid template per wuttaweb
|
||||
- fix broken permission directives in web api startup
|
||||
|
||||
## v0.18.0 (2024-08-16)
|
||||
|
||||
### Feat
|
||||
|
||||
- move "basic" grid pagination logic to wuttaweb
|
||||
- inherit from wutta base class for Grid
|
||||
- inherit most logic from wuttaweb, for GridAction
|
||||
|
||||
### Fix
|
||||
|
||||
- avoid route error in user view, when using wutta people view
|
||||
- fix some more wutta compat for base template
|
||||
|
||||
## v0.17.0 (2024-08-15)
|
||||
|
||||
### Feat
|
||||
|
||||
- use wuttaweb for `get_liburl()` logic
|
||||
|
||||
## v0.16.1 (2024-08-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- improve wutta People view a bit
|
||||
- update references to `get_class_hierarchy()`
|
||||
- tweak template for `people/view_profile` per wutta compat
|
||||
|
||||
## v0.16.0 (2024-08-15)
|
||||
|
||||
### Feat
|
||||
|
||||
- add first wutta-based master, for PersonView
|
||||
- refactor forms/grids/views/templates per wuttaweb compat
|
||||
|
||||
## v0.15.6 (2024-08-13)
|
||||
|
||||
### Fix
|
||||
|
||||
- avoid `before_render` subscriber hook for web API
|
||||
- simplify verbiage for batch execution panel
|
||||
|
||||
## v0.15.5 (2024-08-09)
|
||||
|
||||
### Fix
|
||||
|
||||
- assign convenience attrs for all views (config, app, enum, model)
|
||||
|
||||
## v0.15.4 (2024-08-09)
|
||||
|
||||
### Fix
|
||||
|
||||
- avoid bug when checking current theme
|
||||
|
||||
## v0.15.3 (2024-08-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- fix timepicker `parseTime()` when value is null
|
||||
|
||||
## v0.15.2 (2024-08-06)
|
||||
|
||||
### Fix
|
||||
|
||||
- use auth handler, avoid legacy calls for role/perm checks
|
||||
|
||||
## v0.15.1 (2024-08-05)
|
||||
|
||||
### Fix
|
||||
|
||||
- move magic `b` template context var to wuttaweb
|
||||
|
||||
## v0.15.0 (2024-08-05)
|
||||
|
||||
### Feat
|
||||
|
||||
- move more subscriber logic to wuttaweb
|
||||
|
||||
### Fix
|
||||
|
||||
- use wuttaweb logic for `util.get_form_data()`
|
||||
|
||||
## v0.14.5 (2024-08-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- use auth handler instead of deprecated auth functions
|
||||
- avoid duplicate `partial` param when grid reloads data
|
||||
|
||||
## v0.14.4 (2024-07-18)
|
||||
|
||||
### Fix
|
||||
|
||||
- fix more settings persistence bug(s) for datasync/configure
|
||||
- fix modals for luigi tasks page, per oruga
|
||||
|
||||
## v0.14.3 (2024-07-17)
|
||||
|
||||
### Fix
|
||||
|
||||
- fix auto-collapse title for viewing trainwreck txn
|
||||
- allow auto-collapse of header when viewing trainwreck txn
|
||||
|
||||
## v0.14.2 (2024-07-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- add null menu handler, for use with API apps
|
||||
|
||||
## v0.14.1 (2024-07-14)
|
||||
|
||||
### Fix
|
||||
|
||||
- update usage of auth handler, per rattail changes
|
||||
- fix model reference in menu handler
|
||||
- fix bug when making "integration" menus
|
||||
|
||||
## v0.14.0 (2024-07-14)
|
||||
|
||||
### Feat
|
||||
|
||||
- move core menu logic to wuttaweb
|
||||
|
||||
## v0.13.2 (2024-07-13)
|
||||
|
||||
### Fix
|
||||
|
||||
- fix logic bug for datasync/config settings save
|
||||
|
||||
## v0.13.1 (2024-07-13)
|
||||
|
||||
### Fix
|
||||
|
||||
- fix settings persistence bug(s) for datasync/configure page
|
||||
|
||||
## v0.13.0 (2024-07-12)
|
||||
|
||||
### Feat
|
||||
|
||||
- begin integrating WuttaWeb as upstream dependency
|
||||
|
||||
### Fix
|
||||
|
||||
- cast enum as list to satisfy deform widget
|
||||
|
||||
## v0.12.1 (2024-07-11)
|
||||
|
||||
### Fix
|
||||
|
||||
- refactor `config.get_model()` => `app.model`
|
||||
|
||||
## v0.12.0 (2024-07-09)
|
||||
|
||||
### Feat
|
||||
|
||||
- drop python 3.6 support, use pyproject.toml (again)
|
||||
|
||||
## v0.11.10 (2024-07-05)
|
||||
|
||||
### Fix
|
||||
|
||||
- make the Members tab optional, for profile view
|
||||
|
||||
## v0.11.9 (2024-07-05)
|
||||
|
||||
### Fix
|
||||
|
||||
- do not show flash message when changing app theme
|
||||
|
||||
- improve collapse panels for butterball theme
|
||||
|
||||
- expand input for butterball theme
|
||||
|
||||
- add xref button to customer profile, for trainwreck txn view
|
||||
|
||||
- add optional Transactions tab for profile view
|
||||
|
||||
## v0.11.8 (2024-07-04)
|
||||
|
||||
### Fix
|
||||
|
||||
- fix grid action icons for datasync/configure, per oruga
|
||||
|
||||
- allow view supplements to add extra links for profile employee tab
|
||||
|
||||
- leverage import handler method to determine command/subcommand
|
||||
|
||||
- add tool to make user account from profile view
|
||||
|
||||
## v0.11.7 (2024-07-04)
|
||||
|
||||
### Fix
|
||||
|
||||
- add stacklevel to deprecation warnings
|
||||
|
||||
- require zope.sqlalchemy >= 1.5
|
||||
|
||||
- include edit profile email/phone dialogs only if user has perms
|
||||
|
||||
- allow view supplements to add to profile member context
|
||||
|
||||
- cast enum as list to satisfy deform widget
|
||||
|
||||
- expand POD image URL setting input
|
||||
|
||||
## v0.11.6 (2024-07-01)
|
||||
|
||||
### Fix
|
||||
|
||||
- set explicit referrer when changing dbkey
|
||||
|
||||
- remove references, dependency for `six` package
|
||||
|
||||
## v0.11.5 (2024-06-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- allow comma in numeric filter input
|
||||
|
||||
- add custom url prefix if needed, for fanstatic
|
||||
|
||||
- use vue 3.4.31 and oruga 0.8.12 by default
|
||||
|
||||
## v0.11.4 (2024-06-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- start/stop being root should submit POST instead of GET
|
||||
|
||||
- require vendor when making new ordering batch via api
|
||||
|
||||
- don't escape each address for email attempts grid
|
||||
|
||||
## v0.11.3 (2024-06-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- add link to "resolved by" user for pending products
|
||||
|
||||
- handle error when merging 2 records fails
|
||||
|
||||
## v0.11.2 (2024-06-18)
|
||||
|
||||
### Fix
|
||||
|
||||
- hide certain custorder settings if not applicable
|
||||
|
||||
- use different logic for buefy/oruga for product lookup keydown
|
||||
|
||||
- product records should be touchable
|
||||
|
||||
- show flash error message if resolve pending product fails
|
||||
|
||||
## v0.11.1 (2024-06-14)
|
||||
|
||||
### Fix
|
||||
|
||||
- revert back to setup.py + setup.cfg
|
||||
|
||||
## v0.11.0 (2024-06-10)
|
||||
|
||||
### Feat
|
||||
|
||||
- switch from setup.cfg to pyproject.toml + hatchling
|
||||
|
||||
## v0.10.16 (2024-06-10)
|
||||
|
||||
### Feat
|
||||
|
||||
- standardize how app, package versions are determined
|
||||
|
||||
### Fix
|
||||
|
||||
- avoid deprecated config methods for app/node title
|
||||
|
||||
## v0.10.15 (2024-06-07)
|
||||
|
||||
### Fix
|
||||
|
||||
- do *not* Use `pkg_resources` to determine package versions
|
||||
|
||||
## v0.10.14 (2024-06-06)
|
||||
|
||||
### Fix
|
||||
|
||||
- use `pkg_resources` to determine package versions
|
||||
|
||||
## v0.10.13 (2024-06-06)
|
||||
|
||||
### Feat
|
||||
|
||||
- remove old/unused scaffold for use with `pcreate`
|
||||
|
||||
- add 'fanstatic' support for sake of libcache assets
|
||||
|
||||
## v0.10.12 (2024-06-04)
|
||||
|
||||
### Feat
|
||||
|
||||
- require pyramid 2.x; remove 1.x-style auth policies
|
||||
|
||||
- remove version cap for deform
|
||||
|
||||
- set explicit referrer when changing app theme
|
||||
|
||||
- add `<b-tooltip>` component shim
|
||||
|
||||
- include extra styles from `base_meta` template for butterball
|
||||
|
||||
- include butterball theme by default for new apps
|
||||
|
||||
### Fix
|
||||
|
||||
- fix product lookup component, per butterball
|
||||
|
||||
## v0.10.11 (2024-06-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- fix vue3 refresh bugs for various views
|
||||
|
||||
- fix grid bug for tempmon appliance view, per oruga
|
||||
|
||||
- fix ordering worksheet generator, per butterball
|
||||
|
||||
- fix inventory worksheet generator, per butterball
|
||||
|
||||
## v0.10.10 (2024-06-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- more butterball fixes for "view profile" template
|
||||
|
||||
### Fix
|
||||
|
||||
- fix focus for `<b-select>` shim component
|
||||
|
||||
## v0.10.9 (2024-06-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- let master view control context menu items for page
|
||||
|
||||
- fix the "new custorder" page for butterball
|
||||
|
||||
### Fix
|
||||
|
||||
- fix panel style for PO vs. Invoice breakdown in receiving batch
|
||||
|
||||
## v0.10.8 (2024-06-02)
|
||||
|
||||
### Feat
|
||||
|
||||
- add styling for checked grid rows, per oruga/butterball
|
||||
|
||||
- fix product view template for oruga/butterball
|
||||
|
||||
- allow per-user custom styles for butterball
|
||||
|
||||
- use oruga 0.8.9 by default
|
||||
|
||||
## v0.10.7 (2024-06-01)
|
||||
|
||||
### Feat
|
||||
|
||||
- add setting to allow decimal quantities for receiving
|
||||
|
||||
- log error if registry has no rattail config
|
||||
|
||||
- add column filters for import/export main grid
|
||||
|
||||
- escape all unsafe html for grid data
|
||||
|
||||
- add speedbumps for delete, set preferred email/phone in profile view
|
||||
|
||||
- fix file upload widget for oruga
|
||||
|
||||
### Fix
|
||||
|
||||
- fix overflow when instance header title is too long (butterball)
|
||||
|
||||
## v0.10.6 (2024-05-29)
|
||||
|
||||
### Feat
|
||||
|
||||
- add way to flag organic products within lookup dialog
|
||||
|
||||
- expose db picker for butterball theme
|
||||
|
||||
- expose quickie lookup for butterball theme
|
||||
|
||||
- fix basic problems with people profile view, per butterball
|
||||
|
||||
## v0.10.5 (2024-05-29)
|
||||
|
||||
### Feat
|
||||
|
||||
- add `<tailbone-timepicker>` component for oruga
|
||||
|
||||
## v0.10.4 (2024-05-12)
|
||||
|
||||
### Fix
|
||||
|
||||
- fix styles for grid actions, per butterball
|
||||
|
||||
## v0.10.3 (2024-05-10)
|
||||
|
||||
### Fix
|
||||
|
||||
- fix bug with grid date filters
|
||||
|
||||
## v0.10.2 (2024-05-08)
|
||||
|
||||
### Feat
|
||||
|
||||
- remove version restriction for pyramid_beaker dependency
|
||||
|
||||
- rename some attrs etc. for buefy components used with oruga
|
||||
|
||||
- fix "tools" helper for receiving batch view, per oruga
|
||||
|
||||
- more data type fixes for ``<tailbone-datepicker>``
|
||||
|
||||
- fix "view receiving row" page, per oruga
|
||||
|
||||
- tweak styles for grid action links, per butterball
|
||||
|
||||
### Fix
|
||||
|
||||
- fix employees grid when viewing department (per oruga)
|
||||
|
||||
- fix login "enter" key behavior, per oruga
|
||||
|
||||
- fix button text for autocomplete
|
||||
|
||||
## v0.10.1 (2024-04-28)
|
||||
|
||||
### Feat
|
||||
|
||||
- sort list of available themes
|
||||
|
||||
- update various icon names for oruga compatibility
|
||||
|
||||
- show "View This" button when cloning a record
|
||||
|
||||
- stop including 'falafel' as available theme
|
||||
|
||||
### Fix
|
||||
|
||||
- fix vertical alignment in main menu bar, for butterball
|
||||
|
||||
- fix upgrade execution logic/UI per oruga
|
||||
|
||||
## v0.10.0 (2024-04-28)
|
||||
|
||||
This version bump is to reflect adding support for Vue 3 + Oruga via
|
||||
the 'butterball' theme. There is likely more work to be done for that
|
||||
yet, but it mostly works at this point.
|
||||
|
||||
### Feat
|
||||
|
||||
- misc. template and view logic tweaks (applicable to all themes) for
|
||||
better patterns, consistency etc.
|
||||
|
||||
- add initial support for Vue 3 + Oruga, via "butterball" theme
|
||||
|
||||
|
||||
## Older Releases
|
||||
|
||||
Please see `docs/OLDCHANGES.rst` for older release notes.
|
3444
CHANGES.rst
3444
CHANGES.rst
File diff suppressed because it is too large
Load diff
|
@ -3,6 +3,7 @@ include *.txt
|
|||
include *.rst
|
||||
include *.py
|
||||
|
||||
include tailbone/static/robots.txt
|
||||
recursive-include tailbone/static *.js
|
||||
recursive-include tailbone/static *.css
|
||||
recursive-include tailbone/static *.png
|
||||
|
@ -10,6 +11,8 @@ recursive-include tailbone/static *.jpg
|
|||
recursive-include tailbone/static *.gif
|
||||
recursive-include tailbone/static *.ico
|
||||
|
||||
recursive-include tailbone/static/files *
|
||||
|
||||
recursive-include tailbone/templates *.mako
|
||||
recursive-include tailbone/templates *.pt
|
||||
recursive-include tailbone/reports *.mako
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
|
||||
Tailbone
|
||||
========
|
||||
# Tailbone
|
||||
|
||||
Tailbone is an extensible web application based on Rattail. It provides a
|
||||
"back-office network environment" (BONE) for use in managing retail data.
|
||||
|
||||
Please see Rattail's `home page`_ for more information.
|
||||
|
||||
.. _home page: http://rattailproject.org/
|
||||
Please see Rattail's [home page](http://rattailproject.org/) for more
|
||||
information.
|
7539
docs/OLDCHANGES.rst
Normal file
7539
docs/OLDCHANGES.rst
Normal file
File diff suppressed because it is too large
Load diff
15
docs/api/api/batch/core.rst
Normal file
15
docs/api/api/batch/core.rst
Normal file
|
@ -0,0 +1,15 @@
|
|||
|
||||
``tailbone.api.batch.core``
|
||||
===========================
|
||||
|
||||
.. automodule:: tailbone.api.batch.core
|
||||
|
||||
.. autoclass:: APIBatchMixin
|
||||
|
||||
.. autoclass:: APIBatchView
|
||||
|
||||
.. autoclass:: APIBatchRowView
|
||||
|
||||
.. autoattribute:: editable
|
||||
|
||||
.. autoattribute:: supports_quick_entry
|
41
docs/api/api/batch/ordering.rst
Normal file
41
docs/api/api/batch/ordering.rst
Normal file
|
@ -0,0 +1,41 @@
|
|||
|
||||
``tailbone.api.batch.ordering``
|
||||
===============================
|
||||
|
||||
.. automodule:: tailbone.api.batch.ordering
|
||||
|
||||
.. autoclass:: OrderingBatchViews
|
||||
|
||||
.. autoattribute:: collection_url_prefix
|
||||
|
||||
.. autoattribute:: object_url_prefix
|
||||
|
||||
.. autoattribute:: model_class
|
||||
|
||||
.. autoattribute:: route_prefix
|
||||
|
||||
.. autoattribute:: permission_prefix
|
||||
|
||||
.. autoattribute:: default_handler_spec
|
||||
|
||||
.. automethod:: base_query
|
||||
|
||||
.. automethod:: create_object
|
||||
|
||||
.. autoclass:: OrderingBatchRowViews
|
||||
|
||||
.. autoattribute:: collection_url_prefix
|
||||
|
||||
.. autoattribute:: object_url_prefix
|
||||
|
||||
.. autoattribute:: model_class
|
||||
|
||||
.. autoattribute:: route_prefix
|
||||
|
||||
.. autoattribute:: permission_prefix
|
||||
|
||||
.. autoattribute:: default_handler_spec
|
||||
|
||||
.. autoattribute:: supports_quick_entry
|
||||
|
||||
.. automethod:: update_object
|
6
docs/api/db.rst
Normal file
6
docs/api/db.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``tailbone.db``
|
||||
===============
|
||||
|
||||
.. automodule:: tailbone.db
|
||||
:members:
|
6
docs/api/diffs.rst
Normal file
6
docs/api/diffs.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``tailbone.diffs``
|
||||
==================
|
||||
|
||||
.. automodule:: tailbone.diffs
|
||||
:members:
|
6
docs/api/forms.widgets.rst
Normal file
6
docs/api/forms.widgets.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``tailbone.forms.widgets``
|
||||
==========================
|
||||
|
||||
.. automodule:: tailbone.forms.widgets
|
||||
:members:
|
6
docs/api/grids.core.rst
Normal file
6
docs/api/grids.core.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``tailbone.grids.core``
|
||||
=======================
|
||||
|
||||
.. automodule:: tailbone.grids.core
|
||||
:members:
|
6
docs/api/progress.rst
Normal file
6
docs/api/progress.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``tailbone.progress``
|
||||
=====================
|
||||
|
||||
.. automodule:: tailbone.progress
|
||||
:members:
|
|
@ -3,5 +3,4 @@
|
|||
========================
|
||||
|
||||
.. automodule:: tailbone.subscribers
|
||||
|
||||
.. autofunction:: new_request
|
||||
:members:
|
||||
|
|
6
docs/api/util.rst
Normal file
6
docs/api/util.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``tailbone.util``
|
||||
=================
|
||||
|
||||
.. automodule:: tailbone.util
|
||||
:members:
|
10
docs/api/views/batch.vendorcatalog.rst
Normal file
10
docs/api/views/batch.vendorcatalog.rst
Normal file
|
@ -0,0 +1,10 @@
|
|||
|
||||
``tailbone.views.batch.vendorcatalog``
|
||||
======================================
|
||||
|
||||
.. automodule:: tailbone.views.batch.vendorcatalog
|
||||
|
||||
.. autoclass:: VendorCatalogsView
|
||||
:members:
|
||||
|
||||
.. autofunction:: includeme
|
6
docs/api/views/core.rst
Normal file
6
docs/api/views/core.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``tailbone.views.core``
|
||||
=======================
|
||||
|
||||
.. automodule:: tailbone.views.core
|
||||
:members:
|
|
@ -81,6 +81,12 @@ override when defining your subclass.
|
|||
override this for certain views, if so that should be done within
|
||||
:meth:`get_help_url()`.
|
||||
|
||||
.. attribute:: MasterView.version_diff_factory
|
||||
|
||||
Optional factory to use for version diff objects. By default
|
||||
this is *not set* but a subclass is free to set it. See also
|
||||
:meth:`get_version_diff_factory()`.
|
||||
|
||||
|
||||
Methods to Override
|
||||
-------------------
|
||||
|
@ -88,6 +94,8 @@ Methods to Override
|
|||
The following is a list of methods which you can override when defining your
|
||||
subclass.
|
||||
|
||||
.. automethod:: MasterView.editable_instance
|
||||
|
||||
.. .. automethod:: MasterView.get_settings
|
||||
|
||||
.. automethod:: MasterView.get_csv_fields
|
||||
|
@ -95,3 +103,24 @@ subclass.
|
|||
.. automethod:: MasterView.get_csv_row
|
||||
|
||||
.. automethod:: MasterView.get_help_url
|
||||
|
||||
.. automethod:: MasterView.get_model_key
|
||||
|
||||
.. automethod:: MasterView.get_version_diff_enums
|
||||
|
||||
.. automethod:: MasterView.get_version_diff_factory
|
||||
|
||||
.. automethod:: MasterView.make_version_diff
|
||||
|
||||
.. automethod:: MasterView.title_for_version
|
||||
|
||||
|
||||
Support Methods
|
||||
---------------
|
||||
|
||||
The following is a list of methods you should (probably) not need to
|
||||
override, but may find useful:
|
||||
|
||||
.. automethod:: MasterView.default_edit_url
|
||||
|
||||
.. automethod:: MasterView.get_action_route_kwargs
|
||||
|
|
6
docs/api/views/members.rst
Normal file
6
docs/api/views/members.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``tailbone.views.members``
|
||||
==========================
|
||||
|
||||
.. automodule:: tailbone.views.members
|
||||
:members:
|
9
docs/api/views/purchasing.batch.rst
Normal file
9
docs/api/views/purchasing.batch.rst
Normal file
|
@ -0,0 +1,9 @@
|
|||
|
||||
``tailbone.views.purchasing.batch``
|
||||
===================================
|
||||
|
||||
.. automodule:: tailbone.views.purchasing.batch
|
||||
|
||||
.. autoclass:: PurchasingBatchView
|
||||
|
||||
.. automethod:: save_edit_row_form
|
15
docs/api/views/purchasing.ordering.rst
Normal file
15
docs/api/views/purchasing.ordering.rst
Normal file
|
@ -0,0 +1,15 @@
|
|||
|
||||
``tailbone.views.purchasing.ordering``
|
||||
======================================
|
||||
|
||||
.. automodule:: tailbone.views.purchasing.ordering
|
||||
|
||||
.. autoclass:: OrderingBatchView
|
||||
|
||||
.. autoattribute:: model_class
|
||||
|
||||
.. autoattribute:: default_handler_spec
|
||||
|
||||
.. automethod:: configure_row_form
|
||||
|
||||
.. automethod:: worksheet_update
|
|
@ -1,10 +0,0 @@
|
|||
|
||||
``tailbone.views.vendors.catalogs``
|
||||
===================================
|
||||
|
||||
.. automodule:: tailbone.views.vendors.catalogs
|
||||
|
||||
.. autoclass:: VendorCatalogsView
|
||||
:members:
|
||||
|
||||
.. autofunction:: includeme
|
8
docs/changelog.rst
Normal file
8
docs/changelog.rst
Normal file
|
@ -0,0 +1,8 @@
|
|||
|
||||
Changelog Archive
|
||||
=================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
OLDCHANGES
|
276
docs/conf.py
276
docs/conf.py
|
@ -1,38 +1,21 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# Tailbone documentation build configuration file, created by
|
||||
# sphinx-quickstart on Sat Feb 15 23:15:27 2014.
|
||||
#
|
||||
# This file is exec()d with the current directory set to its
|
||||
# containing dir.
|
||||
#
|
||||
# Note that not all possible configuration values are present in this
|
||||
# autogenerated file.
|
||||
#
|
||||
# All configuration values have a default; values that are commented out
|
||||
# serve to show the default.
|
||||
# For the full list of built-in configuration values, see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
import sys
|
||||
import os
|
||||
# -- Project information -----------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||
|
||||
import sphinx_rtd_theme
|
||||
from importlib.metadata import version as get_version
|
||||
|
||||
exec(open(os.path.join(os.pardir, 'tailbone', '_version.py')).read())
|
||||
project = 'Tailbone'
|
||||
copyright = '2010 - 2024, Lance Edgar'
|
||||
author = 'Lance Edgar'
|
||||
release = get_version('Tailbone')
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
#needs_sphinx = '1.0'
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.todo',
|
||||
|
@ -40,237 +23,30 @@ extensions = [
|
|||
'sphinx.ext.viewcode',
|
||||
]
|
||||
|
||||
templates_path = ['_templates']
|
||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||
|
||||
intersphinx_mapping = {
|
||||
'rattail': ('https://docs.wuttaproject.org/rattail/', None),
|
||||
'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
|
||||
'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None),
|
||||
'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None),
|
||||
}
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
# The suffix of source filenames.
|
||||
source_suffix = '.rst'
|
||||
|
||||
# The encoding of source files.
|
||||
#source_encoding = 'utf-8-sig'
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = u'Tailbone'
|
||||
copyright = u'2010 - 2018, Lance Edgar'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
# version = '0.3'
|
||||
version = '.'.join(__version__.split('.')[:2])
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = __version__
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#language = None
|
||||
|
||||
# There are two options for replacing |today|: either, you set today to some
|
||||
# non-false value, then it is used:
|
||||
#today = ''
|
||||
# Else, today_fmt is used as the format for a strftime call.
|
||||
#today_fmt = '%B %d, %Y'
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
exclude_patterns = ['_build']
|
||||
|
||||
# The reST default role (used for this markup: `text`) to use for all
|
||||
# documents.
|
||||
#default_role = None
|
||||
|
||||
# If true, '()' will be appended to :func: etc. cross-reference text.
|
||||
#add_function_parentheses = True
|
||||
|
||||
# If true, the current module name will be prepended to all description
|
||||
# unit titles (such as .. function::).
|
||||
#add_module_names = True
|
||||
|
||||
# If true, sectionauthor and moduleauthor directives will be shown in the
|
||||
# output. They are ignored by default.
|
||||
#show_authors = False
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
# A list of ignored prefixes for module index sorting.
|
||||
#modindex_common_prefix = []
|
||||
|
||||
# If true, keep warnings as "system message" paragraphs in the built documents.
|
||||
#keep_warnings = False
|
||||
# allow todo entries to show up
|
||||
todo_include_todos = True
|
||||
|
||||
|
||||
# -- Options for HTML output ----------------------------------------------
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
# html_theme = 'classic'
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
# documentation.
|
||||
#html_theme_options = {}
|
||||
|
||||
# Add any paths that contain custom themes here, relative to this directory.
|
||||
#html_theme_path = []
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
|
||||
# The name for this set of Sphinx documents. If None, it defaults to
|
||||
# "<project> v<release> documentation".
|
||||
#html_title = None
|
||||
|
||||
# A shorter title for the navigation bar. Default is the same as html_title.
|
||||
#html_short_title = None
|
||||
html_theme = 'furo'
|
||||
html_static_path = ['_static']
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top
|
||||
# of the sidebar.
|
||||
#html_logo = None
|
||||
html_logo = 'images/rattail_avatar.png'
|
||||
|
||||
# The name of an image file (within the static path) to use as favicon of the
|
||||
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
||||
# pixels large.
|
||||
#html_favicon = None
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
# Add any extra paths that contain custom files (such as robots.txt or
|
||||
# .htaccess) here, relative to this directory. These files are copied
|
||||
# directly to the root of the documentation.
|
||||
#html_extra_path = []
|
||||
|
||||
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
|
||||
# using the given strftime format.
|
||||
#html_last_updated_fmt = '%b %d, %Y'
|
||||
|
||||
# If true, SmartyPants will be used to convert quotes and dashes to
|
||||
# typographically correct entities.
|
||||
#html_use_smartypants = True
|
||||
|
||||
# Custom sidebar templates, maps document names to template names.
|
||||
#html_sidebars = {}
|
||||
|
||||
# Additional templates that should be rendered to pages, maps page names to
|
||||
# template names.
|
||||
#html_additional_pages = {}
|
||||
|
||||
# If false, no module index is generated.
|
||||
#html_domain_indices = True
|
||||
|
||||
# If false, no index is generated.
|
||||
#html_use_index = True
|
||||
|
||||
# If true, the index is split into individual pages for each letter.
|
||||
#html_split_index = False
|
||||
|
||||
# If true, links to the reST sources are added to the pages.
|
||||
#html_show_sourcelink = True
|
||||
|
||||
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
|
||||
#html_show_sphinx = True
|
||||
|
||||
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
|
||||
#html_show_copyright = True
|
||||
|
||||
# If true, an OpenSearch description file will be output, and all pages will
|
||||
# contain a <link> tag referring to it. The value of this option must be the
|
||||
# base URL from which the finished HTML is served.
|
||||
#html_use_opensearch = ''
|
||||
|
||||
# This is the file name suffix for HTML files (e.g. ".xhtml").
|
||||
#html_file_suffix = None
|
||||
#html_logo = 'images/rattail_avatar.png'
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'Tailbonedoc'
|
||||
|
||||
|
||||
# -- Options for LaTeX output ---------------------------------------------
|
||||
|
||||
latex_elements = {
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#'papersize': 'letterpaper',
|
||||
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#'pointsize': '10pt',
|
||||
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#'preamble': '',
|
||||
}
|
||||
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
('index', 'Tailbone.tex', u'Tailbone Documentation',
|
||||
u'Lance Edgar', 'manual'),
|
||||
]
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top of
|
||||
# the title page.
|
||||
#latex_logo = None
|
||||
|
||||
# For "manual" documents, if this is true, then toplevel headings are parts,
|
||||
# not chapters.
|
||||
#latex_use_parts = False
|
||||
|
||||
# If true, show page references after internal links.
|
||||
#latex_show_pagerefs = False
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
#latex_show_urls = False
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
#latex_appendices = []
|
||||
|
||||
# If false, no module index is generated.
|
||||
#latex_domain_indices = True
|
||||
|
||||
|
||||
# -- Options for manual page output ---------------------------------------
|
||||
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
('index', 'tailbone', u'Tailbone Documentation',
|
||||
[u'Lance Edgar'], 1)
|
||||
]
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
#man_show_urls = False
|
||||
|
||||
|
||||
# -- Options for Texinfo output -------------------------------------------
|
||||
|
||||
# Grouping the document tree into Texinfo files. List of tuples
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
('index', 'Tailbone', u'Tailbone Documentation',
|
||||
u'Lance Edgar', 'Tailbone', 'One line description of project.',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
#texinfo_appendices = []
|
||||
|
||||
# If false, no module index is generated.
|
||||
#texinfo_domain_indices = True
|
||||
|
||||
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
||||
#texinfo_show_urls = 'footnote'
|
||||
|
||||
# If true, do not generate a @detailmenu in the "Top" node's menu.
|
||||
#texinfo_no_detailmenu = False
|
||||
#htmlhelp_basename = 'Tailbonedoc'
|
||||
|
|
|
@ -42,12 +42,32 @@ Package API:
|
|||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
api/api/batch/core
|
||||
api/api/batch/ordering
|
||||
api/db
|
||||
api/diffs
|
||||
api/forms
|
||||
api/forms.widgets
|
||||
api/grids
|
||||
api/grids.core
|
||||
api/progress
|
||||
api/subscribers
|
||||
api/util
|
||||
api/views/batch
|
||||
api/views/batch.vendorcatalog
|
||||
api/views/core
|
||||
api/views/master
|
||||
api/views/vendors.catalogs
|
||||
api/views/members
|
||||
api/views/purchasing.batch
|
||||
api/views/purchasing.ordering
|
||||
|
||||
|
||||
Changelog:
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
changelog
|
||||
|
||||
|
||||
Documentation To-Do
|
||||
|
|
|
@ -117,7 +117,6 @@ of course supply the web app layer.
|
|||
│ │ │ └── foobatch/
|
||||
│ │ ├── customers/
|
||||
│ │ ├── menu.mako
|
||||
│ │ ├── mobile/
|
||||
│ │ └── products/
|
||||
│ └── views/
|
||||
│ ├── __init__.py
|
||||
|
|
103
pyproject.toml
Normal file
103
pyproject.toml
Normal file
|
@ -0,0 +1,103 @@
|
|||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
|
||||
[project]
|
||||
name = "Tailbone"
|
||||
version = "0.22.7"
|
||||
description = "Backoffice Web Application for Rattail"
|
||||
readme = "README.md"
|
||||
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
|
||||
license = {text = "GNU GPL v3+"}
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Web Environment",
|
||||
"Framework :: Pyramid",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
||||
"Natural Language :: English",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
"Topic :: Office/Business",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
requires-python = ">= 3.8"
|
||||
dependencies = [
|
||||
"asgiref",
|
||||
"colander",
|
||||
"ColanderAlchemy",
|
||||
"cornice",
|
||||
"cornice-swagger",
|
||||
"deform",
|
||||
"humanize",
|
||||
"Mako",
|
||||
"markdown",
|
||||
"openpyxl",
|
||||
"paginate",
|
||||
"paginate_sqlalchemy",
|
||||
"passlib",
|
||||
"Pillow",
|
||||
"pyramid>=2",
|
||||
"pyramid_beaker",
|
||||
"pyramid_deform",
|
||||
"pyramid_exclog",
|
||||
"pyramid_fanstatic",
|
||||
"pyramid_mako",
|
||||
"pyramid_retry",
|
||||
"pyramid_tm",
|
||||
"rattail[db,bouncer]>=0.20.1",
|
||||
"sa-filters",
|
||||
"simplejson",
|
||||
"transaction",
|
||||
"waitress",
|
||||
"WebHelpers2",
|
||||
"WuttaWeb>=0.21.0",
|
||||
"zope.sqlalchemy>=1.5",
|
||||
]
|
||||
|
||||
|
||||
[project.optional-dependencies]
|
||||
docs = ["Sphinx", "furo"]
|
||||
tests = ["coverage", "mock", "pytest", "pytest-cov"]
|
||||
|
||||
|
||||
[project.entry-points."paste.app_factory"]
|
||||
main = "tailbone.app:main"
|
||||
webapi = "tailbone.webapi:main"
|
||||
|
||||
|
||||
[project.entry-points."rattail.cleaners"]
|
||||
beaker = "tailbone.cleanup:BeakerCleaner"
|
||||
|
||||
|
||||
[project.entry-points."rattail.config.extensions"]
|
||||
tailbone = "tailbone.config:ConfigExtension"
|
||||
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://rattailproject.org"
|
||||
Repository = "https://forgejo.wuttaproject.org/rattail/tailbone"
|
||||
Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues"
|
||||
Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md"
|
||||
|
||||
|
||||
[tool.commitizen]
|
||||
version_provider = "pep621"
|
||||
tag_format = "v$version"
|
||||
update_changelog_on_bump = true
|
||||
|
||||
|
||||
[tool.nosetests]
|
||||
nocapture = 1
|
||||
cover-package = "tailbone"
|
||||
cover-erase = 1
|
||||
cover-html = 1
|
||||
cover-html-dir = "htmlcov"
|
|
@ -1,6 +0,0 @@
|
|||
[nosetests]
|
||||
nocapture = 1
|
||||
cover-package = tailbone
|
||||
cover-erase = 1
|
||||
cover-html = 1
|
||||
cover-html-dir = htmlcov
|
170
setup.py
170
setup.py
|
@ -1,170 +0,0 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2018 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Setup script for Tailbone
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import os.path
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
exec(open(os.path.join(here, 'tailbone', '_version.py')).read())
|
||||
README = open(os.path.join(here, 'README.rst')).read()
|
||||
|
||||
|
||||
requires = [
|
||||
#
|
||||
# Version numbers within comments below have specific meanings.
|
||||
# Basically the 'low' value is a "soft low," and 'high' a "soft high."
|
||||
# In other words:
|
||||
#
|
||||
# If either a 'low' or 'high' value exists, the primary point to be
|
||||
# made about the value is that it represents the most current (stable)
|
||||
# version available for the package (assuming typical public access
|
||||
# methods) whenever this project was started and/or documented.
|
||||
# Therefore:
|
||||
#
|
||||
# If a 'low' version is present, you should know that attempts to use
|
||||
# versions of the package significantly older than the 'low' version
|
||||
# may not yield happy results. (A "hard" high limit may or may not be
|
||||
# indicated by a true version requirement.)
|
||||
#
|
||||
# Similarly, if a 'high' version is present, and especially if this
|
||||
# project has laid dormant for a while, you may need to refactor a bit
|
||||
# when attempting to support a more recent version of the package. (A
|
||||
# "hard" low limit should be indicated by a true version requirement
|
||||
# when a 'high' version is present.)
|
||||
#
|
||||
# In any case, developers and other users are encouraged to play
|
||||
# outside the lines with regard to these soft limits. If bugs are
|
||||
# encountered then they should be filed as such.
|
||||
#
|
||||
# package # low high
|
||||
|
||||
# TODO: why do we need to cap this? breaks tailbone.db zope stuff somehow
|
||||
'zope.sqlalchemy<1.0', # 0.7 0.7.7
|
||||
|
||||
# TODO: apparently they jumped from 0.1 to 0.9 and that broke us...must investigate
|
||||
'webhelpers2_grid==0.1', # 0.1
|
||||
|
||||
'ColanderAlchemy', # 0.3.3
|
||||
'cornice', # 3.4.2
|
||||
'deform', # 2.0.4
|
||||
'humanize', # 0.5.1
|
||||
'Mako', # 0.6.2
|
||||
'openpyxl', # 2.4.7
|
||||
'paginate', # 0.5.6
|
||||
'paginate_sqlalchemy', # 0.2.0
|
||||
'passlib', # 1.7.1
|
||||
'Pillow', # 5.3.0
|
||||
'pyramid', # 1.3b2
|
||||
'pyramid_beaker>=0.6', # 0.6.1
|
||||
'pyramid_deform', # 0.2
|
||||
'pyramid_exclog', # 0.6
|
||||
'pyramid_mako', # 1.0.2
|
||||
'pyramid_tm', # 0.3
|
||||
'rattail[db,bouncer]', # 0.5.0
|
||||
'six', # 1.10.0
|
||||
'transaction', # 1.2.0
|
||||
'waitress', # 0.8.1
|
||||
'WebHelpers2', # 2.0
|
||||
'WTForms', # 2.1
|
||||
]
|
||||
|
||||
|
||||
extras = {
|
||||
|
||||
'docs': [
|
||||
#
|
||||
# package # low high
|
||||
|
||||
'Sphinx', # 1.2
|
||||
'sphinx-rtd-theme', # 0.2.4
|
||||
],
|
||||
|
||||
'tests': [
|
||||
#
|
||||
# package # low high
|
||||
|
||||
'coverage', # 3.6
|
||||
'fixture', # 1.5
|
||||
'mock', # 1.0.1
|
||||
'nose', # 1.3.0
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
setup(
|
||||
name = "Tailbone",
|
||||
version = __version__,
|
||||
author = "Lance Edgar",
|
||||
author_email = "lance@edbob.org",
|
||||
url = "http://rattailproject.org/",
|
||||
license = "GNU GPL v3",
|
||||
description = "Backoffice Web Application for Rattail",
|
||||
long_description = README,
|
||||
|
||||
classifiers = [
|
||||
'Development Status :: 4 - Beta',
|
||||
'Environment :: Web Environment',
|
||||
'Framework :: Pyramid',
|
||||
'Intended Audience :: Developers',
|
||||
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
|
||||
'Natural Language :: English',
|
||||
'Operating System :: OS Independent',
|
||||
'Programming Language :: Python',
|
||||
'Programming Language :: Python :: 2.7',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Topic :: Internet :: WWW/HTTP',
|
||||
'Topic :: Office/Business',
|
||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||
],
|
||||
|
||||
install_requires = requires,
|
||||
extras_require = extras,
|
||||
tests_require = ['Tailbone[tests]'],
|
||||
test_suite = 'nose.collector',
|
||||
|
||||
packages = find_packages(exclude=['tests.*', 'tests']),
|
||||
include_package_data = True,
|
||||
zip_safe = False,
|
||||
|
||||
entry_points = {
|
||||
|
||||
'paste.app_factory': [
|
||||
'main = tailbone.app:main',
|
||||
],
|
||||
|
||||
'rattail.config.extensions': [
|
||||
'tailbone = tailbone.config:ConfigExtension',
|
||||
],
|
||||
|
||||
'pyramid.scaffold': [
|
||||
'rattail = tailbone.scaffolds:RattailTemplate',
|
||||
],
|
||||
},
|
||||
)
|
|
@ -1,3 +1,9 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
__version__ = '0.7.49'
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
except ImportError:
|
||||
from importlib_metadata import version
|
||||
|
||||
|
||||
__version__ = version('Tailbone')
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2018 Lance Edgar
|
||||
# Copyright © 2010-2022 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -27,10 +27,13 @@ Tailbone Web API
|
|||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
from .core import APIView, api
|
||||
from .master import APIMasterView
|
||||
from .master import APIMasterView, SortColumn
|
||||
# TODO: remove this
|
||||
from .master2 import APIMasterView2
|
||||
|
||||
|
||||
def includeme(config):
|
||||
config.include('tailbone.api.common')
|
||||
config.include('tailbone.api.auth')
|
||||
config.include('tailbone.api.customers')
|
||||
config.include('tailbone.api.upgrades')
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2018 Lance Edgar
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,9 +24,7 @@
|
|||
Tailbone Web API - Auth Views
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
from rattail.db.auth import authenticate_user
|
||||
from cornice import Service
|
||||
|
||||
from tailbone.api import APIView, api
|
||||
from tailbone.db import Session
|
||||
|
@ -35,16 +33,6 @@ from tailbone.auth import login_user, logout_user
|
|||
|
||||
class AuthenticationView(APIView):
|
||||
|
||||
def user_info(self, user):
|
||||
return {
|
||||
'ok': True,
|
||||
'user': {
|
||||
'uuid': user.uuid,
|
||||
'username': user.username,
|
||||
'display_name': user.display_name,
|
||||
},
|
||||
}
|
||||
|
||||
@api
|
||||
def check_session(self):
|
||||
"""
|
||||
|
@ -52,9 +40,29 @@ class AuthenticationView(APIView):
|
|||
This will establish a server-side web session for the user if none
|
||||
exists. Note that this also resets the user's session timer.
|
||||
"""
|
||||
data = {'ok': True, 'permissions': []}
|
||||
if self.request.user:
|
||||
return self.user_info(self.request.user)
|
||||
return {}
|
||||
data['user'] = self.get_user_info(self.request.user)
|
||||
data['permissions'] = list(self.request.user_permissions)
|
||||
|
||||
# background color may be set per-request, by some apps
|
||||
if hasattr(self.request, 'background_color') and self.request.background_color:
|
||||
data['background_color'] = self.request.background_color
|
||||
else: # otherwise we use the one from config
|
||||
data['background_color'] = self.rattail_config.get(
|
||||
'tailbone', 'background_color')
|
||||
|
||||
# TODO: this seems the best place to return some global app
|
||||
# settings, but maybe not desirable in all cases..in which
|
||||
# case should caller need to ask for these explicitly? or
|
||||
# make a different call altogether to get them..?
|
||||
app = self.get_rattail_app()
|
||||
customer_handler = app.get_clientele_handler()
|
||||
data['settings'] = {
|
||||
'customer_field_dropdown': customer_handler.choice_uses_dropdown(),
|
||||
}
|
||||
|
||||
return data
|
||||
|
||||
@api
|
||||
def login(self):
|
||||
|
@ -69,12 +77,41 @@ class AuthenticationView(APIView):
|
|||
if not (username and password):
|
||||
return {'error': "Invalid username or password"}
|
||||
|
||||
user = authenticate_user(Session(), username, password)
|
||||
# make sure credentials are valid
|
||||
user = self.authenticate_user(username, password)
|
||||
if not user:
|
||||
return {'error': "Invalid username or password"}
|
||||
|
||||
# is there some reason this user should not login?
|
||||
error = self.why_cant_user_login(user)
|
||||
if error:
|
||||
return {'error': error}
|
||||
|
||||
app = self.get_rattail_app()
|
||||
auth = app.get_auth_handler()
|
||||
|
||||
login_user(self.request, user)
|
||||
return self.user_info(user)
|
||||
return {
|
||||
'ok': True,
|
||||
'user': self.get_user_info(user),
|
||||
'permissions': list(auth.get_permissions(Session(), user)),
|
||||
}
|
||||
|
||||
def authenticate_user(self, username, password):
|
||||
app = self.get_rattail_app()
|
||||
auth = app.get_auth_handler()
|
||||
return auth.authenticate_user(Session(), username, password)
|
||||
|
||||
def why_cant_user_login(self, user):
|
||||
"""
|
||||
This method is given a ``User`` instance, which represents someone who
|
||||
is just now trying to login, and has already cleared the basic hurdle
|
||||
of providing the correct credentials for a user on file. This method
|
||||
is responsible then, for further verification that this user *should*
|
||||
in fact be allowed to login to this app node. If the method determines
|
||||
a reason the user should *not* be allowed to login, then it should
|
||||
return that reason as a simple string.
|
||||
"""
|
||||
|
||||
@api
|
||||
def logout(self):
|
||||
|
@ -87,21 +124,106 @@ class AuthenticationView(APIView):
|
|||
logout_user(self.request)
|
||||
return {'ok': True}
|
||||
|
||||
@api
|
||||
def become_root(self):
|
||||
"""
|
||||
Elevate the current request to 'root' for full system access.
|
||||
"""
|
||||
if not self.request.is_admin:
|
||||
raise self.forbidden()
|
||||
self.request.user.record_event(self.enum.USER_EVENT_BECOME_ROOT)
|
||||
self.request.session['is_root'] = True
|
||||
return {
|
||||
'ok': True,
|
||||
'user': self.get_user_info(self.request.user),
|
||||
}
|
||||
|
||||
@api
|
||||
def stop_root(self):
|
||||
"""
|
||||
Lower the current request from 'root' back to normal access.
|
||||
"""
|
||||
if not self.request.is_admin:
|
||||
raise self.forbidden()
|
||||
self.request.user.record_event(self.enum.USER_EVENT_STOP_ROOT)
|
||||
self.request.session['is_root'] = False
|
||||
return {
|
||||
'ok': True,
|
||||
'user': self.get_user_info(self.request.user),
|
||||
}
|
||||
|
||||
@api
|
||||
def change_password(self):
|
||||
"""
|
||||
View which allows a user to change their password.
|
||||
"""
|
||||
if self.request.method == 'OPTIONS':
|
||||
return self.request.response
|
||||
|
||||
if not self.request.user:
|
||||
raise self.forbidden()
|
||||
|
||||
if self.request.user.prevent_password_change and not self.request.is_root:
|
||||
raise self.forbidden()
|
||||
|
||||
data = self.request.json_body
|
||||
|
||||
# first make sure "current" password is accurate
|
||||
if not self.authenticate_user(self.request.user, data['current_password']):
|
||||
return {'error': "The current/old password you provided is incorrect"}
|
||||
|
||||
# okay then, set new password
|
||||
auth = self.app.get_auth_handler()
|
||||
auth.set_user_password(self.request.user, data['new_password'])
|
||||
return {
|
||||
'ok': True,
|
||||
'user': self.get_user_info(self.request.user),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def defaults(cls, config):
|
||||
cls._auth_defaults(config)
|
||||
|
||||
@classmethod
|
||||
def _auth_defaults(cls, config):
|
||||
|
||||
# session
|
||||
config.add_route('api.session', '/session', request_method='GET')
|
||||
config.add_view(cls, attr='check_session', route_name='api.session', renderer='json')
|
||||
check_session = Service(name='check_session', path='/session')
|
||||
check_session.add_view('GET', 'check_session', klass=cls)
|
||||
config.add_cornice_service(check_session)
|
||||
|
||||
# login
|
||||
config.add_route('api.login', '/login', request_method=('OPTIONS', 'POST'))
|
||||
config.add_view(cls, attr='login', route_name='api.login', renderer='json')
|
||||
login = Service(name='login', path='/login')
|
||||
login.add_view('POST', 'login', klass=cls)
|
||||
config.add_cornice_service(login)
|
||||
|
||||
# logout
|
||||
config.add_route('api.logout', '/logout', request_method=('OPTIONS', 'POST'))
|
||||
config.add_view(cls, attr='logout', route_name='api.logout', renderer='json')
|
||||
logout = Service(name='logout', path='/logout')
|
||||
logout.add_view('POST', 'logout', klass=cls)
|
||||
config.add_cornice_service(logout)
|
||||
|
||||
# become root
|
||||
become_root = Service(name='become_root', path='/become-root')
|
||||
become_root.add_view('POST', 'become_root', klass=cls)
|
||||
config.add_cornice_service(become_root)
|
||||
|
||||
# stop root
|
||||
stop_root = Service(name='stop_root', path='/stop-root')
|
||||
stop_root.add_view('POST', 'stop_root', klass=cls)
|
||||
config.add_cornice_service(stop_root)
|
||||
|
||||
# change password
|
||||
change_password = Service(name='change_password', path='/change-password')
|
||||
change_password.add_view('POST', 'change_password', klass=cls)
|
||||
config.add_cornice_service(change_password)
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
AuthenticationView = kwargs.get('AuthenticationView', base['AuthenticationView'])
|
||||
AuthenticationView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
AuthenticationView.defaults(config)
|
||||
defaults(config)
|
||||
|
|
29
tailbone/api/batch/__init__.py
Normal file
29
tailbone/api/batch/__init__.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2019 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Tailbone Web API - Batches
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
from .core import APIBatchView, APIBatchRowView, BatchAPIMasterView
|
360
tailbone/api/batch/core.py
Normal file
360
tailbone/api/batch/core.py
Normal file
|
@ -0,0 +1,360 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2023 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Tailbone Web API - Batch Views
|
||||
"""
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
from cornice import Service
|
||||
|
||||
from tailbone.api import APIMasterView
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class APIBatchMixin(object):
|
||||
"""
|
||||
Base class for all API views which are meant to handle "batch" *and/or*
|
||||
"batch row" data.
|
||||
"""
|
||||
|
||||
def get_batch_class(self):
|
||||
model_class = self.get_model_class()
|
||||
if hasattr(model_class, '__batch_class__'):
|
||||
return model_class.__batch_class__
|
||||
return model_class
|
||||
|
||||
def get_handler(self):
|
||||
"""
|
||||
Returns a `BatchHandler` instance for the view. All (?) custom batch
|
||||
API views should define a default handler class; however this may in all
|
||||
(?) cases be overridden by config also. The specific setting required
|
||||
to do so will depend on the 'key' for the type of batch involved, e.g.
|
||||
assuming the 'vendor_catalog' batch:
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
[rattail.batch]
|
||||
vendor_catalog.handler = myapp.batch.vendorcatalog:CustomCatalogHandler
|
||||
|
||||
Note that the 'key' for a batch is generally the same as its primary
|
||||
table name, although technically it is whatever value returns from the
|
||||
``batch_key`` attribute of the main batch model class.
|
||||
"""
|
||||
app = self.get_rattail_app()
|
||||
key = self.get_batch_class().batch_key
|
||||
return app.get_batch_handler(key, default=self.default_handler_spec)
|
||||
|
||||
|
||||
class APIBatchView(APIBatchMixin, APIMasterView):
|
||||
"""
|
||||
Base class for all API views which are meant to handle "batch" *and/or*
|
||||
"batch row" data.
|
||||
"""
|
||||
supports_toggle_complete = False
|
||||
supports_execute = False
|
||||
|
||||
def __init__(self, request, **kwargs):
|
||||
super(APIBatchView, self).__init__(request, **kwargs)
|
||||
self.batch_handler = self.get_handler()
|
||||
|
||||
@property
|
||||
def handler(self):
|
||||
warnings.warn("the `handler` property is deprecated; "
|
||||
"please use `batch_handler` instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
return self.batch_handler
|
||||
|
||||
def normalize(self, batch):
|
||||
app = self.get_rattail_app()
|
||||
created = app.localtime(batch.created, from_utc=True)
|
||||
|
||||
executed = None
|
||||
if batch.executed:
|
||||
executed = app.localtime(batch.executed, from_utc=True)
|
||||
|
||||
return {
|
||||
'uuid': batch.uuid,
|
||||
'_str': str(batch),
|
||||
'id': batch.id,
|
||||
'id_str': batch.id_str,
|
||||
'description': batch.description,
|
||||
'notes': batch.notes,
|
||||
'params': batch.params or {},
|
||||
'rowcount': batch.rowcount,
|
||||
'created': str(created),
|
||||
'created_display': self.pretty_datetime(created),
|
||||
'created_by_uuid': batch.created_by.uuid,
|
||||
'created_by_display': str(batch.created_by),
|
||||
'complete': batch.complete,
|
||||
'status_code': batch.status_code,
|
||||
'status_display': batch.STATUS.get(batch.status_code,
|
||||
str(batch.status_code)),
|
||||
'executed': str(executed) if executed else None,
|
||||
'executed_display': self.pretty_datetime(executed) if executed else None,
|
||||
'executed_by_uuid': batch.executed_by_uuid,
|
||||
'executed_by_display': str(batch.executed_by or ''),
|
||||
'mutable': self.batch_handler.is_mutable(batch),
|
||||
}
|
||||
|
||||
def create_object(self, data):
|
||||
"""
|
||||
Create a new object instance and populate it with the given data.
|
||||
|
||||
Here we'll invoke the handler for actual batch creation, instead of
|
||||
typical logic used for simple records.
|
||||
"""
|
||||
user = self.request.user
|
||||
kwargs = dict(data)
|
||||
kwargs['user'] = user
|
||||
batch = self.batch_handler.make_batch(self.Session(), **kwargs)
|
||||
if self.batch_handler.should_populate(batch):
|
||||
self.batch_handler.do_populate(batch, user)
|
||||
return batch
|
||||
|
||||
def update_object(self, batch, data):
|
||||
"""
|
||||
Logic for updating a main object record.
|
||||
|
||||
Here we want to make sure we set "created by" to the current user, when
|
||||
creating a new batch.
|
||||
"""
|
||||
# we're only concerned with *new* batches here
|
||||
if not batch.uuid:
|
||||
|
||||
# assign creator; initialize row count
|
||||
batch.created_by_uuid = self.request.user.uuid
|
||||
if batch.rowcount is None:
|
||||
batch.rowcount = 0
|
||||
|
||||
# then go ahead with usual logic
|
||||
return super(APIBatchView, self).update_object(batch, data)
|
||||
|
||||
def mark_complete(self):
|
||||
"""
|
||||
Mark the given batch as "complete".
|
||||
"""
|
||||
batch = self.get_object()
|
||||
|
||||
if batch.executed:
|
||||
return {'error': "Batch {} has already been executed: {}".format(
|
||||
batch.id_str, batch.description)}
|
||||
|
||||
if batch.complete:
|
||||
return {'error': "Batch {} is already marked complete: {}".format(
|
||||
batch.id_str, batch.description)}
|
||||
|
||||
batch.complete = True
|
||||
return self._get(obj=batch)
|
||||
|
||||
def mark_incomplete(self):
|
||||
"""
|
||||
Mark the given batch as "incomplete".
|
||||
"""
|
||||
batch = self.get_object()
|
||||
|
||||
if batch.executed:
|
||||
return {'error': "Batch {} has already been executed: {}".format(
|
||||
batch.id_str, batch.description)}
|
||||
|
||||
if not batch.complete:
|
||||
return {'error': "Batch {} is already marked incomplete: {}".format(
|
||||
batch.id_str, batch.description)}
|
||||
|
||||
batch.complete = False
|
||||
return self._get(obj=batch)
|
||||
|
||||
def execute(self):
|
||||
"""
|
||||
Execute the given batch.
|
||||
"""
|
||||
batch = self.get_object()
|
||||
|
||||
if batch.executed:
|
||||
return {'error': "Batch {} has already been executed: {}".format(
|
||||
batch.id_str, batch.description)}
|
||||
|
||||
kwargs = dict(self.request.json_body)
|
||||
kwargs.pop('user', None)
|
||||
kwargs.pop('progress', None)
|
||||
result = self.batch_handler.do_execute(batch, self.request.user, **kwargs)
|
||||
return {'ok': bool(result), 'batch': self.normalize(batch)}
|
||||
|
||||
@classmethod
|
||||
def defaults(cls, config):
|
||||
cls._defaults(config)
|
||||
cls._batch_defaults(config)
|
||||
|
||||
@classmethod
|
||||
def _batch_defaults(cls, config):
|
||||
route_prefix = cls.get_route_prefix()
|
||||
permission_prefix = cls.get_permission_prefix()
|
||||
collection_url_prefix = cls.get_collection_url_prefix()
|
||||
object_url_prefix = cls.get_object_url_prefix()
|
||||
|
||||
if cls.supports_toggle_complete:
|
||||
|
||||
# mark complete
|
||||
mark_complete = Service(name='{}.mark_complete'.format(route_prefix),
|
||||
path='{}/{{uuid}}/mark-complete'.format(object_url_prefix))
|
||||
mark_complete.add_view('POST', 'mark_complete', klass=cls,
|
||||
permission='{}.edit'.format(permission_prefix))
|
||||
config.add_cornice_service(mark_complete)
|
||||
|
||||
# mark incomplete
|
||||
mark_incomplete = Service(name='{}.mark_incomplete'.format(route_prefix),
|
||||
path='{}/{{uuid}}/mark-incomplete'.format(object_url_prefix))
|
||||
mark_incomplete.add_view('POST', 'mark_incomplete', klass=cls,
|
||||
permission='{}.edit'.format(permission_prefix))
|
||||
config.add_cornice_service(mark_incomplete)
|
||||
|
||||
if cls.supports_execute:
|
||||
|
||||
# execute batch
|
||||
execute = Service(name='{}.execute'.format(route_prefix),
|
||||
path='{}/{{uuid}}/execute'.format(object_url_prefix))
|
||||
execute.add_view('POST', 'execute', klass=cls,
|
||||
permission='{}.execute'.format(permission_prefix))
|
||||
config.add_cornice_service(execute)
|
||||
|
||||
|
||||
# TODO: deprecate / remove this
|
||||
BatchAPIMasterView = APIBatchView
|
||||
|
||||
|
||||
class APIBatchRowView(APIBatchMixin, APIMasterView):
|
||||
"""
|
||||
Base class for all API views which are meant to handle "batch rows" data.
|
||||
"""
|
||||
editable = False
|
||||
supports_quick_entry = False
|
||||
|
||||
def __init__(self, request, **kwargs):
|
||||
super(APIBatchRowView, self).__init__(request, **kwargs)
|
||||
self.batch_handler = self.get_handler()
|
||||
|
||||
@property
|
||||
def handler(self):
|
||||
warnings.warn("the `handler` property is deprecated; "
|
||||
"please use `batch_handler` instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
return self.batch_handler
|
||||
|
||||
def normalize(self, row):
|
||||
batch = row.batch
|
||||
return {
|
||||
'uuid': row.uuid,
|
||||
'_str': str(row),
|
||||
'_parent_str': str(batch),
|
||||
'_parent_uuid': batch.uuid,
|
||||
'batch_uuid': batch.uuid,
|
||||
'batch_id': batch.id,
|
||||
'batch_id_str': batch.id_str,
|
||||
'batch_description': batch.description,
|
||||
'batch_complete': batch.complete,
|
||||
'batch_executed': bool(batch.executed),
|
||||
'batch_mutable': self.batch_handler.is_mutable(batch),
|
||||
'sequence': row.sequence,
|
||||
'status_code': row.status_code,
|
||||
'status_display': row.STATUS.get(row.status_code, str(row.status_code)),
|
||||
}
|
||||
|
||||
def update_object(self, row, data):
|
||||
"""
|
||||
Supplements the default logic as follows:
|
||||
|
||||
Invokes the batch handler's ``refresh_row()`` method after updating the
|
||||
row's field data per usual.
|
||||
"""
|
||||
if not self.batch_handler.is_mutable(row.batch):
|
||||
return {'error': "Batch is not mutable"}
|
||||
|
||||
# update row per usual
|
||||
row = super(APIBatchRowView, self).update_object(row, data)
|
||||
|
||||
# okay now we apply handler refresh logic
|
||||
self.batch_handler.refresh_row(row)
|
||||
return row
|
||||
|
||||
def delete_object(self, row):
|
||||
"""
|
||||
Overrides the default logic as follows:
|
||||
|
||||
Delegates deletion of the row to the batch handler.
|
||||
"""
|
||||
self.batch_handler.do_remove_row(row)
|
||||
|
||||
def quick_entry(self):
|
||||
"""
|
||||
View for handling "quick entry" user input, for a batch.
|
||||
"""
|
||||
data = self.request.json_body
|
||||
|
||||
uuid = data['batch_uuid']
|
||||
batch = self.Session.get(self.get_batch_class(), uuid)
|
||||
if not batch:
|
||||
raise self.notfound()
|
||||
|
||||
entry = data['quick_entry']
|
||||
|
||||
try:
|
||||
row = self.batch_handler.quick_entry(self.Session(), batch, entry)
|
||||
except Exception as error:
|
||||
log.warning("quick entry failed for '%s' batch %s: %s",
|
||||
self.batch_handler.batch_key, batch.id_str, entry,
|
||||
exc_info=True)
|
||||
msg = str(error)
|
||||
if not msg and isinstance(error, NotImplementedError):
|
||||
msg = "Feature is not implemented"
|
||||
return {'error': msg}
|
||||
|
||||
if not row:
|
||||
return {'error': "Could not identify product"}
|
||||
|
||||
self.Session.flush()
|
||||
result = self._get(obj=row)
|
||||
result['ok'] = True
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def defaults(cls, config):
|
||||
cls._defaults(config)
|
||||
cls._batch_row_defaults(config)
|
||||
|
||||
@classmethod
|
||||
def _batch_row_defaults(cls, config):
|
||||
route_prefix = cls.get_route_prefix()
|
||||
permission_prefix = cls.get_permission_prefix()
|
||||
collection_url_prefix = cls.get_collection_url_prefix()
|
||||
|
||||
if cls.supports_quick_entry:
|
||||
|
||||
# quick entry
|
||||
quick_entry = Service(name='{}.quick_entry'.format(route_prefix),
|
||||
path='{}/quick-entry'.format(collection_url_prefix))
|
||||
quick_entry.add_view('POST', 'quick_entry', klass=cls,
|
||||
permission='{}.edit'.format(permission_prefix))
|
||||
config.add_cornice_service(quick_entry)
|
200
tailbone/api/batch/inventory.py
Normal file
200
tailbone/api/batch/inventory.py
Normal file
|
@ -0,0 +1,200 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2023 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Tailbone Web API - Inventory Batches
|
||||
"""
|
||||
|
||||
import decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from rattail import pod
|
||||
from rattail.db.model import InventoryBatch, InventoryBatchRow
|
||||
|
||||
from cornice import Service
|
||||
|
||||
from tailbone.api.batch import APIBatchView, APIBatchRowView
|
||||
|
||||
|
||||
class InventoryBatchViews(APIBatchView):
|
||||
|
||||
model_class = InventoryBatch
|
||||
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
|
||||
route_prefix = 'inventory'
|
||||
permission_prefix = 'batch.inventory'
|
||||
collection_url_prefix = '/inventory-batches'
|
||||
object_url_prefix = '/inventory-batch'
|
||||
supports_toggle_complete = True
|
||||
|
||||
def normalize(self, batch):
|
||||
data = super().normalize(batch)
|
||||
|
||||
data['mode'] = batch.mode
|
||||
data['mode_display'] = self.enum.INVENTORY_MODE.get(batch.mode)
|
||||
if data['mode_display'] is None and batch.mode is not None:
|
||||
data['mode_display'] = str(batch.mode)
|
||||
|
||||
data['reason_code'] = batch.reason_code
|
||||
|
||||
return data
|
||||
|
||||
def count_modes(self):
|
||||
"""
|
||||
Retrieve info about the available batch count modes.
|
||||
"""
|
||||
permission_prefix = self.get_permission_prefix()
|
||||
if self.request.is_root:
|
||||
modes = self.batch_handler.get_count_modes()
|
||||
else:
|
||||
modes = self.batch_handler.get_allowed_count_modes(
|
||||
self.Session(), self.request.user,
|
||||
permission_prefix=permission_prefix)
|
||||
return modes
|
||||
|
||||
def adjustment_reasons(self):
|
||||
"""
|
||||
Retrieve info about the available "reasons" for inventory adjustment
|
||||
batches.
|
||||
"""
|
||||
raw_reasons = self.batch_handler.get_adjustment_reasons(self.Session())
|
||||
reasons = []
|
||||
for reason in raw_reasons:
|
||||
reasons.append({
|
||||
'uuid': reason.uuid,
|
||||
'code': reason.code,
|
||||
'description': reason.description,
|
||||
'hidden': reason.hidden,
|
||||
})
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def defaults(cls, config):
|
||||
cls._defaults(config)
|
||||
cls._batch_defaults(config)
|
||||
cls._inventory_defaults(config)
|
||||
|
||||
@classmethod
|
||||
def _inventory_defaults(cls, config):
|
||||
route_prefix = cls.get_route_prefix()
|
||||
permission_prefix = cls.get_permission_prefix()
|
||||
collection_url_prefix = cls.get_collection_url_prefix()
|
||||
|
||||
# get count modes
|
||||
count_modes = Service(name='{}.count_modes'.format(route_prefix),
|
||||
path='{}/count-modes'.format(collection_url_prefix))
|
||||
count_modes.add_view('GET', 'count_modes', klass=cls,
|
||||
permission='{}.list'.format(permission_prefix))
|
||||
config.add_cornice_service(count_modes)
|
||||
|
||||
# get adjustment reasons
|
||||
adjustment_reasons = Service(name='{}.adjustment_reasons'.format(route_prefix),
|
||||
path='{}/adjustment-reasons'.format(collection_url_prefix))
|
||||
adjustment_reasons.add_view('GET', 'adjustment_reasons', klass=cls,
|
||||
permission='{}.list'.format(permission_prefix))
|
||||
config.add_cornice_service(adjustment_reasons)
|
||||
|
||||
|
||||
class InventoryBatchRowViews(APIBatchRowView):
|
||||
|
||||
model_class = InventoryBatchRow
|
||||
default_handler_spec = 'rattail.batch.inventory:InventoryBatchHandler'
|
||||
route_prefix = 'inventory.rows'
|
||||
permission_prefix = 'batch.inventory'
|
||||
collection_url_prefix = '/inventory-batch-rows'
|
||||
object_url_prefix = '/inventory-batch-row'
|
||||
editable = True
|
||||
supports_quick_entry = True
|
||||
|
||||
def normalize(self, row):
|
||||
batch = row.batch
|
||||
data = super().normalize(row)
|
||||
app = self.get_rattail_app()
|
||||
|
||||
data['item_id'] = row.item_id
|
||||
data['upc'] = str(row.upc)
|
||||
data['upc_pretty'] = row.upc.pretty() if row.upc else None
|
||||
data['brand_name'] = row.brand_name
|
||||
data['description'] = row.description
|
||||
data['size'] = row.size
|
||||
data['full_description'] = row.product.full_description if row.product else row.description
|
||||
data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None
|
||||
data['case_quantity'] = app.render_quantity(row.case_quantity or 1)
|
||||
|
||||
data['cases'] = row.cases
|
||||
data['units'] = row.units
|
||||
data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
|
||||
data['quantity_display'] = "{} {}".format(
|
||||
app.render_quantity(row.cases or row.units),
|
||||
'CS' if row.cases else data['unit_uom'])
|
||||
|
||||
data['allow_cases'] = self.batch_handler.allow_cases(batch)
|
||||
|
||||
return data
|
||||
|
||||
def update_object(self, row, data):
|
||||
"""
|
||||
Supplements the default logic as follows:
|
||||
|
||||
Converts certain fields within the data, to proper "native" types.
|
||||
"""
|
||||
data = dict(data)
|
||||
|
||||
# convert some data types as needed
|
||||
if 'cases' in data:
|
||||
if data['cases'] == '':
|
||||
data['cases'] = None
|
||||
elif data['cases']:
|
||||
data['cases'] = decimal.Decimal(data['cases'])
|
||||
if 'units' in data:
|
||||
if data['units'] == '':
|
||||
data['units'] = None
|
||||
elif data['units']:
|
||||
data['units'] = decimal.Decimal(data['units'])
|
||||
|
||||
# update row per usual
|
||||
try:
|
||||
row = super().update_object(row, data)
|
||||
except sa.exc.DataError as error:
|
||||
# detect when user scans barcode for cases/units field
|
||||
if hasattr(error, 'orig'):
|
||||
orig = type(error.orig)
|
||||
if hasattr(orig, '__name__'):
|
||||
# nb. this particular error is from psycopg2
|
||||
if orig.__name__ == 'NumericValueOutOfRange':
|
||||
return {'error': "Numeric value out of range"}
|
||||
raise
|
||||
return row
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
InventoryBatchViews = kwargs.get('InventoryBatchViews', base['InventoryBatchViews'])
|
||||
InventoryBatchViews.defaults(config)
|
||||
|
||||
InventoryBatchRowViews = kwargs.get('InventoryBatchRowViews', base['InventoryBatchRowViews'])
|
||||
InventoryBatchRowViews.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
78
tailbone/api/batch/labels.py
Normal file
78
tailbone/api/batch/labels.py
Normal file
|
@ -0,0 +1,78 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Tailbone Web API - Label Batches
|
||||
"""
|
||||
|
||||
from rattail.db import model
|
||||
|
||||
from tailbone.api.batch import APIBatchView, APIBatchRowView
|
||||
|
||||
|
||||
class LabelBatchViews(APIBatchView):
|
||||
|
||||
model_class = model.LabelBatch
|
||||
default_handler_spec = 'rattail.batch.labels:LabelBatchHandler'
|
||||
route_prefix = 'labelbatchviews'
|
||||
permission_prefix = 'labels.batch'
|
||||
collection_url_prefix = '/label-batches'
|
||||
object_url_prefix = '/label-batch'
|
||||
supports_toggle_complete = True
|
||||
|
||||
|
||||
class LabelBatchRowViews(APIBatchRowView):
|
||||
|
||||
model_class = model.LabelBatchRow
|
||||
default_handler_spec = 'rattail.batch.labels:LabelBatchHandler'
|
||||
route_prefix = 'api.label_batch_rows'
|
||||
permission_prefix = 'labels.batch'
|
||||
collection_url_prefix = '/label-batch-rows'
|
||||
object_url_prefix = '/label-batch-row'
|
||||
supports_quick_entry = True
|
||||
|
||||
def normalize(self, row):
|
||||
batch = row.batch
|
||||
data = super().normalize(row)
|
||||
|
||||
data['item_id'] = row.item_id
|
||||
data['upc'] = str(row.upc)
|
||||
data['upc_pretty'] = row.upc.pretty() if row.upc else None
|
||||
data['brand_name'] = row.brand_name
|
||||
data['description'] = row.description
|
||||
data['size'] = row.size
|
||||
data['full_description'] = row.product.full_description if row.product else row.description
|
||||
return data
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
LabelBatchViews = kwargs.get('LabelBatchViews', base['LabelBatchViews'])
|
||||
LabelBatchViews.defaults(config)
|
||||
|
||||
LabelBatchRowViews = kwargs.get('LabelBatchRowViews', base['LabelBatchRowViews'])
|
||||
LabelBatchRowViews.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
318
tailbone/api/batch/ordering.py
Normal file
318
tailbone/api/batch/ordering.py
Normal file
|
@ -0,0 +1,318 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Tailbone Web API - Ordering Batches
|
||||
|
||||
These views expose the basic CRUD interface to "ordering" batches, for the web
|
||||
API.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from rattail.db.model import PurchaseBatch, PurchaseBatchRow
|
||||
|
||||
from cornice import Service
|
||||
|
||||
from tailbone.api.batch import APIBatchView, APIBatchRowView
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrderingBatchViews(APIBatchView):
|
||||
|
||||
model_class = PurchaseBatch
|
||||
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
||||
route_prefix = 'orderingbatchviews'
|
||||
permission_prefix = 'ordering'
|
||||
collection_url_prefix = '/ordering-batches'
|
||||
object_url_prefix = '/ordering-batch'
|
||||
supports_toggle_complete = True
|
||||
supports_execute = True
|
||||
|
||||
def base_query(self):
|
||||
"""
|
||||
Modifies the default logic as follows:
|
||||
|
||||
Adds a condition to the query, to ensure only purchase batches with
|
||||
"ordering" mode are returned.
|
||||
"""
|
||||
model = self.model
|
||||
query = super().base_query()
|
||||
query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING)
|
||||
return query
|
||||
|
||||
def normalize(self, batch):
|
||||
data = super().normalize(batch)
|
||||
|
||||
data['vendor_uuid'] = batch.vendor.uuid
|
||||
data['vendor_display'] = str(batch.vendor)
|
||||
|
||||
data['department_uuid'] = batch.department_uuid
|
||||
data['department_display'] = str(batch.department) if batch.department else None
|
||||
|
||||
data['po_total_calculated_display'] = "${:0.2f}".format(batch.po_total_calculated or 0)
|
||||
data['ship_method'] = batch.ship_method
|
||||
data['notes_to_vendor'] = batch.notes_to_vendor
|
||||
return data
|
||||
|
||||
def create_object(self, data):
|
||||
"""
|
||||
Modifies the default logic as follows:
|
||||
|
||||
Sets the mode to "ordering" for the new batch.
|
||||
"""
|
||||
data = dict(data)
|
||||
if not data.get('vendor_uuid'):
|
||||
raise ValueError("You must specify the vendor")
|
||||
data['mode'] = self.enum.PURCHASE_BATCH_MODE_ORDERING
|
||||
batch = super().create_object(data)
|
||||
return batch
|
||||
|
||||
def worksheet(self):
|
||||
"""
|
||||
Returns primary data for the Ordering Worksheet view.
|
||||
"""
|
||||
batch = self.get_object()
|
||||
if batch.executed:
|
||||
raise self.forbidden()
|
||||
|
||||
app = self.get_rattail_app()
|
||||
|
||||
# TODO: much of the logic below was copied from the traditional master
|
||||
# view for ordering batches. should maybe let them share it somehow?
|
||||
|
||||
# organize existing batch rows by product
|
||||
order_items = {}
|
||||
for row in batch.active_rows():
|
||||
order_items[row.product_uuid] = row
|
||||
|
||||
# organize vendor catalog costs by dept / subdept
|
||||
departments = {}
|
||||
costs = self.batch_handler.get_order_form_costs(self.Session(), batch.vendor)
|
||||
costs = self.batch_handler.sort_order_form_costs(costs)
|
||||
costs = list(costs) # we must have a stable list for the rest of this
|
||||
self.batch_handler.decorate_order_form_costs(batch, costs)
|
||||
for cost in costs:
|
||||
|
||||
department = cost.product.department
|
||||
if department:
|
||||
department_dict = departments.setdefault(department.uuid, {
|
||||
'uuid': department.uuid,
|
||||
'number': department.number,
|
||||
'name': department.name,
|
||||
})
|
||||
else:
|
||||
if None not in departments:
|
||||
departments[None] = {
|
||||
'uuid': None,
|
||||
'number': None,
|
||||
'name': "",
|
||||
}
|
||||
department_dict = departments[None]
|
||||
|
||||
subdepartments = department_dict.setdefault('subdepartments', {})
|
||||
|
||||
subdepartment = cost.product.subdepartment
|
||||
if subdepartment:
|
||||
subdepartment_dict = subdepartments.setdefault(subdepartment.uuid, {
|
||||
'uuid': subdepartment.uuid,
|
||||
'number': subdepartment.number,
|
||||
'name': subdepartment.name,
|
||||
})
|
||||
else:
|
||||
if None not in subdepartments:
|
||||
subdepartments[None] = {
|
||||
'uuid': None,
|
||||
'number': None,
|
||||
'name': "",
|
||||
}
|
||||
subdepartment_dict = subdepartments[None]
|
||||
|
||||
subdept_costs = subdepartment_dict.setdefault('costs', [])
|
||||
product = cost.product
|
||||
subdept_costs.append({
|
||||
'uuid': cost.uuid,
|
||||
'upc': str(product.upc),
|
||||
'upc_pretty': product.upc.pretty() if product.upc else None,
|
||||
'brand_name': product.brand.name if product.brand else None,
|
||||
'description': product.description,
|
||||
'size': product.size,
|
||||
'case_size': cost.case_size,
|
||||
'uom_display': "LB" if product.weighed else "EA",
|
||||
'vendor_item_code': cost.code,
|
||||
'preference': cost.preference,
|
||||
'preferred': cost.preference == 1,
|
||||
'unit_cost': cost.unit_cost,
|
||||
'unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost.unit_cost is not None else "",
|
||||
# TODO
|
||||
# 'cases_ordered': None,
|
||||
# 'units_ordered': None,
|
||||
# 'po_total': None,
|
||||
# 'po_total_display': None,
|
||||
})
|
||||
|
||||
# sort the (sub)department groupings
|
||||
sorted_departments = []
|
||||
for dept in sorted(departments.values(), key=lambda d: d['name']):
|
||||
dept['subdepartments'] = sorted(dept['subdepartments'].values(),
|
||||
key=lambda s: s['name'])
|
||||
sorted_departments.append(dept)
|
||||
|
||||
# fetch recent purchase history, sort/pad for template convenience
|
||||
history = self.batch_handler.get_order_form_history(batch, costs, 6)
|
||||
for i in range(6 - len(history)):
|
||||
history.append(None)
|
||||
history = list(reversed(history))
|
||||
# must convert some date objects to string, for JSON sake
|
||||
for h in history:
|
||||
if not h:
|
||||
continue
|
||||
purchase = h.get('purchase')
|
||||
if purchase:
|
||||
dt = purchase.get('date_ordered')
|
||||
if dt and isinstance(dt, datetime.date):
|
||||
purchase['date_ordered'] = app.render_date(dt)
|
||||
dt = purchase.get('date_received')
|
||||
if dt and isinstance(dt, datetime.date):
|
||||
purchase['date_received'] = app.render_date(dt)
|
||||
|
||||
return {
|
||||
'batch': self.normalize(batch),
|
||||
'departments': departments,
|
||||
'sorted_departments': sorted_departments,
|
||||
'history': history,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def defaults(cls, config):
|
||||
cls._defaults(config)
|
||||
cls._batch_defaults(config)
|
||||
cls._ordering_batch_defaults(config)
|
||||
|
||||
@classmethod
|
||||
def _ordering_batch_defaults(cls, config):
|
||||
route_prefix = cls.get_route_prefix()
|
||||
permission_prefix = cls.get_permission_prefix()
|
||||
object_url_prefix = cls.get_object_url_prefix()
|
||||
|
||||
# worksheet
|
||||
worksheet = Service(name='{}.worksheet'.format(route_prefix),
|
||||
path='{}/{{uuid}}/worksheet'.format(object_url_prefix))
|
||||
worksheet.add_view('GET', 'worksheet', klass=cls,
|
||||
permission='{}.worksheet'.format(permission_prefix))
|
||||
config.add_cornice_service(worksheet)
|
||||
|
||||
|
||||
class OrderingBatchRowViews(APIBatchRowView):
|
||||
|
||||
model_class = PurchaseBatchRow
|
||||
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
||||
route_prefix = 'ordering.rows'
|
||||
permission_prefix = 'ordering'
|
||||
collection_url_prefix = '/ordering-batch-rows'
|
||||
object_url_prefix = '/ordering-batch-row'
|
||||
supports_quick_entry = True
|
||||
editable = True
|
||||
|
||||
def normalize(self, row):
|
||||
data = super().normalize(row)
|
||||
app = self.get_rattail_app()
|
||||
batch = row.batch
|
||||
|
||||
data['item_id'] = row.item_id
|
||||
data['upc'] = str(row.upc)
|
||||
data['upc_pretty'] = row.upc.pretty() if row.upc else None
|
||||
data['brand_name'] = row.brand_name
|
||||
data['description'] = row.description
|
||||
data['size'] = row.size
|
||||
data['full_description'] = row.product.full_description if row.product else row.description
|
||||
|
||||
# # only provide image url if so configured
|
||||
# if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True):
|
||||
# data['image_url'] = pod.get_image_url(self.rattail_config, row.upc) if row.upc else None
|
||||
|
||||
# unit_uom can vary by product
|
||||
data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
|
||||
|
||||
data['case_quantity'] = row.case_quantity
|
||||
data['cases_ordered'] = row.cases_ordered
|
||||
data['units_ordered'] = row.units_ordered
|
||||
data['cases_ordered_display'] = app.render_quantity(row.cases_ordered or 0, empty_zero=False)
|
||||
data['units_ordered_display'] = app.render_quantity(row.units_ordered or 0, empty_zero=False)
|
||||
|
||||
data['po_unit_cost'] = row.po_unit_cost
|
||||
data['po_unit_cost_display'] = "${:0.2f}".format(row.po_unit_cost) if row.po_unit_cost is not None else None
|
||||
data['po_total_calculated'] = row.po_total_calculated
|
||||
data['po_total_calculated_display'] = "${:0.2f}".format(row.po_total_calculated) if row.po_total_calculated is not None else None
|
||||
data['status_code'] = row.status_code
|
||||
data['status_display'] = row.STATUS.get(row.status_code, str(row.status_code))
|
||||
|
||||
return data
|
||||
|
||||
def update_object(self, row, data):
|
||||
"""
|
||||
Overrides the default logic as follows:
|
||||
|
||||
So far, we only allow updating the ``cases_ordered`` and/or
|
||||
``units_ordered`` quantities; therefore ``data`` should have one or
|
||||
both of those keys.
|
||||
|
||||
This data is then passed to the
|
||||
:meth:`~rattail:rattail.batch.purchase.PurchaseBatchHandler.update_row_quantity()`
|
||||
method of the batch handler.
|
||||
|
||||
Note that the "normal" logic for this method is not invoked at all.
|
||||
"""
|
||||
if not self.batch_handler.is_mutable(row.batch):
|
||||
return {'error': "Batch is not mutable"}
|
||||
|
||||
try:
|
||||
self.batch_handler.update_row_quantity(row, **data)
|
||||
self.Session.flush()
|
||||
except Exception as error:
|
||||
log.warning("update_row_quantity failed", exc_info=True)
|
||||
if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'):
|
||||
error = str(error.orig)
|
||||
else:
|
||||
error = str(error)
|
||||
return {'error': error}
|
||||
|
||||
return row
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
OrderingBatchViews = kwargs.get('OrderingBatchViews', base['OrderingBatchViews'])
|
||||
OrderingBatchViews.defaults(config)
|
||||
|
||||
OrderingBatchRowViews = kwargs.get('OrderingBatchRowViews', base['OrderingBatchRowViews'])
|
||||
OrderingBatchRowViews.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
492
tailbone/api/batch/receiving.py
Normal file
492
tailbone/api/batch/receiving.py
Normal file
|
@ -0,0 +1,492 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Tailbone Web API - Receiving Batches
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import humanize
|
||||
import sqlalchemy as sa
|
||||
|
||||
from rattail.db.model import PurchaseBatch, PurchaseBatchRow
|
||||
|
||||
from cornice import Service
|
||||
from deform import widget as dfwidget
|
||||
|
||||
from tailbone import forms
|
||||
from tailbone.api.batch import APIBatchView, APIBatchRowView
|
||||
from tailbone.forms.receiving import ReceiveRow
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReceivingBatchViews(APIBatchView):
|
||||
|
||||
model_class = PurchaseBatch
|
||||
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
||||
route_prefix = 'receivingbatchviews'
|
||||
permission_prefix = 'receiving'
|
||||
collection_url_prefix = '/receiving-batches'
|
||||
object_url_prefix = '/receiving-batch'
|
||||
supports_toggle_complete = True
|
||||
supports_execute = True
|
||||
|
||||
def base_query(self):
|
||||
model = self.app.model
|
||||
query = super().base_query()
|
||||
query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)
|
||||
return query
|
||||
|
||||
def normalize(self, batch):
|
||||
data = super().normalize(batch)
|
||||
|
||||
data['vendor_uuid'] = batch.vendor.uuid
|
||||
data['vendor_display'] = str(batch.vendor)
|
||||
|
||||
data['department_uuid'] = batch.department_uuid
|
||||
data['department_display'] = str(batch.department) if batch.department else None
|
||||
|
||||
data['po_number'] = batch.po_number
|
||||
data['po_total'] = batch.po_total
|
||||
data['invoice_total'] = batch.invoice_total
|
||||
data['invoice_total_calculated'] = batch.invoice_total_calculated
|
||||
|
||||
data['can_auto_receive'] = self.batch_handler.can_auto_receive(batch)
|
||||
|
||||
return data
|
||||
|
||||
def create_object(self, data):
|
||||
data = dict(data)
|
||||
|
||||
# all about receiving mode here
|
||||
data['mode'] = self.enum.PURCHASE_BATCH_MODE_RECEIVING
|
||||
|
||||
# assume "receive from PO" if given a PO key
|
||||
if data.get('purchase_key'):
|
||||
data['workflow'] = 'from_po'
|
||||
|
||||
return super().create_object(data)
|
||||
|
||||
def auto_receive(self):
|
||||
"""
|
||||
View which handles auto-marking as received, all items within
|
||||
a pending batch.
|
||||
"""
|
||||
batch = self.get_object()
|
||||
self.batch_handler.auto_receive_all_items(batch)
|
||||
return self._get(obj=batch)
|
||||
|
||||
def mark_receiving_complete(self):
|
||||
"""
|
||||
Mark the given batch as "receiving complete".
|
||||
"""
|
||||
batch = self.get_object()
|
||||
|
||||
if batch.executed:
|
||||
return {'error': "Batch {} has already been executed: {}".format(
|
||||
batch.id_str, batch.description)}
|
||||
|
||||
if batch.complete:
|
||||
return {'error': "Batch {} is already marked complete: {}".format(
|
||||
batch.id_str, batch.description)}
|
||||
|
||||
if batch.receiving_complete:
|
||||
return {'error': "Receiving is already complete for batch {}: {}".format(
|
||||
batch.id_str, batch.description)}
|
||||
|
||||
batch.receiving_complete = True
|
||||
return self._get(obj=batch)
|
||||
|
||||
def eligible_purchases(self):
|
||||
model = self.app.model
|
||||
uuid = self.request.params.get('vendor_uuid')
|
||||
vendor = self.Session.get(model.Vendor, uuid) if uuid else None
|
||||
if not vendor:
|
||||
return {'error': "Vendor not found"}
|
||||
|
||||
purchases = self.batch_handler.get_eligible_purchases(
|
||||
vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING)
|
||||
|
||||
purchases = [self.normalize_eligible_purchase(p)
|
||||
for p in purchases]
|
||||
|
||||
return {'purchases': purchases}
|
||||
|
||||
def normalize_eligible_purchase(self, purchase):
|
||||
return self.batch_handler.normalize_eligible_purchase(purchase)
|
||||
|
||||
def render_eligible_purchase(self, purchase):
|
||||
return self.batch_handler.render_eligible_purchase(purchase)
|
||||
|
||||
@classmethod
|
||||
def defaults(cls, config):
|
||||
cls._defaults(config)
|
||||
cls._batch_defaults(config)
|
||||
cls._receiving_batch_defaults(config)
|
||||
|
||||
@classmethod
|
||||
def _receiving_batch_defaults(cls, config):
|
||||
route_prefix = cls.get_route_prefix()
|
||||
permission_prefix = cls.get_permission_prefix()
|
||||
collection_url_prefix = cls.get_collection_url_prefix()
|
||||
object_url_prefix = cls.get_object_url_prefix()
|
||||
|
||||
# auto_receive
|
||||
auto_receive = Service(name='{}.auto_receive'.format(route_prefix),
|
||||
path='{}/{{uuid}}/auto-receive'.format(object_url_prefix))
|
||||
auto_receive.add_view('GET', 'auto_receive', klass=cls,
|
||||
permission='{}.auto_receive'.format(permission_prefix))
|
||||
config.add_cornice_service(auto_receive)
|
||||
|
||||
# mark_receiving_complete
|
||||
mark_receiving_complete = Service(name='{}.mark_receiving_complete'.format(route_prefix),
|
||||
path='{}/{{uuid}}/mark-receiving-complete'.format(object_url_prefix))
|
||||
mark_receiving_complete.add_view('POST', 'mark_receiving_complete', klass=cls,
|
||||
permission='{}.edit'.format(permission_prefix))
|
||||
config.add_cornice_service(mark_receiving_complete)
|
||||
|
||||
# eligible purchases
|
||||
eligible_purchases = Service(name='{}.eligible_purchases'.format(route_prefix),
|
||||
path='{}/eligible-purchases'.format(collection_url_prefix))
|
||||
eligible_purchases.add_view('GET', 'eligible_purchases', klass=cls,
|
||||
permission='{}.create'.format(permission_prefix))
|
||||
config.add_cornice_service(eligible_purchases)
|
||||
|
||||
|
||||
class ReceivingBatchRowViews(APIBatchRowView):
|
||||
|
||||
model_class = PurchaseBatchRow
|
||||
default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
|
||||
route_prefix = 'receiving.rows'
|
||||
permission_prefix = 'receiving'
|
||||
collection_url_prefix = '/receiving-batch-rows'
|
||||
object_url_prefix = '/receiving-batch-row'
|
||||
supports_quick_entry = True
|
||||
|
||||
def make_filter_spec(self):
|
||||
model = self.app.model
|
||||
filters = super().make_filter_spec()
|
||||
if filters:
|
||||
|
||||
# must translate certain convenience filters
|
||||
orig_filters, filters = filters, []
|
||||
for filtr in orig_filters:
|
||||
|
||||
# # is_received
|
||||
# # NOTE: this is only relevant for truck dump or "from scratch"
|
||||
# if filtr['field'] == 'is_received' and filtr['op'] == 'eq' and filtr['value'] is True:
|
||||
# filters.extend([
|
||||
# {'or': [
|
||||
# {'field': 'cases_received', 'op': '!=', 'value': 0},
|
||||
# {'field': 'units_received', 'op': '!=', 'value': 0},
|
||||
# ]},
|
||||
# ])
|
||||
|
||||
# is_incomplete
|
||||
if filtr['field'] == 'is_incomplete' and filtr['op'] == 'eq' and filtr['value'] is True:
|
||||
# looking for any rows with "ordered" quantity, but where the
|
||||
# status does *not* signify a "settled" row so to speak
|
||||
# TODO: would be nice if we had a simple flag to leverage?
|
||||
filters.extend([
|
||||
{'or': [
|
||||
{'field': 'cases_ordered', 'op': '!=', 'value': 0},
|
||||
{'field': 'units_ordered', 'op': '!=', 'value': 0},
|
||||
]},
|
||||
{'field': 'status_code', 'op': 'not_in', 'value': [
|
||||
model.PurchaseBatchRow.STATUS_OK,
|
||||
model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND,
|
||||
model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS,
|
||||
]},
|
||||
])
|
||||
|
||||
# is_invalid
|
||||
elif filtr['field'] == 'is_invalid' and filtr['op'] == 'eq' and filtr['value'] is True:
|
||||
filters.extend([
|
||||
{'field': 'status_code', 'op': 'in', 'value': [
|
||||
model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND,
|
||||
model.PurchaseBatchRow.STATUS_COST_NOT_FOUND,
|
||||
model.PurchaseBatchRow.STATUS_CASE_QUANTITY_UNKNOWN,
|
||||
model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS,
|
||||
]},
|
||||
])
|
||||
|
||||
# is_unexpected
|
||||
elif filtr['field'] == 'is_unexpected' and filtr['op'] == 'eq' and filtr['value'] is True:
|
||||
# looking for any rows which do *not* have "ordered/shipped" quantity
|
||||
filters.extend([
|
||||
{'and': [
|
||||
{'or': [
|
||||
{'field': 'cases_ordered', 'op': 'is_null'},
|
||||
{'field': 'cases_ordered', 'op': '==', 'value': 0},
|
||||
]},
|
||||
{'or': [
|
||||
{'field': 'units_ordered', 'op': 'is_null'},
|
||||
{'field': 'units_ordered', 'op': '==', 'value': 0},
|
||||
]},
|
||||
{'or': [
|
||||
{'field': 'cases_shipped', 'op': 'is_null'},
|
||||
{'field': 'cases_shipped', 'op': '==', 'value': 0},
|
||||
]},
|
||||
{'or': [
|
||||
{'field': 'units_shipped', 'op': 'is_null'},
|
||||
{'field': 'units_shipped', 'op': '==', 'value': 0},
|
||||
]},
|
||||
{'or': [
|
||||
# but "unexpected" also implies we have some confirmed amount(s)
|
||||
{'field': 'cases_received', 'op': '!=', 'value': 0},
|
||||
{'field': 'units_received', 'op': '!=', 'value': 0},
|
||||
{'field': 'cases_damaged', 'op': '!=', 'value': 0},
|
||||
{'field': 'units_damaged', 'op': '!=', 'value': 0},
|
||||
{'field': 'cases_expired', 'op': '!=', 'value': 0},
|
||||
{'field': 'units_expired', 'op': '!=', 'value': 0},
|
||||
]},
|
||||
]},
|
||||
])
|
||||
|
||||
# is_damaged
|
||||
elif filtr['field'] == 'is_damaged' and filtr['op'] == 'eq' and filtr['value'] is True:
|
||||
filters.extend([
|
||||
{'or': [
|
||||
{'field': 'cases_damaged', 'op': '!=', 'value': 0},
|
||||
{'field': 'units_damaged', 'op': '!=', 'value': 0},
|
||||
]},
|
||||
])
|
||||
|
||||
# is_expired
|
||||
elif filtr['field'] == 'is_expired' and filtr['op'] == 'eq' and filtr['value'] is True:
|
||||
filters.extend([
|
||||
{'or': [
|
||||
{'field': 'cases_expired', 'op': '!=', 'value': 0},
|
||||
{'field': 'units_expired', 'op': '!=', 'value': 0},
|
||||
]},
|
||||
])
|
||||
|
||||
# is_missing
|
||||
elif filtr['field'] == 'is_missing' and filtr['op'] == 'eq' and filtr['value'] is True:
|
||||
filters.extend([
|
||||
{'or': [
|
||||
{'field': 'cases_missing', 'op': '!=', 'value': 0},
|
||||
{'field': 'units_missing', 'op': '!=', 'value': 0},
|
||||
]},
|
||||
])
|
||||
|
||||
else: # just some filter, use as-is
|
||||
filters.append(filtr)
|
||||
|
||||
return filters
|
||||
|
||||
def normalize(self, row):
|
||||
data = super().normalize(row)
|
||||
model = self.app.model
|
||||
|
||||
batch = row.batch
|
||||
prodder = self.app.get_products_handler()
|
||||
|
||||
data['product_uuid'] = row.product_uuid
|
||||
data['item_id'] = row.item_id
|
||||
data['upc'] = str(row.upc)
|
||||
data['upc_pretty'] = row.upc.pretty() if row.upc else None
|
||||
data['brand_name'] = row.brand_name
|
||||
data['description'] = row.description
|
||||
data['size'] = row.size
|
||||
data['full_description'] = row.product.full_description if row.product else row.description
|
||||
|
||||
# only provide image url if so configured
|
||||
if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True):
|
||||
data['image_url'] = prodder.get_image_url(product=row.product, upc=row.upc)
|
||||
|
||||
# unit_uom can vary by product
|
||||
data['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
|
||||
|
||||
data['case_quantity'] = row.case_quantity
|
||||
data['order_quantities_known'] = batch.order_quantities_known
|
||||
|
||||
data['cases_ordered'] = row.cases_ordered
|
||||
data['units_ordered'] = row.units_ordered
|
||||
|
||||
data['cases_shipped'] = row.cases_shipped
|
||||
data['units_shipped'] = row.units_shipped
|
||||
|
||||
data['cases_received'] = row.cases_received
|
||||
data['units_received'] = row.units_received
|
||||
|
||||
data['cases_damaged'] = row.cases_damaged
|
||||
data['units_damaged'] = row.units_damaged
|
||||
|
||||
data['cases_expired'] = row.cases_expired
|
||||
data['units_expired'] = row.units_expired
|
||||
|
||||
data['cases_missing'] = row.cases_missing
|
||||
data['units_missing'] = row.units_missing
|
||||
|
||||
cases, units = self.batch_handler.get_unconfirmed_counts(row)
|
||||
data['cases_unconfirmed'] = cases
|
||||
data['units_unconfirmed'] = units
|
||||
|
||||
data['po_unit_cost'] = row.po_unit_cost
|
||||
data['po_total'] = row.po_total
|
||||
|
||||
data['invoice_number'] = row.invoice_number
|
||||
data['invoice_unit_cost'] = row.invoice_unit_cost
|
||||
data['invoice_total'] = row.invoice_total
|
||||
data['invoice_total_calculated'] = row.invoice_total_calculated
|
||||
|
||||
data['allow_cases'] = self.batch_handler.allow_cases()
|
||||
|
||||
data['quick_receive'] = self.rattail_config.getbool(
|
||||
'rattail.batch', 'purchase.mobile_quick_receive',
|
||||
default=True)
|
||||
|
||||
if batch.order_quantities_known:
|
||||
data['quick_receive_all'] = self.rattail_config.getbool(
|
||||
'rattail.batch', 'purchase.mobile_quick_receive_all',
|
||||
default=False)
|
||||
|
||||
# TODO: this was copied from regular view receive_row() method; should merge
|
||||
if data['quick_receive'] and data.get('quick_receive_all'):
|
||||
if data['allow_cases']:
|
||||
data['quick_receive_uom'] = 'CS'
|
||||
raise NotImplementedError("TODO: add CS support for quick_receive_all")
|
||||
else:
|
||||
data['quick_receive_uom'] = data['unit_uom']
|
||||
accounted_for = self.batch_handler.get_units_accounted_for(row)
|
||||
remainder = self.batch_handler.get_units_ordered(row) - accounted_for
|
||||
|
||||
if accounted_for:
|
||||
# some product accounted for; button should receive "remainder" only
|
||||
if remainder:
|
||||
remainder = self.app.render_quantity(remainder)
|
||||
data['quick_receive_quantity'] = remainder
|
||||
data['quick_receive_text'] = "Receive Remainder ({} {})".format(
|
||||
remainder, data['unit_uom'])
|
||||
else:
|
||||
# unless there is no remainder, in which case disable it
|
||||
data['quick_receive'] = False
|
||||
|
||||
else: # nothing yet accounted for, button should receive "all"
|
||||
if not remainder:
|
||||
log.warning("quick receive remainder is empty for row %s", row.uuid)
|
||||
remainder = self.app.render_quantity(remainder)
|
||||
data['quick_receive_quantity'] = remainder
|
||||
data['quick_receive_text'] = "Receive ALL ({} {})".format(
|
||||
remainder, data['unit_uom'])
|
||||
|
||||
data['unexpected_alert'] = None
|
||||
if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
|
||||
warn = True
|
||||
if batch.is_truck_dump_parent() and row.product:
|
||||
uuids = [child.uuid for child in batch.truck_dump_children]
|
||||
if uuids:
|
||||
count = self.Session.query(model.PurchaseBatchRow)\
|
||||
.filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
|
||||
.filter(model.PurchaseBatchRow.product == row.product)\
|
||||
.count()
|
||||
if count:
|
||||
warn = False
|
||||
if warn:
|
||||
data['unexpected_alert'] = "This item was NOT on the original purchase order."
|
||||
|
||||
# TODO: surely the caller of API should determine this flag?
|
||||
# maybe alert user if they've already received some of this product
|
||||
alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received',
|
||||
default=False)
|
||||
if alert_received:
|
||||
data['received_alert'] = None
|
||||
if self.batch_handler.get_units_confirmed(row):
|
||||
msg = "You have already received some of this product; last update was {}.".format(
|
||||
humanize.naturaltime(self.app.make_utc() - row.modified))
|
||||
data['received_alert'] = msg
|
||||
|
||||
return data
|
||||
|
||||
def receive(self):
|
||||
"""
|
||||
View which handles "receiving" against a particular batch row.
|
||||
"""
|
||||
model = self.app.model
|
||||
|
||||
# first do basic input validation
|
||||
schema = ReceiveRow().bind(session=self.Session())
|
||||
form = forms.Form(schema=schema, request=self.request)
|
||||
# TODO: this seems hacky, but avoids "complex" date value parsing
|
||||
form.set_widget('expiration_date', dfwidget.TextInputWidget())
|
||||
if not form.validate():
|
||||
log.warning("form did not validate: %s",
|
||||
form.make_deform_form().error)
|
||||
return {'error': "Form did not validate"}
|
||||
|
||||
# fetch / validate row object
|
||||
row = self.Session.get(model.PurchaseBatchRow, form.validated['row'])
|
||||
if row is not self.get_object():
|
||||
return {'error': "Specified row does not match the route!"}
|
||||
|
||||
# handler takes care of the row receiving logic for us
|
||||
kwargs = dict(form.validated)
|
||||
del kwargs['row']
|
||||
try:
|
||||
self.batch_handler.receive_row(row, **kwargs)
|
||||
self.Session.flush()
|
||||
except Exception as error:
|
||||
log.warning("receive() failed", exc_info=True)
|
||||
if isinstance(error, sa.exc.DataError) and hasattr(error, 'orig'):
|
||||
error = str(error.orig)
|
||||
else:
|
||||
error = str(error)
|
||||
return {'error': error}
|
||||
|
||||
return self._get(obj=row)
|
||||
|
||||
@classmethod
|
||||
def defaults(cls, config):
|
||||
cls._defaults(config)
|
||||
cls._batch_row_defaults(config)
|
||||
cls._receiving_batch_row_defaults(config)
|
||||
|
||||
@classmethod
|
||||
def _receiving_batch_row_defaults(cls, config):
|
||||
route_prefix = cls.get_route_prefix()
|
||||
permission_prefix = cls.get_permission_prefix()
|
||||
object_url_prefix = cls.get_object_url_prefix()
|
||||
|
||||
# receive (row)
|
||||
receive = Service(name='{}.receive'.format(route_prefix),
|
||||
path='{}/{{uuid}}/receive'.format(object_url_prefix))
|
||||
receive.add_view('POST', 'receive', klass=cls,
|
||||
permission='{}.edit_row'.format(permission_prefix))
|
||||
config.add_cornice_service(receive)
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
ReceivingBatchViews = kwargs.get('ReceivingBatchViews', base['ReceivingBatchViews'])
|
||||
ReceivingBatchViews.defaults(config)
|
||||
|
||||
ReceivingBatchRowViews = kwargs.get('ReceivingBatchRowViews', base['ReceivingBatchRowViews'])
|
||||
ReceivingBatchRowViews.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
159
tailbone/api/common.py
Normal file
159
tailbone/api/common.py
Normal file
|
@ -0,0 +1,159 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Tailbone Web API - "Common" Views
|
||||
"""
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from rattail.util import get_pkg_version
|
||||
|
||||
from cornice import Service
|
||||
from cornice.service import get_services
|
||||
from cornice_swagger import CorniceSwagger
|
||||
|
||||
from tailbone import forms
|
||||
from tailbone.forms.common import Feedback
|
||||
from tailbone.api import APIView, api
|
||||
from tailbone.db import Session
|
||||
|
||||
|
||||
class CommonView(APIView):
|
||||
"""
|
||||
Misc. "common" views for the API.
|
||||
|
||||
.. attribute:: feedback_email_key
|
||||
|
||||
This is the email key which will be used when sending "user feedback"
|
||||
email. Default value is ``'user_feedback'``.
|
||||
"""
|
||||
feedback_email_key = 'user_feedback'
|
||||
|
||||
@api
|
||||
def about(self):
|
||||
"""
|
||||
Generic view to show "about project" info page.
|
||||
"""
|
||||
packages = self.get_packages()
|
||||
return {
|
||||
'project_title': self.get_project_title(),
|
||||
'project_version': self.get_project_version(),
|
||||
'packages': packages,
|
||||
'package_names': list(packages),
|
||||
}
|
||||
|
||||
def get_project_title(self):
|
||||
app = self.get_rattail_app()
|
||||
return app.get_title()
|
||||
|
||||
def get_project_version(self):
|
||||
app = self.get_rattail_app()
|
||||
return app.get_version()
|
||||
|
||||
def get_packages(self):
|
||||
"""
|
||||
Should return the full set of packages which should be displayed on the
|
||||
'about' page.
|
||||
"""
|
||||
return OrderedDict([
|
||||
('rattail', get_pkg_version('rattail')),
|
||||
('Tailbone', get_pkg_version('Tailbone')),
|
||||
])
|
||||
|
||||
@api
|
||||
def feedback(self):
|
||||
"""
|
||||
View to handle user feedback form submits.
|
||||
"""
|
||||
app = self.get_rattail_app()
|
||||
model = self.model
|
||||
# TODO: this logic was copied from tailbone.views.common and is largely
|
||||
# identical; perhaps should merge somehow?
|
||||
schema = Feedback().bind(session=Session())
|
||||
form = forms.Form(schema=schema, request=self.request)
|
||||
if form.validate():
|
||||
data = dict(form.validated)
|
||||
|
||||
# figure out who the sending user is, if any
|
||||
if self.request.user:
|
||||
data['user'] = self.request.user
|
||||
elif data['user']:
|
||||
data['user'] = Session.get(model.User, data['user'])
|
||||
|
||||
# TODO: should provide URL to view user
|
||||
if data['user']:
|
||||
data['user_url'] = '#' # TODO: could get from config?
|
||||
|
||||
data['client_ip'] = self.request.client_addr
|
||||
email_key = data['email_key'] or self.feedback_email_key
|
||||
app.send_email(email_key, data=data)
|
||||
return {'ok': True}
|
||||
|
||||
return {'error': "Form did not validate!"}
|
||||
|
||||
def swagger(self):
|
||||
doc = CorniceSwagger(get_services())
|
||||
app = self.get_rattail_app()
|
||||
spec = doc.generate(f"{app.get_node_title()} API docs",
|
||||
app.get_version(),
|
||||
base_path='/api') # TODO
|
||||
return spec
|
||||
|
||||
@classmethod
|
||||
def defaults(cls, config):
|
||||
cls._common_defaults(config)
|
||||
|
||||
@classmethod
|
||||
def _common_defaults(cls, config):
|
||||
rattail_config = config.registry.settings.get('rattail_config')
|
||||
app = rattail_config.get_app()
|
||||
|
||||
# about
|
||||
about = Service(name='about', path='/about')
|
||||
about.add_view('GET', 'about', klass=cls)
|
||||
config.add_cornice_service(about)
|
||||
|
||||
# feedback
|
||||
feedback = Service(name='feedback', path='/feedback')
|
||||
feedback.add_view('POST', 'feedback', klass=cls,
|
||||
permission='common.feedback')
|
||||
config.add_cornice_service(feedback)
|
||||
|
||||
# swagger
|
||||
swagger = Service(name='swagger',
|
||||
path='/swagger.json',
|
||||
description=f"OpenAPI documentation for {app.get_title()}")
|
||||
swagger.add_view('GET', 'swagger', klass=cls,
|
||||
permission='common.api_swagger')
|
||||
config.add_cornice_service(swagger)
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
CommonView = kwargs.get('CommonView', base['CommonView'])
|
||||
CommonView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2018 Lance Edgar
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,8 +24,6 @@
|
|||
Tailbone Web API - Core Views
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
from tailbone.views import View
|
||||
|
||||
|
||||
|
@ -63,3 +61,65 @@ class APIView(View):
|
|||
"""
|
||||
Base class for all API views.
|
||||
"""
|
||||
|
||||
def pretty_datetime(self, dt):
|
||||
if not dt:
|
||||
return ""
|
||||
return dt.strftime('%Y-%m-%d @ %I:%M %p')
|
||||
|
||||
def get_user_info(self, user):
|
||||
"""
|
||||
This method is present on *all* API views, and is meant to provide a
|
||||
single means of obtaining "common" user info, for return to the caller.
|
||||
Such info may be returned in several places, e.g. upon login but also
|
||||
in the "check session" call, or e.g. as part of a broader return value
|
||||
from any other call.
|
||||
|
||||
:returns: Dictionary of user info data, ready for JSON serialization.
|
||||
|
||||
Note that you should *not* (usually) override this method in any view,
|
||||
but instead configure a "supplemental" function which can then add or
|
||||
replace info entries. Config for that looks like e.g.:
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
[tailbone.api]
|
||||
extra_user_info = poser.web.api.util:extra_user_info
|
||||
|
||||
Note that the above config assumes a simple *function* defined in your
|
||||
``util`` module; such a function would look like e.g.::
|
||||
|
||||
def extra_user_info(request, user, **info):
|
||||
# add favorite color
|
||||
info['favorite_color'] = 'green'
|
||||
# override display name
|
||||
info['display_name'] = "TODO"
|
||||
# remove short_name
|
||||
info.pop('short_name', None)
|
||||
return info
|
||||
"""
|
||||
app = self.get_rattail_app()
|
||||
auth = app.get_auth_handler()
|
||||
|
||||
# basic / default info
|
||||
is_admin = auth.user_is_admin(user)
|
||||
employee = app.get_employee(user)
|
||||
info = {
|
||||
'uuid': user.uuid,
|
||||
'username': user.username,
|
||||
'display_name': user.display_name,
|
||||
'short_name': auth.get_short_display_name(user),
|
||||
'is_admin': is_admin,
|
||||
'is_root': is_admin and self.request.session.get('is_root', False),
|
||||
'employee_uuid': employee.uuid if employee else None,
|
||||
'email_address': app.get_contact_email_address(user),
|
||||
}
|
||||
|
||||
# maybe get/use "extra" info
|
||||
extra = self.rattail_config.get('tailbone.api', 'extra_user_info',
|
||||
usedb=False)
|
||||
if extra:
|
||||
extra = app.load_object(extra)
|
||||
info = extra(self.request, user, **info)
|
||||
|
||||
return info
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2018 Lance Edgar
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,34 +24,37 @@
|
|||
Tailbone Web API - Customer Views
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
from rattail.db import model
|
||||
|
||||
from cornice.resource import resource, view
|
||||
|
||||
from tailbone.api import APIMasterView
|
||||
|
||||
|
||||
@resource(collection_path='/customers', path='/customer/{uuid}')
|
||||
class CustomerView(APIMasterView):
|
||||
|
||||
"""
|
||||
API views for Customer data
|
||||
"""
|
||||
model_class = model.Customer
|
||||
collection_url_prefix = '/customers'
|
||||
object_url_prefix = '/customer'
|
||||
supports_autocomplete = True
|
||||
autocomplete_fieldname = 'name'
|
||||
|
||||
def normalize(self, customer):
|
||||
return {
|
||||
'uuid': customer.uuid,
|
||||
'_str': str(customer),
|
||||
'id': customer.id,
|
||||
'number': customer.number,
|
||||
'name': customer.name,
|
||||
}
|
||||
|
||||
@view(permission='customers.list')
|
||||
def collection_get(self):
|
||||
return self._collection_get()
|
||||
|
||||
@view(permission='customers.view')
|
||||
def get(self):
|
||||
return self._get()
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
CustomerView = kwargs.get('CustomerView', base['CustomerView'])
|
||||
CustomerView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
config.scan(__name__)
|
||||
defaults(config)
|
||||
|
|
36
tailbone/api/essentials.py
Normal file
36
tailbone/api/essentials.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2023 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Essential views for convenient includes
|
||||
"""
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
mod = lambda spec: kwargs.get(spec, spec)
|
||||
|
||||
config.include(mod('tailbone.api.auth'))
|
||||
config.include(mod('tailbone.api.common'))
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
|
@ -1,8 +1,8 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2017 Lance Edgar
|
||||
# Copyright © 2010-2023 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -21,25 +21,31 @@
|
|||
#
|
||||
################################################################################
|
||||
"""
|
||||
Pyramid scaffold templates
|
||||
Tailbone Web API - Label Views
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
from rattail.files import resource_path
|
||||
from rattail.util import prettify
|
||||
from rattail.db.model import LabelProfile
|
||||
|
||||
from pyramid.scaffolds import PyramidTemplate
|
||||
from tailbone.api import APIMasterView
|
||||
|
||||
|
||||
class RattailTemplate(PyramidTemplate):
|
||||
_template_dir = resource_path('rattail:data/project')
|
||||
summary = "Starter project based on Rattail / Tailbone"
|
||||
class LabelProfileView(APIMasterView):
|
||||
"""
|
||||
API views for Label Profile data
|
||||
"""
|
||||
model_class = LabelProfile
|
||||
collection_url_prefix = '/label-profiles'
|
||||
object_url_prefix = '/label-profile'
|
||||
|
||||
def pre(self, command, output_dir, vars):
|
||||
"""
|
||||
Adds some more variables to the template context.
|
||||
"""
|
||||
vars['project_title'] = prettify(vars['project'])
|
||||
vars['package_title'] = vars['package'].capitalize()
|
||||
return super(RattailTemplate, self).pre(command, output_dir, vars)
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
LabelProfileView = kwargs.get('LabelProfileView', base['LabelProfileView'])
|
||||
LabelProfileView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2018 Lance Edgar
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,18 +24,29 @@
|
|||
Tailbone Web API - Master View
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
import json
|
||||
|
||||
from paginate_sqlalchemy import SqlalchemyOrmPage
|
||||
from rattail.db.util import get_fieldnames
|
||||
|
||||
from tailbone.api import APIView, api
|
||||
from cornice import resource, Service
|
||||
|
||||
from tailbone.api import APIView
|
||||
from tailbone.db import Session
|
||||
from tailbone.util import SortColumn
|
||||
|
||||
|
||||
class APIMasterView(APIView):
|
||||
"""
|
||||
Base class for data model REST API views.
|
||||
"""
|
||||
listable = True
|
||||
creatable = True
|
||||
viewable = True
|
||||
editable = True
|
||||
deletable = True
|
||||
supports_autocomplete = False
|
||||
supports_download = False
|
||||
supports_rawbytes = False
|
||||
|
||||
@property
|
||||
def Session(self):
|
||||
|
@ -53,6 +64,51 @@ class APIMasterView(APIView):
|
|||
return cls.normalized_model_name
|
||||
return cls.get_model_class().__name__.lower()
|
||||
|
||||
@classmethod
|
||||
def get_route_prefix(cls):
|
||||
"""
|
||||
Returns a prefix which (by default) applies to all routes provided by
|
||||
this view class.
|
||||
"""
|
||||
prefix = getattr(cls, 'route_prefix', None)
|
||||
if prefix:
|
||||
return prefix
|
||||
model_name = cls.get_normalized_model_name()
|
||||
return '{}s'.format(model_name)
|
||||
|
||||
@classmethod
|
||||
def get_permission_prefix(cls):
|
||||
"""
|
||||
Returns a prefix which (by default) applies to all permissions
|
||||
leveraged by this view class.
|
||||
"""
|
||||
prefix = getattr(cls, 'permission_prefix', None)
|
||||
if prefix:
|
||||
return prefix
|
||||
return cls.get_route_prefix()
|
||||
|
||||
@classmethod
|
||||
def get_collection_url_prefix(cls):
|
||||
"""
|
||||
Returns a prefix which (by default) applies to all "collection" URLs
|
||||
provided by this view class.
|
||||
"""
|
||||
prefix = getattr(cls, 'collection_url_prefix', None)
|
||||
if prefix:
|
||||
return prefix
|
||||
return '/{}'.format(cls.get_route_prefix())
|
||||
|
||||
@classmethod
|
||||
def get_object_url_prefix(cls):
|
||||
"""
|
||||
Returns a prefix which (by default) applies to all "object" URLs
|
||||
provided by this view class.
|
||||
"""
|
||||
prefix = getattr(cls, 'object_url_prefix', None)
|
||||
if prefix:
|
||||
return prefix
|
||||
return '/{}'.format(cls.get_route_prefix())
|
||||
|
||||
@classmethod
|
||||
def get_object_key(cls):
|
||||
if hasattr(cls, 'object_key'):
|
||||
|
@ -65,32 +121,498 @@ class APIMasterView(APIView):
|
|||
return cls.collection_key
|
||||
return '{}s'.format(cls.get_object_key())
|
||||
|
||||
def _collection_get(self):
|
||||
cls = self.get_model_class()
|
||||
objects = self.Session.query(cls)
|
||||
@classmethod
|
||||
def establish_method(cls, method_name):
|
||||
"""
|
||||
Establish the given HTTP method for this Cornice Resource.
|
||||
|
||||
sort = self.request.params.get('sort')
|
||||
if sort:
|
||||
# TODO: this is fragile, but what to do if bad params?
|
||||
Cornice will auto-register any class methods for a resource, if they
|
||||
are named according to what it expects (i.e. 'get', 'collection_get'
|
||||
etc.). Tailbone API tries to make things automagical for the sake of
|
||||
e.g. Poser logic, but in this case if we predefine all of these methods
|
||||
and then some subclass view wants to *not* allow one, it's not clear
|
||||
how to "undefine" it per se. Or at least, the more straightforward
|
||||
thing (I think) is to not define such a method in the first place, if
|
||||
it was not wanted.
|
||||
|
||||
Enter ``establish_method()``, which is what finally "defines" each
|
||||
resource method according to what the subclass has declared via its
|
||||
various attributes (:attr:`creatable`, :attr:`deletable` etc.).
|
||||
|
||||
Note that you will not likely have any need to use this
|
||||
``establish_method()`` yourself! But we describe its purpose here, for
|
||||
clarity.
|
||||
"""
|
||||
def method(self):
|
||||
internal_method = getattr(self, '_{}'.format(method_name))
|
||||
return internal_method()
|
||||
|
||||
setattr(cls, method_name, method)
|
||||
|
||||
def make_filter_spec(self):
|
||||
if not self.request.GET.has_key('filters'):
|
||||
return []
|
||||
|
||||
filters = json.loads(self.request.GET.getone('filters'))
|
||||
return filters
|
||||
|
||||
def make_sort_spec(self):
|
||||
|
||||
# we prefer a "native sort"
|
||||
if self.request.GET.has_key('nativeSort'):
|
||||
return json.loads(self.request.GET.getone('nativeSort'))
|
||||
|
||||
# these params are based on 'vuetable-2'
|
||||
# https://www.vuetable.com/guide/sorting.html#initial-sorting-order
|
||||
if 'sort' in self.request.params:
|
||||
sort = self.request.params['sort']
|
||||
sortkey, sortdir = sort.split('|')
|
||||
sortkey = getattr(cls, sortkey)
|
||||
objects = objects.order_by(getattr(sortkey, sortdir)())
|
||||
if sortdir != 'desc':
|
||||
sortdir = 'asc'
|
||||
return [
|
||||
{
|
||||
# 'model': self.model_class.__name__,
|
||||
'field': sortkey,
|
||||
'direction': sortdir,
|
||||
},
|
||||
]
|
||||
|
||||
# NOTE: we only page results if sorting is in effect, otherwise
|
||||
# record sequence is "non-determinant" (is that the word?)
|
||||
page = self.request.params.get('page')
|
||||
per_page = self.request.params.get('per_page')
|
||||
# these params are based on 'vue-tables-2'
|
||||
# https://github.com/matfish2/vue-tables-2#server-side
|
||||
if 'orderBy' in self.request.params and 'ascending' in self.request.params:
|
||||
sortcol = self.interpret_sortcol(self.request.params['orderBy'])
|
||||
if sortcol:
|
||||
spec = {
|
||||
'field': sortcol.field_name,
|
||||
'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc',
|
||||
}
|
||||
if sortcol.model_name:
|
||||
spec['model'] = sortcol.model_name
|
||||
return [spec]
|
||||
|
||||
def interpret_sortcol(self, order_by):
|
||||
"""
|
||||
This must return a ``SortColumn`` object based on parsing of the given
|
||||
``order_by`` string, which is "raw" as received from the client.
|
||||
|
||||
Please override as necessary, but in all cases you should invoke
|
||||
:meth:`sortcol()` to obtain your return value. Default behavior
|
||||
for this method is to simply do (only) that::
|
||||
|
||||
return self.sortcol(order_by)
|
||||
|
||||
Note that you can also return ``None`` here, if the given ``order_by``
|
||||
string does not represent a valid sort.
|
||||
"""
|
||||
return self.sortcol(order_by)
|
||||
|
||||
def sortcol(self, field_name, model_name=None):
|
||||
"""
|
||||
Return a simple ``SortColumn`` object which denotes the field and
|
||||
optionally, the model, to be used when sorting.
|
||||
"""
|
||||
if not model_name:
|
||||
model_name = self.model_class.__name__
|
||||
return SortColumn(field_name, model_name)
|
||||
|
||||
def join_for_sort_spec(self, query, sort_spec):
|
||||
"""
|
||||
This should apply any joins needed on the given query, to accommodate
|
||||
requested sorting as per ``sort_spec`` - which will be non-empty but
|
||||
otherwise no claims are made regarding its contents.
|
||||
|
||||
Please override as necessary, but in all cases you should return a
|
||||
query, either untouched or else with join(s) applied.
|
||||
"""
|
||||
model_name = sort_spec[0].get('model')
|
||||
return self.join_for_sort_model(query, model_name)
|
||||
|
||||
def join_for_sort_model(self, query, model_name):
|
||||
"""
|
||||
This should apply any joins needed on the given query, to accommodate
|
||||
requested sorting on a field associated with the given model.
|
||||
|
||||
Please override as necessary, but in all cases you should return a
|
||||
query, either untouched or else with join(s) applied.
|
||||
"""
|
||||
return query
|
||||
|
||||
def make_pagination_spec(self):
|
||||
|
||||
# these params are based on 'vuetable-2'
|
||||
# https://github.com/ratiw/vuetable-2-tutorial/wiki/prerequisite#sample-api-endpoint
|
||||
if 'page' in self.request.params and 'per_page' in self.request.params:
|
||||
page = self.request.params['page']
|
||||
per_page = self.request.params['per_page']
|
||||
if page.isdigit() and per_page.isdigit():
|
||||
page = int(page)
|
||||
per_page = int(per_page)
|
||||
objects = SqlalchemyOrmPage(objects, items_per_page=per_page, page=page)
|
||||
return int(page), int(per_page)
|
||||
|
||||
objects = [self.normalize(obj) for obj in objects]
|
||||
return {self.get_collection_key(): objects}
|
||||
# these params are based on 'vue-tables-2'
|
||||
# https://github.com/matfish2/vue-tables-2#server-side
|
||||
if 'page' in self.request.params and 'limit' in self.request.params:
|
||||
page = self.request.params['page']
|
||||
limit = self.request.params['limit']
|
||||
if page.isdigit() and limit.isdigit():
|
||||
return int(page), int(limit)
|
||||
|
||||
def _get(self):
|
||||
uuid = self.request.matchdict['uuid']
|
||||
obj = self.Session.query(self.get_model_class()).get(uuid)
|
||||
def base_query(self):
|
||||
cls = self.get_model_class()
|
||||
query = self.Session.query(cls)
|
||||
return query
|
||||
|
||||
def get_fieldnames(self):
|
||||
if not hasattr(self, '_fieldnames'):
|
||||
self._fieldnames = get_fieldnames(
|
||||
self.rattail_config, self.model_class,
|
||||
columns=True, proxies=True, relations=False)
|
||||
return self._fieldnames
|
||||
|
||||
def normalize(self, obj):
|
||||
data = {'_str': str(obj)}
|
||||
|
||||
for field in self.get_fieldnames():
|
||||
data[field] = getattr(obj, field)
|
||||
|
||||
return data
|
||||
|
||||
def _collection_get(self):
|
||||
from sa_filters import apply_filters, apply_sort, apply_pagination
|
||||
|
||||
query = self.base_query()
|
||||
context = {}
|
||||
|
||||
# maybe filter query
|
||||
filter_spec = self.make_filter_spec()
|
||||
if filter_spec:
|
||||
query = apply_filters(query, filter_spec)
|
||||
|
||||
# maybe sort query
|
||||
sort_spec = self.make_sort_spec()
|
||||
if sort_spec:
|
||||
query = self.join_for_sort_spec(query, sort_spec)
|
||||
query = apply_sort(query, sort_spec)
|
||||
|
||||
# maybe paginate query
|
||||
pagination_spec = self.make_pagination_spec()
|
||||
if pagination_spec:
|
||||
number, size = pagination_spec
|
||||
query, pagination = apply_pagination(query, page_number=number, page_size=size)
|
||||
|
||||
# these properties are based on 'vuetable-2'
|
||||
# https://www.vuetable.com/guide/pagination.html#how-the-pagination-component-works
|
||||
context['total'] = pagination.total_results
|
||||
context['per_page'] = pagination.page_size
|
||||
context['current_page'] = pagination.page_number
|
||||
context['last_page'] = pagination.num_pages
|
||||
context['from'] = pagination.page_size * (pagination.page_number - 1) + 1
|
||||
to = pagination.page_size * (pagination.page_number - 1) + pagination.page_size
|
||||
if to > pagination.total_results:
|
||||
context['to'] = pagination.total_results
|
||||
else:
|
||||
context['to'] = to
|
||||
|
||||
# these properties are based on 'vue-tables-2'
|
||||
# https://github.com/matfish2/vue-tables-2#server-side
|
||||
context['count'] = pagination.total_results
|
||||
|
||||
objects = [self.normalize(obj) for obj in query]
|
||||
|
||||
# TODO: test this for ratbob!
|
||||
context[self.get_collection_key()] = objects
|
||||
|
||||
# these properties are based on 'vue-tables-2'
|
||||
# https://github.com/matfish2/vue-tables-2#server-side
|
||||
context['data'] = objects
|
||||
if 'count' not in context:
|
||||
context['count'] = len(objects)
|
||||
|
||||
return context
|
||||
|
||||
def get_object(self, uuid=None):
|
||||
if not uuid:
|
||||
uuid = self.request.matchdict['uuid']
|
||||
|
||||
obj = self.Session.get(self.get_model_class(), uuid)
|
||||
if obj:
|
||||
return obj
|
||||
|
||||
raise self.notfound()
|
||||
|
||||
def _get(self, obj=None, uuid=None):
|
||||
if not obj:
|
||||
obj = self.get_object(uuid=uuid)
|
||||
key = self.get_object_key()
|
||||
normal = self.normalize(obj)
|
||||
return {key: normal, 'data': normal}
|
||||
|
||||
def _collection_post(self):
|
||||
"""
|
||||
Default method for actually processing a POST request for the
|
||||
collection, aka. "create new object".
|
||||
"""
|
||||
# assume our data comes only from request JSON body
|
||||
data = self.request.json_body
|
||||
|
||||
# add instance to session, and return data for it
|
||||
try:
|
||||
obj = self.create_object(data)
|
||||
except Exception as error:
|
||||
return self.json_response({'error': str(error)})
|
||||
else:
|
||||
self.Session.flush()
|
||||
return self._get(obj)
|
||||
|
||||
def create_object(self, data):
|
||||
"""
|
||||
Create a new object instance and populate it with the given data.
|
||||
|
||||
Note that this method by default will only populate *simple* fields, so
|
||||
you may need to subclass and override to add more complex field logic.
|
||||
"""
|
||||
# create new instance of model class
|
||||
cls = self.get_model_class()
|
||||
obj = cls()
|
||||
|
||||
# "update" new object with given data
|
||||
obj = self.update_object(obj, data)
|
||||
|
||||
# that's all we can do here, subclass must override if more needed
|
||||
self.Session.add(obj)
|
||||
return obj
|
||||
|
||||
def _post(self, uuid=None):
|
||||
"""
|
||||
Default method for actually processing a POST request for an object,
|
||||
aka. "update existing object".
|
||||
"""
|
||||
if not uuid:
|
||||
uuid = self.request.matchdict['uuid']
|
||||
obj = self.Session.get(self.get_model_class(), uuid)
|
||||
if not obj:
|
||||
raise self.notfound()
|
||||
return {self.get_object_key(): self.normalize(obj)}
|
||||
|
||||
# assume our data comes only from request JSON body
|
||||
data = self.request.json_body
|
||||
|
||||
# try to update data for object, returning error as necessary
|
||||
obj = self.update_object(obj, data)
|
||||
if isinstance(obj, dict) and 'error' in obj:
|
||||
return {'error': obj['error']}
|
||||
|
||||
# return data for object
|
||||
self.Session.flush()
|
||||
return self._get(obj)
|
||||
|
||||
def update_object(self, obj, data):
|
||||
"""
|
||||
Update the given object instance with the given data.
|
||||
|
||||
Note that this method by default will only update *simple* fields, so
|
||||
you may need to subclass and override to add more complex field logic.
|
||||
"""
|
||||
# set values for simple fields only
|
||||
for key, value in data.items():
|
||||
if hasattr(obj, key):
|
||||
# TODO: what about datetime, decimal etc.?
|
||||
setattr(obj, key, value)
|
||||
|
||||
# that's all we can do here, subclass must override if more needed
|
||||
return obj
|
||||
|
||||
##############################
|
||||
# delete
|
||||
##############################
|
||||
|
||||
def _delete(self):
|
||||
"""
|
||||
View to handle DELETE action for an existing record/object.
|
||||
"""
|
||||
obj = self.get_object()
|
||||
self.delete_object(obj)
|
||||
|
||||
def delete_object(self, obj):
|
||||
"""
|
||||
Delete the object, or mark it as deleted, or whatever you need to do.
|
||||
"""
|
||||
# flush immediately to force any pending integrity errors etc.
|
||||
self.Session.delete(obj)
|
||||
self.Session.flush()
|
||||
|
||||
##############################
|
||||
# download
|
||||
##############################
|
||||
|
||||
def download(self):
|
||||
"""
|
||||
GET view allowing for download of a single file, which is attached to a
|
||||
given record.
|
||||
"""
|
||||
obj = self.get_object()
|
||||
|
||||
filename = self.request.GET.get('filename', None)
|
||||
if not filename:
|
||||
raise self.notfound()
|
||||
path = self.download_path(obj, filename)
|
||||
|
||||
response = self.file_response(path)
|
||||
return response
|
||||
|
||||
def download_path(self, obj, filename):
|
||||
"""
|
||||
Should return absolute path on disk, for the given object and filename.
|
||||
Result will be used to return a file response to client.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def rawbytes(self):
|
||||
"""
|
||||
GET view allowing for direct access to the raw bytes of a file, which
|
||||
is attached to a given record. Basically the same as 'download' except
|
||||
this does not come as an attachment.
|
||||
"""
|
||||
obj = self.get_object()
|
||||
|
||||
# TODO: is this really needed?
|
||||
# filename = self.request.GET.get('filename', None)
|
||||
# if filename:
|
||||
# path = self.download_path(obj, filename)
|
||||
# return self.file_response(path, attachment=False)
|
||||
|
||||
return self.rawbytes_response(obj)
|
||||
|
||||
def rawbytes_response(self, obj):
|
||||
raise NotImplementedError
|
||||
|
||||
##############################
|
||||
# autocomplete
|
||||
##############################
|
||||
|
||||
def autocomplete(self):
|
||||
"""
|
||||
View which accepts a single ``term`` param, and returns a list of
|
||||
autocomplete results to match.
|
||||
"""
|
||||
term = self.request.params.get('term', '').strip()
|
||||
term = self.prepare_autocomplete_term(term)
|
||||
if not term:
|
||||
return []
|
||||
|
||||
results = self.get_autocomplete_data(term)
|
||||
return [{'label': self.autocomplete_display(x),
|
||||
'value': self.autocomplete_value(x)}
|
||||
for x in results]
|
||||
|
||||
@property
|
||||
def autocomplete_fieldname(self):
|
||||
raise NotImplementedError("You must define `autocomplete_fieldname` "
|
||||
"attribute for API view class: {}".format(
|
||||
self.__class__))
|
||||
|
||||
def autocomplete_display(self, obj):
|
||||
return getattr(obj, self.autocomplete_fieldname)
|
||||
|
||||
def autocomplete_value(self, obj):
|
||||
return obj.uuid
|
||||
|
||||
def get_autocomplete_data(self, term):
|
||||
query = self.make_autocomplete_query(term)
|
||||
return query.all()
|
||||
|
||||
def make_autocomplete_query(self, term):
|
||||
model_class = self.get_model_class()
|
||||
query = self.Session.query(model_class)
|
||||
query = self.filter_autocomplete_query(query)
|
||||
|
||||
field = getattr(model_class, self.autocomplete_fieldname)
|
||||
query = query.filter(field.ilike('%%%s%%' % term))\
|
||||
.order_by(field)
|
||||
|
||||
return query
|
||||
|
||||
def filter_autocomplete_query(self, query):
|
||||
return query
|
||||
|
||||
def prepare_autocomplete_term(self, term):
|
||||
"""
|
||||
If necessary, massage the incoming search term for use with the
|
||||
autocomplete query.
|
||||
"""
|
||||
return term
|
||||
|
||||
@classmethod
|
||||
def defaults(cls, config):
|
||||
cls._defaults(config)
|
||||
|
||||
@classmethod
|
||||
def _defaults(cls, config):
|
||||
route_prefix = cls.get_route_prefix()
|
||||
permission_prefix = cls.get_permission_prefix()
|
||||
collection_url_prefix = cls.get_collection_url_prefix()
|
||||
object_url_prefix = cls.get_object_url_prefix()
|
||||
|
||||
# first, the primary resource API
|
||||
|
||||
# list/search
|
||||
if cls.listable:
|
||||
cls.establish_method('collection_get')
|
||||
resource.add_view(cls.collection_get, permission='{}.list'.format(permission_prefix))
|
||||
|
||||
# create
|
||||
if cls.creatable:
|
||||
cls.establish_method('collection_post')
|
||||
if hasattr(cls, 'permission_to_create'):
|
||||
permission = cls.permission_to_create
|
||||
else:
|
||||
permission = '{}.create'.format(permission_prefix)
|
||||
resource.add_view(cls.collection_post, permission=permission)
|
||||
|
||||
# view
|
||||
if cls.viewable:
|
||||
cls.establish_method('get')
|
||||
resource.add_view(cls.get, permission='{}.view'.format(permission_prefix))
|
||||
|
||||
# edit
|
||||
if cls.editable:
|
||||
cls.establish_method('post')
|
||||
resource.add_view(cls.post, permission='{}.edit'.format(permission_prefix))
|
||||
|
||||
# delete
|
||||
if cls.deletable:
|
||||
cls.establish_method('delete')
|
||||
resource.add_view(cls.delete, permission='{}.delete'.format(permission_prefix))
|
||||
|
||||
# register primary resource API via cornice
|
||||
object_resource = resource.add_resource(
|
||||
cls,
|
||||
collection_path=collection_url_prefix,
|
||||
# TODO: probably should allow for other (composite?) key fields
|
||||
path='{}/{{uuid}}'.format(object_url_prefix))
|
||||
config.add_cornice_resource(object_resource)
|
||||
|
||||
# now for some more "custom" things, which are still somewhat generic
|
||||
|
||||
# autocomplete
|
||||
if cls.supports_autocomplete:
|
||||
autocomplete = Service(name='{}.autocomplete'.format(route_prefix),
|
||||
path='{}/autocomplete'.format(collection_url_prefix))
|
||||
autocomplete.add_view('GET', 'autocomplete', klass=cls,
|
||||
permission='{}.list'.format(permission_prefix))
|
||||
config.add_cornice_service(autocomplete)
|
||||
|
||||
# download
|
||||
if cls.supports_download:
|
||||
download = Service(name='{}.download'.format(route_prefix),
|
||||
# TODO: probably should allow for other (composite?) key fields
|
||||
path='{}/{{uuid}}/download'.format(object_url_prefix))
|
||||
download.add_view('GET', 'download', klass=cls,
|
||||
permission='{}.download'.format(permission_prefix))
|
||||
config.add_cornice_service(download)
|
||||
|
||||
# rawbytes
|
||||
if cls.supports_rawbytes:
|
||||
rawbytes = Service(name='{}.rawbytes'.format(route_prefix),
|
||||
# TODO: probably should allow for other (composite?) key fields
|
||||
path='{}/{{uuid}}/rawbytes'.format(object_url_prefix))
|
||||
rawbytes.add_view('GET', 'rawbytes', klass=cls,
|
||||
permission='{}.download'.format(permission_prefix))
|
||||
config.add_cornice_service(rawbytes)
|
||||
|
|
43
tailbone/api/master2.py
Normal file
43
tailbone/api/master2.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2022 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Tailbone Web API - Master View (v2)
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import warnings
|
||||
|
||||
from tailbone.api import APIMasterView
|
||||
|
||||
|
||||
class APIMasterView2(APIMasterView):
|
||||
"""
|
||||
Base class for data model REST API views.
|
||||
"""
|
||||
|
||||
def __init__(self, request, context=None):
|
||||
warnings.warn("APIMasterView2 class is deprecated; please use "
|
||||
"APIMasterView instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
super(APIMasterView2, self).__init__(request, context=context)
|
59
tailbone/api/people.py
Normal file
59
tailbone/api/people.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Tailbone Web API - Person Views
|
||||
"""
|
||||
|
||||
from rattail.db import model
|
||||
|
||||
from tailbone.api import APIMasterView
|
||||
|
||||
|
||||
class PersonView(APIMasterView):
|
||||
"""
|
||||
API views for Person data
|
||||
"""
|
||||
model_class = model.Person
|
||||
permission_prefix = 'people'
|
||||
collection_url_prefix = '/people'
|
||||
object_url_prefix = '/person'
|
||||
|
||||
def normalize(self, person):
|
||||
return {
|
||||
'uuid': person.uuid,
|
||||
'_str': str(person),
|
||||
'first_name': person.first_name,
|
||||
'last_name': person.last_name,
|
||||
'display_name': person.display_name,
|
||||
}
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
PersonView = kwargs.get('PersonView', base['PersonView'])
|
||||
PersonView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
220
tailbone/api/products.py
Normal file
220
tailbone/api/products.py
Normal file
|
@ -0,0 +1,220 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2023 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Tailbone Web API - Product Views
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import orm
|
||||
|
||||
from cornice import Service
|
||||
|
||||
from rattail.db import model
|
||||
|
||||
from tailbone.api import APIMasterView
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProductView(APIMasterView):
|
||||
"""
|
||||
API views for Product data
|
||||
"""
|
||||
model_class = model.Product
|
||||
collection_url_prefix = '/products'
|
||||
object_url_prefix = '/product'
|
||||
supports_autocomplete = True
|
||||
|
||||
def __init__(self, request, context=None):
|
||||
super(ProductView, self).__init__(request, context=context)
|
||||
app = self.get_rattail_app()
|
||||
self.products_handler = app.get_products_handler()
|
||||
|
||||
def normalize(self, product):
|
||||
|
||||
# get what we can from handler
|
||||
data = self.products_handler.normalize_product(product, fields=[
|
||||
'brand_name',
|
||||
'full_description',
|
||||
'department_name',
|
||||
'unit_price_display',
|
||||
'sale_price',
|
||||
'sale_price_display',
|
||||
'sale_ends',
|
||||
'sale_ends_display',
|
||||
'tpr_price',
|
||||
'tpr_price_display',
|
||||
'tpr_ends',
|
||||
'tpr_ends_display',
|
||||
'current_price',
|
||||
'current_price_display',
|
||||
'current_ends',
|
||||
'current_ends_display',
|
||||
'vendor_name',
|
||||
'costs',
|
||||
'image_url',
|
||||
])
|
||||
|
||||
# but must supplement
|
||||
cost = product.cost
|
||||
data.update({
|
||||
'upc': str(product.upc),
|
||||
'scancode': product.scancode,
|
||||
'item_id': product.item_id,
|
||||
'item_type': product.item_type,
|
||||
'status_code': product.status_code,
|
||||
'default_unit_cost': cost.unit_cost if cost else None,
|
||||
'default_unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost and cost.unit_cost is not None else None,
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
def make_autocomplete_query(self, term):
|
||||
query = self.Session.query(model.Product)\
|
||||
.outerjoin(model.Brand)\
|
||||
.filter(sa.or_(
|
||||
model.Brand.name.ilike('%{}%'.format(term)),
|
||||
model.Product.description.ilike('%{}%'.format(term))))
|
||||
|
||||
if not self.request.has_perm('products.view_deleted'):
|
||||
query = query.filter(model.Product.deleted == False)
|
||||
|
||||
query = query.order_by(model.Brand.name,
|
||||
model.Product.description)\
|
||||
.options(orm.joinedload(model.Product.brand))
|
||||
return query
|
||||
|
||||
def autocomplete_display(self, product):
|
||||
return product.full_description
|
||||
|
||||
def quick_lookup(self):
|
||||
"""
|
||||
View for handling "quick lookup" user input, for index page.
|
||||
"""
|
||||
data = self.request.GET
|
||||
entry = data['entry']
|
||||
|
||||
product = self.products_handler.locate_product_for_entry(self.Session(),
|
||||
entry)
|
||||
if not product:
|
||||
return {'error': "Product not found"}
|
||||
|
||||
return {'ok': True,
|
||||
'product': self.normalize(product)}
|
||||
|
||||
def label_profiles(self):
|
||||
"""
|
||||
Returns the set of label profiles available for use with
|
||||
printing label for product.
|
||||
"""
|
||||
app = self.get_rattail_app()
|
||||
label_handler = app.get_label_handler()
|
||||
model = self.model
|
||||
|
||||
profiles = []
|
||||
for profile in label_handler.get_label_profiles(self.Session()):
|
||||
profiles.append({
|
||||
'uuid': profile.uuid,
|
||||
'description': profile.description,
|
||||
})
|
||||
|
||||
return {'label_profiles': profiles}
|
||||
|
||||
def print_labels(self):
|
||||
app = self.get_rattail_app()
|
||||
label_handler = app.get_label_handler()
|
||||
model = self.model
|
||||
data = self.request.json_body
|
||||
|
||||
uuid = data.get('label_profile_uuid')
|
||||
profile = self.Session.get(model.LabelProfile, uuid) if uuid else None
|
||||
if not profile:
|
||||
return {'error': "Label profile not found"}
|
||||
|
||||
uuid = data.get('product_uuid')
|
||||
product = self.Session.get(model.Product, uuid) if uuid else None
|
||||
if not product:
|
||||
return {'error': "Product not found"}
|
||||
|
||||
try:
|
||||
quantity = int(data.get('quantity'))
|
||||
except:
|
||||
return {'error': "Quantity must be integer"}
|
||||
|
||||
printer = label_handler.get_printer(profile)
|
||||
if not printer:
|
||||
return {'error': "Couldn't get printer from label profile"}
|
||||
|
||||
try:
|
||||
printer.print_labels([({'product': product}, quantity)])
|
||||
except Exception as error:
|
||||
log.warning("error occurred while printing labels", exc_info=True)
|
||||
return {'error': str(error)}
|
||||
|
||||
return {'ok': True}
|
||||
|
||||
@classmethod
|
||||
def defaults(cls, config):
|
||||
cls._defaults(config)
|
||||
cls._product_defaults(config)
|
||||
|
||||
@classmethod
|
||||
def _product_defaults(cls, config):
|
||||
route_prefix = cls.get_route_prefix()
|
||||
permission_prefix = cls.get_permission_prefix()
|
||||
collection_url_prefix = cls.get_collection_url_prefix()
|
||||
|
||||
# quick lookup
|
||||
quick_lookup = Service(name='{}.quick_lookup'.format(route_prefix),
|
||||
path='{}/quick-lookup'.format(collection_url_prefix))
|
||||
quick_lookup.add_view('GET', 'quick_lookup', klass=cls,
|
||||
permission='{}.list'.format(permission_prefix))
|
||||
config.add_cornice_service(quick_lookup)
|
||||
|
||||
# label profiles
|
||||
label_profiles = Service(name=f'{route_prefix}.label_profiles',
|
||||
path=f'{collection_url_prefix}/label-profiles')
|
||||
label_profiles.add_view('GET', 'label_profiles', klass=cls,
|
||||
permission=f'{permission_prefix}.print_labels')
|
||||
config.add_cornice_service(label_profiles)
|
||||
|
||||
# print labels
|
||||
print_labels = Service(name='{}.print_labels'.format(route_prefix),
|
||||
path='{}/print-labels'.format(collection_url_prefix))
|
||||
print_labels.add_view('POST', 'print_labels', klass=cls,
|
||||
permission='{}.print_labels'.format(permission_prefix))
|
||||
config.add_cornice_service(print_labels)
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
ProductView = kwargs.get('ProductView', base['ProductView'])
|
||||
ProductView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2018 Lance Edgar
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,23 +24,18 @@
|
|||
Tailbone Web API - Upgrade Views
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import six
|
||||
|
||||
from rattail.db import model
|
||||
|
||||
from cornice.resource import resource, view
|
||||
|
||||
from tailbone.api import APIMasterView
|
||||
|
||||
|
||||
@resource(collection_path='/upgrades', path='/upgrades/{uuid}')
|
||||
class UpgradeAPIView(APIMasterView):
|
||||
class UpgradeView(APIMasterView):
|
||||
"""
|
||||
REST API views for Upgrade model.
|
||||
"""
|
||||
model_class = model.Upgrade
|
||||
collection_url_prefix = '/upgrades'
|
||||
object_url_prefix = '/upgrades'
|
||||
|
||||
def normalize(self, upgrade):
|
||||
data = {
|
||||
|
@ -54,17 +49,16 @@ class UpgradeAPIView(APIMasterView):
|
|||
data['status_code'] = None
|
||||
else:
|
||||
data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code,
|
||||
six.text_type(upgrade.status_code))
|
||||
str(upgrade.status_code))
|
||||
return data
|
||||
|
||||
@view(permission='upgrades.list')
|
||||
def collection_get(self):
|
||||
return self._collection_get()
|
||||
|
||||
@view(permission='upgrades.view')
|
||||
def get(self):
|
||||
return self._get()
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
UpgradeView = kwargs.get('UpgradeView', base['UpgradeView'])
|
||||
UpgradeView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
config.scan(__name__)
|
||||
defaults(config)
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2018 Lance Edgar
|
||||
# Copyright © 2010-2023 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,36 +24,48 @@
|
|||
Tailbone Web API - User Views
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import six
|
||||
|
||||
from rattail.db import model
|
||||
|
||||
from cornice.resource import resource, view
|
||||
|
||||
from tailbone.api import APIMasterView
|
||||
|
||||
|
||||
@resource(collection_path='/users', path='/users/{uuid}')
|
||||
class UserView(APIMasterView):
|
||||
|
||||
"""
|
||||
API views for User data
|
||||
"""
|
||||
model_class = model.User
|
||||
collection_url_prefix = '/users'
|
||||
object_url_prefix = '/user'
|
||||
|
||||
def normalize(self, user):
|
||||
return {
|
||||
'uuid': user.uuid,
|
||||
'username': user.username,
|
||||
'person': six.text_type(user.person or ''),
|
||||
'person_display_name': (user.person.display_name or '') if user.person else '',
|
||||
'active': user.active,
|
||||
}
|
||||
|
||||
@view(permission='users.list')
|
||||
def collection_get(self):
|
||||
return self._collection_get()
|
||||
def interpret_sortcol(self, order_by):
|
||||
if order_by == 'person_display_name':
|
||||
return self.sortcol('Person', 'display_name')
|
||||
return self.sortcol(order_by)
|
||||
|
||||
@view(permission='users.view')
|
||||
def get(self):
|
||||
return self._get()
|
||||
def join_for_sort_model(self, query, model_name):
|
||||
if model_name == 'Person':
|
||||
query = query.outerjoin(model.Person)
|
||||
return query
|
||||
|
||||
def update_object(self, user, data):
|
||||
# TODO: should ensure prevent_password_change is respected
|
||||
return super(UserView, self).update_object(user, data)
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
UserView = kwargs.get('UserView', base['UserView'])
|
||||
UserView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
config.scan(__name__)
|
||||
defaults(config)
|
||||
|
|
57
tailbone/api/vendors.py
Normal file
57
tailbone/api/vendors.py
Normal file
|
@ -0,0 +1,57 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Tailbone Web API - Vendor Views
|
||||
"""
|
||||
|
||||
from rattail.db import model
|
||||
|
||||
from tailbone.api import APIMasterView
|
||||
|
||||
|
||||
class VendorView(APIMasterView):
|
||||
|
||||
model_class = model.Vendor
|
||||
collection_url_prefix = '/vendors'
|
||||
object_url_prefix = '/vendor'
|
||||
supports_autocomplete = True
|
||||
autocomplete_fieldname = 'name'
|
||||
|
||||
def normalize(self, vendor):
|
||||
return {
|
||||
'uuid': vendor.uuid,
|
||||
'_str': str(vendor),
|
||||
'id': vendor.id,
|
||||
'name': vendor.name,
|
||||
}
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
VendorView = kwargs.get('VendorView', base['VendorView'])
|
||||
VendorView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
234
tailbone/api/workorders.py
Normal file
234
tailbone/api/workorders.py
Normal file
|
@ -0,0 +1,234 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Tailbone Web API - Work Order Views
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from rattail.db.model import WorkOrder
|
||||
|
||||
from cornice import Service
|
||||
|
||||
from tailbone.api import APIMasterView
|
||||
|
||||
|
||||
class WorkOrderView(APIMasterView):
|
||||
|
||||
model_class = WorkOrder
|
||||
collection_url_prefix = '/workorders'
|
||||
object_url_prefix = '/workorder'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
app = self.get_rattail_app()
|
||||
self.workorder_handler = app.get_workorder_handler()
|
||||
|
||||
def normalize(self, workorder):
|
||||
data = super().normalize(workorder)
|
||||
data.update({
|
||||
'customer_name': workorder.customer.name,
|
||||
'status_label': self.enum.WORKORDER_STATUS[workorder.status_code],
|
||||
'date_submitted': str(workorder.date_submitted or ''),
|
||||
'date_received': str(workorder.date_received or ''),
|
||||
'date_released': str(workorder.date_released or ''),
|
||||
'date_delivered': str(workorder.date_delivered or ''),
|
||||
})
|
||||
return data
|
||||
|
||||
def create_object(self, data):
|
||||
|
||||
# invoke the handler instead of normal API CRUD logic
|
||||
workorder = self.workorder_handler.make_workorder(self.Session(), **data)
|
||||
return workorder
|
||||
|
||||
def update_object(self, workorder, data):
|
||||
date_fields = [
|
||||
'date_submitted',
|
||||
'date_received',
|
||||
'date_released',
|
||||
'date_delivered',
|
||||
]
|
||||
|
||||
# coerce date field values to proper datetime.date objects
|
||||
for field in date_fields:
|
||||
if field in data:
|
||||
if data[field] == '':
|
||||
data[field] = None
|
||||
elif not isinstance(data[field], datetime.date):
|
||||
date = datetime.datetime.strptime(data[field], '%Y-%m-%d').date()
|
||||
data[field] = date
|
||||
|
||||
# coerce status code value to proper integer
|
||||
if 'status_code' in data:
|
||||
data['status_code'] = int(data['status_code'])
|
||||
|
||||
return super().update_object(workorder, data)
|
||||
|
||||
def status_codes(self):
|
||||
"""
|
||||
Retrieve all info about possible work order status codes.
|
||||
"""
|
||||
return self.workorder_handler.status_codes()
|
||||
|
||||
def receive(self):
|
||||
"""
|
||||
Sets work order status to "received".
|
||||
"""
|
||||
workorder = self.get_object()
|
||||
self.workorder_handler.receive(workorder)
|
||||
self.Session.flush()
|
||||
return self.normalize(workorder)
|
||||
|
||||
def await_estimate(self):
|
||||
"""
|
||||
Sets work order status to "awaiting estimate confirmation".
|
||||
"""
|
||||
workorder = self.get_object()
|
||||
self.workorder_handler.await_estimate(workorder)
|
||||
self.Session.flush()
|
||||
return self.normalize(workorder)
|
||||
|
||||
def await_parts(self):
|
||||
"""
|
||||
Sets work order status to "awaiting parts".
|
||||
"""
|
||||
workorder = self.get_object()
|
||||
self.workorder_handler.await_parts(workorder)
|
||||
self.Session.flush()
|
||||
return self.normalize(workorder)
|
||||
|
||||
def work_on_it(self):
|
||||
"""
|
||||
Sets work order status to "working on it".
|
||||
"""
|
||||
workorder = self.get_object()
|
||||
self.workorder_handler.work_on_it(workorder)
|
||||
self.Session.flush()
|
||||
return self.normalize(workorder)
|
||||
|
||||
def release(self):
|
||||
"""
|
||||
Sets work order status to "released".
|
||||
"""
|
||||
workorder = self.get_object()
|
||||
self.workorder_handler.release(workorder)
|
||||
self.Session.flush()
|
||||
return self.normalize(workorder)
|
||||
|
||||
def deliver(self):
|
||||
"""
|
||||
Sets work order status to "delivered".
|
||||
"""
|
||||
workorder = self.get_object()
|
||||
self.workorder_handler.deliver(workorder)
|
||||
self.Session.flush()
|
||||
return self.normalize(workorder)
|
||||
|
||||
def cancel(self):
|
||||
"""
|
||||
Sets work order status to "canceled".
|
||||
"""
|
||||
workorder = self.get_object()
|
||||
self.workorder_handler.cancel(workorder)
|
||||
self.Session.flush()
|
||||
return self.normalize(workorder)
|
||||
|
||||
@classmethod
|
||||
def defaults(cls, config):
|
||||
cls._defaults(config)
|
||||
cls._workorder_defaults(config)
|
||||
|
||||
@classmethod
|
||||
def _workorder_defaults(cls, config):
|
||||
route_prefix = cls.get_route_prefix()
|
||||
permission_prefix = cls.get_permission_prefix()
|
||||
collection_url_prefix = cls.get_collection_url_prefix()
|
||||
object_url_prefix = cls.get_object_url_prefix()
|
||||
|
||||
# status codes
|
||||
status_codes = Service(name='{}.status_codes'.format(route_prefix),
|
||||
path='{}/status-codes'.format(collection_url_prefix))
|
||||
status_codes.add_view('GET', 'status_codes', klass=cls,
|
||||
permission='{}.list'.format(permission_prefix))
|
||||
config.add_cornice_service(status_codes)
|
||||
|
||||
# receive
|
||||
receive = Service(name='{}.receive'.format(route_prefix),
|
||||
path='{}/{{uuid}}/receive'.format(object_url_prefix))
|
||||
receive.add_view('POST', 'receive', klass=cls,
|
||||
permission='{}.edit'.format(permission_prefix))
|
||||
config.add_cornice_service(receive)
|
||||
|
||||
# await estimate confirmation
|
||||
await_estimate = Service(name='{}.await_estimate'.format(route_prefix),
|
||||
path='{}/{{uuid}}/await-estimate'.format(object_url_prefix))
|
||||
await_estimate.add_view('POST', 'await_estimate', klass=cls,
|
||||
permission='{}.edit'.format(permission_prefix))
|
||||
config.add_cornice_service(await_estimate)
|
||||
|
||||
# await parts
|
||||
await_parts = Service(name='{}.await_parts'.format(route_prefix),
|
||||
path='{}/{{uuid}}/await-parts'.format(object_url_prefix))
|
||||
await_parts.add_view('POST', 'await_parts', klass=cls,
|
||||
permission='{}.edit'.format(permission_prefix))
|
||||
config.add_cornice_service(await_parts)
|
||||
|
||||
# work on it
|
||||
work_on_it = Service(name='{}.work_on_it'.format(route_prefix),
|
||||
path='{}/{{uuid}}/work-on-it'.format(object_url_prefix))
|
||||
work_on_it.add_view('POST', 'work_on_it', klass=cls,
|
||||
permission='{}.edit'.format(permission_prefix))
|
||||
config.add_cornice_service(work_on_it)
|
||||
|
||||
# release
|
||||
release = Service(name='{}.release'.format(route_prefix),
|
||||
path='{}/{{uuid}}/release'.format(object_url_prefix))
|
||||
release.add_view('POST', 'release', klass=cls,
|
||||
permission='{}.edit'.format(permission_prefix))
|
||||
config.add_cornice_service(release)
|
||||
|
||||
# deliver
|
||||
deliver = Service(name='{}.deliver'.format(route_prefix),
|
||||
path='{}/{{uuid}}/deliver'.format(object_url_prefix))
|
||||
deliver.add_view('POST', 'deliver', klass=cls,
|
||||
permission='{}.edit'.format(permission_prefix))
|
||||
config.add_cornice_service(deliver)
|
||||
|
||||
# cancel
|
||||
cancel = Service(name='{}.cancel'.format(route_prefix),
|
||||
path='{}/{{uuid}}/cancel'.format(object_url_prefix))
|
||||
cancel.add_view('POST', 'cancel', klass=cls,
|
||||
permission='{}.edit'.format(permission_prefix))
|
||||
config.add_cornice_service(cancel)
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
WorkOrderView = kwargs.get('WorkOrderView', base['WorkOrderView'])
|
||||
WorkOrderView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
238
tailbone/app.py
238
tailbone/app.py
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2018 Lance Edgar
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,24 +24,23 @@
|
|||
Application Entry Point
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import os
|
||||
import warnings
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||
|
||||
from wuttjamaican.util import parse_list
|
||||
|
||||
import rattail.db
|
||||
from rattail.config import make_config
|
||||
from rattail.exceptions import ConfigurationError
|
||||
from rattail.db.config import get_engines
|
||||
from rattail.db.types import GPCType
|
||||
|
||||
from pyramid.config import Configurator
|
||||
from pyramid.authentication import SessionAuthenticationPolicy
|
||||
from zope.sqlalchemy import register
|
||||
|
||||
import tailbone.db
|
||||
from tailbone.auth import TailboneAuthorizationPolicy
|
||||
from tailbone.auth import TailboneSecurityPolicy
|
||||
from tailbone.config import csrf_token_name, csrf_header_name
|
||||
from tailbone.util import get_effective_theme, get_theme_template_path
|
||||
from tailbone.providers import get_all_providers
|
||||
|
||||
|
||||
def make_rattail_config(settings):
|
||||
|
@ -59,29 +58,39 @@ def make_rattail_config(settings):
|
|||
"to the path of your config file. Lame, but necessary.")
|
||||
rattail_config = make_config(path)
|
||||
settings['rattail_config'] = rattail_config
|
||||
rattail_config.configure_logging()
|
||||
|
||||
rattail_engines = settings.get('rattail_engines')
|
||||
if not rattail_engines:
|
||||
# nb. this is for compaibility with wuttaweb
|
||||
settings['wutta_config'] = rattail_config
|
||||
|
||||
# Load all Rattail database engines from config, and store in settings
|
||||
# dict. This is necessary e.g. in the case of a host server, to have
|
||||
# access to its subordinate store servers.
|
||||
rattail_engines = get_engines(rattail_config)
|
||||
settings['rattail_engines'] = rattail_engines
|
||||
# must import all sqlalchemy models before things get rolling,
|
||||
# otherwise can have errors about continuum TransactionMeta class
|
||||
# not yet mapped, when relevant pages are first requested...
|
||||
# cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models
|
||||
# hat tip to https://stackoverflow.com/a/59241485
|
||||
if getattr(rattail_config, 'tempmon_engine', None):
|
||||
from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession
|
||||
tempmon_session = TempmonSession()
|
||||
tempmon_session.query(tempmon_model.Appliance).first()
|
||||
tempmon_session.close()
|
||||
|
||||
# Configure the database session classes. Note that most of the time we'll
|
||||
# be using the Tailbone Session, but occasionally (e.g. within batch
|
||||
# processing threads) we want the Rattail Session. The reason is that
|
||||
# during normal request processing, the Tailbone Session is preferable as
|
||||
# it includes Zope Transaction magic. Within an explicitly-spawned thread
|
||||
# however, this is *not* desirable.
|
||||
rattail.db.Session.configure(bind=rattail_engines['default'])
|
||||
tailbone.db.Session.configure(bind=rattail_engines['default'])
|
||||
if hasattr(rattail_config, 'tempmon_engine'):
|
||||
tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine)
|
||||
# configure database sessions
|
||||
if hasattr(rattail_config, 'appdb_engine'):
|
||||
tailbone.db.Session.configure(bind=rattail_config.appdb_engine)
|
||||
if hasattr(rattail_config, 'trainwreck_engine'):
|
||||
tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine)
|
||||
if hasattr(rattail_config, 'tempmon_engine'):
|
||||
tailbone.db.TempmonSession.configure(bind=rattail_config.tempmon_engine)
|
||||
|
||||
# maybe set "future" behavior for SQLAlchemy
|
||||
if rattail_config.getbool('rattail.db', 'sqlalchemy_future_mode', usedb=False):
|
||||
tailbone.db.Session.configure(future=True)
|
||||
|
||||
# create session wrappers for each "extra" Trainwreck engine
|
||||
for key, engine in rattail_config.trainwreck_engines.items():
|
||||
if key != 'default':
|
||||
Session = scoped_session(sessionmaker(bind=engine))
|
||||
register(Session)
|
||||
tailbone.db.ExtraTrainwreckSessions[key] = Session
|
||||
|
||||
# Make sure rattail config object uses our scoped session, to avoid
|
||||
# unnecessary connections (and pooling limits).
|
||||
|
@ -119,27 +128,52 @@ def make_pyramid_config(settings, configure_csrf=True):
|
|||
"""
|
||||
Make a Pyramid config object from the given settings.
|
||||
"""
|
||||
rattail_config = settings['rattail_config']
|
||||
|
||||
config = settings.pop('pyramid_config', None)
|
||||
if config:
|
||||
config.set_root_factory(Root)
|
||||
else:
|
||||
|
||||
# declare this web app of the "classic" variety
|
||||
settings.setdefault('tailbone.classic', 'true')
|
||||
|
||||
# we want the new themes feature!
|
||||
establish_theme(settings)
|
||||
|
||||
settings.setdefault('fanstatic.versioning', 'true')
|
||||
settings.setdefault('pyramid_deform.template_search_path', 'tailbone:templates/deform')
|
||||
config = Configurator(settings=settings, root_factory=Root)
|
||||
|
||||
# configure user authorization / authentication
|
||||
config.set_authorization_policy(TailboneAuthorizationPolicy())
|
||||
config.set_authentication_policy(SessionAuthenticationPolicy())
|
||||
# add rattail config directly to registry, for access throughout the app
|
||||
config.registry['rattail_config'] = rattail_config
|
||||
|
||||
# always require CSRF token protection
|
||||
# configure user authorization / authentication
|
||||
config.set_security_policy(TailboneSecurityPolicy())
|
||||
|
||||
# maybe require CSRF token protection
|
||||
if configure_csrf:
|
||||
config.set_default_csrf_options(require_csrf=True, token='_csrf')
|
||||
config.set_default_csrf_options(require_csrf=True,
|
||||
token=csrf_token_name(rattail_config),
|
||||
header=csrf_header_name(rattail_config))
|
||||
|
||||
# Bring in some Pyramid goodies.
|
||||
config.include('tailbone.beaker')
|
||||
config.include('pyramid_deform')
|
||||
config.include('pyramid_fanstatic')
|
||||
config.include('pyramid_mako')
|
||||
config.include('pyramid_tm')
|
||||
|
||||
# TODO: this may be a good idea some day, if wanting to leverage
|
||||
# deform resources for component JS? cf. also base.mako template
|
||||
# # override default script mapping for deform
|
||||
# from deform import Field
|
||||
# from deform.widget import ResourceRegistry, default_resources
|
||||
# registry = ResourceRegistry(use_defaults=False)
|
||||
# for key in default_resources:
|
||||
# registry.set_js_resources(key, None, {'js': []})
|
||||
# Field.set_default_resource_registry(registry)
|
||||
|
||||
# bring in the pyramid_retry logic, if available
|
||||
# TODO: pretty soon we can require this package, hopefully..
|
||||
try:
|
||||
|
@ -149,13 +183,142 @@ def make_pyramid_config(settings, configure_csrf=True):
|
|||
else:
|
||||
config.include('pyramid_retry')
|
||||
|
||||
# Add some permissions magic.
|
||||
config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group')
|
||||
config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission')
|
||||
# fetch all tailbone providers
|
||||
providers = get_all_providers(rattail_config)
|
||||
for provider in providers.values():
|
||||
|
||||
# configure DB sessions associated with transaction manager
|
||||
provider.configure_db_sessions(rattail_config, config)
|
||||
|
||||
# add any static includes
|
||||
includes = provider.get_static_includes()
|
||||
if includes:
|
||||
for spec in includes:
|
||||
config.include(spec)
|
||||
|
||||
# add some permissions magic
|
||||
config.add_directive('add_wutta_permission_group',
|
||||
'wuttaweb.auth.add_permission_group')
|
||||
config.add_directive('add_wutta_permission',
|
||||
'wuttaweb.auth.add_permission')
|
||||
# TODO: deprecate / remove these
|
||||
config.add_directive('add_tailbone_permission_group',
|
||||
'wuttaweb.auth.add_permission_group')
|
||||
config.add_directive('add_tailbone_permission',
|
||||
'wuttaweb.auth.add_permission')
|
||||
|
||||
# and some similar magic for certain master views
|
||||
config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page')
|
||||
config.add_directive('add_tailbone_config_page', 'tailbone.app.add_config_page')
|
||||
config.add_directive('add_tailbone_model_view', 'tailbone.app.add_model_view')
|
||||
config.add_directive('add_tailbone_view_supplement', 'tailbone.app.add_view_supplement')
|
||||
|
||||
config.add_directive('add_tailbone_websocket', 'tailbone.app.add_websocket')
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def add_websocket(config, name, view, attr=None):
|
||||
"""
|
||||
Register a websocket entry point for the app.
|
||||
"""
|
||||
def action():
|
||||
rattail_config = config.registry.settings['rattail_config']
|
||||
rattail_app = rattail_config.get_app()
|
||||
|
||||
if isinstance(view, str):
|
||||
view_callable = rattail_app.load_object(view)
|
||||
else:
|
||||
view_callable = view
|
||||
view_callable = view_callable(config)
|
||||
if attr:
|
||||
view_callable = getattr(view_callable, attr)
|
||||
|
||||
# register route
|
||||
path = '/ws/{}'.format(name)
|
||||
route_name = 'ws.{}'.format(name)
|
||||
config.add_route(route_name, path, static=True)
|
||||
|
||||
# register view callable
|
||||
websockets = config.registry.setdefault('tailbone_websockets', {})
|
||||
websockets[path] = view_callable
|
||||
|
||||
config.action('tailbone-add-websocket-{}'.format(name), action,
|
||||
# nb. since this action adds routes, it must happen
|
||||
# sooner in the order than it normally would, hence
|
||||
# we declare that
|
||||
order=-20)
|
||||
|
||||
|
||||
def add_index_page(config, route_name, label, permission):
|
||||
"""
|
||||
Register a config page for the app.
|
||||
"""
|
||||
def action():
|
||||
pages = config.get_settings().get('tailbone_index_pages', [])
|
||||
pages.append({'label': label, 'route': route_name,
|
||||
'permission': permission})
|
||||
config.add_settings({'tailbone_index_pages': pages})
|
||||
config.action(None, action)
|
||||
|
||||
|
||||
def add_config_page(config, route_name, label, permission):
|
||||
"""
|
||||
Register a config page for the app.
|
||||
"""
|
||||
def action():
|
||||
pages = config.get_settings().get('tailbone_config_pages', [])
|
||||
pages.append({'label': label, 'route': route_name,
|
||||
'permission': permission})
|
||||
config.add_settings({'tailbone_config_pages': pages})
|
||||
config.action(None, action)
|
||||
|
||||
|
||||
def add_model_view(config, model_name, label, route_prefix, permission_prefix):
|
||||
"""
|
||||
Register a model view for the app.
|
||||
"""
|
||||
def action():
|
||||
all_views = config.get_settings().get('tailbone_model_views', {})
|
||||
|
||||
model_views = all_views.setdefault(model_name, [])
|
||||
model_views.append({
|
||||
'label': label,
|
||||
'route_prefix': route_prefix,
|
||||
'permission_prefix': permission_prefix,
|
||||
})
|
||||
|
||||
config.add_settings({'tailbone_model_views': all_views})
|
||||
|
||||
config.action(None, action)
|
||||
|
||||
|
||||
def add_view_supplement(config, route_prefix, cls):
|
||||
"""
|
||||
Register a master view supplement for the app.
|
||||
"""
|
||||
def action():
|
||||
supplements = config.get_settings().get('tailbone_view_supplements', {})
|
||||
supplements.setdefault(route_prefix, []).append(cls)
|
||||
config.add_settings({'tailbone_view_supplements': supplements})
|
||||
config.action(None, action)
|
||||
|
||||
|
||||
def establish_theme(settings):
|
||||
rattail_config = settings['rattail_config']
|
||||
|
||||
theme = get_effective_theme(rattail_config)
|
||||
settings['tailbone.theme'] = theme
|
||||
|
||||
directories = settings['mako.directories']
|
||||
if isinstance(directories, str):
|
||||
directories = parse_list(directories)
|
||||
|
||||
path = get_theme_template_path(rattail_config)
|
||||
directories.insert(0, path)
|
||||
settings['mako.directories'] = directories
|
||||
|
||||
|
||||
def configure_postgresql(pyramid_config):
|
||||
"""
|
||||
Add some PostgreSQL-specific tweaks to the final app config. Specifically,
|
||||
|
@ -169,7 +332,8 @@ def main(global_config, **settings):
|
|||
"""
|
||||
This function returns a Pyramid WSGI application.
|
||||
"""
|
||||
settings.setdefault('mako.directories', ['tailbone:templates'])
|
||||
settings.setdefault('mako.directories', ['tailbone:templates',
|
||||
'wuttaweb:templates'])
|
||||
rattail_config = make_rattail_config(settings)
|
||||
pyramid_config = make_pyramid_config(settings)
|
||||
pyramid_config.include('tailbone')
|
||||
|
|
110
tailbone/asgi.py
Normal file
110
tailbone/asgi.py
Normal file
|
@ -0,0 +1,110 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
ASGI App Utilities
|
||||
"""
|
||||
|
||||
import os
|
||||
import configparser
|
||||
import logging
|
||||
|
||||
from rattail.util import load_object
|
||||
|
||||
from asgiref.wsgi import WsgiToAsgi
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TailboneWsgiToAsgi(WsgiToAsgi):
|
||||
"""
|
||||
Custom WSGI -> ASGI wrapper, to add routing for websockets.
|
||||
"""
|
||||
|
||||
async def __call__(self, scope, *args, **kwargs):
|
||||
protocol = scope['type']
|
||||
path = scope['path']
|
||||
|
||||
# strip off the root path, if non-empty. needed for serving
|
||||
# under /poser or anything other than true site root
|
||||
root_path = scope['root_path']
|
||||
if root_path and path.startswith(root_path):
|
||||
path = path[len(root_path):]
|
||||
|
||||
if protocol == 'websocket':
|
||||
websockets = self.wsgi_application.registry.get(
|
||||
'tailbone_websockets', {})
|
||||
if path in websockets:
|
||||
await websockets[path](scope, *args, **kwargs)
|
||||
|
||||
try:
|
||||
await super().__call__(scope, *args, **kwargs)
|
||||
except ValueError as e:
|
||||
# The developer may wish to improve handling of this exception.
|
||||
# See https://github.com/Pylons/pyramid_cookbook/issues/225 and
|
||||
# https://asgi.readthedocs.io/en/latest/specs/www.html#websocket
|
||||
pass
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
|
||||
def make_asgi_app(main_app=None):
|
||||
"""
|
||||
This function returns an ASGI application.
|
||||
"""
|
||||
path = os.environ.get('TAILBONE_ASGI_CONFIG')
|
||||
if not path:
|
||||
raise RuntimeError("You must define TAILBONE_ASGI_CONFIG env variable.")
|
||||
|
||||
# make a config parser good enough to load pyramid settings
|
||||
configdir = os.path.dirname(path)
|
||||
parser = configparser.ConfigParser(defaults={'__file__': path,
|
||||
'here': configdir})
|
||||
|
||||
# read the config file
|
||||
parser.read(path)
|
||||
|
||||
# parse the settings needed for pyramid app
|
||||
settings = dict(parser.items('app:main'))
|
||||
|
||||
if isinstance(main_app, str):
|
||||
make_wsgi_app = load_object(main_app)
|
||||
elif callable(main_app):
|
||||
make_wsgi_app = main_app
|
||||
else:
|
||||
if main_app:
|
||||
log.warning("specified main app of unknown type: %s", main_app)
|
||||
make_wsgi_app = load_object('tailbone.app:main')
|
||||
|
||||
# construct a pyramid app "per usual"
|
||||
app = make_wsgi_app({}, **settings)
|
||||
|
||||
# then wrap it with ASGI
|
||||
return TailboneWsgiToAsgi(app)
|
||||
|
||||
|
||||
def asgi_main():
|
||||
"""
|
||||
This function returns an ASGI application.
|
||||
"""
|
||||
return make_asgi_app()
|
110
tailbone/auth.py
110
tailbone/auth.py
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2017 Lance Edgar
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,32 +24,31 @@
|
|||
Authentication & Authorization
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from rattail import enum
|
||||
from rattail.util import prettify, NOTSET
|
||||
from wuttjamaican.util import UNSPECIFIED
|
||||
|
||||
from zope.interface import implementer
|
||||
from pyramid.interfaces import IAuthorizationPolicy
|
||||
from pyramid.security import remember, forget, Everyone, Authenticated
|
||||
from pyramid.security import remember, forget
|
||||
|
||||
from wuttaweb.auth import WuttaSecurityPolicy
|
||||
from tailbone.db import Session
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def login_user(request, user, timeout=NOTSET):
|
||||
def login_user(request, user, timeout=UNSPECIFIED):
|
||||
"""
|
||||
Perform the steps necessary to login the given user. Note that this
|
||||
returns a ``headers`` dict which you should pass to the redirect.
|
||||
"""
|
||||
user.record_event(enum.USER_EVENT_LOGIN)
|
||||
config = request.rattail_config
|
||||
app = config.get_app()
|
||||
user.record_event(app.enum.USER_EVENT_LOGIN)
|
||||
headers = remember(request, user.uuid)
|
||||
if timeout is NOTSET:
|
||||
timeout = session_timeout_for_user(user)
|
||||
if timeout is UNSPECIFIED:
|
||||
timeout = session_timeout_for_user(config, user)
|
||||
log.debug("setting session timeout for '{}' to {}".format(user.username, timeout))
|
||||
set_session_timeout(request, timeout)
|
||||
return headers
|
||||
|
@ -60,24 +59,28 @@ def logout_user(request):
|
|||
Perform the logout action for the given request. Note that this returns a
|
||||
``headers`` dict which you should pass to the redirect.
|
||||
"""
|
||||
app = request.rattail_config.get_app()
|
||||
user = request.user
|
||||
if user:
|
||||
user.record_event(enum.USER_EVENT_LOGOUT)
|
||||
user.record_event(app.enum.USER_EVENT_LOGOUT)
|
||||
request.session.delete()
|
||||
request.session.invalidate()
|
||||
headers = forget(request)
|
||||
return headers
|
||||
|
||||
|
||||
def session_timeout_for_user(user):
|
||||
def session_timeout_for_user(config, user):
|
||||
"""
|
||||
Returns the "max" session timeout for the user, according to roles
|
||||
"""
|
||||
from rattail.db.auth import authenticated_role
|
||||
app = config.get_app()
|
||||
auth = app.get_auth_handler()
|
||||
|
||||
roles = user.roles + [authenticated_role(Session())]
|
||||
authenticated = auth.get_role_authenticated(Session())
|
||||
roles = user.roles + [authenticated]
|
||||
timeouts = [role.session_timeout for role in roles
|
||||
if role.session_timeout is not None]
|
||||
|
||||
if timeouts and 0 not in timeouts:
|
||||
return max(timeouts)
|
||||
|
||||
|
@ -89,53 +92,42 @@ def set_session_timeout(request, timeout):
|
|||
request.session['_timeout'] = timeout or None
|
||||
|
||||
|
||||
@implementer(IAuthorizationPolicy)
|
||||
class TailboneAuthorizationPolicy(object):
|
||||
class TailboneSecurityPolicy(WuttaSecurityPolicy):
|
||||
|
||||
def permits(self, context, principals, permission):
|
||||
from rattail.db import model
|
||||
from rattail.db.auth import has_permission
|
||||
def __init__(self, db_session=None, api_mode=False, **kwargs):
|
||||
kwargs['db_session'] = db_session or Session()
|
||||
super().__init__(**kwargs)
|
||||
self.api_mode = api_mode
|
||||
|
||||
for userid in principals:
|
||||
if userid not in (Everyone, Authenticated):
|
||||
if context.request.user and context.request.user.uuid == userid:
|
||||
return context.request.has_perm(permission)
|
||||
else:
|
||||
assert False # should no longer happen..right?
|
||||
user = Session.query(model.User).get(userid)
|
||||
if user:
|
||||
if has_permission(Session(), user, permission):
|
||||
return True
|
||||
if Everyone in principals:
|
||||
return has_permission(Session(), None, permission)
|
||||
return False
|
||||
def load_identity(self, request):
|
||||
config = request.registry.settings.get('rattail_config')
|
||||
app = config.get_app()
|
||||
user = None
|
||||
|
||||
def principals_allowed_by_permission(self, context, permission):
|
||||
raise NotImplementedError
|
||||
if self.api_mode:
|
||||
|
||||
# determine/load user from header token if present
|
||||
credentials = request.headers.get('Authorization')
|
||||
if credentials:
|
||||
match = re.match(r'^Bearer (\S+)$', credentials)
|
||||
if match:
|
||||
token = match.group(1)
|
||||
auth = app.get_auth_handler()
|
||||
user = auth.authenticate_user_token(self.db_session, token)
|
||||
|
||||
def add_permission_group(config, key, label=None, overwrite=True):
|
||||
"""
|
||||
Add a permission group to the app configuration.
|
||||
"""
|
||||
def action():
|
||||
perms = config.get_settings().get('tailbone_permissions', {})
|
||||
if key not in perms or overwrite:
|
||||
group = perms.setdefault(key, {'key': key})
|
||||
group['label'] = label or prettify(key)
|
||||
config.add_settings({'tailbone_permissions': perms})
|
||||
config.action(None, action)
|
||||
if not user:
|
||||
|
||||
# fetch user uuid from current session
|
||||
uuid = self.session_helper.authenticated_userid(request)
|
||||
if not uuid:
|
||||
return
|
||||
|
||||
def add_permission(config, groupkey, key, label=None):
|
||||
"""
|
||||
Add a permission to the app configuration.
|
||||
"""
|
||||
def action():
|
||||
perms = config.get_settings().get('tailbone_permissions', {})
|
||||
group = perms.setdefault(groupkey, {'key': groupkey})
|
||||
group.setdefault('label', prettify(groupkey))
|
||||
perm = group.setdefault('perms', {}).setdefault(key, {'key': key})
|
||||
perm['label'] = label or prettify(key)
|
||||
config.add_settings({'tailbone_permissions': perms})
|
||||
config.action(None, action)
|
||||
# fetch user object from db
|
||||
model = app.model
|
||||
user = self.db_session.get(model.User, uuid)
|
||||
if not user:
|
||||
return
|
||||
|
||||
# this user is responsible for data changes in current request
|
||||
self.db_session.set_continuum_user(user)
|
||||
return user
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2017 Lance Edgar
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -27,10 +27,12 @@ Note that most of the code for this module was copied from the beaker and
|
|||
pyramid_beaker projects.
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import time
|
||||
from pkg_resources import parse_version
|
||||
|
||||
from rattail.util import get_pkg_version
|
||||
|
||||
import beaker
|
||||
from beaker.session import Session
|
||||
from beaker.util import coerce_session_params
|
||||
from pyramid.settings import asbool
|
||||
|
@ -45,6 +47,10 @@ class TailboneSession(Session):
|
|||
|
||||
def load(self):
|
||||
"Loads the data from this session from persistent storage"
|
||||
|
||||
# are we using older version of beaker?
|
||||
old_beaker = parse_version(get_pkg_version('beaker')) < parse_version('1.12')
|
||||
|
||||
self.namespace = self.namespace_class(self.id,
|
||||
data_dir=self.data_dir,
|
||||
digest_filenames=False,
|
||||
|
@ -60,8 +66,12 @@ class TailboneSession(Session):
|
|||
try:
|
||||
session_data = self.namespace['session']
|
||||
|
||||
if (session_data is not None and self.encrypt_key):
|
||||
session_data = self._decrypt_data(session_data)
|
||||
if old_beaker:
|
||||
if (session_data is not None and self.encrypt_key):
|
||||
session_data = self._decrypt_data(session_data)
|
||||
else: # beaker >= 1.12
|
||||
if session_data is not None:
|
||||
session_data = self._decrypt_data(session_data)
|
||||
|
||||
# Memcached always returns a key, its None when its not
|
||||
# present
|
||||
|
@ -90,6 +100,7 @@ class TailboneSession(Session):
|
|||
# for this module entirely...
|
||||
timeout = session_data.get('_timeout', self.timeout)
|
||||
if timeout is not None and \
|
||||
'_accessed_time' in session_data and \
|
||||
now - session_data['_accessed_time'] > timeout:
|
||||
timed_out = True
|
||||
else:
|
||||
|
@ -103,9 +114,6 @@ class TailboneSession(Session):
|
|||
# Update the current _accessed_time
|
||||
session_data['_accessed_time'] = now
|
||||
|
||||
# Set the path if applicable
|
||||
if '_path' in session_data:
|
||||
self._path = session_data['_path']
|
||||
self.update(session_data)
|
||||
self.accessed_dict = session_data.copy()
|
||||
finally:
|
||||
|
|
80
tailbone/cleanup.py
Normal file
80
tailbone/cleanup.py
Normal file
|
@ -0,0 +1,80 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2022 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Cleanup logic
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
|
||||
from rattail.cleanup import Cleaner
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BeakerCleaner(Cleaner):
|
||||
"""
|
||||
Cleanup logic for old Beaker session files.
|
||||
"""
|
||||
|
||||
def get_session_dir(self):
|
||||
session_dir = self.config.get('rattail.cleanup', 'beaker.session_dir')
|
||||
if session_dir and os.path.isdir(session_dir):
|
||||
return session_dir
|
||||
|
||||
session_dir = os.path.join(self.config.appdir(), 'sessions')
|
||||
if os.path.isdir(session_dir):
|
||||
return session_dir
|
||||
|
||||
def cleanup(self, session, dry_run=False, progress=None, **kwargs):
|
||||
session_dir = self.get_session_dir()
|
||||
if not session_dir:
|
||||
return
|
||||
|
||||
data_dir = os.path.join(session_dir, 'data')
|
||||
lock_dir = os.path.join(session_dir, 'lock')
|
||||
|
||||
# looking for files older than X days
|
||||
days = self.config.getint('rattail.cleanup',
|
||||
'beaker.session_cutoff_days',
|
||||
default=30)
|
||||
cutoff = time.time() - 3600 * 24 * days
|
||||
|
||||
for topdir in (data_dir, lock_dir):
|
||||
if not os.path.isdir(topdir):
|
||||
continue
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(topdir):
|
||||
for fname in filenames:
|
||||
path = os.path.join(dirpath, fname)
|
||||
ts = os.path.getmtime(path)
|
||||
if ts <= cutoff:
|
||||
if dry_run:
|
||||
log.debug("would delete file: %s", path)
|
||||
else:
|
||||
os.remove(path)
|
||||
log.debug("deleted file: %s", path)
|
|
@ -1,8 +1,8 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2017 Lance Edgar
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,15 +24,16 @@
|
|||
Rattail config extension for Tailbone
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
import warnings
|
||||
|
||||
from wuttjamaican.conf import WuttaConfigExtension
|
||||
|
||||
from rattail.config import ConfigExtension as BaseExtension
|
||||
from rattail.db.config import configure_session
|
||||
|
||||
from tailbone.db import Session
|
||||
|
||||
|
||||
class ConfigExtension(BaseExtension):
|
||||
class ConfigExtension(WuttaConfigExtension):
|
||||
"""
|
||||
Rattail config extension for Tailbone. Does the following:
|
||||
|
||||
|
@ -47,3 +48,31 @@ class ConfigExtension(BaseExtension):
|
|||
def configure(self, config):
|
||||
Session.configure(rattail_config=config)
|
||||
configure_session(config, Session)
|
||||
|
||||
# provide default theme selection
|
||||
config.setdefault('tailbone', 'themes.keys', 'default, butterball')
|
||||
config.setdefault('tailbone', 'themes.expose_picker', 'true')
|
||||
|
||||
# override oruga detection
|
||||
config.setdefault('wuttaweb.oruga_detector.spec', 'tailbone.util:should_use_oruga')
|
||||
|
||||
|
||||
def csrf_token_name(config):
|
||||
return config.get('tailbone', 'csrf_token_name', default='_csrf')
|
||||
|
||||
|
||||
def csrf_header_name(config):
|
||||
return config.get('tailbone', 'csrf_header_name', default='X-CSRF-TOKEN')
|
||||
|
||||
|
||||
def global_help_url(config):
|
||||
return config.get('tailbone', 'global_help_url')
|
||||
|
||||
|
||||
def protected_usernames(config):
|
||||
return config.getlist('tailbone', 'protected_usernames')
|
||||
|
||||
|
||||
def should_expose_websockets(config):
|
||||
return config.getbool('tailbone', 'expose_websockets',
|
||||
usedb=False, default=False)
|
||||
|
|
145
tailbone/db.py
145
tailbone/db.py
|
@ -1,8 +1,8 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2017 Lance Edgar
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -21,11 +21,9 @@
|
|||
#
|
||||
################################################################################
|
||||
"""
|
||||
Database Stuff
|
||||
Database sessions etc.
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import sqlalchemy as sa
|
||||
from zope.sqlalchemy import datamanager
|
||||
import sqlalchemy_continuum as continuum
|
||||
|
@ -35,24 +33,37 @@ from rattail.db import SessionBase
|
|||
from rattail.db.continuum import versioning_manager
|
||||
|
||||
|
||||
Session = scoped_session(sessionmaker(class_=SessionBase, rattail_config=None, rattail_record_changes=False, expire_on_commit=False))
|
||||
Session = scoped_session(sessionmaker(class_=SessionBase, rattail_config=None, expire_on_commit=False))
|
||||
|
||||
# not necessarily used, but here if you need it
|
||||
TempmonSession = scoped_session(sessionmaker())
|
||||
TrainwreckSession = scoped_session(sessionmaker())
|
||||
|
||||
# empty dict for now, this must populated on app startup (if needed)
|
||||
ExtraTrainwreckSessions = {}
|
||||
|
||||
|
||||
class TailboneSessionDataManager(datamanager.SessionDataManager):
|
||||
"""Integrate a top level sqlalchemy session transaction into a zope transaction
|
||||
"""
|
||||
Integrate a top level sqlalchemy session transaction into a zope
|
||||
transaction
|
||||
|
||||
One phase variant.
|
||||
|
||||
.. note::
|
||||
This class appears to be necessary in order for the Continuum
|
||||
integration to work alongside the Zope transaction integration.
|
||||
|
||||
This class appears to be necessary in order for the
|
||||
SQLAlchemy-Continuum integration to work alongside the Zope
|
||||
transaction integration.
|
||||
|
||||
It subclasses
|
||||
``zope.sqlalchemy.datamanager.SessionDataManager`` but injects
|
||||
some SQLAlchemy-Continuum logic within :meth:`tpc_vote()`, and
|
||||
is sort of monkey-patched into the mix.
|
||||
"""
|
||||
|
||||
def tpc_vote(self, trans):
|
||||
""" """
|
||||
# for a one phase data manager commit last in tpc_vote
|
||||
if self.tx is not None: # there may have been no work to do
|
||||
|
||||
|
@ -64,25 +75,42 @@ class TailboneSessionDataManager(datamanager.SessionDataManager):
|
|||
self._finish('committed')
|
||||
|
||||
|
||||
def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
|
||||
"""Join a session to a transaction using the appropriate datamanager.
|
||||
def join_transaction(
|
||||
session,
|
||||
initial_state=datamanager.STATUS_ACTIVE,
|
||||
transaction_manager=datamanager.zope_transaction.manager,
|
||||
keep_session=False,
|
||||
):
|
||||
"""
|
||||
Join a session to a transaction using the appropriate datamanager.
|
||||
|
||||
It is safe to call this multiple times, if the session is already joined
|
||||
then it just returns.
|
||||
It is safe to call this multiple times, if the session is already
|
||||
joined then it just returns.
|
||||
|
||||
`initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY
|
||||
`initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or
|
||||
STATUS_READONLY
|
||||
|
||||
If using the default initial status of STATUS_ACTIVE, you must ensure that
|
||||
mark_changed(session) is called when data is written to the database.
|
||||
If using the default initial status of STATUS_ACTIVE, you must
|
||||
ensure that mark_changed(session) is called when data is written
|
||||
to the database.
|
||||
|
||||
The ZopeTransactionExtesion SessionExtension can be used to ensure that this is
|
||||
called automatically after session write operations.
|
||||
The ZopeTransactionExtesion SessionExtension can be used to ensure
|
||||
that this is called automatically after session write operations.
|
||||
|
||||
.. note::
|
||||
This function is copied from upstream, and tweaked so that our custom
|
||||
:class:`TailboneSessionDataManager` will be used.
|
||||
|
||||
This function appears to be necessary in order for the
|
||||
SQLAlchemy-Continuum integration to work alongside the Zope
|
||||
transaction integration.
|
||||
|
||||
It overrides ``zope.sqlalchemy.datamanager.join_transaction()``
|
||||
to ensure the custom :class:`TailboneSessionDataManager` is
|
||||
used, and is sort of monkey-patched into the mix.
|
||||
"""
|
||||
if datamanager._SESSION_STATE.get(id(session), None) is None:
|
||||
# the upstream internals of this function has changed a little over time.
|
||||
# unfortunately for us, that means we must include each variant here.
|
||||
|
||||
if datamanager._SESSION_STATE.get(session, None) is None:
|
||||
if session.twophase:
|
||||
DataManager = datamanager.TwoPhaseSessionDataManager
|
||||
else:
|
||||
|
@ -90,44 +118,74 @@ def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transacti
|
|||
DataManager(session, initial_state, transaction_manager, keep_session=keep_session)
|
||||
|
||||
|
||||
class ZopeTransactionExtension(datamanager.ZopeTransactionExtension):
|
||||
"""Record that a flush has occurred on a session's connection. This allows
|
||||
the DataManager to rollback rather than commit on read only transactions.
|
||||
class ZopeTransactionEvents(datamanager.ZopeTransactionEvents):
|
||||
"""
|
||||
Record that a flush has occurred on a session's connection. This
|
||||
allows the DataManager to rollback rather than commit on read only
|
||||
transactions.
|
||||
|
||||
.. note::
|
||||
This class is copied from upstream, and tweaked so that our custom
|
||||
:func:`join_transaction()` will be used.
|
||||
|
||||
This class appears to be necessary in order for the
|
||||
SQLAlchemy-Continuum integration to work alongside the Zope
|
||||
transaction integration.
|
||||
|
||||
It subclasses
|
||||
``zope.sqlalchemy.datamanager.ZopeTransactionEvents`` but
|
||||
overrides various methods to ensure the custom
|
||||
:func:`join_transaction()` is called, and is sort of
|
||||
monkey-patched into the mix.
|
||||
"""
|
||||
|
||||
def after_begin(self, session, transaction, connection):
|
||||
join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session)
|
||||
""" """
|
||||
join_transaction(session, self.initial_state,
|
||||
self.transaction_manager, self.keep_session)
|
||||
|
||||
def after_attach(self, session, instance):
|
||||
join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session)
|
||||
""" """
|
||||
join_transaction(session, self.initial_state,
|
||||
self.transaction_manager, self.keep_session)
|
||||
|
||||
def join_transaction(self, session):
|
||||
""" """
|
||||
join_transaction(session, self.initial_state,
|
||||
self.transaction_manager, self.keep_session)
|
||||
|
||||
|
||||
def register(session, initial_state=datamanager.STATUS_ACTIVE,
|
||||
transaction_manager=datamanager.zope_transaction.manager, keep_session=False):
|
||||
"""Register ZopeTransaction listener events on the
|
||||
given Session or Session factory/class.
|
||||
def register(
|
||||
session,
|
||||
initial_state=datamanager.STATUS_ACTIVE,
|
||||
transaction_manager=datamanager.zope_transaction.manager,
|
||||
keep_session=False,
|
||||
):
|
||||
"""
|
||||
Register ZopeTransaction listener events on the given Session or
|
||||
Session factory/class.
|
||||
|
||||
This function requires at least SQLAlchemy 0.7 and makes use
|
||||
of the newer sqlalchemy.event package in order to register event listeners
|
||||
on the given Session.
|
||||
This function requires at least SQLAlchemy 0.7 and makes use of
|
||||
the newer sqlalchemy.event package in order to register event
|
||||
listeners on the given Session.
|
||||
|
||||
The session argument here may be a Session class or subclass, a
|
||||
sessionmaker or scoped_session instance, or a specific Session instance.
|
||||
Event listening will be specific to the scope of the type of argument
|
||||
passed, including specificity to its subclass as well as its identity.
|
||||
sessionmaker or scoped_session instance, or a specific Session
|
||||
instance. Event listening will be specific to the scope of the
|
||||
type of argument passed, including specificity to its subclass as
|
||||
well as its identity.
|
||||
|
||||
.. note::
|
||||
This function is copied from upstream, and tweaked so that our custom
|
||||
:class:`ZopeTransactionExtension` will be used.
|
||||
|
||||
This function appears to be necessary in order for the
|
||||
SQLAlchemy-Continuum integration to work alongside the Zope
|
||||
transaction integration.
|
||||
|
||||
It overrides ``zope.sqlalchemy.datamanager.regsiter()`` to
|
||||
ensure the custom :class:`ZopeTransactionEvents` is used.
|
||||
"""
|
||||
from sqlalchemy import event
|
||||
|
||||
ext = ZopeTransactionExtension(
|
||||
initial_state=initial_state,
|
||||
ext = ZopeTransactionEvents(
|
||||
initial_state=initial_state,
|
||||
transaction_manager=transaction_manager,
|
||||
keep_session=keep_session,
|
||||
)
|
||||
|
@ -139,6 +197,9 @@ def register(session, initial_state=datamanager.STATUS_ACTIVE,
|
|||
event.listen(session, "after_bulk_delete", ext.after_bulk_delete)
|
||||
event.listen(session, "before_commit", ext.before_commit)
|
||||
|
||||
if datamanager.SA_GE_14:
|
||||
event.listen(session, "do_orm_execute", ext.do_orm_execute)
|
||||
|
||||
|
||||
register(Session)
|
||||
register(TempmonSession)
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2017 Lance Edgar
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,7 +24,8 @@
|
|||
Tools for displaying data diffs
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
import sqlalchemy as sa
|
||||
import sqlalchemy_continuum as continuum
|
||||
|
||||
from pyramid.renderers import render
|
||||
from webhelpers2.html import HTML
|
||||
|
@ -33,16 +34,43 @@ from webhelpers2.html import HTML
|
|||
class Diff(object):
|
||||
"""
|
||||
Core diff class. In sore need of documentation.
|
||||
|
||||
You must provide the old and new data sets, and the set of
|
||||
relevant fields as well, if they cannot be easily introspected.
|
||||
|
||||
:param old_data: Dict of "old" data values.
|
||||
|
||||
:param new_data: Dict of "old" data values.
|
||||
|
||||
:param fields: Sequence of relevant field names. Note that
|
||||
both data dicts are expected to have keys which match these
|
||||
field names. If you do not specify the fields then they
|
||||
will (hopefully) be introspected from the old or new data
|
||||
sets; however this will not work if they are both empty.
|
||||
|
||||
:param monospace: If true, this flag will cause the value
|
||||
columns to be rendered in monospace font. This is assumed
|
||||
to be helpful when comparing "raw" data values which are
|
||||
shown as e.g. ``repr(val)``.
|
||||
|
||||
:param enums: Optional dict of enums for use when displaying field
|
||||
values. If specified, keys should be field names and values
|
||||
should be enum dicts.
|
||||
"""
|
||||
|
||||
def __init__(self, old_data, new_data, columns=None, fields=None, render_field=None, render_value=None, monospace=False):
|
||||
def __init__(self, old_data, new_data, columns=None, fields=None, enums=None,
|
||||
render_field=None, render_value=None, nature='dirty',
|
||||
monospace=False, extra_row_attrs=None):
|
||||
self.old_data = old_data
|
||||
self.new_data = new_data
|
||||
self.columns = columns or ["field name", "old value", "new value"]
|
||||
self.fields = fields or self.make_fields()
|
||||
self.enums = enums or {}
|
||||
self._render_field = render_field or self.render_field_default
|
||||
self.render_value = render_value or self.render_value_default
|
||||
self.nature = nature
|
||||
self.monospace = monospace
|
||||
self.extra_row_attrs = extra_row_attrs
|
||||
|
||||
def make_fields(self):
|
||||
return sorted(set(self.old_data) | set(self.new_data), key=lambda x: x.lower())
|
||||
|
@ -61,6 +89,32 @@ class Diff(object):
|
|||
context['diff'] = self
|
||||
return HTML.literal(render(template, context))
|
||||
|
||||
def get_row_attrs(self, field):
|
||||
"""
|
||||
Returns a *rendered* set of extra attributes for the ``<tr>`` element
|
||||
for the given field. May be an empty string, or a snippet of HTML
|
||||
attribute syntax, e.g.:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
class="diff" foo="bar"
|
||||
|
||||
If you wish to supply additional attributes, please define
|
||||
:attr:`extra_row_attrs`, which can be either a static dict, or a
|
||||
callable returning a dict.
|
||||
"""
|
||||
attrs = {}
|
||||
if self.values_differ(field):
|
||||
attrs['class'] = 'diff'
|
||||
|
||||
if self.extra_row_attrs:
|
||||
if callable(self.extra_row_attrs):
|
||||
attrs.update(self.extra_row_attrs(field, attrs))
|
||||
else:
|
||||
attrs.update(self.extra_row_attrs)
|
||||
|
||||
return HTML.render_attrs(attrs)
|
||||
|
||||
def render_field(self, field):
|
||||
return self._render_field(field, self)
|
||||
|
||||
|
@ -77,3 +131,161 @@ class Diff(object):
|
|||
def render_new_value(self, field):
|
||||
value = self.new_value(field)
|
||||
return self.render_value(field, value)
|
||||
|
||||
|
||||
class VersionDiff(Diff):
|
||||
"""
|
||||
Special diff class, for use with version history views. Note that
|
||||
while based on :class:`Diff`, this class uses a different
|
||||
signature for the constructor.
|
||||
|
||||
:param version: Reference to a Continuum version record (object).
|
||||
|
||||
:param \*args: Typical usage will not require positional args
|
||||
beyond the ``version`` param, in which case ``old_data`` and
|
||||
``new_data`` params will be auto-determined based on the
|
||||
``version``. But if you specify positional args then nothing
|
||||
automatic is done, they are passed as-is to the parent
|
||||
:class:`Diff` constructor.
|
||||
|
||||
:param \*\*kwargs: Remaining kwargs are passed as-is to the
|
||||
:class:`Diff` constructor.
|
||||
"""
|
||||
|
||||
def __init__(self, version, *args, **kwargs):
|
||||
self.version = version
|
||||
self.mapper = sa.inspect(continuum.parent_class(type(self.version)))
|
||||
self.version_mapper = sa.inspect(type(self.version))
|
||||
self.title = kwargs.pop('title', None)
|
||||
|
||||
if 'nature' not in kwargs:
|
||||
if version.previous and version.operation_type == continuum.Operation.DELETE:
|
||||
kwargs['nature'] = 'deleted'
|
||||
elif version.previous:
|
||||
kwargs['nature'] = 'dirty'
|
||||
else:
|
||||
kwargs['nature'] = 'new'
|
||||
|
||||
if 'fields' not in kwargs:
|
||||
kwargs['fields'] = self.get_default_fields()
|
||||
|
||||
if not args:
|
||||
old_data = {}
|
||||
new_data = {}
|
||||
for field in kwargs['fields']:
|
||||
if version.previous:
|
||||
old_data[field] = getattr(version.previous, field)
|
||||
new_data[field] = getattr(version, field)
|
||||
args = (old_data, new_data)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_default_fields(self):
|
||||
fields = sorted(self.version_mapper.columns.keys())
|
||||
|
||||
unwanted = [
|
||||
'transaction_id',
|
||||
'end_transaction_id',
|
||||
'operation_type',
|
||||
]
|
||||
|
||||
return [field for field in fields
|
||||
if field not in unwanted]
|
||||
|
||||
def render_version_value(self, field, value, version):
|
||||
"""
|
||||
Render the cell value text for the given version/field info.
|
||||
|
||||
Note that this method is used to render both sides of the diff
|
||||
(before and after values).
|
||||
|
||||
:param field: Name of the field, as string.
|
||||
|
||||
:param value: Raw value for the field, as obtained from ``version``.
|
||||
|
||||
:param version: Reference to the Continuum version object.
|
||||
|
||||
:returns: Rendered text as string, or ``None``.
|
||||
"""
|
||||
text = HTML.tag('span', c=[repr(value)],
|
||||
style='font-family: monospace;')
|
||||
|
||||
# assume the enum display is all we need, if enum exists for the field
|
||||
if field in self.enums:
|
||||
|
||||
# but skip the enum display if None
|
||||
display = self.enums[field].get(value)
|
||||
if display is None and value is None:
|
||||
return text
|
||||
|
||||
# otherwise show enum display to the right of raw value
|
||||
display = self.enums[field].get(value, str(value))
|
||||
return HTML.tag('span', c=[
|
||||
text,
|
||||
HTML.tag('span', c=[display],
|
||||
style='margin-left: 2rem; font-style: italic; font-weight: bold;'),
|
||||
])
|
||||
|
||||
# next we look for a relationship and may render the foreign object
|
||||
for prop in self.mapper.relationships:
|
||||
if prop.uselist:
|
||||
continue
|
||||
|
||||
for col in prop.local_columns:
|
||||
if col.name != field:
|
||||
continue
|
||||
|
||||
if not hasattr(version, prop.key):
|
||||
continue
|
||||
|
||||
if col in self.mapper.primary_key:
|
||||
continue
|
||||
|
||||
ref = getattr(version, prop.key)
|
||||
if ref:
|
||||
ref = getattr(ref, 'version_parent', None)
|
||||
if ref:
|
||||
return HTML.tag('span', c=[
|
||||
text,
|
||||
HTML.tag('span', c=[str(ref)],
|
||||
style='margin-left: 2rem; font-style: italic; font-weight: bold;'),
|
||||
])
|
||||
|
||||
return text
|
||||
|
||||
def render_old_value(self, field):
|
||||
if self.nature == 'new':
|
||||
return ''
|
||||
value = self.old_value(field)
|
||||
return self.render_version_value(field, value, self.version.previous)
|
||||
|
||||
def render_new_value(self, field):
|
||||
if self.nature == 'deleted':
|
||||
return ''
|
||||
value = self.new_value(field)
|
||||
return self.render_version_value(field, value, self.version)
|
||||
|
||||
def as_struct(self):
|
||||
values = {}
|
||||
for field in self.fields:
|
||||
values[field] = {'before': self.render_old_value(field),
|
||||
'after': self.render_new_value(field)}
|
||||
|
||||
operation = None
|
||||
if self.version.operation_type == continuum.Operation.INSERT:
|
||||
operation = 'INSERT'
|
||||
elif self.version.operation_type == continuum.Operation.UPDATE:
|
||||
operation = 'UPDATE'
|
||||
elif self.version.operation_type == continuum.Operation.DELETE:
|
||||
operation = 'DELETE'
|
||||
else:
|
||||
operation = self.version.operation_type
|
||||
|
||||
return {
|
||||
'key': id(self.version),
|
||||
'model_title': self.title,
|
||||
'operation': operation,
|
||||
'diff_class': self.nature,
|
||||
'fields': self.fields,
|
||||
'values': values,
|
||||
}
|
||||
|
|
49
tailbone/exceptions.py
Normal file
49
tailbone/exceptions.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Tailbone Exceptions
|
||||
"""
|
||||
|
||||
from rattail.exceptions import RattailError
|
||||
|
||||
|
||||
class TailboneError(RattailError):
|
||||
"""
|
||||
Base class for all Tailbone exceptions.
|
||||
"""
|
||||
|
||||
|
||||
class TailboneJSONFieldError(TailboneError):
|
||||
"""
|
||||
Error raised when JSON serialization of a form field results in an error.
|
||||
This is just a simple wrapper, to make the error message more helpful for
|
||||
the developer.
|
||||
"""
|
||||
|
||||
def __init__(self, field, error):
|
||||
self.field = field
|
||||
self.error = error
|
||||
|
||||
def __str__(self):
|
||||
return ("Failed to serialize field '{}' as JSON! "
|
||||
"Original error was: {}".format(self.field, self.error))
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2017 Lance Edgar
|
||||
# Copyright © 2010-2023 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,8 +24,7 @@
|
|||
Forms Library
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
from . import types
|
||||
# nb. import widgets before types, b/c types may refer to widgets
|
||||
from . import widgets
|
||||
from .core import Form
|
||||
from . import types
|
||||
from .core import Form, SimpleFileImport
|
||||
|
|
62
tailbone/forms/common.py
Normal file
62
tailbone/forms/common.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2023 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Common Forms
|
||||
"""
|
||||
|
||||
from rattail.db import model
|
||||
|
||||
import colander
|
||||
|
||||
|
||||
@colander.deferred
|
||||
def validate_user(node, kw):
|
||||
session = kw['session']
|
||||
def validate(node, value):
|
||||
user = session.get(model.User, value)
|
||||
if not user:
|
||||
raise colander.Invalid(node, "User not found")
|
||||
return user.uuid
|
||||
return validate
|
||||
|
||||
|
||||
class Feedback(colander.Schema):
|
||||
"""
|
||||
Form schema for user feedback.
|
||||
"""
|
||||
email_key = colander.SchemaNode(colander.String(),
|
||||
missing=colander.null)
|
||||
|
||||
referrer = colander.SchemaNode(colander.String())
|
||||
|
||||
user = colander.SchemaNode(colander.String(),
|
||||
missing=colander.null,
|
||||
validator=validate_user)
|
||||
|
||||
user_name = colander.SchemaNode(colander.String(),
|
||||
missing=colander.null)
|
||||
|
||||
please_reply_to = colander.SchemaNode(colander.String(),
|
||||
missing=colander.null)
|
||||
|
||||
message = colander.SchemaNode(colander.String())
|
File diff suppressed because it is too large
Load diff
68
tailbone/forms/receiving.py
Normal file
68
tailbone/forms/receiving.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2023 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Forms for Receiving
|
||||
"""
|
||||
|
||||
from rattail.db import model
|
||||
|
||||
import colander
|
||||
|
||||
|
||||
@colander.deferred
|
||||
def valid_purchase_batch_row(node, kw):
|
||||
session = kw['session']
|
||||
def validate(node, value):
|
||||
row = session.get(model.PurchaseBatchRow, value)
|
||||
if not row:
|
||||
raise colander.Invalid(node, "Batch row not found")
|
||||
if row.batch.executed:
|
||||
raise colander.Invalid(node, "Batch has already been executed")
|
||||
return row.uuid
|
||||
return validate
|
||||
|
||||
|
||||
class ReceiveRow(colander.MappingSchema):
|
||||
|
||||
row = colander.SchemaNode(colander.String(),
|
||||
validator=valid_purchase_batch_row)
|
||||
|
||||
mode = colander.SchemaNode(colander.String(),
|
||||
validator=colander.OneOf([
|
||||
'received',
|
||||
'damaged',
|
||||
'expired',
|
||||
'missing',
|
||||
# 'mispick',
|
||||
]))
|
||||
|
||||
cases = colander.SchemaNode(colander.Decimal(),
|
||||
missing=colander.null)
|
||||
|
||||
units = colander.SchemaNode(colander.Decimal(),
|
||||
missing=colander.null)
|
||||
|
||||
expiration_date = colander.SchemaNode(colander.Date(),
|
||||
missing=colander.null)
|
||||
|
||||
quick_receive = colander.SchemaNode(colander.Boolean())
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2018 Lance Edgar
|
||||
# Copyright © 2010-2023 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,11 +24,9 @@
|
|||
Form Schema Types
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import re
|
||||
|
||||
import six
|
||||
import datetime
|
||||
import json
|
||||
|
||||
from rattail.db import model
|
||||
from rattail.gpc import GPC
|
||||
|
@ -36,6 +34,7 @@ from rattail.gpc import GPC
|
|||
import colander
|
||||
|
||||
from tailbone.db import Session
|
||||
from tailbone.forms import widgets
|
||||
|
||||
|
||||
class JQueryTime(colander.Time):
|
||||
|
@ -55,12 +54,94 @@ class JQueryTime(colander.Time):
|
|||
]
|
||||
for fmt in formats:
|
||||
try:
|
||||
return colander.timeparse(cstruct, fmt)
|
||||
return datetime.datetime.strptime(cstruct, fmt).time()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# re-try first format, for "better" error message
|
||||
return colander.timeparse(cstruct, formats[0])
|
||||
return datetime.datetime.strptime(cstruct, formats[0]).time()
|
||||
|
||||
|
||||
class DateTimeBoolean(colander.Boolean):
|
||||
"""
|
||||
Schema type which presents the user with a "boolean" whereas the underlying
|
||||
node is really a datetime (assumed to be "naive" UTC, and allow nulls).
|
||||
"""
|
||||
|
||||
def deserialize(self, node, cstruct):
|
||||
value = super(DateTimeBoolean, self).deserialize(node, cstruct)
|
||||
if value: # else return None
|
||||
return datetime.datetime.utcnow()
|
||||
|
||||
|
||||
class FalafelDateTime(colander.DateTime):
|
||||
"""
|
||||
Custom schema node type for rattail UTC datetimes
|
||||
"""
|
||||
widget_maker = widgets.FalafelDateTimeWidget
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
request = kwargs.pop('request')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.request = request
|
||||
|
||||
def serialize(self, node, appstruct):
|
||||
if not appstruct:
|
||||
return {}
|
||||
|
||||
# cant use isinstance; dt subs date
|
||||
if type(appstruct) is datetime.date:
|
||||
appstruct = datetime.datetime.combine(appstruct, datetime.time())
|
||||
|
||||
if not isinstance(appstruct, datetime.datetime):
|
||||
raise colander.Invalid(node, f'"{appstruct}" is not a datetime object')
|
||||
|
||||
if appstruct.tzinfo is None:
|
||||
appstruct = appstruct.replace(tzinfo=self.default_tzinfo)
|
||||
|
||||
app = self.request.rattail_config.get_app()
|
||||
dt = app.localtime(appstruct, from_utc=True)
|
||||
|
||||
return {
|
||||
'date': str(dt.date()),
|
||||
'time': str(dt.time()),
|
||||
}
|
||||
|
||||
def deserialize(self, node, cstruct):
|
||||
if not cstruct:
|
||||
return colander.null
|
||||
|
||||
if not cstruct['date'] and not cstruct['time']:
|
||||
return colander.null
|
||||
|
||||
try:
|
||||
date = datetime.datetime.strptime(cstruct['date'], '%Y-%m-%d').date()
|
||||
except:
|
||||
node.raise_invalid("Missing or invalid date")
|
||||
|
||||
try:
|
||||
time = datetime.datetime.strptime(cstruct['time'], '%H:%M:%S').time()
|
||||
except:
|
||||
node.raise_invalid("Missing or invalid time")
|
||||
|
||||
result = datetime.datetime.combine(date, time)
|
||||
|
||||
app = self.request.rattail_config.get_app()
|
||||
result = app.localtime(result)
|
||||
result = app.make_utc(result)
|
||||
return result
|
||||
|
||||
|
||||
class FalafelTime(colander.Time):
|
||||
"""
|
||||
Custom schema node type for simple time fields
|
||||
"""
|
||||
widget_maker = widgets.FalafelTimeWidget
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
request = kwargs.pop('request')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.request = request
|
||||
|
||||
|
||||
class GPCType(colander.SchemaType):
|
||||
|
@ -71,7 +152,7 @@ class GPCType(colander.SchemaType):
|
|||
def serialize(self, node, appstruct):
|
||||
if appstruct is colander.null:
|
||||
return colander.null
|
||||
return six.text_type(appstruct)
|
||||
return str(appstruct)
|
||||
|
||||
def deserialize(self, node, cstruct):
|
||||
if not cstruct:
|
||||
|
@ -82,7 +163,17 @@ class GPCType(colander.SchemaType):
|
|||
try:
|
||||
return GPC(digits)
|
||||
except Exception as err:
|
||||
raise colander.Invalid(node, six.text_type(err))
|
||||
raise colander.Invalid(node, str(err))
|
||||
|
||||
|
||||
class ProductQuantity(colander.MappingSchema):
|
||||
"""
|
||||
Combo schema type for product cases and units; useful for inventory,
|
||||
ordering, receiving etc. Meant to be used with the ``CasesUnitsWidget``.
|
||||
"""
|
||||
cases = colander.SchemaNode(colander.Decimal(), missing=colander.null)
|
||||
|
||||
units = colander.SchemaNode(colander.Decimal(), missing=colander.null)
|
||||
|
||||
|
||||
class ModelType(colander.SchemaType):
|
||||
|
@ -110,12 +201,12 @@ class ModelType(colander.SchemaType):
|
|||
def serialize(self, node, appstruct):
|
||||
if appstruct is colander.null:
|
||||
return colander.null
|
||||
return six.text_type(appstruct)
|
||||
return str(appstruct)
|
||||
|
||||
def deserialize(self, node, cstruct):
|
||||
if not cstruct:
|
||||
return None
|
||||
obj = self.session.query(self.model_class).get(cstruct)
|
||||
obj = self.session.get(self.model_class, cstruct)
|
||||
if not obj:
|
||||
raise colander.Invalid(node, "{} not found".format(self.model_title))
|
||||
return obj
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2018 Lance Edgar
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,23 +24,24 @@
|
|||
Form Widgets
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import json
|
||||
import datetime
|
||||
|
||||
import six
|
||||
import decimal
|
||||
import re
|
||||
|
||||
import colander
|
||||
from deform import widget as dfwidget
|
||||
from webhelpers2.html import tags, HTML
|
||||
|
||||
from tailbone.db import Session
|
||||
|
||||
|
||||
class ReadonlyWidget(dfwidget.HiddenWidget):
|
||||
|
||||
readonly = True
|
||||
|
||||
def serialize(self, field, cstruct, **kw):
|
||||
""" """
|
||||
if cstruct in (colander.null, None):
|
||||
cstruct = ''
|
||||
# TODO: is this hacky?
|
||||
|
@ -55,14 +56,150 @@ class NumberInputWidget(dfwidget.TextInputWidget):
|
|||
autocomplete = 'off'
|
||||
|
||||
|
||||
class NumericInputWidget(NumberInputWidget):
|
||||
"""
|
||||
This widget uses a ``<numeric-input>`` component, which will
|
||||
leverage the ``numeric.js`` functions to ensure user doesn't enter
|
||||
any non-numeric values. Note that this still uses a normal "text"
|
||||
input on the HTML side, as opposed to a "number" input, since the
|
||||
latter is a bit ugly IMHO.
|
||||
"""
|
||||
template = 'numericinput'
|
||||
allow_enter = True
|
||||
|
||||
|
||||
class PercentInputWidget(dfwidget.TextInputWidget):
|
||||
"""
|
||||
Custom text input widget, used for "percent" type fields. This widget
|
||||
assumes that the underlying storage for the value is a "traditional"
|
||||
percent value, e.g. ``0.36135`` - but the UI should represent this as a
|
||||
"human-friendly" value, e.g. ``36.135 %``.
|
||||
"""
|
||||
template = 'percentinput'
|
||||
autocomplete = 'off'
|
||||
|
||||
def serialize(self, field, cstruct, **kw):
|
||||
""" """
|
||||
if cstruct not in (colander.null, None):
|
||||
# convert "traditional" value to "human-friendly"
|
||||
value = decimal.Decimal(cstruct) * 100
|
||||
value = value.quantize(decimal.Decimal('0.001'))
|
||||
cstruct = str(value)
|
||||
return super().serialize(field, cstruct, **kw)
|
||||
|
||||
def deserialize(self, field, pstruct):
|
||||
""" """
|
||||
pstruct = super().deserialize(field, pstruct)
|
||||
if pstruct is colander.null:
|
||||
return colander.null
|
||||
# convert "human-friendly" value to "traditional"
|
||||
try:
|
||||
value = decimal.Decimal(pstruct)
|
||||
except decimal.InvalidOperation:
|
||||
raise colander.Invalid(field.schema, "Invalid decimal string: {}".format(pstruct))
|
||||
value = value.quantize(decimal.Decimal('0.00001'))
|
||||
value /= 100
|
||||
return str(value)
|
||||
|
||||
|
||||
class CasesUnitsWidget(dfwidget.Widget):
|
||||
"""
|
||||
Widget for collecting case and/or unit quantities. Most useful when you
|
||||
need to ensure user provides cases *or* units but not both.
|
||||
"""
|
||||
template = 'cases_units'
|
||||
amount_required = False
|
||||
one_amount_only = False
|
||||
|
||||
def serialize(self, field, cstruct, **kw):
|
||||
""" """
|
||||
if cstruct in (colander.null, None):
|
||||
cstruct = ''
|
||||
readonly = kw.get('readonly', self.readonly)
|
||||
kw['cases'] = cstruct['cases'] or ''
|
||||
kw['units'] = cstruct['units'] or ''
|
||||
template = readonly and self.readonly_template or self.template
|
||||
values = self.get_template_values(field, cstruct, kw)
|
||||
return field.renderer(template, **values)
|
||||
|
||||
def deserialize(self, field, pstruct):
|
||||
""" """
|
||||
from tailbone.forms.types import ProductQuantity
|
||||
|
||||
if pstruct is colander.null:
|
||||
return colander.null
|
||||
|
||||
schema = ProductQuantity()
|
||||
try:
|
||||
validated = schema.deserialize(pstruct)
|
||||
except colander.Invalid as exc:
|
||||
raise colander.Invalid(field.schema, "Invalid pstruct: %s" % exc)
|
||||
|
||||
if self.amount_required and not (validated['cases'] or validated['units']):
|
||||
raise colander.Invalid(field.schema, "Must provide case or unit amount",
|
||||
value=validated)
|
||||
|
||||
if self.amount_required and self.one_amount_only and validated['cases'] and validated['units']:
|
||||
raise colander.Invalid(field.schema, "Must provide case *or* unit amount, "
|
||||
"but *not* both", value=validated)
|
||||
|
||||
return validated
|
||||
|
||||
|
||||
class DynamicCheckboxWidget(dfwidget.CheckboxWidget):
|
||||
"""
|
||||
This checkbox widget can be "dynamic" in the sense that form logic can
|
||||
control its value and state.
|
||||
"""
|
||||
template = 'checkbox_dynamic'
|
||||
|
||||
|
||||
# TODO: deprecate / remove this
|
||||
class PlainSelectWidget(dfwidget.SelectWidget):
|
||||
template = 'select_plain'
|
||||
|
||||
|
||||
class CustomSelectWidget(dfwidget.SelectWidget):
|
||||
"""
|
||||
This widget is mostly for convenience. You can set extra kwargs for the
|
||||
:meth:`serialize()` method, e.g.::
|
||||
|
||||
widget.set_template_values(foo='bar')
|
||||
"""
|
||||
|
||||
def set_template_values(self, **kw):
|
||||
if not hasattr(self, 'extra_template_values'):
|
||||
self.extra_template_values = {}
|
||||
self.extra_template_values.update(kw)
|
||||
|
||||
def get_template_values(self, field, cstruct, kw):
|
||||
values = super().get_template_values(field, cstruct, kw)
|
||||
if hasattr(self, 'extra_template_values'):
|
||||
values.update(self.extra_template_values)
|
||||
return values
|
||||
|
||||
|
||||
class DynamicSelectWidget(CustomSelectWidget):
|
||||
"""
|
||||
This is a "normal" select widget, but instead of (or in addition to) its
|
||||
values being set when constructed, they must be assigned dynamically in
|
||||
real-time, e.g. based on other user selections.
|
||||
|
||||
Really all this widget "does" is render some Vue.js-compatible HTML, but
|
||||
the page which contains the widget is ultimately responsible for wiring up
|
||||
the logic for things to work right.
|
||||
"""
|
||||
template = 'select_dynamic'
|
||||
|
||||
|
||||
class JQuerySelectWidget(dfwidget.SelectWidget):
|
||||
template = 'select_jquery'
|
||||
|
||||
|
||||
class PlainDateWidget(dfwidget.DateInputWidget):
|
||||
template = 'date_plain'
|
||||
|
||||
|
||||
class JQueryDateWidget(dfwidget.DateInputWidget):
|
||||
"""
|
||||
Uses the jQuery datepicker UI widget, instead of whatever it is deform uses
|
||||
|
@ -79,6 +216,7 @@ class JQueryDateWidget(dfwidget.DateInputWidget):
|
|||
)
|
||||
|
||||
def serialize(self, field, cstruct, **kw):
|
||||
""" """
|
||||
if cstruct in (colander.null, None):
|
||||
cstruct = ''
|
||||
readonly = kw.get('readonly', self.readonly)
|
||||
|
@ -106,6 +244,48 @@ class JQueryTimeWidget(dfwidget.TimeInputWidget):
|
|||
)
|
||||
|
||||
|
||||
class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget):
|
||||
"""
|
||||
Custom widget for rattail UTC datetimes
|
||||
"""
|
||||
template = 'datetime_falafel'
|
||||
|
||||
new_pattern = re.compile(r'^\d\d?:\d\d:\d\d [AP]M$')
|
||||
|
||||
def serialize(self, field, cstruct, **kw):
|
||||
""" """
|
||||
readonly = kw.get('readonly', self.readonly)
|
||||
values = self.get_template_values(field, cstruct, kw)
|
||||
template = self.readonly_template if readonly else self.template
|
||||
return field.renderer(template, **values)
|
||||
|
||||
def deserialize(self, field, pstruct):
|
||||
""" """
|
||||
if pstruct == '':
|
||||
return colander.null
|
||||
|
||||
# nb. we now allow '4:20:00 PM' on the widget side, but the
|
||||
# true node needs it to be '16:20:00' instead
|
||||
if self.new_pattern.match(pstruct['time']):
|
||||
time = datetime.datetime.strptime(pstruct['time'], '%I:%M:%S %p')
|
||||
pstruct['time'] = time.strftime('%H:%M:%S')
|
||||
|
||||
return pstruct
|
||||
|
||||
|
||||
class FalafelTimeWidget(dfwidget.TimeInputWidget):
|
||||
"""
|
||||
Custom widget for simple time fields
|
||||
"""
|
||||
template = 'time_falafel'
|
||||
|
||||
def deserialize(self, field, pstruct):
|
||||
""" """
|
||||
if pstruct == '':
|
||||
return colander.null
|
||||
return pstruct
|
||||
|
||||
|
||||
class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
|
||||
"""
|
||||
Uses the jQuery autocomplete plugin, instead of whatever it is deform uses
|
||||
|
@ -114,9 +294,13 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
|
|||
template = 'autocomplete_jquery'
|
||||
requirements = None
|
||||
field_display = ""
|
||||
assigned_label = None
|
||||
service_url = None
|
||||
cleared_callback = None
|
||||
selected_callback = None
|
||||
input_callback = None
|
||||
new_label_callback = None
|
||||
ref = None
|
||||
|
||||
default_options = (
|
||||
('autoFocus', True),
|
||||
|
@ -124,6 +308,7 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
|
|||
options = None
|
||||
|
||||
def serialize(self, field, cstruct, **kw):
|
||||
""" """
|
||||
if 'delay' in kw or getattr(self, 'delay', None):
|
||||
raise ValueError(
|
||||
'AutocompleteWidget does not support *delay* parameter '
|
||||
|
@ -142,7 +327,333 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
|
|||
kw['options'] = json.dumps(options)
|
||||
kw['field_display'] = self.field_display
|
||||
kw['cleared_callback'] = self.cleared_callback
|
||||
kw['assigned_label'] = self.assigned_label
|
||||
kw['input_callback'] = self.input_callback
|
||||
kw['new_label_callback'] = self.new_label_callback
|
||||
kw['ref'] = self.ref
|
||||
kw.setdefault('selected_callback', self.selected_callback)
|
||||
tmpl_values = self.get_template_values(field, cstruct, kw)
|
||||
template = readonly and self.readonly_template or self.template
|
||||
return field.renderer(template, **tmpl_values)
|
||||
|
||||
|
||||
class FileUploadWidget(dfwidget.FileUploadWidget):
|
||||
"""
|
||||
Widget to handle file upload. Must override to add ``use_oruga``
|
||||
to field template context.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.request = kwargs.pop('request')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_template_values(self, field, cstruct, kw):
|
||||
values = super().get_template_values(field, cstruct, kw)
|
||||
if self.request:
|
||||
values['use_oruga'] = self.request.use_oruga
|
||||
return values
|
||||
|
||||
|
||||
class MultiFileUploadWidget(dfwidget.FileUploadWidget):
|
||||
"""
|
||||
Widget to handle multiple (arbitrary number) of file uploads.
|
||||
"""
|
||||
template = 'multi_file_upload'
|
||||
requirements = ()
|
||||
|
||||
def serialize(self, field, cstruct, **kw):
|
||||
""" """
|
||||
if cstruct in (colander.null, None):
|
||||
cstruct = []
|
||||
|
||||
if cstruct:
|
||||
for fileinfo in cstruct:
|
||||
uid = fileinfo['uid']
|
||||
if uid not in self.tmpstore:
|
||||
self.tmpstore[uid] = fileinfo
|
||||
|
||||
readonly = kw.get("readonly", self.readonly)
|
||||
template = readonly and self.readonly_template or self.template
|
||||
values = self.get_template_values(field, cstruct, kw)
|
||||
return field.renderer(template, **values)
|
||||
|
||||
def deserialize(self, field, pstruct):
|
||||
""" """
|
||||
if pstruct is colander.null:
|
||||
return colander.null
|
||||
|
||||
# TODO: why is this a thing? pstruct == [b'']
|
||||
if len(pstruct) == 1 and pstruct[0] == b'':
|
||||
return colander.null
|
||||
|
||||
files_data = []
|
||||
for upload in pstruct:
|
||||
|
||||
data = self.deserialize_upload(upload)
|
||||
if data:
|
||||
files_data.append(data)
|
||||
|
||||
if not files_data:
|
||||
return colander.null
|
||||
|
||||
return files_data
|
||||
|
||||
def deserialize_upload(self, upload):
|
||||
""" """
|
||||
# nb. this logic was copied from parent class and adapted
|
||||
# to allow for multiple files. needs some more love.
|
||||
|
||||
uid = None # TODO?
|
||||
|
||||
if hasattr(upload, "file"):
|
||||
# the upload control had a file selected
|
||||
data = dfwidget.filedict()
|
||||
data["fp"] = upload.file
|
||||
filename = upload.filename
|
||||
# sanitize IE whole-path filenames
|
||||
filename = filename[filename.rfind("\\") + 1 :].strip()
|
||||
data["filename"] = filename
|
||||
data["mimetype"] = upload.type
|
||||
data["size"] = upload.length
|
||||
if uid is None:
|
||||
# no previous file exists
|
||||
while 1:
|
||||
uid = self.random_id()
|
||||
if self.tmpstore.get(uid) is None:
|
||||
data["uid"] = uid
|
||||
self.tmpstore[uid] = data
|
||||
preview_url = self.tmpstore.preview_url(uid)
|
||||
self.tmpstore[uid]["preview_url"] = preview_url
|
||||
break
|
||||
else:
|
||||
# a previous file exists
|
||||
data["uid"] = uid
|
||||
self.tmpstore[uid] = data
|
||||
preview_url = self.tmpstore.preview_url(uid)
|
||||
self.tmpstore[uid]["preview_url"] = preview_url
|
||||
else:
|
||||
# the upload control had no file selected
|
||||
if uid is None:
|
||||
# no previous file exists
|
||||
return colander.null
|
||||
else:
|
||||
# a previous file should exist
|
||||
data = self.tmpstore.get(uid)
|
||||
# but if it doesn't, don't blow up
|
||||
if data is None:
|
||||
return colander.null
|
||||
return data
|
||||
|
||||
|
||||
def make_customer_widget(request, **kwargs):
|
||||
"""
|
||||
Make a customer widget; will be either autocomplete or dropdown
|
||||
depending on config.
|
||||
"""
|
||||
# use autocomplete widget by default
|
||||
factory = CustomerAutocompleteWidget
|
||||
|
||||
# caller may request dropdown widget
|
||||
if kwargs.pop('dropdown', False):
|
||||
factory = CustomerDropdownWidget
|
||||
|
||||
else: # or, config may say to use dropdown
|
||||
if request.rattail_config.getbool(
|
||||
'rattail', 'customers.choice_uses_dropdown',
|
||||
default=False):
|
||||
factory = CustomerDropdownWidget
|
||||
|
||||
# instantiate whichever
|
||||
return factory(request, **kwargs)
|
||||
|
||||
|
||||
class CustomerAutocompleteWidget(JQueryAutocompleteWidget):
|
||||
"""
|
||||
Autocomplete widget for a
|
||||
:class:`~rattail:rattail.db.model.customers.Customer` reference
|
||||
field.
|
||||
"""
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.request = request
|
||||
app = self.request.rattail_config.get_app()
|
||||
model = app.model
|
||||
|
||||
# must figure out URL providing autocomplete service
|
||||
if 'service_url' not in kwargs:
|
||||
|
||||
# caller can just pass 'url' instead of 'service_url'
|
||||
if 'url' in kwargs:
|
||||
self.service_url = kwargs['url']
|
||||
|
||||
else: # use default url
|
||||
self.service_url = self.request.route_url('customers.autocomplete')
|
||||
|
||||
# TODO
|
||||
if 'input_callback' not in kwargs:
|
||||
if 'input_handler' in kwargs:
|
||||
self.input_callback = input_handler
|
||||
|
||||
def serialize(self, field, cstruct, **kw):
|
||||
""" """
|
||||
# fetch customer to provide button label, if we have a value
|
||||
if cstruct:
|
||||
app = self.request.rattail_config.get_app()
|
||||
model = app.model
|
||||
customer = Session.get(model.Customer, cstruct)
|
||||
if customer:
|
||||
self.field_display = str(customer)
|
||||
|
||||
return super().serialize(
|
||||
field, cstruct, **kw)
|
||||
|
||||
|
||||
class CustomerDropdownWidget(dfwidget.SelectWidget):
|
||||
"""
|
||||
Dropdown widget for a
|
||||
:class:`~rattail:rattail.db.model.customers.Customer` reference
|
||||
field.
|
||||
"""
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.request = request
|
||||
app = self.request.rattail_config.get_app()
|
||||
|
||||
# must figure out dropdown values, if they weren't given
|
||||
if 'values' not in kwargs:
|
||||
|
||||
# use what caller gave us, if they did
|
||||
if 'customers' in kwargs:
|
||||
customers = kwargs['customers']
|
||||
if callable(customers):
|
||||
customers = customers()
|
||||
|
||||
else: # default customer list
|
||||
customers = app.get_clientele_handler()\
|
||||
.get_all_customers(Session())
|
||||
|
||||
# convert customer list to option values
|
||||
self.values = [(c.uuid, c.name)
|
||||
for c in customers]
|
||||
|
||||
|
||||
class DepartmentWidget(dfwidget.SelectWidget):
|
||||
"""
|
||||
Custom select widget for a Department reference field.
|
||||
|
||||
Constructor accepts the normal ``values`` kwarg but if not
|
||||
provided then the widget will fetch department list from Rattail
|
||||
DB.
|
||||
|
||||
Constructor also accepts ``required`` kwarg, which defaults to
|
||||
true unless specified.
|
||||
"""
|
||||
|
||||
def __init__(self, request, **kwargs):
|
||||
|
||||
if 'values' not in kwargs:
|
||||
app = request.rattail_config.get_app()
|
||||
model = app.model
|
||||
departments = Session.query(model.Department)\
|
||||
.order_by(model.Department.number)
|
||||
values = [(dept.uuid, str(dept))
|
||||
for dept in departments]
|
||||
if not kwargs.pop('required', True):
|
||||
values.insert(0, ('', "(none)"))
|
||||
kwargs['values'] = values
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
def make_vendor_widget(request, **kwargs):
|
||||
"""
|
||||
Make a vendor widget; will be either autocomplete or dropdown
|
||||
depending on config.
|
||||
"""
|
||||
# use autocomplete widget by default
|
||||
factory = VendorAutocompleteWidget
|
||||
|
||||
# caller may request dropdown widget
|
||||
if kwargs.pop('dropdown', False):
|
||||
factory = VendorDropdownWidget
|
||||
|
||||
else: # or, config may say to use dropdown
|
||||
app = request.rattail_config.get_app()
|
||||
vendor_handler = app.get_vendor_handler()
|
||||
if vendor_handler.choice_uses_dropdown():
|
||||
factory = VendorDropdownWidget
|
||||
|
||||
# instantiate whichever
|
||||
return factory(request, **kwargs)
|
||||
|
||||
|
||||
class VendorAutocompleteWidget(JQueryAutocompleteWidget):
|
||||
"""
|
||||
Autocomplete widget for a Vendor reference field.
|
||||
"""
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.request = request
|
||||
app = self.request.rattail_config.get_app()
|
||||
model = app.model
|
||||
|
||||
# must figure out URL providing autocomplete service
|
||||
if 'service_url' not in kwargs:
|
||||
|
||||
# caller can just pass 'url' instead of 'service_url'
|
||||
if 'url' in kwargs:
|
||||
self.service_url = kwargs['url']
|
||||
|
||||
else: # use default url
|
||||
self.service_url = self.request.route_url('vendors.autocomplete')
|
||||
|
||||
# # TODO
|
||||
# if 'input_callback' not in kwargs:
|
||||
# if 'input_handler' in kwargs:
|
||||
# self.input_callback = input_handler
|
||||
|
||||
def serialize(self, field, cstruct, **kw):
|
||||
""" """
|
||||
# fetch vendor to provide button label, if we have a value
|
||||
if cstruct:
|
||||
app = self.request.rattail_config.get_app()
|
||||
model = app.model
|
||||
vendor = Session.get(model.Vendor, cstruct)
|
||||
if vendor:
|
||||
self.field_display = str(vendor)
|
||||
|
||||
return super().serialize(
|
||||
field, cstruct, **kw)
|
||||
|
||||
|
||||
class VendorDropdownWidget(dfwidget.SelectWidget):
|
||||
"""
|
||||
Dropdown widget for a Vendor reference field.
|
||||
"""
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.request = request
|
||||
|
||||
# must figure out dropdown values, if they weren't given
|
||||
if 'values' not in kwargs:
|
||||
|
||||
# use what caller gave us, if they did
|
||||
if 'vendors' in kwargs:
|
||||
vendors = kwargs['vendors']
|
||||
if callable(vendors):
|
||||
vendors = vendors()
|
||||
|
||||
else: # default vendor list
|
||||
app = self.request.rattail_config.get_app()
|
||||
model = app.model
|
||||
vendors = Session.query(model.Vendor)\
|
||||
.order_by(model.Vendor.name)\
|
||||
.all()
|
||||
|
||||
# convert vendor list to option values
|
||||
self.values = [(c.uuid, c.name)
|
||||
for c in vendors]
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2017 Lance Edgar
|
||||
# Copyright © 2010-2021 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -28,4 +28,3 @@ from __future__ import unicode_literals, absolute_import
|
|||
|
||||
from . import filters
|
||||
from .core import Grid, GridAction
|
||||
from .mobile import MobileGrid
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2018 Lance Edgar
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,16 +24,15 @@
|
|||
Grid Filters
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import re
|
||||
import datetime
|
||||
import decimal
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
import six
|
||||
import sqlalchemy as sa
|
||||
|
||||
from rattail.gpc import GPC
|
||||
from rattail.util import OrderedDict
|
||||
from rattail.core import UNSPECIFIED
|
||||
from rattail.time import localtime, make_utc
|
||||
from rattail.util import prettify
|
||||
|
@ -51,6 +50,7 @@ class FilterValueRenderer(object):
|
|||
"""
|
||||
Base class for all filter renderers.
|
||||
"""
|
||||
data_type = 'string'
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
@ -74,6 +74,7 @@ class NumericValueRenderer(FilterValueRenderer):
|
|||
"""
|
||||
Input renderer for numeric values.
|
||||
"""
|
||||
data_type = 'number'
|
||||
|
||||
def render(self, value=None, **kwargs):
|
||||
kwargs.setdefault('step', '0.001')
|
||||
|
@ -84,6 +85,7 @@ class DateValueRenderer(FilterValueRenderer):
|
|||
"""
|
||||
Input renderer for date values.
|
||||
"""
|
||||
data_type = 'date'
|
||||
|
||||
def render(self, value=None, **kwargs):
|
||||
kwargs['data-datepicker'] = 'true'
|
||||
|
@ -94,6 +96,7 @@ class ChoiceValueRenderer(FilterValueRenderer):
|
|||
"""
|
||||
Renders value input as a dropdown/selectmenu of available choices.
|
||||
"""
|
||||
data_type = 'choice'
|
||||
|
||||
def __init__(self, options):
|
||||
self.options = options
|
||||
|
@ -108,8 +111,11 @@ class EnumValueRenderer(ChoiceValueRenderer):
|
|||
"""
|
||||
|
||||
def __init__(self, enum):
|
||||
sorted_keys = sorted(enum, key=lambda k: enum[k].lower())
|
||||
self.options = [tags.Option(enum[k], six.text_type(k)) for k in sorted_keys]
|
||||
if isinstance(enum, OrderedDict):
|
||||
sorted_keys = list(enum.keys())
|
||||
else:
|
||||
sorted_keys = sorted(enum, key=lambda k: enum[k].lower())
|
||||
self.options = [tags.Option(enum[k], str(k)) for k in sorted_keys]
|
||||
|
||||
|
||||
class GridFilter(object):
|
||||
|
@ -121,37 +127,69 @@ class GridFilter(object):
|
|||
'is_any': "is any",
|
||||
'equal': "equal to",
|
||||
'not_equal': "not equal to",
|
||||
'equal_any_of': "equal to any of",
|
||||
'greater_than': "greater than",
|
||||
'greater_equal': "greater than or equal to",
|
||||
'less_than': "less than",
|
||||
'less_equal': "less than or equal to",
|
||||
'is_empty': "is empty",
|
||||
'is_not_empty': "is not empty",
|
||||
'between': "between",
|
||||
'is_null': "is null",
|
||||
'is_not_null': "is not null",
|
||||
'is_true': "is true",
|
||||
'is_false': "is false",
|
||||
'is_false_null': "is false or null",
|
||||
'is_empty_or_null': "is either empty or null",
|
||||
'contains': "contains",
|
||||
'does_not_contain': "does not contain",
|
||||
'contains_any_of': "contains any of",
|
||||
'is_me': "is me",
|
||||
'is_not_me': "is not me",
|
||||
}
|
||||
|
||||
valueless_verbs = ['is_any', 'is_null', 'is_not_null', 'is_true', 'is_false',
|
||||
'is_me', 'is_not_me']
|
||||
valueless_verbs = [
|
||||
'is_any',
|
||||
'is_empty',
|
||||
'is_not_empty',
|
||||
'is_null',
|
||||
'is_not_null',
|
||||
'is_true',
|
||||
'is_false',
|
||||
'is_false_null',
|
||||
'is_empty_or_null',
|
||||
'is_me',
|
||||
'is_not_me',
|
||||
]
|
||||
|
||||
multiple_value_verbs = [
|
||||
'equal_any_of',
|
||||
'contains_any_of',
|
||||
]
|
||||
|
||||
value_renderer_factory = DefaultValueRenderer
|
||||
data_type = 'string' # default, but will be set from value renderer
|
||||
choices = {}
|
||||
|
||||
def __init__(self, key, label=None, verbs=None, value_enum=None, value_renderer=None,
|
||||
def __init__(self, key, config=None, label=None, verbs=None,
|
||||
value_enum=None, value_renderer=None,
|
||||
default_active=False, default_verb=None, default_value=None,
|
||||
encode_values=False, value_encoding='utf-8', **kwargs):
|
||||
self.key = key
|
||||
self.config = config
|
||||
self.label = label or prettify(key)
|
||||
self.verbs = verbs or self.get_default_verbs()
|
||||
|
||||
if value_renderer:
|
||||
self.set_value_renderer(value_renderer)
|
||||
elif value_enum:
|
||||
self.set_value_renderer(EnumValueRenderer(value_enum))
|
||||
self.set_choices(value_enum)
|
||||
else:
|
||||
self.set_value_renderer(self.value_renderer_factory)
|
||||
|
||||
# nb. do this after setting choices, if applicable, since that
|
||||
# could change default verbs
|
||||
self.verbs = verbs or self.get_default_verbs()
|
||||
|
||||
self.default_active = default_active
|
||||
self.default_verb = default_verb
|
||||
self.default_value = default_value
|
||||
|
@ -177,6 +215,48 @@ class GridFilter(object):
|
|||
return verbs
|
||||
return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
|
||||
|
||||
def normalize_choices(self, choices):
|
||||
"""
|
||||
Normalize a set of "choices" to a format suitable for use with the
|
||||
filter.
|
||||
|
||||
:param choices: A collection of "choices" in one of the following
|
||||
formats:
|
||||
|
||||
* simple list, each value of which should be a string, which is
|
||||
assumed to be able to serve as both key and value (ordering of
|
||||
choices will be preserved)
|
||||
* simple dict, keys and values of which will define the choices
|
||||
(note that the final choices will be sorted by key!)
|
||||
* OrderedDict, keys and values of which will define the choices
|
||||
(ordering of choices will be preserved)
|
||||
"""
|
||||
if isinstance(choices, OrderedDict):
|
||||
normalized = choices
|
||||
|
||||
elif isinstance(choices, dict):
|
||||
normalized = OrderedDict([
|
||||
(key, choices[key])
|
||||
for key in sorted(choices)])
|
||||
|
||||
elif isinstance(choices, list):
|
||||
normalized = OrderedDict([
|
||||
(key, key)
|
||||
for key in choices])
|
||||
|
||||
return normalized
|
||||
|
||||
def set_choices(self, choices):
|
||||
"""
|
||||
Set the value choices for the filter. Note that this also will set the
|
||||
value renderer to one which supports choices.
|
||||
|
||||
:param choices: A collection of "choices" which will be normalized by
|
||||
way of :meth:`normalize_choices()`.
|
||||
"""
|
||||
self.choices = self.normalize_choices(choices)
|
||||
self.set_value_renderer(ChoiceValueRenderer(self.choices))
|
||||
|
||||
def set_value_renderer(self, renderer):
|
||||
"""
|
||||
Set the value renderer for the filter, post-construction.
|
||||
|
@ -185,6 +265,7 @@ class GridFilter(object):
|
|||
renderer = renderer()
|
||||
renderer.filter = self
|
||||
self.value_renderer = renderer
|
||||
self.data_type = renderer.data_type
|
||||
|
||||
def filter(self, data, verb=None, value=UNSPECIFIED):
|
||||
"""
|
||||
|
@ -196,14 +277,15 @@ class GridFilter(object):
|
|||
value = self.get_value(value)
|
||||
filtr = getattr(self, 'filter_{0}'.format(verb), None)
|
||||
if not filtr:
|
||||
raise ValueError("Unknown filter verb: {0}".format(repr(verb)))
|
||||
log.warning("unknown filter verb: %s", verb)
|
||||
return data
|
||||
return filtr(data, value)
|
||||
|
||||
def get_value(self, value=UNSPECIFIED):
|
||||
return value if value is not UNSPECIFIED else self.value
|
||||
|
||||
def encode_value(self, value):
|
||||
if self.encode_values and isinstance(value, six.string_types):
|
||||
if self.encode_values and isinstance(value, str):
|
||||
return value.encode('utf-8')
|
||||
return value
|
||||
|
||||
|
@ -225,18 +307,6 @@ class GridFilter(object):
|
|||
return self.value_renderer.render(value=value, **kwargs)
|
||||
|
||||
|
||||
class MobileFilter(GridFilter):
|
||||
"""
|
||||
Base class for mobile grid filters.
|
||||
"""
|
||||
default_verbs = ['equal']
|
||||
|
||||
def __init__(self, key, **kwargs):
|
||||
kwargs.setdefault('default_active', True)
|
||||
kwargs.setdefault('default_verb', 'equal')
|
||||
super(MobileFilter, self).__init__(key, **kwargs)
|
||||
|
||||
|
||||
class AlchemyGridFilter(GridFilter):
|
||||
"""
|
||||
Base class for SQLAlchemy grid filters.
|
||||
|
@ -244,7 +314,7 @@ class AlchemyGridFilter(GridFilter):
|
|||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.column = kwargs.pop('column')
|
||||
super(AlchemyGridFilter, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def filter_equal(self, query, value):
|
||||
"""
|
||||
|
@ -268,6 +338,38 @@ class AlchemyGridFilter(GridFilter):
|
|||
self.column != self.encode_value(value),
|
||||
))
|
||||
|
||||
def filter_equal_any_of(self, query, value):
|
||||
"""
|
||||
This filter expects "multiple values" separated by newline
|
||||
character, and will add an "OR" condition with each value
|
||||
being checked separately. For instance if the user submits a
|
||||
"value" like this:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
foo bar
|
||||
baz
|
||||
|
||||
This will result in SQL condition like this:
|
||||
|
||||
.. code-block:: sql
|
||||
|
||||
name = 'foo bar' OR name = 'baz'
|
||||
"""
|
||||
if not value:
|
||||
return query
|
||||
|
||||
values = value.split('\n')
|
||||
values = [value for value in values if value]
|
||||
if not values:
|
||||
return query
|
||||
|
||||
conditions = []
|
||||
for value in values:
|
||||
conditions.append(self.column == self.encode_value(value))
|
||||
|
||||
return query.filter(sa.or_(*conditions))
|
||||
|
||||
def filter_is_null(self, query, value):
|
||||
"""
|
||||
Filter data with an 'IS NULL' query. Note that this filter does not
|
||||
|
@ -314,6 +416,47 @@ class AlchemyGridFilter(GridFilter):
|
|||
return query
|
||||
return query.filter(self.column <= self.encode_value(value))
|
||||
|
||||
def filter_between(self, query, value):
|
||||
"""
|
||||
Filter data with a "between" query. Really this uses ">=" and
|
||||
"<=" (inclusive) logic instead of SQL "between" keyword.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return query
|
||||
|
||||
if '|' not in value:
|
||||
return query
|
||||
|
||||
values = value.split('|')
|
||||
if len(values) != 2:
|
||||
return query
|
||||
|
||||
start_value, end_value = values
|
||||
|
||||
# we'll only filter if we have start and/or end value
|
||||
if not start_value and not end_value:
|
||||
return query
|
||||
|
||||
return self.filter_for_range(query, start_value, end_value)
|
||||
|
||||
def filter_for_range(self, query, start_value, end_value):
|
||||
"""
|
||||
This method should actually apply filter(s) to the query,
|
||||
according to the given value range. Subclasses may override
|
||||
this logic.
|
||||
"""
|
||||
if start_value:
|
||||
if self.value_invalid(start_value):
|
||||
return query
|
||||
query = query.filter(self.column >= self.encode_value(start_value))
|
||||
|
||||
if end_value:
|
||||
if self.value_invalid(end_value):
|
||||
return query
|
||||
query = query.filter(self.column <= self.encode_value(end_value))
|
||||
|
||||
return query
|
||||
|
||||
|
||||
class AlchemyStringFilter(AlchemyGridFilter):
|
||||
"""
|
||||
|
@ -324,8 +467,17 @@ class AlchemyStringFilter(AlchemyGridFilter):
|
|||
"""
|
||||
Expose contains / does-not-contain verbs in addition to core.
|
||||
"""
|
||||
|
||||
if self.choices:
|
||||
return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
|
||||
|
||||
return ['contains', 'does_not_contain',
|
||||
'equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
|
||||
'contains_any_of',
|
||||
'equal', 'not_equal', 'equal_any_of',
|
||||
'is_empty', 'is_not_empty',
|
||||
'is_null', 'is_not_null',
|
||||
'is_empty_or_null',
|
||||
'is_any']
|
||||
|
||||
def filter_contains(self, query, value):
|
||||
"""
|
||||
|
@ -333,9 +485,13 @@ class AlchemyStringFilter(AlchemyGridFilter):
|
|||
"""
|
||||
if value is None or value == '':
|
||||
return query
|
||||
return query.filter(sa.and_(
|
||||
*[self.column.ilike(self.encode_value('%{}%'.format(v)))
|
||||
for v in value.split()]))
|
||||
|
||||
criteria = []
|
||||
for val in value.split():
|
||||
val = val.replace('_', r'\_')
|
||||
val = self.encode_value(f'%{val}%')
|
||||
criteria.append(self.column.ilike(val))
|
||||
return query.filter(sa.and_(*criteria))
|
||||
|
||||
def filter_does_not_contain(self, query, value):
|
||||
"""
|
||||
|
@ -344,14 +500,65 @@ class AlchemyStringFilter(AlchemyGridFilter):
|
|||
if value is None or value == '':
|
||||
return query
|
||||
|
||||
criteria = []
|
||||
for val in value.split():
|
||||
val = val.replace('_', r'\_')
|
||||
val = self.encode_value(f'%{val}%')
|
||||
criteria.append(~self.column.ilike(val))
|
||||
|
||||
# When saying something is 'not like' something else, we must also
|
||||
# include things which are nothing at all, in our result set.
|
||||
return query.filter(sa.or_(
|
||||
self.column == None,
|
||||
sa.and_(
|
||||
*[~self.column.ilike(self.encode_value('%{}%'.format(v)))
|
||||
for v in value.split()]),
|
||||
))
|
||||
sa.and_(*criteria)))
|
||||
|
||||
def filter_contains_any_of(self, query, value):
|
||||
"""
|
||||
This filter expects "multiple values" separated by newline character,
|
||||
and will add an "OR" condition with each value being checked via
|
||||
"ILIKE". For instance if the user submits a "value" like this:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
foo bar
|
||||
baz
|
||||
|
||||
This will result in SQL condition like this:
|
||||
|
||||
.. code-block:: sql
|
||||
|
||||
(name ILIKE '%foo%' AND name ILIKE '%bar%') OR name ILIKE '%baz%'
|
||||
"""
|
||||
if not value:
|
||||
return query
|
||||
|
||||
values = value.split('\n')
|
||||
values = [value for value in values if value]
|
||||
if not values:
|
||||
return query
|
||||
|
||||
conditions = []
|
||||
for value in values:
|
||||
criteria = []
|
||||
for val in value.split():
|
||||
val = val.replace('_', r'\_')
|
||||
val = self.encode_value(f'%{val}%')
|
||||
criteria.append(self.column.ilike(val))
|
||||
conditions.append(sa.and_(*criteria))
|
||||
|
||||
return query.filter(sa.or_(*conditions))
|
||||
|
||||
def filter_is_empty(self, query, value):
|
||||
return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''))
|
||||
|
||||
def filter_is_not_empty(self, query, value):
|
||||
return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value(''))
|
||||
|
||||
def filter_is_empty_or_null(self, query, value):
|
||||
return query.filter(
|
||||
sa.or_(
|
||||
sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''),
|
||||
self.column == None))
|
||||
|
||||
|
||||
class AlchemyEmptyStringFilter(AlchemyStringFilter):
|
||||
|
@ -363,13 +570,13 @@ class AlchemyEmptyStringFilter(AlchemyStringFilter):
|
|||
return query.filter(
|
||||
sa.or_(
|
||||
self.column == None,
|
||||
sa.func.trim(self.column) == self.encode_value('')))
|
||||
sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value('')))
|
||||
|
||||
def filter_is_not_null(self, query, value):
|
||||
return query.filter(
|
||||
sa.and_(
|
||||
self.column != None,
|
||||
sa.func.trim(self.column) != self.encode_value('')))
|
||||
sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value('')))
|
||||
|
||||
|
||||
class AlchemyByteStringFilter(AlchemyStringFilter):
|
||||
|
@ -381,8 +588,8 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
|
|||
value_encoding = 'utf-8'
|
||||
|
||||
def get_value(self, value=UNSPECIFIED):
|
||||
value = super(AlchemyByteStringFilter, self).get_value(value)
|
||||
if isinstance(value, six.text_type):
|
||||
value = super().get_value(value)
|
||||
if isinstance(value, str):
|
||||
value = value.encode(self.value_encoding)
|
||||
return value
|
||||
|
||||
|
@ -392,8 +599,13 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
|
|||
"""
|
||||
if value is None or value == '':
|
||||
return query
|
||||
return query.filter(sa.and_(
|
||||
*[self.column.ilike(b'%{}%'.format(v)) for v in value.split()]))
|
||||
|
||||
criteria = []
|
||||
for val in value.split():
|
||||
val = val.replace('_', r'\_')
|
||||
val = b'%{}%'.format(val)
|
||||
criteria.append(self.column.ilike(val))
|
||||
return query.filters(sa.and_(*criteria))
|
||||
|
||||
def filter_does_not_contain(self, query, value):
|
||||
"""
|
||||
|
@ -402,13 +614,16 @@ class AlchemyByteStringFilter(AlchemyStringFilter):
|
|||
if value is None or value == '':
|
||||
return query
|
||||
|
||||
for val in value.split():
|
||||
val = val.replace('_', '\_')
|
||||
val = b'%{}%'.format(val)
|
||||
criteria.append(~self.column.ilike(val))
|
||||
|
||||
# When saying something is 'not like' something else, we must also
|
||||
# include things which are nothing at all, in our result set.
|
||||
return query.filter(sa.or_(
|
||||
self.column == None,
|
||||
sa.and_(
|
||||
*[~self.column.ilike(b'%{}%'.format(v)) for v in value.split()]),
|
||||
))
|
||||
sa.and_(*criteria)))
|
||||
|
||||
|
||||
class AlchemyNumericFilter(AlchemyGridFilter):
|
||||
|
@ -417,9 +632,11 @@ class AlchemyNumericFilter(AlchemyGridFilter):
|
|||
"""
|
||||
value_renderer_factory = NumericValueRenderer
|
||||
|
||||
# expose greater-than / less-than verbs in addition to core
|
||||
default_verbs = ['equal', 'not_equal', 'greater_than', 'greater_equal',
|
||||
'less_than', 'less_equal', 'is_null', 'is_not_null', 'is_any']
|
||||
def default_verbs(self):
|
||||
# expose greater-than / less-than verbs in addition to core
|
||||
return ['equal', 'not_equal', 'greater_than', 'greater_equal',
|
||||
'less_than', 'less_equal', 'between',
|
||||
'is_null', 'is_not_null', 'is_any']
|
||||
|
||||
# TODO: what follows "works" in that it prevents an error...but from the
|
||||
# user's perspective it still fails silently...need to improve on front-end
|
||||
|
@ -428,43 +645,69 @@ class AlchemyNumericFilter(AlchemyGridFilter):
|
|||
# term for integer field...
|
||||
|
||||
def value_invalid(self, value):
|
||||
return bool(value and len(six.text_type(value)) > 8)
|
||||
|
||||
# first just make sure it's somewhat numeric
|
||||
try:
|
||||
self.parse_decimal(value)
|
||||
except decimal.InvalidOperation:
|
||||
return True
|
||||
|
||||
return bool(value and len(str(value)) > 8)
|
||||
|
||||
def parse_decimal(self, value):
|
||||
if value:
|
||||
value = value.replace(',', '')
|
||||
return decimal.Decimal(value)
|
||||
|
||||
def encode_value(self, value):
|
||||
if value:
|
||||
value = str(self.parse_decimal(value))
|
||||
return super().encode_value(value)
|
||||
|
||||
def filter_equal(self, query, value):
|
||||
if self.value_invalid(value):
|
||||
return query
|
||||
return super(AlchemyNumericFilter, self).filter_equal(query, value)
|
||||
return super().filter_equal(query, value)
|
||||
|
||||
def filter_not_equal(self, query, value):
|
||||
if self.value_invalid(value):
|
||||
return query
|
||||
return super(AlchemyNumericFilter, self).filter_not_equal(query, value)
|
||||
return super().filter_not_equal(query, value)
|
||||
|
||||
def filter_greater_than(self, query, value):
|
||||
if self.value_invalid(value):
|
||||
return query
|
||||
return super(AlchemyNumericFilter, self).filter_greater_than(query, value)
|
||||
return super().filter_greater_than(query, value)
|
||||
|
||||
def filter_greater_equal(self, query, value):
|
||||
if self.value_invalid(value):
|
||||
return query
|
||||
return super(AlchemyNumericFilter, self).filter_greater_equal(query, value)
|
||||
return super().filter_greater_equal(query, value)
|
||||
|
||||
def filter_less_than(self, query, value):
|
||||
if self.value_invalid(value):
|
||||
return query
|
||||
return super(AlchemyNumericFilter, self).filter_less_than(query, value)
|
||||
return super().filter_less_than(query, value)
|
||||
|
||||
def filter_less_equal(self, query, value):
|
||||
if self.value_invalid(value):
|
||||
return query
|
||||
return super(AlchemyNumericFilter, self).filter_less_equal(query, value)
|
||||
return super().filter_less_equal(query, value)
|
||||
|
||||
|
||||
class AlchemyIntegerFilter(AlchemyNumericFilter):
|
||||
"""
|
||||
Integer filter for SQLAlchemy.
|
||||
"""
|
||||
bigint = False
|
||||
|
||||
def default_verbs(self):
|
||||
|
||||
# limited verbs if choices are defined
|
||||
if self.choices:
|
||||
return ['equal', 'not_equal', 'is_null', 'is_not_null', 'is_any']
|
||||
|
||||
return super().default_verbs()
|
||||
|
||||
def value_invalid(self, value):
|
||||
if value:
|
||||
|
@ -472,12 +715,25 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
|
|||
return True
|
||||
if not value.isdigit():
|
||||
return True
|
||||
# TODO: this one is to avoid DataError from PG, but perhaps that
|
||||
# isn't a good enough reason to make this global logic?
|
||||
if int(value) > 2147483647:
|
||||
# normal Integer columns have a max value, beyond which PG
|
||||
# will throw an error if we try to query for larger values
|
||||
# TODO: this seems hacky, how to better handle it?
|
||||
if not self.bigint and int(value) > 2147483647:
|
||||
return True
|
||||
return False
|
||||
|
||||
def encode_value(self, value):
|
||||
# ensure we pass integer value to sqlalchemy, so it does not try to
|
||||
# encode it as a string etc.
|
||||
return int(value)
|
||||
|
||||
|
||||
class AlchemyBigIntegerFilter(AlchemyIntegerFilter):
|
||||
"""
|
||||
BigInteger filter for SQLAlchemy.
|
||||
"""
|
||||
bigint = True
|
||||
|
||||
|
||||
class AlchemyBooleanFilter(AlchemyGridFilter):
|
||||
"""
|
||||
|
@ -504,7 +760,16 @@ class AlchemyNullableBooleanFilter(AlchemyBooleanFilter):
|
|||
"""
|
||||
Boolean filter for SQLAlchemy which is NULL-aware.
|
||||
"""
|
||||
default_verbs = ['is_true', 'is_false', 'is_null', 'is_not_null', 'is_any']
|
||||
default_verbs = ['is_true', 'is_false', 'is_false_null',
|
||||
'is_null', 'is_not_null', 'is_any']
|
||||
|
||||
def filter_is_false_null(self, query, value):
|
||||
"""
|
||||
Filter data with an "is false or null" query. Note that this filter
|
||||
does not use the value for anything.
|
||||
"""
|
||||
return query.filter(sa.or_(self.column == False,
|
||||
self.column == None))
|
||||
|
||||
|
||||
class AlchemyDateFilter(AlchemyGridFilter):
|
||||
|
@ -520,6 +785,7 @@ class AlchemyDateFilter(AlchemyGridFilter):
|
|||
'greater_equal': "on or after",
|
||||
'less_than': "before",
|
||||
'less_equal': "on or before",
|
||||
'between': "between",
|
||||
'is_null': "is null",
|
||||
'is_not_null': "is not null",
|
||||
'is_any': "is any",
|
||||
|
@ -529,14 +795,27 @@ class AlchemyDateFilter(AlchemyGridFilter):
|
|||
"""
|
||||
Expose greater-than / less-than verbs in addition to core.
|
||||
"""
|
||||
return ['equal', 'not_equal', 'greater_than', 'greater_equal',
|
||||
'less_than', 'less_equal', 'is_null', 'is_not_null', 'is_any']
|
||||
return [
|
||||
'equal',
|
||||
'not_equal',
|
||||
'greater_than',
|
||||
'greater_equal',
|
||||
'less_than',
|
||||
'less_equal',
|
||||
'between',
|
||||
'is_null',
|
||||
'is_not_null',
|
||||
'is_any',
|
||||
]
|
||||
|
||||
def make_date(self, value):
|
||||
"""
|
||||
Convert user input to a proper ``datetime.date`` object.
|
||||
"""
|
||||
if value:
|
||||
if isinstance(value, datetime.date):
|
||||
return value
|
||||
|
||||
try:
|
||||
dt = datetime.datetime.strptime(value, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
|
@ -544,6 +823,87 @@ class AlchemyDateFilter(AlchemyGridFilter):
|
|||
else:
|
||||
return dt.date()
|
||||
|
||||
def filter_equal(self, query, value):
|
||||
date = self.make_date(value)
|
||||
if not date:
|
||||
return query
|
||||
|
||||
return query.filter(self.column == self.encode_value(date))
|
||||
|
||||
def filter_not_equal(self, query, value):
|
||||
date = self.make_date(value)
|
||||
if not date:
|
||||
return query
|
||||
|
||||
return query.filter(sa.or_(
|
||||
self.column == None,
|
||||
self.column != self.encode_value(date),
|
||||
))
|
||||
|
||||
def filter_greater_than(self, query, value):
|
||||
date = self.make_date(value)
|
||||
if not date:
|
||||
return query
|
||||
return query.filter(self.column > self.encode_value(date))
|
||||
|
||||
def filter_greater_equal(self, query, value):
|
||||
date = self.make_date(value)
|
||||
if not date:
|
||||
return query
|
||||
return query.filter(self.column >= self.encode_value(date))
|
||||
|
||||
def filter_less_than(self, query, value):
|
||||
date = self.make_date(value)
|
||||
if not date:
|
||||
return query
|
||||
return query.filter(self.column < self.encode_value(date))
|
||||
|
||||
def filter_less_equal(self, query, value):
|
||||
date = self.make_date(value)
|
||||
if not date:
|
||||
return query
|
||||
return query.filter(self.column <= self.encode_value(date))
|
||||
|
||||
# TODO: this should be merged into parent class
|
||||
def filter_between(self, query, value):
|
||||
"""
|
||||
Filter data with a "between" query. Really this uses ">=" and "<="
|
||||
(inclusive) logic instead of SQL "between" keyword.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return query
|
||||
|
||||
if '|' not in value:
|
||||
return query
|
||||
|
||||
values = value.split('|')
|
||||
if len(values) != 2:
|
||||
return query
|
||||
|
||||
start_date, end_date = values
|
||||
if start_date:
|
||||
start_date = self.make_date(start_date)
|
||||
if end_date:
|
||||
end_date = self.make_date(end_date)
|
||||
|
||||
# we'll only filter if we have start and/or end date
|
||||
if not start_date and not end_date:
|
||||
return query
|
||||
|
||||
return self.filter_date_range(query, start_date, end_date)
|
||||
|
||||
# TODO: this should be merged into parent class
|
||||
def filter_date_range(self, query, start_date, end_date):
|
||||
"""
|
||||
This method should actually apply filter(s) to the query, according to
|
||||
the given date range. Subclasses may override this logic.
|
||||
"""
|
||||
if start_date:
|
||||
query = query.filter(self.column >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(self.column <= end_date)
|
||||
return query
|
||||
|
||||
|
||||
class AlchemyDateTimeFilter(AlchemyDateFilter):
|
||||
"""
|
||||
|
@ -633,6 +993,17 @@ class AlchemyDateTimeFilter(AlchemyDateFilter):
|
|||
time = make_utc(localtime(self.config, time))
|
||||
return query.filter(self.column < time)
|
||||
|
||||
def filter_date_range(self, query, start_date, end_date):
|
||||
if start_date:
|
||||
start_time = datetime.datetime.combine(start_date, datetime.time(0))
|
||||
start_time = localtime(self.config, start_time)
|
||||
query = query.filter(self.column >= make_utc(start_time))
|
||||
if end_date:
|
||||
end_time = datetime.datetime.combine(end_date + datetime.timedelta(days=1), datetime.time(0))
|
||||
end_time = localtime(self.config, end_time)
|
||||
query = query.filter(self.column <= make_utc(end_time))
|
||||
return query
|
||||
|
||||
|
||||
class AlchemyLocalDateTimeFilter(AlchemyDateTimeFilter):
|
||||
"""
|
||||
|
@ -723,19 +1094,36 @@ class AlchemyLocalDateTimeFilter(AlchemyDateTimeFilter):
|
|||
time = localtime(self.config, time, tzinfo=False)
|
||||
return query.filter(self.column < time)
|
||||
|
||||
def filter_date_range(self, query, start_date, end_date):
|
||||
if start_date:
|
||||
start_time = datetime.datetime.combine(start_date, datetime.time(0))
|
||||
start_time = localtime(self.config, start_time, tzinfo=False)
|
||||
query = query.filter(self.column >= start_time)
|
||||
if end_date:
|
||||
end_time = datetime.datetime.combine(end_date + datetime.timedelta(days=1), datetime.time(0))
|
||||
end_time = localtime(self.config, end_time, tzinfo=False)
|
||||
query = query.filter(self.column <= end_time)
|
||||
return query
|
||||
|
||||
|
||||
class AlchemyGPCFilter(AlchemyGridFilter):
|
||||
"""
|
||||
GPC filter for SQLAlchemy.
|
||||
"""
|
||||
default_verbs = ['equal', 'not_equal']
|
||||
default_verbs = ['equal', 'not_equal', 'equal_any_of',
|
||||
'is_null', 'is_not_null']
|
||||
|
||||
def filter_equal(self, query, value):
|
||||
"""
|
||||
Filter data with an equal ('=') query.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
if value is None:
|
||||
return query
|
||||
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return query
|
||||
|
||||
try:
|
||||
return query.filter(self.column.in_((
|
||||
GPC(value),
|
||||
|
@ -745,9 +1133,13 @@ class AlchemyGPCFilter(AlchemyGridFilter):
|
|||
|
||||
def filter_not_equal(self, query, value):
|
||||
"""
|
||||
Filter data with a not eqaul ('!=') query.
|
||||
Filter data with a not equal ('!=') query.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
if value is None:
|
||||
return query
|
||||
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return query
|
||||
|
||||
# When saying something is 'not equal' to something else, we must also
|
||||
|
@ -761,12 +1153,114 @@ class AlchemyGPCFilter(AlchemyGridFilter):
|
|||
except ValueError:
|
||||
return query
|
||||
|
||||
def filter_equal_any_of(self, query, value):
|
||||
"""
|
||||
This filter expects "multiple values" separated by newline character,
|
||||
and will add an "OR" condition with each value being checked via
|
||||
"ILIKE". For instance if the user submits a "value" like this:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
07430500132
|
||||
07430500116
|
||||
|
||||
This will result in SQL condition like this:
|
||||
|
||||
.. code-block:: sql
|
||||
|
||||
(upc IN (7430500132, 74305001321)) OR (upc IN (7430500116, 74305001161))
|
||||
"""
|
||||
if not value:
|
||||
return query
|
||||
|
||||
values = value.split('\n')
|
||||
values = [value for value in values if value]
|
||||
if not values:
|
||||
return query
|
||||
|
||||
conditions = []
|
||||
for value in values:
|
||||
try:
|
||||
clause = self.column.in_((
|
||||
GPC(value),
|
||||
GPC(value, calc_check_digit='upc')))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
conditions.append(clause)
|
||||
|
||||
if not conditions:
|
||||
return query
|
||||
|
||||
return query.filter(sa.or_(*conditions))
|
||||
|
||||
|
||||
class AlchemyPhoneNumberFilter(AlchemyStringFilter):
|
||||
"""
|
||||
Special string filter, with logic to deal with phone numbers.
|
||||
"""
|
||||
|
||||
def parse_value(self, value):
|
||||
newvalue = None
|
||||
|
||||
# first we try to split according to typical 7- or 10-digit number
|
||||
digits = re.sub(r'\D', '', value or '')
|
||||
if len(digits) == 7:
|
||||
newvalue = "{} {}".format(digits[:3], digits[3:])
|
||||
elif len(digits) == 10:
|
||||
newvalue = "{} {} {}".format(digits[:3], digits[3:6], digits[6:])
|
||||
|
||||
# if that didn't work, we can also try to split by grouped digits
|
||||
if not newvalue and value:
|
||||
parts = re.split(r'\D+', value)
|
||||
newvalue = ' '.join(parts)
|
||||
|
||||
return newvalue or value
|
||||
|
||||
def filter_contains(self, query, value):
|
||||
"""
|
||||
Try to parse the value into "parts" of a phone number, then do a normal
|
||||
'ILIKE' query with those parts.
|
||||
"""
|
||||
value = self.parse_value(value)
|
||||
return super().filter_contains(query, value)
|
||||
|
||||
def filter_does_not_contain(self, query, value):
|
||||
"""
|
||||
Try to parse the value into "parts" of a phone number, then do a normal
|
||||
'NOT ILIKE' query with those parts.
|
||||
"""
|
||||
value = self.parse_value(value)
|
||||
return super().filter_does_not_contain(query, value)
|
||||
|
||||
|
||||
class GridFilterSet(OrderedDict):
|
||||
"""
|
||||
Collection class for :class:`GridFilter` instances.
|
||||
"""
|
||||
|
||||
def move_before(self, key, refkey):
|
||||
"""
|
||||
Rearrange underlying key sorting, such that the given ``key`` comes
|
||||
just *before* the given ``refkey``.
|
||||
"""
|
||||
# first must work out the new order for all keys
|
||||
newkeys = []
|
||||
for k in self.keys():
|
||||
if k == key:
|
||||
continue
|
||||
if k == refkey:
|
||||
newkeys.append(key)
|
||||
newkeys.append(refkey)
|
||||
else:
|
||||
newkeys.append(k)
|
||||
|
||||
# then effectively replace dict contents, using new order
|
||||
items = dict(self)
|
||||
self.clear()
|
||||
for k in newkeys:
|
||||
self[k] = items[k]
|
||||
|
||||
|
||||
class GridFiltersForm(forms.Form):
|
||||
"""
|
||||
|
@ -781,7 +1275,7 @@ class GridFiltersForm(forms.Form):
|
|||
node = colander.SchemaNode(colander.String(), name=key)
|
||||
schema.add(node)
|
||||
kwargs['schema'] = schema
|
||||
super(GridFiltersForm, self).__init__(**kwargs)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def iter_filters(self):
|
||||
return self.filters.values()
|
||||
|
|
82
tailbone/handler.py
Normal file
82
tailbone/handler.py
Normal file
|
@ -0,0 +1,82 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Tailbone Handler
|
||||
"""
|
||||
|
||||
import warnings
|
||||
|
||||
from mako.lookup import TemplateLookup
|
||||
|
||||
from rattail.app import GenericHandler
|
||||
from rattail.files import resource_path
|
||||
|
||||
from tailbone.providers import get_all_providers
|
||||
|
||||
|
||||
class TailboneHandler(GenericHandler):
|
||||
"""
|
||||
Base class and default implementation for Tailbone handler.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# TODO: make templates dir configurable?
|
||||
templates = [resource_path('rattail:templates/web')]
|
||||
self.templates = TemplateLookup(directories=templates)
|
||||
|
||||
def get_menu_handler(self, **kwargs):
|
||||
"""
|
||||
DEPRECATED; use
|
||||
:meth:`wuttaweb.handler.WebHandler.get_menu_handler()`
|
||||
instead.
|
||||
"""
|
||||
warnings.warn("TailboneHandler.get_menu_handler() is deprecated; "
|
||||
"please use WebHandler.get_menu_handler() instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
|
||||
if not hasattr(self, 'menu_handler'):
|
||||
spec = self.config.get('tailbone.menus', 'handler',
|
||||
default='tailbone.menus:MenuHandler')
|
||||
Handler = self.app.load_object(spec)
|
||||
self.menu_handler = Handler(self.config)
|
||||
self.menu_handler.tb = self
|
||||
return self.menu_handler
|
||||
|
||||
def iter_providers(self):
|
||||
"""
|
||||
Returns an iterator over all registered Tailbone providers.
|
||||
"""
|
||||
providers = get_all_providers(self.config)
|
||||
return providers.values()
|
||||
|
||||
def write_model_view(self, data, path, **kwargs):
|
||||
"""
|
||||
Write code for a new model view, based on the given data dict,
|
||||
to the given path.
|
||||
"""
|
||||
template = self.templates.get_template('/new-model-view.mako')
|
||||
content = template.render(**data)
|
||||
with open(path, 'wt') as f:
|
||||
f.write(content)
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2018 Lance Edgar
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,18 +24,21 @@
|
|||
Template Context Helpers
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
# start off with all from wuttaweb
|
||||
from wuttaweb.helpers import *
|
||||
|
||||
import os
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from collections import OrderedDict
|
||||
|
||||
from rattail.time import localtime, make_utc
|
||||
from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal
|
||||
from rattail.db.util import maxlen
|
||||
|
||||
from webhelpers2.html import *
|
||||
from webhelpers2.html.tags import *
|
||||
|
||||
from tailbone.util import csrf_token, pretty_datetime
|
||||
from tailbone.util import (pretty_datetime, raw_datetime,
|
||||
render_markdown,
|
||||
route_exists)
|
||||
|
||||
|
||||
def pretty_date(date):
|
||||
|
|
772
tailbone/menus.py
Normal file
772
tailbone/menus.py
Normal file
|
@ -0,0 +1,772 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
App Menus
|
||||
"""
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
from rattail.util import prettify, simple_error
|
||||
|
||||
from webhelpers2.html import tags, HTML
|
||||
|
||||
from wuttaweb.menus import MenuHandler as WuttaMenuHandler
|
||||
|
||||
from tailbone.db import Session
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TailboneMenuHandler(WuttaMenuHandler):
|
||||
"""
|
||||
Base class and default implementation for menu handler.
|
||||
"""
|
||||
|
||||
##############################
|
||||
# internal methods
|
||||
##############################
|
||||
|
||||
def _is_allowed(self, request, item):
|
||||
"""
|
||||
TODO: must override this until wuttaweb has proper user auth checks
|
||||
"""
|
||||
perm = item.get('perm')
|
||||
if perm:
|
||||
return request.has_perm(perm)
|
||||
return True
|
||||
|
||||
def _make_raw_menus(self, request, **kwargs):
|
||||
"""
|
||||
We are overriding this to allow for making dynamic menus from
|
||||
config/settings. Which may or may not be a good idea..
|
||||
"""
|
||||
# first try to make menus from config, but this is highly
|
||||
# susceptible to failure, so try to warn user of problems
|
||||
try:
|
||||
menus = self._make_menus_from_config(request)
|
||||
if menus:
|
||||
return menus
|
||||
except Exception as error:
|
||||
|
||||
# TODO: these messages show up multiple times on some pages?!
|
||||
# that must mean the BeforeRender event is firing multiple
|
||||
# times..but why?? seems like there is only 1 request...
|
||||
log.warning("failed to make menus from config", exc_info=True)
|
||||
request.session.flash(simple_error(error), 'error')
|
||||
request.session.flash("Menu config is invalid! Reverting to menus "
|
||||
"defined in code!", 'warning')
|
||||
msg = HTML.literal('Please edit your {} ASAP.'.format(
|
||||
tags.link_to("Menu Config", request.route_url('configure_menus'))))
|
||||
request.session.flash(msg, 'warning')
|
||||
|
||||
# okay, no config, so menus will be built from code
|
||||
return self.make_menus(request, **kwargs)
|
||||
|
||||
def _make_menus_from_config(self, request, **kwargs):
|
||||
"""
|
||||
Try to build a complete menu set from config/settings.
|
||||
|
||||
This will look in the DB settings table, or config file, for
|
||||
menu data. If found, it constructs menus from that data.
|
||||
"""
|
||||
# bail unless config defines top-level menu keys
|
||||
main_keys = self.config.getlist('tailbone.menu', 'menus')
|
||||
if not main_keys:
|
||||
return
|
||||
|
||||
model = self.app.model
|
||||
menus = []
|
||||
|
||||
# menu definition can come either from config file or db
|
||||
# settings, but if the latter then we want to optimize with
|
||||
# one big query
|
||||
if self.config.getbool('tailbone.menu', 'from_settings',
|
||||
default=False):
|
||||
|
||||
# fetch all menu-related settings at once
|
||||
query = Session().query(model.Setting)\
|
||||
.filter(model.Setting.name.like('tailbone.menu.%'))
|
||||
settings = self.app.cache_model(Session(), model.Setting,
|
||||
query=query, key='name',
|
||||
normalizer=lambda s: s.value)
|
||||
for key in main_keys:
|
||||
menus.append(self._make_single_menu_from_settings(request, key, settings))
|
||||
|
||||
else: # read from config file only
|
||||
for key in main_keys:
|
||||
menus.append(self._make_single_menu_from_config(request, key))
|
||||
|
||||
return menus
|
||||
|
||||
def _make_single_menu_from_config(self, request, key, **kwargs):
|
||||
"""
|
||||
Makes a single top-level menu dict from config file. Note
|
||||
that this will read from config file(s) *only* and avoids
|
||||
querying the database, for efficiency.
|
||||
"""
|
||||
menu = {
|
||||
'key': key,
|
||||
'type': 'menu',
|
||||
'items': [],
|
||||
}
|
||||
|
||||
# title
|
||||
title = self.config.get('tailbone.menu',
|
||||
'menu.{}.label'.format(key),
|
||||
usedb=False)
|
||||
menu['title'] = title or prettify(key)
|
||||
|
||||
# items
|
||||
item_keys = self.config.getlist('tailbone.menu',
|
||||
'menu.{}.items'.format(key),
|
||||
usedb=False)
|
||||
for item_key in item_keys:
|
||||
item = {}
|
||||
|
||||
if item_key == 'SEP':
|
||||
item['type'] = 'sep'
|
||||
|
||||
else:
|
||||
item['type'] = 'item'
|
||||
item['key'] = item_key
|
||||
|
||||
# title
|
||||
title = self.config.get('tailbone.menu',
|
||||
'menu.{}.item.{}.label'.format(key, item_key),
|
||||
usedb=False)
|
||||
item['title'] = title or prettify(item_key)
|
||||
|
||||
# route
|
||||
route = self.config.get('tailbone.menu',
|
||||
'menu.{}.item.{}.route'.format(key, item_key),
|
||||
usedb=False)
|
||||
if route:
|
||||
item['route'] = route
|
||||
item['url'] = request.route_url(route)
|
||||
|
||||
else:
|
||||
|
||||
# url
|
||||
url = self.config.get('tailbone.menu',
|
||||
'menu.{}.item.{}.url'.format(key, item_key),
|
||||
usedb=False)
|
||||
if not url:
|
||||
url = request.route_url(item_key)
|
||||
elif url.startswith('route:'):
|
||||
url = request.route_url(url[6:])
|
||||
item['url'] = url
|
||||
|
||||
# perm
|
||||
perm = self.config.get('tailbone.menu',
|
||||
'menu.{}.item.{}.perm'.format(key, item_key),
|
||||
usedb=False)
|
||||
item['perm'] = perm or '{}.list'.format(item_key)
|
||||
|
||||
menu['items'].append(item)
|
||||
|
||||
return menu
|
||||
|
||||
def _make_single_menu_from_settings(self, request, key, settings, **kwargs):
|
||||
"""
|
||||
Makes a single top-level menu dict from DB settings.
|
||||
"""
|
||||
menu = {
|
||||
'key': key,
|
||||
'type': 'menu',
|
||||
'items': [],
|
||||
}
|
||||
|
||||
# title
|
||||
title = settings.get('tailbone.menu.menu.{}.label'.format(key))
|
||||
menu['title'] = title or prettify(key)
|
||||
|
||||
# items
|
||||
item_keys = self.config.parse_list(
|
||||
settings.get('tailbone.menu.menu.{}.items'.format(key)))
|
||||
for item_key in item_keys:
|
||||
item = {}
|
||||
|
||||
if item_key == 'SEP':
|
||||
item['type'] = 'sep'
|
||||
|
||||
else:
|
||||
item['type'] = 'item'
|
||||
item['key'] = item_key
|
||||
|
||||
# title
|
||||
title = settings.get('tailbone.menu.menu.{}.item.{}.label'.format(
|
||||
key, item_key))
|
||||
item['title'] = title or prettify(item_key)
|
||||
|
||||
# route
|
||||
route = settings.get('tailbone.menu.menu.{}.item.{}.route'.format(
|
||||
key, item_key))
|
||||
if route:
|
||||
item['route'] = route
|
||||
item['url'] = request.route_url(route)
|
||||
|
||||
else:
|
||||
|
||||
# url
|
||||
url = settings.get('tailbone.menu.menu.{}.item.{}.url'.format(
|
||||
key, item_key))
|
||||
if not url:
|
||||
url = request.route_url(item_key)
|
||||
if url.startswith('route:'):
|
||||
url = request.route_url(url[6:])
|
||||
item['url'] = url
|
||||
|
||||
# perm
|
||||
perm = settings.get('tailbone.menu.menu.{}.item.{}.perm'.format(
|
||||
key, item_key))
|
||||
item['perm'] = perm or '{}.list'.format(item_key)
|
||||
|
||||
menu['items'].append(item)
|
||||
|
||||
return menu
|
||||
|
||||
##############################
|
||||
# menu defaults
|
||||
##############################
|
||||
|
||||
def make_menus(self, request, **kwargs):
|
||||
"""
|
||||
Make the full set of menus for the app.
|
||||
|
||||
This method provides a semi-sane menu set by default, but it
|
||||
is expected for most apps to override it.
|
||||
"""
|
||||
menus = [
|
||||
self.make_custorders_menu(request),
|
||||
self.make_people_menu(request),
|
||||
self.make_products_menu(request),
|
||||
self.make_vendors_menu(request),
|
||||
]
|
||||
|
||||
integration_menus = self.make_integration_menus(request)
|
||||
if integration_menus:
|
||||
menus.extend(integration_menus)
|
||||
|
||||
menus.extend([
|
||||
self.make_reports_menu(request, include_trainwreck=True),
|
||||
self.make_batches_menu(request),
|
||||
self.make_admin_menu(request, include_stores=True),
|
||||
])
|
||||
|
||||
return menus
|
||||
|
||||
def make_integration_menus(self, request, **kwargs):
|
||||
"""
|
||||
Make a set of menus for all registered system integrations.
|
||||
"""
|
||||
tb = self.app.get_tailbone_handler()
|
||||
menus = []
|
||||
for provider in tb.iter_providers():
|
||||
menu = provider.make_integration_menu(request)
|
||||
if menu:
|
||||
menus.append(menu)
|
||||
menus.sort(key=lambda menu: menu['title'].lower())
|
||||
return menus
|
||||
|
||||
def make_custorders_menu(self, request, **kwargs):
|
||||
"""
|
||||
Generate a typical Customer Orders menu
|
||||
"""
|
||||
return {
|
||||
'title': "Orders",
|
||||
'type': 'menu',
|
||||
'items': [
|
||||
{
|
||||
'title': "New Customer Order",
|
||||
'route': 'custorders.create',
|
||||
'perm': 'custorders.create',
|
||||
},
|
||||
{
|
||||
'title': "All New Orders",
|
||||
'route': 'new_custorders',
|
||||
'perm': 'new_custorders.list',
|
||||
},
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "All Customer Orders",
|
||||
'route': 'custorders',
|
||||
'perm': 'custorders.list',
|
||||
},
|
||||
{
|
||||
'title': "All Order Items",
|
||||
'route': 'custorders.items',
|
||||
'perm': 'custorders.items.list',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def make_people_menu(self, request, **kwargs):
|
||||
"""
|
||||
Generate a typical People menu
|
||||
"""
|
||||
return {
|
||||
'title': "People",
|
||||
'type': 'menu',
|
||||
'items': [
|
||||
{
|
||||
'title': "Members",
|
||||
'route': 'members',
|
||||
'perm': 'members.list',
|
||||
},
|
||||
{
|
||||
'title': "Member Equity Payments",
|
||||
'route': 'member_equity_payments',
|
||||
'perm': 'member_equity_payments.list',
|
||||
},
|
||||
{
|
||||
'title': "Membership Types",
|
||||
'route': 'membership_types',
|
||||
'perm': 'membership_types.list',
|
||||
},
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "Customers",
|
||||
'route': 'customers',
|
||||
'perm': 'customers.list',
|
||||
},
|
||||
{
|
||||
'title': "Customer Shoppers",
|
||||
'route': 'customer_shoppers',
|
||||
'perm': 'customer_shoppers.list',
|
||||
},
|
||||
{
|
||||
'title': "Customer Groups",
|
||||
'route': 'customergroups',
|
||||
'perm': 'customergroups.list',
|
||||
},
|
||||
{
|
||||
'title': "Pending Customers",
|
||||
'route': 'pending_customers',
|
||||
'perm': 'pending_customers.list',
|
||||
},
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "Employees",
|
||||
'route': 'employees',
|
||||
'perm': 'employees.list',
|
||||
},
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "All People",
|
||||
'route': 'people',
|
||||
'perm': 'people.list',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def make_products_menu(self, request, **kwargs):
|
||||
"""
|
||||
Generate a typical Products menu
|
||||
"""
|
||||
return {
|
||||
'title': "Products",
|
||||
'type': 'menu',
|
||||
'items': [
|
||||
{
|
||||
'title': "Products",
|
||||
'route': 'products',
|
||||
'perm': 'products.list',
|
||||
},
|
||||
{
|
||||
'title': "Product Costs",
|
||||
'route': 'product_costs',
|
||||
'perm': 'product_costs.list',
|
||||
},
|
||||
{
|
||||
'title': "Departments",
|
||||
'route': 'departments',
|
||||
'perm': 'departments.list',
|
||||
},
|
||||
{
|
||||
'title': "Subdepartments",
|
||||
'route': 'subdepartments',
|
||||
'perm': 'subdepartments.list',
|
||||
},
|
||||
{
|
||||
'title': "Brands",
|
||||
'route': 'brands',
|
||||
'perm': 'brands.list',
|
||||
},
|
||||
{
|
||||
'title': "Categories",
|
||||
'route': 'categories',
|
||||
'perm': 'categories.list',
|
||||
},
|
||||
{
|
||||
'title': "Families",
|
||||
'route': 'families',
|
||||
'perm': 'families.list',
|
||||
},
|
||||
{
|
||||
'title': "Report Codes",
|
||||
'route': 'reportcodes',
|
||||
'perm': 'reportcodes.list',
|
||||
},
|
||||
{
|
||||
'title': "Units of Measure",
|
||||
'route': 'uoms',
|
||||
'perm': 'uoms.list',
|
||||
},
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "Pending Products",
|
||||
'route': 'pending_products',
|
||||
'perm': 'pending_products.list',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def make_vendors_menu(self, request, **kwargs):
|
||||
"""
|
||||
Generate a typical Vendors menu
|
||||
"""
|
||||
return {
|
||||
'title': "Vendors",
|
||||
'type': 'menu',
|
||||
'items': [
|
||||
{
|
||||
'title': "Vendors",
|
||||
'route': 'vendors',
|
||||
'perm': 'vendors.list',
|
||||
},
|
||||
{
|
||||
'title': "Product Costs",
|
||||
'route': 'product_costs',
|
||||
'perm': 'product_costs.list',
|
||||
},
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "Ordering",
|
||||
'route': 'ordering',
|
||||
'perm': 'ordering.list',
|
||||
},
|
||||
{
|
||||
'title': "Receiving",
|
||||
'route': 'receiving',
|
||||
'perm': 'receiving.list',
|
||||
},
|
||||
{
|
||||
'title': "Invoice Costing",
|
||||
'route': 'invoice_costing',
|
||||
'perm': 'invoice_costing.list',
|
||||
},
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "Purchases",
|
||||
'route': 'purchases',
|
||||
'perm': 'purchases.list',
|
||||
},
|
||||
{
|
||||
'title': "Credits",
|
||||
'route': 'purchases.credits',
|
||||
'perm': 'purchases.credits.list',
|
||||
},
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "Catalog Batches",
|
||||
'route': 'vendorcatalogs',
|
||||
'perm': 'vendorcatalogs.list',
|
||||
},
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "Sample Files",
|
||||
'route': 'vendorsamplefiles',
|
||||
'perm': 'vendorsamplefiles.list',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def make_batches_menu(self, request, **kwargs):
|
||||
"""
|
||||
Generate a typical Batches menu
|
||||
"""
|
||||
return {
|
||||
'title': "Batches",
|
||||
'type': 'menu',
|
||||
'items': [
|
||||
{
|
||||
'title': "Handheld",
|
||||
'route': 'batch.handheld',
|
||||
'perm': 'batch.handheld.list',
|
||||
},
|
||||
{
|
||||
'title': "Inventory",
|
||||
'route': 'batch.inventory',
|
||||
'perm': 'batch.inventory.list',
|
||||
},
|
||||
{
|
||||
'title': "Import / Export",
|
||||
'route': 'batch.importer',
|
||||
'perm': 'batch.importer.list',
|
||||
},
|
||||
{
|
||||
'title': "POS",
|
||||
'route': 'batch.pos',
|
||||
'perm': 'batch.pos.list',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def make_reports_menu(self, request, **kwargs):
|
||||
"""
|
||||
Generate a typical Reports menu
|
||||
"""
|
||||
items = [
|
||||
{
|
||||
'title': "New Report",
|
||||
'route': 'report_output.create',
|
||||
'perm': 'report_output.create',
|
||||
},
|
||||
{
|
||||
'title': "Generated Reports",
|
||||
'route': 'report_output',
|
||||
'perm': 'report_output.list',
|
||||
},
|
||||
{
|
||||
'title': "Problem Reports",
|
||||
'route': 'problem_reports',
|
||||
'perm': 'problem_reports.list',
|
||||
},
|
||||
]
|
||||
|
||||
if kwargs.get('include_poser', False):
|
||||
items.extend([
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "Poser Reports",
|
||||
'route': 'poser_reports',
|
||||
'perm': 'poser_reports.list',
|
||||
},
|
||||
])
|
||||
|
||||
if kwargs.get('include_worksheets', False):
|
||||
items.extend([
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "Ordering Worksheet",
|
||||
'route': 'reports.ordering',
|
||||
},
|
||||
{
|
||||
'title': "Inventory Worksheet",
|
||||
'route': 'reports.inventory',
|
||||
},
|
||||
])
|
||||
|
||||
if kwargs.get('include_trainwreck', False):
|
||||
items.extend([
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "Trainwreck",
|
||||
'route': 'trainwreck.transactions',
|
||||
'perm': 'trainwreck.transactions.list',
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
'title': "Reports",
|
||||
'type': 'menu',
|
||||
'items': items,
|
||||
}
|
||||
|
||||
def make_tempmon_menu(self, request, **kwargs):
|
||||
"""
|
||||
Generate a typical TempMon menu
|
||||
"""
|
||||
return {
|
||||
'title': "TempMon",
|
||||
'type': 'menu',
|
||||
'items': [
|
||||
{
|
||||
'title': "Dashboard",
|
||||
'route': 'tempmon.dashboard',
|
||||
'perm': 'tempmon.appliances.dashboard',
|
||||
},
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "Appliances",
|
||||
'route': 'tempmon.appliances',
|
||||
'perm': 'tempmon.appliances.list',
|
||||
},
|
||||
{
|
||||
'title': "Clients",
|
||||
'route': 'tempmon.clients',
|
||||
'perm': 'tempmon.clients.list',
|
||||
},
|
||||
{
|
||||
'title': "Probes",
|
||||
'route': 'tempmon.probes',
|
||||
'perm': 'tempmon.probes.list',
|
||||
},
|
||||
{
|
||||
'title': "Readings",
|
||||
'route': 'tempmon.readings',
|
||||
'perm': 'tempmon.readings.list',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def make_admin_menu(self, request, **kwargs):
|
||||
"""
|
||||
Generate a typical Admin menu
|
||||
"""
|
||||
items = []
|
||||
|
||||
include_stores = kwargs.get('include_stores', True)
|
||||
include_tenders = kwargs.get('include_tenders', True)
|
||||
|
||||
if include_stores or include_tenders:
|
||||
|
||||
if include_stores:
|
||||
items.extend([
|
||||
{
|
||||
'title': "Stores",
|
||||
'route': 'stores',
|
||||
'perm': 'stores.list',
|
||||
},
|
||||
])
|
||||
|
||||
if include_tenders:
|
||||
items.extend([
|
||||
{
|
||||
'title': "Tenders",
|
||||
'route': 'tenders',
|
||||
'perm': 'tenders.list',
|
||||
},
|
||||
])
|
||||
|
||||
items.append({'type': 'sep'})
|
||||
|
||||
items.extend([
|
||||
{
|
||||
'title': "Users",
|
||||
'route': 'users',
|
||||
'perm': 'users.list',
|
||||
},
|
||||
{
|
||||
'title': "Roles",
|
||||
'route': 'roles',
|
||||
'perm': 'roles.list',
|
||||
},
|
||||
{
|
||||
'title': "Raw Permissions",
|
||||
'route': 'permissions',
|
||||
'perm': 'permissions.list',
|
||||
},
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "Email Settings",
|
||||
'route': 'emailprofiles',
|
||||
'perm': 'emailprofiles.list',
|
||||
},
|
||||
{
|
||||
'title': "Email Attempts",
|
||||
'route': 'email_attempts',
|
||||
'perm': 'email_attempts.list',
|
||||
},
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "DataSync Status",
|
||||
'route': 'datasync.status',
|
||||
'perm': 'datasync.status',
|
||||
},
|
||||
{
|
||||
'title': "DataSync Changes",
|
||||
'route': 'datasyncchanges',
|
||||
'perm': 'datasync_changes.list',
|
||||
},
|
||||
{
|
||||
'title': "Importing / Exporting",
|
||||
'route': 'importing',
|
||||
'perm': 'importing.list',
|
||||
},
|
||||
{
|
||||
'title': "Luigi Tasks",
|
||||
'route': 'luigi',
|
||||
'perm': 'luigi.list',
|
||||
},
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "App Info",
|
||||
'route': 'appinfo',
|
||||
'perm': 'appinfo.list',
|
||||
},
|
||||
])
|
||||
|
||||
if kwargs.get('include_label_settings', False):
|
||||
items.extend([
|
||||
{
|
||||
'title': "Label Settings",
|
||||
'route': 'labelprofiles',
|
||||
'perm': 'labelprofiles.list',
|
||||
},
|
||||
])
|
||||
|
||||
items.extend([
|
||||
{
|
||||
'title': "Raw Settings",
|
||||
'route': 'settings',
|
||||
'perm': 'settings.list',
|
||||
},
|
||||
{
|
||||
'title': "Upgrades",
|
||||
'route': 'upgrades',
|
||||
'perm': 'upgrades.list',
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
'title': "Admin",
|
||||
'type': 'menu',
|
||||
'items': items,
|
||||
}
|
||||
|
||||
|
||||
class MenuHandler(TailboneMenuHandler):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
warnings.warn("tailbone.menus.MenuHandler is deprecated; "
|
||||
"please use tailbone.menus.TailboneMenuHandler instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class NullMenuHandler(WuttaMenuHandler):
|
||||
"""
|
||||
Null menu handler which uses an empty menu set.
|
||||
|
||||
.. note:
|
||||
|
||||
This class shouldn't even exist, but for the moment, it is
|
||||
useful to configure non-traditional (e.g. API) web apps to use
|
||||
this, in order to avoid most of the overhead.
|
||||
"""
|
||||
|
||||
def make_menus(self, request, **kwargs):
|
||||
return []
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2018 Lance Edgar
|
||||
# Copyright © 2010-2022 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -27,22 +27,33 @@ Progress Indicator
|
|||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
import os
|
||||
import warnings
|
||||
|
||||
from rattail.progress import ProgressBase
|
||||
|
||||
from beaker.session import Session
|
||||
|
||||
|
||||
def get_basic_session(config, request={}, **kwargs):
|
||||
"""
|
||||
Create/get a "basic" Beaker session object.
|
||||
"""
|
||||
kwargs['use_cookies'] = False
|
||||
session = Session(request, **kwargs)
|
||||
return session
|
||||
|
||||
|
||||
def get_progress_session(request, key, **kwargs):
|
||||
"""
|
||||
Create/get a Beaker session object, to be used for progress.
|
||||
"""
|
||||
id = '{}.progress.{}'.format(request.session.id, key)
|
||||
kwargs['use_cookies'] = False
|
||||
kwargs['id'] = '{}.progress.{}'.format(request.session.id, key)
|
||||
if kwargs.get('type') == 'file':
|
||||
warnings.warn("Passing a 'type' kwarg to get_progress_session() "
|
||||
"is deprecated...i think",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
kwargs['data_dir'] = os.path.join(request.rattail_config.appdir(), 'sessions')
|
||||
session = Session(request, id, **kwargs)
|
||||
return session
|
||||
return get_basic_session(request.rattail_config, request, **kwargs)
|
||||
|
||||
|
||||
class SessionProgress(ProgressBase):
|
||||
|
@ -52,11 +63,20 @@ class SessionProgress(ProgressBase):
|
|||
This class is only responsible for keeping the progress *data* current. It
|
||||
is the responsibility of some client-side AJAX (etc.) to consume the data
|
||||
for display to the user.
|
||||
|
||||
:param ws: If true, then websockets are assumed, and the progress will
|
||||
behave accordingly. The default is false, "traditional" behavior.
|
||||
"""
|
||||
|
||||
def __init__(self, request, key, session_type=None):
|
||||
def __init__(self, request, key, session_type=None, ws=False):
|
||||
self.key = key
|
||||
self.session = get_progress_session(request, key, type=session_type)
|
||||
self.ws = ws
|
||||
|
||||
if self.ws:
|
||||
self.session = get_basic_session(request.rattail_config, id=key)
|
||||
else:
|
||||
self.session = get_progress_session(request, key, type=session_type)
|
||||
|
||||
self.canceled = False
|
||||
self.clear()
|
||||
|
||||
|
|
62
tailbone/providers.py
Normal file
62
tailbone/providers.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2023 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
# Rattail is free software: you can redistribute it and/or modify it under the
|
||||
# terms of the GNU General Public License as published by the Free Software
|
||||
# Foundation, either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Providers for Tailbone features
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
from rattail.util import load_entry_points
|
||||
|
||||
|
||||
class TailboneProvider(object):
|
||||
"""
|
||||
Base class for Tailbone providers. These are responsible for
|
||||
declaring which things a given project makes available to the app.
|
||||
(Or at least the things which should be easily configurable.)
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
def configure_db_sessions(self, rattail_config, pyramid_config):
|
||||
pass
|
||||
|
||||
def get_static_includes(self):
|
||||
pass
|
||||
|
||||
def get_provided_views(self):
|
||||
return {}
|
||||
|
||||
def make_integration_menu(self, request, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def get_all_providers(config):
|
||||
"""
|
||||
Returns a dict of all registered providers.
|
||||
"""
|
||||
providers = load_entry_points('tailbone.providers')
|
||||
for key in list(providers):
|
||||
providers[key] = providers[key](config)
|
||||
return providers
|
|
@ -111,7 +111,7 @@
|
|||
<td class="brand">${cost.product.brand or ''}</td>
|
||||
<td class="desc">${cost.product.description}</td>
|
||||
<td class="size">${cost.product.size or ''}</td>
|
||||
<td class="case-qty">${cost.case_size} ${"LB" if cost.product.weighed else "EA"}</td>
|
||||
<td class="case-qty">${app.render_quantity(cost.case_size)} ${"LB" if cost.product.weighed else "EA"}</td>
|
||||
<td class="code">${cost.code or ''}</td>
|
||||
<td class="preferred">${'X' if cost.preference == 1 else ''}</td>
|
||||
% for i in range(14):
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
################################################################################
|
||||
#
|
||||
# Rattail -- Retail Software Framework
|
||||
# Copyright © 2010-2017 Lance Edgar
|
||||
# Copyright © 2010-2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Rattail.
|
||||
#
|
||||
|
@ -24,9 +24,8 @@
|
|||
Static Assets
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
|
||||
def includeme(config):
|
||||
config.include('wuttaweb.static')
|
||||
config.add_static_view('tailbone', 'tailbone:static')
|
||||
config.add_static_view('deform', 'deform:static')
|
||||
|
|
|
@ -1,115 +1,14 @@
|
|||
|
||||
/******************************
|
||||
* General
|
||||
******************************/
|
||||
|
||||
* {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Verdana, Arial, sans-serif;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0972a5;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 12pt;
|
||||
margin: 20px auto 10px auto;
|
||||
}
|
||||
|
||||
li {
|
||||
line-height: 2em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.left {
|
||||
float: left;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
div.buttons {
|
||||
clear: both;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
div.dialog {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.flash-message {
|
||||
background-color: #dddddd;
|
||||
margin-bottom: 8px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
div.flash-messages div.ui-state-highlight {
|
||||
padding: .3em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
div.error-messages div.ui-state-error {
|
||||
padding: .3em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.flash-messages,
|
||||
.error-messages {
|
||||
margin: 0.5em 0 0 0;
|
||||
}
|
||||
|
||||
ul.error {
|
||||
color: #dd6666;
|
||||
font-weight: bold;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
ul.error li {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
/******************************
|
||||
* jQuery UI tweaks
|
||||
******************************/
|
||||
|
||||
ul.ui-menu {
|
||||
max-height: 30em;
|
||||
}
|
||||
|
||||
/******************************
|
||||
* tweaks for root user
|
||||
******************************/
|
||||
|
||||
.menubar .root-user .ui-button-text,
|
||||
.menubar .root-user.ui-menu-item a {
|
||||
.navbar .navbar-end .navbar-link.root-user,
|
||||
.navbar .navbar-end .navbar-link.root-user:hover,
|
||||
.navbar .navbar-end .navbar-link.root-user.is_active,
|
||||
.navbar .navbar-end .navbar-item.root-user,
|
||||
.navbar .navbar-end .navbar-item.root-user:hover,
|
||||
.navbar .navbar-end .navbar-item.root-user.is_active {
|
||||
background-color: red;
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.menubar .root-user.ui-menu-item a {
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
|
74
tailbone/static/css/codehilite.css
Normal file
74
tailbone/static/css/codehilite.css
Normal file
|
@ -0,0 +1,74 @@
|
|||
pre { line-height: 125%; }
|
||||
td.linenos pre { color: #000000; background-color: #f0f0f0; padding-left: 5px; padding-right: 5px; }
|
||||
span.linenos { color: #000000; background-color: #f0f0f0; padding-left: 5px; padding-right: 5px; }
|
||||
td.linenos pre.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
|
||||
span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
|
||||
.codehilite .hll { background-color: #ffffcc }
|
||||
.codehilite { background: #f8f8f8; }
|
||||
.codehilite .c { color: #408080; font-style: italic } /* Comment */
|
||||
.codehilite .err { border: 1px solid #FF0000 } /* Error */
|
||||
.codehilite .k { color: #008000; font-weight: bold } /* Keyword */
|
||||
.codehilite .o { color: #666666 } /* Operator */
|
||||
.codehilite .ch { color: #408080; font-style: italic } /* Comment.Hashbang */
|
||||
.codehilite .cm { color: #408080; font-style: italic } /* Comment.Multiline */
|
||||
.codehilite .cp { color: #BC7A00 } /* Comment.Preproc */
|
||||
.codehilite .cpf { color: #408080; font-style: italic } /* Comment.PreprocFile */
|
||||
.codehilite .c1 { color: #408080; font-style: italic } /* Comment.Single */
|
||||
.codehilite .cs { color: #408080; font-style: italic } /* Comment.Special */
|
||||
.codehilite .gd { color: #A00000 } /* Generic.Deleted */
|
||||
.codehilite .ge { font-style: italic } /* Generic.Emph */
|
||||
.codehilite .gr { color: #FF0000 } /* Generic.Error */
|
||||
.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */
|
||||
.codehilite .gi { color: #00A000 } /* Generic.Inserted */
|
||||
.codehilite .go { color: #888888 } /* Generic.Output */
|
||||
.codehilite .gp { color: #000080; font-weight: bold } /* Generic.Prompt */
|
||||
.codehilite .gs { font-weight: bold } /* Generic.Strong */
|
||||
.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
|
||||
.codehilite .gt { color: #0044DD } /* Generic.Traceback */
|
||||
.codehilite .kc { color: #008000; font-weight: bold } /* Keyword.Constant */
|
||||
.codehilite .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
|
||||
.codehilite .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
|
||||
.codehilite .kp { color: #008000 } /* Keyword.Pseudo */
|
||||
.codehilite .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */
|
||||
.codehilite .kt { color: #B00040 } /* Keyword.Type */
|
||||
.codehilite .m { color: #666666 } /* Literal.Number */
|
||||
.codehilite .s { color: #BA2121 } /* Literal.String */
|
||||
.codehilite .na { color: #7D9029 } /* Name.Attribute */
|
||||
.codehilite .nb { color: #008000 } /* Name.Builtin */
|
||||
.codehilite .nc { color: #0000FF; font-weight: bold } /* Name.Class */
|
||||
.codehilite .no { color: #880000 } /* Name.Constant */
|
||||
.codehilite .nd { color: #AA22FF } /* Name.Decorator */
|
||||
.codehilite .ni { color: #999999; font-weight: bold } /* Name.Entity */
|
||||
.codehilite .ne { color: #D2413A; font-weight: bold } /* Name.Exception */
|
||||
.codehilite .nf { color: #0000FF } /* Name.Function */
|
||||
.codehilite .nl { color: #A0A000 } /* Name.Label */
|
||||
.codehilite .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */
|
||||
.codehilite .nt { color: #008000; font-weight: bold } /* Name.Tag */
|
||||
.codehilite .nv { color: #19177C } /* Name.Variable */
|
||||
.codehilite .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */
|
||||
.codehilite .w { color: #bbbbbb } /* Text.Whitespace */
|
||||
.codehilite .mb { color: #666666 } /* Literal.Number.Bin */
|
||||
.codehilite .mf { color: #666666 } /* Literal.Number.Float */
|
||||
.codehilite .mh { color: #666666 } /* Literal.Number.Hex */
|
||||
.codehilite .mi { color: #666666 } /* Literal.Number.Integer */
|
||||
.codehilite .mo { color: #666666 } /* Literal.Number.Oct */
|
||||
.codehilite .sa { color: #BA2121 } /* Literal.String.Affix */
|
||||
.codehilite .sb { color: #BA2121 } /* Literal.String.Backtick */
|
||||
.codehilite .sc { color: #BA2121 } /* Literal.String.Char */
|
||||
.codehilite .dl { color: #BA2121 } /* Literal.String.Delimiter */
|
||||
.codehilite .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
|
||||
.codehilite .s2 { color: #BA2121 } /* Literal.String.Double */
|
||||
.codehilite .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */
|
||||
.codehilite .sh { color: #BA2121 } /* Literal.String.Heredoc */
|
||||
.codehilite .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */
|
||||
.codehilite .sx { color: #008000 } /* Literal.String.Other */
|
||||
.codehilite .sr { color: #BB6688 } /* Literal.String.Regex */
|
||||
.codehilite .s1 { color: #BA2121 } /* Literal.String.Single */
|
||||
.codehilite .ss { color: #19177C } /* Literal.String.Symbol */
|
||||
.codehilite .bp { color: #008000 } /* Name.Builtin.Pseudo */
|
||||
.codehilite .fm { color: #0000FF } /* Name.Function.Magic */
|
||||
.codehilite .vc { color: #19177C } /* Name.Variable.Class */
|
||||
.codehilite .vg { color: #19177C } /* Name.Variable.Global */
|
||||
.codehilite .vi { color: #19177C } /* Name.Variable.Instance */
|
||||
.codehilite .vm { color: #19177C } /* Name.Variable.Magic */
|
||||
.codehilite .il { color: #666666 } /* Literal.Number.Integer.Long */
|
|
@ -1,28 +1,18 @@
|
|||
|
||||
/******************************
|
||||
* Filters
|
||||
* Grid Filters
|
||||
******************************/
|
||||
|
||||
div.filters form {
|
||||
margin-bottom: 10px;
|
||||
.filters .filter-fieldname .field,
|
||||
.filters .filter-fieldname .field label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div.filters div.filter {
|
||||
margin-bottom: 10px;
|
||||
.filters .filter-fieldname .field label {
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
div.filters div.filter label {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
div.filters div.filter select.filter-type {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
div.filters div.filter div.value {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
div.filters div.buttons * {
|
||||
margin-right: 8px;
|
||||
.filters .filter-verb .select,
|
||||
.filters .filter-verb .select select {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -1,67 +1,37 @@
|
|||
|
||||
/******************************
|
||||
* Form Wrapper
|
||||
* forms
|
||||
******************************/
|
||||
|
||||
div.form-wrapper {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
/******************************
|
||||
* Context Menu
|
||||
******************************/
|
||||
|
||||
div.form-wrapper ul.context-menu {
|
||||
float: right;
|
||||
list-style-type: none;
|
||||
margin: 0px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
div.form-wrapper ul.context-menu li {
|
||||
line-height: 2em;
|
||||
}
|
||||
|
||||
|
||||
/******************************
|
||||
* "object helper" panel
|
||||
******************************/
|
||||
|
||||
.object-helper {
|
||||
border: 1px solid black;
|
||||
float: right;
|
||||
margin-top: 1em;
|
||||
padding: 1em;
|
||||
width: 20em;
|
||||
}
|
||||
|
||||
.object-helper-content {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
|
||||
/******************************
|
||||
* Forms
|
||||
******************************/
|
||||
|
||||
div.form,
|
||||
div.fieldset-form,
|
||||
div.fieldset {
|
||||
clear: left;
|
||||
float: left;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* note that this should only apply to "normal" primary forms */
|
||||
/* TODO: replace this with bulma equivalent */
|
||||
.form {
|
||||
padding-left: 5em;
|
||||
}
|
||||
|
||||
/* note that this should only apply to "normal" primary forms */
|
||||
.form-wrapper .form .field.is-horizontal .field-label .label {
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
width: 18em;
|
||||
}
|
||||
|
||||
/* note that this should only apply to "normal" primary forms */
|
||||
.form-wrapper .form .field.is-horizontal .field-body {
|
||||
min-width: 30em;
|
||||
}
|
||||
|
||||
/* note that this should only apply to "normal" primary forms */
|
||||
.form-wrapper .form .field.is-horizontal .field-body .select,
|
||||
.form-wrapper .form .field.is-horizontal .field-body .select select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/******************************
|
||||
* Fieldsets
|
||||
* field-wrappers
|
||||
******************************/
|
||||
|
||||
/* TODO: replace this with bulma equivalent */
|
||||
.field-wrapper {
|
||||
clear: both;
|
||||
min-height: 30px;
|
||||
|
@ -69,16 +39,12 @@ div.fieldset {
|
|||
margin: 15px;
|
||||
}
|
||||
|
||||
.field-wrapper.with-error {
|
||||
background-color: #ddcccc;
|
||||
border: 2px solid #dd6666;
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
/* TODO: replace this with bulma equivalent */
|
||||
.field-wrapper .field-row {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
/* TODO: replace this with bulma equivalent */
|
||||
.field-wrapper label {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
|
@ -88,47 +54,8 @@ div.fieldset {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field-wrapper.with-error label {
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
.field-wrapper .field-error {
|
||||
padding: 1em 0 0.5em 1em;
|
||||
}
|
||||
|
||||
.field-wrapper .field-error .error-msg {
|
||||
color: #dd6666;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* TODO: replace this with bulma equivalent */
|
||||
.field-wrapper .field {
|
||||
display: table-cell;
|
||||
line-height: 25px;
|
||||
}
|
||||
|
||||
.field-wrapper .field input[type=text],
|
||||
.field-wrapper .field input[type=password],
|
||||
.field-wrapper .field select,
|
||||
.field-wrapper .field textarea {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
label input[type="checkbox"],
|
||||
label input[type="radio"] {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.field ul {
|
||||
margin: 0px;
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
|
||||
/******************************
|
||||
* Buttons
|
||||
******************************/
|
||||
|
||||
div.buttons {
|
||||
clear: both;
|
||||
margin: 10px 0px;
|
||||
}
|
||||
|
|
|
@ -22,10 +22,14 @@
|
|||
}
|
||||
|
||||
.grid-wrapper .grid-header #context-menu {
|
||||
float: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.grid-tools {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.grid-wrapper .grid-header td.tools {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
@ -262,6 +266,10 @@
|
|||
* main actions
|
||||
******************************/
|
||||
|
||||
a.grid-action {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.grid .actions {
|
||||
width: 1px;
|
||||
}
|
||||
|
|
49
tailbone/static/css/grids.rowstatus.css
Normal file
49
tailbone/static/css/grids.rowstatus.css
Normal file
|
@ -0,0 +1,49 @@
|
|||
|
||||
/********************************************************************************
|
||||
* grids.rowstatus.css
|
||||
*
|
||||
* Add "row status" styles for grid tables.
|
||||
********************************************************************************/
|
||||
|
||||
/**************************************************
|
||||
* grid rows with "warning" status
|
||||
**************************************************/
|
||||
|
||||
tr.warning {
|
||||
background-color: #fcc;
|
||||
}
|
||||
|
||||
|
||||
/**************************************************
|
||||
* grid rows with "notice" status
|
||||
**************************************************/
|
||||
|
||||
tr.notice {
|
||||
background-color: #fe8;
|
||||
}
|
||||
|
||||
|
||||
/**************************************************
|
||||
* grid rows which are "checked" (selected)
|
||||
**************************************************/
|
||||
|
||||
/* TODO: this references some color values, whereas it would be preferable
|
||||
* to refer to some sort of "state" instead, color of which was
|
||||
* configurable. b/c these are just the default Buefy theme colors. */
|
||||
|
||||
tr.is-checked {
|
||||
background-color: #7957d5;
|
||||
color: white;
|
||||
}
|
||||
|
||||
tr.is-checked:hover {
|
||||
color: #363636;
|
||||
}
|
||||
|
||||
tr.is-checked a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
tr.is-checked:hover a {
|
||||
color: #7957d5;
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
.loadmask {
|
||||
z-index: 100;
|
||||
position: absolute;
|
||||
top:0;
|
||||
left:0;
|
||||
-moz-opacity: 0.5;
|
||||
opacity: .50;
|
||||
filter: alpha(opacity=50);
|
||||
background-color: #CCC;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
zoom: 1;
|
||||
}
|
||||
.loadmask-msg {
|
||||
z-index: 20001;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border:1px solid #6593cf;
|
||||
background: #c3daf9;
|
||||
padding:2px;
|
||||
}
|
||||
.loadmask-msg div {
|
||||
padding:5px 10px 5px 25px;
|
||||
background: #fbfbfb url('../img/loading.gif') no-repeat 5px 5px;
|
||||
line-height: 16px;
|
||||
border:1px solid #a3bad9;
|
||||
color:#222;
|
||||
font:normal 11px tahoma, arial, helvetica, sans-serif;
|
||||
cursor:wait;
|
||||
}
|
||||
.masked {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.masked-relative {
|
||||
position: relative !important;
|
||||
}
|
||||
.masked-hidden {
|
||||
visibility: hidden !important;
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
ul.tagit {
|
||||
padding: 1px 5px;
|
||||
overflow: auto;
|
||||
margin-left: inherit; /* usually we don't want the regular ul margins. */
|
||||
margin-right: inherit;
|
||||
}
|
||||
ul.tagit li {
|
||||
display: block;
|
||||
float: left;
|
||||
margin: 2px 5px 2px 0;
|
||||
}
|
||||
ul.tagit li.tagit-choice {
|
||||
position: relative;
|
||||
line-height: inherit;
|
||||
}
|
||||
input.tagit-hidden-field {
|
||||
display: none;
|
||||
}
|
||||
ul.tagit li.tagit-choice-read-only {
|
||||
padding: .2em .5em .2em .5em;
|
||||
}
|
||||
|
||||
ul.tagit li.tagit-choice-editable {
|
||||
padding: .2em 18px .2em .5em;
|
||||
}
|
||||
|
||||
ul.tagit li.tagit-new {
|
||||
padding: .25em 4px .25em 0;
|
||||
}
|
||||
|
||||
ul.tagit li.tagit-choice a.tagit-label {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
ul.tagit li.tagit-choice .tagit-close {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: .1em;
|
||||
top: 50%;
|
||||
margin-top: -8px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
/* used for some custom themes that don't need image icons */
|
||||
ul.tagit li.tagit-choice .tagit-close .text-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ul.tagit li.tagit-choice input {
|
||||
display: block;
|
||||
float: left;
|
||||
margin: 2px 5px 2px 0;
|
||||
}
|
||||
ul.tagit input[type="text"] {
|
||||
-moz-box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
|
||||
-moz-box-shadow: none;
|
||||
-webkit-box-shadow: none;
|
||||
box-shadow: none;
|
||||
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: inherit;
|
||||
background-color: inherit;
|
||||
outline: none;
|
||||
}
|
15
tailbone/static/css/jquery.ui.menubar.css
vendored
15
tailbone/static/css/jquery.ui.menubar.css
vendored
|
@ -1,15 +0,0 @@
|
|||
/*
|
||||
* jQuery UI Menubar @VERSION
|
||||
*
|
||||
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
|
||||
* Dual licensed under the MIT or GPL Version 2 licenses.
|
||||
* http://jquery.org/license
|
||||
*/
|
||||
.ui-menubar { list-style: none; margin: 0; padding-left: 0; }
|
||||
|
||||
.ui-menubar-item { float: left; }
|
||||
|
||||
.ui-menubar .ui-button { float: left; font-weight: normal; border-top-width: 0 !important; border-bottom-width: 0 !important; margin: 0; outline: none; }
|
||||
.ui-menubar .ui-menubar-link { border-right: 1px dashed transparent; border-left: 1px dashed transparent; }
|
||||
|
||||
.ui-menubar .ui-menu { width: 200px; position: absolute; z-index: 9999; font-weight: normal; }
|
14
tailbone/static/css/jquery.ui.tailbone.css
vendored
14
tailbone/static/css/jquery.ui.tailbone.css
vendored
|
@ -1,14 +0,0 @@
|
|||
|
||||
/**********************************************************************
|
||||
* jquery.ui.tailbone.css
|
||||
*
|
||||
* jQuery UI tweaks for Tailbone
|
||||
**********************************************************************/
|
||||
|
||||
.ui-widget {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.ui-menu-item a {
|
||||
display: block;
|
||||
}
|
57
tailbone/static/css/jquery.ui.timepicker.css
vendored
57
tailbone/static/css/jquery.ui.timepicker.css
vendored
|
@ -1,57 +0,0 @@
|
|||
/*
|
||||
* Timepicker stylesheet
|
||||
* Highly inspired from datepicker
|
||||
* FG - Nov 2010 - Web3R
|
||||
*
|
||||
* version 0.0.3 : Fixed some settings, more dynamic
|
||||
* version 0.0.4 : Removed width:100% on tables
|
||||
* version 0.1.1 : set width 0 on tables to fix an ie6 bug
|
||||
*/
|
||||
|
||||
.ui-timepicker-inline { display: inline; }
|
||||
|
||||
#ui-timepicker-div { padding: 0.2em; }
|
||||
.ui-timepicker-table { display: inline-table; width: 0; }
|
||||
.ui-timepicker-table table { margin:0.15em 0 0 0; border-collapse: collapse; }
|
||||
|
||||
.ui-timepicker-hours, .ui-timepicker-minutes { padding: 0.2em; }
|
||||
|
||||
.ui-timepicker-table .ui-timepicker-title { line-height: 1.8em; text-align: center; }
|
||||
.ui-timepicker-table td { padding: 0.1em; width: 2.2em; }
|
||||
.ui-timepicker-table th.periods { padding: 0.1em; width: 2.2em; }
|
||||
|
||||
/* span for disabled cells */
|
||||
.ui-timepicker-table td span {
|
||||
display:block;
|
||||
padding:0.2em 0.3em 0.2em 0.5em;
|
||||
width: 1.2em;
|
||||
|
||||
text-align:right;
|
||||
text-decoration:none;
|
||||
}
|
||||
/* anchors for clickable cells */
|
||||
.ui-timepicker-table td a {
|
||||
display:block;
|
||||
padding:0.2em 0.3em 0.2em 0.5em;
|
||||
width: 1.2em;
|
||||
cursor: pointer;
|
||||
text-align:right;
|
||||
text-decoration:none;
|
||||
}
|
||||
|
||||
|
||||
/* buttons and button pane styling */
|
||||
.ui-timepicker .ui-timepicker-buttonpane {
|
||||
background-image: none; margin: .7em 0 0 0; padding:0 .2em; border-left: 0; border-right: 0; border-bottom: 0;
|
||||
}
|
||||
.ui-timepicker .ui-timepicker-buttonpane button { margin: .5em .2em .4em; cursor: pointer; padding: .2em .6em .3em .6em; width:auto; overflow:visible; }
|
||||
/* The close button */
|
||||
.ui-timepicker .ui-timepicker-close { float: right }
|
||||
|
||||
/* the now button */
|
||||
.ui-timepicker .ui-timepicker-now { float: left; }
|
||||
|
||||
/* the deselect button */
|
||||
.ui-timepicker .ui-timepicker-deselect { float: left; }
|
||||
|
||||
|
|
@ -1,249 +1,158 @@
|
|||
|
||||
/******************************
|
||||
* Main Layout
|
||||
* main layout
|
||||
******************************/
|
||||
|
||||
html, body, #body-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body > #body-wrapper {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
#body-wrapper {
|
||||
margin: 0 1em;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
#header {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
}
|
||||
|
||||
#body {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 5em;
|
||||
}
|
||||
|
||||
#footer {
|
||||
clear: both;
|
||||
margin-top: -4em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
/******************************
|
||||
* Header
|
||||
******************************/
|
||||
|
||||
#header h1 {
|
||||
float: left;
|
||||
font-size: 25px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
#header h1.title {
|
||||
font-size: 20px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
#header div.login {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.global .title {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
/* new stuff from 'better' theme begins here */
|
||||
|
||||
header .global {
|
||||
background-color: #eaeaea;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
header .global a.home,
|
||||
header .global a.global,
|
||||
header .global span.global {
|
||||
display: block;
|
||||
float: left;
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
line-height: 60px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
header .global a.home img {
|
||||
display: block;
|
||||
float: left;
|
||||
padding: 5px 5px 5px 30px;
|
||||
}
|
||||
|
||||
header .global .grid-nav {
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 60px;
|
||||
margin-left: 5em;
|
||||
}
|
||||
|
||||
header .global .grid-nav .ui-button,
|
||||
header .global .grid-nav span.viewing {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
header .global .feedback {
|
||||
float: right;
|
||||
line-height: 60px;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
header .page {
|
||||
border-bottom: 1px solid lightgrey;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
header .page h1 {
|
||||
margin: 0;
|
||||
padding: 0 0 0 0.5em;
|
||||
}
|
||||
|
||||
/******************************
|
||||
* Logo
|
||||
******************************/
|
||||
|
||||
#logo {
|
||||
display: block;
|
||||
margin: 40px auto;
|
||||
}
|
||||
|
||||
|
||||
/****************************************
|
||||
* content
|
||||
****************************************/
|
||||
|
||||
body > #body-wrapper {
|
||||
margin: 0px;
|
||||
position: relative;
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
height: 100%;
|
||||
padding-bottom: 30px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#scrollpane {
|
||||
height: 100%;
|
||||
|
||||
/******************************
|
||||
* header
|
||||
******************************/
|
||||
|
||||
/* this is the one in the very top left of screen, next to logo and linked to
|
||||
the home page */
|
||||
#global-header-title {
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
|
||||
#scrollpane .inner-content {
|
||||
padding: 0 0.5em 0.5em 0.5em;
|
||||
header .level {
|
||||
/* TODO: not sure what this 60px was supposed to do? but it broke the */
|
||||
/* styles for the feedback dialog, so disabled it is.
|
||||
/* height: 60px; */
|
||||
/* line-height: 60px; */
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
header .level #header-logo {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
header .level .global-title,
|
||||
header .level-left .global-title {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* indent nested menu items a bit */
|
||||
header .navbar-item.nested {
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
header span.header-text {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#content-title h1 {
|
||||
margin-bottom: 0;
|
||||
margin-right: 1rem;
|
||||
max-width: 50%;
|
||||
overflow: hidden;
|
||||
padding: 0 0.3rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/******************************
|
||||
* content
|
||||
******************************/
|
||||
|
||||
#page-body {
|
||||
padding: 0.4em;
|
||||
}
|
||||
|
||||
/******************************
|
||||
* context menu
|
||||
******************************/
|
||||
|
||||
#context-menu {
|
||||
float: right;
|
||||
list-style-type: none;
|
||||
margin: 0.5em;
|
||||
margin-bottom: 1em;
|
||||
margin-left: 1em;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
/******************************
|
||||
* Panels
|
||||
* "object helper" panel
|
||||
******************************/
|
||||
|
||||
.panel-wrapper {
|
||||
float: left;
|
||||
margin-right: 15px;
|
||||
width: 40%;
|
||||
.object-helpers .panel {
|
||||
margin: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.panel-grid {
|
||||
border-left: 1px solid Black;
|
||||
margin-bottom: 15px;
|
||||
.object-helpers .panel-heading {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-bottom: 1px solid Black;
|
||||
border-right: 1px solid Black;
|
||||
padding: 0px;
|
||||
.object-helpers a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel h2,
|
||||
.panel-grid h2 {
|
||||
border-bottom: 1px solid Black;
|
||||
border-top: 1px solid Black;
|
||||
padding: 5px;
|
||||
margin: 0px;
|
||||
.object-helper {
|
||||
border: 1px solid black;
|
||||
margin: 1em;
|
||||
padding: 1em;
|
||||
width: 20em;
|
||||
}
|
||||
|
||||
.panel-grid h2 {
|
||||
border-right: 1px solid Black;
|
||||
.object-helper-content {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
overflow: auto;
|
||||
padding: 5px;
|
||||
/******************************
|
||||
* markdown
|
||||
******************************/
|
||||
|
||||
.rendered-markdown p,
|
||||
.rendered-markdown ul {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* footer
|
||||
****************************************/
|
||||
|
||||
#footer {
|
||||
border-top: 1px solid lightgray;
|
||||
bottom: 0;
|
||||
font-size: 9pt;
|
||||
height: 20px;
|
||||
left: 0;
|
||||
line-height: 20px;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
.rendered-markdown .codehilite {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/******************************
|
||||
* fix datepicker within modals
|
||||
* TODO: someday this may not be necessary? cf.
|
||||
* https://github.com/buefy/buefy/issues/292#issuecomment-347365637
|
||||
******************************/
|
||||
|
||||
.modal .animation-content .modal-card {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.modal-card-body {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* TODO: a simpler option we might try sometime instead? */
|
||||
/* cf. https://github.com/buefy/buefy/issues/292#issuecomment-1073851313 */
|
||||
|
||||
/* .dropdown-content{ */
|
||||
/* position: fixed; */
|
||||
/* } */
|
||||
|
||||
/******************************
|
||||
* feedback
|
||||
******************************/
|
||||
|
||||
#feedback-dialog {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#feedback-dialog p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
#feedback-dialog .red {
|
||||
.feedback-dialog .red {
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#feedback-dialog .field-wrapper {
|
||||
margin-top: 1em;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#feedback-dialog .field {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
#feedback-dialog .referrer .field {
|
||||
clear: both;
|
||||
float: none;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
#feedback-dialog textarea {
|
||||
width: auto;
|
||||
}
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
|
||||
/******************************
|
||||
* login.css
|
||||
******************************/
|
||||
|
||||
#logo {
|
||||
margin: 40px auto;
|
||||
}
|
||||
|
||||
div.form {
|
||||
margin: auto;
|
||||
float: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div.field-wrapper {
|
||||
margin: 10px auto;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
div.field-wrapper label {
|
||||
text-align: right;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
div.field-wrapper div.field input[type="text"],
|
||||
div.field-wrapper div.field input[type="password"] {
|
||||
margin-left: 1em;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
div.buttons input {
|
||||
margin: auto 5px;
|
||||
}
|
||||
|
||||
/* this is for "login as chuck" tip in demo mode */
|
||||
.tips {
|
||||
margin-top: 2em;
|
||||
text-align: center;
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
|
||||
/****************************************
|
||||
* Global styles for mobile templates
|
||||
****************************************/
|
||||
|
||||
/* main user menu button when root */
|
||||
[data-role="header"] a.root-user,
|
||||
[data-role="header"] a.root-user:hover {
|
||||
background-color: red;
|
||||
}
|
||||
|
||||
/* become/stop root menu links */
|
||||
#usermenu .root-user a {
|
||||
background-color: red;
|
||||
}
|
||||
|
||||
/* normal flash messages */
|
||||
.flash {
|
||||
color: green;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
/* error flash messages */
|
||||
.error,
|
||||
.error-messages {
|
||||
color: red;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
/* receiving warning flash messages */
|
||||
.receiving-warning {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.replacement-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.field-wrapper.with-error {
|
||||
background-color: #ddcccc;
|
||||
border: 2px solid #dd6666;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.field-wrapper label {
|
||||
font-weight: bold;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.field-error .error-msg {
|
||||
color: Red;
|
||||
}
|
||||
|
||||
/* make sure space comes between simple filter and "grid" list */
|
||||
.simple-filter {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
|
@ -3,12 +3,13 @@
|
|||
* Permission Lists
|
||||
******************************/
|
||||
|
||||
.field-wrapper.permissions .field .group {
|
||||
.permissions-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.field-wrapper.permissions .field .group p {
|
||||
.permissions-group .group-label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.field-wrapper.permissions .field label {
|
||||
|
@ -21,12 +22,12 @@
|
|||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.field-wrapper.permissions .field .group p.perm {
|
||||
.permissions-group .perm {
|
||||
font-weight: normal;
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.field-wrapper.permissions .field .group p.perm span {
|
||||
.permissions-group p.perm span {
|
||||
font-family: monospace;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
|
BIN
tailbone/static/files/newproduct_template.xlsx
Normal file
BIN
tailbone/static/files/newproduct_template.xlsx
Normal file
Binary file not shown.
BIN
tailbone/static/files/vendor_catalog_template.xlsx
Normal file
BIN
tailbone/static/files/vendor_catalog_template.xlsx
Normal file
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 10 KiB |
36
tailbone/static/js/debounce.js
Normal file
36
tailbone/static/js/debounce.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
|
||||
// this code was politely stolen from
|
||||
// https://vanillajstoolkit.com/helpers/debounce/
|
||||
|
||||
// its purpose is to help with Buefy autocomplete performance
|
||||
// https://buefy.org/documentation/autocomplete/
|
||||
|
||||
/**
|
||||
* Debounce functions for better performance
|
||||
* (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com
|
||||
* @param {Function} fn The function to debounce
|
||||
*/
|
||||
function debounce (fn) {
|
||||
|
||||
// Setup a timer
|
||||
let timeout;
|
||||
|
||||
// Return a function to run debounced
|
||||
return function () {
|
||||
|
||||
// Setup the arguments
|
||||
let context = this;
|
||||
let args = arguments;
|
||||
|
||||
// If there's a timer, cancel it
|
||||
if (timeout) {
|
||||
window.cancelAnimationFrame(timeout);
|
||||
}
|
||||
|
||||
// Setup the new requestAnimationFrame()
|
||||
timeout = window.requestAnimationFrame(function () {
|
||||
fn.apply(context, args);
|
||||
});
|
||||
|
||||
};
|
||||
}
|
452
tailbone/static/js/jquery.ui.tailbone.js
vendored
452
tailbone/static/js/jquery.ui.tailbone.js
vendored
|
@ -1,452 +0,0 @@
|
|||
|
||||
/**********************************************************************
|
||||
* jQuery UI plugins for Tailbone
|
||||
**********************************************************************/
|
||||
|
||||
/**********************************************************************
|
||||
* gridcore plugin
|
||||
**********************************************************************/
|
||||
|
||||
(function($) {
|
||||
|
||||
$.widget('tailbone.gridcore', {
|
||||
|
||||
_create: function() {
|
||||
|
||||
var that = this;
|
||||
|
||||
// Add hover highlight effect to grid rows during mouse-over.
|
||||
// this.element.on('mouseenter', 'tbody tr:not(.header)', function() {
|
||||
this.element.on('mouseenter', 'tr:not(.header)', function() {
|
||||
$(this).addClass('hovering');
|
||||
});
|
||||
// this.element.on('mouseleave', 'tbody tr:not(.header)', function() {
|
||||
this.element.on('mouseleave', 'tr:not(.header)', function() {
|
||||
$(this).removeClass('hovering');
|
||||
});
|
||||
|
||||
// do some extra stuff for grids with checkboxes
|
||||
|
||||
// mark rows selected on page load, as needed
|
||||
this.element.find('tr:not(.header) td.checkbox :checkbox:checked').each(function() {
|
||||
$(this).parents('tr:first').addClass('selected');
|
||||
});
|
||||
|
||||
// (un-)check all rows when clicking check-all box in header
|
||||
if (this.element.find('tr.header td.checkbox :checkbox').length) {
|
||||
this.element.on('click', 'tr.header td.checkbox :checkbox', function() {
|
||||
var checked = $(this).prop('checked');
|
||||
var rows = that.element.find('tr:not(.header)');
|
||||
rows.find('td.checkbox :checkbox').prop('checked', checked);
|
||||
if (checked) {
|
||||
rows.addClass('selected');
|
||||
} else {
|
||||
rows.removeClass('selected');
|
||||
}
|
||||
that.element.trigger('gridchecked', that.count_selected());
|
||||
});
|
||||
}
|
||||
|
||||
// when row with checkbox is clicked, toggle selected status,
|
||||
// unless clicking checkbox (since that already toggles it) or a
|
||||
// link (since that does something completely different)
|
||||
this.element.on('click', 'tr:not(.header)', function(event) {
|
||||
var el = $(event.target);
|
||||
if (!el.is('a') && !el.is(':checkbox')) {
|
||||
$(this).find('td.checkbox :checkbox').click();
|
||||
}
|
||||
});
|
||||
|
||||
this.element.on('change', 'tr:not(.header) td.checkbox :checkbox', function() {
|
||||
if (this.checked) {
|
||||
$(this).parents('tr:first').addClass('selected');
|
||||
} else {
|
||||
$(this).parents('tr:first').removeClass('selected');
|
||||
}
|
||||
that.element.trigger('gridchecked', that.count_selected());
|
||||
});
|
||||
|
||||
// Show 'more' actions when user hovers over 'more' link.
|
||||
this.element.on('mouseenter', '.actions a.more', function() {
|
||||
that.element.find('.actions div.more').hide();
|
||||
$(this).siblings('div.more')
|
||||
.show()
|
||||
.position({my: 'left-5 top-4', at: 'left top', of: $(this)});
|
||||
});
|
||||
this.element.on('mouseleave', '.actions div.more', function() {
|
||||
$(this).hide();
|
||||
});
|
||||
|
||||
// Add speed bump for "Delete Row" action, if grid is so configured.
|
||||
if (this.element.data('delete-speedbump')) {
|
||||
this.element.on('click', 'tr:not(.header) .actions a.delete', function() {
|
||||
return confirm("Are you sure you wish to delete this object?");
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
count_selected: function() {
|
||||
return this.element.find('tr:not(.header) td.checkbox :checkbox:checked').length;
|
||||
},
|
||||
|
||||
// TODO: deprecate / remove this?
|
||||
count_checked: function() {
|
||||
return this.count_selected();
|
||||
},
|
||||
|
||||
selected_rows: function() {
|
||||
return this.element.find('tr:not(.header) td.checkbox :checkbox:checked').parents('tr:first');
|
||||
},
|
||||
|
||||
all_uuids: function() {
|
||||
var uuids = [];
|
||||
this.element.find('tr:not(.header)').each(function() {
|
||||
uuids.push($(this).data('uuid'));
|
||||
});
|
||||
return uuids;
|
||||
},
|
||||
|
||||
selected_uuids: function() {
|
||||
var uuids = [];
|
||||
this.element.find('tr:not(.header) td.checkbox :checkbox:checked').each(function() {
|
||||
uuids.push($(this).parents('tr:first').data('uuid'));
|
||||
});
|
||||
return uuids;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
})( jQuery );
|
||||
|
||||
|
||||
/**********************************************************************
|
||||
* gridwrapper plugin
|
||||
**********************************************************************/
|
||||
|
||||
(function($) {
|
||||
|
||||
$.widget('tailbone.gridwrapper', {
|
||||
|
||||
_create: function() {
|
||||
|
||||
var that = this;
|
||||
|
||||
// Snag some element references.
|
||||
this.filters = this.element.find('.newfilters');
|
||||
this.filters_form = this.filters.find('form');
|
||||
this.add_filter = this.filters.find('#add-filter');
|
||||
this.apply_filters = this.filters.find('#apply-filters');
|
||||
this.default_filters = this.filters.find('#default-filters');
|
||||
this.clear_filters = this.filters.find('#clear-filters');
|
||||
this.save_defaults = this.filters.find('#save-defaults');
|
||||
this.grid = this.element.find('.grid');
|
||||
|
||||
// add standard grid behavior
|
||||
this.grid.gridcore();
|
||||
|
||||
// Enhance filters etc.
|
||||
this.filters.find('.filter').gridfilter();
|
||||
this.apply_filters.button('option', 'icons', {primary: 'ui-icon-search'});
|
||||
this.default_filters.button('option', 'icons', {primary: 'ui-icon-home'});
|
||||
this.clear_filters.button('option', 'icons', {primary: 'ui-icon-trash'});
|
||||
this.save_defaults.button('option', 'icons', {primary: 'ui-icon-disk'});
|
||||
if (! this.filters.find('.active:checked').length) {
|
||||
this.apply_filters.button('disable');
|
||||
}
|
||||
this.add_filter.selectmenu({
|
||||
width: '15em',
|
||||
|
||||
// Initially disabled if contains no enabled filter options.
|
||||
disabled: this.add_filter.find('option:enabled').length == 1,
|
||||
|
||||
// When add-filter choice is made, show/focus new filter value input,
|
||||
// and maybe hide the add-filter selection or show the apply button.
|
||||
change: function (event, ui) {
|
||||
var filter = that.filters.find('#filter-' + ui.item.value);
|
||||
var select = $(this);
|
||||
var option = ui.item.element;
|
||||
filter.gridfilter('active', true);
|
||||
filter.gridfilter('focus');
|
||||
select.val('');
|
||||
option.attr('disabled', 'disabled');
|
||||
select.selectmenu('refresh');
|
||||
if (select.find('option:enabled').length == 1) { // prompt is always enabled
|
||||
select.selectmenu('disable');
|
||||
}
|
||||
that.apply_filters.button('enable');
|
||||
}
|
||||
});
|
||||
|
||||
this.add_filter.on('selectmenuopen', function(event, ui) {
|
||||
show_all_options($(this));
|
||||
});
|
||||
|
||||
// Intercept filters form submittal, and submit via AJAX instead.
|
||||
this.filters_form.on('submit', function() {
|
||||
var settings = {filter: true, partial: true};
|
||||
if (that.filters_form.find('input[name="save-current-filters-as-defaults"]').val() == 'true') {
|
||||
settings['save-current-filters-as-defaults'] = true;
|
||||
}
|
||||
that.filters.find('.filter').each(function() {
|
||||
|
||||
// currently active filters will be included in form data
|
||||
if ($(this).gridfilter('active')) {
|
||||
settings[$(this).data('key')] = $(this).gridfilter('value');
|
||||
settings[$(this).data('key') + '.verb'] = $(this).gridfilter('verb');
|
||||
|
||||
// others will be hidden from view
|
||||
} else {
|
||||
$(this).gridfilter('hide');
|
||||
}
|
||||
});
|
||||
|
||||
// if no filters are visible, disable submit button
|
||||
if (! that.filters.find('.filter:visible').length) {
|
||||
that.apply_filters.button('disable');
|
||||
}
|
||||
|
||||
// okay, submit filters to server and refresh grid
|
||||
that.refresh(settings);
|
||||
return false;
|
||||
});
|
||||
|
||||
// When user clicks Default Filters button, refresh page with
|
||||
// instructions for the server to reset filters to default settings.
|
||||
this.default_filters.click(function() {
|
||||
that.filters_form.off('submit');
|
||||
that.filters_form.find('input[name="reset-to-default-filters"]').val('true');
|
||||
that.element.mask("Refreshing data...");
|
||||
that.filters_form.get(0).submit();
|
||||
});
|
||||
|
||||
// When user clicks Save Defaults button, refresh the grid as with
|
||||
// Apply Filters, but add an instruction for the server to save
|
||||
// current settings as defaults for the user.
|
||||
this.save_defaults.click(function() {
|
||||
that.filters_form.find('input[name="save-current-filters-as-defaults"]').val('true');
|
||||
that.filters_form.submit();
|
||||
that.filters_form.find('input[name="save-current-filters-as-defaults"]').val('false');
|
||||
});
|
||||
|
||||
// When user clicks Clear Filters button, deactivate all filters
|
||||
// and refresh the grid.
|
||||
this.clear_filters.click(function() {
|
||||
that.filters.find('.filter').each(function() {
|
||||
if ($(this).gridfilter('active')) {
|
||||
$(this).gridfilter('active', false);
|
||||
}
|
||||
});
|
||||
that.filters_form.submit();
|
||||
});
|
||||
|
||||
// Refresh data when user clicks a sortable column header.
|
||||
this.element.on('click', 'tr.header a', function() {
|
||||
var td = $(this).parent();
|
||||
var data = {
|
||||
sortkey: $(this).data('sortkey'),
|
||||
sortdir: (td.hasClass('asc')) ? 'desc' : 'asc',
|
||||
page: 1,
|
||||
partial: true
|
||||
};
|
||||
that.refresh(data);
|
||||
return false;
|
||||
});
|
||||
|
||||
// Refresh data when user chooses a new page size setting.
|
||||
this.element.on('change', '.pager #pagesize', function() {
|
||||
var settings = {
|
||||
partial: true,
|
||||
pagesize: $(this).val()
|
||||
};
|
||||
that.refresh(settings);
|
||||
});
|
||||
|
||||
// Refresh data when user clicks a pager link.
|
||||
this.element.on('click', '.pager a', function() {
|
||||
that.refresh(this.search.substring(1)); // remove leading '?'
|
||||
return false;
|
||||
});
|
||||
},
|
||||
|
||||
// Refreshes the visible data within the grid, according to the given settings.
|
||||
refresh: function(settings) {
|
||||
var that = this;
|
||||
this.element.mask("Refreshing data...");
|
||||
$.get(this.grid.data('url'), settings, function(data) {
|
||||
that.grid.replaceWith(data);
|
||||
that.grid = that.element.find('.grid');
|
||||
that.grid.gridcore();
|
||||
that.element.unmask();
|
||||
});
|
||||
},
|
||||
|
||||
results_count: function(as_text) {
|
||||
var count = null;
|
||||
var match = /showing \d+ thru \d+ of (\S+)/.exec(this.element.find('.pager .showing').text());
|
||||
if (match) {
|
||||
count = match[1];
|
||||
if (!as_text) {
|
||||
count = parseInt(count, 10);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
},
|
||||
|
||||
all_uuids: function() {
|
||||
return this.grid.gridcore('all_uuids');
|
||||
},
|
||||
|
||||
selected_uuids: function() {
|
||||
return this.grid.gridcore('selected_uuids');
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
})( jQuery );
|
||||
|
||||
|
||||
/**********************************************************************
|
||||
* gridfilter plugin
|
||||
**********************************************************************/
|
||||
|
||||
(function($) {
|
||||
|
||||
$.widget('tailbone.gridfilter', {
|
||||
|
||||
_create: function() {
|
||||
|
||||
var that = this;
|
||||
|
||||
// Track down some important elements.
|
||||
this.checkbox = this.element.find('input[name$="-active"]');
|
||||
this.label = this.element.find('label');
|
||||
this.inputs = this.element.find('.inputs');
|
||||
this.add_filter = this.element.parents('.grid-wrapper').find('#add-filter');
|
||||
|
||||
// Hide the checkbox and label, and add button for toggling active status.
|
||||
this.checkbox.addClass('ui-helper-hidden-accessible');
|
||||
this.label.hide();
|
||||
this.activebutton = $('<button type="button" class="toggle" />')
|
||||
.insertAfter(this.label)
|
||||
.text(this.label.text())
|
||||
.button({
|
||||
icons: {primary: 'ui-icon-blank'}
|
||||
});
|
||||
|
||||
// Enhance verb dropdown as selectmenu.
|
||||
this.verb_select = this.inputs.find('.verb');
|
||||
this.valueless_verbs = {};
|
||||
$.each(this.verb_select.data('hide-value-for').split(' '), function(index, value) {
|
||||
that.valueless_verbs[value] = true;
|
||||
});
|
||||
this.verb_select.selectmenu({
|
||||
width: '15em',
|
||||
change: function(event, ui) {
|
||||
if (ui.item.value in that.valueless_verbs) {
|
||||
that.inputs.find('.value').hide();
|
||||
} else {
|
||||
that.inputs.find('.value').show();
|
||||
that.focus();
|
||||
that.select();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.verb_select.on('selectmenuopen', function(event, ui) {
|
||||
show_all_options($(this));
|
||||
});
|
||||
|
||||
// Enhance any date values with datepicker widget.
|
||||
this.inputs.find('.value input[data-datepicker="true"]').datepicker({
|
||||
dateFormat: 'yy-mm-dd',
|
||||
changeYear: true,
|
||||
changeMonth: true
|
||||
});
|
||||
|
||||
// Enhance any choice/dropdown values with selectmenu.
|
||||
this.inputs.find('.value select').selectmenu({
|
||||
// provide sane width for value dropdown
|
||||
width: '15em'
|
||||
});
|
||||
|
||||
this.inputs.find('.value select').on('selectmenuopen', function(event, ui) {
|
||||
show_all_options($(this));
|
||||
});
|
||||
|
||||
// Listen for button click, to keep checkbox in sync.
|
||||
this._on(this.activebutton, {
|
||||
click: function(e) {
|
||||
var checked = !this.checkbox.is(':checked');
|
||||
this.checkbox.prop('checked', checked);
|
||||
this.refresh();
|
||||
if (checked) {
|
||||
this.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update the initial state of the button according to checkbox.
|
||||
this.refresh();
|
||||
},
|
||||
|
||||
refresh: function() {
|
||||
if (this.checkbox.is(':checked')) {
|
||||
this.activebutton.button('option', 'icons', {primary: 'ui-icon-check'});
|
||||
if (this.verb() in this.valueless_verbs) {
|
||||
this.inputs.find('.value').hide();
|
||||
} else {
|
||||
this.inputs.find('.value').show();
|
||||
}
|
||||
this.inputs.show();
|
||||
} else {
|
||||
this.activebutton.button('option', 'icons', {primary: 'ui-icon-blank'});
|
||||
this.inputs.hide();
|
||||
}
|
||||
},
|
||||
|
||||
active: function(value) {
|
||||
if (value === undefined) {
|
||||
return this.checkbox.is(':checked');
|
||||
}
|
||||
if (value) {
|
||||
if (!this.checkbox.is(':checked')) {
|
||||
this.checkbox.prop('checked', true);
|
||||
this.refresh();
|
||||
this.element.show();
|
||||
}
|
||||
} else if (this.checkbox.is(':checked')) {
|
||||
this.checkbox.prop('checked', false);
|
||||
this.refresh();
|
||||
}
|
||||
},
|
||||
|
||||
hide: function() {
|
||||
this.active(false);
|
||||
this.element.hide();
|
||||
var option = this.add_filter.find('option[value="' + this.element.data('key') + '"]');
|
||||
option.attr('disabled', false);
|
||||
if (this.add_filter.selectmenu('option', 'disabled')) {
|
||||
this.add_filter.selectmenu('enable');
|
||||
}
|
||||
this.add_filter.selectmenu('refresh');
|
||||
},
|
||||
|
||||
focus: function() {
|
||||
this.inputs.find('.value input').focus();
|
||||
},
|
||||
|
||||
select: function() {
|
||||
this.inputs.find('.value input').select();
|
||||
},
|
||||
|
||||
value: function() {
|
||||
return this.inputs.find('.value input, .value select').val();
|
||||
},
|
||||
|
||||
verb: function() {
|
||||
return this.inputs.find('.verb').val();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
})( jQuery );
|
|
@ -1,81 +0,0 @@
|
|||
|
||||
/******************************************
|
||||
* jQuery Mobile plugins for Tailbone
|
||||
*****************************************/
|
||||
|
||||
/******************************************
|
||||
* mobile autocomplete
|
||||
*****************************************/
|
||||
|
||||
(function($) {
|
||||
|
||||
$.widget('tailbone.mobileautocomplete', {
|
||||
|
||||
_create: function() {
|
||||
var that = this;
|
||||
|
||||
// snag some element references
|
||||
this.search = this.element.find('.ui-input-search');
|
||||
this.hidden_field = this.element.find('input[type="hidden"]');
|
||||
this.text_field = this.element.find('input[type="text"]');
|
||||
this.ul = this.element.find('ul');
|
||||
this.button = this.element.find('button');
|
||||
|
||||
// establish our autocomplete URL
|
||||
this.url = this.options.url || this.element.data('url');
|
||||
|
||||
// NOTE: much of this code was copied from the jquery mobile demo site
|
||||
// https://demos.jquerymobile.com/1.4.5/listview-autocomplete-remote/
|
||||
this.ul.on('filterablebeforefilter', function(e, data) {
|
||||
|
||||
var $input = $( data.input ),
|
||||
value = $input.val(),
|
||||
html = "";
|
||||
that.ul.html( "" );
|
||||
if ( value && value.length > 2 ) {
|
||||
that.ul.html( "<li><div class='ui-loader'><span class='ui-icon ui-icon-loading'></span></div></li>" );
|
||||
that.ul.listview( "refresh" );
|
||||
$.ajax({
|
||||
url: that.url,
|
||||
data: {
|
||||
term: $input.val()
|
||||
}
|
||||
})
|
||||
.then( function ( response ) {
|
||||
$.each( response, function ( i, val ) {
|
||||
html += '<li data-uuid="' + val.value + '">' + val.label + "</li>";
|
||||
});
|
||||
that.ul.html( html );
|
||||
that.ul.listview( "refresh" );
|
||||
that.ul.trigger( "updatelayout");
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// when user clicks autocomplete result, hide search etc.
|
||||
this.ul.on('click', 'li', function() {
|
||||
var $li = $(this);
|
||||
var uuid = $li.data('uuid');
|
||||
that.search.hide();
|
||||
that.hidden_field.val(uuid);
|
||||
that.button.text($li.text()).show();
|
||||
that.ul.hide();
|
||||
that.element.trigger('autocompleteitemselected', uuid);
|
||||
});
|
||||
|
||||
// when user clicks "change" button, show search etc.
|
||||
this.button.click(function() {
|
||||
that.button.hide();
|
||||
that.ul.empty().show();
|
||||
that.hidden_field.val('');
|
||||
that.search.show();
|
||||
that.text_field.focus();
|
||||
that.element.trigger('autocompleteitemcleared');
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
})( jQuery );
|
10
tailbone/static/js/lib/jquery.loadmask.min.js
vendored
10
tailbone/static/js/lib/jquery.loadmask.min.js
vendored
|
@ -1,10 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2009 Sergiy Kovalchuk (serg472@gmail.com)
|
||||
*
|
||||
* Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php)
|
||||
* and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses.
|
||||
*
|
||||
* Following code is based on Element.mask() implementation from ExtJS framework (http://extjs.com/)
|
||||
*
|
||||
*/
|
||||
(function(a){a.fn.mask=function(c,b){a(this).each(function(){if(b!==undefined&&b>0){var d=a(this);d.data("_mask_timeout",setTimeout(function(){a.maskElement(d,c)},b))}else{a.maskElement(a(this),c)}})};a.fn.unmask=function(){a(this).each(function(){a.unmaskElement(a(this))})};a.fn.isMasked=function(){return this.hasClass("masked")};a.maskElement=function(d,c){if(d.data("_mask_timeout")!==undefined){clearTimeout(d.data("_mask_timeout"));d.removeData("_mask_timeout")}if(d.isMasked()){a.unmaskElement(d)}if(d.css("position")=="static"){d.addClass("masked-relative")}d.addClass("masked");var e=a('<div class="loadmask"></div>');if(navigator.userAgent.toLowerCase().indexOf("msie")>-1){e.height(d.height()+parseInt(d.css("padding-top"))+parseInt(d.css("padding-bottom")));e.width(d.width()+parseInt(d.css("padding-left"))+parseInt(d.css("padding-right")))}if(navigator.userAgent.toLowerCase().indexOf("msie 6")>-1){d.find("select").addClass("masked-hidden")}d.append(e);if(c!==undefined){var b=a('<div class="loadmask-msg" style="display:none;"></div>');b.append("<div>"+c+"</div>");d.append(b);b.css("top",Math.round(d.height()/2-(b.height()-parseInt(b.css("padding-top"))-parseInt(b.css("padding-bottom")))/2)+"px");b.css("left",Math.round(d.width()/2-(b.width()-parseInt(b.css("padding-left"))-parseInt(b.css("padding-right")))/2)+"px");b.show()}};a.unmaskElement=function(b){if(b.data("_mask_timeout")!==undefined){clearTimeout(b.data("_mask_timeout"));b.removeData("_mask_timeout")}b.find(".loadmask-msg,.loadmask").remove();b.removeClass("masked");b.removeClass("masked-relative");b.find("select").removeClass("masked-hidden")}})(jQuery);
|
331
tailbone/static/js/lib/jquery.ui.menubar.js
vendored
331
tailbone/static/js/lib/jquery.ui.menubar.js
vendored
|
@ -1,331 +0,0 @@
|
|||
/*
|
||||
* jQuery UI Menubar @VERSION
|
||||
*
|
||||
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
|
||||
* Dual licensed under the MIT or GPL Version 2 licenses.
|
||||
* http://jquery.org/license
|
||||
*
|
||||
* http://docs.jquery.com/UI/Menubar
|
||||
*
|
||||
* Depends:
|
||||
* jquery.ui.core.js
|
||||
* jquery.ui.widget.js
|
||||
* jquery.ui.position.js
|
||||
* jquery.ui.menu.js
|
||||
*/
|
||||
(function( $ ) {
|
||||
|
||||
// TODO when mixing clicking menus and keyboard navigation, focus handling is broken
|
||||
// there has to be just one item that has tabindex
|
||||
$.widget( "ui.menubar", {
|
||||
version: "@VERSION",
|
||||
options: {
|
||||
autoExpand: false,
|
||||
buttons: false,
|
||||
items: "li",
|
||||
menuElement: "ul",
|
||||
menuIcon: false,
|
||||
position: {
|
||||
my: "left top",
|
||||
at: "left bottom"
|
||||
}
|
||||
},
|
||||
_create: function() {
|
||||
var that = this;
|
||||
this.menuItems = this.element.children( this.options.items );
|
||||
this.items = this.menuItems.children( "button, a" );
|
||||
|
||||
this.menuItems
|
||||
.addClass( "ui-menubar-item" )
|
||||
.attr( "role", "presentation" );
|
||||
// let only the first item receive focus
|
||||
this.items.slice(1).attr( "tabIndex", -1 );
|
||||
|
||||
this.element
|
||||
.addClass( "ui-menubar ui-widget-header ui-helper-clearfix" )
|
||||
.attr( "role", "menubar" );
|
||||
this._focusable( this.items );
|
||||
this._hoverable( this.items );
|
||||
this.items.siblings( this.options.menuElement )
|
||||
.menu({
|
||||
position: {
|
||||
within: this.options.position.within
|
||||
},
|
||||
select: function( event, ui ) {
|
||||
ui.item.parents( "ul.ui-menu:last" ).hide();
|
||||
that._close();
|
||||
// TODO what is this targetting? there's probably a better way to access it
|
||||
$(event.target).prev().focus();
|
||||
that._trigger( "select", event, ui );
|
||||
},
|
||||
menus: that.options.menuElement
|
||||
})
|
||||
.hide()
|
||||
.attr({
|
||||
"aria-hidden": "true",
|
||||
"aria-expanded": "false"
|
||||
})
|
||||
// TODO use _on
|
||||
.bind( "keydown.menubar", function( event ) {
|
||||
var menu = $( this );
|
||||
if ( menu.is( ":hidden" ) ) {
|
||||
return;
|
||||
}
|
||||
switch ( event.keyCode ) {
|
||||
case $.ui.keyCode.LEFT:
|
||||
that.previous( event );
|
||||
event.preventDefault();
|
||||
break;
|
||||
case $.ui.keyCode.RIGHT:
|
||||
that.next( event );
|
||||
event.preventDefault();
|
||||
break;
|
||||
}
|
||||
});
|
||||
this.items.each(function() {
|
||||
var input = $(this),
|
||||
// TODO menu var is only used on two places, doesn't quite justify the .each
|
||||
menu = input.next( that.options.menuElement );
|
||||
|
||||
// might be a non-menu button
|
||||
if ( menu.length ) {
|
||||
// TODO use _on
|
||||
input.bind( "click.menubar focus.menubar mouseenter.menubar", function( event ) {
|
||||
// ignore triggered focus event
|
||||
if ( event.type === "focus" && !event.originalEvent ) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
// TODO can we simplify or extractthis check? especially the last two expressions
|
||||
// there's a similar active[0] == menu[0] check in _open
|
||||
if ( event.type === "click" && menu.is( ":visible" ) && that.active && that.active[0] === menu[0] ) {
|
||||
that._close();
|
||||
return;
|
||||
}
|
||||
if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" || that.options.autoExpand ) {
|
||||
if( that.options.autoExpand ) {
|
||||
clearTimeout( that.closeTimer );
|
||||
}
|
||||
|
||||
that._open( event, menu );
|
||||
}
|
||||
})
|
||||
// TODO use _on
|
||||
.bind( "keydown", function( event ) {
|
||||
switch ( event.keyCode ) {
|
||||
case $.ui.keyCode.SPACE:
|
||||
case $.ui.keyCode.UP:
|
||||
case $.ui.keyCode.DOWN:
|
||||
that._open( event, $( this ).next() );
|
||||
event.preventDefault();
|
||||
break;
|
||||
case $.ui.keyCode.LEFT:
|
||||
that.previous( event );
|
||||
event.preventDefault();
|
||||
break;
|
||||
case $.ui.keyCode.RIGHT:
|
||||
that.next( event );
|
||||
event.preventDefault();
|
||||
break;
|
||||
}
|
||||
})
|
||||
.attr( "aria-haspopup", "true" );
|
||||
|
||||
// TODO review if these options (menuIcon and buttons) are a good choice, maybe they can be merged
|
||||
if ( that.options.menuIcon ) {
|
||||
input.addClass( "ui-state-default" ).append( "<span class='ui-button-icon-secondary ui-icon ui-icon-triangle-1-s'></span>" );
|
||||
input.removeClass( "ui-button-text-only" ).addClass( "ui-button-text-icon-secondary" );
|
||||
}
|
||||
} else {
|
||||
// TODO use _on
|
||||
input.bind( "click.menubar mouseenter.menubar", function( event ) {
|
||||
if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" ) {
|
||||
that._close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
input
|
||||
.addClass( "ui-button ui-widget ui-button-text-only ui-menubar-link" )
|
||||
.attr( "role", "menuitem" )
|
||||
.wrapInner( "<span class='ui-button-text'></span>" );
|
||||
|
||||
if ( that.options.buttons ) {
|
||||
input.removeClass( "ui-menubar-link" ).addClass( "ui-state-default" );
|
||||
}
|
||||
});
|
||||
that._on( {
|
||||
keydown: function( event ) {
|
||||
if ( event.keyCode === $.ui.keyCode.ESCAPE && that.active && that.active.menu( "collapse", event ) !== true ) {
|
||||
var active = that.active;
|
||||
that.active.blur();
|
||||
that._close( event );
|
||||
active.prev().focus();
|
||||
}
|
||||
},
|
||||
focusin: function( event ) {
|
||||
clearTimeout( that.closeTimer );
|
||||
},
|
||||
focusout: function( event ) {
|
||||
that.closeTimer = setTimeout( function() {
|
||||
that._close( event );
|
||||
}, 150);
|
||||
},
|
||||
"mouseleave .ui-menubar-item": function( event ) {
|
||||
if ( that.options.autoExpand ) {
|
||||
that.closeTimer = setTimeout( function() {
|
||||
that._close( event );
|
||||
}, 150);
|
||||
}
|
||||
},
|
||||
"mouseenter .ui-menubar-item": function( event ) {
|
||||
clearTimeout( that.closeTimer );
|
||||
}
|
||||
});
|
||||
|
||||
// Keep track of open submenus
|
||||
this.openSubmenus = 0;
|
||||
},
|
||||
|
||||
_destroy : function() {
|
||||
this.menuItems
|
||||
.removeClass( "ui-menubar-item" )
|
||||
.removeAttr( "role" );
|
||||
|
||||
this.element
|
||||
.removeClass( "ui-menubar ui-widget-header ui-helper-clearfix" )
|
||||
.removeAttr( "role" )
|
||||
.unbind( ".menubar" );
|
||||
|
||||
this.items
|
||||
.unbind( ".menubar" )
|
||||
.removeClass( "ui-button ui-widget ui-button-text-only ui-menubar-link ui-state-default" )
|
||||
.removeAttr( "role" )
|
||||
.removeAttr( "aria-haspopup" )
|
||||
// TODO unwrap?
|
||||
.children( "span.ui-button-text" ).each(function( i, e ) {
|
||||
var item = $( this );
|
||||
item.parent().html( item.html() );
|
||||
})
|
||||
.end()
|
||||
.children( ".ui-icon" ).remove();
|
||||
|
||||
this.element.find( ":ui-menu" )
|
||||
.menu( "destroy" )
|
||||
.show()
|
||||
.removeAttr( "aria-hidden" )
|
||||
.removeAttr( "aria-expanded" )
|
||||
.removeAttr( "tabindex" )
|
||||
.unbind( ".menubar" );
|
||||
},
|
||||
|
||||
_close: function() {
|
||||
if ( !this.active || !this.active.length ) {
|
||||
return;
|
||||
}
|
||||
this.active
|
||||
.menu( "collapseAll" )
|
||||
.hide()
|
||||
.attr({
|
||||
"aria-hidden": "true",
|
||||
"aria-expanded": "false"
|
||||
});
|
||||
this.active
|
||||
.prev()
|
||||
.removeClass( "ui-state-active" )
|
||||
.removeAttr( "tabIndex" );
|
||||
this.active = null;
|
||||
this.open = false;
|
||||
this.openSubmenus = 0;
|
||||
},
|
||||
|
||||
_open: function( event, menu ) {
|
||||
// on a single-button menubar, ignore reopening the same menu
|
||||
if ( this.active && this.active[0] === menu[0] ) {
|
||||
return;
|
||||
}
|
||||
// TODO refactor, almost the same as _close above, but don't remove tabIndex
|
||||
if ( this.active ) {
|
||||
this.active
|
||||
.menu( "collapseAll" )
|
||||
.hide()
|
||||
.attr({
|
||||
"aria-hidden": "true",
|
||||
"aria-expanded": "false"
|
||||
});
|
||||
this.active
|
||||
.prev()
|
||||
.removeClass( "ui-state-active" );
|
||||
}
|
||||
// set tabIndex -1 to have the button skipped on shift-tab when menu is open (it gets focus)
|
||||
var button = menu.prev().addClass( "ui-state-active" ).attr( "tabIndex", -1 );
|
||||
this.active = menu
|
||||
.show()
|
||||
.position( $.extend({
|
||||
of: button
|
||||
}, this.options.position ) )
|
||||
.removeAttr( "aria-hidden" )
|
||||
.attr( "aria-expanded", "true" )
|
||||
.menu("focus", event, menu.children( ".ui-menu-item" ).first() )
|
||||
// TODO need a comment here why both events are triggered
|
||||
// TODO: heh well given the above comment i'm not sure what the
|
||||
// implications might be for disabling the focus() call..but it
|
||||
// messes with text input focus in undesirable ways..so disable it
|
||||
// we will..until we know why we shouldn't
|
||||
// .focus()
|
||||
.focusin();
|
||||
this.open = true;
|
||||
},
|
||||
|
||||
next: function( event ) {
|
||||
if ( this.open && this.active.data( "menu" ).active.has( ".ui-menu" ).length ) {
|
||||
// Track number of open submenus and prevent moving to next menubar item
|
||||
this.openSubmenus++;
|
||||
return;
|
||||
}
|
||||
this.openSubmenus = 0;
|
||||
this._move( "next", "first", event );
|
||||
},
|
||||
|
||||
previous: function( event ) {
|
||||
if ( this.open && this.openSubmenus ) {
|
||||
// Track number of open submenus and prevent moving to previous menubar item
|
||||
this.openSubmenus--;
|
||||
return;
|
||||
}
|
||||
this.openSubmenus = 0;
|
||||
this._move( "prev", "last", event );
|
||||
},
|
||||
|
||||
_move: function( direction, filter, event ) {
|
||||
var next,
|
||||
wrapItem;
|
||||
if ( this.open ) {
|
||||
next = this.active.closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).first().children( ".ui-menu" ).eq( 0 );
|
||||
wrapItem = this.menuItems[ filter ]().children( ".ui-menu" ).eq( 0 );
|
||||
} else {
|
||||
if ( event ) {
|
||||
next = $( event.target ).closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).children( ".ui-menubar-link" ).eq( 0 );
|
||||
wrapItem = this.menuItems[ filter ]().children( ".ui-menubar-link" ).eq( 0 );
|
||||
} else {
|
||||
next = wrapItem = this.menuItems.children( "a" ).eq( 0 );
|
||||
}
|
||||
}
|
||||
|
||||
if ( next.length ) {
|
||||
if ( this.open ) {
|
||||
this._open( event, next );
|
||||
} else {
|
||||
next.removeAttr( "tabIndex")[0].focus();
|
||||
}
|
||||
} else {
|
||||
if ( this.open ) {
|
||||
this._open( event, wrapItem );
|
||||
} else {
|
||||
wrapItem.removeAttr( "tabIndex")[0].focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}( jQuery ));
|
1496
tailbone/static/js/lib/jquery.ui.timepicker.js
vendored
1496
tailbone/static/js/lib/jquery.ui.timepicker.js
vendored
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue