Compare commits
2353 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 | ||
![]() |
a9b60b3d4a | ||
![]() |
20e654ddea | ||
![]() |
bdbb8e2a7d | ||
![]() |
37b9a81344 | ||
![]() |
21014c5013 | ||
![]() |
9daefed9b3 | ||
![]() |
23a94ebfad | ||
![]() |
d1980aeed8 | ||
![]() |
fec8ba28e2 | ||
![]() |
9b61b05155 | ||
![]() |
ad35481234 | ||
![]() |
31ae5eacd5 | ||
![]() |
22ef6aad7b | ||
![]() |
e4a518c444 | ||
![]() |
b8fdce378f | ||
![]() |
0c41395cfc | ||
![]() |
f43b6db427 | ||
![]() |
5222f44904 | ||
![]() |
1123cbb728 | ||
![]() |
2131ea65cb | ||
![]() |
fc8391c6d1 | ||
![]() |
f086a2aa38 | ||
![]() |
92c1b165fb | ||
![]() |
3df1407073 | ||
![]() |
05c33a4b34 | ||
![]() |
2bd107056c | ||
![]() |
b9da7e1b12 | ||
![]() |
f1eba6a404 | ||
![]() |
0e13e5606a | ||
![]() |
c6b2f831e5 | ||
![]() |
f26f42427f | ||
![]() |
ed9f8a269c | ||
![]() |
fe2905e9df | ||
![]() |
7e28619e9d | ||
![]() |
e277a19f71 | ||
![]() |
78941ec8d9 | ||
![]() |
4aa8f43a7e | ||
![]() |
40a8761feb | ||
![]() |
aa97a36167 | ||
![]() |
daa304c613 | ||
![]() |
67e7ba023a | ||
![]() |
af956c6c42 | ||
![]() |
1bbf6c0940 | ||
![]() |
8d1bd2adcd | ||
![]() |
ff8f1ee435 | ||
![]() |
fd15865d0b | ||
![]() |
5a9fedbb9a | ||
![]() |
79e71ec4ab | ||
![]() |
0ed3429cf7 | ||
![]() |
fd0760ed07 | ||
![]() |
9e065541b9 | ||
![]() |
f36c1fbc3f | ||
![]() |
94ba18eaee | ||
![]() |
362173ef10 | ||
![]() |
3f2c57c89f | ||
![]() |
e05a58bdee | ||
![]() |
f17d7355e0 | ||
![]() |
29e023096b | ||
![]() |
7650064b64 | ||
![]() |
d50d3678ec | ||
![]() |
848b727b11 | ||
![]() |
5e49c2709b | ||
![]() |
6c1d67c966 | ||
![]() |
66807a801b | ||
![]() |
c1f05bf014 | ||
![]() |
d458d699e6 | ||
![]() |
6c309705a0 | ||
![]() |
27d5a92fee | ||
![]() |
878486cdab | ||
![]() |
ed5455089e | ||
![]() |
d7863c2572 | ||
![]() |
4a610ba2e6 | ||
![]() |
255485296c | ||
![]() |
99688c1c77 | ||
![]() |
fb3105c099 | ||
![]() |
c3637bc416 | ||
![]() |
8c26b632fe | ||
![]() |
0b9fe2dfe7 | ||
![]() |
3b0292029d | ||
![]() |
3a91ab6bec | ||
![]() |
66f1ed0e41 | ||
![]() |
acd8c97afc | ||
![]() |
be49ca6967 | ||
![]() |
2939b53467 | ||
![]() |
6fb78c5dde | ||
![]() |
5b2f4127ea | ||
![]() |
c5fef6b954 | ||
![]() |
84db66a60c | ||
![]() |
db0eee707a | ||
![]() |
06b5f6c97c | ||
![]() |
a6a7d22ec1 | ||
![]() |
f0d8f79676 | ||
![]() |
4d0223e305 | ||
![]() |
528c0f9622 | ||
![]() |
56392ccdd0 | ||
![]() |
d4b2cf9943 | ||
![]() |
950af8b5e0 | ||
![]() |
21740ea2fd | ||
![]() |
5e879a2d92 | ||
![]() |
6ef5677dc5 | ||
![]() |
ac451757b4 | ||
![]() |
a348755be2 | ||
![]() |
a24076f0ce | ||
![]() |
e0f7ba827f | ||
![]() |
cefadc7c27 | ||
![]() |
de6401c5db | ||
![]() |
e43f713a66 | ||
![]() |
8d77111b06 | ||
![]() |
f6712a6686 | ||
![]() |
6af9440ed7 | ||
![]() |
d145ce5f6d | ||
![]() |
634a93061b | ||
![]() |
6b3e645c12 | ||
![]() |
fba7c5f978 | ||
![]() |
5db7d3776a | ||
![]() |
34bdd2ac84 | ||
![]() |
87ba8026e5 | ||
![]() |
c2968fbe52 | ||
![]() |
117e52df23 | ||
![]() |
4e09b757c3 | ||
![]() |
012a06d8a6 | ||
![]() |
d8b45db331 | ||
![]() |
a34a42d2b2 | ||
![]() |
3cc8adba86 | ||
![]() |
eccce1cabb | ||
![]() |
d3e67ccbcd | ||
![]() |
45f19517d3 | ||
![]() |
259d123876 | ||
![]() |
0853fac66a | ||
![]() |
6fc517269f | ||
![]() |
0e57152888 | ||
![]() |
935a6b2a68 | ||
![]() |
68bd3047c4 | ||
![]() |
aa6e540abd | ||
![]() |
9caf0e2e1f | ||
![]() |
8039af1c06 | ||
![]() |
699536b1ab | ||
![]() |
16ab8b6ffa | ||
![]() |
477a34cfa7 | ||
![]() |
147c65afe6 | ||
![]() |
2983ff7ba0 | ||
![]() |
ed6f2f27cc | ||
![]() |
9dd6f8ef7d | ||
![]() |
053fc4eb55 | ||
![]() |
44663fe548 | ||
![]() |
c88d060fe0 | ||
![]() |
469f9cf015 | ||
![]() |
3dfdb26502 | ||
![]() |
9149902c78 | ||
![]() |
b464db5722 | ||
![]() |
8cadec9a16 | ||
![]() |
00a0e6fb11 | ||
![]() |
9a0a280d7d | ||
![]() |
ad5444d270 | ||
![]() |
3cc789dda9 | ||
![]() |
ac5a6c011b | ||
![]() |
6febd01e76 | ||
![]() |
4c2f1aa4ed | ||
![]() |
944e896196 | ||
![]() |
4ffd0df7c1 | ||
![]() |
2ffb930f7f | ||
![]() |
8d0dfd631b | ||
![]() |
350e901c2a | ||
![]() |
1342d67746 | ||
![]() |
b9d699df84 | ||
![]() |
6b01a7e888 | ||
![]() |
49f241a4b9 | ||
![]() |
eeba784c32 | ||
![]() |
0ccb6883f8 | ||
![]() |
da10c6503c | ||
![]() |
b66af5903b | ||
![]() |
440a88aa0f | ||
![]() |
ee1065bfdb | ||
![]() |
076d3d8189 | ||
![]() |
c1e2c5551c | ||
![]() |
edbf7e6723 |
501 changed files with 78518 additions and 20275 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.
|
3094
CHANGES.rst
3094
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:: add_rattail_config_attribute_to_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
|
171
setup.py
171
setup.py
|
@ -1,171 +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: Pyramid 1.9 looks like it breaks us..? playing it safe for now..
|
||||
'pyramid<1.9', # 1.3b2 1.8.3
|
||||
|
||||
# apparently 2.0 removes the retry support, in which case we then need
|
||||
# pyramid_retry .. but that requires pyramid 1.9 ...
|
||||
'pyramid_tm<2.0', # 0.3 1.1.1
|
||||
|
||||
# TODO: why do we need to cap this? breaks tailbone.db zope stuff somehow
|
||||
'zope.sqlalchemy<1.0', # 0.7 0.7.7
|
||||
|
||||
'ColanderAlchemy', # 0.3.3
|
||||
'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
|
||||
'pyramid_beaker>=0.6', # 0.6.1
|
||||
'pyramid_deform', # 0.2
|
||||
'pyramid_exclog', # 0.6
|
||||
'pyramid_mako', # 1.0.2
|
||||
'rattail[db,bouncer]', # 0.5.0
|
||||
'six', # 1.10.0
|
||||
'transaction', # 1.2.0
|
||||
'waitress', # 0.8.1
|
||||
'WebHelpers2', # 2.0
|
||||
'webhelpers2_grid', # 0.1
|
||||
'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.20'
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
except ImportError:
|
||||
from importlib_metadata import version
|
||||
|
||||
|
||||
__version__ = version('Tailbone')
|
||||
|
|
40
tailbone/api/__init__.py
Normal file
40
tailbone/api/__init__.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
# -*- 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
|
||||
"""
|
||||
|
||||
from __future__ import unicode_literals, absolute_import
|
||||
|
||||
from .core import APIView, api
|
||||
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')
|
||||
config.include('tailbone.api.users')
|
229
tailbone/api/auth.py
Normal file
229
tailbone/api/auth.py
Normal file
|
@ -0,0 +1,229 @@
|
|||
# -*- 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 - Auth Views
|
||||
"""
|
||||
|
||||
from cornice import Service
|
||||
|
||||
from tailbone.api import APIView, api
|
||||
from tailbone.db import Session
|
||||
from tailbone.auth import login_user, logout_user
|
||||
|
||||
|
||||
class AuthenticationView(APIView):
|
||||
|
||||
@api
|
||||
def check_session(self):
|
||||
"""
|
||||
View to serve as "no-op" / ping action to check current user's session.
|
||||
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:
|
||||
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):
|
||||
"""
|
||||
API login view.
|
||||
"""
|
||||
if self.request.method == 'OPTIONS':
|
||||
return self.request.response
|
||||
|
||||
username = self.request.json.get('username')
|
||||
password = self.request.json.get('password')
|
||||
if not (username and password):
|
||||
return {'error': "Invalid username or 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 {
|
||||
'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):
|
||||
"""
|
||||
API logout view.
|
||||
"""
|
||||
if self.request.method == 'OPTIONS':
|
||||
return self.request.response
|
||||
|
||||
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
|
||||
check_session = Service(name='check_session', path='/session')
|
||||
check_session.add_view('GET', 'check_session', klass=cls)
|
||||
config.add_cornice_service(check_session)
|
||||
|
||||
# login
|
||||
login = Service(name='login', path='/login')
|
||||
login.add_view('POST', 'login', klass=cls)
|
||||
config.add_cornice_service(login)
|
||||
|
||||
# logout
|
||||
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):
|
||||
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)
|
125
tailbone/api/core.py
Normal file
125
tailbone/api/core.py
Normal file
|
@ -0,0 +1,125 @@
|
|||
# -*- 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 - Core Views
|
||||
"""
|
||||
|
||||
from tailbone.views import View
|
||||
|
||||
|
||||
def api(view_meth):
|
||||
"""
|
||||
Common decorator for all API views. Ideally this would not be needed..but
|
||||
for now, alas, it is.
|
||||
"""
|
||||
def wrapped(view, *args, **kwargs):
|
||||
|
||||
# TODO: why doesn't this work here...? (instead we have to repeat this
|
||||
# code in lots of other places)
|
||||
# if view.request.method == 'OPTIONS':
|
||||
# return view.request.response
|
||||
|
||||
# invoke the view logic first, since presumably it may involve a
|
||||
# redirect in which case we don't really need to add the CSRF token.
|
||||
# main known use case for this is the /logout endpoint - if that gets
|
||||
# hit then the "current" (old) session will be destroyed, in which case
|
||||
# we can't use the token from that, but instead must generate a new one.
|
||||
result = view_meth(view, *args, **kwargs)
|
||||
|
||||
# explicitly set CSRF token cookie, unless OPTIONS request
|
||||
# TODO: why doesn't pyramid do this for us again?
|
||||
if view.request.method != 'OPTIONS':
|
||||
view.request.response.set_cookie(name='XSRF-TOKEN',
|
||||
value=view.request.session.get_csrf_token())
|
||||
|
||||
return result
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
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
|
60
tailbone/api/customers.py
Normal file
60
tailbone/api/customers.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
# -*- 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 - Customer Views
|
||||
"""
|
||||
|
||||
from rattail.db import model
|
||||
|
||||
from tailbone.api import APIMasterView
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
CustomerView = kwargs.get('CustomerView', base['CustomerView'])
|
||||
CustomerView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
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)
|
618
tailbone/api/master.py
Normal file
618
tailbone/api/master.py
Normal file
|
@ -0,0 +1,618 @@
|
|||
# -*- 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 - Master View
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from rattail.db.util import get_fieldnames
|
||||
|
||||
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):
|
||||
return Session
|
||||
|
||||
@classmethod
|
||||
def get_model_class(cls):
|
||||
if hasattr(cls, 'model_class'):
|
||||
return cls.model_class
|
||||
raise NotImplementedError("must set `model_class` for {}".format(cls.__name__))
|
||||
|
||||
@classmethod
|
||||
def get_normalized_model_name(cls):
|
||||
if hasattr(cls, 'normalized_model_name'):
|
||||
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'):
|
||||
return cls.object_key
|
||||
return cls.get_normalized_model_name()
|
||||
|
||||
@classmethod
|
||||
def get_collection_key(cls):
|
||||
if hasattr(cls, 'collection_key'):
|
||||
return cls.collection_key
|
||||
return '{}s'.format(cls.get_object_key())
|
||||
|
||||
@classmethod
|
||||
def establish_method(cls, method_name):
|
||||
"""
|
||||
Establish the given HTTP method for this Cornice Resource.
|
||||
|
||||
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('|')
|
||||
if sortdir != 'desc':
|
||||
sortdir = 'asc'
|
||||
return [
|
||||
{
|
||||
# 'model': self.model_class.__name__,
|
||||
'field': sortkey,
|
||||
'direction': sortdir,
|
||||
},
|
||||
]
|
||||
|
||||
# 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():
|
||||
return int(page), int(per_page)
|
||||
|
||||
# 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 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()
|
||||
|
||||
# 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)
|
64
tailbone/api/upgrades.py
Normal file
64
tailbone/api/upgrades.py
Normal file
|
@ -0,0 +1,64 @@
|
|||
# -*- 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 - Upgrade Views
|
||||
"""
|
||||
|
||||
from rattail.db import model
|
||||
|
||||
from tailbone.api import 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 = {
|
||||
'created': upgrade.created.isoformat(),
|
||||
'description': upgrade.description,
|
||||
'enabled': upgrade.enabled,
|
||||
'executed': upgrade.executed.isoformat() if upgrade.executed else None,
|
||||
# 'executed_by':
|
||||
}
|
||||
if upgrade.status_code is None:
|
||||
data['status_code'] = None
|
||||
else:
|
||||
data['status_code'] = self.enum.UPGRADE_STATUS.get(upgrade.status_code,
|
||||
str(upgrade.status_code))
|
||||
return data
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
UpgradeView = kwargs.get('UpgradeView', base['UpgradeView'])
|
||||
UpgradeView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
71
tailbone/api/users.py
Normal file
71
tailbone/api/users.py
Normal file
|
@ -0,0 +1,71 @@
|
|||
# -*- 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 - User Views
|
||||
"""
|
||||
|
||||
from rattail.db import model
|
||||
|
||||
from tailbone.api import APIMasterView
|
||||
|
||||
|
||||
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_display_name': (user.person.display_name or '') if user.person else '',
|
||||
'active': user.active,
|
||||
}
|
||||
|
||||
def interpret_sortcol(self, order_by):
|
||||
if order_by == 'person_display_name':
|
||||
return self.sortcol('Person', 'display_name')
|
||||
return self.sortcol(order_by)
|
||||
|
||||
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):
|
||||
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)
|
271
tailbone/app.py
271
tailbone/app.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,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, configure_versioning
|
||||
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):
|
||||
|
@ -55,45 +54,48 @@ def make_rattail_config(settings):
|
|||
# available for web requests later
|
||||
path = settings.get('rattail.config')
|
||||
if not path or not os.path.exists(path):
|
||||
path = settings.get('edbob.config')
|
||||
if not path or not os.path.exists(path):
|
||||
raise ConfigurationError("Please set 'rattail.config' in [app:main] section of config "
|
||||
"to the path of your config file. Lame, but necessary.")
|
||||
warnings.warn("[app:main] setting 'edbob.config' is deprecated; "
|
||||
"please use 'rattail.config' setting instead",
|
||||
DeprecationWarning)
|
||||
raise ConfigurationError("Please set 'rattail.config' in [app:main] section of config "
|
||||
"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).
|
||||
rattail_config._session_factory = lambda: (tailbone.db.Session(), False)
|
||||
|
||||
# Configure (or not) Continuum versioning.
|
||||
configure_versioning(rattail_config)
|
||||
return rattail_config
|
||||
|
||||
|
||||
|
@ -103,7 +105,12 @@ def provide_postgresql_settings(settings):
|
|||
this enables retrying transactions a second time, in an attempt to
|
||||
gracefully handle database restarts.
|
||||
"""
|
||||
settings.setdefault('tm.attempts', 2)
|
||||
try:
|
||||
import pyramid_retry
|
||||
except ImportError:
|
||||
settings.setdefault('tm.attempts', 2)
|
||||
else:
|
||||
settings.setdefault('retry.attempts', 2)
|
||||
|
||||
|
||||
class Root(dict):
|
||||
|
@ -121,42 +128,197 @@ 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')
|
||||
|
||||
# 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')
|
||||
# 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)
|
||||
|
||||
# TODO: This can finally be removed once all CRUD/index views have been
|
||||
# converted to use the new master view etc.
|
||||
for label, perms in settings.get('edbob.permissions', []):
|
||||
groupkey = label.lower().replace(' ', '_')
|
||||
config.add_tailbone_permission_group(groupkey, label)
|
||||
for key, label in perms:
|
||||
config.add_tailbone_permission(groupkey, key, label)
|
||||
# bring in the pyramid_retry logic, if available
|
||||
# TODO: pretty soon we can require this package, hopefully..
|
||||
try:
|
||||
import pyramid_retry
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
config.include('pyramid_retry')
|
||||
|
||||
# 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,
|
||||
|
@ -170,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,8 +715,25 @@ class AlchemyIntegerFilter(AlchemyNumericFilter):
|
|||
return True
|
||||
if not value.isdigit():
|
||||
return True
|
||||
# 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):
|
||||
"""
|
||||
|
@ -500,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):
|
||||
|
@ -516,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",
|
||||
|
@ -525,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:
|
||||
|
@ -540,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):
|
||||
"""
|
||||
|
@ -629,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):
|
||||
"""
|
||||
|
@ -719,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),
|
||||
|
@ -741,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
|
||||
|
@ -757,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):
|
||||
"""
|
||||
|
@ -777,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-2017 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
|
||||
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 */
|
|
@ -10,6 +10,10 @@ table.diff {
|
|||
min-width: 80%;
|
||||
}
|
||||
|
||||
.ui-dialog-content table.diff {
|
||||
color: black;
|
||||
}
|
||||
|
||||
table.diff th,
|
||||
table.diff td {
|
||||
border-bottom: 1px solid Black;
|
||||
|
|
|
@ -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,50 +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;
|
||||
}
|
||||
|
||||
|
||||
/******************************
|
||||
* 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;
|
||||
|
@ -52,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;
|
||||
|
@ -71,58 +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;
|
||||
}
|
||||
|
||||
|
||||
/******************************
|
||||
* Email Profile forms
|
||||
******************************/
|
||||
|
||||
.field-wrapper.description .field {
|
||||
/* NOTE: This was added specifically for email settings (description), who
|
||||
knows what else it breaks...hopefully nothing. */
|
||||
width: 800px;
|
||||
}
|
||||
|
|
|
@ -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,243 +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,
|
||||
.panel-grid {
|
||||
border-left: 1px solid Black;
|
||||
margin-bottom: 15px;
|
||||
.object-helpers .panel {
|
||||
margin: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-bottom: 1px solid Black;
|
||||
border-right: 1px solid Black;
|
||||
padding: 0px;
|
||||
.object-helpers .panel-heading {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel h2,
|
||||
.panel-grid h2 {
|
||||
border-bottom: 1px solid Black;
|
||||
border-top: 1px solid Black;
|
||||
padding: 5px;
|
||||
margin: 0px;
|
||||
.object-helpers a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel-grid h2 {
|
||||
border-right: 1px solid Black;
|
||||
.object-helper {
|
||||
border: 1px solid black;
|
||||
margin: 1em;
|
||||
padding: 1em;
|
||||
width: 20em;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
overflow: auto;
|
||||
padding: 5px;
|
||||
.object-helper-content {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* footer
|
||||
****************************************/
|
||||
/******************************
|
||||
* markdown
|
||||
******************************/
|
||||
|
||||
#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 p,
|
||||
.rendered-markdown ul {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.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 ));
|
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