mirror of
https://github.com/jazzband/django-eav2.git
synced 2026-05-13 18:13:16 +00:00
Compare commits
784 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d941373d34 | ||
|
|
8b70e6afec | ||
|
|
5248498008 | ||
|
|
ce5f58dc50 | ||
|
|
f42a53bcaf | ||
|
|
32e20994f1 | ||
|
|
ec9a5b413d | ||
|
|
dcc643ff81 | ||
|
|
88d367f924 | ||
|
|
f7aeed0b14 | ||
|
|
a32e94adb6 | ||
|
|
794c71c7a6 | ||
|
|
8dd92753d6 | ||
|
|
539f0003a1 | ||
|
|
dc371db44f | ||
|
|
24c1de89fa | ||
|
|
3e5841af10 | ||
|
|
dc49b53c82 | ||
|
|
f193bd41cd | ||
|
|
f7f3d59b30 | ||
|
|
439fa5046f | ||
|
|
fe6db896bd | ||
|
|
682cf61840 | ||
|
|
7e572801b0 | ||
|
|
a38a6b9f5c | ||
|
|
2b9b9d7aa7 | ||
|
|
f6b3cf0865 | ||
|
|
041b19a1d2 | ||
|
|
e789a0dcd3 | ||
|
|
fafe528ea5 | ||
|
|
4deda2abc5 | ||
|
|
28c67b3d04 | ||
|
|
449ddc9248 | ||
|
|
a95f2a1c33 | ||
|
|
996512b04c | ||
|
|
73755c4fdf | ||
|
|
579a1e0fc7 | ||
|
|
b160b38309 | ||
|
|
436edd5492 | ||
|
|
9261d518da | ||
|
|
18148c2b97 | ||
|
|
eca5995616 | ||
|
|
1125887ba9 | ||
|
|
9c68743af8 | ||
|
|
6c44ba988a | ||
|
|
75708e3fbb | ||
|
|
34862ed30a | ||
|
|
abd93a44a1 | ||
|
|
835717bd27 | ||
|
|
3a7d8eec63 | ||
|
|
fef15f0ba6 | ||
|
|
ae73962bb2 | ||
|
|
70fceedda0 | ||
|
|
39c3540592 | ||
|
|
6c3c7f39e8 | ||
|
|
a47b1b05e0 | ||
|
|
b276cb3e35 | ||
|
|
5a1d7546f4 | ||
|
|
dc2cd2dff5 | ||
|
|
0e27224106 | ||
|
|
305740e2e6 | ||
|
|
d281ff97c2 | ||
|
|
e17778b522 | ||
|
|
0f218add0b | ||
|
|
f07e2d0506 | ||
|
|
0d82d5ab5a | ||
|
|
d0b531f7be | ||
|
|
8f18d5e7e2 | ||
|
|
3e7563338f | ||
|
|
1c4355e948 | ||
|
|
353bc5f094 | ||
|
|
17c94198d0 | ||
|
|
625b8a5315 | ||
|
|
6b7a04f8a7 | ||
|
|
9f4bddb94d | ||
|
|
393e3e352a | ||
|
|
56939d9c5e | ||
|
|
3ccf3146eb | ||
|
|
50db5bead4 | ||
|
|
b5b576aca5 | ||
|
|
5e1a7d2803 | ||
|
|
b8bcc383a4 | ||
|
|
41fa7ddc5c | ||
|
|
1262a52282 | ||
|
|
27d3887604 | ||
|
|
3990d7d6cb | ||
|
|
6f141ff4f2 | ||
|
|
ab23ba118d | ||
|
|
03cb115531 | ||
|
|
a56559ebb7 | ||
|
|
3884d2c2aa | ||
|
|
b0a73e2b9c | ||
|
|
cad0846d2c | ||
|
|
c86b909970 | ||
|
|
6e63441ca0 | ||
|
|
d2c34da383 | ||
|
|
c8bc9310d9 | ||
|
|
4940d7fa0b | ||
|
|
8ab1d09627 | ||
|
|
3ea0257a21 | ||
|
|
5dba618e63 | ||
|
|
17e018a0a0 | ||
|
|
c82273e62e | ||
|
|
ceaf3abf40 | ||
|
|
38ddd519cf | ||
|
|
c35d1355b0 | ||
|
|
97eb3f7fc2 | ||
|
|
a4f5a8ae3d | ||
|
|
322753b821 | ||
|
|
c62c927548 | ||
|
|
c0f485e766 | ||
|
|
462e949a08 | ||
|
|
6585010038 | ||
|
|
8c87f2f53b | ||
|
|
1b0ea0b9c4 | ||
|
|
1b73435ff3 | ||
|
|
3c74c2c008 | ||
|
|
04b1926d3a | ||
|
|
6d3892459f | ||
|
|
671fd814b6 | ||
|
|
27dfd357de | ||
|
|
9746ef8218 | ||
|
|
5c804658b9 | ||
|
|
18b821e4a5 | ||
|
|
8529a40d9d | ||
|
|
827c54895f | ||
|
|
26dfc17c1b | ||
|
|
b48882ee9d | ||
|
|
b94cc44db5 | ||
|
|
4a01f13109 | ||
|
|
20c9194372 | ||
|
|
e6cc7bc64e | ||
|
|
6cc8e1206c | ||
|
|
9ee8af9a8a | ||
|
|
19fb9f2017 | ||
|
|
bc64acf39c | ||
|
|
83e598a14d | ||
|
|
8318cfd8cc | ||
|
|
aea2f96874 | ||
|
|
49587d0800 | ||
|
|
792fc65003 | ||
|
|
d6af6a004a | ||
|
|
4d2c080857 | ||
|
|
2d7b06c956 | ||
|
|
93c434bf72 | ||
|
|
5bc35da842 | ||
|
|
84bfbf9063 | ||
|
|
c97fdd2b2b | ||
|
|
e0e5602cbd | ||
|
|
90cd78d8f7 | ||
|
|
9712b9152c | ||
|
|
fecebffd1b | ||
|
|
64555d861b | ||
|
|
75473be13e | ||
|
|
0d9b2b4b48 | ||
|
|
af46fec191 | ||
|
|
176d74e0bc | ||
|
|
76c92da8fe | ||
|
|
bdde209f30 | ||
|
|
27e8aee9b8 | ||
|
|
159d16671f | ||
|
|
5e97e2ab9a | ||
|
|
aad9a79396 | ||
|
|
e30fdf7a8c | ||
|
|
23f10d2955 | ||
|
|
8a067fe88b | ||
|
|
8be3cedf04 | ||
|
|
aba1ca4b2e | ||
|
|
c2776cca52 | ||
|
|
d592c61e78 | ||
|
|
9713c0d1c9 | ||
|
|
eb31634b9e | ||
|
|
91c1b1452d | ||
|
|
24a35e77dc | ||
|
|
1a46ddc9a7 | ||
|
|
fa56adc94a | ||
|
|
6d97fce0b0 | ||
|
|
932bcd2b02 | ||
|
|
188f21a120 | ||
|
|
ec1d9737ab | ||
|
|
3b8ce78010 | ||
|
|
6c4c418b7c | ||
|
|
6ec6e008dc | ||
|
|
b51be65f44 | ||
|
|
0e899c4c21 | ||
|
|
0b683ef3d5 | ||
|
|
560fa4aea5 | ||
|
|
f7ef931b3e | ||
|
|
43dfefaaeb | ||
|
|
cd9f32bb21 | ||
|
|
91565d476e | ||
|
|
45c15759ba | ||
|
|
f0a8e0636b | ||
|
|
a9c7c7a85e | ||
|
|
7aa9b62d6d | ||
|
|
5ed4f1c1e2 | ||
|
|
549e5c9efb | ||
|
|
a8e7fb0b18 | ||
|
|
3ca27bb65e | ||
|
|
76f52835e9 | ||
|
|
5019052792 | ||
|
|
9c1b5971c0 | ||
|
|
fb1d3f8423 | ||
|
|
141a274d74 | ||
|
|
163d973455 | ||
|
|
ba8e737810 | ||
|
|
330607d403 | ||
|
|
f7b806afed | ||
|
|
b3d2e7ee21 | ||
|
|
c7948e1fdf | ||
|
|
c5185f2afc | ||
|
|
f8990447b7 | ||
|
|
2aba31144b | ||
|
|
9bfd7f994c | ||
|
|
8168d4d74b | ||
|
|
6e512833fe | ||
|
|
561a9054c7 | ||
|
|
35a681c597 | ||
|
|
ae6d311460 | ||
|
|
190621966b | ||
|
|
4151cfc167 | ||
|
|
e1bc6673b0 | ||
|
|
ac90a17bd9 | ||
|
|
ee3f5662de | ||
|
|
289e86e03f | ||
|
|
08086b00c6 | ||
|
|
6382edcfba | ||
|
|
1cdc2c4a41 | ||
|
|
7153bedce0 | ||
|
|
ef0647cfb3 | ||
|
|
87c73c9b0b | ||
|
|
bb80c75647 | ||
|
|
ef88025b92 | ||
|
|
6c66ebc3d0 | ||
|
|
b53474661a | ||
|
|
3858d9f1d2 | ||
|
|
40a50cd8a3 | ||
|
|
25c7f9cf14 | ||
|
|
8b179c0029 | ||
|
|
4680b45026 | ||
|
|
ab9f33c772 | ||
|
|
bb3d0c333b | ||
|
|
0e70322562 | ||
|
|
35869a56f0 | ||
|
|
568e704d26 | ||
|
|
48190e0426 | ||
|
|
010e629f2a | ||
|
|
000c7605d4 | ||
|
|
6296f1abca | ||
|
|
ab5a2af67a | ||
|
|
9ce261428c | ||
|
|
debc0d099e | ||
|
|
d32f0d0e74 | ||
|
|
36221042b7 | ||
|
|
e1dba28fff | ||
|
|
205e16d25d | ||
|
|
62d5bcc0a2 | ||
|
|
1e4d5742ad | ||
|
|
100a72df96 | ||
|
|
6ddd6e01e8 | ||
|
|
0a23169da1 | ||
|
|
6d43889223 | ||
|
|
a90533fcf9 | ||
|
|
3b1be10e9b | ||
|
|
0b1e01dde9 | ||
|
|
48c48532e4 | ||
|
|
8109402ce2 | ||
|
|
d3da79bc90 | ||
|
|
85e770ece3 | ||
|
|
964f77dc54 | ||
|
|
6791df9ba0 | ||
|
|
537ef3792a | ||
|
|
691e5a8a52 | ||
|
|
f1192bc2b2 | ||
|
|
714f26be9e | ||
|
|
829becd370 | ||
|
|
79ab62a74d | ||
|
|
9053dd6306 | ||
|
|
71dd789c3e | ||
|
|
e2d9d56a9a | ||
|
|
98e3b2948b | ||
|
|
efd9416819 | ||
|
|
c8697c7f29 | ||
|
|
e76bb17958 | ||
|
|
a823d8849c | ||
|
|
8cad58b22e | ||
|
|
d2ceab3af4 | ||
|
|
d45e31520e | ||
|
|
dd5f375640 | ||
|
|
b31f5c2d37 | ||
|
|
a50f8ccfb5 | ||
|
|
e015a987ab | ||
|
|
f3b04472c9 | ||
|
|
4b9f10b4a5 | ||
|
|
7fec64d6c1 | ||
|
|
6a78b02f5a | ||
|
|
c7cef5350a | ||
|
|
5d50478fe3 | ||
|
|
921576042c | ||
|
|
ec957dc01e | ||
|
|
4f95e6cda7 | ||
|
|
860bc7bcf1 | ||
|
|
170c95a94c | ||
|
|
f84542b115 | ||
|
|
5be3e8006a | ||
|
|
bb381255bc | ||
|
|
70354a96e5 | ||
|
|
6df9405a98 | ||
|
|
83e2e3e47e | ||
|
|
137c77e4e0 | ||
|
|
31a354216f | ||
|
|
78469530f9 | ||
|
|
eeb66ca73f | ||
|
|
c9b3068adf | ||
|
|
a6fc17ee2f | ||
|
|
245424d215 | ||
|
|
50ce01bfad | ||
|
|
9f30879186 | ||
|
|
d897e82f62 | ||
|
|
643a959342 | ||
|
|
ba45668048 | ||
|
|
918a3b02f8 | ||
|
|
2b47d463e4 | ||
|
|
26d8984fe4 | ||
|
|
d5e6998b07 | ||
|
|
f18e2d5c99 | ||
|
|
ea129e7df1 | ||
|
|
dcfc74e8ae | ||
|
|
ed7af1bf11 | ||
|
|
7732ca60f4 | ||
|
|
0acc6fc926 | ||
|
|
a5a0e132ee | ||
|
|
a576fc8b68 | ||
|
|
9c0aeb6f90 | ||
|
|
208317322c | ||
|
|
d1e13cab4a | ||
|
|
ac90f43db0 | ||
|
|
fbe4b86cb0 | ||
|
|
d05bde1ef2 | ||
|
|
8ebf8327e2 | ||
|
|
bb39b757ec | ||
|
|
6b512cfa1a | ||
|
|
d982bdf1bd | ||
|
|
7b3f9ffde0 | ||
|
|
07b2f6c95a | ||
|
|
bae84976d6 | ||
|
|
1abaf9c161 | ||
|
|
870ea499f2 | ||
|
|
15514ff671 | ||
|
|
847c48ac78 | ||
|
|
07bc101e5e | ||
|
|
21ea6e584d | ||
|
|
199c1c83f9 | ||
|
|
ec664671b8 | ||
|
|
4268ce318a | ||
|
|
074eba2b1f | ||
|
|
a6b8c924de | ||
|
|
e4212676ae | ||
|
|
c52ef43b9c | ||
|
|
254fdfa16d | ||
|
|
fc9864ffcb | ||
|
|
3d91eb7df7 | ||
|
|
09563d839e | ||
|
|
93445341e1 | ||
|
|
e0c3ef3db3 | ||
|
|
674d5ecb2b | ||
|
|
2cf8c86bc5 | ||
|
|
1a0df67fed | ||
|
|
38a5516500 | ||
|
|
811e981eb4 | ||
|
|
579fd904fc | ||
|
|
21a74f377b | ||
|
|
634b8c14ad | ||
|
|
7164ba4aac | ||
|
|
a2aef1fe06 | ||
|
|
fce27c840f | ||
|
|
ae04b34925 | ||
|
|
4dc89cfd20 | ||
|
|
fbd671903f | ||
|
|
667680d479 | ||
|
|
fb124d533b | ||
|
|
30112dc805 | ||
|
|
8ed82e9173 | ||
|
|
891b257a50 | ||
|
|
0b8d20d07c | ||
|
|
b352f3ef30 | ||
|
|
95a2d5217b | ||
|
|
437fcc4335 | ||
|
|
e55af3f1fc | ||
|
|
0a3ca02876 | ||
|
|
b7aae3980d | ||
|
|
4ae6d71c28 | ||
|
|
1a6dc445db | ||
|
|
2f9f41b153 | ||
|
|
9818ec12f8 | ||
|
|
15de2e054c | ||
|
|
6b9e32e256 | ||
|
|
c3c009d5ea | ||
|
|
c426a99907 | ||
|
|
ef2da8b68a | ||
|
|
75f70aa3d6 | ||
|
|
a6ade067cc | ||
|
|
4c67210346 | ||
|
|
48e90dd276 | ||
|
|
daf7975fe0 | ||
|
|
8fbd18bf79 | ||
|
|
f1831deafa | ||
|
|
8cc237e7a3 | ||
|
|
21ad1cc78a | ||
|
|
d8c3a47352 | ||
|
|
fc1638e5c9 | ||
|
|
157e490815 | ||
|
|
c9efddb009 | ||
|
|
90de1963c9 | ||
|
|
97e7d1df85 | ||
|
|
5db82070e6 | ||
|
|
38c98515f9 | ||
|
|
1857129417 | ||
|
|
0c05f27771 | ||
|
|
7cd0fb5295 | ||
|
|
aa99a5a038 | ||
|
|
aa64b961d1 | ||
|
|
8718968a36 | ||
|
|
637e0ecf84 | ||
|
|
9d6aad3924 | ||
|
|
066ae8e310 | ||
|
|
a98c0d2904 | ||
|
|
d6a05a0ccb | ||
|
|
952e6a1ffa | ||
|
|
9746e0146a | ||
|
|
92810bee53 | ||
|
|
6047a9c486 | ||
|
|
ae5698e9bf | ||
|
|
bd52770377 | ||
|
|
116103b2a0 | ||
|
|
e6af204bbb | ||
|
|
f208f32010 | ||
|
|
818ded9907 | ||
|
|
6bc1b19967 | ||
|
|
789f53bcc5 | ||
|
|
eb0f2c80c8 | ||
|
|
a85e808ac6 | ||
|
|
8bd15f52e8 | ||
|
|
90a7905190 | ||
|
|
555c1e18c2 | ||
|
|
f38d4ca762 | ||
|
|
50275a50e7 | ||
|
|
f8c820eaaf | ||
|
|
02edf005d0 | ||
|
|
a536ad4c6c | ||
|
|
387ebe5bfe | ||
|
|
674f58f6af | ||
|
|
d02f9e6108 | ||
|
|
9f7ce41be7 | ||
|
|
71ccfb4751 | ||
|
|
7fb29e8ce5 | ||
|
|
ac070dba28 | ||
|
|
0e0572cf70 | ||
|
|
a06ac42fb6 | ||
|
|
e4122c413f | ||
|
|
498727a5d8 | ||
|
|
0d795fee5b | ||
|
|
09a651b76c | ||
|
|
451ccc3879 | ||
|
|
eba4502991 | ||
|
|
996e5708ee | ||
|
|
1ba5df9275 | ||
|
|
ee1f4d7e96 | ||
|
|
63a58197b1 | ||
|
|
3deb032ffa | ||
|
|
2bd846c72e | ||
|
|
de1bb112fd | ||
|
|
58aa89ca36 | ||
|
|
93d21ae67c | ||
|
|
c3fa502738 | ||
|
|
fbdca16704 | ||
|
|
23b24ce5c3 | ||
|
|
26187c0624 | ||
|
|
7a8701ef9d | ||
|
|
f08c4e0b43 | ||
|
|
228c172422 | ||
|
|
7a0de05139 | ||
|
|
0049971936 | ||
|
|
0790f9aa40 | ||
|
|
3e953b2619 | ||
|
|
523dfd08eb | ||
|
|
34f2d51194 | ||
|
|
1daa336dbc | ||
|
|
fb453ab711 | ||
|
|
2c12fd009f | ||
|
|
adb3302560 | ||
|
|
d39eb45f12 | ||
|
|
5760f20f7a | ||
|
|
439d609fa7 | ||
|
|
883ddfabea | ||
|
|
8807a07eb4 | ||
|
|
c5af0cec2a | ||
|
|
cb52c151ed | ||
|
|
eb1184f889 | ||
|
|
b0e22dce17 | ||
|
|
a77dfc3826 | ||
|
|
12a08e2f2d | ||
|
|
a3116ab263 | ||
|
|
f9fd4eb51f | ||
|
|
e98823fb6e | ||
|
|
676ea27a79 | ||
|
|
fc94c29138 | ||
|
|
943a63fd84 | ||
|
|
5e1193c0ee | ||
|
|
62184d8ccf | ||
|
|
9cb71074ec | ||
|
|
5cf6f1d708 | ||
|
|
1a9e442567 | ||
|
|
b59f556187 | ||
|
|
a0de1686bc | ||
|
|
ba0577a5ae | ||
|
|
479cbf93d3 | ||
|
|
8a7d16d783 | ||
|
|
bbf12d5413 | ||
|
|
0a3a6f6fd7 | ||
|
|
e24edcdc2d | ||
|
|
fb83744dd9 | ||
|
|
31e12a0ef9 | ||
|
|
8c183333d1 | ||
|
|
653ed7fd38 | ||
|
|
f6afc45613 | ||
|
|
e5f9ecb9e9 | ||
|
|
cc2acd0724 | ||
|
|
c0a6188405 | ||
|
|
850ee17187 | ||
|
|
ab3e7093ad | ||
|
|
9c0c1365c6 | ||
|
|
c1b44e24d1 | ||
|
|
0a2f15628f | ||
|
|
9965ce16c9 | ||
|
|
b8f9a49f39 | ||
|
|
edce1cfa58 | ||
|
|
71c7615aba | ||
|
|
33a87cdb5c | ||
|
|
3c201a453c | ||
|
|
7c5f6fc8d2 | ||
|
|
0941a6899a | ||
|
|
432217db0b | ||
|
|
a3165f095c | ||
|
|
aa09555875 | ||
|
|
0abd935cf1 | ||
|
|
b9f745cdfe | ||
|
|
a6b3dcaaf3 | ||
|
|
af700aa9d0 | ||
|
|
be09b47bff | ||
|
|
4d96b48091 | ||
|
|
f5a5fe9530 | ||
|
|
9d6953fedc | ||
|
|
b3b6410187 | ||
|
|
2c6f1480bc | ||
|
|
57e2c0caf6 | ||
|
|
a9061e8fe0 | ||
|
|
b5702fb68e | ||
|
|
361eebf8cb | ||
|
|
8332a39def | ||
|
|
d643bddb95 | ||
|
|
5a6cc40084 | ||
|
|
04f5fa4790 | ||
|
|
e34ca74c86 | ||
|
|
d5356317b9 | ||
|
|
75261682dc | ||
|
|
ba0c1d397d | ||
|
|
90a70f1ad6 | ||
|
|
e37fbf4130 | ||
|
|
0e7f15a8fb | ||
|
|
5135fd164d | ||
|
|
4f9b34dacc | ||
|
|
470972405c | ||
|
|
a9489c817f | ||
|
|
482e05133f | ||
|
|
9fd792e0a9 | ||
|
|
7b57853033 | ||
|
|
af46e14522 | ||
|
|
ed5b985f63 | ||
|
|
d4409e5ff9 | ||
|
|
b1ac69a4dd | ||
|
|
ae7dfcb16b | ||
|
|
ff824d1470 | ||
|
|
c013a5dca3 | ||
|
|
ae509f5500 | ||
|
|
d95e639895 | ||
|
|
404d1098e1 | ||
|
|
262ad0c6aa | ||
|
|
8021708999 | ||
|
|
2cfb00b9a0 | ||
|
|
ff70b46d0f | ||
|
|
e39cc14363 | ||
|
|
8541928a4f | ||
|
|
f445ffbc56 | ||
|
|
642794e816 | ||
|
|
99b9bfc3ef | ||
|
|
027c98adfc | ||
|
|
d1f3f67ceb | ||
|
|
ba4ec1654d | ||
|
|
e45196e596 | ||
|
|
779a6c9673 | ||
|
|
0830d5503e | ||
|
|
87bdcddd7d | ||
|
|
f38e6326f0 | ||
|
|
463e12f801 | ||
|
|
4003eefb9c | ||
|
|
b92f623c87 | ||
|
|
b95503ff58 | ||
|
|
c4c338347e | ||
|
|
10a92249e9 | ||
|
|
b5c7dbf1f9 | ||
|
|
104966d31a | ||
|
|
6c79c6c873 | ||
|
|
fa014e25ce | ||
|
|
cf040d2f2e | ||
|
|
c99369d970 | ||
|
|
f9d7363a03 | ||
|
|
2c373a6faa | ||
|
|
bba383bc0a | ||
|
|
79daf02d66 | ||
|
|
dac24f4df7 | ||
|
|
ed7a808ee8 | ||
|
|
6416477dad | ||
|
|
559d9abcc8 | ||
|
|
11dc1b5090 | ||
|
|
37e6180ddc | ||
|
|
1f3f4d826c | ||
|
|
a01485189b | ||
|
|
8ea7c505b1 | ||
|
|
194c2809af | ||
|
|
ea72dc164f | ||
|
|
5f694b87af | ||
|
|
c76a3631b3 | ||
|
|
cef3089ecf | ||
|
|
89bf3a9649 | ||
|
|
e742ec8597 | ||
|
|
5f329128b1 | ||
|
|
341a88f4f4 | ||
|
|
9f624b7f00 | ||
|
|
814aebac4c | ||
|
|
8ec8826313 | ||
|
|
b632788fa7 | ||
|
|
eacdc7c082 | ||
|
|
b22669b517 | ||
|
|
219b28ac1a | ||
|
|
ab2ea0d084 | ||
|
|
d9bb51ff79 | ||
|
|
19bdee7ad5 | ||
|
|
b9b511bf87 | ||
|
|
421e88ae61 | ||
|
|
c9581c69a0 | ||
|
|
2a965eeef2 | ||
|
|
ca6fbca166 | ||
|
|
5d121ef49d | ||
|
|
00385f8243 | ||
|
|
899c34c240 | ||
|
|
7c0bc6329e | ||
|
|
bdd115e3cd | ||
|
|
bb68d7cec7 | ||
|
|
9b5df1c99e | ||
|
|
a98766fc55 | ||
|
|
edd050dd98 | ||
|
|
5ca7791b57 | ||
|
|
b84e7da265 | ||
|
|
4490cdfed5 | ||
|
|
068db31001 | ||
|
|
63ff3e517f | ||
|
|
a303c841ff | ||
|
|
990bdbd1cf | ||
|
|
013cd58109 | ||
|
|
53dc297cee | ||
|
|
eeea0e1dbe | ||
|
|
f10444ed78 | ||
|
|
f8773956ed | ||
|
|
fe65a9f542 | ||
|
|
6013220684 | ||
|
|
98c6be3ba5 | ||
|
|
21e9accecf | ||
|
|
8e4aabf182 | ||
|
|
1a85e7f018 | ||
|
|
24a03bf6fc | ||
|
|
640e974dcf | ||
|
|
6a32bd90e8 | ||
|
|
5b9e5f2615 | ||
|
|
065de53ba4 | ||
|
|
b1badf8dc5 | ||
|
|
e35258dc31 | ||
|
|
575e2480ca | ||
|
|
eb21c74a21 | ||
|
|
670ca66d7c | ||
|
|
6220f0f466 | ||
|
|
b85439b12c | ||
|
|
de41a8050a | ||
|
|
198911582a | ||
|
|
3f5aa35dd7 | ||
|
|
ac05b63690 | ||
|
|
198cc422bb | ||
|
|
9a204bb11d | ||
|
|
2d7d54e788 | ||
|
|
53387f81da | ||
|
|
d8c9c162f1 | ||
|
|
1e3c547551 | ||
|
|
86ccff34e9 | ||
|
|
f1c4328327 | ||
|
|
cc57b7b47e | ||
|
|
cae35d1032 | ||
|
|
9849f8356d | ||
|
|
3ebbf8d8e7 | ||
|
|
597a5374dc | ||
|
|
db1a2d0358 | ||
|
|
c9a80834ac | ||
|
|
0b6335b737 | ||
|
|
09918b6224 | ||
|
|
6d10892c20 | ||
|
|
668caf39e3 | ||
|
|
6a470e2567 | ||
|
|
9178644d0f | ||
|
|
3d2c48ae36 | ||
|
|
b2c16c05c4 | ||
|
|
58762a914d | ||
|
|
493e63645e | ||
|
|
c638065c9b | ||
|
|
6f8f3b7b4b | ||
|
|
fcc0dcb29e | ||
|
|
841ffd623d | ||
|
|
47efd09cb2 | ||
|
|
4e9f04bdc6 | ||
|
|
11a23d7c03 | ||
|
|
49ee47c399 | ||
|
|
000fd963dc | ||
|
|
75c1b72d5b | ||
|
|
ef3f5d9df6 | ||
|
|
056ff88926 | ||
|
|
8ecd0e241b | ||
|
|
6cce6d8347 | ||
|
|
621ca5791f | ||
|
|
80323b1c87 | ||
|
|
d8ab935654 | ||
|
|
851ca4cc74 | ||
|
|
39e1ff35f7 | ||
|
|
6cd8163099 | ||
|
|
9983786184 | ||
|
|
715dea0626 | ||
|
|
71a517d1da | ||
|
|
f60639bec6 | ||
|
|
9a4c8f3ad3 | ||
|
|
5d8ba99361 | ||
|
|
aaaa813b9b | ||
|
|
2c5e1e6602 | ||
|
|
def1bc5ade | ||
|
|
1f629506a8 | ||
|
|
a9c54f6ce3 | ||
|
|
7a71df16b0 | ||
|
|
e5c3ec600b | ||
|
|
3a008f2fb9 | ||
|
|
89358a6c46 | ||
|
|
bfc48aab6e | ||
|
|
1a0c9d0cc0 | ||
|
|
e857819fce | ||
|
|
27cf385f58 | ||
|
|
cf8f95dfa7 | ||
|
|
08104f8c0e | ||
|
|
92ba7992d7 | ||
|
|
2891175221 | ||
|
|
c033ee64a2 | ||
|
|
fbe5f5ce3d | ||
|
|
8d00f86517 | ||
|
|
4d8a2044ff | ||
|
|
d520b6c474 | ||
|
|
9f1f04de27 | ||
|
|
bb79ad1ca3 | ||
|
|
5ba33e7e6e | ||
|
|
be0197d5f3 | ||
|
|
c182c515ae | ||
|
|
d49031c8e6 | ||
|
|
f35ceaed22 | ||
|
|
99254cd578 | ||
|
|
e0a76f86b2 | ||
|
|
9d044ee8e2 | ||
|
|
0c8bd2208c | ||
|
|
d9dba5703c | ||
|
|
b755c778d6 | ||
|
|
a652894a81 | ||
|
|
d26bcfdbe7 |
107 changed files with 9663 additions and 2450 deletions
|
|
@ -1,5 +0,0 @@
|
||||||
[run]
|
|
||||||
omit =
|
|
||||||
*/migrations/*
|
|
||||||
eav/__init__.py
|
|
||||||
eav/utils.py
|
|
||||||
17
.editorconfig
Normal file
17
.editorconfig
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Check https://editorconfig.org for more information
|
||||||
|
# This is the main config file for this project:
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.py]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.pyi]
|
||||||
|
indent_size = 4
|
||||||
2
.git-blame-ignore-revs
Normal file
2
.git-blame-ignore-revs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Apply ruff linter rules and standardize code style
|
||||||
|
c4d7cedeb8b7a8bded8db9a658ae635195071ce3
|
||||||
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Provide a general summary of the issue in the title above.
|
||||||
|
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
Tell us what should happen.
|
||||||
|
|
||||||
|
## Actual Behavior
|
||||||
|
Tell us what happens instead.
|
||||||
|
|
||||||
|
## Possible Fix
|
||||||
|
Not obligatory, but you can suggest a fix or reason for the bug.
|
||||||
|
|
||||||
|
## Steps to Reproduce
|
||||||
|
This is not required, but it would be highly appreciated if you
|
||||||
|
provided a link to a live example, or an unambiguous set of steps to
|
||||||
|
reproduce this bug. Include code to reproduce, if relevant.
|
||||||
|
|
||||||
|
|
||||||
|
## Your Environment
|
||||||
|
Include relevant details about the environment you experienced the bug in.
|
||||||
|
|
||||||
|
* Version used:
|
||||||
|
* Environment name and version (e.g. Django 2.0.4, pip 9.0.1):
|
||||||
|
* Operating System and version:
|
||||||
|
* Link to your project (if applicable):
|
||||||
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Provide a general summary of the issue in the title above.
|
||||||
|
|
||||||
|
## Detailed Description
|
||||||
|
Provide a description of the change or addition you are proposing.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Why is this change important to you? How would you use it?
|
||||||
|
How can it benefit other users?
|
||||||
|
|
||||||
|
## Possible Implementation
|
||||||
|
Not obligatory, but you may suggest an idea for implementing addition or change.
|
||||||
15
.github/dependabot.yml
vendored
Normal file
15
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: pip
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: daily
|
||||||
|
time: "02:00"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: daily
|
||||||
|
time: "02:00"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
54
.github/pull_request_template.md
vendored
Normal file
54
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# I'm helping!
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Thank you for submitting a Pull Request. We appreciate it!
|
||||||
|
|
||||||
|
Please, fill in all the required information
|
||||||
|
to make our review and merging processes easier.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Please check everything that applies:
|
||||||
|
-->
|
||||||
|
|
||||||
|
- [ ] I have double checked that there are no unrelated changes in this pull request (old patches, accidental config files, etc.)
|
||||||
|
- [ ] I have created at least one test case for the changes I have made
|
||||||
|
- [ ] I have updated the documentation for the changes I have made
|
||||||
|
- [ ] I have added my changes to the `CHANGELOG.md`
|
||||||
|
|
||||||
|
## Pull Request type
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Please try to limit your pull request to one type, submit multiple pull requests if needed.
|
||||||
|
-->
|
||||||
|
|
||||||
|
Please check the type of change your PR introduces:
|
||||||
|
|
||||||
|
- [ ] Bugfix
|
||||||
|
- [ ] Feature
|
||||||
|
- [ ] Code style update (formatting, renaming)
|
||||||
|
- [ ] Refactoring (no functional changes, no api changes)
|
||||||
|
- [ ] Build related changes
|
||||||
|
- [ ] Documentation content changes
|
||||||
|
- [ ] Other (please describe):
|
||||||
|
|
||||||
|
## Related issue(s)
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Mark what issues this Pull Request closes or references.
|
||||||
|
|
||||||
|
Format is:
|
||||||
|
- Closes #issue-number
|
||||||
|
- Refs #issue-number
|
||||||
|
|
||||||
|
Example. Refs #0
|
||||||
|
Documentation: https://blog.github.com/2013-05-14-closing-issues-via-pull-requests/
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Other Information
|
||||||
|
|
||||||
|
<!--
|
||||||
|
If you have any other comments, feel free to share!
|
||||||
|
-->
|
||||||
39
.github/workflows/release.yml
vendored
Normal file
39
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: github.repository == 'jazzband/django-eav2'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: 3.8
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install -U pip
|
||||||
|
python -m pip install -U poetry twine
|
||||||
|
|
||||||
|
- name: Build package
|
||||||
|
run: |
|
||||||
|
poetry build
|
||||||
|
twine check dist/*
|
||||||
|
|
||||||
|
- name: Upload packages to Jazzband
|
||||||
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||||
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
with:
|
||||||
|
user: jazzband
|
||||||
|
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
||||||
|
repository_url: https://jazzband.co/projects/django-eav2/upload
|
||||||
65
.github/workflows/test.yml
vendored
Normal file
65
.github/workflows/test.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# https://docs.djangoproject.com/en/stable/faq/install/#what-python-version-can-i-use-with-django
|
||||||
|
name: test
|
||||||
|
|
||||||
|
"on":
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-matrix:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
||||||
|
django-version: ['4.2', '5.1', '5.2']
|
||||||
|
exclude:
|
||||||
|
# Exclude Python 3.9 with Django 5.1 and 5.2
|
||||||
|
- python-version: '3.9'
|
||||||
|
django-version: '5.1'
|
||||||
|
- python-version: '3.9'
|
||||||
|
django-version: '5.2'
|
||||||
|
# Exclude Python 3.13 with Django 4.2
|
||||||
|
- python-version: '3.13'
|
||||||
|
django-version: '4.2'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install Poetry
|
||||||
|
uses: snok/install-poetry@v1
|
||||||
|
with:
|
||||||
|
version: 1.8.4
|
||||||
|
virtualenvs-create: true
|
||||||
|
virtualenvs-in-project: true
|
||||||
|
installer-parallel: true
|
||||||
|
|
||||||
|
- name: Set up cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .venv
|
||||||
|
key: venv-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
poetry install
|
||||||
|
poetry run pip install --upgrade pip
|
||||||
|
poetry run pip install --upgrade "django==${{ matrix.django-version }}.*"
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
poetry run pytest
|
||||||
|
poetry check
|
||||||
|
poetry run pip check
|
||||||
|
|
||||||
|
- name: Upload coverage to Codecov
|
||||||
|
uses: codecov/codecov-action@v5
|
||||||
|
with:
|
||||||
|
file: ./coverage.xml
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -123,3 +123,11 @@ venv.bak/
|
||||||
*~
|
*~
|
||||||
# Auto-generated tag files
|
# Auto-generated tag files
|
||||||
tags
|
tags
|
||||||
|
|
||||||
|
## Mac
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
|
||||||
|
## IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
|
||||||
27
.pre-commit-config.yaml
Normal file
27
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# See https://pre-commit.com for more information
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v5.0.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-added-large-files
|
||||||
|
- id: mixed-line-ending
|
||||||
|
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
# Ruff version.
|
||||||
|
rev: v0.11.12
|
||||||
|
hooks:
|
||||||
|
# Run the linter.
|
||||||
|
- id: ruff
|
||||||
|
args: [ --fix ]
|
||||||
|
# Run the formatter.
|
||||||
|
- id: ruff-format
|
||||||
|
|
||||||
|
- repo: https://github.com/remastr/pre-commit-django-check-migrations
|
||||||
|
rev: v0.1.0
|
||||||
|
hooks:
|
||||||
|
- id: check-migrations-created
|
||||||
|
args: [--manage-path=manage.py]
|
||||||
|
additional_dependencies: [django==4.1]
|
||||||
24
.readthedocs.yml
Normal file
24
.readthedocs.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# .readthedocs.yaml
|
||||||
|
# Read the Docs configuration file
|
||||||
|
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
# Set the version of Python and other tools you might need
|
||||||
|
build:
|
||||||
|
os: ubuntu-20.04
|
||||||
|
tools:
|
||||||
|
python: '3.9'
|
||||||
|
jobs:
|
||||||
|
# See https://github.com/readthedocs/readthedocs.org/issues/4912
|
||||||
|
pre_create_environment:
|
||||||
|
- asdf plugin add poetry
|
||||||
|
- asdf install poetry latest
|
||||||
|
- asdf global poetry latest
|
||||||
|
- poetry config virtualenvs.create false
|
||||||
|
post_install:
|
||||||
|
- . "$(pwd | rev | sed 's/stuokcehc/svne/' | rev)/bin/activate" && poetry install --only main --only docs
|
||||||
|
|
||||||
|
# Build documentation in the docs/ directory with Sphinx
|
||||||
|
sphinx:
|
||||||
|
configuration: docs/source/conf.py
|
||||||
|
fail_on_warning: true
|
||||||
22
.travis.yml
22
.travis.yml
|
|
@ -1,22 +0,0 @@
|
||||||
language: python
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- python: 2.7
|
|
||||||
env: TOXENV=py27
|
|
||||||
- python: 3.5
|
|
||||||
env: TOXENV=py35
|
|
||||||
- python: 3.6
|
|
||||||
env: TOXENV=py36
|
|
||||||
- python: 3.7-dev
|
|
||||||
env: TOXENV=py37
|
|
||||||
install:
|
|
||||||
- pip install -r requirements.txt
|
|
||||||
- pip install tox==3.0.0
|
|
||||||
- pip install coveralls==1.3.0
|
|
||||||
- pip install coverage==4.5.1
|
|
||||||
before_script:
|
|
||||||
- coverage erase
|
|
||||||
script:
|
|
||||||
- coverage run --source=eav runtests; tox
|
|
||||||
after_success:
|
|
||||||
- COVERALLS_REPO_TOKEN=71NkMDQFpFKB9QYXoK12LYuWUEmQ2wD6V coveralls
|
|
||||||
171
CHANGELOG.md
Normal file
171
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
# Version History
|
||||||
|
|
||||||
|
We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` release.
|
||||||
|
|
||||||
|
## 1.8.1 (2025-06-02)
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
|
||||||
|
- Added support for Django 5.2
|
||||||
|
- Updated dependencies to their latest versions
|
||||||
|
|
||||||
|
## 1.8.0 (2025-02-24)
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
|
||||||
|
- Add database constraints to Value model for data integrity by @Dresdn in https://github.com/jazzband/django-eav2/pull/706
|
||||||
|
- Fix for issue #648: Ensure choices are valid (value, label) tuples by @altimore in https://github.com/jazzband/django-eav2/pull/707
|
||||||
|
|
||||||
|
## 1.7.1 (2024-09-01)
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
* Restore backward compatibility for Attribute creation with invalid slugs by @Dresdn in https://github.com/jazzband/django-eav2/pull/639
|
||||||
|
|
||||||
|
## 1.7.0 (2024-09-01)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
|
||||||
|
- Enhance slug validation for Python identifier compliance
|
||||||
|
- Migrate to ruff
|
||||||
|
- Drop support for Django 3.2
|
||||||
|
- Add support for Django 5.1
|
||||||
|
|
||||||
|
## 1.6.1 (2024-06-23)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
|
||||||
|
- Ensure eav.register() Maintains Manager Order by @Dresdn in https://github.com/jazzband/django-eav2/pull/595
|
||||||
|
- Update downstream dependencies by @Dresdn in https://github.com/jazzband/django-eav2/pull/597
|
||||||
|
|
||||||
|
## 1.6.0 (2024-03-14)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
|
||||||
|
- Corrects `BaseEntityAdmin` integration into Django Admin site
|
||||||
|
- Split model modules by @iacobfred in https://github.com/jazzband/django-eav2/pull/467
|
||||||
|
- Add Django 5.0 and Python 3.12 to the testing by @cclauss in https://github.com/jazzband/django-eav2/pull/487
|
||||||
|
- Fix typos with codespell by @cclauss in https://github.com/jazzband/django-eav2/pull/489
|
||||||
|
- Enhance BaseEntityAdmin by @Dresdn in https://github.com/jazzband/django-eav2/pull/541
|
||||||
|
- Remove support for Django < 3.2 and Python < 3.8 by @Dresdn in https://github.com/jazzband/django-eav2/pull/542
|
||||||
|
|
||||||
|
## 1.5.0 (2023-11-08)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixes querying with multiple eav kwargs [#395](https://github.com/jazzband/django-eav2/issues/395)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Support for many type of primary key (UUIDField, BigAutoField)
|
||||||
|
- Support for natural key use for some models for serialization (EnumValue, EnumGroup, Attribute, Value)
|
||||||
|
- Add support for Django 4.2
|
||||||
|
- Add support for Python 3.11
|
||||||
|
|
||||||
|
## 1.4.0 (2023-07-07)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Support Bahasa Indonesia Translations
|
||||||
|
- Support Django 4.2
|
||||||
|
|
||||||
|
## 1.3.1 (2023-02-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Generate missing migrations [#331](https://github.com/jazzband/django-eav2/issues/331)
|
||||||
|
|
||||||
|
## 1.3.0 (2023-02-10)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add support for Django 4.1
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixes missing `Add another` button for inlines in `BaseEntityAdmin`
|
||||||
|
- Fixes saving of Attribute date types rendering using `BaseDynamicEntityForm` [#261](https://github.com/jazzband/django-eav2/issues/261)
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
|
||||||
|
- Drops support for Django 2.2 and Python 3.7
|
||||||
|
|
||||||
|
## 1.2.3 (2022-08-15)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Don't mark doc8 as a dependency [#235](https://github.com/jazzband/django-eav2/issues/235)
|
||||||
|
- Make Read the Docs dependencies all optional
|
||||||
|
|
||||||
|
## 1.2.2 (2022-08-13)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixes AttributeError when using CSVFormField [#187](https://github.com/jazzband/django-eav2/issues/187)
|
||||||
|
- Fixes slug generation for Attribute.name fields longer than 50 characters [#223](https://github.com/jazzband/django-eav2/issues/223)
|
||||||
|
- Migrates Attribute.slug to django.db.models.SlugField() [#223](https://github.com/jazzband/django-eav2/issues/223)
|
||||||
|
|
||||||
|
## 1.2.1 (2022-02-08)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixes FieldError when filtering on foreign keys [#163](https://github.com/jazzband/django-eav2/issues/163)
|
||||||
|
|
||||||
|
## 1.2.0 (2021-12-18)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Adds 64-bit support for `Value.value_int`
|
||||||
|
- Adds Django 4.0 and Python 3.10 support
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
|
||||||
|
- Drops support for Django 3.1 and Python 3.6
|
||||||
|
|
||||||
|
## 1.1.0 (2021-11-07)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Adds support for entity models with UUId as a primary key #38
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fixes `ValueError` for models without local managers #41
|
||||||
|
- Fixes `str()` and `repr()` for `EnumGroup` and `EnumValue` objects #91
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
|
||||||
|
- Bumps min python version to `3.6.2`
|
||||||
|
|
||||||
|
**Full Changelog**: <https://github.com/jazzband/django-eav2/compare/1.0.0...1.1.0>
|
||||||
|
|
||||||
|
## 1.0.0 (2021-10-21)
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- Drops support for `django1.x`
|
||||||
|
- Drops support for `django3.0`
|
||||||
|
- Moves `JSONField()` datatype to `django-jsonfield-backport` for Django2.2 instances
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Adds support for `django3.2`
|
||||||
|
- Adds support for `python3.9`
|
||||||
|
- Adds support for `defaults` keyword on `get_or_create()`
|
||||||
|
|
||||||
|
### #Misc
|
||||||
|
|
||||||
|
- Revamps all tooling, including moving to `poetry`, `pytest`, and `black`
|
||||||
|
- Adds Github Actions and Dependabot
|
||||||
|
|
||||||
|
**Full Changelog**: <https://github.com/jazzband/django-eav2/compare/0.14.0...1.0.0>
|
||||||
|
|
||||||
|
## 0.14.0 (2021-04-23)
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
|
||||||
|
- This release will be the last to support this range of Django versions: 1.11, 2.0, 2.1, 2.2, 3.0. SInce all of their extended support was ended by Django Project.
|
||||||
|
- From the next release only will be supported 2.2 LTS, 3.1, and 3.2 LTS (eventually 4.x)
|
||||||
|
|
||||||
|
**Full Changelog**: <https://github.com/jazzband/django-eav2/compare/0.13.0...0.14.0>
|
||||||
|
|
||||||
|
(Anything before 0.14.0 was not recorded.)
|
||||||
46
CODE_OF_CONDUCT.md
Normal file
46
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Code of Conduct
|
||||||
|
|
||||||
|
As contributors and maintainers of the Jazzband projects, and in the interest of
|
||||||
|
fostering an open and welcoming community, we pledge to respect all people who
|
||||||
|
contribute through reporting issues, posting feature requests, updating documentation,
|
||||||
|
submitting pull requests or patches, and other activities.
|
||||||
|
|
||||||
|
We are committed to making participation in the Jazzband a harassment-free experience
|
||||||
|
for everyone, regardless of the level of experience, gender, gender identity and
|
||||||
|
expression, sexual orientation, disability, personal appearance, body size, race,
|
||||||
|
ethnicity, age, religion, or nationality.
|
||||||
|
|
||||||
|
Examples of unacceptable behavior by participants include:
|
||||||
|
|
||||||
|
- The use of sexualized language or imagery
|
||||||
|
- Personal attacks
|
||||||
|
- Trolling or insulting/derogatory comments
|
||||||
|
- Public or private harassment
|
||||||
|
- Publishing other's private information, such as physical or electronic addresses,
|
||||||
|
without explicit permission
|
||||||
|
- Other unethical or unprofessional conduct
|
||||||
|
|
||||||
|
The Jazzband roadies have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are not
|
||||||
|
aligned to this Code of Conduct, or to ban temporarily or permanently any contributor
|
||||||
|
for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||||
|
|
||||||
|
By adopting this Code of Conduct, the roadies commit themselves to fairly and
|
||||||
|
consistently applying these principles to every aspect of managing the jazzband
|
||||||
|
projects. Roadies who do not follow or enforce the Code of Conduct may be permanently
|
||||||
|
removed from the Jazzband roadies.
|
||||||
|
|
||||||
|
This code of conduct applies both within project spaces and in public spaces when an
|
||||||
|
individual is representing the project or its community.
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by
|
||||||
|
contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and
|
||||||
|
investigated and will result in a response that is deemed necessary and appropriate to
|
||||||
|
the circumstances. Roadies are obligated to maintain confidentiality with regard to the
|
||||||
|
reporter of an incident.
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version
|
||||||
|
1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version]
|
||||||
|
|
||||||
|
[homepage]: https://contributor-covenant.org
|
||||||
|
[version]: https://contributor-covenant.org/version/1/3/0/
|
||||||
65
CONTRIBUTING.md
Normal file
65
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
[](https://jazzband.co/)
|
||||||
|
|
||||||
|
This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines).
|
||||||
|
|
||||||
|
# Contributing
|
||||||
|
We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's:
|
||||||
|
|
||||||
|
- Reporting a bug
|
||||||
|
- Discussing the current state of the code
|
||||||
|
- Submitting a fix
|
||||||
|
- Proposing new features
|
||||||
|
- Becoming a maintainer
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
We use [poetry](https://github.com/sdispater/poetry) to manage the dependencies.
|
||||||
|
|
||||||
|
To install them you would need to run `install` command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry install
|
||||||
|
```
|
||||||
|
|
||||||
|
To activate your `virtualenv` run `poetry shell`.
|
||||||
|
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
We use `pytest` and `flake8` for quality control.
|
||||||
|
|
||||||
|
To run all tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## We develop with Github
|
||||||
|
We use github to host code, to track issues and feature requests, as well as accept pull requests.
|
||||||
|
|
||||||
|
### We use [Github Flow](https://guides.github.com/introduction/flow/index.html), so all code changes from community happen through pull requests
|
||||||
|
Pull requests are the best way to propose changes to the codebase (we use [Github Flow](https://guides.github.com/introduction/flow/index.html)). We actively welcome your pull requests:
|
||||||
|
|
||||||
|
1. Fork the repo and create your branch from `master`.
|
||||||
|
2. If you've added code that should be tested, add tests.
|
||||||
|
3. If you've changed APIs, update the documentation.
|
||||||
|
4. Ensure the test suite passes.
|
||||||
|
5. Make sure your code lints.
|
||||||
|
6. Describe the pull request using [this](https://github.com/jazzband/django-eav2/blob/master/PULL_REQUEST_TEMPLATE.md) template.
|
||||||
|
|
||||||
|
### Any contributions you make will be under the GNU Lesser General Public License v3.0
|
||||||
|
In short, when you submit code changes, your submissions are understood to be under the same [LGPLv3](https://choosealicense.com/licenses/lgpl-3.0/) that covers the project. Feel free to contact the maintainers if that's a concern.
|
||||||
|
|
||||||
|
### Report bugs using Github's [issues](https://github.com/jazzband/django-eav2/issues)
|
||||||
|
We use GitHub issues to track public bugs. Report a bug by opening a new issue. Use [this](https://github.com/jazzband/django-eav2/blob/master/.github/ISSUE_TEMPLATE/bug_report.md) template to describe your reports.
|
||||||
|
|
||||||
|
|
||||||
|
### Use a consistent coding style
|
||||||
|
|
||||||
|
We use [Black](https://github.com/psf/black) and (working towards) [wemake-python-styleguide](https://github.com/wemake-services/wemake-python-styleguide) for code and [Google-style](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) reStructuredText for doc-strings.
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md).
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
Contributors, alphabetically:
|
Contributors, alphabetically:
|
||||||
|
|
||||||
* amarder (Andrew Marder)
|
* amarder (Andrew Marder)
|
||||||
* daveycrockett (David McCann)
|
* daveycrockett (David McCann)
|
||||||
* dgelvin (David Gelvin)
|
* dgelvin (David Gelvin)
|
||||||
* dorey
|
* dorey
|
||||||
* fosil (Filip Novák)
|
* fosil (Filip Novák)
|
||||||
* gingerjoos
|
* gingerjoos
|
||||||
* IwoHerka (Iwo Herka)
|
* IwoHerka (Iwo Herka)
|
||||||
* jpwhite3
|
* jpwhite3
|
||||||
* katembu (Moses Katembu)
|
* katembu (Moses Katembu)
|
||||||
* madEng84
|
* lvm (Mauro Lizaur)
|
||||||
* nicpottier (Nic Pottier)
|
* madEng84
|
||||||
* tavaresb (Bruno Tavares)
|
* MajekX (Adam Majczyk)
|
||||||
* timlinux (Tim Sutton)
|
* nicpottier (Nic Pottier)
|
||||||
|
* pisemsky (Evgeny Pisemsky)
|
||||||
|
* tavaresb (Bruno Tavares)
|
||||||
|
* therefromhere (John Carter)
|
||||||
|
* timlinux (Tim Sutton)
|
||||||
|
|
|
||||||
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
||||||
This software is derived from Django EAV (https://github.com/mvpdev/django-eav)
|
This software is derived from Django EAV (https://github.com/mvpdev/django-eav)
|
||||||
which, in turn, was derived from EAV Django, originally written and copyrighted
|
which, in turn, was derived from EAV Django, originally written and copyrighted
|
||||||
by Andrey Mikhaylenko <http://pypi.python.org/pypi/eav-django>.
|
by Andrey Mikhaylenko <https://pypi.org/project/eav-django/>.
|
||||||
|
|
||||||
This is free software: you can redistribute it and/or modify
|
This is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU Lesser General Public License as published
|
it under the terms of the GNU Lesser General Public License as published
|
||||||
|
|
|
||||||
174
README.md
174
README.md
|
|
@ -1,33 +1,158 @@
|
||||||
|
[](https://github.com/jazzband/django-eav2/actions/workflows/test.yml)
|
||||||
[](https://travis-ci.org/makimo/django-eav2)
|
[](https://codecov.io/gh/jazzband/django-eav2)
|
||||||
[](https://coveralls.io/github/makimo/django-eav2?branch=master)
|
[](https://pypi.org/project/django-eav2/)
|
||||||
[](https://www.codacy.com/app/IwoHerka/django-eav2?utm_source=github.com&utm_medium=referral&utm_content=makimo/django-eav2&utm_campaign=Badge_Grade)
|
[](https://pypi.org/project/django-eav2/)
|
||||||
[](https://codeclimate.com/github/makimo/django-eav2/maintainability)
|
[](https://jazzband.co/)
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
## Django EAV 2 - Entity-Attribute-Value storage for Django
|
## Django EAV 2 - Entity-Attribute-Value storage for Django
|
||||||
|
|
||||||
Django EAV 2 is a fork of django-eav (which itself was derived from eav-django).
|
Django EAV 2 is a fork of django-eav (which itself was derived from eav-django).
|
||||||
You can find documentation <a href="https://django-eav-2.readthedocs.io/en/improvement-docs/">here</a>.
|
You can find documentation <a href="https://django-eav2.rtfd.io">here</a>.
|
||||||
|
|
||||||
|
## What is EAV anyway?
|
||||||
|
|
||||||
|
> Entity–attribute–value model (EAV) is a data model to encode, in a space-efficient manner, entities where the number of attributes (properties, parameters) that can be used to describe them is potentially vast, but the number that will actually apply to a given entity is relatively modest. Such entities correspond to the mathematical notion of a sparse matrix. (Wikipedia)
|
||||||
|
|
||||||
|
Data in EAV is stored as a 3-tuple (typically corresponding to three distinct tables):
|
||||||
|
|
||||||
|
- The entity: the item being described, e.g. `Person(name='Mike')`.
|
||||||
|
- The attribute: often a foreign key into a table of attributes, e.g. `Attribute(slug='height', datatype=FLOAT)`.
|
||||||
|
- The value of the attribute, with links both an attribute and an entity, e.g. `Value(value_float=15.5, person=mike, attr=height)`.
|
||||||
|
|
||||||
|
Entities in **django-eav2** are your typical Django model instances. Attributes (name and type) are stored in their own table, which makes it easy to manipulate the list of available attributes in the system. Values are an intermediate table between attributes and entities, each instance holding a single value.
|
||||||
|
This implementation also makes it easy to edit attributes in Django Admin and form instances.
|
||||||
|
|
||||||
|
You will find detailed description of the EAV here:
|
||||||
|
|
||||||
|
- [Wikipedia - Entity–attribute–value model](https://en.wikipedia.org/wiki/Entity%E2%80%93attribute%E2%80%93value_model)
|
||||||
|
|
||||||
|
## EAV - The Good, the Bad or the Ugly?
|
||||||
|
|
||||||
|
EAV is a trade-off between flexibility and complexity. As such, it should not be thought of as either an amelioration pattern, nor an anti-pattern. It is more of a [gray pattern](https://wiki.c2.com/?GreyPattern) - it exists in some context, to solve certain set of problems. When used appropriately, it can introduce great flexibility, cut prototyping time or deacrease complexity. When used carelessly, however, it can complicate database schema, degrade the performance and make maintenance hard. **As with every tool, it should not be overused.** In the following paragraphs we briefly discuss the pros, the cons and pointers to keep in mind when using EAV.
|
||||||
|
|
||||||
|
### When to use EAV?
|
||||||
|
|
||||||
|
Originally, EAV was introduced to workaround a problem which cannot be easily solved within relational model. In order to achieve this, EAV bypasses normal schema restrictions. Some refer to this as an example of the [inner-platform effect](https://en.wikipedia.org/wiki/Inner-platform_effect#Examples). Naturally, in such scenarios RDMS resources cannot be used efficiently.
|
||||||
|
|
||||||
|
Typical application of the EAV model sets to solve the problem of sparse data with a large number of applicable attributes, but only a small fraction that applies to a given entity that may not be known beforehand. Consider the classic example:
|
||||||
|
|
||||||
|
> A problem that data modelers commonly encounter in the biomedical domain is organizing and storing highly diverse and heterogeneous data. For example, a single patient may have thousands of applicable descriptive parameters, all of which need to be easily accessible in an electronic patient record system. These requirements pose significant modeling and implementation challenges. [1]
|
||||||
|
|
||||||
|
And:
|
||||||
|
|
||||||
|
> [...] what do you do when you have customers that demand real-time, on-demand addition of attributes that they want to store? In one of the systems I manage, our customers wanted to do exactly this. Since we run a SaaS (software as a service) application, we have many customers across several different industries, who in turn want to use our system to store different types of information about _their_ customers. A salon chain might want to record facts such as 'hair color,' 'hair type,' and 'haircut frequency'; while an investment company might want to record facts such as 'portfolio name,' 'last portfolio adjustment date,' and 'current portfolio balance.' [2]
|
||||||
|
|
||||||
|
In both of these problems we have to deal with sparse and heterogeneous properties that apply only to potentially different subsets of particular entities. Applying EAV to a sub-schema of the database allows to model the desired behaviour. Traditional solution would involves wide tables with many columns storing NULL values for attributes that don't apply to an entity.
|
||||||
|
|
||||||
|
Very common use case for EAV are custom product attributes in E-commerce implementations, such as Magento. [3]
|
||||||
|
|
||||||
|
As a rule of thumb, EAV can be used when:
|
||||||
|
|
||||||
|
- Model attributes are to be added and removed by end users (or are unknowable in some different way). EAV supports these without ALTER TABLE statements and allows the attributes to be strongly typed and easily searchable.
|
||||||
|
- There will be many attributes and values are sparse, in contrast to having tables with mostly-null columns.
|
||||||
|
- The data is highly dynamic/volatile/vulnerable to change. This problem is present in the second example given above. Other example would be rapidly evolving system, such as a prototype with constantly changing requirements.
|
||||||
|
- We want to store meta-data or supporting information, e.g. to customize system's behavior.
|
||||||
|
- Numerous classes of data need to be represented, each class has a limited number of attributes, but the number of instances of each class is very small.
|
||||||
|
- We want to minimise programmer's input when changing the data model.
|
||||||
|
|
||||||
|
For more throughout discussion on the appropriate use-cases see:
|
||||||
|
|
||||||
|
1. [Wikipedia - Scenarios that are appropriate for EAV modeling](https://en.wikipedia.org/wiki/Entity%E2%80%93attribute%E2%80%93value_model#Scenarios_that_are_appropriate_for_EAV_modeling)
|
||||||
|
2. [StackOverflow - Entity Attribute Value Database vs. strict Relational Model E-commerce](https://stackoverflow.com/questions/870808/entity-attribute-value-database-vs-strict-relational-model-ecommerce)
|
||||||
|
3. [WikiWikiWeb - Generic Data Model](https://wiki.c2.com/?GenericDataModel)
|
||||||
|
|
||||||
|
## When to avoid it?
|
||||||
|
|
||||||
|
As we outlined in the opening section, EAV is a trade-off. It should not be used when:
|
||||||
|
|
||||||
|
##### 1. System is performance critical
|
||||||
|
|
||||||
|
> Attribute-centric query is inherently more difficult when data are stored in EAV form than when they are stored conventionally. [4]
|
||||||
|
|
||||||
|
In general, the more structured your data model, the more efficiently you can deal with it. Therefore, loose data storage such as EAV has obvious trade-off in performance. Specifically, application of the EAV model makes performing JOINs on tables more complicated.
|
||||||
|
|
||||||
|
##### 2. Low complexity/low maintenance cost is of priority
|
||||||
|
|
||||||
|
EAV complicates data model by splitting information across tables. This increases conceptual complexity as well as SQL statements required to query the data. In consequence, optimization in one area that also makes the system harder to understand and maintain.
|
||||||
|
|
||||||
|
However, it is important to note that:
|
||||||
|
|
||||||
|
> An EAV design should be employed only for that sub-schema of a database where sparse attributes need to be modeled: even here, they need to be supported by third normal form metadata tables. There are relatively few database-design problems where sparse attributes are encountered: this is why the circumstances where EAV design is applicable are relatively rare. [1]
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
In some use-cases, JSONB (binary JSON data) datatype (Postgres 9.4+ and analogous in other RDMSs) can be used as an alternative to EAV. JSONB supports indexing, which amortizes performance trade-off. It's important to keep in mind that JSONB is not RDMS-agnostic solution and has it's own problems, such as typing.
|
||||||
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
You can install **django-eav2** from three sources:
|
|
||||||
|
Install with pip
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From PyPI via pip
|
|
||||||
pip install django-eav2
|
pip install django-eav2
|
||||||
|
```
|
||||||
|
|
||||||
# From source via pip
|
## Configuration
|
||||||
pip install git+https://github.com/makimo/django-eav2@master
|
|
||||||
|
|
||||||
# From source via setuptools
|
Add `eav` to `INSTALLED_APPS` in your settings.
|
||||||
git clone git@github.com:makimo/django-eav2.git
|
|
||||||
cd django-eav2
|
|
||||||
python setup.py install
|
|
||||||
|
|
||||||
# To uninstall:
|
```python
|
||||||
python setup.py install --record files.txt
|
INSTALLED_APPS = [
|
||||||
rm $(cat files.txt)
|
...
|
||||||
|
'eav',
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `django.db.models.UUIDField` or `django.db.models.BigAutoField` as value of `EAV2_PRIMARY_KEY_FIELD` in your settings
|
||||||
|
|
||||||
|
``` python
|
||||||
|
EAV2_PRIMARY_KEY_FIELD = "django.db.models.UUIDField" # as example
|
||||||
|
```
|
||||||
|
|
||||||
|
### Note: Primary key mandatory modification field
|
||||||
|
|
||||||
|
If the primary key of eav models are to be modified (UUIDField -> BigAutoField, BigAutoField -> UUIDField) in the middle of the project when the migrations are already done, you have to change the value of `EAV2_PRIMARY_KEY_FIELD` in your settings.
|
||||||
|
|
||||||
|
##### Step 1
|
||||||
|
Change the value of `EAV2_PRIMARY_KEY_FIELD` into `django.db.models.CharField` in your settings.
|
||||||
|
|
||||||
|
```python
|
||||||
|
EAV2_PRIMARY_KEY_FIELD = "django.db.models.CharField"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py makemigrations
|
||||||
|
python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Step 2
|
||||||
|
Change the value of `EAV2_PRIMARY_KEY_FIELD` into the desired value (`django.db.models.BigAutoField` or `django.db.models.UUIDField`) in your settings.
|
||||||
|
|
||||||
|
```python
|
||||||
|
EAV2_PRIMARY_KEY_FIELD = "django.db.models.BigAutoField" # as example
|
||||||
|
```
|
||||||
|
|
||||||
|
Run again the migrations.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py makemigrations
|
||||||
|
python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Note: Django 2.2 Users
|
||||||
|
|
||||||
|
Since `models.JSONField()` isn't supported in Django 2.2, we use [django-jsonfield-backport](https://github.com/laymonage/django-jsonfield-backport) to provide [JSONField](https://docs.djangoproject.com/en/dev/releases/3.1/#jsonfield-for-all-supported-database-backends) functionality.
|
||||||
|
|
||||||
|
This requires adding `django_jsonfield_backport` to your `INSTALLED_APPS` as well.
|
||||||
|
|
||||||
|
```python
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
...
|
||||||
|
'eav',
|
||||||
|
'django_jsonfield_backport',
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
@ -65,4 +190,13 @@ Supplier.objects.filter(eav__city='London')
|
||||||
# = <EavQuerySet [<Supplier: Supplier object (1)>]>
|
# = <EavQuerySet [<Supplier: Supplier object (1)>]>
|
||||||
```
|
```
|
||||||
|
|
||||||
### What next? Check out <a href="https://django-eav-2.readthedocs.io/en/improvement-docs/">documentation</a>.
|
**What next? Check out the <a href="https://django-eav2.readthedocs.io/en/latest/#documentation">documentation</a>.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### References
|
||||||
|
|
||||||
|
[1] Exploring Performance Issues for a Clinical Database Organized Using an Entity-Attribute-Value Representation, https://doi.org/10.1136/jamia.2000.0070475 <br>
|
||||||
|
[2] What is so bad about EAV, anyway?, https://sqlblog.org/2009/11/19/what-is-so-bad-about-eav-anyway <br>
|
||||||
|
[3] Magento for Developers: Part 7—Advanced ORM: Entity Attribute Value, https://devdocs.magento.com/guides/m1x/magefordev/mage-for-dev-7.html <br>
|
||||||
|
[4] Data Extraction and Ad Hoc Query of an Entity— Attribute— Value Database, https://www.ncbi.nlm.nih.gov/pmc/articles/PMC61332/
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,20 @@
|
||||||
# Makefile for Sphinx documentation
|
# Minimal makefile for Sphinx documentation
|
||||||
#
|
#
|
||||||
|
|
||||||
# You can set these variables from the command line.
|
# You can set these variables from the command line.
|
||||||
SPHINXOPTS =
|
SPHINXOPTS =
|
||||||
SPHINXBUILD = sphinx-build
|
SPHINXBUILD = sphinx-build
|
||||||
PAPER =
|
SPHINXPROJ = DjangoEAV2
|
||||||
BUILDDIR = _build
|
SOURCEDIR = source
|
||||||
|
BUILDDIR = build
|
||||||
# Internal variables.
|
|
||||||
PAPEROPT_a4 = -D latex_paper_size=a4
|
|
||||||
PAPEROPT_letter = -D latex_paper_size=letter
|
|
||||||
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
|
||||||
|
|
||||||
.PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest
|
|
||||||
|
|
||||||
|
# Put it first so that "make" without argument is like "make help".
|
||||||
help:
|
help:
|
||||||
@echo "Please use \`make <target>' where <target> is one of"
|
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
@echo " html to make standalone HTML files"
|
|
||||||
@echo " dirhtml to make HTML files named index.html in directories"
|
|
||||||
@echo " pickle to make pickle files"
|
|
||||||
@echo " json to make JSON files"
|
|
||||||
@echo " htmlhelp to make HTML files and a HTML help project"
|
|
||||||
@echo " qthelp to make HTML files and a qthelp project"
|
|
||||||
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
|
|
||||||
@echo " changes to make an overview of all changed/added/deprecated items"
|
|
||||||
@echo " linkcheck to check all external links for integrity"
|
|
||||||
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
|
||||||
|
|
||||||
clean:
|
.PHONY: help Makefile
|
||||||
-rm -rf $(BUILDDIR)/*
|
|
||||||
|
|
||||||
html:
|
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||||
@echo
|
%: Makefile
|
||||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
|
|
||||||
dirhtml:
|
|
||||||
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
|
|
||||||
@echo
|
|
||||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
|
|
||||||
|
|
||||||
pickle:
|
|
||||||
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
|
|
||||||
@echo
|
|
||||||
@echo "Build finished; now you can process the pickle files."
|
|
||||||
|
|
||||||
json:
|
|
||||||
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
|
|
||||||
@echo
|
|
||||||
@echo "Build finished; now you can process the JSON files."
|
|
||||||
|
|
||||||
htmlhelp:
|
|
||||||
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
|
|
||||||
@echo
|
|
||||||
@echo "Build finished; now you can run HTML Help Workshop with the" \
|
|
||||||
".hhp project file in $(BUILDDIR)/htmlhelp."
|
|
||||||
|
|
||||||
qthelp:
|
|
||||||
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
|
|
||||||
@echo
|
|
||||||
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
|
|
||||||
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
|
|
||||||
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-eav.qhcp"
|
|
||||||
@echo "To view the help file:"
|
|
||||||
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-eav.qhc"
|
|
||||||
|
|
||||||
latex:
|
|
||||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
|
||||||
@echo
|
|
||||||
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
|
|
||||||
@echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \
|
|
||||||
"run these through (pdf)latex."
|
|
||||||
|
|
||||||
changes:
|
|
||||||
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
|
|
||||||
@echo
|
|
||||||
@echo "The overview file is in $(BUILDDIR)/changes."
|
|
||||||
|
|
||||||
linkcheck:
|
|
||||||
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
|
|
||||||
@echo
|
|
||||||
@echo "Link check complete; look for any errors in the above output " \
|
|
||||||
"or in $(BUILDDIR)/linkcheck/output.txt."
|
|
||||||
|
|
||||||
doctest:
|
|
||||||
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
|
|
||||||
@echo "Testing of doctests in the sources finished, look at the " \
|
|
||||||
"results in $(BUILDDIR)/doctest/output.txt."
|
|
||||||
|
|
|
||||||
199
docs/conf.py
199
docs/conf.py
|
|
@ -1,199 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
|
||||||
# django-eav documentation build configuration file, created by
|
|
||||||
# sphinx-quickstart on Fri Sep 24 10:48:33 2010.
|
|
||||||
#
|
|
||||||
# This file is execfile()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.
|
|
||||||
|
|
||||||
import sys, os
|
|
||||||
|
|
||||||
# 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.append(os.path.abspath(os.path.join('..','..')))
|
|
||||||
|
|
||||||
# django setup
|
|
||||||
import settings
|
|
||||||
from django.core.management import setup_environ
|
|
||||||
setup_environ(settings)
|
|
||||||
|
|
||||||
# -- General configuration -----------------------------------------------------
|
|
||||||
|
|
||||||
# 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','sphinxtogithub']
|
|
||||||
|
|
||||||
# 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'
|
|
||||||
|
|
||||||
# The master toctree document.
|
|
||||||
master_doc = 'index'
|
|
||||||
|
|
||||||
# General information about the project.
|
|
||||||
project = u'django-eav'
|
|
||||||
copyright = u'2010, MVP Africa'
|
|
||||||
|
|
||||||
# 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.9'
|
|
||||||
# The full version, including alpha/beta/rc tags.
|
|
||||||
release = '0.9.1'
|
|
||||||
|
|
||||||
# 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 documents that shouldn't be included in the build.
|
|
||||||
#unused_docs = []
|
|
||||||
|
|
||||||
# List of directories, relative to source directory, that shouldn't be searched
|
|
||||||
# for source files.
|
|
||||||
exclude_trees = ['_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 = []
|
|
||||||
|
|
||||||
|
|
||||||
# -- Options for HTML output ---------------------------------------------------
|
|
||||||
|
|
||||||
# The theme to use for HTML and HTML Help pages. Major themes that come with
|
|
||||||
# Sphinx are currently 'default' and 'sphinxdoc'.
|
|
||||||
html_theme = 'default'
|
|
||||||
|
|
||||||
# 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 = []
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# The name of an image file (relative to this directory) to place at the top
|
|
||||||
# of the sidebar.
|
|
||||||
#html_logo = None
|
|
||||||
|
|
||||||
# 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']
|
|
||||||
|
|
||||||
# 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_use_modindex = 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, 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 = ''
|
|
||||||
|
|
||||||
# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml").
|
|
||||||
#html_file_suffix = ''
|
|
||||||
|
|
||||||
# Output file base name for HTML help builder.
|
|
||||||
htmlhelp_basename = 'django-eavdoc'
|
|
||||||
|
|
||||||
|
|
||||||
# -- Options for LaTeX output --------------------------------------------------
|
|
||||||
|
|
||||||
# The paper size ('letter' or 'a4').
|
|
||||||
#latex_paper_size = 'letter'
|
|
||||||
|
|
||||||
# The font size ('10pt', '11pt' or '12pt').
|
|
||||||
#latex_font_size = '10pt'
|
|
||||||
|
|
||||||
# Grouping the document tree into LaTeX files. List of tuples
|
|
||||||
# (source start file, target name, title, author, documentclass [howto/manual]).
|
|
||||||
latex_documents = [
|
|
||||||
('index', 'django-eav.tex', u'django-eav Documentation',
|
|
||||||
u'MVP Africa', '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
|
|
||||||
|
|
||||||
# Additional stuff for the LaTeX preamble.
|
|
||||||
#latex_preamble = ''
|
|
||||||
|
|
||||||
# Documents to append as an appendix to all manuals.
|
|
||||||
#latex_appendices = []
|
|
||||||
|
|
||||||
# If false, no module index is generated.
|
|
||||||
#latex_use_modindex = True
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
Docstrings
|
|
||||||
==========
|
|
||||||
|
|
||||||
.. automodule:: eav
|
|
||||||
:members:
|
|
||||||
|
|
||||||
.. automodule:: eav.models
|
|
||||||
:members:
|
|
||||||
|
|
||||||
.. automodule:: eav.validators
|
|
||||||
:members:
|
|
||||||
|
|
||||||
.. automodule:: eav.fields
|
|
||||||
:members:
|
|
||||||
|
|
||||||
.. automodule:: eav.forms
|
|
||||||
:members:
|
|
||||||
|
|
||||||
.. automodule:: eav.managers
|
|
||||||
:members:
|
|
||||||
|
|
||||||
.. automodule:: eav.registry
|
|
||||||
:members:
|
|
||||||
|
|
||||||
240
docs/index.rst
240
docs/index.rst
|
|
@ -1,240 +0,0 @@
|
||||||
.. django-eav documentation master file, created by
|
|
||||||
sphinx-quickstart on Fri Sep 24 10:48:33 2010.
|
|
||||||
You can adapt this file completely to your liking, but it should at least
|
|
||||||
contain the root `toctree` directive.
|
|
||||||
|
|
||||||
##########
|
|
||||||
django-eav
|
|
||||||
##########
|
|
||||||
|
|
||||||
|
|
||||||
Introduction
|
|
||||||
============
|
|
||||||
django-eav provides an Entity-Attribute-Value storage model for django apps.
|
|
||||||
|
|
||||||
For a decent explanation of what an Entity-Attribute-Value storage model is,
|
|
||||||
check `Wikipedia
|
|
||||||
<http://en.wikipedia.org/wiki/Entity-attribute-value_model>`_.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
This software was inspired / derived from the excellent `eav-django
|
|
||||||
<http://pypi.python.org/pypi/eav-django/1.0.2>`_ written by Andrey
|
|
||||||
Mikhaylenko.
|
|
||||||
|
|
||||||
There are a few notable differences between this implementation and the
|
|
||||||
eav-django implementation.
|
|
||||||
|
|
||||||
* This one is called django-eav, whereas the other is called eav-django.
|
|
||||||
* This app allows you to to 'attach' EAV attributes to any existing django
|
|
||||||
model (even from third-party apps) without making any changes to the those
|
|
||||||
models' code.
|
|
||||||
* This app has slightly more robust (but still not perfect) filtering.
|
|
||||||
|
|
||||||
|
|
||||||
Installation
|
|
||||||
============
|
|
||||||
You can install django-eav directly from guthub::
|
|
||||||
|
|
||||||
pip install -e git+git://github.com/mvpdev/django-eav.git#egg=django-eav
|
|
||||||
|
|
||||||
After installing, add ``eav`` to your ``INSTALLED_APPS`` in your
|
|
||||||
project's ``settings.py`` file.
|
|
||||||
|
|
||||||
Usage
|
|
||||||
=====
|
|
||||||
In order to attach EAV attributes to a model, you first need to register it
|
|
||||||
(just like you may register your models with django.contrib.admin).
|
|
||||||
|
|
||||||
Registration
|
|
||||||
------------
|
|
||||||
Registering a model with eav does a few things:
|
|
||||||
|
|
||||||
* Adds the eav :class:`eav.managers.EntityManager` to your class. By default,
|
|
||||||
it will replace the default ``objects`` manager of the model, but you can
|
|
||||||
choose to have the eav manager named something else if you don't want it to
|
|
||||||
replace ``objects`` (see :ref:`advancedregistration`).
|
|
||||||
* Connects the model's ``post_init`` signal to
|
|
||||||
:meth:`~eav.registry.Registry.attach_eav_attr`. This function will attach
|
|
||||||
the eav :class:`eav.models.Entity` helper class to every instance of your
|
|
||||||
model when it is instatiated. By default, it will be attached to your models
|
|
||||||
as an attribute named ``eav``, which will allow you to access it through
|
|
||||||
``my_model_instance.eav``, but you can choose to name it something else if you
|
|
||||||
want (again see :ref:`advancedregistration`).
|
|
||||||
* Connect's the model's ``pre_save`` signal to
|
|
||||||
:meth:`eav.models.Entity.pre_save_handler`.
|
|
||||||
* Connect's the model's ``post_save`` signal to
|
|
||||||
:meth:`eav.models.Entity.post_save_handler`.
|
|
||||||
* Adds a generic relation helper to the class.
|
|
||||||
* Sets an attribute called ``_eav_config_cls`` on the model class to either
|
|
||||||
the default :class:`eav.registry.EavConfig` config class, or to the config
|
|
||||||
class you provided during registration.
|
|
||||||
|
|
||||||
If that all sounds too complicated, don't worry, you really don't need to think
|
|
||||||
about it. Just thought you should know.
|
|
||||||
|
|
||||||
Simple Registration
|
|
||||||
^^^^^^^^^^^^^^^^^^^
|
|
||||||
To register any model with EAV, you simply need to add the registration line
|
|
||||||
somewhere that will be executed when django starts::
|
|
||||||
|
|
||||||
import eav
|
|
||||||
eav.register(MyModel)
|
|
||||||
|
|
||||||
Generally, the most appropriate place for this would be in your ``models.py``
|
|
||||||
immediately after your model definition.
|
|
||||||
|
|
||||||
.. _advancedregistration:
|
|
||||||
|
|
||||||
Advanced Registration
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
Advanced registration is only required if:
|
|
||||||
|
|
||||||
* You don't want eav to replace your model's default ``objects`` manager.
|
|
||||||
* You want to name the :class:`~eav.models.Entity` helper attribute something
|
|
||||||
other than ``eav``
|
|
||||||
* You don't want all eav :class:`~eav.models.Attribute` objects to
|
|
||||||
be able to be set for all of your registered models. In other words, you
|
|
||||||
have different types of entities, each with different attributes.
|
|
||||||
|
|
||||||
Advanced registration is simple, and is performed the exact same way
|
|
||||||
you override the django.contrib.admin registration defaults.
|
|
||||||
|
|
||||||
You just need to define your own config class that subclasses
|
|
||||||
:class:`~eav.registry.EavConfig` and override the default class attributes
|
|
||||||
and method.
|
|
||||||
|
|
||||||
There are five :class:`~eav.registry.EavConfig` class attributes you can
|
|
||||||
override:
|
|
||||||
|
|
||||||
================================= ================================== ==========================================================================
|
|
||||||
Class Attribute Default Description
|
|
||||||
================================= ================================== ==========================================================================
|
|
||||||
``manager_attr`` ``'objects'`` The name of the eav manager
|
|
||||||
``manager_only`` ``False`` *boolean* Whether to *only* replace the manager, and do nothing else
|
|
||||||
``eav_attr`` ``'eav'`` The attribute name of the entity helper
|
|
||||||
``generic_relation_attr`` ``'eav_values'`` The attribute name of the generic relation helper
|
|
||||||
``generic_relation_related_name`` The model's ``__class__.__name__`` The related name of the related name of the generic relation to your model
|
|
||||||
================================= ================================== ==========================================================================
|
|
||||||
|
|
||||||
An example of just choosing a different name for the manager (and thus leaving
|
|
||||||
``objects`` intact)::
|
|
||||||
|
|
||||||
class MyEavConfigClass(EavConfig):
|
|
||||||
manager_attr = 'eav_objects'
|
|
||||||
|
|
||||||
eav.register(MyModel, MyEavConfigClass)
|
|
||||||
|
|
||||||
Additionally, :class:`~eav.registry.EavConfig` defines a classmethod called
|
|
||||||
``get_attributes`` that, by default will return ``Attribute.objects.all()``
|
|
||||||
This method is used to determine which :class:`~eav.models.Attribute` can be
|
|
||||||
applied to the entity model you are registering. If you want to limit which
|
|
||||||
attributes can be applied to your entity, you would need to override it.
|
|
||||||
|
|
||||||
For example::
|
|
||||||
|
|
||||||
class MyEavConfigClass(EavConfig):
|
|
||||||
@classmethod
|
|
||||||
def get_attributes(cls):
|
|
||||||
return Attribute.objects.filter(type='person')
|
|
||||||
|
|
||||||
eav.register(MyModel, MyEavConfigClass)
|
|
||||||
|
|
||||||
|
|
||||||
Using Attributes
|
|
||||||
================
|
|
||||||
Once you've registered your model(s), you can begin to use them with EAV
|
|
||||||
attributes. Let's assume your model is called ``Person`` and it has one
|
|
||||||
normal django ``CharField`` called name, but you want to be able to dynamically
|
|
||||||
store other data about each Person.
|
|
||||||
|
|
||||||
First, let's create some attributes::
|
|
||||||
|
|
||||||
>>> Attribute.objects.create(name='Weight', datatype=Attribute.TYPE_FLOAT)
|
|
||||||
>>> Attribute.objects.create(name='Height', datatype=Attribute.TYPE_INT)
|
|
||||||
>>> Attribute.objects.create(name='Is pregant?', datatype=Attribute.TYPE_BOOLEAN)
|
|
||||||
|
|
||||||
Now let's create a patient, and set some of these attributes::
|
|
||||||
|
|
||||||
>>> p = Patient.objects.create(name='Bob')
|
|
||||||
>>> p.eav.height = 46
|
|
||||||
>>> p.eav.weight = 42.2
|
|
||||||
>>> p.eav.is_pregnant = False
|
|
||||||
>>> p.save()
|
|
||||||
>>> bob = Patient.objects.get(name='Bob')
|
|
||||||
>>> bob.eav.height
|
|
||||||
46
|
|
||||||
>>> bob.eav.weight
|
|
||||||
42.2
|
|
||||||
>>> bob.is_pregnant
|
|
||||||
False
|
|
||||||
|
|
||||||
Additionally, assuming we're using the eav manager, we can also do::
|
|
||||||
|
|
||||||
>>> p = Patient.objects.create(name='Jen', eav__height=32, eav__pregnant=True)
|
|
||||||
|
|
||||||
|
|
||||||
Filtering
|
|
||||||
=========
|
|
||||||
|
|
||||||
eav attributes are filterable, using the same __ notation as django foreign
|
|
||||||
keys::
|
|
||||||
|
|
||||||
Patient.objects.filter(eav__weight=42.2)
|
|
||||||
Patient.objects.filter(eav__weight__gt=42)
|
|
||||||
Patient.objects.filter(name='Bob', eav__weight__gt=42)
|
|
||||||
Patient.objects.exclude(eav__is_pregnant=False)
|
|
||||||
|
|
||||||
You can even use Q objects, however there are some known issues
|
|
||||||
(see :ref:`qobjectissue`) with Q object filters::
|
|
||||||
|
|
||||||
Patient.objects.filter(Q(name='Bob') | Q(eav__is_pregnant=False))
|
|
||||||
|
|
||||||
What about if you have a foreign key to a model that uses eav, but you want
|
|
||||||
to filter from a model that doesn't use eav? For example, let's say you have
|
|
||||||
a ``Patient`` model that **doesn't** use eav, but it has a foreign key to
|
|
||||||
``Encounter`` that **does** use eav. You can even filter through eav across
|
|
||||||
this relationship, but you need to use the eav manager for ``Patient``.
|
|
||||||
|
|
||||||
Just register ``Patient`` with eav, but set ``manager_only = True``
|
|
||||||
see (see :ref:`advancedregistration`). Then you can do::
|
|
||||||
|
|
||||||
Patient.objects.filter(encounter__eav__weight=2)
|
|
||||||
|
|
||||||
|
|
||||||
Admin Integration
|
|
||||||
=================
|
|
||||||
|
|
||||||
You can even have your eav attributes show up just like normal fields in your
|
|
||||||
models admin pages. Just register using the eav admin class::
|
|
||||||
|
|
||||||
from django.contrib import admin
|
|
||||||
from eav.forms import BaseDynamicEntityForm
|
|
||||||
from eav.admin import BaseEntityAdmin
|
|
||||||
|
|
||||||
class PatientAdminForm(BaseDynamicEntityForm):
|
|
||||||
model = Patient
|
|
||||||
|
|
||||||
class PatientAdmin(BaseEntityAdmin):
|
|
||||||
form = PatientAdminForm
|
|
||||||
|
|
||||||
admin.site.register(Patient, PatientAdmin)
|
|
||||||
|
|
||||||
|
|
||||||
Known Issues
|
|
||||||
============
|
|
||||||
|
|
||||||
.. _qobjectissue:
|
|
||||||
|
|
||||||
Q Object Filters
|
|
||||||
----------------
|
|
||||||
Due to an unexplained Q object / generic relation issue, exclude filters with
|
|
||||||
EAV Q objects, or EAV Q objects ANDed together may produce inaccurate results.
|
|
||||||
|
|
||||||
Additional Resources
|
|
||||||
====================
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 2
|
|
||||||
|
|
||||||
docstrings
|
|
||||||
|
|
||||||
202
docs/source/_static/LICENSE.txt
Normal file
202
docs/source/_static/LICENSE.txt
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
4
docs/source/_templates/layout.html
Normal file
4
docs/source/_templates/layout.html
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{% extends "!layout.html" %}
|
||||||
|
{% block extrahead %}
|
||||||
|
<a href="https://github.com/jazzband/django-eav2"><img style="position: fixed; top: 0; right: 0; border: 0;" src="https://s3.amazonaws.com/github/ribbons/forkme_right_darkblue_121621.png" alt="Fork me on GitHub"></a>
|
||||||
|
{% endblock %}
|
||||||
11
docs/source/_templates/sidebarintro.html
Normal file
11
docs/source/_templates/sidebarintro.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<h3>About</h3>
|
||||||
|
<p>
|
||||||
|
Django EAV 2 is an entity-attribute-value storage for modern Django.
|
||||||
|
</p>
|
||||||
|
<h3>Useful Links</h3>
|
||||||
|
<ul>
|
||||||
|
<li><a href="index.html">Home</a></li>
|
||||||
|
<li><a href="https://pypi.org/project/django-eav2/">PyPI</a></li>
|
||||||
|
<li><a href="https://github.com/jazzband/django-eav2">GitHub</a></li>
|
||||||
|
<li><a href="https://github.com/jazzband/django-eav2/issues">Issue Tracker</a></li>
|
||||||
|
</ul>
|
||||||
3
docs/source/_templates/sidebarlogo.html
Normal file
3
docs/source/_templates/sidebarlogo.html
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<p class="logo"><a href="{{ pathto(master_doc) }}">
|
||||||
|
<img class="logo" src="{{ pathto('_static/logo.png', 1) }}" width="250" height="50" alt="Logo">
|
||||||
|
</a></p>
|
||||||
74
docs/source/api.rst
Normal file
74
docs/source/api.rst
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
.. rst-class:: doc-api
|
||||||
|
|
||||||
|
API Reference
|
||||||
|
=============
|
||||||
|
|
||||||
|
If you are looking for information on a specific function, class, or method,
|
||||||
|
this part of the documentation is for you.
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
|
||||||
|
|
||||||
|
Admin
|
||||||
|
-----
|
||||||
|
|
||||||
|
.. automodule:: eav.admin
|
||||||
|
:members:
|
||||||
|
:member-order: bysource
|
||||||
|
|
||||||
|
Decorators
|
||||||
|
----------
|
||||||
|
|
||||||
|
.. automodule:: eav.decorators
|
||||||
|
:members:
|
||||||
|
:member-order: bysource
|
||||||
|
|
||||||
|
Fields
|
||||||
|
------
|
||||||
|
|
||||||
|
.. automodule:: eav.fields
|
||||||
|
:members:
|
||||||
|
:member-order: bysource
|
||||||
|
|
||||||
|
Forms
|
||||||
|
-----
|
||||||
|
|
||||||
|
.. automodule:: eav.forms
|
||||||
|
:members:
|
||||||
|
:member-order: bysource
|
||||||
|
:exclude-members: FIELD_CLASSES
|
||||||
|
|
||||||
|
Managers
|
||||||
|
--------
|
||||||
|
|
||||||
|
.. automodule:: eav.managers
|
||||||
|
:members:
|
||||||
|
:member-order: bysource
|
||||||
|
|
||||||
|
Models
|
||||||
|
------
|
||||||
|
|
||||||
|
.. automodule:: eav.models
|
||||||
|
:members:
|
||||||
|
:member-order: bysource
|
||||||
|
|
||||||
|
Queryset
|
||||||
|
--------
|
||||||
|
|
||||||
|
.. automodule:: eav.queryset
|
||||||
|
:members:
|
||||||
|
:member-order: bysource
|
||||||
|
|
||||||
|
Registry
|
||||||
|
--------
|
||||||
|
|
||||||
|
.. automodule:: eav.registry
|
||||||
|
:members:
|
||||||
|
:member-order: bysource
|
||||||
|
|
||||||
|
Validators
|
||||||
|
----------
|
||||||
|
|
||||||
|
.. automodule:: eav.validators
|
||||||
|
:members:
|
||||||
|
:member-order: bysource
|
||||||
162
docs/source/conf.py
Normal file
162
docs/source/conf.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
# Sphinx documentation generator configuration.
|
||||||
|
#
|
||||||
|
# More information on the configuration options is available at:
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import django
|
||||||
|
from django.conf import settings
|
||||||
|
from sphinx.ext.autodoc import between
|
||||||
|
|
||||||
|
# For discovery of Python modules
|
||||||
|
sys.path.insert(0, str(Path().cwd()))
|
||||||
|
|
||||||
|
# For finding the django_settings.py file
|
||||||
|
sys.path.insert(0, str(Path("../../").resolve()))
|
||||||
|
|
||||||
|
|
||||||
|
# Pass settings into configure.
|
||||||
|
settings.configure(
|
||||||
|
INSTALLED_APPS=[
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
"eav",
|
||||||
|
],
|
||||||
|
SECRET_KEY=os.environ.get("DJANGO_SECRET_KEY", "this-is-not-s3cur3"),
|
||||||
|
EAV2_PRIMARY_KEY_FIELD="django.db.models.BigAutoField",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call django.setup to load installed apps and other stuff.
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
# -- Project information -----------------------------------------------------
|
||||||
|
|
||||||
|
project = "Django EAV 2"
|
||||||
|
copyright = "2018, Iwo Herka and team at MAKIMO" # noqa: A001
|
||||||
|
author = "-"
|
||||||
|
|
||||||
|
# The short X.Y version
|
||||||
|
version = ""
|
||||||
|
# The full version, including alpha/beta/rc tags
|
||||||
|
release = "0.10.0"
|
||||||
|
|
||||||
|
|
||||||
|
def setup(app):
|
||||||
|
"""Use the configuration file itself as an extension."""
|
||||||
|
app.connect(
|
||||||
|
"autodoc-process-docstring",
|
||||||
|
between(
|
||||||
|
"^.*IGNORE.*$",
|
||||||
|
exclude=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# -- General configuration ---------------------------------------------------
|
||||||
|
|
||||||
|
extensions = [
|
||||||
|
"sphinx.ext.napoleon",
|
||||||
|
"sphinx.ext.autodoc",
|
||||||
|
"sphinx.ext.intersphinx",
|
||||||
|
"sphinx.ext.coverage",
|
||||||
|
"sphinx.ext.viewcode",
|
||||||
|
"sphinx_rtd_theme",
|
||||||
|
]
|
||||||
|
|
||||||
|
templates_path = ["_templates"]
|
||||||
|
|
||||||
|
source_suffix = ".rst"
|
||||||
|
|
||||||
|
master_doc = "index"
|
||||||
|
|
||||||
|
language = "en"
|
||||||
|
|
||||||
|
exclude_patterns = ["build"]
|
||||||
|
|
||||||
|
# The name of the Pygments (syntax highlighting) style to use.
|
||||||
|
pygments_style = "sphinx"
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for HTML output -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
html_theme = "sphinx_rtd_theme"
|
||||||
|
|
||||||
|
html_static_path = ["_static"]
|
||||||
|
|
||||||
|
html_sidebars = {
|
||||||
|
"index": ["sidebarintro.html", "localtoc.html"],
|
||||||
|
"**": [
|
||||||
|
"sidebarintro.html",
|
||||||
|
"localtoc.html",
|
||||||
|
"relations.html",
|
||||||
|
"searchbox.html",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlhelp_basename = "DjangoEAV2doc"
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for LaTeX output ------------------------------------------------
|
||||||
|
|
||||||
|
latex_elements: dict[str, str] = {}
|
||||||
|
|
||||||
|
# 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 = [
|
||||||
|
(master_doc, "DjangoEAV2.tex", "Django EAV 2 Documentation", "-", "manual"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for manual page output ------------------------------------------
|
||||||
|
|
||||||
|
# One entry per manual page. List of tuples
|
||||||
|
# (source start file, name, description, authors, manual section).
|
||||||
|
man_pages = [
|
||||||
|
(
|
||||||
|
master_doc,
|
||||||
|
"djangoeav2",
|
||||||
|
"Django EAV 2 Documentation",
|
||||||
|
[author],
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# -- 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 = [
|
||||||
|
(
|
||||||
|
master_doc,
|
||||||
|
"DjangoEAV2",
|
||||||
|
"Django EAV 2 Documentation",
|
||||||
|
author,
|
||||||
|
"DjangoEAV2",
|
||||||
|
"One line description of project.",
|
||||||
|
"Miscellaneous",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# -- Extension configuration -------------------------------------------------
|
||||||
|
|
||||||
|
# -- Options for intersphinx extension ---------------------------------------
|
||||||
|
|
||||||
|
# Example configuration for intersphinx: refer to the Python standard library.
|
||||||
|
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
|
||||||
|
|
||||||
|
# -- Autodoc configuration ---------------------------------------------------
|
||||||
|
|
||||||
|
add_module_names = False
|
||||||
37
docs/source/getting_started.rst
Normal file
37
docs/source/getting_started.rst
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
Getting Started
|
||||||
|
===============
|
||||||
|
|
||||||
|
Installation
|
||||||
|
------------
|
||||||
|
::
|
||||||
|
|
||||||
|
pip install django-eav2
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
-------------
|
||||||
|
|
||||||
|
After you've installed the package, you have to add it to your Django apps
|
||||||
|
::
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
...
|
||||||
|
'eav',
|
||||||
|
]
|
||||||
|
|
||||||
|
Note: Django 2.2 Users
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Since ``models.JSONField()`` isn't supported in Django 2.2, we use `django-jsonfield-backport <https://github.com/laymonage/django-jsonfield-backport>`_
|
||||||
|
to provide `JSONField <https://docs.djangoproject.com/en/dev/releases/3.1/#jsonfield-for-all-supported-database-backends>`_
|
||||||
|
functionality.
|
||||||
|
|
||||||
|
This requires adding ``django_jsonfield_backport`` to your INSTALLED_APPS as well.
|
||||||
|
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
...
|
||||||
|
'eav',
|
||||||
|
'django_jsonfield_backport',
|
||||||
|
]
|
||||||
72
docs/source/index.rst
Normal file
72
docs/source/index.rst
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
.. .. image:: _static/logo.png
|
||||||
|
.. :align: center
|
||||||
|
|
||||||
|
.. rst-class:: doc-title
|
||||||
|
|
||||||
|
Django EAV 2
|
||||||
|
============
|
||||||
|
|
||||||
|
Django EAV 2 is an entity-attribute-value storage for modern Django.
|
||||||
|
Getting started is very easy.
|
||||||
|
|
||||||
|
**Step 1.** Register a model:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
import eav
|
||||||
|
eav.register(Supplier)
|
||||||
|
|
||||||
|
or with decorators:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from eav.decorators import register_eav
|
||||||
|
|
||||||
|
@register_eav
|
||||||
|
class Supplier(models.Model):
|
||||||
|
...
|
||||||
|
|
||||||
|
**Step 2.** Create an attribute:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
Attribute.objects.create(name='City', datatype=Attribute.TYPE_TEXT)
|
||||||
|
|
||||||
|
**Step 3.** That's it! You're ready to go:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
supplier.eav.city = 'London'
|
||||||
|
supplier.save()
|
||||||
|
|
||||||
|
Supplier.objects.filter(eav__city='London')
|
||||||
|
# = <EavQuerySet [<Supplier: Supplier object (1)>]>
|
||||||
|
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
-------------
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
|
||||||
|
getting_started
|
||||||
|
usage
|
||||||
|
|
||||||
|
|
||||||
|
API Reference
|
||||||
|
-------------
|
||||||
|
|
||||||
|
If you are looking for information on a specific function, class, or
|
||||||
|
method, this part of the documentation is for you.
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
|
||||||
|
api
|
||||||
|
|
||||||
|
Indices and tables
|
||||||
|
------------------
|
||||||
|
|
||||||
|
* :ref:`genindex`
|
||||||
|
* :ref:`modindex`
|
||||||
|
* :ref:`search`
|
||||||
314
docs/source/usage.rst
Normal file
314
docs/source/usage.rst
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
Usage
|
||||||
|
=====
|
||||||
|
|
||||||
|
This part of the documentation will take you through all of library's
|
||||||
|
usage patterns. Before you can use EAV attributes, however, you need to
|
||||||
|
register your models.
|
||||||
|
|
||||||
|
Simple Registration
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
Basic registration is very simple. You can do it with :func:`~eav.register` method:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
import eav
|
||||||
|
eav.register(Parts)
|
||||||
|
|
||||||
|
or with decorators:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from eav.decorators import register_eav
|
||||||
|
|
||||||
|
@register_eav
|
||||||
|
class Supplier(models.Model):
|
||||||
|
...
|
||||||
|
|
||||||
|
Generally, if you chose the former, the most appropriate place for the
|
||||||
|
statement would be at the bottom of your ``models.py`` or immediately after
|
||||||
|
model definition.
|
||||||
|
|
||||||
|
Advanced Registration
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
Under the hood, registration does a couple of things:
|
||||||
|
|
||||||
|
1. Attaches :class:`~eav.managers.EntityManager` to your class. By default,
|
||||||
|
it replaces standard manager (*objects*). You can configure under which
|
||||||
|
attribute it is accessible with :class:`~.eav.registry.EavConfig` (see below).
|
||||||
|
|
||||||
|
2. Binds your model's *post_init* signal with
|
||||||
|
:meth:`~eav.registry.Registry.attach_eav_attr` method. It is used to
|
||||||
|
attach :class:`~eav.models.Entity` helper object to each model instance.
|
||||||
|
Entity, in turn, is used to retrieve, store and validate attribute values.
|
||||||
|
By default, it's accessible under *eav* attribute:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
part.eav.weight = 0.56
|
||||||
|
part.save()
|
||||||
|
|
||||||
|
3. Binds your model's *pre_save* and *post_save* signals to
|
||||||
|
:meth:`~eav.models.Entity.pre_save_handler` and
|
||||||
|
:meth:`~eav.models.Entity.post_save_hander`, respectively.
|
||||||
|
Those methods are responsible for validation and storage
|
||||||
|
of attribute values.
|
||||||
|
|
||||||
|
4. Setups up generic relation to :class:`~eav.models.Value` set.
|
||||||
|
By default, it's accessed under *eav_values*:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
patient.eav_values.all()
|
||||||
|
# = <QuerySet [has fever?: "True" (1), temperature: 37.7 (2)]>
|
||||||
|
|
||||||
|
5. Sets *_eav_config_cls* attribute storing model of the config class
|
||||||
|
used by :class:`~eav.registry.Registry`. Defaults
|
||||||
|
to :class:`~eav.registry.EavConfig`; can be overridden (see below).
|
||||||
|
|
||||||
|
With that out of the way, almost every aspect of the registration can
|
||||||
|
be customized. All available options are provided to registration
|
||||||
|
via config class: :class:`~eav.registry.EavConfig` passed to
|
||||||
|
:meth:`~eav.register`. You can change them by overriding the class and passing
|
||||||
|
it as a second argument. Available options are as follows:
|
||||||
|
|
||||||
|
1. ``manager_attr`` - Specifies manager name. Used to refer to the
|
||||||
|
manager from Entity class, "objects" by default.
|
||||||
|
2. ``manager_only`` - Specifies whether signals and generic relation should
|
||||||
|
be setup for the registered model.
|
||||||
|
3. ``eav_attr`` - Named of the Entity toolkit instance on the registered
|
||||||
|
model instance. "eav" by default. See attach_eav_attr.
|
||||||
|
4. ``generic_relation_attr`` - Name of the GenericRelation to Value
|
||||||
|
objects. "eav_values" by default.
|
||||||
|
5. ``generic_relation_related_name`` - Name of the related name for
|
||||||
|
GenericRelation from Entity to Value. None by default. Therefore,
|
||||||
|
if not overridden, it is not possible to query Values by Entities.
|
||||||
|
|
||||||
|
Example registration may look like:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
class SupplierEavConfig(EavConfig):
|
||||||
|
manager_attr = 'eav_objects'
|
||||||
|
|
||||||
|
eav.register(supplier, SupplierEavConfig)
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
As of now, configurable registration is not supported via
|
||||||
|
class decorator. You have to use explicit method call.
|
||||||
|
|
||||||
|
Additionally, :class:`~eav.registry.EavConfig` provides *classmethod*
|
||||||
|
:meth:`~eav.registry.EavConfig.get_attributes` which is used to determine
|
||||||
|
a set of attributes available to a given model. By default, it returns
|
||||||
|
``Attribute.objects.all()``. As usual, it can be customized:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from eav.models import Attribute
|
||||||
|
|
||||||
|
class SomeModelEavConfig(EavConfig):
|
||||||
|
@classmethod
|
||||||
|
def get_attributes(cls):
|
||||||
|
return Attribute.objects.filter(slug__startswith='a')
|
||||||
|
|
||||||
|
Attribute validation includes checks against illegal attribute value
|
||||||
|
assignments. This means that value assignments for attributes which are
|
||||||
|
excluded for the model are treated with
|
||||||
|
:class:`~eav.exceptions.IllegalAssignmentException`. For example (extending
|
||||||
|
previous one):
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
some_model.eav.beard = True
|
||||||
|
some_model.save()
|
||||||
|
|
||||||
|
will throw an exception.
|
||||||
|
|
||||||
|
Creating Attributes
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
Once your models are registered, you can starting creating attributes for
|
||||||
|
them. Two most important attributes of ``Attribute`` class are *slug* and
|
||||||
|
*datatype*. *slug* is a unique global identifier (there must be at most
|
||||||
|
one ``Attribute`` instance with given `slug`) and must be a valid Python
|
||||||
|
variable name, as it's used to access values for that attribute from
|
||||||
|
:class:`~eav.models.Entity` helper:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from eav.models import Attribute
|
||||||
|
|
||||||
|
Attribute.objects.create(slug='color', datatype=Attribute.TYPE_TEXT)
|
||||||
|
flower.eav.color = 'red'
|
||||||
|
|
||||||
|
# Alternatively, assuming you're using default EntityManager:
|
||||||
|
Attribute.objects.create(slug='color', datatype=Attribute.TYPE_TEXT)
|
||||||
|
Flower.objects.create(name='rose', eav__color='red')
|
||||||
|
|
||||||
|
*datatype* determines type of attribute (and by extension type of value
|
||||||
|
stored in :class:`~eav.models.Value`). Available choices are:
|
||||||
|
|
||||||
|
========= ==================
|
||||||
|
Type Attribute Constant
|
||||||
|
========= ==================
|
||||||
|
*int* ``TYPE_INT``
|
||||||
|
*float* ``TYPE_FLOAT``
|
||||||
|
*text* ``TYPE_TEXT``
|
||||||
|
*date* ``TYPE_DATE``
|
||||||
|
*bool* ``TYPE_BOOLEAN``
|
||||||
|
*object* ``TYPE_OBJECT``
|
||||||
|
*enum* ``TYPE_ENUM``
|
||||||
|
*json* ``TYPE_JSON``
|
||||||
|
*csv* ``TYPE_CSV``
|
||||||
|
========= ==================
|
||||||
|
|
||||||
|
If you want to create an attribute with data-type *enum*, you need to provide
|
||||||
|
it with ``enum_group``:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from eav.models import EnumValue, EnumGroup, Attribute
|
||||||
|
|
||||||
|
true = EnumValue.objects.create(value='Yes')
|
||||||
|
false = EnumValue.objects.create(value='No')
|
||||||
|
bool_group = EnumGroup.objects.create(name='Yes / No')
|
||||||
|
bool_group.enums.add(true, false)
|
||||||
|
|
||||||
|
Attribute.objects.create(
|
||||||
|
name='hungry?',
|
||||||
|
datatype=Attribute.TYPE_ENUM,
|
||||||
|
enum_group=bool_group
|
||||||
|
)
|
||||||
|
# = <Attribute: hungry? (Multiple Choice)>
|
||||||
|
|
||||||
|
The attribute type *json* allows to store them in JSON format, which internally use JSONField:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
Attribute.objects.create(name='name_intl', datatype=Attribute.TYPE_JSON)
|
||||||
|
|
||||||
|
prod = Product.objects.create(sku='PRD00001', eav__name_intl={
|
||||||
|
"es": "Escoba Verde",
|
||||||
|
"en": "Green Broom",
|
||||||
|
"it": "Scopa Verde"
|
||||||
|
})
|
||||||
|
|
||||||
|
prod2 = Product.objects.create(sku='PRD00002', eav__name_intl={
|
||||||
|
"es": "Escoba Roja",
|
||||||
|
"en": "Red Broom"
|
||||||
|
})
|
||||||
|
|
||||||
|
prod3 = Product.objects.create(sku='PRD00003', eav__name_intl={
|
||||||
|
"es": "Escoba Azul",
|
||||||
|
"it": "Scopa Blu"
|
||||||
|
})
|
||||||
|
|
||||||
|
prod.eav.name_intl
|
||||||
|
{'es': 'Escoba Verde', 'en': 'Green Broom', 'it': 'Scopa Verde'}
|
||||||
|
|
||||||
|
type(prod.eav.name_intl)
|
||||||
|
dict
|
||||||
|
|
||||||
|
Product.objects.filter(eav__name_intl__has_key="it")
|
||||||
|
<EavQuerySet [<Product: PRD00001>, <Product: PRD00003>]>
|
||||||
|
|
||||||
|
The attribute type *csv* allows to store Comma Separated Values, using ";" as a separator:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
Attribute.objects.create(name='colors', datatype=Attribute.TYPE_CSV)
|
||||||
|
|
||||||
|
prod = Product.objects.create(sku='PRD00001', eav__colors="red;green;blue")
|
||||||
|
|
||||||
|
prod2 = Product.objects.create(sku='PRD00002', eav__colors="red;green")
|
||||||
|
|
||||||
|
prod3 = Product.objects.create(sku='PRD00003', eav__colors="red;blue")
|
||||||
|
|
||||||
|
prod4 = Product.objects.create(sku='PRD00004', eav__colors="")
|
||||||
|
|
||||||
|
prod.eav.colors
|
||||||
|
["red", "green", "blue"]
|
||||||
|
|
||||||
|
type(prod.eav.name_intl)
|
||||||
|
list
|
||||||
|
|
||||||
|
Product.objects.filter(eav__name_colors="green")
|
||||||
|
<EavQuerySet [<Product: PRD00001>, <Product: PRD00002>]>
|
||||||
|
|
||||||
|
Product.objects.filter(~Q(eav__name_colors__isnull=False))
|
||||||
|
<EavQuerySet [<Product: PRD00004>]>
|
||||||
|
|
||||||
|
|
||||||
|
Finally, attribute type *object* allows to relate Django model instances
|
||||||
|
via generic foreign keys:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
Attribute.objects.create(name='Supplier', datatype=Attribute.TYPE_OBJECT)
|
||||||
|
|
||||||
|
steve = Supplier.objects.create(name='Steve')
|
||||||
|
cog = Part.objects.create(name='Cog', eav__supplier=steve)
|
||||||
|
|
||||||
|
cog.eav.supplier
|
||||||
|
# = <Supplier: Steve (1)>
|
||||||
|
|
||||||
|
Filtering By Attributes
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Once you've created your attributes and values for them, you can use them
|
||||||
|
to filter Django models. Django EAV 2 is using the same notation as Django's
|
||||||
|
foreign-keys:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
Part.objects.filter(eav__weight=10)
|
||||||
|
Part.objects.filter(eav__weight__gt=10)
|
||||||
|
Part.objects.filter(eav__code__startswith='A')
|
||||||
|
|
||||||
|
# Of course, you can mix them with regular queries:
|
||||||
|
Part.objects.filter(name='Cog', eav__height=7.8)
|
||||||
|
|
||||||
|
# Querying enums works either by enum instance or by it's text representation as follows:
|
||||||
|
yes = EnumValue.objects.get(name='Yes')
|
||||||
|
Part.objects.filter(eav__is_available=yes) # via EnumValue
|
||||||
|
Part.objects.filter(eav__is_available='yes) # via EnumValue's value
|
||||||
|
|
||||||
|
You can use ``Q`` expressions too:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
Patient.objects.filter(
|
||||||
|
Q(eav__sex='male', eav__fever=no) | Q(eav__city='Nice') & Q(eav__age__gt=32)
|
||||||
|
)
|
||||||
|
|
||||||
|
Admin Integration
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
Django EAV 2 seamlessly integrates with Django's admin interface by providing
|
||||||
|
dynamic attribute management directly within the admin panel. This feature
|
||||||
|
provides the EAV Attributes as a separate fieldset, whether use the base
|
||||||
|
fieldset or when providing your own.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from eav.forms import BaseDynamicEntityForm
|
||||||
|
from eav.admin import BaseEntityAdmin
|
||||||
|
|
||||||
|
class PatientAdminForm(BaseDynamicEntityForm):
|
||||||
|
model = Patient
|
||||||
|
|
||||||
|
class PatientAdmin(BaseEntityAdmin):
|
||||||
|
form = PatientAdminForm
|
||||||
|
|
||||||
|
admin.site.register(Patient, PatientAdmin)
|
||||||
|
|
||||||
|
Customizing the EAV Fieldset
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
The Django EAV 2 integration allows you to customize the presentation of EAV
|
||||||
|
attributes in the admin interface through the use of a dedicated fieldset. You
|
||||||
|
can configure this fieldset by setting ``eav_fieldset_title`` and
|
||||||
|
``eav_fieldset_description`` within your admin class.
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
__version__ = '0.10.2'
|
|
||||||
|
|
||||||
def register(model_cls, config_cls=None):
|
def register(model_cls, config_cls=None):
|
||||||
from .registry import Registry
|
from eav.registry import Registry
|
||||||
|
|
||||||
Registry.register(model_cls, config_cls)
|
Registry.register(model_cls, config_cls)
|
||||||
|
|
||||||
|
|
||||||
def unregister(model_cls):
|
def unregister(model_cls):
|
||||||
from .registry import Registry
|
from eav.registry import Registry
|
||||||
|
|
||||||
Registry.unregister(model_cls)
|
Registry.unregister(model_cls)
|
||||||
|
|
|
||||||
148
eav/admin.py
148
eav/admin.py
|
|
@ -1,65 +1,147 @@
|
||||||
'''Admin. This module contains classes used for admin integration.'''
|
"""This module contains classes used for admin integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Union
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.admin.options import InlineModelAdmin
|
from django.contrib.admin.options import InlineModelAdmin, ModelAdmin
|
||||||
from django.contrib.admin.options import ModelAdmin
|
|
||||||
from django.forms.models import BaseInlineFormSet
|
from django.forms.models import BaseInlineFormSet
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from .models import Attribute, EnumGroup, EnumValue, Value
|
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
_FIELDSET_TYPE = List[Union[str, Dict[str, Any]]] # type: ignore[misc]
|
||||||
|
|
||||||
|
some_attribute = ClassVar[Dict[str, str]]
|
||||||
|
|
||||||
|
|
||||||
class BaseEntityAdmin(ModelAdmin):
|
class BaseEntityAdmin(ModelAdmin):
|
||||||
|
"""Custom admin model to support dynamic EAV fieldsets.
|
||||||
|
|
||||||
|
Overrides the default rendering of the change form in the Django admin to
|
||||||
|
dynamically integrate EAV fields into the form fieldsets. This approach
|
||||||
|
allows EAV attributes to be rendered alongside standard model fields within
|
||||||
|
the admin interface.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
eav_fieldset_title (str): Title for the dynamically added EAV fieldset.
|
||||||
|
eav_fieldset_description (str): Optional description for the EAV fieldset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
eav_fieldset_title: str = "EAV Attributes"
|
||||||
|
eav_fieldset_description: str | None = None
|
||||||
|
|
||||||
def render_change_form(self, request, context, *args, **kwargs):
|
def render_change_form(self, request, context, *args, **kwargs):
|
||||||
"""
|
"""Dynamically modifies the admin form to include EAV fields.
|
||||||
Wrapper for `ModelAdmin.render_change_form`. Replaces standard static
|
|
||||||
`AdminForm` with an EAV-friendly one. The point is that our form generates
|
|
||||||
fields dynamically and fieldsets must be inferred from a prepared and
|
|
||||||
validated form instance, not just the form class. Django does not seem
|
|
||||||
to provide hooks for this purpose, so we simply wrap the view and
|
|
||||||
substitute some data.
|
|
||||||
"""
|
|
||||||
form = context['adminform'].form
|
|
||||||
|
|
||||||
# Infer correct data from the form.
|
Identifies EAV fields associated with the instance being edited and
|
||||||
fieldsets = self.fieldsets or [(None, {'fields': form.fields.keys()})]
|
dynamically inserts them into the admin form's fieldsets. This method
|
||||||
adminform = admin.helpers.AdminForm(form, fieldsets, self.prepopulated_fields)
|
ensures EAV fields are appropriately displayed in a dedicated fieldset
|
||||||
media = mark_safe(self.media + adminform.media)
|
and avoids field duplication.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: HttpRequest object representing the current request.
|
||||||
|
context: Dictionary containing context data for the form template.
|
||||||
|
*args: Variable length argument list.
|
||||||
|
**kwargs: Arbitrary keyword arguments.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HttpResponse object representing the rendered change form.
|
||||||
|
"""
|
||||||
|
form = context["adminform"].form
|
||||||
|
|
||||||
|
# Identify EAV fields based on the form instance's configuration.
|
||||||
|
eav_fields = self._get_eav_fields(form.instance)
|
||||||
|
|
||||||
|
# # Fallback to default if no EAV fields exist
|
||||||
|
if not eav_fields:
|
||||||
|
return super().render_change_form(request, context, *args, **kwargs)
|
||||||
|
|
||||||
|
# Get the non-EAV fieldsets and then append our own
|
||||||
|
fieldsets = list(self.get_fieldsets(request, kwargs["obj"]))
|
||||||
|
fieldsets.append(self._get_eav_fieldset(eav_fields))
|
||||||
|
|
||||||
|
# Reconstruct the admin form with updated fieldsets.
|
||||||
|
adminform = admin.helpers.AdminForm(
|
||||||
|
form,
|
||||||
|
fieldsets,
|
||||||
|
# Clear prepopulated fields on a view-only form to avoid a crash.
|
||||||
|
(
|
||||||
|
self.prepopulated_fields
|
||||||
|
if self.has_change_permission(request, kwargs["obj"])
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
readonly_fields=self.readonly_fields,
|
||||||
|
model_admin=self,
|
||||||
|
)
|
||||||
|
media = mark_safe(context["media"] + adminform.media) # noqa: S308
|
||||||
context.update(adminform=adminform, media=media)
|
context.update(adminform=adminform, media=media)
|
||||||
|
|
||||||
return super(BaseEntityAdmin, self).render_change_form(
|
return super().render_change_form(request, context, *args, **kwargs)
|
||||||
request, context, *args, **kwargs
|
|
||||||
)
|
def _get_eav_fields(self, instance) -> list[str]:
|
||||||
|
"""Retrieves a list of EAV field slugs for the given instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance: The model instance for which EAV fields are determined.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of strings representing the slugs of EAV fields.
|
||||||
|
"""
|
||||||
|
entity = getattr(instance, instance._eav_config_cls.eav_attr) # noqa: SLF001
|
||||||
|
return list(entity.get_all_attributes().values_list("slug", flat=True))
|
||||||
|
|
||||||
|
def _get_eav_fieldset(self, eav_fields) -> _FIELDSET_TYPE:
|
||||||
|
"""Constructs an EAV Attributes fieldset for inclusion in admin form fieldsets.
|
||||||
|
|
||||||
|
Generates a list representing a fieldset specifically for Entity-Attribute-Value
|
||||||
|
(EAV) fields, intended to be appended to the admin form's fieldsets
|
||||||
|
configuration. This facilitates the dynamic inclusion of EAV fields within the
|
||||||
|
Django admin interface by creating a designated section for these attributes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
eav_fields (List[str]): A list of slugs representing the EAV fields to be
|
||||||
|
included in the EAV Attributes fieldset.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
self.eav_fieldset_title,
|
||||||
|
{"fields": eav_fields, "description": self.eav_fieldset_description},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class BaseEntityInlineFormSet(BaseInlineFormSet):
|
class BaseEntityInlineFormSet(BaseInlineFormSet):
|
||||||
"""
|
"""
|
||||||
An inline formset that correctly initializes EAV forms.
|
An inline formset that correctly initializes EAV forms.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def add_fields(self, form, index):
|
def add_fields(self, form, index):
|
||||||
if self.instance:
|
if self.instance:
|
||||||
setattr(form.instance, self.fk.name, self.instance)
|
setattr(form.instance, self.fk.name, self.instance)
|
||||||
form._build_dynamic_fields()
|
form._build_dynamic_fields() # noqa: SLF001
|
||||||
super(BaseEntityInlineFormSet, self).add_fields(form, index)
|
|
||||||
|
super().add_fields(form, index)
|
||||||
|
|
||||||
|
|
||||||
class BaseEntityInline(InlineModelAdmin):
|
class BaseEntityInline(InlineModelAdmin):
|
||||||
"""
|
"""
|
||||||
Inline model admin that works correctly with EAV attributes. You should mix
|
Inline model admin that works correctly with EAV attributes. You should mix
|
||||||
in the standard StackedInline or TabularInline classes in order to define
|
in the standard ``StackedInline`` or ``TabularInline`` classes in order to
|
||||||
formset representation, e.g.::
|
define formset representation, e.g.::
|
||||||
|
|
||||||
class ItemInline(BaseEntityInline, StackedInline):
|
class ItemInline(BaseEntityInline, StackedInline):
|
||||||
model = Item
|
model = Item
|
||||||
form = forms.ItemForm
|
form = forms.ItemForm
|
||||||
|
|
||||||
.. warning: TabularInline does *not* work out of the box. There is,
|
.. warning:: ``TabularInline`` does *not* work out of the box. There is,
|
||||||
however, a patched template `admin/edit_inline/tabular.html` bundled
|
however, a patched template ``admin/edit_inline/tabular.html`` bundled
|
||||||
with EAV-Django. You can copy or symlink the `admin` directory to your
|
with EAV-Django. You can copy or symlink the ``admin`` directory to
|
||||||
templates search path (see Django documentation).
|
your templates search path (see Django documentation).
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
formset = BaseEntityInlineFormSet
|
formset = BaseEntityInlineFormSet
|
||||||
|
|
||||||
def get_fieldsets(self, request, obj=None):
|
def get_fieldsets(self, request, obj=None):
|
||||||
|
|
@ -72,15 +154,15 @@ class BaseEntityInline(InlineModelAdmin):
|
||||||
instance = self.model(**kw)
|
instance = self.model(**kw)
|
||||||
form = formset.form(request.POST, instance=instance)
|
form = formset.form(request.POST, instance=instance)
|
||||||
|
|
||||||
return [(None, {'fields': form.fields.keys()})]
|
return [(None, {"fields": form.fields.keys()})]
|
||||||
|
|
||||||
|
|
||||||
class AttributeAdmin(ModelAdmin):
|
class AttributeAdmin(ModelAdmin):
|
||||||
list_display = ('name', 'slug', 'datatype', 'description')
|
list_display = ("name", "slug", "datatype", "description")
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields: ClassVar[dict[str, Sequence[str]]] = {"slug": ("name",)}
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Attribute, AttributeAdmin)
|
admin.site.register(Attribute, AttributeAdmin)
|
||||||
admin.site.register(Value)
|
|
||||||
admin.site.register(EnumValue)
|
admin.site.register(EnumValue)
|
||||||
admin.site.register(EnumGroup)
|
admin.site.register(EnumGroup)
|
||||||
|
admin.site.register(Value)
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
'''
|
"""
|
||||||
Decorators.
|
|
||||||
|
|
||||||
This module contains pure wrapper functions used as decorators.
|
This module contains pure wrapper functions used as decorators.
|
||||||
Functions in this module should be simple and not involve complex logic.
|
Functions in this module should be simple and not involve complex logic.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
|
|
||||||
def register_eav(**kwargs):
|
def register_eav(**kwargs):
|
||||||
'''
|
"""
|
||||||
Registers the given model(s) classes and wrapped Model class with
|
Registers the given model(s) classes and wrapped ``Model`` class with
|
||||||
django-eav::
|
Django EAV 2::
|
||||||
|
|
||||||
@register_eav
|
@register_eav
|
||||||
class Author(models.Model):
|
class Author(models.Model):
|
||||||
pass
|
pass
|
||||||
'''
|
"""
|
||||||
from . import register
|
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
|
|
||||||
|
from eav import register
|
||||||
|
|
||||||
def _model_eav_wrapper(model_class):
|
def _model_eav_wrapper(model_class):
|
||||||
if not issubclass(model_class, Model):
|
if not issubclass(model_class, Model):
|
||||||
raise ValueError('Wrapped class must subclass Model.')
|
raise TypeError("Wrapped class must subclass Model.")
|
||||||
register(model_class, **kwargs)
|
register(model_class, **kwargs)
|
||||||
return model_class
|
return model_class
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
class IllegalAssignmentException(Exception):
|
class IllegalAssignmentException(Exception): # noqa: N818
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
118
eav/fields.py
118
eav/fields.py
|
|
@ -1,67 +1,83 @@
|
||||||
'''
|
|
||||||
Fields.
|
|
||||||
|
|
||||||
Contains two custom fields:
|
|
||||||
* :class:`EavSlugField`
|
|
||||||
* :class:`EavDatatypeField`
|
|
||||||
'''
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from eav.forms import CSVFormField
|
||||||
class EavSlugField(models.SlugField):
|
|
||||||
'''
|
|
||||||
The slug field used by :class:`~eav.models.Attribute`
|
|
||||||
'''
|
|
||||||
|
|
||||||
def validate(self, value, instance):
|
|
||||||
'''
|
|
||||||
Slugs are used to convert the Python attribute name to a database
|
|
||||||
lookup and vice versa. We need it to be a valid Python identifier.
|
|
||||||
We don't want it to start with a '_', underscore will be used
|
|
||||||
var variables we don't want to be saved in db.
|
|
||||||
'''
|
|
||||||
super(EavSlugField, self).validate(value, instance)
|
|
||||||
slug_regex = r'[a-z][a-z0-9_]*'
|
|
||||||
if not re.match(slug_regex, value):
|
|
||||||
raise ValidationError(_(u"Must be all lower case, " \
|
|
||||||
u"start with a letter, and contain " \
|
|
||||||
u"only letters, numbers, or underscores."))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create_slug_from_name(name):
|
|
||||||
'''
|
|
||||||
Creates a slug based on the name
|
|
||||||
'''
|
|
||||||
name = name.strip().lower()
|
|
||||||
|
|
||||||
# Change spaces to underscores
|
|
||||||
name = '_'.join(name.split())
|
|
||||||
|
|
||||||
# Remove non alphanumeric characters
|
|
||||||
return re.sub('[^\w]', '', name)
|
|
||||||
|
|
||||||
|
|
||||||
class EavDatatypeField(models.CharField):
|
class EavDatatypeField(models.CharField):
|
||||||
'''
|
"""
|
||||||
The datatype field used by :class:`~eav.models.Attribute`
|
The datatype field used by :class:`~eav.models.Attribute`.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
def validate(self, value, instance):
|
def validate(self, value, instance):
|
||||||
'''
|
"""
|
||||||
Raise ``ValidationError`` if they try to change the datatype of an
|
Raise ``ValidationError`` if they try to change the datatype of an
|
||||||
:class:`~eav.models.Attribute` that is already used by
|
:class:`~eav.models.Attribute` that is already used by
|
||||||
:class:`~eav.models.Value` objects.
|
:class:`~eav.models.Value` objects.
|
||||||
'''
|
"""
|
||||||
super(EavDatatypeField, self).validate(value, instance)
|
super().validate(value, instance)
|
||||||
|
|
||||||
if not instance.pk:
|
if not instance.pk:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# added
|
||||||
|
if not type(instance).objects.filter(pk=instance.pk).exists():
|
||||||
|
return
|
||||||
|
|
||||||
if type(instance).objects.get(pk=instance.pk).datatype == instance.datatype:
|
if type(instance).objects.get(pk=instance.pk).datatype == instance.datatype:
|
||||||
return
|
return
|
||||||
|
|
||||||
if instance.value_set.count():
|
if instance.value_set.count():
|
||||||
raise ValidationError(_(u"You cannot change the datatype of an "
|
raise ValidationError(
|
||||||
u"attribute that is already in use."))
|
_(
|
||||||
|
"You cannot change the datatype of an "
|
||||||
|
+ "attribute that is already in use.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CSVField(models.TextField): # (models.Field):
|
||||||
|
description = _("A Comma-Separated-Value field.")
|
||||||
|
default_separator = ";"
|
||||||
|
|
||||||
|
def __init__(self, separator=";", *args, **kwargs):
|
||||||
|
self.separator = separator
|
||||||
|
kwargs.setdefault("default", "")
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def deconstruct(self):
|
||||||
|
name, path, args, kwargs = super().deconstruct()
|
||||||
|
if self.separator != self.default_separator:
|
||||||
|
kwargs["separator"] = self.separator
|
||||||
|
return name, path, args, kwargs
|
||||||
|
|
||||||
|
def formfield(self, **kwargs):
|
||||||
|
defaults = {"form_class": CSVFormField}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return super().formfield(**defaults)
|
||||||
|
|
||||||
|
def from_db_value(self, value, expression, connection):
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
return value.split(self.separator)
|
||||||
|
|
||||||
|
def to_python(self, value):
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
return value.split(self.separator)
|
||||||
|
|
||||||
|
def get_prep_value(self, value):
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if isinstance(value, list):
|
||||||
|
return self.separator.join(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def value_to_string(self, obj):
|
||||||
|
value = self.value_from_object(obj)
|
||||||
|
return self.get_prep_value(value)
|
||||||
|
|
|
||||||
142
eav/forms.py
142
eav/forms.py
|
|
@ -1,98 +1,152 @@
|
||||||
'''Forms. This module contains forms used for admin integration.'''
|
"""This module contains forms used for admin integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
from django.contrib.admin.widgets import AdminSplitDateTime
|
from django.contrib.admin.widgets import AdminSplitDateTime
|
||||||
from django.forms import (BooleanField, CharField, ChoiceField, DateTimeField,
|
from django.core.exceptions import ValidationError
|
||||||
FloatField, IntegerField, ModelForm)
|
from django.forms import (
|
||||||
from django.utils.translation import ugettext_lazy as _
|
BooleanField,
|
||||||
|
CharField,
|
||||||
|
ChoiceField,
|
||||||
|
Field,
|
||||||
|
FloatField,
|
||||||
|
IntegerField,
|
||||||
|
JSONField,
|
||||||
|
ModelForm,
|
||||||
|
SplitDateTimeField,
|
||||||
|
)
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from eav.widgets import CSVWidget
|
||||||
|
|
||||||
|
|
||||||
|
class CSVFormField(Field):
|
||||||
|
message = _("Enter comma-separated-values. eg: one;two;three.")
|
||||||
|
code = "invalid"
|
||||||
|
widget = CSVWidget
|
||||||
|
default_separator = ";"
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs.pop("max_length", None)
|
||||||
|
self.separator = kwargs.pop("separator", self.default_separator)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def to_python(self, value):
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
return [v.strip() for v in value.split(self.separator) if v]
|
||||||
|
|
||||||
|
def validate(self, field_value):
|
||||||
|
super().validate(field_value)
|
||||||
|
|
||||||
|
if not isinstance(field_value, list):
|
||||||
|
raise ValidationError(self.message, code=self.code)
|
||||||
|
|
||||||
|
|
||||||
class BaseDynamicEntityForm(ModelForm):
|
class BaseDynamicEntityForm(ModelForm):
|
||||||
'''
|
"""
|
||||||
ModelForm for entity with support for EAV attributes. Form fields are
|
``ModelForm`` for entity with support for EAV attributes. Form fields are
|
||||||
created on the fly depending on Schema defined for given entity instance.
|
created on the fly depending on schema defined for given entity instance.
|
||||||
If no schema is defined (i.e. the entity instance has not been saved yet),
|
If no schema is defined (i.e. the entity instance has not been saved yet),
|
||||||
only static fields are used. However, on form validation the schema will be
|
only static fields are used. However, on form validation the schema will be
|
||||||
retrieved and EAV fields dynamically added to the form, so when the
|
retrieved and EAV fields dynamically added to the form, so when the
|
||||||
validation is actually done, all EAV fields are present in it (unless
|
validation is actually done, all EAV fields are present in it (unless
|
||||||
Rubric is not defined).
|
Rubric is not defined).
|
||||||
'''
|
|
||||||
FIELD_CLASSES = {
|
Mapping between attribute types and field classes is as follows:
|
||||||
'text': CharField,
|
|
||||||
'float': FloatField,
|
===== =============
|
||||||
'int': IntegerField,
|
Type Field
|
||||||
'date': DateTimeField,
|
===== =============
|
||||||
'bool': BooleanField,
|
text CharField
|
||||||
'enum': ChoiceField,
|
float IntegerField
|
||||||
|
int DateTimeField
|
||||||
|
date SplitDateTimeField
|
||||||
|
bool BooleanField
|
||||||
|
enum ChoiceField
|
||||||
|
json JSONField
|
||||||
|
csv CSVField
|
||||||
|
===== =============
|
||||||
|
"""
|
||||||
|
|
||||||
|
FIELD_CLASSES: ClassVar[dict[str, Field]] = {
|
||||||
|
"text": CharField,
|
||||||
|
"float": FloatField,
|
||||||
|
"int": IntegerField,
|
||||||
|
"date": SplitDateTimeField,
|
||||||
|
"bool": BooleanField,
|
||||||
|
"enum": ChoiceField,
|
||||||
|
"json": JSONField,
|
||||||
|
"csv": CSVFormField,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
super(BaseDynamicEntityForm, self).__init__(data, *args, **kwargs)
|
super().__init__(data, *args, **kwargs)
|
||||||
config_cls = self.instance._eav_config_cls
|
config_cls = self.instance._eav_config_cls # noqa: SLF001
|
||||||
self.entity = getattr(self.instance, config_cls.eav_attr)
|
self.entity = getattr(self.instance, config_cls.eav_attr)
|
||||||
self._build_dynamic_fields()
|
self._build_dynamic_fields()
|
||||||
|
|
||||||
def _build_dynamic_fields(self):
|
def _build_dynamic_fields(self):
|
||||||
# reset form fields
|
# Reset form fields.
|
||||||
self.fields = deepcopy(self.base_fields)
|
self.fields = deepcopy(self.base_fields)
|
||||||
|
|
||||||
for attribute in self.entity.get_all_attributes():
|
for attribute in self.entity.get_all_attributes():
|
||||||
value = getattr(self.entity, attribute.slug)
|
value = getattr(self.entity, attribute.slug)
|
||||||
|
|
||||||
defaults = {
|
defaults = {
|
||||||
'label': attribute.name.capitalize(),
|
"label": attribute.name.capitalize(),
|
||||||
'required': attribute.required,
|
"required": attribute.required,
|
||||||
'help_text': attribute.help_text,
|
"help_text": attribute.help_text,
|
||||||
'validators': attribute.get_validators(),
|
"validators": attribute.get_validators(),
|
||||||
}
|
}
|
||||||
|
|
||||||
datatype = attribute.datatype
|
datatype = attribute.datatype
|
||||||
|
|
||||||
if datatype == attribute.TYPE_ENUM:
|
if datatype == attribute.TYPE_ENUM:
|
||||||
enums = attribute.get_choices() \
|
values = attribute.get_choices().values_list("id", "value")
|
||||||
.values_list('id', 'value')
|
choices = [("", ""), ("-----", "-----"), *list(values)]
|
||||||
|
defaults.update({"choices": choices})
|
||||||
|
|
||||||
choices = [('', '-----')] + list(enums)
|
|
||||||
|
|
||||||
defaults.update({'choices': choices})
|
|
||||||
if value:
|
if value:
|
||||||
defaults.update({'initial': value.pk})
|
defaults.update({"initial": value.pk})
|
||||||
|
|
||||||
elif datatype == attribute.TYPE_DATE:
|
elif datatype == attribute.TYPE_DATE:
|
||||||
defaults.update({'widget': AdminSplitDateTime})
|
defaults.update({"widget": AdminSplitDateTime})
|
||||||
elif datatype == attribute.TYPE_OBJECT:
|
elif datatype == attribute.TYPE_OBJECT:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
MappedField = self.FIELD_CLASSES[datatype]
|
MappedField = self.FIELD_CLASSES[datatype] # noqa: N806
|
||||||
self.fields[attribute.slug] = MappedField(**defaults)
|
self.fields[attribute.slug] = MappedField(**defaults)
|
||||||
|
|
||||||
# fill initial data (if attribute was already defined)
|
# Fill initial data (if attribute was already defined).
|
||||||
if value and not datatype == attribute.TYPE_ENUM: #enum done above
|
if value and datatype != attribute.TYPE_ENUM:
|
||||||
self.initial[attribute.slug] = value
|
self.initial[attribute.slug] = value
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, *, commit=True):
|
||||||
"""
|
"""
|
||||||
Saves this ``form``'s cleaned_data into model instance
|
Saves this ``form``'s cleaned_data into model instance
|
||||||
``self.instance`` and related EAV attributes.
|
``self.instance`` and related EAV attributes. Returns ``instance``.
|
||||||
|
|
||||||
Returns ``instance``.
|
|
||||||
"""
|
"""
|
||||||
if self.errors:
|
if self.errors:
|
||||||
raise ValueError(_(u"The %s could not be saved because the data"
|
raise ValueError(
|
||||||
u"didn't validate.") % \
|
_(
|
||||||
self.instance._meta.object_name)
|
"The %s could not be saved because the data didn't validate.",
|
||||||
|
)
|
||||||
|
% self.instance._meta.object_name, # noqa: SLF001
|
||||||
|
)
|
||||||
|
|
||||||
# Create entity instance, don't save yet.
|
# Create entity instance, don't save yet.
|
||||||
instance = super(BaseDynamicEntityForm, self).save(commit=False)
|
instance = super().save(commit=False)
|
||||||
|
|
||||||
# Assign attributes.
|
# Assign attributes.
|
||||||
for attribute in self.entity.get_all_attributes():
|
for attribute in self.entity.get_all_attributes():
|
||||||
value = self.cleaned_data.get(attribute.slug)
|
value = self.cleaned_data.get(attribute.slug)
|
||||||
|
|
||||||
if attribute.datatype == attribute.TYPE_ENUM:
|
if attribute.datatype == attribute.TYPE_ENUM:
|
||||||
if value:
|
value = attribute.enum_group.values.get(pk=value) if value else None
|
||||||
value = attribute.enum_group.enums.get(pk=value)
|
|
||||||
else:
|
|
||||||
value = None
|
|
||||||
|
|
||||||
setattr(self.entity, attribute.slug, value)
|
setattr(self.entity, attribute.slug, value)
|
||||||
|
|
||||||
|
|
|
||||||
278
eav/locale/id/LC_MESSAGES/django.po
Normal file
278
eav/locale/id/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
# Indonesian translation for django-eav2
|
||||||
|
# Copyright (C) 2023
|
||||||
|
# This file is distributed under the same license as the django-eav2 package.
|
||||||
|
# Kira <kiraware@github.com>, 2023.
|
||||||
|
#
|
||||||
|
#, fuzzy
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: django-eav2 1.3.1\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2023-06-29 16:43+0800\n"
|
||||||
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
|
"Last-Translator: Kira <kiraware@github.com>, 2023\n"
|
||||||
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
|
"Language: id\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||||
|
#: .\eav\fields.py:30
|
||||||
|
msgid "You cannot change the datatype of an attribute that is already in use."
|
||||||
|
msgstr "Anda tidak dapat mengubah tipe data atribut yang sudah digunakan."
|
||||||
|
|
||||||
|
#: .\eav\fields.py:36
|
||||||
|
msgid "A Comma-Separated-Value field."
|
||||||
|
msgstr "Bidang Nilai-yang-Dipisahkan-Koma."
|
||||||
|
|
||||||
|
#: .\eav\forms.py:28
|
||||||
|
msgid "Enter comma-separated-values. eg: one;two;three."
|
||||||
|
msgstr "Masukkan nilai-yang-dipisahkan-koma. misalnya: satu;dua;tiga."
|
||||||
|
|
||||||
|
#: .\eav\forms.py:138
|
||||||
|
#, python-format
|
||||||
|
msgid "The %s could not be saved because the datadidn't validate."
|
||||||
|
msgstr "%s tidak dapat disimpan karena datanya tidak tervalidasi."
|
||||||
|
|
||||||
|
#: .\eav\models.py:77
|
||||||
|
msgid "EnumValue"
|
||||||
|
msgstr "EnumValue"
|
||||||
|
|
||||||
|
#: .\eav\models.py:78
|
||||||
|
msgid "EnumValues"
|
||||||
|
msgstr "EnumValues"
|
||||||
|
|
||||||
|
#: .\eav\models.py:81 .\eav\models.py:439
|
||||||
|
msgid "Value"
|
||||||
|
msgstr "Nilai"
|
||||||
|
|
||||||
|
#: .\eav\models.py:106
|
||||||
|
msgid "EnumGroup"
|
||||||
|
msgstr "EnumGroup"
|
||||||
|
|
||||||
|
#: .\eav\models.py:107
|
||||||
|
msgid "EnumGroups"
|
||||||
|
msgstr "EnumGroups"
|
||||||
|
|
||||||
|
#: .\eav\models.py:112 .\eav\models.py:218
|
||||||
|
msgid "Name"
|
||||||
|
msgstr "Nama"
|
||||||
|
|
||||||
|
#: .\eav\models.py:116
|
||||||
|
msgid "Enum group"
|
||||||
|
msgstr "Grup enum"
|
||||||
|
|
||||||
|
#: .\eav\models.py:182 .\eav\models.py:447
|
||||||
|
msgid "Attribute"
|
||||||
|
msgstr "Atribut"
|
||||||
|
|
||||||
|
#: .\eav\models.py:183
|
||||||
|
msgid "Attributes"
|
||||||
|
msgstr "Atribut"
|
||||||
|
|
||||||
|
#: .\eav\models.py:196
|
||||||
|
msgid "Text"
|
||||||
|
msgstr "Teks"
|
||||||
|
|
||||||
|
#: .\eav\models.py:197
|
||||||
|
msgid "Date"
|
||||||
|
msgstr "Tanggal"
|
||||||
|
|
||||||
|
#: .\eav\models.py:198
|
||||||
|
msgid "Float"
|
||||||
|
msgstr "Bilangan desimal"
|
||||||
|
|
||||||
|
#: .\eav\models.py:199
|
||||||
|
msgid "Integer"
|
||||||
|
msgstr "Bilangan bulat"
|
||||||
|
|
||||||
|
#: .\eav\models.py:200
|
||||||
|
msgid "True / False"
|
||||||
|
msgstr "Benar / Salah"
|
||||||
|
|
||||||
|
#: .\eav\models.py:201
|
||||||
|
msgid "Django Object"
|
||||||
|
msgstr "Objek Django"
|
||||||
|
|
||||||
|
#: .\eav\models.py:202
|
||||||
|
msgid "Multiple Choice"
|
||||||
|
msgstr "Pilihan Ganda"
|
||||||
|
|
||||||
|
#: .\eav\models.py:203
|
||||||
|
msgid "JSON Object"
|
||||||
|
msgstr "Objek JSON"
|
||||||
|
|
||||||
|
#: .\eav\models.py:204
|
||||||
|
msgid "Comma-Separated-Value"
|
||||||
|
msgstr "Nilai-yang-Dipisahkan-Koma"
|
||||||
|
|
||||||
|
#: .\eav\models.py:212
|
||||||
|
msgid "Data Type"
|
||||||
|
msgstr "Tipe Data"
|
||||||
|
|
||||||
|
#: .\eav\models.py:217
|
||||||
|
msgid "User-friendly attribute name"
|
||||||
|
msgstr "Nama atribut yang ramah pengguna"
|
||||||
|
|
||||||
|
#: .\eav\models.py:230
|
||||||
|
msgid "Short unique attribute label"
|
||||||
|
msgstr "Label atribut unik yang pendek"
|
||||||
|
|
||||||
|
#: .\eav\models.py:231
|
||||||
|
msgid "Slug"
|
||||||
|
msgstr "Slug"
|
||||||
|
|
||||||
|
#: .\eav\models.py:242
|
||||||
|
msgid "Required"
|
||||||
|
msgstr "Diperlukan"
|
||||||
|
|
||||||
|
#: .\eav\models.py:248
|
||||||
|
msgid "Entity content type"
|
||||||
|
msgstr "Jenis konten entitas"
|
||||||
|
|
||||||
|
#: .\eav\models.py:262
|
||||||
|
msgid "Choice Group"
|
||||||
|
msgstr "Grup Pilihan"
|
||||||
|
|
||||||
|
#: .\eav\models.py:269
|
||||||
|
msgid "Short description"
|
||||||
|
msgstr "Deskripsi singkat"
|
||||||
|
|
||||||
|
#: .\eav\models.py:270
|
||||||
|
msgid "Description"
|
||||||
|
msgstr "Deskripsi"
|
||||||
|
|
||||||
|
#: .\eav\models.py:277
|
||||||
|
msgid "Display order"
|
||||||
|
msgstr "Urutan tampilan"
|
||||||
|
|
||||||
|
#: .\eav\models.py:282 .\eav\models.py:490
|
||||||
|
msgid "Modified"
|
||||||
|
msgstr "Dimodifikasi"
|
||||||
|
|
||||||
|
#: .\eav\models.py:288 .\eav\models.py:485
|
||||||
|
msgid "Created"
|
||||||
|
msgstr "Dibuat"
|
||||||
|
|
||||||
|
#: .\eav\models.py:332
|
||||||
|
#, python-format
|
||||||
|
msgid "%(val)s is not a valid choice for %(attr)s"
|
||||||
|
msgstr "%(val)s bukan pilihan yang valid untuk %(attr)s"
|
||||||
|
|
||||||
|
#: .\eav\models.py:355
|
||||||
|
msgid "You must set the choice group for multiple choice attributes"
|
||||||
|
msgstr "Anda harus mengatur grup pilihan untuk atribut pilihan ganda"
|
||||||
|
|
||||||
|
#: .\eav\models.py:360
|
||||||
|
msgid "You can only assign a choice group to multiple choice attributes"
|
||||||
|
msgstr "Anda hanya dapat menetapkan grup pilihan ke atribut pilihan ganda"
|
||||||
|
|
||||||
|
#: .\eav\models.py:440
|
||||||
|
msgid "Values"
|
||||||
|
msgstr "Nilai"
|
||||||
|
|
||||||
|
#: .\eav\models.py:456
|
||||||
|
msgid "Entity id"
|
||||||
|
msgstr "id entitas"
|
||||||
|
|
||||||
|
#: .\eav\models.py:462
|
||||||
|
msgid "Entity uuid"
|
||||||
|
msgstr "uuid entitas"
|
||||||
|
|
||||||
|
#: .\eav\models.py:469
|
||||||
|
msgid "Entity ct"
|
||||||
|
msgstr "Entitas ct"
|
||||||
|
|
||||||
|
#: .\eav\models.py:497
|
||||||
|
msgid "Value bool"
|
||||||
|
msgstr "Nilai bool"
|
||||||
|
|
||||||
|
#: .\eav\models.py:502
|
||||||
|
msgid "Value CSV"
|
||||||
|
msgstr "Nilai CSV"
|
||||||
|
|
||||||
|
#: .\eav\models.py:507
|
||||||
|
msgid "Value date"
|
||||||
|
msgstr "Nilai tanggal"
|
||||||
|
|
||||||
|
#: .\eav\models.py:512
|
||||||
|
msgid "Value float"
|
||||||
|
msgstr "Nilai float"
|
||||||
|
|
||||||
|
#: .\eav\models.py:517
|
||||||
|
msgid "Value int"
|
||||||
|
msgstr "Nilai int"
|
||||||
|
|
||||||
|
#: .\eav\models.py:522
|
||||||
|
msgid "Value text"
|
||||||
|
msgstr "Nilai teks"
|
||||||
|
|
||||||
|
#: .\eav\models.py:530
|
||||||
|
msgid "Value JSON"
|
||||||
|
msgstr "Nilai JSON"
|
||||||
|
|
||||||
|
#: .\eav\models.py:539
|
||||||
|
msgid "Value enum"
|
||||||
|
msgstr "Nilai enum"
|
||||||
|
|
||||||
|
#: .\eav\models.py:546
|
||||||
|
msgid "Generic value id"
|
||||||
|
msgstr "Id nilai generik"
|
||||||
|
|
||||||
|
#: .\eav\models.py:555
|
||||||
|
msgid "Generic value content type"
|
||||||
|
msgstr "Jenis konten nilai generik"
|
||||||
|
|
||||||
|
#: .\eav\models.py:653
|
||||||
|
#, python-format
|
||||||
|
msgid "%(obj)s has no EAV attribute named %(attr)s"
|
||||||
|
msgstr "%(obj)s tidak memiliki atribut EAV bernama %(attr)s"
|
||||||
|
|
||||||
|
#: .\eav\models.py:725
|
||||||
|
msgid "{} EAV field cannot be blank"
|
||||||
|
msgstr "{} Bidang EAV tidak boleh kosong"
|
||||||
|
|
||||||
|
#: .\eav\models.py:732
|
||||||
|
#, python-format
|
||||||
|
msgid "%(attr)s EAV field %(err)s"
|
||||||
|
msgstr "%(attr)s bidang EAV %(err)s"
|
||||||
|
|
||||||
|
#: .\eav\validators.py:26
|
||||||
|
msgid "Must be str or unicode"
|
||||||
|
msgstr "Harus berupa str atau unicode"
|
||||||
|
|
||||||
|
#: .\eav\validators.py:36
|
||||||
|
msgid "Must be a float"
|
||||||
|
msgstr "Harus berupa float"
|
||||||
|
|
||||||
|
#: .\eav\validators.py:46
|
||||||
|
msgid "Must be an integer"
|
||||||
|
msgstr "Harus berupa integer"
|
||||||
|
|
||||||
|
#: .\eav\validators.py:57
|
||||||
|
msgid "Must be a date or datetime"
|
||||||
|
msgstr "Harus berupa date atau datetime"
|
||||||
|
|
||||||
|
#: .\eav\validators.py:65
|
||||||
|
msgid "Must be a boolean"
|
||||||
|
msgstr "Harus berupa boolean"
|
||||||
|
|
||||||
|
#: .\eav\validators.py:74
|
||||||
|
msgid "Must be a django model object instance"
|
||||||
|
msgstr "Harus berupa instance objek model django"
|
||||||
|
|
||||||
|
#: .\eav\validators.py:77
|
||||||
|
msgid "Model has not been saved yet"
|
||||||
|
msgstr "Model belum disimpan"
|
||||||
|
|
||||||
|
#: .\eav\validators.py:88
|
||||||
|
msgid "EnumValue has not been saved yet"
|
||||||
|
msgstr "EnumValue belum disimpan"
|
||||||
|
|
||||||
|
#: .\eav\validators.py:99 .\eav\validators.py:101
|
||||||
|
msgid "Must be a JSON Serializable object"
|
||||||
|
msgstr "Harus berupa objek JSON yang dapat diserialisasikan"
|
||||||
|
|
||||||
|
#: .\eav\validators.py:111
|
||||||
|
msgid "Must be Comma-Separated-Value."
|
||||||
|
msgstr "Harus berupa Nilai-ang-Dipisahkan-Koma."
|
||||||
BIN
eav/locale/ru/LC_MESSAGES/django.mo
Normal file
BIN
eav/locale/ru/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
194
eav/locale/ru/LC_MESSAGES/django.po
Normal file
194
eav/locale/ru/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
# Russian translation for django-eav2
|
||||||
|
# Copyright (C) 2019
|
||||||
|
# This file is distributed under the same license as the django-eav2 package.
|
||||||
|
# Evgeny Pisemsky <evgeny@pisemsky.com>, 2019.
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: \n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2019-02-15 01:10+0300\n"
|
||||||
|
"PO-Revision-Date: 2019-02-15 02:13+0300\n"
|
||||||
|
"Language: ru\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
|
||||||
|
"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||||
|
"Last-Translator: Evgeny Pisemsky <evgeny@pisemsky.com>\n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"X-Generator: Poedit 1.8.11\n"
|
||||||
|
|
||||||
|
#: fields.py:25
|
||||||
|
msgid ""
|
||||||
|
"Must be all lower case, start with a letter, and contain only letters, "
|
||||||
|
"numbers, or underscores."
|
||||||
|
msgstr ""
|
||||||
|
"Должно быть в нижнем регистре, начинаться с буквы и содержать только буквы, "
|
||||||
|
"числа или подчёркивания."
|
||||||
|
|
||||||
|
#: fields.py:62
|
||||||
|
msgid "You cannot change the datatype of an attribute that is already in use."
|
||||||
|
msgstr "Вы не можете изменить тип данных атрибута, который уже используется."
|
||||||
|
|
||||||
|
#: forms.py:91
|
||||||
|
#, python-format
|
||||||
|
msgid "The %s could not be saved because the datadidn't validate."
|
||||||
|
msgstr "%s не может быть сохранено, потому что данные не корректны."
|
||||||
|
|
||||||
|
#: models.py:54
|
||||||
|
msgid "Value"
|
||||||
|
msgstr "Значение"
|
||||||
|
|
||||||
|
#: models.py:68 models.py:153
|
||||||
|
msgid "Name"
|
||||||
|
msgstr "Название"
|
||||||
|
|
||||||
|
#: models.py:69
|
||||||
|
msgid "Enum group"
|
||||||
|
msgstr "Группа выбора"
|
||||||
|
|
||||||
|
#: models.py:135
|
||||||
|
msgid "Text"
|
||||||
|
msgstr "Текст"
|
||||||
|
|
||||||
|
#: models.py:136
|
||||||
|
msgid "Date"
|
||||||
|
msgstr "Дата"
|
||||||
|
|
||||||
|
#: models.py:137
|
||||||
|
msgid "Float"
|
||||||
|
msgstr "Число с плавающей запятой"
|
||||||
|
|
||||||
|
#: models.py:138
|
||||||
|
msgid "Integer"
|
||||||
|
msgstr "Целое число"
|
||||||
|
|
||||||
|
#: models.py:139
|
||||||
|
msgid "True / False"
|
||||||
|
msgstr "Правда / Ложь"
|
||||||
|
|
||||||
|
#: models.py:140
|
||||||
|
msgid "Django Object"
|
||||||
|
msgstr "Объект Django"
|
||||||
|
|
||||||
|
#: models.py:141
|
||||||
|
msgid "Multiple Choice"
|
||||||
|
msgstr "Множественный выбор"
|
||||||
|
|
||||||
|
#: models.py:147
|
||||||
|
msgid "Data Type"
|
||||||
|
msgstr "Тип данных"
|
||||||
|
|
||||||
|
#: models.py:155
|
||||||
|
msgid "User-friendly attribute name"
|
||||||
|
msgstr "Понятное пользователю название атрибута"
|
||||||
|
|
||||||
|
#: models.py:164
|
||||||
|
msgid "Slug"
|
||||||
|
msgstr "Псевдоним"
|
||||||
|
|
||||||
|
#: models.py:168
|
||||||
|
msgid "Short unique attribute label"
|
||||||
|
msgstr "Короткая уникальная метка атрибута"
|
||||||
|
|
||||||
|
#: models.py:177
|
||||||
|
msgid "Required"
|
||||||
|
msgstr "Обязательно"
|
||||||
|
|
||||||
|
#: models.py:181
|
||||||
|
msgid "Choice Group"
|
||||||
|
msgstr "Группа выбора"
|
||||||
|
|
||||||
|
#: models.py:188
|
||||||
|
msgid "Description"
|
||||||
|
msgstr "Описание"
|
||||||
|
|
||||||
|
#: models.py:192
|
||||||
|
msgid "Short description"
|
||||||
|
msgstr "Краткое описание"
|
||||||
|
|
||||||
|
#: models.py:198
|
||||||
|
msgid "Display order"
|
||||||
|
msgstr "Порядок отображения"
|
||||||
|
|
||||||
|
#: models.py:203 models.py:392
|
||||||
|
msgid "Modified"
|
||||||
|
msgstr "Изменено"
|
||||||
|
|
||||||
|
#: models.py:208 models.py:391
|
||||||
|
msgid "Created"
|
||||||
|
msgstr "Создано"
|
||||||
|
|
||||||
|
#: models.py:250
|
||||||
|
#, python-format
|
||||||
|
msgid "%(val)s is not a valid choice for %(attr)s"
|
||||||
|
msgstr "%(val)s не является корректным выбором для %(attr)s"
|
||||||
|
|
||||||
|
#: models.py:273
|
||||||
|
msgid "You must set the choice group for multiple choice attributes"
|
||||||
|
msgstr "Вы должны назначить группу выбора для атрибутов множественного выбора"
|
||||||
|
|
||||||
|
#: models.py:278
|
||||||
|
msgid "You can only assign a choice group to multiple choice attributes"
|
||||||
|
msgstr ""
|
||||||
|
"Вы можете назначить группу выбора только для атрибутов множественного выбора"
|
||||||
|
|
||||||
|
#: models.py:398
|
||||||
|
msgid "Attribute"
|
||||||
|
msgstr "Атрибут"
|
||||||
|
|
||||||
|
#: models.py:416
|
||||||
|
#, python-format
|
||||||
|
msgid "%(enum)s is not a valid choice for %(attr)s"
|
||||||
|
msgstr "%(enum)s не является корректным выбором для %(attr)s"
|
||||||
|
|
||||||
|
#: models.py:492
|
||||||
|
#, python-format
|
||||||
|
msgid "%(obj)s has no EAV attribute named %(attr)s"
|
||||||
|
msgstr "%(obj)s не имеет атрибута EAV с названием %(attr)s"
|
||||||
|
|
||||||
|
#: models.py:557
|
||||||
|
msgid "{} EAV field cannot be blank"
|
||||||
|
msgstr "Поле EAV {} не может быть пустым"
|
||||||
|
|
||||||
|
#: models.py:564
|
||||||
|
#, python-format
|
||||||
|
msgid "%(attr)s EAV field %(err)s"
|
||||||
|
msgstr "Поле EAV %(attr)s %(err)s"
|
||||||
|
|
||||||
|
#: validators.py:25
|
||||||
|
msgid "Must be str or unicode"
|
||||||
|
msgstr "Должно быть строкой или юникодом"
|
||||||
|
|
||||||
|
#: validators.py:35
|
||||||
|
msgid "Must be a float"
|
||||||
|
msgstr "Должно быть числом с плавающей запятой"
|
||||||
|
|
||||||
|
#: validators.py:45
|
||||||
|
msgid "Must be an integer"
|
||||||
|
msgstr "Должно быть целым числом"
|
||||||
|
|
||||||
|
#: validators.py:54
|
||||||
|
msgid "Must be a date or datetime"
|
||||||
|
msgstr "Должно быть датой или датой со временем"
|
||||||
|
|
||||||
|
#: validators.py:62
|
||||||
|
msgid "Must be a boolean"
|
||||||
|
msgstr "Должно быть булевым значением"
|
||||||
|
|
||||||
|
#: validators.py:71
|
||||||
|
msgid "Must be a django model object instance"
|
||||||
|
msgstr "Должно быть экземпляром объекта модели Django"
|
||||||
|
|
||||||
|
#: validators.py:74
|
||||||
|
msgid "Model has not been saved yet"
|
||||||
|
msgstr "Модель ещё не была сохранена"
|
||||||
|
|
||||||
|
#: validators.py:85
|
||||||
|
msgid "Must be an EnumValue model object instance"
|
||||||
|
msgstr "Должно быть экземпляром объекта модели EnumValue"
|
||||||
|
|
||||||
|
#: validators.py:88
|
||||||
|
msgid "EnumValue has not been saved yet"
|
||||||
|
msgstr "EnumValue ещё не было сохранено"
|
||||||
BIN
eav/locale/zh_Hans/LC_MESSAGES/django.mo
Normal file
BIN
eav/locale/zh_Hans/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
279
eav/locale/zh_Hans/LC_MESSAGES/django.po
Normal file
279
eav/locale/zh_Hans/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
# Simplified Chinese translation for django-eav2
|
||||||
|
# Copyright (C) 2023
|
||||||
|
# This file is distributed under the same license as the django-eav2 package.
|
||||||
|
# FIRST 954-Ivory <954ivory@gmail.com>, 2023.
|
||||||
|
#
|
||||||
|
#, fuzzy
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2023-02-07 02:17+0800\n"
|
||||||
|
"PO-Revision-Date: 2023-02-27 16:36+0800\n"
|
||||||
|
"Last-Translator: 954-Ivory <954ivory@gmail.com>\n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"Language: zh-Hans\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||||
|
|
||||||
|
#: .\fields.py:30
|
||||||
|
msgid "You cannot change the datatype of an attribute that is already in use."
|
||||||
|
msgstr "您不能更改已使用属性的数据类型。"
|
||||||
|
|
||||||
|
#: .\fields.py:36
|
||||||
|
msgid "A Comma-Separated-Value field."
|
||||||
|
msgstr "字符分隔值(CSV)字段。"
|
||||||
|
|
||||||
|
#: .\forms.py:28
|
||||||
|
msgid "Enter comma-separated-values. eg: one;two;three."
|
||||||
|
msgstr "输入字符分隔值(CSV)字段,例如:one;two;three。"
|
||||||
|
|
||||||
|
#: .\forms.py:137
|
||||||
|
#, python-format
|
||||||
|
msgid "The %s could not be saved because the datadidn't validate."
|
||||||
|
msgstr "由于数据未验证,无法保存 %s 。"
|
||||||
|
|
||||||
|
#: .\models.py:81
|
||||||
|
msgid "EnumValue"
|
||||||
|
msgstr "枚举值"
|
||||||
|
|
||||||
|
#: .\models.py:82
|
||||||
|
msgid "EnumValues"
|
||||||
|
msgstr "枚举值"
|
||||||
|
|
||||||
|
#: .\models.py:85 .\models.py:443
|
||||||
|
msgid "Value"
|
||||||
|
msgstr "值"
|
||||||
|
|
||||||
|
#: .\models.py:110
|
||||||
|
msgid "EnumGroup"
|
||||||
|
msgstr "枚举组"
|
||||||
|
|
||||||
|
#: .\models.py:111
|
||||||
|
msgid "EnumGroups"
|
||||||
|
msgstr "枚举组"
|
||||||
|
|
||||||
|
#: .\models.py:116 .\models.py:222
|
||||||
|
msgid "Name"
|
||||||
|
msgstr "名称"
|
||||||
|
|
||||||
|
#: .\models.py:120
|
||||||
|
msgid "Enum group"
|
||||||
|
msgstr "枚举组"
|
||||||
|
|
||||||
|
#: .\models.py:186 .\models.py:451
|
||||||
|
msgid "Attribute"
|
||||||
|
msgstr "属性"
|
||||||
|
|
||||||
|
#: .\models.py:187
|
||||||
|
msgid "Attributes"
|
||||||
|
msgstr "属性"
|
||||||
|
|
||||||
|
#: .\models.py:200
|
||||||
|
msgid "Text"
|
||||||
|
msgstr "文本"
|
||||||
|
|
||||||
|
#: .\models.py:201
|
||||||
|
msgid "Date"
|
||||||
|
msgstr "日期"
|
||||||
|
|
||||||
|
#: .\models.py:202
|
||||||
|
msgid "Float"
|
||||||
|
msgstr "浮点数"
|
||||||
|
|
||||||
|
#: .\models.py:203
|
||||||
|
msgid "Integer"
|
||||||
|
msgstr "整数"
|
||||||
|
|
||||||
|
#: .\models.py:204
|
||||||
|
msgid "True / False"
|
||||||
|
msgstr "布尔值"
|
||||||
|
|
||||||
|
#: .\models.py:205
|
||||||
|
msgid "Django Object"
|
||||||
|
msgstr "Django 对象"
|
||||||
|
|
||||||
|
#: .\models.py:206
|
||||||
|
msgid "Multiple Choice"
|
||||||
|
msgstr "多项选择"
|
||||||
|
|
||||||
|
#: .\models.py:207
|
||||||
|
msgid "JSON Object"
|
||||||
|
msgstr "JSON 对象"
|
||||||
|
|
||||||
|
#: .\models.py:208
|
||||||
|
msgid "Comma-Separated-Value"
|
||||||
|
msgstr "字符分隔值(CSV)"
|
||||||
|
|
||||||
|
#: .\models.py:216
|
||||||
|
msgid "Data Type"
|
||||||
|
msgstr "数据类型"
|
||||||
|
|
||||||
|
#: .\models.py:221
|
||||||
|
msgid "User-friendly attribute name"
|
||||||
|
msgstr "面向用户的名称"
|
||||||
|
|
||||||
|
#: .\models.py:234
|
||||||
|
msgid "Short unique attribute label"
|
||||||
|
msgstr "唯一的属性短标识符"
|
||||||
|
|
||||||
|
#: .\models.py:235
|
||||||
|
msgid "Slug"
|
||||||
|
msgstr "短标识符(Slug)"
|
||||||
|
|
||||||
|
#: .\models.py:246
|
||||||
|
msgid "Required"
|
||||||
|
msgstr "必填项"
|
||||||
|
|
||||||
|
#: .\models.py:252
|
||||||
|
msgid "Entity content type"
|
||||||
|
msgstr "实体内容类型"
|
||||||
|
|
||||||
|
#: .\models.py:266
|
||||||
|
msgid "Choice Group"
|
||||||
|
msgstr "选项组"
|
||||||
|
|
||||||
|
#: .\models.py:273
|
||||||
|
msgid "Short description"
|
||||||
|
msgstr "简短描述"
|
||||||
|
|
||||||
|
#: .\models.py:274
|
||||||
|
msgid "Description"
|
||||||
|
msgstr "描述"
|
||||||
|
|
||||||
|
#: .\models.py:281
|
||||||
|
msgid "Display order"
|
||||||
|
msgstr "显示顺序"
|
||||||
|
|
||||||
|
#: .\models.py:286 .\models.py:494
|
||||||
|
msgid "Modified"
|
||||||
|
msgstr "修改"
|
||||||
|
|
||||||
|
#: .\models.py:292 .\models.py:489
|
||||||
|
msgid "Created"
|
||||||
|
msgstr "创建"
|
||||||
|
|
||||||
|
#: .\models.py:336
|
||||||
|
#, python-format
|
||||||
|
msgid "%(val)s is not a valid choice for %(attr)s"
|
||||||
|
msgstr "%(val)s 不是有效的 %(attr)s 选项"
|
||||||
|
|
||||||
|
#: .\models.py:359
|
||||||
|
msgid "You must set the choice group for multiple choice attributes"
|
||||||
|
msgstr "您必须为多项选择属性设置选项组"
|
||||||
|
|
||||||
|
#: .\models.py:364
|
||||||
|
msgid "You can only assign a choice group to multiple choice attributes"
|
||||||
|
msgstr "您只能将选项组分配给多项选择属性"
|
||||||
|
|
||||||
|
#: .\models.py:444
|
||||||
|
msgid "Values"
|
||||||
|
msgstr "值"
|
||||||
|
|
||||||
|
#: .\models.py:460
|
||||||
|
msgid "Entity id"
|
||||||
|
msgstr "实体 ID"
|
||||||
|
|
||||||
|
#: .\models.py:466
|
||||||
|
msgid "Entity uuid"
|
||||||
|
msgstr "实体 UUID"
|
||||||
|
|
||||||
|
#: .\models.py:473
|
||||||
|
msgid "Entity ct"
|
||||||
|
msgstr "实体内容类型"
|
||||||
|
|
||||||
|
#: .\models.py:501
|
||||||
|
msgid "Value bool"
|
||||||
|
msgstr "布尔值"
|
||||||
|
|
||||||
|
#: .\models.py:506
|
||||||
|
msgid "Value CSV"
|
||||||
|
msgstr "字符分隔值(CSV)"
|
||||||
|
|
||||||
|
#: .\models.py:511
|
||||||
|
msgid "Value date"
|
||||||
|
msgstr "日期值"
|
||||||
|
|
||||||
|
#: .\models.py:516
|
||||||
|
msgid "Value float"
|
||||||
|
msgstr "浮点值"
|
||||||
|
|
||||||
|
#: .\models.py:521
|
||||||
|
msgid "Value int"
|
||||||
|
msgstr "整型值"
|
||||||
|
|
||||||
|
#: .\models.py:526
|
||||||
|
msgid "Value text"
|
||||||
|
msgstr "文本值"
|
||||||
|
|
||||||
|
#: .\models.py:534
|
||||||
|
msgid "Value JSON"
|
||||||
|
msgstr "JSON 值"
|
||||||
|
|
||||||
|
#: .\models.py:543
|
||||||
|
msgid "Value enum"
|
||||||
|
msgstr "枚举值"
|
||||||
|
|
||||||
|
#: .\models.py:550
|
||||||
|
msgid "Generic value id"
|
||||||
|
msgstr "通用值 ID"
|
||||||
|
|
||||||
|
#: .\models.py:559
|
||||||
|
msgid "Generic value content type"
|
||||||
|
msgstr "通用值内容类型"
|
||||||
|
|
||||||
|
#: .\models.py:657
|
||||||
|
#, python-format
|
||||||
|
msgid "%(obj)s has no EAV attribute named %(attr)s"
|
||||||
|
msgstr "%(obj)s 中不存在为 %(attr)s 的属性"
|
||||||
|
|
||||||
|
#: .\models.py:729
|
||||||
|
msgid "{} EAV field cannot be blank"
|
||||||
|
msgstr "{} 字段不能为空白(blank)"
|
||||||
|
|
||||||
|
#: .\models.py:736
|
||||||
|
#, python-format
|
||||||
|
msgid "%(attr)s EAV field %(err)s"
|
||||||
|
msgstr "%(attr)s 字段错误:%(err)s"
|
||||||
|
|
||||||
|
#: .\validators.py:26
|
||||||
|
msgid "Must be str or unicode"
|
||||||
|
msgstr "必须是一个 str 或 unicode"
|
||||||
|
|
||||||
|
#: .\validators.py:36
|
||||||
|
msgid "Must be a float"
|
||||||
|
msgstr "必须是一个浮点数"
|
||||||
|
|
||||||
|
#: .\validators.py:46
|
||||||
|
msgid "Must be an integer"
|
||||||
|
msgstr "必须是一个整数"
|
||||||
|
|
||||||
|
#: .\validators.py:57
|
||||||
|
msgid "Must be a date or datetime"
|
||||||
|
msgstr "必须是一个日期(date)或者日期时间(datetime)"
|
||||||
|
|
||||||
|
#: .\validators.py:65
|
||||||
|
msgid "Must be a boolean"
|
||||||
|
msgstr "必须是一个布尔值"
|
||||||
|
|
||||||
|
#: .\validators.py:74
|
||||||
|
msgid "Must be a django model object instance"
|
||||||
|
msgstr "必须是一个 Django Model 对象的实例"
|
||||||
|
|
||||||
|
#: .\validators.py:77
|
||||||
|
msgid "Model has not been saved yet"
|
||||||
|
msgstr "Model 尚未保存"
|
||||||
|
|
||||||
|
#: .\validators.py:88
|
||||||
|
msgid "EnumValue has not been saved yet"
|
||||||
|
msgstr "枚举值尚未保存"
|
||||||
|
|
||||||
|
#: .\validators.py:99 .\validators.py:101
|
||||||
|
msgid "Must be a JSON Serializable object"
|
||||||
|
msgstr "必须是一个 JSON 序列化对象"
|
||||||
|
|
||||||
|
#: .\validators.py:111
|
||||||
|
msgid "Must be Comma-Separated-Value."
|
||||||
|
msgstr "必须是一个字符分隔值(CSV)"
|
||||||
12
eav/logic/entity_pk.py
Normal file
12
eav/logic/entity_pk.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
from django.db.models.fields import UUIDField
|
||||||
|
|
||||||
|
|
||||||
|
def get_entity_pk_type(entity_cls) -> str:
|
||||||
|
"""Returns the entity PK type to use.
|
||||||
|
|
||||||
|
These values map to `models.Value` as potential fields to use to relate
|
||||||
|
to the proper entity via the correct PK type.
|
||||||
|
"""
|
||||||
|
if isinstance(entity_cls._meta.pk, UUIDField): # noqa: SLF001
|
||||||
|
return "entity_uuid"
|
||||||
|
return "entity_id"
|
||||||
97
eav/logic/managers.py
Normal file
97
eav/logic/managers.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class EnumValueManager(models.Manager):
|
||||||
|
"""
|
||||||
|
Custom manager for `EnumValue` model.
|
||||||
|
|
||||||
|
This manager adds utility methods specific to the `EnumValue` model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_by_natural_key(self, value):
|
||||||
|
"""
|
||||||
|
Retrieves an EnumValue instance using its `value` as a natural key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (str): The value of the EnumValue instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EnumValue: The instance matching the provided value.
|
||||||
|
"""
|
||||||
|
return self.get(value=value)
|
||||||
|
|
||||||
|
|
||||||
|
class EnumGroupManager(models.Manager):
|
||||||
|
"""
|
||||||
|
Custom manager for `EnumGroup` model.
|
||||||
|
|
||||||
|
This manager adds utility methods specific to the `EnumGroup` model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_by_natural_key(self, name):
|
||||||
|
"""
|
||||||
|
Retrieves an EnumGroup instance using its `name` as a natural key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): The name of the EnumGroup instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EnumGroup: The instance matching the provided name.
|
||||||
|
"""
|
||||||
|
return self.get(name=name)
|
||||||
|
|
||||||
|
|
||||||
|
class AttributeManager(models.Manager):
|
||||||
|
"""
|
||||||
|
Custom manager for `Attribute` model.
|
||||||
|
|
||||||
|
This manager adds utility methods specific to the `Attribute` model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_by_natural_key(self, name, slug):
|
||||||
|
"""
|
||||||
|
Retrieves an Attribute instance using its `name` and `slug` as natural keys.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): The name of the Attribute instance.
|
||||||
|
slug (str): The slug of the Attribute instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Attribute: The instance matching the provided name and slug.
|
||||||
|
"""
|
||||||
|
return self.get(name=name, slug=slug)
|
||||||
|
|
||||||
|
|
||||||
|
class ValueManager(models.Manager):
|
||||||
|
"""
|
||||||
|
Custom manager for `Value` model.
|
||||||
|
|
||||||
|
This manager adds utility methods specific to the `Value` model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_by_natural_key(self, attribute, entity_id, entity_uuid):
|
||||||
|
"""
|
||||||
|
Retrieve a Value instance using multiple natural keys.
|
||||||
|
|
||||||
|
This method utilizes a combination of an `attribute` (defined by its
|
||||||
|
name and slug), `entity_id`, and `entity_uuid` to retrieve a unique
|
||||||
|
Value instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attribute (tuple): A tuple containing the name and slug of the
|
||||||
|
Attribute instance.
|
||||||
|
entity_id (int): The ID of the associated entity.
|
||||||
|
entity_uuid (str): The UUID of the associated entity.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Value: The instance matching the provided keys.
|
||||||
|
"""
|
||||||
|
from eav.models import Attribute
|
||||||
|
|
||||||
|
attribute = Attribute.objects.get(name=attribute[0], slug=attribute[1])
|
||||||
|
|
||||||
|
return self.get(
|
||||||
|
attribute=attribute,
|
||||||
|
entity_id=entity_id,
|
||||||
|
entity_uuid=entity_uuid,
|
||||||
|
)
|
||||||
44
eav/logic/object_pk.py
Normal file
44
eav/logic/object_pk.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import uuid
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
#: Constants
|
||||||
|
_DEFAULT_CHARFIELD_LEN: int = 40
|
||||||
|
|
||||||
|
_FIELD_MAPPING = {
|
||||||
|
"django.db.models.UUIDField": partial(
|
||||||
|
models.UUIDField,
|
||||||
|
primary_key=True,
|
||||||
|
editable=False,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
),
|
||||||
|
"django.db.models.CharField": partial(
|
||||||
|
models.CharField,
|
||||||
|
primary_key=True,
|
||||||
|
editable=False,
|
||||||
|
max_length=_DEFAULT_CHARFIELD_LEN,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_pk_format() -> models.Field:
|
||||||
|
"""
|
||||||
|
Get the primary key field format based on the Django settings.
|
||||||
|
|
||||||
|
This function returns a field factory function that corresponds to the
|
||||||
|
primary key format specified in Django settings. If the primary key
|
||||||
|
format is not recognized, it defaults to using BigAutoField.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Type[models.Field]: A field factory function that can be used to
|
||||||
|
create the primary key field instance.
|
||||||
|
"""
|
||||||
|
field_factory = _FIELD_MAPPING.get(
|
||||||
|
settings.EAV2_PRIMARY_KEY_FIELD,
|
||||||
|
partial(models.BigAutoField, primary_key=True, editable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create and return the field instance
|
||||||
|
return field_factory()
|
||||||
61
eav/logic/slug.py
Normal file
61
eav/logic/slug.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
SLUGFIELD_MAX_LENGTH: Final = 50
|
||||||
|
|
||||||
|
|
||||||
|
def non_identifier_chars() -> dict[str, str]:
|
||||||
|
"""Generate a mapping of non-identifier characters to their Unicode representations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, str]: A dictionary where keys are special characters and values
|
||||||
|
are their Unicode representations.
|
||||||
|
"""
|
||||||
|
# Start with all printable characters
|
||||||
|
all_chars = string.printable
|
||||||
|
|
||||||
|
# Filter out characters that are valid in Python identifiers
|
||||||
|
special_chars = [
|
||||||
|
char for char in all_chars if not char.isalnum() and char not in ["_", " "]
|
||||||
|
]
|
||||||
|
|
||||||
|
return {char: f"u{ord(char):04x}" for char in special_chars}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_slug(value: str) -> str:
|
||||||
|
"""Generate a valid slug based on the given value.
|
||||||
|
|
||||||
|
This function converts the input value into a Python-identifier-friendly slug.
|
||||||
|
It handles special characters, ensures a valid Python identifier, and truncates
|
||||||
|
the result to fit within the maximum allowed length.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (str): The input string to generate a slug from.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: A valid Python identifier slug, with a maximum
|
||||||
|
length of SLUGFIELD_MAX_LENGTH.
|
||||||
|
"""
|
||||||
|
for char, replacement in non_identifier_chars().items():
|
||||||
|
value = value.replace(char, replacement)
|
||||||
|
|
||||||
|
# Use slugify to create a URL-friendly base slug.
|
||||||
|
slug = slugify(value, allow_unicode=False).replace("-", "_")
|
||||||
|
|
||||||
|
# If slugify returns an empty string, generate a fallback
|
||||||
|
# slug to ensure it's never empty.
|
||||||
|
if not slug:
|
||||||
|
chars = string.ascii_lowercase + string.digits
|
||||||
|
randstr = "".join(secrets.choice(chars) for _ in range(8))
|
||||||
|
slug = f"rand_{randstr}"
|
||||||
|
|
||||||
|
# Ensure the slug doesn't start with a digit to make it a valid Python identifier.
|
||||||
|
if slug[0].isdigit():
|
||||||
|
slug = "_" + slug
|
||||||
|
|
||||||
|
return slug[:SLUGFIELD_MAX_LENGTH]
|
||||||
|
|
@ -1,52 +1,55 @@
|
||||||
'''
|
"""
|
||||||
Managers.
|
|
||||||
|
|
||||||
This module contains the custom manager used by entities registered with eav.
|
This module contains the custom manager used by entities registered with eav.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from .queryset import EavQuerySet
|
from eav.queryset import EavQuerySet
|
||||||
|
|
||||||
|
|
||||||
class EntityManager(models.Manager):
|
class EntityManager(models.Manager):
|
||||||
'''
|
"""
|
||||||
Our custom manager, overrides ``models.Manager``.
|
Our custom manager, overrides ``models.Manager``.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
_queryset_class = EavQuerySet
|
_queryset_class = EavQuerySet
|
||||||
|
|
||||||
def create(self, **kwargs):
|
def create(self, **kwargs):
|
||||||
'''
|
"""
|
||||||
Parse eav attributes out of *kwargs*, then try to create and save
|
Parse eav attributes out of *kwargs*, then try to create and save
|
||||||
the object, then assign and save it's eav attributes.
|
the object, then assign and save it's eav attributes.
|
||||||
'''
|
"""
|
||||||
config_cls = getattr(self.model, '_eav_config_cls', None)
|
config_cls = getattr(self.model, "_eav_config_cls", None)
|
||||||
|
|
||||||
if not config_cls or config_cls.manager_only:
|
if not config_cls or config_cls.manager_only:
|
||||||
return super(EntityManager, self).create(**kwargs)
|
return super().create(**kwargs)
|
||||||
|
|
||||||
#attributes = config_cls.get_attributes()
|
|
||||||
prefix = '%s__' % config_cls.eav_attr
|
|
||||||
|
|
||||||
|
prefix = f"{config_cls.eav_attr}__"
|
||||||
new_kwargs = {}
|
new_kwargs = {}
|
||||||
eav_kwargs = {}
|
eav_kwargs = {}
|
||||||
|
|
||||||
for key, value in kwargs.items():
|
for key, value in kwargs.items():
|
||||||
if key.startswith(prefix):
|
if key.startswith(prefix):
|
||||||
eav_kwargs.update({key[len(prefix):]: value})
|
eav_kwargs.update({key[len(prefix) :]: value})
|
||||||
else:
|
else:
|
||||||
new_kwargs.update({key: value})
|
new_kwargs.update({key: value})
|
||||||
|
|
||||||
obj = self.model(**new_kwargs)
|
obj = self.model(**new_kwargs)
|
||||||
obj_eav = getattr(obj, config_cls.eav_attr)
|
obj_eav = getattr(obj, config_cls.eav_attr)
|
||||||
|
|
||||||
for key, value in eav_kwargs.items():
|
for key, value in eav_kwargs.items():
|
||||||
setattr(obj_eav, key, value)
|
setattr(obj_eav, key, value)
|
||||||
|
|
||||||
obj.save()
|
obj.save()
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def get_or_create(self, **kwargs):
|
def get_or_create(self, defaults=None, **kwargs):
|
||||||
'''
|
"""
|
||||||
Reproduces the behavior of get_or_create, eav friendly.
|
Reproduces the behavior of get_or_create, eav friendly.
|
||||||
'''
|
"""
|
||||||
try:
|
try:
|
||||||
return self.get(**kwargs), False
|
return self.get(**kwargs), False
|
||||||
except self.model.DoesNotExist:
|
except self.model.DoesNotExist:
|
||||||
|
if defaults:
|
||||||
|
kwargs = {**kwargs, **defaults}
|
||||||
return self.create(**kwargs), True
|
return self.create(**kwargs), True
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,227 @@
|
||||||
# Generated by Django 2.0.4 on 2018-06-01 09:36
|
# Generated by Django 2.0.4 on 2018-06-01 09:36
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django.utils.timezone
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
import eav.fields
|
import eav.fields
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
"""Initial migration for the Attribute, EnumGroup, EnumValue, and Value models."""
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('contenttypes', '0002_remove_content_type_name'),
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Attribute',
|
name="Attribute",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('name', models.CharField(help_text='User-friendly attribute name', max_length=100, verbose_name='name')),
|
"id",
|
||||||
('slug', eav.fields.EavSlugField(help_text='Short unique attribute label', unique=True, verbose_name='slug')),
|
models.AutoField(
|
||||||
('description', models.CharField(blank=True, help_text='Short description', max_length=256, null=True, verbose_name='description')),
|
auto_created=True,
|
||||||
('datatype', eav.fields.EavDatatypeField(choices=[('text', 'Text'), ('float', 'Float'), ('int', 'Integer'), ('date', 'Date'), ('bool', 'True / False'), ('object', 'Django Object'), ('enum', 'Multiple Choice')], max_length=6, verbose_name='data type')),
|
primary_key=True,
|
||||||
('created', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
serialize=False,
|
||||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
verbose_name="ID",
|
||||||
('required', models.BooleanField(default=False, verbose_name='required')),
|
),
|
||||||
('display_order', models.PositiveIntegerField(default=1, verbose_name='display order')),
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(
|
||||||
|
help_text="User-friendly attribute name",
|
||||||
|
max_length=100,
|
||||||
|
verbose_name="Name",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"slug",
|
||||||
|
models.SlugField(
|
||||||
|
help_text="Short unique attribute label",
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Slug",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Short description",
|
||||||
|
max_length=256,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Description",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"datatype",
|
||||||
|
eav.fields.EavDatatypeField(
|
||||||
|
choices=[
|
||||||
|
("text", "Text"),
|
||||||
|
("date", "Date"),
|
||||||
|
("float", "Float"),
|
||||||
|
("int", "Integer"),
|
||||||
|
("bool", "True / False"),
|
||||||
|
("object", "Django Object"),
|
||||||
|
("enum", "Multiple Choice"),
|
||||||
|
],
|
||||||
|
max_length=6,
|
||||||
|
verbose_name="Data Type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
models.DateTimeField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="Created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
models.DateTimeField(auto_now=True, verbose_name="Modified"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"required",
|
||||||
|
models.BooleanField(default=False, verbose_name="Required"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"display_order",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=1,
|
||||||
|
verbose_name="Display order",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['name'],
|
"ordering": ["name"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='EnumGroup',
|
name="EnumGroup",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('name', models.CharField(max_length=100, unique=True, verbose_name='name')),
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"name",
|
||||||
|
models.CharField(max_length=100, unique=True, verbose_name="Name"),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='EnumValue',
|
name="EnumValue",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('value', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='value')),
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"value",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True,
|
||||||
|
max_length=50,
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Value",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Value',
|
name="Value",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('entity_id', models.IntegerField()),
|
"id",
|
||||||
('value_text', models.TextField(blank=True, null=True)),
|
models.AutoField(
|
||||||
('value_float', models.FloatField(blank=True, null=True)),
|
auto_created=True,
|
||||||
('value_int', models.IntegerField(blank=True, null=True)),
|
primary_key=True,
|
||||||
('value_date', models.DateTimeField(blank=True, null=True)),
|
serialize=False,
|
||||||
('value_bool', models.NullBooleanField()),
|
verbose_name="ID",
|
||||||
('generic_value_id', models.IntegerField(blank=True, null=True)),
|
),
|
||||||
('created', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created')),
|
),
|
||||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
("entity_id", models.IntegerField()),
|
||||||
('attribute', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='eav.Attribute', verbose_name='attribute')),
|
("value_text", models.TextField(blank=True, null=True)),
|
||||||
('entity_ct', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='value_entities', to='contenttypes.ContentType')),
|
("value_float", models.FloatField(blank=True, null=True)),
|
||||||
('generic_value_ct', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='value_values', to='contenttypes.ContentType')),
|
("value_int", models.IntegerField(blank=True, null=True)),
|
||||||
('value_enum', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='eav_values', to='eav.EnumValue')),
|
("value_date", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("value_bool", models.NullBooleanField()),
|
||||||
|
("generic_value_id", models.IntegerField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
models.DateTimeField(
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
verbose_name="Created",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
models.DateTimeField(auto_now=True, verbose_name="Modified"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"attribute",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to="eav.Attribute",
|
||||||
|
verbose_name="Attribute",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"entity_ct",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="value_entities",
|
||||||
|
to="contenttypes.ContentType",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"generic_value_ct",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="value_values",
|
||||||
|
to="contenttypes.ContentType",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"value_enum",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="eav_values",
|
||||||
|
to="eav.EnumValue",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='enumgroup',
|
model_name="enumgroup",
|
||||||
name='enums',
|
name="values",
|
||||||
field=models.ManyToManyField(to='eav.EnumValue', verbose_name='enum group'),
|
field=models.ManyToManyField(to="eav.EnumValue", verbose_name="Enum group"),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='attribute',
|
model_name="attribute",
|
||||||
name='enum_group',
|
name="enum_group",
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='eav.EnumGroup', verbose_name='choice group'),
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to="eav.EnumGroup",
|
||||||
|
verbose_name="Choice Group",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
18
eav/migrations/0002_add_entity_ct_field.py
Normal file
18
eav/migrations/0002_add_entity_ct_field.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""Add entity_ct field to Attribute model."""
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
|
("eav", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="attribute",
|
||||||
|
name="entity_ct",
|
||||||
|
field=models.ManyToManyField(blank=True, to="contenttypes.ContentType"),
|
||||||
|
),
|
||||||
|
]
|
||||||
44
eav/migrations/0003_auto_20210404_2209.py
Normal file
44
eav/migrations/0003_auto_20210404_2209.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Generated by Django 3.1.6 on 2021-04-04 22:09
|
||||||
|
|
||||||
|
import django.core.serializers.json
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db.models import JSONField
|
||||||
|
|
||||||
|
import eav.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("eav", "0002_add_entity_ct_field"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="value",
|
||||||
|
name="value_json",
|
||||||
|
field=JSONField(
|
||||||
|
blank=True,
|
||||||
|
default=dict,
|
||||||
|
encoder=django.core.serializers.json.DjangoJSONEncoder,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="attribute",
|
||||||
|
name="datatype",
|
||||||
|
field=eav.fields.EavDatatypeField(
|
||||||
|
choices=[
|
||||||
|
("text", "Text"),
|
||||||
|
("date", "Date"),
|
||||||
|
("float", "Float"),
|
||||||
|
("int", "Integer"),
|
||||||
|
("bool", "True / False"),
|
||||||
|
("object", "Django Object"),
|
||||||
|
("enum", "Multiple Choice"),
|
||||||
|
("json", "JSON Object"),
|
||||||
|
],
|
||||||
|
max_length=6,
|
||||||
|
verbose_name="Data Type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
17
eav/migrations/0004_alter_value_value_bool.py
Normal file
17
eav/migrations/0004_alter_value_value_bool.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 3.2 on 2021-04-23 19:06
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("eav", "0003_auto_20210404_2209"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="value_bool",
|
||||||
|
field=models.BooleanField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
38
eav/migrations/0005_auto_20210510_1305.py
Normal file
38
eav/migrations/0005_auto_20210510_1305.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Generated by Django 3.2 on 2021-05-10 13:05
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
import eav.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("eav", "0004_alter_value_value_bool"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="value",
|
||||||
|
name="value_csv",
|
||||||
|
field=eav.fields.CSVField(blank=True, default="", null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="attribute",
|
||||||
|
name="datatype",
|
||||||
|
field=eav.fields.EavDatatypeField(
|
||||||
|
choices=[
|
||||||
|
("text", "Text"),
|
||||||
|
("date", "Date"),
|
||||||
|
("float", "Float"),
|
||||||
|
("int", "Integer"),
|
||||||
|
("bool", "True / False"),
|
||||||
|
("object", "Django Object"),
|
||||||
|
("enum", "Multiple Choice"),
|
||||||
|
("json", "JSON Object"),
|
||||||
|
("csv", "Comma-Separated-Value"),
|
||||||
|
],
|
||||||
|
max_length=6,
|
||||||
|
verbose_name="Data Type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
22
eav/migrations/0006_add_entity_uuid.py
Normal file
22
eav/migrations/0006_add_entity_uuid.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""Creates UUID field to map to Entity FK."""
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("eav", "0005_auto_20210510_1305"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="value",
|
||||||
|
name="entity_uuid",
|
||||||
|
field=models.UUIDField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="entity_id",
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
17
eav/migrations/0007_alter_value_value_int.py
Normal file
17
eav/migrations/0007_alter_value_value_int.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""Convert Value.value_int to BigInteger."""
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("eav", "0006_add_entity_uuid"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="value_int",
|
||||||
|
field=models.BigIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
21
eav/migrations/0008_use_native_slugfield.py
Normal file
21
eav/migrations/0008_use_native_slugfield.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""Use Django SlugField() for Attribute.slug."""
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("eav", "0007_alter_value_value_int"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="attribute",
|
||||||
|
name="slug",
|
||||||
|
field=models.SlugField(
|
||||||
|
help_text="Short unique attribute label",
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Slug",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
178
eav/migrations/0009_enchance_naming.py
Normal file
178
eav/migrations/0009_enchance_naming.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
from eav.fields import CSVField
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""Define verbose naming for models and fields."""
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
|
("eav", "0008_use_native_slugfield"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="attribute",
|
||||||
|
options={
|
||||||
|
"ordering": ["name"],
|
||||||
|
"verbose_name": "Attribute",
|
||||||
|
"verbose_name_plural": "Attributes",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="enumgroup",
|
||||||
|
options={
|
||||||
|
"verbose_name": "EnumGroup",
|
||||||
|
"verbose_name_plural": "EnumGroups",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="enumvalue",
|
||||||
|
options={
|
||||||
|
"verbose_name": "EnumValue",
|
||||||
|
"verbose_name_plural": "EnumValues",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="value",
|
||||||
|
options={"verbose_name": "Value", "verbose_name_plural": "Values"},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="attribute",
|
||||||
|
name="entity_ct",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
to="contenttypes.contenttype",
|
||||||
|
verbose_name="Entity content type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="entity_ct",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=models.deletion.PROTECT,
|
||||||
|
related_name="value_entities",
|
||||||
|
to="contenttypes.contenttype",
|
||||||
|
verbose_name="Entity ct",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="entity_id",
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Entity id",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="entity_uuid",
|
||||||
|
field=models.UUIDField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Entity uuid",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="generic_value_ct",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.deletion.PROTECT,
|
||||||
|
related_name="value_values",
|
||||||
|
to="contenttypes.contenttype",
|
||||||
|
verbose_name="Generic value content type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="generic_value_id",
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Generic value id",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="value_bool",
|
||||||
|
field=models.BooleanField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Value bool",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="value_csv",
|
||||||
|
field=CSVField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
null=True,
|
||||||
|
verbose_name="Value CSV",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="value_date",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Value date",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="value_enum",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.deletion.PROTECT,
|
||||||
|
related_name="eav_values",
|
||||||
|
to="eav.enumvalue",
|
||||||
|
verbose_name="Value enum",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="value_float",
|
||||||
|
field=models.FloatField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Value float",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="value_int",
|
||||||
|
field=models.BigIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Value int",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="value_json",
|
||||||
|
field=models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
default=dict,
|
||||||
|
encoder=DjangoJSONEncoder,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Value JSON",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="value_text",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Value text",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
48
eav/migrations/0010_dynamic_pk_type_for_models.py
Normal file
48
eav/migrations/0010_dynamic_pk_type_for_models.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""Migration to use BigAutoField as default for all models."""
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("eav", "0009_enchance_naming"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="attribute",
|
||||||
|
name="id",
|
||||||
|
field=models.BigAutoField(
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="enumgroup",
|
||||||
|
name="id",
|
||||||
|
field=models.BigAutoField(
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="enumvalue",
|
||||||
|
name="id",
|
||||||
|
field=models.BigAutoField(
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="id",
|
||||||
|
field=models.BigAutoField(
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
36
eav/migrations/0011_update_defaults_and_meta.py
Normal file
36
eav/migrations/0011_update_defaults_and_meta.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""Update default values and meta options for Attribute and Value models."""
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("eav", "0010_dynamic_pk_type_for_models"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="attribute",
|
||||||
|
options={
|
||||||
|
"ordering": ("name",),
|
||||||
|
"verbose_name": "Attribute",
|
||||||
|
"verbose_name_plural": "Attributes",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="attribute",
|
||||||
|
name="description",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Short description",
|
||||||
|
max_length=256,
|
||||||
|
verbose_name="Description",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="value",
|
||||||
|
name="value_text",
|
||||||
|
field=models.TextField(blank=True, default="", verbose_name="Value text"),
|
||||||
|
),
|
||||||
|
]
|
||||||
54
eav/migrations/0012_add_value_uniqueness_checks.py
Normal file
54
eav/migrations/0012_add_value_uniqueness_checks.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""
|
||||||
|
Add uniqueness and integrity constraints to the Value model.
|
||||||
|
|
||||||
|
This migration adds database-level constraints to ensure:
|
||||||
|
1. Each entity (identified by UUID) can have only one value per attribute
|
||||||
|
2. Each entity (identified by integer ID) can have only one value per attribute
|
||||||
|
3. Each value must use either entity_id OR entity_uuid, never both or neither
|
||||||
|
|
||||||
|
These constraints ensure data integrity by preventing duplicate attribute values
|
||||||
|
for the same entity and enforcing the XOR relationship between the two types of
|
||||||
|
entity identification (integer ID vs UUID).
|
||||||
|
"""
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("eav", "0011_update_defaults_and_meta"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="value",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("entity_ct", "attribute", "entity_uuid"),
|
||||||
|
name="unique_entity_uuid_per_attribute",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="value",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("entity_ct", "attribute", "entity_id"),
|
||||||
|
name="unique_entity_id_per_attribute",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="value",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
check=models.Q(
|
||||||
|
models.Q(
|
||||||
|
("entity_id__isnull", False),
|
||||||
|
("entity_uuid__isnull", True),
|
||||||
|
),
|
||||||
|
models.Q(
|
||||||
|
("entity_id__isnull", True),
|
||||||
|
("entity_uuid__isnull", False),
|
||||||
|
),
|
||||||
|
_connector="OR",
|
||||||
|
),
|
||||||
|
name="ensure_entity_id_xor_entity_uuid",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
618
eav/models.py
618
eav/models.py
|
|
@ -1,618 +0,0 @@
|
||||||
'''
|
|
||||||
Models.
|
|
||||||
|
|
||||||
This module defines the four concrete, non-abstract models:
|
|
||||||
* :class:`Value`
|
|
||||||
* :class:`Attribute`
|
|
||||||
* :class:`EnumValue`
|
|
||||||
* :class:`EnumGroup`
|
|
||||||
|
|
||||||
Along with the :class:`Entity` helper class.
|
|
||||||
'''
|
|
||||||
from copy import copy
|
|
||||||
|
|
||||||
from django.contrib.contenttypes import fields as generic
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.db import models
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
|
||||||
|
|
||||||
from .exceptions import IllegalAssignmentException
|
|
||||||
from .fields import EavDatatypeField, EavSlugField
|
|
||||||
from .validators import *
|
|
||||||
|
|
||||||
|
|
||||||
class EnumValue(models.Model):
|
|
||||||
'''
|
|
||||||
*EnumValue* objects are the value 'choices' to multiple choice
|
|
||||||
*TYPE_ENUM* :class:`Attribute` objects.
|
|
||||||
|
|
||||||
They have only one field, *value*, a ``CharField`` that must be unique.
|
|
||||||
For example::
|
|
||||||
|
|
||||||
yes = EnumValue.objects.create(value='Yes') # doctest: SKIP
|
|
||||||
no = EnumValue.objects.create(value='No')
|
|
||||||
unknown = EnumValue.objects.create(value='Unknown')
|
|
||||||
|
|
||||||
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
|
||||||
ynu.enums.add(yes, no, unknown)
|
|
||||||
|
|
||||||
Attribute.objects.create(name='has fever?', datatype=Attribute.TYPE_ENUM, enum_group=ynu)
|
|
||||||
# = <Attribute: has fever? (Multiple Choice)>
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
The same *EnumValue* objects should be reused within multiple
|
|
||||||
*EnumGroups*. For example, if you have one *EnumGroup*
|
|
||||||
called: *Yes / No / Unknown* and another called *Yes / No /
|
|
||||||
Not applicable*, you should only have a total of four *EnumValues*
|
|
||||||
objects, as you should have used the same *Yes* and *No* *EnumValues*
|
|
||||||
for both *EnumGroups*.
|
|
||||||
'''
|
|
||||||
value = models.CharField(_('value'), db_index=True, unique=True, max_length=50)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return '<EnumValue {}>'.format(self.value)
|
|
||||||
|
|
||||||
|
|
||||||
class EnumGroup(models.Model):
|
|
||||||
'''
|
|
||||||
*EnumGroup* objects have two fields - a *name* ``CharField`` and *enums*,
|
|
||||||
a ``ManyToManyField`` to :class:`EnumValue`. :class:`Attribute` classes
|
|
||||||
with datatype *TYPE_ENUM* have a ``ForeignKey`` field to *EnumGroup*.
|
|
||||||
|
|
||||||
See :class:`EnumValue` for an example.
|
|
||||||
'''
|
|
||||||
name = models.CharField(_('name'), unique = True, max_length = 100)
|
|
||||||
enums = models.ManyToManyField(EnumValue, verbose_name = _('enum group'))
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return '<EnumGroup {}>'.format(self.name)
|
|
||||||
|
|
||||||
|
|
||||||
class Attribute(models.Model):
|
|
||||||
'''
|
|
||||||
Putting the **A** in *EAV*. This holds the attributes, or concepts.
|
|
||||||
Examples of possible *Attributes*: color, height, weight,
|
|
||||||
number of children, number of patients, has fever?, etc...
|
|
||||||
|
|
||||||
Each attribute has a name, and a description, along with a slug that must
|
|
||||||
be unique. If you don't provide a slug, a default slug (derived from
|
|
||||||
name), will be created.
|
|
||||||
|
|
||||||
The *required* field is a boolean that indicates whether this EAV attribute
|
|
||||||
is required for entities to which it applies. It defaults to *False*.
|
|
||||||
|
|
||||||
.. warning::
|
|
||||||
Just like a normal model field that is required, you will not be able
|
|
||||||
to save or create any entity object for which this attribute applies,
|
|
||||||
without first setting this EAV attribute.
|
|
||||||
|
|
||||||
There are 7 possible values for datatype:
|
|
||||||
|
|
||||||
* int (TYPE_INT)
|
|
||||||
* float (TYPE_FLOAT)
|
|
||||||
* text (TYPE_TEXT)
|
|
||||||
* date (TYPE_DATE)
|
|
||||||
* bool (TYPE_BOOLEAN)
|
|
||||||
* object (TYPE_OBJECT)
|
|
||||||
* enum (TYPE_ENUM)
|
|
||||||
|
|
||||||
Examples::
|
|
||||||
|
|
||||||
Attribute.objects.create(name='Height', datatype=Attribute.TYPE_INT)
|
|
||||||
# = <Attribute: Height (Integer)>
|
|
||||||
|
|
||||||
Attribute.objects.create(name='Color', datatype=Attribute.TYPE_TEXT)
|
|
||||||
# = <Attribute: Color (Text)>
|
|
||||||
|
|
||||||
yes = EnumValue.objects.create(value='yes')
|
|
||||||
no = EnumValue.objects.create(value='no')
|
|
||||||
unknown = EnumValue.objects.create(value='unknown')
|
|
||||||
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
|
||||||
ynu.enums.add(yes, no, unknown)
|
|
||||||
|
|
||||||
Attribute.objects.create(name='has fever?', datatype=Attribute.TYPE_ENUM, enum_group=ynu)
|
|
||||||
# = <Attribute: has fever? (Multiple Choice)>
|
|
||||||
|
|
||||||
.. warning:: Once an Attribute has been used by an entity, you can not
|
|
||||||
change it's datatype.
|
|
||||||
'''
|
|
||||||
class Meta:
|
|
||||||
ordering = ['name']
|
|
||||||
|
|
||||||
TYPE_TEXT = 'text'
|
|
||||||
TYPE_FLOAT = 'float'
|
|
||||||
TYPE_INT = 'int'
|
|
||||||
TYPE_DATE = 'date'
|
|
||||||
TYPE_BOOLEAN = 'bool'
|
|
||||||
TYPE_OBJECT = 'object'
|
|
||||||
TYPE_ENUM = 'enum'
|
|
||||||
|
|
||||||
DATATYPE_CHOICES = (
|
|
||||||
(TYPE_TEXT, _('Text')),
|
|
||||||
(TYPE_DATE, _('Date')),
|
|
||||||
(TYPE_FLOAT, _('Float')),
|
|
||||||
(TYPE_INT, _('Integer')),
|
|
||||||
(TYPE_BOOLEAN, _('True / False')),
|
|
||||||
(TYPE_OBJECT, _('Django Object')),
|
|
||||||
(TYPE_ENUM, _('Multiple Choice')),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Core attributes
|
|
||||||
|
|
||||||
datatype = EavDatatypeField(
|
|
||||||
verbose_name = _('Data Type'),
|
|
||||||
choices = DATATYPE_CHOICES,
|
|
||||||
max_length = 6
|
|
||||||
)
|
|
||||||
|
|
||||||
name = models.CharField(
|
|
||||||
verbose_name = _('Name'),
|
|
||||||
max_length = 100,
|
|
||||||
help_text = _('User-friendly attribute name')
|
|
||||||
)
|
|
||||||
|
|
||||||
'''
|
|
||||||
Main identifer for the attribute.
|
|
||||||
Upon creation, slug is autogenerated from the name.
|
|
||||||
(see :meth:`~eav.fields.EavSlugField.create_slug_from_name`).
|
|
||||||
'''
|
|
||||||
slug = EavSlugField(
|
|
||||||
verbose_name = _('Slug'),
|
|
||||||
max_length = 50,
|
|
||||||
db_index = True,
|
|
||||||
unique = True,
|
|
||||||
help_text = _('Short unique attribute label')
|
|
||||||
)
|
|
||||||
|
|
||||||
'''
|
|
||||||
.. warning::
|
|
||||||
This attribute should be used with caution. Setting this to *True*
|
|
||||||
means that *all* entities that *can* have this attribute will
|
|
||||||
be required to have a value for it.
|
|
||||||
'''
|
|
||||||
required = models.BooleanField(verbose_name = _('Required'), default = False)
|
|
||||||
|
|
||||||
enum_group = models.ForeignKey(
|
|
||||||
EnumGroup,
|
|
||||||
verbose_name = _('Choice Group'),
|
|
||||||
on_delete = models.PROTECT,
|
|
||||||
blank = True,
|
|
||||||
null = True
|
|
||||||
)
|
|
||||||
|
|
||||||
description = models.CharField(
|
|
||||||
verbose_name = _('Description'),
|
|
||||||
max_length = 256,
|
|
||||||
blank = True,
|
|
||||||
null = True,
|
|
||||||
help_text = _('Short description')
|
|
||||||
)
|
|
||||||
|
|
||||||
# Useful meta-information
|
|
||||||
|
|
||||||
display_order = models.PositiveIntegerField(
|
|
||||||
verbose_name = _('Display order'),
|
|
||||||
default = 1
|
|
||||||
)
|
|
||||||
|
|
||||||
modified = models.DateTimeField(
|
|
||||||
verbose_name = _('Modified'),
|
|
||||||
auto_now = True
|
|
||||||
)
|
|
||||||
|
|
||||||
created = models.DateTimeField(
|
|
||||||
verbose_name = _('Created'),
|
|
||||||
default = timezone.now,
|
|
||||||
editable = False
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def help_text(self):
|
|
||||||
return self.description
|
|
||||||
|
|
||||||
def get_validators(self):
|
|
||||||
'''
|
|
||||||
Returns the appropriate validator function from :mod:`~eav.validators`
|
|
||||||
as a list (of length one) for the datatype.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
The reason it returns it as a list, is eventually we may want this
|
|
||||||
method to look elsewhere for additional attribute specific
|
|
||||||
validators to return as well as the default, built-in one.
|
|
||||||
'''
|
|
||||||
DATATYPE_VALIDATORS = {
|
|
||||||
'text': validate_text,
|
|
||||||
'float': validate_float,
|
|
||||||
'int': validate_int,
|
|
||||||
'date': validate_date,
|
|
||||||
'bool': validate_bool,
|
|
||||||
'object': validate_object,
|
|
||||||
'enum': validate_enum,
|
|
||||||
}
|
|
||||||
|
|
||||||
return [DATATYPE_VALIDATORS[self.datatype]]
|
|
||||||
|
|
||||||
def validate_value(self, value):
|
|
||||||
'''
|
|
||||||
Check *value* against the validators returned by
|
|
||||||
:meth:`get_validators` for this attribute.
|
|
||||||
'''
|
|
||||||
for validator in self.get_validators():
|
|
||||||
validator(value)
|
|
||||||
|
|
||||||
if self.datatype == self.TYPE_ENUM:
|
|
||||||
if value not in self.enum_group.enums.all():
|
|
||||||
raise ValidationError(
|
|
||||||
_('%(val)s is not a valid choice for %(attr)s')
|
|
||||||
% dict(val = value, attr = self)
|
|
||||||
)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
'''
|
|
||||||
Saves the Attribute and auto-generates a slug field
|
|
||||||
if one wasn't provided.
|
|
||||||
'''
|
|
||||||
if not self.slug:
|
|
||||||
self.slug = EavSlugField.create_slug_from_name(self.name)
|
|
||||||
|
|
||||||
self.full_clean()
|
|
||||||
super(Attribute, self).save(*args, **kwargs)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
'''
|
|
||||||
Validates the attribute. Will raise ``ValidationError`` if
|
|
||||||
the attribute's datatype is *TYPE_ENUM* and enum_group is not set,
|
|
||||||
or if the attribute is not *TYPE_ENUM* and the enum group is set.
|
|
||||||
'''
|
|
||||||
if self.datatype == self.TYPE_ENUM and not self.enum_group:
|
|
||||||
raise ValidationError(
|
|
||||||
_('You must set the choice group for multiple choice attributes')
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.datatype != self.TYPE_ENUM and self.enum_group:
|
|
||||||
raise ValidationError(
|
|
||||||
_('You can only assign a choice group to multiple choice attributes')
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_choices(self):
|
|
||||||
'''
|
|
||||||
Returns a query set of :class:`EnumValue` objects for this attribute.
|
|
||||||
Returns None if the datatype of this attribute is not *TYPE_ENUM*.
|
|
||||||
'''
|
|
||||||
return self.enum_group.enums.all() if self.datatype == Attribute.TYPE_ENUM else None
|
|
||||||
|
|
||||||
def save_value(self, entity, value):
|
|
||||||
'''
|
|
||||||
Called with *entity*, any Django object registered with eav, and
|
|
||||||
*value*, the :class:`Value` this attribute for *entity* should
|
|
||||||
be set to.
|
|
||||||
|
|
||||||
If a :class:`Value` object for this *entity* and attribute doesn't
|
|
||||||
exist, one will be created.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
If *value* is None and a :class:`Value` object exists for this
|
|
||||||
Attribute and *entity*, it will delete that :class:`Value` object.
|
|
||||||
'''
|
|
||||||
ct = ContentType.objects.get_for_model(entity)
|
|
||||||
|
|
||||||
try:
|
|
||||||
value_obj = self.value_set.get(
|
|
||||||
entity_ct = ct,
|
|
||||||
entity_id = entity.pk,
|
|
||||||
attribute = self
|
|
||||||
)
|
|
||||||
except Value.DoesNotExist:
|
|
||||||
if value == None or value == '':
|
|
||||||
return
|
|
||||||
|
|
||||||
value_obj = Value.objects.create(
|
|
||||||
entity_ct = ct,
|
|
||||||
entity_id = entity.pk,
|
|
||||||
attribute = self
|
|
||||||
)
|
|
||||||
|
|
||||||
if value == None or value == '':
|
|
||||||
value_obj.delete()
|
|
||||||
return
|
|
||||||
|
|
||||||
if value != value_obj.value:
|
|
||||||
value_obj.value = value
|
|
||||||
value_obj.save()
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return '{} ({})'.format(self.name, self.get_datatype_display())
|
|
||||||
|
|
||||||
|
|
||||||
class Value(models.Model):
|
|
||||||
'''
|
|
||||||
Putting the **V** in *EAV*. This model stores the value for one particular
|
|
||||||
:class:`Attribute` for some entity.
|
|
||||||
|
|
||||||
As with most EAV implementations, most of the columns of this model will
|
|
||||||
be blank, as onle one *value_* field will be used.
|
|
||||||
|
|
||||||
Example::
|
|
||||||
|
|
||||||
import eav
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
|
|
||||||
eav.register(User)
|
|
||||||
|
|
||||||
u = User.objects.create(username='crazy_dev_user')
|
|
||||||
a = Attribute.objects.create(name='Fav Drink', datatype='text')
|
|
||||||
|
|
||||||
Value.objects.create(entity = u, attribute = a, value_text = 'red bull')
|
|
||||||
# = <Value: crazy_dev_user - Fav Drink: "red bull">
|
|
||||||
'''
|
|
||||||
|
|
||||||
entity_ct = models.ForeignKey(
|
|
||||||
ContentType,
|
|
||||||
on_delete = models.PROTECT,
|
|
||||||
related_name = 'value_entities'
|
|
||||||
)
|
|
||||||
|
|
||||||
entity_id = models.IntegerField()
|
|
||||||
entity = generic.GenericForeignKey(ct_field = 'entity_ct', fk_field = 'entity_id')
|
|
||||||
|
|
||||||
value_text = models.TextField(blank = True, null = True)
|
|
||||||
value_float = models.FloatField(blank = True, null = True)
|
|
||||||
value_int = models.IntegerField(blank = True, null = True)
|
|
||||||
value_date = models.DateTimeField(blank = True, null = True)
|
|
||||||
value_bool = models.NullBooleanField(blank = True, null = True)
|
|
||||||
|
|
||||||
value_enum = models.ForeignKey(
|
|
||||||
EnumValue,
|
|
||||||
blank = True,
|
|
||||||
null = True,
|
|
||||||
on_delete = models.PROTECT,
|
|
||||||
related_name = 'eav_values'
|
|
||||||
)
|
|
||||||
|
|
||||||
generic_value_id = models.IntegerField(blank=True, null=True)
|
|
||||||
|
|
||||||
generic_value_ct = models.ForeignKey(
|
|
||||||
ContentType,
|
|
||||||
blank = True,
|
|
||||||
null = True,
|
|
||||||
on_delete = models.PROTECT,
|
|
||||||
related_name ='value_values'
|
|
||||||
)
|
|
||||||
|
|
||||||
value_object = generic.GenericForeignKey(
|
|
||||||
ct_field = 'generic_value_ct',
|
|
||||||
fk_field = 'generic_value_id'
|
|
||||||
)
|
|
||||||
|
|
||||||
created = models.DateTimeField(_('Created'), default = timezone.now)
|
|
||||||
modified = models.DateTimeField(_('Modified'), auto_now = True)
|
|
||||||
|
|
||||||
attribute = models.ForeignKey(
|
|
||||||
Attribute,
|
|
||||||
db_index = True,
|
|
||||||
on_delete = models.PROTECT,
|
|
||||||
verbose_name = _('attribute')
|
|
||||||
)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
'''
|
|
||||||
Validate and save this value.
|
|
||||||
'''
|
|
||||||
self.full_clean()
|
|
||||||
super(Value, self).save(*args, **kwargs)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
'''
|
|
||||||
Raises ``ValidationError`` if this value's attribute is *TYPE_ENUM*
|
|
||||||
and value_enum is not a valid choice for this value's attribute.
|
|
||||||
'''
|
|
||||||
if self.attribute.datatype == Attribute.TYPE_ENUM and self.value_enum:
|
|
||||||
if self.value_enum not in self.attribute.enum_group.enums.all():
|
|
||||||
raise ValidationError(
|
|
||||||
_('%(enum)s is not a valid choice for %(attr)s')
|
|
||||||
% dict(enum = self.value_enum, attr = self.attribute)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_value(self):
|
|
||||||
'''
|
|
||||||
Return the python object this value is holding
|
|
||||||
'''
|
|
||||||
return getattr(self, 'value_%s' % self.attribute.datatype)
|
|
||||||
|
|
||||||
def _set_value(self, new_value):
|
|
||||||
'''
|
|
||||||
Set the object this value is holding
|
|
||||||
'''
|
|
||||||
setattr(self, 'value_%s' % self.attribute.datatype, new_value)
|
|
||||||
|
|
||||||
value = property(_get_value, _set_value)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return '{}: "{}" ({})'.format(self.attribute.name, self.value, self.entity)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return '{}: "{}" ({})'.format(self.attribute.name, self.value, self.entity.pk)
|
|
||||||
|
|
||||||
|
|
||||||
class Entity(object):
|
|
||||||
'''
|
|
||||||
The helper class that will be attached to any entity
|
|
||||||
registered with eav.
|
|
||||||
'''
|
|
||||||
@staticmethod
|
|
||||||
def pre_save_handler(sender, *args, **kwargs):
|
|
||||||
'''
|
|
||||||
Pre save handler attached to self.instance. Called before the
|
|
||||||
model instance we are attached to is saved. This allows us to call
|
|
||||||
:meth:`validate_attributes` before the entity is saved.
|
|
||||||
'''
|
|
||||||
instance = kwargs['instance']
|
|
||||||
entity = getattr(kwargs['instance'], instance._eav_config_cls.eav_attr)
|
|
||||||
entity.validate_attributes()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def post_save_handler(sender, *args, **kwargs):
|
|
||||||
'''
|
|
||||||
Post save handler attached to self.instance. Calls :meth:`save` when
|
|
||||||
the model instance we are attached to is saved.
|
|
||||||
'''
|
|
||||||
instance = kwargs['instance']
|
|
||||||
entity = getattr(instance, instance._eav_config_cls.eav_attr)
|
|
||||||
entity.save()
|
|
||||||
|
|
||||||
def __init__(self, instance):
|
|
||||||
'''
|
|
||||||
Set self.instance equal to the instance of the model that we're attached
|
|
||||||
to. Also, store the content type of that instance.
|
|
||||||
'''
|
|
||||||
self.instance = instance
|
|
||||||
self.ct = ContentType.objects.get_for_model(instance)
|
|
||||||
|
|
||||||
def __getattr__(self, name):
|
|
||||||
'''
|
|
||||||
Tha magic getattr helper. This is called whenever user invokes::
|
|
||||||
|
|
||||||
instance.<attribute>
|
|
||||||
|
|
||||||
Checks if *name* is a valid slug for attributes available to this
|
|
||||||
instances. If it is, tries to lookup the :class:`Value` with that
|
|
||||||
attribute slug. If there is one, it returns the value of the
|
|
||||||
class:`Value` object, otherwise it hasn't been set, so it returns
|
|
||||||
None.
|
|
||||||
'''
|
|
||||||
if not name.startswith('_'):
|
|
||||||
try:
|
|
||||||
attribute = self.get_attribute_by_slug(name)
|
|
||||||
except Attribute.DoesNotExist:
|
|
||||||
raise AttributeError(
|
|
||||||
_('%(obj)s has no EAV attribute named %(attr)s')
|
|
||||||
% dict(obj = self.instance, attr = name)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return self.get_value_by_attribute(attribute).value
|
|
||||||
except Value.DoesNotExist:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return getattr(super(Entity, self), name)
|
|
||||||
|
|
||||||
def get_all_attributes(self):
|
|
||||||
'''
|
|
||||||
Return a query set of all :class:`Attribute` objects that can be set
|
|
||||||
for this entity.
|
|
||||||
'''
|
|
||||||
return self.instance._eav_config_cls.get_attributes().order_by('display_order')
|
|
||||||
|
|
||||||
def _hasattr(self, attribute_slug):
|
|
||||||
'''
|
|
||||||
Since we override __getattr__ with a backdown to the database, this
|
|
||||||
exists as a way of checking whether a user has set a real attribute on
|
|
||||||
ourselves, without going to the db if not.
|
|
||||||
'''
|
|
||||||
return attribute_slug in self.__dict__
|
|
||||||
|
|
||||||
def _getattr(self, attribute_slug):
|
|
||||||
'''
|
|
||||||
Since we override __getattr__ with a backdown to the database, this
|
|
||||||
exists as a way of getting the value a user set for one of our
|
|
||||||
attributes, without going to the db to check.
|
|
||||||
'''
|
|
||||||
return self.__dict__[attribute_slug]
|
|
||||||
|
|
||||||
def save(self):
|
|
||||||
'''
|
|
||||||
Saves all the EAV values that have been set on this entity.
|
|
||||||
'''
|
|
||||||
for attribute in self.get_all_attributes():
|
|
||||||
if self._hasattr(attribute.slug):
|
|
||||||
attribute_value = self._getattr(attribute.slug)
|
|
||||||
attribute.save_value(self.instance, attribute_value)
|
|
||||||
|
|
||||||
def validate_attributes(self):
|
|
||||||
'''
|
|
||||||
Called before :meth:`save`, first validate all the entity values to
|
|
||||||
make sure they can be created / saved cleanly.
|
|
||||||
Raises ``ValidationError`` if they can't be.
|
|
||||||
'''
|
|
||||||
values_dict = self.get_values_dict()
|
|
||||||
|
|
||||||
for attribute in self.get_all_attributes():
|
|
||||||
value = None
|
|
||||||
|
|
||||||
# Value was assigned to this instance.
|
|
||||||
if self._hasattr(attribute.slug):
|
|
||||||
value = self._getattr(attribute.slug)
|
|
||||||
values_dict.pop(attribute.slug, None)
|
|
||||||
# Otherwise try pre-loaded from DB.
|
|
||||||
else:
|
|
||||||
value = values_dict.pop(attribute.slug, None)
|
|
||||||
|
|
||||||
if value is None:
|
|
||||||
if attribute.required:
|
|
||||||
raise ValidationError(
|
|
||||||
_('{} EAV field cannot be blank'.format(attribute.slug))
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
attribute.validate_value(value)
|
|
||||||
except ValidationError as e:
|
|
||||||
raise ValidationError(
|
|
||||||
_('%(attr)s EAV field %(err)s')
|
|
||||||
% dict(attr = attribute.slug, err = e)
|
|
||||||
)
|
|
||||||
|
|
||||||
illegal = values_dict or (
|
|
||||||
self.get_object_attributes() - self.get_all_attribute_slugs())
|
|
||||||
|
|
||||||
if illegal:
|
|
||||||
raise IllegalAssignmentException(
|
|
||||||
'Instance of the class {} cannot have values for attributes: {}.'
|
|
||||||
.format(self.instance.__class__, ', '.join(illegal))
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_values_dict(self):
|
|
||||||
return {v.attribute.slug: v.value for v in self.get_values()}
|
|
||||||
|
|
||||||
def get_values(self):
|
|
||||||
'''
|
|
||||||
Get all set :class:`Value` objects for self.instance
|
|
||||||
'''
|
|
||||||
return Value.objects.filter(
|
|
||||||
entity_ct = self.ct,
|
|
||||||
entity_id = self.instance.pk
|
|
||||||
).select_related()
|
|
||||||
|
|
||||||
def get_all_attribute_slugs(self):
|
|
||||||
'''
|
|
||||||
Returns a list of slugs for all attributes available to this entity.
|
|
||||||
'''
|
|
||||||
return set(self.get_all_attributes().values_list('slug', flat=True))
|
|
||||||
|
|
||||||
def get_attribute_by_slug(self, slug):
|
|
||||||
'''
|
|
||||||
Returns a single :class:`Attribute` with *slug*.
|
|
||||||
'''
|
|
||||||
return self.get_all_attributes().get(slug=slug)
|
|
||||||
|
|
||||||
def get_value_by_attribute(self, attribute):
|
|
||||||
'''
|
|
||||||
Returns a single :class:`Value` for *attribute*.
|
|
||||||
'''
|
|
||||||
return self.get_values().get(attribute=attribute)
|
|
||||||
|
|
||||||
def get_object_attributes(self):
|
|
||||||
'''
|
|
||||||
Returns entity instance attributes, except for
|
|
||||||
``instance`` and ``ct`` which are used internally.
|
|
||||||
'''
|
|
||||||
return set(copy(self.__dict__).keys()) - set(['instance', 'ct'])
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
'''
|
|
||||||
Iterate over set eav values. This would allow you to do::
|
|
||||||
|
|
||||||
for i in m.eav: print(i)
|
|
||||||
'''
|
|
||||||
return iter(self.get_values())
|
|
||||||
25
eav/models/__init__.py
Normal file
25
eav/models/__init__.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""
|
||||||
|
This module defines the four concrete, non-abstract models:
|
||||||
|
* :class:`Value`
|
||||||
|
* :class:`Attribute`
|
||||||
|
* :class:`EnumValue`
|
||||||
|
* :class:`EnumGroup`.
|
||||||
|
|
||||||
|
Along with the :class:`Entity` helper class and :class:`EAVModelMeta`
|
||||||
|
optional metaclass for each eav model class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .attribute import Attribute
|
||||||
|
from .entity import EAVModelMeta, Entity
|
||||||
|
from .enum_group import EnumGroup
|
||||||
|
from .enum_value import EnumValue
|
||||||
|
from .value import Value
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Attribute",
|
||||||
|
"EAVModelMeta",
|
||||||
|
"Entity",
|
||||||
|
"EnumGroup",
|
||||||
|
"EnumValue",
|
||||||
|
"Value",
|
||||||
|
]
|
||||||
367
eav/models/attribute.py
Normal file
367
eav/models/attribute.py
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
# ruff: noqa: UP007
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import ForeignKey
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from eav.fields import EavDatatypeField
|
||||||
|
from eav.logic.entity_pk import get_entity_pk_type
|
||||||
|
from eav.logic.managers import AttributeManager
|
||||||
|
from eav.logic.object_pk import get_pk_format
|
||||||
|
from eav.logic.slug import SLUGFIELD_MAX_LENGTH, generate_slug
|
||||||
|
from eav.settings import CHARFIELD_LENGTH
|
||||||
|
from eav.validators import (
|
||||||
|
validate_bool,
|
||||||
|
validate_csv,
|
||||||
|
validate_date,
|
||||||
|
validate_enum,
|
||||||
|
validate_float,
|
||||||
|
validate_int,
|
||||||
|
validate_json,
|
||||||
|
validate_object,
|
||||||
|
validate_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .enum_value import EnumValue
|
||||||
|
from .value import Value
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .enum_group import EnumGroup
|
||||||
|
|
||||||
|
|
||||||
|
class Attribute(models.Model):
|
||||||
|
"""
|
||||||
|
Putting the **A** in *EAV*. This holds the attributes, or concepts.
|
||||||
|
Examples of possible *Attributes*: color, height, weight, number of
|
||||||
|
children, number of patients, has fever?, etc...
|
||||||
|
|
||||||
|
Each attribute has a name, and a description, along with a slug that must
|
||||||
|
be unique. If you don't provide a slug, a default slug (derived from
|
||||||
|
name), will be created.
|
||||||
|
|
||||||
|
The *required* field is a boolean that indicates whether this EAV attribute
|
||||||
|
is required for entities to which it applies. It defaults to *False*.
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
Just like a normal model field that is required, you will not be able
|
||||||
|
to save or create any entity object for which this attribute applies,
|
||||||
|
without first setting this EAV attribute.
|
||||||
|
|
||||||
|
There are 7 possible values for datatype:
|
||||||
|
|
||||||
|
* int (TYPE_INT)
|
||||||
|
* float (TYPE_FLOAT)
|
||||||
|
* text (TYPE_TEXT)
|
||||||
|
* date (TYPE_DATE)
|
||||||
|
* bool (TYPE_BOOLEAN)
|
||||||
|
* object (TYPE_OBJECT)
|
||||||
|
* enum (TYPE_ENUM)
|
||||||
|
* json (TYPE_JSON)
|
||||||
|
* csv (TYPE_CSV)
|
||||||
|
|
||||||
|
|
||||||
|
Examples::
|
||||||
|
|
||||||
|
Attribute.objects.create(name='Height', datatype=Attribute.TYPE_INT)
|
||||||
|
# = <Attribute: Height (Integer)>
|
||||||
|
|
||||||
|
Attribute.objects.create(name='Color', datatype=Attribute.TYPE_TEXT)
|
||||||
|
# = <Attribute: Color (Text)>
|
||||||
|
|
||||||
|
yes = EnumValue.objects.create(value='yes')
|
||||||
|
no = EnumValue.objects.create(value='no')
|
||||||
|
unknown = EnumValue.objects.create(value='unknown')
|
||||||
|
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
||||||
|
ynu.values.add(yes, no, unknown)
|
||||||
|
|
||||||
|
Attribute.objects.create(name='has fever?',
|
||||||
|
datatype=Attribute.TYPE_ENUM,
|
||||||
|
enum_group=ynu
|
||||||
|
)
|
||||||
|
# = <Attribute: has fever? (Multiple Choice)>
|
||||||
|
|
||||||
|
.. warning:: Once an Attribute has been used by an entity, you can not
|
||||||
|
change it's datatype.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TYPE_TEXT = "text"
|
||||||
|
TYPE_FLOAT = "float"
|
||||||
|
TYPE_INT = "int"
|
||||||
|
TYPE_DATE = "date"
|
||||||
|
TYPE_BOOLEAN = "bool"
|
||||||
|
TYPE_OBJECT = "object"
|
||||||
|
TYPE_ENUM = "enum"
|
||||||
|
TYPE_JSON = "json"
|
||||||
|
TYPE_CSV = "csv"
|
||||||
|
|
||||||
|
DATATYPE_CHOICES = (
|
||||||
|
(TYPE_TEXT, _("Text")),
|
||||||
|
(TYPE_DATE, _("Date")),
|
||||||
|
(TYPE_FLOAT, _("Float")),
|
||||||
|
(TYPE_INT, _("Integer")),
|
||||||
|
(TYPE_BOOLEAN, _("True / False")),
|
||||||
|
(TYPE_OBJECT, _("Django Object")),
|
||||||
|
(TYPE_ENUM, _("Multiple Choice")),
|
||||||
|
(TYPE_JSON, _("JSON Object")),
|
||||||
|
(TYPE_CSV, _("Comma-Separated-Value")),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Core attributes
|
||||||
|
id = get_pk_format()
|
||||||
|
|
||||||
|
datatype = EavDatatypeField(
|
||||||
|
choices=DATATYPE_CHOICES,
|
||||||
|
max_length=6,
|
||||||
|
verbose_name=_("Data Type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=CHARFIELD_LENGTH,
|
||||||
|
help_text=_("User-friendly attribute name"),
|
||||||
|
verbose_name=_("Name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
"""
|
||||||
|
Main identifier for the attribute.
|
||||||
|
Upon creation, slug is autogenerated from the name.
|
||||||
|
(see :meth:`~eav.fields.EavSlugField.create_slug_from_name`).
|
||||||
|
"""
|
||||||
|
slug = models.SlugField(
|
||||||
|
max_length=SLUGFIELD_MAX_LENGTH,
|
||||||
|
db_index=True,
|
||||||
|
unique=True,
|
||||||
|
help_text=_("Short unique attribute label"),
|
||||||
|
verbose_name=_("Slug"),
|
||||||
|
)
|
||||||
|
|
||||||
|
"""
|
||||||
|
.. warning::
|
||||||
|
This attribute should be used with caution. Setting this to *True*
|
||||||
|
means that *all* entities that *can* have this attribute will
|
||||||
|
be required to have a value for it.
|
||||||
|
"""
|
||||||
|
required = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_("Required"),
|
||||||
|
)
|
||||||
|
|
||||||
|
entity_ct = models.ManyToManyField(
|
||||||
|
ContentType,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("Entity content type"),
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
This field allows you to specify a relationship with any number of content types.
|
||||||
|
This would be useful, for example, if you wanted an attribute to apply only to
|
||||||
|
a subset of entities. In that case, you could filter by content type in the
|
||||||
|
:meth:`~eav.registry.EavConfig.get_attributes` method of that entity's config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enum_group: ForeignKey[Optional[EnumGroup]] = ForeignKey(
|
||||||
|
"eav.EnumGroup",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_("Choice Group"),
|
||||||
|
)
|
||||||
|
|
||||||
|
description = models.CharField(
|
||||||
|
max_length=256,
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text=_("Short description"),
|
||||||
|
verbose_name=_("Description"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Useful meta-information
|
||||||
|
|
||||||
|
display_order = models.PositiveIntegerField(
|
||||||
|
default=1,
|
||||||
|
verbose_name=_("Display order"),
|
||||||
|
)
|
||||||
|
|
||||||
|
modified = models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
verbose_name=_("Modified"),
|
||||||
|
)
|
||||||
|
|
||||||
|
created = models.DateTimeField(
|
||||||
|
default=timezone.now,
|
||||||
|
editable=False,
|
||||||
|
verbose_name=_("Created"),
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = AttributeManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ("name",)
|
||||||
|
verbose_name = _("Attribute")
|
||||||
|
verbose_name_plural = _("Attributes")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.name} ({self.get_datatype_display()})"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Saves the Attribute and auto-generates a slug field
|
||||||
|
if one wasn't provided.
|
||||||
|
"""
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = generate_slug(self.name)
|
||||||
|
|
||||||
|
self.full_clean()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def natural_key(self) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Retrieve the natural key for the Attribute instance.
|
||||||
|
|
||||||
|
The natural key for an Attribute is defined by its `name` and `slug`. This
|
||||||
|
method returns a tuple containing these two attributes of the instance.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tuple: A tuple containing the name and slug of the Attribute instance.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
self.name,
|
||||||
|
self.slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def help_text(self):
|
||||||
|
return self.description
|
||||||
|
|
||||||
|
def get_validators(self):
|
||||||
|
"""
|
||||||
|
Returns the appropriate validator function from :mod:`~eav.validators`
|
||||||
|
as a list (of length one) for the datatype.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
The reason it returns it as a list, is eventually we may want this
|
||||||
|
method to look elsewhere for additional attribute specific
|
||||||
|
validators to return as well as the default, built-in one.
|
||||||
|
"""
|
||||||
|
datatype_validators = {
|
||||||
|
"text": validate_text,
|
||||||
|
"float": validate_float,
|
||||||
|
"int": validate_int,
|
||||||
|
"date": validate_date,
|
||||||
|
"bool": validate_bool,
|
||||||
|
"object": validate_object,
|
||||||
|
"enum": validate_enum,
|
||||||
|
"json": validate_json,
|
||||||
|
"csv": validate_csv,
|
||||||
|
}
|
||||||
|
|
||||||
|
return [datatype_validators[self.datatype]]
|
||||||
|
|
||||||
|
def validate_value(self, value):
|
||||||
|
"""
|
||||||
|
Check *value* against the validators returned by
|
||||||
|
:meth:`get_validators` for this attribute.
|
||||||
|
"""
|
||||||
|
for validator in self.get_validators():
|
||||||
|
validator(value)
|
||||||
|
|
||||||
|
if self.datatype == self.TYPE_ENUM:
|
||||||
|
if isinstance(value, EnumValue):
|
||||||
|
value = value.value
|
||||||
|
if not self.enum_group.values.filter(value=value).exists():
|
||||||
|
raise ValidationError(
|
||||||
|
_("%(val)s is not a valid choice for %(attr)s")
|
||||||
|
% {"val": value, "attr": self},
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""
|
||||||
|
Validates the attribute. Will raise ``ValidationError`` if the
|
||||||
|
attribute's datatype is *TYPE_ENUM* and enum_group is not set, or if
|
||||||
|
the attribute is not *TYPE_ENUM* and the enum group is set.
|
||||||
|
"""
|
||||||
|
if self.datatype == self.TYPE_ENUM and not self.enum_group:
|
||||||
|
raise ValidationError(
|
||||||
|
_("You must set the choice group for multiple choice attributes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.datatype != self.TYPE_ENUM and self.enum_group:
|
||||||
|
raise ValidationError(
|
||||||
|
_("You can only assign a choice group to multiple choice attributes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_fields(self, exclude=None):
|
||||||
|
"""Perform field-specific validation on the model's fields.
|
||||||
|
|
||||||
|
This method extends the default field cleaning process to include
|
||||||
|
custom validation for the slug field.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
exclude (list): Fields to exclude from cleaning.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If the slug is not a valid Python identifier.
|
||||||
|
"""
|
||||||
|
super().clean_fields(exclude=exclude)
|
||||||
|
|
||||||
|
if not self.slug.isidentifier():
|
||||||
|
warnings.warn(
|
||||||
|
f"Slug '{self.slug}' is not a valid Python identifier. "
|
||||||
|
+ "Consider updating it.",
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_choices(self):
|
||||||
|
"""
|
||||||
|
Returns a query set of :class:`EnumValue` objects for this attribute.
|
||||||
|
Returns None if the datatype of this attribute is not *TYPE_ENUM*.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
self.enum_group.values.all()
|
||||||
|
if self.datatype == Attribute.TYPE_ENUM
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_value(self, entity, value):
|
||||||
|
"""
|
||||||
|
Called with *entity*, any Django object registered with eav, and
|
||||||
|
*value*, the :class:`Value` this attribute for *entity* should
|
||||||
|
be set to.
|
||||||
|
|
||||||
|
If a :class:`Value` object for this *entity* and attribute doesn't
|
||||||
|
exist, one will be created.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
If *value* is None and a :class:`Value` object exists for this
|
||||||
|
Attribute and *entity*, it will delete that :class:`Value` object.
|
||||||
|
"""
|
||||||
|
ct = ContentType.objects.get_for_model(entity)
|
||||||
|
|
||||||
|
entity_filter = {
|
||||||
|
"entity_ct": ct,
|
||||||
|
"attribute": self,
|
||||||
|
f"{get_entity_pk_type(entity)}": entity.pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
value_obj = self.value_set.get(**entity_filter)
|
||||||
|
except Value.DoesNotExist:
|
||||||
|
if value is None or value == "":
|
||||||
|
return
|
||||||
|
|
||||||
|
value_obj = Value.objects.create(**entity_filter)
|
||||||
|
|
||||||
|
if value is None or value == "":
|
||||||
|
value_obj.delete()
|
||||||
|
return
|
||||||
|
|
||||||
|
if value != value_obj.value:
|
||||||
|
value_obj.value = value
|
||||||
|
value_obj.save()
|
||||||
208
eav/models/entity.py
Normal file
208
eav/models/entity.py
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
from copy import copy
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db.models.base import ModelBase
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from eav import register
|
||||||
|
from eav.exceptions import IllegalAssignmentException
|
||||||
|
from eav.logic.entity_pk import get_entity_pk_type
|
||||||
|
|
||||||
|
from .attribute import Attribute
|
||||||
|
from .enum_value import EnumValue
|
||||||
|
from .value import Value
|
||||||
|
|
||||||
|
|
||||||
|
class Entity:
|
||||||
|
"""Helper class that will be attached to entities registered with eav."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def pre_save_handler(sender, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Pre save handler attached to self.instance. Called before the
|
||||||
|
model instance we are attached to is saved. This allows us to call
|
||||||
|
:meth:`validate_attributes` before the entity is saved.
|
||||||
|
"""
|
||||||
|
instance = kwargs["instance"]
|
||||||
|
entity = getattr(kwargs["instance"], instance._eav_config_cls.eav_attr) # noqa: SLF001
|
||||||
|
entity.validate_attributes()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def post_save_handler(sender, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Post save handler attached to self.instance. Calls :meth:`save` when
|
||||||
|
the model instance we are attached to is saved.
|
||||||
|
"""
|
||||||
|
instance = kwargs["instance"]
|
||||||
|
entity = getattr(instance, instance._eav_config_cls.eav_attr) # noqa: SLF001
|
||||||
|
entity.save()
|
||||||
|
|
||||||
|
def __init__(self, instance) -> None:
|
||||||
|
"""
|
||||||
|
Set self.instance equal to the instance of the model that we're attached
|
||||||
|
to. Also, store the content type of that instance.
|
||||||
|
"""
|
||||||
|
self.instance = instance
|
||||||
|
self.ct = ContentType.objects.get_for_model(instance)
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
"""
|
||||||
|
The magic getattr helper. This is called whenever user invokes::
|
||||||
|
|
||||||
|
instance.<attribute>
|
||||||
|
|
||||||
|
Checks if *name* is a valid slug for attributes available to this
|
||||||
|
instances. If it is, tries to lookup the :class:`Value` with that
|
||||||
|
attribute slug. If there is one, it returns the value of the
|
||||||
|
class:`Value` object, otherwise it hasn't been set, so it returns
|
||||||
|
None.
|
||||||
|
"""
|
||||||
|
if not name.startswith("_"):
|
||||||
|
try:
|
||||||
|
attribute = self.get_attribute_by_slug(name)
|
||||||
|
except Attribute.DoesNotExist as err:
|
||||||
|
raise AttributeError(
|
||||||
|
_("%(obj)s has no EAV attribute named %(attr)s")
|
||||||
|
% {"obj": self.instance, "attr": name},
|
||||||
|
) from err
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.get_value_by_attribute(attribute).value
|
||||||
|
except Value.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return getattr(super(), name)
|
||||||
|
|
||||||
|
def get_all_attributes(self):
|
||||||
|
"""
|
||||||
|
Return a query set of all :class:`Attribute` objects that can be set
|
||||||
|
for this entity.
|
||||||
|
"""
|
||||||
|
return self.instance._eav_config_cls.get_attributes( # noqa: SLF001
|
||||||
|
instance=self.instance,
|
||||||
|
).order_by("display_order")
|
||||||
|
|
||||||
|
def _hasattr(self, attribute_slug):
|
||||||
|
"""
|
||||||
|
Since we override __getattr__ with a backdown to the database, this
|
||||||
|
exists as a way of checking whether a user has set a real attribute on
|
||||||
|
ourselves, without going to the db if not.
|
||||||
|
"""
|
||||||
|
return attribute_slug in self.__dict__
|
||||||
|
|
||||||
|
def _getattr(self, attribute_slug):
|
||||||
|
"""
|
||||||
|
Since we override __getattr__ with a backdown to the database, this
|
||||||
|
exists as a way of getting the value a user set for one of our
|
||||||
|
attributes, without going to the db to check.
|
||||||
|
"""
|
||||||
|
return self.__dict__[attribute_slug]
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
"""Saves all the EAV values that have been set on this entity."""
|
||||||
|
for attribute in self.get_all_attributes():
|
||||||
|
if self._hasattr(attribute.slug):
|
||||||
|
attribute_value = self._getattr(attribute.slug)
|
||||||
|
if (
|
||||||
|
attribute.datatype == Attribute.TYPE_ENUM
|
||||||
|
and not isinstance(
|
||||||
|
attribute_value,
|
||||||
|
EnumValue,
|
||||||
|
)
|
||||||
|
and attribute_value is not None
|
||||||
|
):
|
||||||
|
attribute_value = EnumValue.objects.get(value=attribute_value)
|
||||||
|
attribute.save_value(self.instance, attribute_value)
|
||||||
|
|
||||||
|
def validate_attributes(self):
|
||||||
|
"""
|
||||||
|
Called before :meth:`save`, first validate all the entity values to
|
||||||
|
make sure they can be created / saved cleanly.
|
||||||
|
Raises ``ValidationError`` if they can't be.
|
||||||
|
"""
|
||||||
|
values_dict = self.get_values_dict()
|
||||||
|
|
||||||
|
for attribute in self.get_all_attributes():
|
||||||
|
value = None
|
||||||
|
|
||||||
|
# Value was assigned to this instance.
|
||||||
|
if self._hasattr(attribute.slug):
|
||||||
|
value = self._getattr(attribute.slug)
|
||||||
|
values_dict.pop(attribute.slug, None)
|
||||||
|
# Otherwise try pre-loaded from DB.
|
||||||
|
else:
|
||||||
|
value = values_dict.pop(attribute.slug, None)
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
if attribute.required:
|
||||||
|
raise ValidationError(
|
||||||
|
_("%s EAV field cannot be blank") % attribute.slug,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
attribute.validate_value(value)
|
||||||
|
except ValidationError as err:
|
||||||
|
raise ValidationError(
|
||||||
|
_("%(attr)s EAV field %(err)s")
|
||||||
|
% {"attr": attribute.slug, "err": err},
|
||||||
|
) from err
|
||||||
|
|
||||||
|
illegal = values_dict or (
|
||||||
|
self.get_object_attributes() - self.get_all_attribute_slugs()
|
||||||
|
)
|
||||||
|
|
||||||
|
if illegal:
|
||||||
|
message = (
|
||||||
|
"Instance of the class {} cannot have values for attributes: {}."
|
||||||
|
).format(
|
||||||
|
self.instance.__class__,
|
||||||
|
", ".join(illegal),
|
||||||
|
)
|
||||||
|
raise IllegalAssignmentException(message)
|
||||||
|
|
||||||
|
def get_values_dict(self):
|
||||||
|
return {v.attribute.slug: v.value for v in self.get_values()}
|
||||||
|
|
||||||
|
def get_values(self):
|
||||||
|
"""Get all set :class:`Value` objects for self.instance."""
|
||||||
|
entity_filter = {
|
||||||
|
"entity_ct": self.ct,
|
||||||
|
f"{get_entity_pk_type(self.instance)}": self.instance.pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Value.objects.filter(**entity_filter).select_related()
|
||||||
|
|
||||||
|
def get_all_attribute_slugs(self):
|
||||||
|
"""Returns a list of slugs for all attributes available to this entity."""
|
||||||
|
return set(self.get_all_attributes().values_list("slug", flat=True))
|
||||||
|
|
||||||
|
def get_attribute_by_slug(self, slug):
|
||||||
|
"""Returns a single :class:`Attribute` with *slug*."""
|
||||||
|
return self.get_all_attributes().get(slug=slug)
|
||||||
|
|
||||||
|
def get_value_by_attribute(self, attribute):
|
||||||
|
"""Returns a single :class:`Value` for *attribute*."""
|
||||||
|
return self.get_values().get(attribute=attribute)
|
||||||
|
|
||||||
|
def get_object_attributes(self):
|
||||||
|
"""
|
||||||
|
Returns entity instance attributes, except for
|
||||||
|
``instance`` and ``ct`` which are used internally.
|
||||||
|
"""
|
||||||
|
return set(copy(self.__dict__).keys()) - {"instance", "ct"}
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
"""
|
||||||
|
Iterate over set eav values. This would allow you to do::
|
||||||
|
|
||||||
|
for i in m.eav: print(i)
|
||||||
|
"""
|
||||||
|
return iter(self.get_values())
|
||||||
|
|
||||||
|
|
||||||
|
class EAVModelMeta(ModelBase):
|
||||||
|
def __new__(cls, name, bases, namespace, **kwds):
|
||||||
|
result = super().__new__(cls, name, bases, dict(namespace))
|
||||||
|
register(result)
|
||||||
|
return result
|
||||||
63
eav/models/enum_group.py
Normal file
63
eav/models/enum_group.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import ManyToManyField
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from eav.logic.managers import EnumGroupManager
|
||||||
|
from eav.logic.object_pk import get_pk_format
|
||||||
|
from eav.settings import CHARFIELD_LENGTH
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .enum_value import EnumValue
|
||||||
|
|
||||||
|
|
||||||
|
class EnumGroup(models.Model):
|
||||||
|
"""
|
||||||
|
*EnumGroup* objects have two fields - a *name* ``CharField`` and *values*,
|
||||||
|
a ``ManyToManyField`` to :class:`EnumValue`. :class:`Attribute` classes
|
||||||
|
with datatype *TYPE_ENUM* have a ``ForeignKey`` field to *EnumGroup*.
|
||||||
|
|
||||||
|
See :class:`EnumValue` for an example.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = get_pk_format()
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
unique=True,
|
||||||
|
max_length=CHARFIELD_LENGTH,
|
||||||
|
verbose_name=_("Name"),
|
||||||
|
)
|
||||||
|
values: ManyToManyField[EnumValue, Any] = ManyToManyField(
|
||||||
|
"eav.EnumValue",
|
||||||
|
verbose_name=_("Enum group"),
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = EnumGroupManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("EnumGroup")
|
||||||
|
verbose_name_plural = _("EnumGroups")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""String representation of `EnumGroup` instance."""
|
||||||
|
return str(self.name)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of `EnumGroup` object."""
|
||||||
|
return f"<EnumGroup {self.name}>"
|
||||||
|
|
||||||
|
def natural_key(self) -> tuple[str]:
|
||||||
|
"""
|
||||||
|
Retrieve the natural key for the EnumGroup instance.
|
||||||
|
|
||||||
|
The natural key for an EnumGroup is defined by its `name`. This method
|
||||||
|
returns the name of the instance as a single-element tuple.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tuple: A tuple containing the name of the EnumGroup instance.
|
||||||
|
"""
|
||||||
|
return (self.name,)
|
||||||
74
eav/models/enum_value.py
Normal file
74
eav/models/enum_value.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from eav.logic.managers import EnumValueManager
|
||||||
|
from eav.logic.object_pk import get_pk_format
|
||||||
|
from eav.logic.slug import SLUGFIELD_MAX_LENGTH
|
||||||
|
|
||||||
|
|
||||||
|
class EnumValue(models.Model):
|
||||||
|
"""
|
||||||
|
*EnumValue* objects are the value 'choices' to multiple choice *TYPE_ENUM*
|
||||||
|
:class:`Attribute` objects. They have only one field, *value*, a
|
||||||
|
``CharField`` that must be unique.
|
||||||
|
|
||||||
|
For example::
|
||||||
|
|
||||||
|
yes = EnumValue.objects.create(value='Yes') # doctest: SKIP
|
||||||
|
no = EnumValue.objects.create(value='No')
|
||||||
|
unknown = EnumValue.objects.create(value='Unknown')
|
||||||
|
|
||||||
|
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
||||||
|
ynu.values.add(yes, no, unknown)
|
||||||
|
|
||||||
|
Attribute.objects.create(name='has fever?',
|
||||||
|
datatype=Attribute.TYPE_ENUM, enum_group=ynu)
|
||||||
|
# = <Attribute: has fever? (Multiple Choice)>
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
The same *EnumValue* objects should be reused within multiple
|
||||||
|
*EnumGroups*. For example, if you have one *EnumGroup* called: *Yes /
|
||||||
|
No / Unknown* and another called *Yes / No / Not applicable*, you should
|
||||||
|
only have a total of four *EnumValues* objects, as you should have used
|
||||||
|
the same *Yes* and *No* *EnumValues* for both *EnumGroups*.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = get_pk_format()
|
||||||
|
|
||||||
|
value = models.CharField(
|
||||||
|
_("Value"),
|
||||||
|
db_index=True,
|
||||||
|
unique=True,
|
||||||
|
max_length=SLUGFIELD_MAX_LENGTH,
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = EnumValueManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("EnumValue")
|
||||||
|
verbose_name_plural = _("EnumValues")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""String representation of `EnumValue` instance."""
|
||||||
|
return str(
|
||||||
|
self.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of `EnumValue` object."""
|
||||||
|
return f"<EnumValue {self.value}>"
|
||||||
|
|
||||||
|
def natural_key(self) -> tuple[str]:
|
||||||
|
"""
|
||||||
|
Retrieve the natural key for the EnumValue instance.
|
||||||
|
|
||||||
|
The natural key for an EnumValue is defined by its `value`. This method returns
|
||||||
|
the value of the instance as a single-element tuple.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tuple: A tuple containing the value of the EnumValue instance.
|
||||||
|
"""
|
||||||
|
return (self.value,)
|
||||||
232
eav/models/value.py
Normal file
232
eav/models/value.py
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
# ruff: noqa: UP007
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, ClassVar, Optional
|
||||||
|
|
||||||
|
from django.contrib.contenttypes import fields as generic
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import ForeignKey
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from eav.fields import CSVField
|
||||||
|
from eav.logic.managers import ValueManager
|
||||||
|
from eav.logic.object_pk import get_pk_format
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .attribute import Attribute
|
||||||
|
from .enum_value import EnumValue
|
||||||
|
|
||||||
|
|
||||||
|
class Value(models.Model):
|
||||||
|
"""
|
||||||
|
Putting the **V** in *EAV*.
|
||||||
|
|
||||||
|
This model stores the value for one particular :class:`Attribute` for
|
||||||
|
some entity.
|
||||||
|
|
||||||
|
As with most EAV implementations, most of the columns of this model will
|
||||||
|
be blank, as onle one *value_* field will be used.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
import eav
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
eav.register(User)
|
||||||
|
|
||||||
|
u = User.objects.create(username='crazy_dev_user')
|
||||||
|
a = Attribute.objects.create(name='Fav Drink', datatype='text')
|
||||||
|
|
||||||
|
Value.objects.create(entity = u, attribute = a, value_text = 'red bull')
|
||||||
|
# = <Value: crazy_dev_user - Fav Drink: "red bull">
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = get_pk_format()
|
||||||
|
|
||||||
|
# Direct foreign keys
|
||||||
|
attribute: ForeignKey[Attribute] = ForeignKey(
|
||||||
|
"eav.Attribute",
|
||||||
|
db_index=True,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
verbose_name=_("Attribute"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Entity generic relationships. Rather than rely on database casting,
|
||||||
|
# this will instead use a separate ForeignKey field attribute that matches
|
||||||
|
# the FK type of the entity.
|
||||||
|
entity_id = models.IntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_("Entity id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
entity_uuid = models.UUIDField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_("Entity uuid"),
|
||||||
|
)
|
||||||
|
|
||||||
|
entity_ct = ForeignKey(
|
||||||
|
ContentType,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="value_entities",
|
||||||
|
verbose_name=_("Entity ct"),
|
||||||
|
)
|
||||||
|
|
||||||
|
entity_pk_int = generic.GenericForeignKey(
|
||||||
|
ct_field="entity_ct",
|
||||||
|
fk_field="entity_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
entity_pk_uuid = generic.GenericForeignKey(
|
||||||
|
ct_field="entity_ct",
|
||||||
|
fk_field="entity_uuid",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Model attributes
|
||||||
|
created = models.DateTimeField(
|
||||||
|
default=timezone.now,
|
||||||
|
verbose_name=_("Created"),
|
||||||
|
)
|
||||||
|
|
||||||
|
modified = models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
verbose_name=_("Modified"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Value attributes
|
||||||
|
value_bool = models.BooleanField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_("Value bool"),
|
||||||
|
)
|
||||||
|
value_csv = CSVField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_("Value CSV"),
|
||||||
|
)
|
||||||
|
value_date = models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_("Value date"),
|
||||||
|
)
|
||||||
|
value_float = models.FloatField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_("Value float"),
|
||||||
|
)
|
||||||
|
value_int = models.BigIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_("Value int"),
|
||||||
|
)
|
||||||
|
value_text = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
verbose_name=_("Value text"),
|
||||||
|
)
|
||||||
|
|
||||||
|
value_json = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
encoder=DjangoJSONEncoder,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_("Value JSON"),
|
||||||
|
)
|
||||||
|
|
||||||
|
value_enum: ForeignKey[Optional[EnumValue]] = ForeignKey(
|
||||||
|
"eav.EnumValue",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="eav_values",
|
||||||
|
verbose_name=_("Value enum"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Value object relationship
|
||||||
|
generic_value_id = models.IntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_("Generic value id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
generic_value_ct = ForeignKey(
|
||||||
|
ContentType,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="value_values",
|
||||||
|
verbose_name=_("Generic value content type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
value_object = generic.GenericForeignKey(
|
||||||
|
ct_field="generic_value_ct",
|
||||||
|
fk_field="generic_value_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = ValueManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Value")
|
||||||
|
verbose_name_plural = _("Values")
|
||||||
|
|
||||||
|
constraints: ClassVar[list[models.Constraint]] = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["entity_ct", "attribute", "entity_uuid"],
|
||||||
|
name="unique_entity_uuid_per_attribute",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["entity_ct", "attribute", "entity_id"],
|
||||||
|
name="unique_entity_id_per_attribute",
|
||||||
|
),
|
||||||
|
models.CheckConstraint(
|
||||||
|
check=(
|
||||||
|
models.Q(entity_id__isnull=False, entity_uuid__isnull=True)
|
||||||
|
| models.Q(entity_id__isnull=True, entity_uuid__isnull=False)
|
||||||
|
),
|
||||||
|
name="ensure_entity_id_xor_entity_uuid",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""String representation of a Value."""
|
||||||
|
entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int
|
||||||
|
return f'{self.attribute.name}: "{self.value}" ({entity})'
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""Representation of Value object."""
|
||||||
|
entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int
|
||||||
|
return f'{self.attribute.name}: "{self.value}" ({entity})'
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Validate and save this value."""
|
||||||
|
self.full_clean()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def natural_key(self) -> tuple[tuple[str, str], int, str]:
|
||||||
|
"""
|
||||||
|
Retrieve the natural key for the Value instance.
|
||||||
|
|
||||||
|
The natural key for a Value is a combination of its `attribute` natural key,
|
||||||
|
`entity_id`, and `entity_uuid`. This method returns a tuple containing these
|
||||||
|
three elements.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tuple: A tuple containing the natural key of the attribute, entity ID,
|
||||||
|
and entity UUID of the Value instance.
|
||||||
|
"""
|
||||||
|
return (self.attribute.natural_key(), self.entity_id, self.entity_uuid)
|
||||||
|
|
||||||
|
def _get_value(self):
|
||||||
|
"""Return the python object this value is holding."""
|
||||||
|
return getattr(self, f"value_{self.attribute.datatype}")
|
||||||
|
|
||||||
|
def _set_value(self, new_value):
|
||||||
|
"""Set the object this value is holding."""
|
||||||
|
setattr(self, f"value_{self.attribute.datatype}", new_value)
|
||||||
|
|
||||||
|
value = property(_get_value, _set_value)
|
||||||
317
eav/queryset.py
317
eav/queryset.py
|
|
@ -1,102 +1,106 @@
|
||||||
# -*- coding: utf-8 -*-
|
"""
|
||||||
|
This module contains custom :class:`EavQuerySet` class used for overriding
|
||||||
'''
|
|
||||||
Queryset.
|
|
||||||
|
|
||||||
This module contains custom EavQuerySet class used for overriding
|
|
||||||
relational operators and pure functions for rewriting Q-expressions.
|
relational operators and pure functions for rewriting Q-expressions.
|
||||||
Q-expressions need to be rewritten for two reasons:
|
Q-expressions need to be rewritten for two reasons:
|
||||||
|
|
||||||
1. In order to hide implementation from the user and provide easy to use
|
1. In order to hide implementation from the user and provide easy to use
|
||||||
syntax sugar, i.e.::
|
syntax sugar, i.e.::
|
||||||
|
|
||||||
Supplier.objects.filter(eav__city__startswith='New')
|
Supplier.objects.filter(eav__city__startswith='New')
|
||||||
|
|
||||||
instead of::
|
instead of::
|
||||||
|
|
||||||
city_values = Value.objects.filter(value__text__startswith='New')
|
city_values = Value.objects.filter(value__text__startswith='New')
|
||||||
Supplier.objects.filter(eav_values__in=city_values)
|
Supplier.objects.filter(eav_values__in=city_values)
|
||||||
|
|
||||||
For details see: ``eav_filter``.
|
For details see: :func:`eav_filter`.
|
||||||
|
|
||||||
2. To ensure that Q-expression tree is compiled to valid SQL.
|
2. To ensure that Q-expression tree is compiled to valid SQL.
|
||||||
For details see: ``rewrite_q_expr``.
|
For details see: :func:`rewrite_q_expr`.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from itertools import count
|
||||||
|
|
||||||
from django.db import models
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.db.models import Q
|
from django.db.models import Case, IntegerField, Q, When
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
|
from django.db.utils import NotSupportedError
|
||||||
|
|
||||||
from .models import Attribute, Value
|
from eav.models import Attribute, EnumValue, Value
|
||||||
|
|
||||||
|
|
||||||
def is_eav_and_leaf(expr, gr_name):
|
def is_eav_and_leaf(expr, gr_name):
|
||||||
'''
|
"""
|
||||||
Checks whether Q-expression is an EAV AND leaf.
|
Checks whether Q-expression is an EAV AND leaf.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
expr (Q | tuple): Q-expression to be checked.
|
expr (Union[Q, tuple]): Q-expression to be checked.
|
||||||
gr_name (str): Generic relation attribute name, by default 'eav_values'
|
gr_name (str): Generic relation attribute name, by default 'eav_values'
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool
|
bool
|
||||||
'''
|
"""
|
||||||
return (getattr(expr, 'connector', None) == 'AND' and
|
return (
|
||||||
len(expr.children) == 1 and
|
getattr(expr, "connector", None) == "AND"
|
||||||
expr.children[0][0] in ['pk__in', '{}__in'.format(gr_name)])
|
and len(expr.children) == 1
|
||||||
|
and expr.children[0][0] in ["pk__in", f"{gr_name}__in"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def rewrite_q_expr(model_cls, expr):
|
def rewrite_q_expr(model_cls, expr):
|
||||||
'''
|
"""
|
||||||
Rewrites Q-expression to safe form, in order to ensure that
|
Rewrites Q-expression to safe form, in order to ensure that
|
||||||
generated SQL is valid.
|
generated SQL is valid.
|
||||||
|
|
||||||
Suppose we have the following Q-expression:
|
IGNORE:
|
||||||
|
Suppose we have the following Q-expression:
|
||||||
|
|
||||||
└── OR
|
└── OR
|
||||||
├── AND
|
├── AND
|
||||||
│ └── eav_values__in [1, 2, 3]
|
│ └── eav_values__in [1, 2, 3]
|
||||||
└── AND (1)
|
└── AND (1)
|
||||||
├── AND
|
├── AND
|
||||||
│ └── eav_values__in [4, 5]
|
│ └── eav_values__in [4, 5]
|
||||||
└── AND
|
└── AND
|
||||||
└── eav_values__in [6, 7, 8]
|
└── eav_values__in [6, 7, 8]
|
||||||
|
IGNORE
|
||||||
|
|
||||||
All EAV values are stored in a single table. Therefore, INNER JOIN
|
All EAV values are stored in a single table. Therefore, INNER JOIN
|
||||||
generated for the AND-expression (1) will always fail, i.e.
|
generated for the AND-expression (1) will always fail, i.e.
|
||||||
single row in a eav_values table cannot be both in two disjoint sets at
|
single row in a eav_values table cannot be both in two disjoint sets at
|
||||||
the same time (and the whole point of using AND, usually, is two have
|
the same time (and the whole point of using AND, usually, is two have
|
||||||
two different sets). Therefore, we must paritially rewrite the
|
two different sets). Therefore, we must paritially rewrite the
|
||||||
expression so that the generated SQL is valid::
|
expression so that the generated SQL is valid.
|
||||||
|
|
||||||
└── OR
|
IGNORE:
|
||||||
├── AND
|
└── OR
|
||||||
│ └── eav_values__in [1, 2, 3]
|
├── AND
|
||||||
└── AND
|
│ └── eav_values__in [1, 2, 3]
|
||||||
└── pk__in [1, 2]
|
└── AND
|
||||||
|
└── pk__in [1, 2]
|
||||||
|
IGNORE
|
||||||
|
|
||||||
This is done by merging dangerous AND's and substituting them with
|
This is done by merging dangerous AND's and substituting them with
|
||||||
explicit ``pk__in`` filter, where pks are taken from evaluted
|
explicit ``pk__in`` filter, where pks are taken from evaluated
|
||||||
Q-expr branch.
|
Q-expr branch.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_cls (Model class): model class used to construct QuerySet
|
model_cls (TypeVar): model class used to construct :meth:`QuerySet`
|
||||||
from leaf attribute-value expression.
|
from leaf attribute-value expression.
|
||||||
expr: (Q | tuple): Q-expression (or attr-val leaf) to be rewritten.
|
expr: (Q | tuple): Q-expression (or attr-val leaf) to be rewritten.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Q | tuple
|
Union[Q, tuple]
|
||||||
'''
|
"""
|
||||||
# Node in a Q-expr can be a Q or an attribute-value tuple (leaf).
|
# Node in a Q-expr can be a Q or an attribute-value tuple (leaf).
|
||||||
# We are only interested in Qs.
|
# We are only interested in Qs.
|
||||||
|
|
||||||
if isinstance(expr, Q):
|
if isinstance(expr, Q):
|
||||||
config_cls = getattr(model_cls, '_eav_config_cls', None)
|
config_cls = getattr(model_cls, "_eav_config_cls", None)
|
||||||
gr_name = config_cls.generic_relation_attr
|
gr_name = config_cls.generic_relation_attr
|
||||||
|
|
||||||
# Recurively check child nodes.
|
# Recursively check child nodes.
|
||||||
expr.children = [rewrite_q_expr(model_cls, c) for c in expr.children]
|
expr.children = [rewrite_q_expr(model_cls, c) for c in expr.children]
|
||||||
# Check which ones need a rewrite.
|
# Check which ones need a rewrite.
|
||||||
rewritable = [c for c in expr.children if is_eav_and_leaf(c, gr_name)]
|
rewritable = [c for c in expr.children if is_eav_and_leaf(c, gr_name)]
|
||||||
|
|
@ -107,18 +111,18 @@ def rewrite_q_expr(model_cls, expr):
|
||||||
if len(rewritable) > 1:
|
if len(rewritable) > 1:
|
||||||
q = None
|
q = None
|
||||||
# Save nodes which shouldn't be merged (non-EAV).
|
# Save nodes which shouldn't be merged (non-EAV).
|
||||||
other = [c for c in expr.children if not c in rewritable]
|
other = [c for c in expr.children if c not in rewritable]
|
||||||
|
|
||||||
for child in rewritable:
|
for child in rewritable:
|
||||||
if not (child.children and len(child.children) == 1):
|
if not (child.children and len(child.children) == 1):
|
||||||
raise AssertionError('Child must have exactly one descendant')
|
raise AssertionError("Child must have exactly one descendant")
|
||||||
# Child to be merged is always a terminal Q node,
|
# Child to be merged is always a terminal Q node,
|
||||||
# i.e. it's an AND expression with attribute-value tuple child.
|
# i.e. it's an AND expression with attribute-value tuple child.
|
||||||
attrval = child.children[0]
|
attrval = child.children[0]
|
||||||
if not isinstance(attrval, tuple):
|
if not isinstance(attrval, tuple):
|
||||||
raise AssertionError('Attribute-value must be a tuple')
|
raise TypeError("Attribute-value must be a tuple")
|
||||||
|
|
||||||
fname = '{}__in'.format(gr_name)
|
fname = f"{gr_name}__in"
|
||||||
|
|
||||||
# Child can be either a 'eav_values__in' or 'pk__in' query.
|
# Child can be either a 'eav_values__in' or 'pk__in' query.
|
||||||
# If it's the former then transform it into the latter.
|
# If it's the former then transform it into the latter.
|
||||||
|
|
@ -126,7 +130,7 @@ def rewrite_q_expr(model_cls, expr):
|
||||||
# If so, reverse it back to QuerySet so that set operators
|
# If so, reverse it back to QuerySet so that set operators
|
||||||
# can be applied.
|
# can be applied.
|
||||||
|
|
||||||
if attrval[0] == fname or hasattr(attrval[1], '__contains__'):
|
if attrval[0] == fname or hasattr(attrval[1], "__contains__"):
|
||||||
# Create model queryset.
|
# Create model queryset.
|
||||||
_q = model_cls.objects.filter(**{fname: attrval[1]})
|
_q = model_cls.objects.filter(**{fname: attrval[1]})
|
||||||
else:
|
else:
|
||||||
|
|
@ -135,27 +139,28 @@ def rewrite_q_expr(model_cls, expr):
|
||||||
|
|
||||||
# Explicitly check for None. 'or' doesn't work here
|
# Explicitly check for None. 'or' doesn't work here
|
||||||
# as empty QuerySet, which is valid, is falsy.
|
# as empty QuerySet, which is valid, is falsy.
|
||||||
q = q if q != None else _q
|
q = q if q is not None else _q
|
||||||
|
|
||||||
if expr.connector == 'AND':
|
if expr.connector == "AND":
|
||||||
q &= _q
|
q &= _q
|
||||||
else:
|
else:
|
||||||
q |= _q
|
q |= _q
|
||||||
|
|
||||||
# If any two children were merged,
|
# If any two children were merged,
|
||||||
# update parent expression.
|
# update parent expression.
|
||||||
if q != None:
|
if q is not None:
|
||||||
expr.children = other + [('pk__in', q)]
|
expr.children = [*other, ("pk__in", q)]
|
||||||
|
|
||||||
return expr
|
return expr
|
||||||
|
|
||||||
|
|
||||||
def eav_filter(func):
|
def eav_filter(func):
|
||||||
'''
|
"""
|
||||||
Decorator used to wrap filter and exclude methods. Passes args through
|
Decorator used to wrap filter and exclude methods. Passes args through
|
||||||
expand_q_filters and kwargs through expand_eav_filter. Returns the
|
:func:`expand_q_filters` and kwargs through :func:`expand_eav_filter`. Returns the
|
||||||
called function (filter or exclude).
|
called function (filter or exclude).
|
||||||
'''
|
"""
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(self, *args, **kwargs):
|
def wrapper(self, *args, **kwargs):
|
||||||
nargs = []
|
nargs = []
|
||||||
|
|
@ -164,9 +169,9 @@ def eav_filter(func):
|
||||||
for arg in args:
|
for arg in args:
|
||||||
if isinstance(arg, Q):
|
if isinstance(arg, Q):
|
||||||
# Modify Q objects (warning: recursion ahead).
|
# Modify Q objects (warning: recursion ahead).
|
||||||
arg = expand_q_filters(arg, self.model)
|
arg = expand_q_filters(arg, self.model) # noqa: PLW2901
|
||||||
# Rewrite Q-expression to safeform.
|
# Rewrite Q-expression to safeform.
|
||||||
arg = rewrite_q_expr(self.model, arg)
|
arg = rewrite_q_expr(self.model, arg) # noqa: PLW2901
|
||||||
nargs.append(arg)
|
nargs.append(arg)
|
||||||
|
|
||||||
for key, value in kwargs.items():
|
for key, value in kwargs.items():
|
||||||
|
|
@ -174,8 +179,11 @@ def eav_filter(func):
|
||||||
nkey, nval = expand_eav_filter(self.model, key, value)
|
nkey, nval = expand_eav_filter(self.model, key, value)
|
||||||
|
|
||||||
if nkey in nkwargs:
|
if nkey in nkwargs:
|
||||||
# Apply AND to both querysets.
|
# Add filter to check if matching entity_id is
|
||||||
nkwargs[nkey] = (nkwargs[nkey] & nval).distinct()
|
# in the previous queryset with same nkey
|
||||||
|
nkwargs[nkey] = nval.filter(
|
||||||
|
entity_id__in=nkwargs[nkey].values_list("entity_id", flat=True),
|
||||||
|
).distinct()
|
||||||
else:
|
else:
|
||||||
nkwargs.update({nkey: nval})
|
nkwargs.update({nkey: nval})
|
||||||
|
|
||||||
|
|
@ -185,11 +193,11 @@ def eav_filter(func):
|
||||||
|
|
||||||
|
|
||||||
def expand_q_filters(q, root_cls):
|
def expand_q_filters(q, root_cls):
|
||||||
'''
|
"""
|
||||||
Takes a Q object and a model class.
|
Takes a Q object and a model class.
|
||||||
Recursively passes each filter / value in the Q object tree leaf nodes
|
Recursively passes each filter / value in the Q object tree leaf nodes
|
||||||
through expand_eav_filter
|
through :func:`expand_eav_filter`.
|
||||||
'''
|
"""
|
||||||
new_children = []
|
new_children = []
|
||||||
|
|
||||||
for qi in q.children:
|
for qi in q.children:
|
||||||
|
|
@ -207,7 +215,7 @@ def expand_q_filters(q, root_cls):
|
||||||
|
|
||||||
|
|
||||||
def expand_eav_filter(model_cls, key, value):
|
def expand_eav_filter(model_cls, key, value):
|
||||||
'''
|
"""
|
||||||
Accepts a model class and a key, value.
|
Accepts a model class and a key, value.
|
||||||
Recurisively replaces any eav filter with a subquery.
|
Recurisively replaces any eav filter with a subquery.
|
||||||
|
|
||||||
|
|
@ -220,62 +228,143 @@ def expand_eav_filter(model_cls, key, value):
|
||||||
|
|
||||||
key = 'eav_values__in'
|
key = 'eav_values__in'
|
||||||
value = Values.objects.filter(value_int=5, attribute__slug='height')
|
value = Values.objects.filter(value_int=5, attribute__slug='height')
|
||||||
'''
|
"""
|
||||||
fields = key.split('__')
|
fields = key.split("__")
|
||||||
config_cls = getattr(model_cls, '_eav_config_cls', None)
|
config_cls = getattr(model_cls, "_eav_config_cls", None)
|
||||||
|
|
||||||
if len(fields) > 1 and config_cls and fields[0] == config_cls.eav_attr:
|
if len(fields) > 1 and config_cls and fields[0] == config_cls.eav_attr:
|
||||||
slug = fields[1]
|
slug = fields[1]
|
||||||
gr_name = config_cls.generic_relation_attr
|
gr_name = config_cls.generic_relation_attr
|
||||||
datatype = Attribute.objects.get(slug=slug).datatype
|
datatype = Attribute.objects.get(slug=slug).datatype
|
||||||
|
|
||||||
lookup = '__%s' % fields[2] if len(fields) > 2 else ''
|
value_key = ""
|
||||||
kwargs = {
|
if datatype == Attribute.TYPE_ENUM and not isinstance(value, EnumValue):
|
||||||
'value_{}{}'.format(datatype, lookup): value,
|
lookup = f"__value__{fields[2]}" if len(fields) > 2 else "__value" # noqa: PLR2004
|
||||||
'attribute__slug': slug
|
value_key = f"value_{datatype}{lookup}"
|
||||||
}
|
elif datatype == Attribute.TYPE_OBJECT:
|
||||||
|
value_key = "generic_value_id"
|
||||||
|
else:
|
||||||
|
lookup = f"__{fields[2]}" if len(fields) > 2 else "" # noqa: PLR2004
|
||||||
|
value_key = f"value_{datatype}{lookup}"
|
||||||
|
kwargs = {value_key: value, "attribute__slug": slug}
|
||||||
value = Value.objects.filter(**kwargs)
|
value = Value.objects.filter(**kwargs)
|
||||||
|
|
||||||
return '%s__in' % gr_name, value
|
return f"{gr_name}__in", value
|
||||||
|
|
||||||
try:
|
# Not an eav field, so keep as is
|
||||||
field = model_cls._meta.get_field(fields[0])
|
return key, value
|
||||||
except models.FieldDoesNotExist:
|
|
||||||
return key, value
|
|
||||||
|
|
||||||
if not field.auto_created or field.concrete:
|
|
||||||
return key, value
|
|
||||||
else:
|
|
||||||
sub_key = '__'.join(fields[1:])
|
|
||||||
key, value = expand_eav_filter(field.model, sub_key, value)
|
|
||||||
return '{}__{}'.format(ields[0], key), value
|
|
||||||
|
|
||||||
|
|
||||||
class EavQuerySet(QuerySet):
|
class EavQuerySet(QuerySet):
|
||||||
'''
|
"""
|
||||||
Overrides relational operators for EAV models.
|
Overrides relational operators for EAV models.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
@eav_filter
|
@eav_filter
|
||||||
def filter(self, *args, **kwargs):
|
def filter(self, *args, **kwargs):
|
||||||
'''
|
"""
|
||||||
Pass *args* and *kwargs* through ``eav_filter``, then pass to
|
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
|
||||||
the ``models.Manager`` filter method.
|
the ``Manager`` filter method.
|
||||||
'''
|
"""
|
||||||
return super().filter(*args, **kwargs)
|
return super().filter(*args, **kwargs)
|
||||||
|
|
||||||
@eav_filter
|
@eav_filter
|
||||||
def exclude(self, *args, **kwargs):
|
def exclude(self, *args, **kwargs):
|
||||||
'''
|
"""
|
||||||
Pass *args* and *kwargs* through ``eav_filter``, then pass to
|
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
|
||||||
the ``models.Manager`` exclude method.
|
the ``Manager`` exclude method.
|
||||||
'''
|
"""
|
||||||
return super().exclude(*args, **kwargs)
|
return super().exclude(*args, **kwargs)
|
||||||
|
|
||||||
@eav_filter
|
@eav_filter
|
||||||
def get(self, *args, **kwargs):
|
def get(self, *args, **kwargs):
|
||||||
'''
|
"""
|
||||||
Pass *args* and *kwargs* through ``eav_filter``, then pass to
|
Pass *args* and *kwargs* through :func:`eav_filter`, then pass to
|
||||||
the ``models.Manager`` get method.
|
the ``Manager`` get method.
|
||||||
'''
|
"""
|
||||||
return super().get(*args, **kwargs)
|
return super().get(*args, **kwargs)
|
||||||
|
|
||||||
|
def order_by(self, *fields):
|
||||||
|
# Django only allows to order querysets by direct fields and
|
||||||
|
# foreign-key chains. In order to bypass this behaviour and order
|
||||||
|
# by EAV attributes, it is required to construct custom order-by
|
||||||
|
# clause manually using Django's conditional expressions.
|
||||||
|
# This will be slow, of course.
|
||||||
|
order_clauses = []
|
||||||
|
query_clause = self
|
||||||
|
config_cls = self.model._eav_config_cls # noqa: SLF001
|
||||||
|
|
||||||
|
for term in [t.split("__") for t in fields]:
|
||||||
|
# Continue only for EAV attributes.
|
||||||
|
if len(term) == 2 and term[0] == config_cls.eav_attr: # noqa: PLR2004
|
||||||
|
# Retrieve Attribute over which the ordering is performed.
|
||||||
|
try:
|
||||||
|
attr = Attribute.objects.get(slug=term[1])
|
||||||
|
except ObjectDoesNotExist as err:
|
||||||
|
raise ObjectDoesNotExist(
|
||||||
|
f'Cannot find EAV attribute "{term[1]}"',
|
||||||
|
) from err
|
||||||
|
|
||||||
|
field_name = f"value_{attr.datatype}"
|
||||||
|
|
||||||
|
pks_values = (
|
||||||
|
Value.objects.filter(
|
||||||
|
# Retrieve pk-values pairs of the related values
|
||||||
|
# (i.e. values for the specified attribute and
|
||||||
|
# belonging to entities in the queryset).
|
||||||
|
attribute__slug=attr.slug,
|
||||||
|
entity_id__in=self,
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
# Order values by their value-field of
|
||||||
|
# appropriate attribute data-type.
|
||||||
|
field_name,
|
||||||
|
)
|
||||||
|
.values_list(
|
||||||
|
# Retrieve only primary-keys of the entities
|
||||||
|
# in the current queryset.
|
||||||
|
"entity_id",
|
||||||
|
field_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retrieve ordered values from pk-value list.
|
||||||
|
_, ordered_values = zip(*pks_values)
|
||||||
|
|
||||||
|
# Add explicit ordering and turn
|
||||||
|
# list of pairs into look-up table.
|
||||||
|
val2ind = dict(zip(ordered_values, count()))
|
||||||
|
|
||||||
|
# Finally, zip ordered pks with their grouped orderings.
|
||||||
|
entities_pk = [(pk, val2ind[val]) for pk, val in pks_values]
|
||||||
|
|
||||||
|
# Using ordered primary-keys, construct
|
||||||
|
# CASE clause of the form:
|
||||||
|
#
|
||||||
|
# CASE
|
||||||
|
# WHEN id = 2 THEN 1
|
||||||
|
# WHEN id = 5 THEN 2
|
||||||
|
# WHEN id = 9 THEN 2
|
||||||
|
# WHEN id = 4 THEN 3
|
||||||
|
# END
|
||||||
|
#
|
||||||
|
when_clauses = [When(id=pk, then=i) for pk, i in entities_pk]
|
||||||
|
|
||||||
|
order_clause = Case(*when_clauses, output_field=IntegerField())
|
||||||
|
|
||||||
|
clause_name = "__".join(term)
|
||||||
|
# Use when-clause to construct
|
||||||
|
# custom order-by clause.
|
||||||
|
query_clause = query_clause.annotate(**{clause_name: order_clause})
|
||||||
|
|
||||||
|
order_clauses.append(clause_name)
|
||||||
|
|
||||||
|
elif len(term) >= 2 and term[0] == config_cls.eav_attr: # noqa: PLR2004
|
||||||
|
raise NotSupportedError(
|
||||||
|
"EAV does not support ordering through foreign-key chains",
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
order_clauses.append(term[0])
|
||||||
|
|
||||||
|
return QuerySet.order_by(query_clause, *order_clauses)
|
||||||
|
|
|
||||||
198
eav/registry.py
198
eav/registry.py
|
|
@ -1,171 +1,195 @@
|
||||||
'''Registry. This modules contains the registry classes.'''
|
"""This modules contains the registry classes."""
|
||||||
|
|
||||||
from django.contrib.contenttypes import fields as generic
|
from django.contrib.contenttypes import fields as generic
|
||||||
from django.db.models.signals import post_init, post_save, pre_save
|
from django.db.models.signals import post_init, post_save, pre_save
|
||||||
|
|
||||||
from .managers import EntityManager
|
from eav.logic.entity_pk import get_entity_pk_type
|
||||||
from .models import Attribute, Entity, Value
|
from eav.managers import EntityManager
|
||||||
|
from eav.models import Attribute, Entity, Value
|
||||||
|
|
||||||
|
|
||||||
class EavConfig(object):
|
class EavConfig:
|
||||||
'''
|
"""
|
||||||
The default EavConfig class used if it is not overriden on registration.
|
The default ``EavConfig`` class used if it is not overridden on registration.
|
||||||
This is where all the default eav attribute names are defined.
|
This is where all the default eav attribute names are defined.
|
||||||
|
|
||||||
Available options are as follows:
|
Available options are as follows:
|
||||||
1. manager_attr - Specifies manager name. Used to refer to the
|
|
||||||
manager from Entity class, "objects" by default.
|
1. manager_attr - Specifies manager name. Used to refer to the
|
||||||
2. manager_only - Specifies whether signals and generic relation should
|
manager from Entity class, "objects" by default.
|
||||||
be setup for the registered model.
|
2. manager_only - Specifies whether signals and generic relation should
|
||||||
3. eav_attr - Named of the Entity toolkit instance on the registered
|
be setup for the registered model.
|
||||||
model instance. "eav" by default. See attach_eav_attr.
|
3. eav_attr - Named of the Entity toolkit instance on the registered
|
||||||
4. generic_relation_attr - Name of the GenericRelation to Value
|
model instance. "eav" by default. See attach_eav_attr.
|
||||||
objects. "eav_values" by default.
|
4. generic_relation_attr - Name of the GenericRelation to Value
|
||||||
5. generic_relation_related_name - Name of the related name for
|
objects. "eav_values" by default.
|
||||||
GenericRelation from Entity to Value. None by default. Therefore,
|
5. generic_relation_related_name - Name of the related name for
|
||||||
if not overridden, it is not possible to query Values by Entities.
|
GenericRelation from Entity to Value. None by default. Therefore,
|
||||||
'''
|
if not overridden, it is not possible to query Values by Entities.
|
||||||
manager_attr = 'objects'
|
"""
|
||||||
|
|
||||||
|
manager_attr = "objects"
|
||||||
manager_only = False
|
manager_only = False
|
||||||
eav_attr = 'eav'
|
eav_attr = "eav"
|
||||||
generic_relation_attr = 'eav_values'
|
generic_relation_attr = "eav_values"
|
||||||
generic_relation_related_name = None
|
generic_relation_related_name = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_attributes(cls):
|
def get_attributes(cls, instance=None):
|
||||||
'''
|
"""
|
||||||
By default, all :class:`~eav.models.Attribute` object apply to an
|
By default, all :class:`~eav.models.Attribute` object apply to an
|
||||||
entity, unless you provide a custom EavConfig class overriding this.
|
entity, unless you provide a custom EavConfig class overriding this.
|
||||||
'''
|
"""
|
||||||
return Attribute.objects.all()
|
return Attribute.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class Registry(object):
|
class Registry:
|
||||||
'''
|
"""
|
||||||
Handles registration through the
|
Handles registration through the
|
||||||
:meth:`register` and :meth:`unregister` methods.
|
:meth:`register` and :meth:`unregister` methods.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def register(model_cls, config_cls=None):
|
def register(model_cls, config_cls=None):
|
||||||
'''
|
"""
|
||||||
Registers *model_cls* with eav. You can pass an optional *config_cls*
|
Registers *model_cls* with eav. You can pass an optional *config_cls*
|
||||||
to override the EavConfig defaults.
|
to override the EavConfig defaults.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Multiple registrations for the same entity are harmlessly ignored.
|
Multiple registrations for the same entity are harmlessly ignored.
|
||||||
'''
|
"""
|
||||||
if hasattr(model_cls, '_eav_config_cls'):
|
if hasattr(model_cls, "_eav_config_cls"):
|
||||||
return
|
return
|
||||||
|
|
||||||
if config_cls is EavConfig or config_cls is None:
|
if config_cls is EavConfig or config_cls is None:
|
||||||
config_cls = type("%sConfig" % model_cls.__name__,
|
config_cls = type(f"{model_cls.__name__}Config", (EavConfig,), {})
|
||||||
(EavConfig,), {})
|
|
||||||
|
|
||||||
# set _eav_config_cls on the model so we can access it there
|
# set _eav_config_cls on the model so we can access it there
|
||||||
setattr(model_cls, '_eav_config_cls', config_cls)
|
model_cls._eav_config_cls = config_cls
|
||||||
|
|
||||||
reg = Registry(model_cls)
|
reg = Registry(model_cls)
|
||||||
reg._register_self()
|
reg._register_self()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def unregister(model_cls):
|
def unregister(model_cls):
|
||||||
'''
|
"""
|
||||||
Unregisters *model_cls* with eav.
|
Unregisters *model_cls* with eav.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
Unregistering a class not already registered is harmlessly ignored.
|
Unregistering a class not already registered is harmlessly ignored.
|
||||||
'''
|
"""
|
||||||
if not getattr(model_cls, '_eav_config_cls', None):
|
if not getattr(model_cls, "_eav_config_cls", None):
|
||||||
return
|
return
|
||||||
reg = Registry(model_cls)
|
reg = Registry(model_cls)
|
||||||
reg._unregister_self()
|
reg._unregister_self()
|
||||||
|
|
||||||
delattr(model_cls, '_eav_config_cls')
|
delattr(model_cls, "_eav_config_cls")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def attach_eav_attr(sender, *args, **kwargs):
|
def attach_eav_attr(sender, *args, **kwargs):
|
||||||
'''
|
"""
|
||||||
Attach EAV Entity toolkit to an instance after init.
|
Attach EAV Entity toolkit to an instance after init.
|
||||||
'''
|
"""
|
||||||
instance = kwargs['instance']
|
instance = kwargs["instance"]
|
||||||
config_cls = instance.__class__._eav_config_cls
|
config_cls = instance.__class__._eav_config_cls
|
||||||
setattr(instance, config_cls.eav_attr, Entity(instance))
|
setattr(instance, config_cls.eav_attr, Entity(instance))
|
||||||
|
|
||||||
def __init__(self, model_cls):
|
def __init__(self, model_cls):
|
||||||
'''
|
"""
|
||||||
Set the *model_cls* and its *config_cls*
|
Set the *model_cls* and its *config_cls*
|
||||||
'''
|
"""
|
||||||
self.model_cls = model_cls
|
self.model_cls = model_cls
|
||||||
self.config_cls = model_cls._eav_config_cls
|
self.config_cls = model_cls._eav_config_cls
|
||||||
|
|
||||||
def _attach_manager(self):
|
def _attach_manager(self) -> None:
|
||||||
'''
|
"""
|
||||||
Attach the manager to *manager_attr* specified in *config_cls*
|
Attach the EntityManager to the model class.
|
||||||
'''
|
|
||||||
# Save the old manager if the attribute name conflicts with the new one.
|
|
||||||
if hasattr(self.model_cls, self.config_cls.manager_attr):
|
|
||||||
mgr = getattr(self.model_cls, self.config_cls.manager_attr)
|
|
||||||
self.config_cls.old_mgr = mgr
|
|
||||||
self.model_cls._meta.local_managers.remove(mgr)
|
|
||||||
self.model_cls._meta._expire_cache()
|
|
||||||
|
|
||||||
# Attach the new manager to the model.
|
This method replaces the existing manager specified in the `config_cls`
|
||||||
mgr = EntityManager()
|
with a new instance of `EntityManager`. If the specified manager is the
|
||||||
mgr.contribute_to_class(self.model_cls, self.config_cls.manager_attr)
|
default manager, the `EntityManager` is set as the new default manager.
|
||||||
|
Otherwise, it is appended to the list of managers.
|
||||||
|
|
||||||
|
If the model class already has a manager with the same name as the one
|
||||||
|
specified in `config_cls`, it is saved as `old_mgr` in the `config_cls`
|
||||||
|
for use during detachment.
|
||||||
|
"""
|
||||||
|
manager_attr = self.config_cls.manager_attr
|
||||||
|
model_meta = self.model_cls._meta
|
||||||
|
current_manager = getattr(self.model_cls, manager_attr, None)
|
||||||
|
|
||||||
|
if isinstance(current_manager, EntityManager):
|
||||||
|
# EntityManager is already attached, no need to proceed
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create a new EntityManager
|
||||||
|
new_manager = EntityManager()
|
||||||
|
|
||||||
|
# Save and remove the old manager if it exists
|
||||||
|
if current_manager and current_manager in model_meta.local_managers:
|
||||||
|
self.config_cls.old_mgr = current_manager
|
||||||
|
model_meta.local_managers.remove(current_manager)
|
||||||
|
|
||||||
|
# Set the creation_counter to maintain the order
|
||||||
|
# This ensures that the new manager has the same priority as the old one
|
||||||
|
new_manager.creation_counter = current_manager.creation_counter
|
||||||
|
|
||||||
|
# Attach the new EntityManager instance to the model.
|
||||||
|
new_manager.contribute_to_class(self.model_cls, manager_attr)
|
||||||
|
|
||||||
def _detach_manager(self):
|
def _detach_manager(self):
|
||||||
'''
|
"""
|
||||||
Detach the manager and restore the previous one (if there was one).
|
Detach the manager and restore the previous one (if there was one).
|
||||||
'''
|
"""
|
||||||
mgr = getattr(self.model_cls, self.config_cls.manager_attr)
|
mgr = getattr(self.model_cls, self.config_cls.manager_attr)
|
||||||
self.model_cls._meta.local_managers.remove(mgr)
|
self.model_cls._meta.local_managers.remove(mgr)
|
||||||
self.model_cls._meta._expire_cache()
|
self.model_cls._meta._expire_cache()
|
||||||
delattr(self.model_cls, self.config_cls.manager_attr)
|
delattr(self.model_cls, self.config_cls.manager_attr)
|
||||||
|
|
||||||
if hasattr(self.config_cls, 'old_mgr'):
|
if hasattr(self.config_cls, "old_mgr"):
|
||||||
self.config_cls.old_mgr \
|
self.config_cls.old_mgr.contribute_to_class(
|
||||||
.contribute_to_class(self.model_cls,
|
self.model_cls,
|
||||||
self.config_cls.manager_attr)
|
self.config_cls.manager_attr,
|
||||||
|
)
|
||||||
|
|
||||||
def _attach_signals(self):
|
def _attach_signals(self):
|
||||||
'''
|
"""
|
||||||
Attach pre- and post- save signals from model class
|
Attach pre- and post- save signals from model class
|
||||||
to Entity helper. This way, Entity instance will be
|
to Entity helper. This way, Entity instance will be
|
||||||
able to prepare and clean-up before and after creation /
|
able to prepare and clean-up before and after creation /
|
||||||
update of the user's model class instance.
|
update of the user's model class instance.
|
||||||
'''
|
"""
|
||||||
post_init.connect(Registry.attach_eav_attr, sender = self.model_cls)
|
post_init.connect(Registry.attach_eav_attr, sender=self.model_cls)
|
||||||
pre_save.connect(Entity.pre_save_handler, sender = self.model_cls)
|
pre_save.connect(Entity.pre_save_handler, sender=self.model_cls)
|
||||||
post_save.connect(Entity.post_save_handler, sender = self.model_cls)
|
post_save.connect(Entity.post_save_handler, sender=self.model_cls)
|
||||||
|
|
||||||
def _detach_signals(self):
|
def _detach_signals(self):
|
||||||
'''
|
"""
|
||||||
Detach all signals for eav.
|
Detach all signals for eav.
|
||||||
'''
|
"""
|
||||||
post_init.disconnect(Registry.attach_eav_attr, sender = self.model_cls)
|
post_init.disconnect(Registry.attach_eav_attr, sender=self.model_cls)
|
||||||
pre_save.disconnect(Entity.pre_save_handler, sender = self.model_cls)
|
pre_save.disconnect(Entity.pre_save_handler, sender=self.model_cls)
|
||||||
post_save.disconnect(Entity.post_save_handler, sender = self.model_cls)
|
post_save.disconnect(Entity.post_save_handler, sender=self.model_cls)
|
||||||
|
|
||||||
def _attach_generic_relation(self):
|
def _attach_generic_relation(self):
|
||||||
'''
|
"""Set up the generic relation for the entity."""
|
||||||
Set up the generic relation for the entity
|
rel_name = (
|
||||||
'''
|
self.config_cls.generic_relation_related_name or self.model_cls.__name__
|
||||||
rel_name = self.config_cls.generic_relation_related_name or \
|
)
|
||||||
self.model_cls.__name__
|
|
||||||
|
|
||||||
gr_name = self.config_cls.generic_relation_attr.lower()
|
gr_name = self.config_cls.generic_relation_attr.lower()
|
||||||
generic_relation = \
|
generic_relation = generic.GenericRelation(
|
||||||
generic.GenericRelation(Value,
|
Value,
|
||||||
object_id_field='entity_id',
|
object_id_field=get_entity_pk_type(self.model_cls),
|
||||||
content_type_field='entity_ct',
|
content_type_field="entity_ct",
|
||||||
related_query_name=rel_name)
|
related_query_name=rel_name,
|
||||||
|
)
|
||||||
generic_relation.contribute_to_class(self.model_cls, gr_name)
|
generic_relation.contribute_to_class(self.model_cls, gr_name)
|
||||||
|
|
||||||
def _detach_generic_relation(self):
|
def _detach_generic_relation(self):
|
||||||
'''
|
"""
|
||||||
Remove the generic relation from the entity
|
Remove the generic relation from the entity
|
||||||
'''
|
"""
|
||||||
gen_rel_field = self.config_cls.generic_relation_attr.lower()
|
gen_rel_field = self.config_cls.generic_relation_attr.lower()
|
||||||
for field in self.model_cls._meta.local_many_to_many:
|
for field in self.model_cls._meta.local_many_to_many:
|
||||||
if field.name == gen_rel_field:
|
if field.name == gen_rel_field:
|
||||||
|
|
@ -175,9 +199,9 @@ class Registry(object):
|
||||||
delattr(self.model_cls, gen_rel_field)
|
delattr(self.model_cls, gen_rel_field)
|
||||||
|
|
||||||
def _register_self(self):
|
def _register_self(self):
|
||||||
'''
|
"""
|
||||||
Call the necessary registration methods
|
Call the necessary registration methods
|
||||||
'''
|
"""
|
||||||
self._attach_manager()
|
self._attach_manager()
|
||||||
|
|
||||||
if not self.config_cls.manager_only:
|
if not self.config_cls.manager_only:
|
||||||
|
|
@ -185,9 +209,9 @@ class Registry(object):
|
||||||
self._attach_generic_relation()
|
self._attach_generic_relation()
|
||||||
|
|
||||||
def _unregister_self(self):
|
def _unregister_self(self):
|
||||||
'''
|
"""
|
||||||
Call the necessary unregistration methods
|
Call the necessary unregistration methods
|
||||||
'''
|
"""
|
||||||
self._detach_manager()
|
self._detach_manager()
|
||||||
|
|
||||||
if not self.config_cls.manager_only:
|
if not self.config_cls.manager_only:
|
||||||
|
|
|
||||||
3
eav/settings.py
Normal file
3
eav/settings.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
CHARFIELD_LENGTH: Final = 100
|
||||||
24
eav/utils.py
24
eav/utils.py
|
|
@ -1,24 +0,0 @@
|
||||||
'''Utilities. This module contains non-essential helper methods.'''
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from django.db.models import Q
|
|
||||||
|
|
||||||
|
|
||||||
def print_q_expr(expr, indent="", is_tail=True):
|
|
||||||
'''
|
|
||||||
Simple print method for debugging Q-expressions' trees.
|
|
||||||
'''
|
|
||||||
sys.stdout.write(indent)
|
|
||||||
sa, sb = (' └── ', ' ') if is_tail else (' ├── ', ' │ ')
|
|
||||||
|
|
||||||
if isinstance(expr, Q):
|
|
||||||
sys.stdout.write('{}{}\n'.format(sa, expr.connector))
|
|
||||||
for child in expr.children:
|
|
||||||
print_q_expr(child, indent + sb, expr.children[-1] == child)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
queryset = ', '.join(repr(v) for v in expr[1])
|
|
||||||
except TypeError:
|
|
||||||
queryset = repr(expr[1])
|
|
||||||
sys.stdout.write(' └── {} {}\n'.format(expr[0], queryset))
|
|
||||||
|
|
@ -1,89 +1,112 @@
|
||||||
# -*- coding: utf-8 -*-
|
"""
|
||||||
|
This module contains a validator for each :class:`~eav.models.Attribute` datatype.
|
||||||
'''
|
|
||||||
Validtors.
|
|
||||||
|
|
||||||
This module contains a validator for each Attribute datatype.
|
|
||||||
|
|
||||||
A validator is a callable that takes a value and raises a ``ValidationError``
|
A validator is a callable that takes a value and raises a ``ValidationError``
|
||||||
if it doesn’t meet some criteria. (see
|
if it doesn't meet some criteria (see `Django validators
|
||||||
`django validators <http://docs.djangoproject.com/en/dev/ref/validators/>`_)
|
<https://docs.djangoproject.com/en/dev/ref/validators/>`_).
|
||||||
|
|
||||||
These validators are called by the
|
These validators are called by the
|
||||||
:meth:`~eav.models.Attribute.validate_value` method in the
|
:meth:`~eav.models.Attribute.validate_value` method in the
|
||||||
:class:`~eav.models.Attribute` model.
|
:class:`~eav.models.Attribute` model.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
def validate_text(value):
|
def validate_text(value):
|
||||||
'''
|
"""
|
||||||
Raises ``ValidationError`` unless *value* type is ``str`` or ``unicode``
|
Raises ``ValidationError`` unless *value* type is ``str`` or ``unicode``
|
||||||
'''
|
"""
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
raise ValidationError(_(u"Must be str or unicode"))
|
raise ValidationError(_("Must be str or unicode"))
|
||||||
|
|
||||||
|
|
||||||
def validate_float(value):
|
def validate_float(value):
|
||||||
'''
|
"""
|
||||||
Raises ``ValidationError`` unless *value* can be cast as a ``float``
|
Raises ``ValidationError`` unless *value* can be cast as a ``float``
|
||||||
'''
|
"""
|
||||||
try:
|
try:
|
||||||
float(value)
|
float(value)
|
||||||
except ValueError:
|
except ValueError as err:
|
||||||
raise ValidationError(_(u"Must be a float"))
|
raise ValidationError(_("Must be a float")) from err
|
||||||
|
|
||||||
|
|
||||||
def validate_int(value):
|
def validate_int(value):
|
||||||
'''
|
"""
|
||||||
Raises ``ValidationError`` unless *value* can be cast as an ``int``
|
Raises ``ValidationError`` unless *value* can be cast as an ``int``
|
||||||
'''
|
"""
|
||||||
try:
|
try:
|
||||||
int(value)
|
int(value)
|
||||||
except ValueError:
|
except ValueError as err:
|
||||||
raise ValidationError(_(u"Must be an integer"))
|
raise ValidationError(_("Must be an integer")) from err
|
||||||
|
|
||||||
|
|
||||||
def validate_date(value):
|
def validate_date(value):
|
||||||
'''
|
"""
|
||||||
Raises ``ValidationError`` unless *value* is an instance of ``datetime``
|
Raises ``ValidationError`` unless *value* is an instance of ``datetime``
|
||||||
or ``date``
|
or ``date``
|
||||||
'''
|
"""
|
||||||
if not isinstance(value, datetime.datetime) and not isinstance(value, datetime.date):
|
if not isinstance(value, datetime.datetime) and not isinstance(
|
||||||
raise ValidationError(_(u"Must be a date or datetime"))
|
value,
|
||||||
|
datetime.date,
|
||||||
|
):
|
||||||
|
raise ValidationError(_("Must be a date or datetime"))
|
||||||
|
|
||||||
|
|
||||||
def validate_bool(value):
|
def validate_bool(value):
|
||||||
'''
|
"""
|
||||||
Raises ``ValidationError`` unless *value* type is ``bool``
|
Raises ``ValidationError`` unless *value* type is ``bool``
|
||||||
'''
|
"""
|
||||||
if not isinstance(value, bool):
|
if not isinstance(value, bool):
|
||||||
raise ValidationError(_(u"Must be a boolean"))
|
raise ValidationError(_("Must be a boolean"))
|
||||||
|
|
||||||
|
|
||||||
def validate_object(value):
|
def validate_object(value):
|
||||||
'''
|
"""
|
||||||
Raises ``ValidationError`` unless *value* is a saved
|
Raises ``ValidationError`` unless *value* is a saved
|
||||||
django model instance.
|
django model instance.
|
||||||
'''
|
"""
|
||||||
if not isinstance(value, models.Model):
|
if not isinstance(value, models.Model):
|
||||||
raise ValidationError(_(u"Must be a django model object instance"))
|
raise ValidationError(_("Must be a django model object instance"))
|
||||||
|
|
||||||
if not value.pk:
|
if not value.pk:
|
||||||
raise ValidationError(_(u"Model has not been saved yet"))
|
raise ValidationError(_("Model has not been saved yet"))
|
||||||
|
|
||||||
|
|
||||||
def validate_enum(value):
|
def validate_enum(value):
|
||||||
'''
|
"""
|
||||||
Raises ``ValidationError`` unless *value* is a saved
|
Raises ``ValidationError`` unless *value* is a saved
|
||||||
:class:`~eav.models.EnumValue` model instance.
|
:class:`~eav.models.EnumValue` model instance.
|
||||||
'''
|
"""
|
||||||
from .models import EnumValue
|
from eav.models import EnumValue
|
||||||
if not isinstance(value, EnumValue):
|
|
||||||
raise ValidationError(_(u"Must be an EnumValue model object instance"))
|
if isinstance(value, EnumValue) and not value.pk:
|
||||||
if not value.pk:
|
raise ValidationError(_("EnumValue has not been saved yet"))
|
||||||
raise ValidationError(_(u"EnumValue has not been saved yet"))
|
|
||||||
|
|
||||||
|
def validate_json(value):
|
||||||
|
"""
|
||||||
|
Raises ``ValidationError`` unless *value* can be cast as an ``json object`` (a dict)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = json.loads(value)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValidationError(_("Must be a JSON Serializable object"))
|
||||||
|
except ValueError as err:
|
||||||
|
raise ValidationError(_("Must be a JSON Serializable object")) from err
|
||||||
|
|
||||||
|
|
||||||
|
def validate_csv(value):
|
||||||
|
"""
|
||||||
|
Raises ``ValidationError`` unless *value* is a c-s-v value.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.split(";")
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise ValidationError(_("Must be Comma-Separated-Value."))
|
||||||
|
|
|
||||||
39
eav/widgets.py
Normal file
39
eav/widgets.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
from django.core import validators
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.forms.widgets import Textarea
|
||||||
|
|
||||||
|
EMPTY_VALUES = (*validators.EMPTY_VALUES, "[]")
|
||||||
|
|
||||||
|
|
||||||
|
class CSVWidget(Textarea):
|
||||||
|
is_hidden = False
|
||||||
|
|
||||||
|
def prep_value(self, value):
|
||||||
|
"""Prepare value before effectively render widget"""
|
||||||
|
if value in EMPTY_VALUES:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if isinstance(value, list):
|
||||||
|
return ";".join(value)
|
||||||
|
raise ValidationError("Invalid format.")
|
||||||
|
|
||||||
|
def render(self, name, value, **kwargs):
|
||||||
|
value = self.prep_value(value)
|
||||||
|
return super().render(name, value, **kwargs)
|
||||||
|
|
||||||
|
def value_from_datadict(self, data, files, name):
|
||||||
|
"""
|
||||||
|
Return the value of this widget or None.
|
||||||
|
|
||||||
|
Since we're only given the value of the entity name and the data dict
|
||||||
|
contains the '_eav_config_cls' (which we don't have access to) as the
|
||||||
|
key, we need to loop through each field checking if the eav attribute
|
||||||
|
exists with the given 'name'.
|
||||||
|
"""
|
||||||
|
for data_value in data.values():
|
||||||
|
widget_value = getattr(data_value, name, None)
|
||||||
|
if widget_value is not None:
|
||||||
|
return widget_value
|
||||||
|
|
||||||
|
return None
|
||||||
31
manage.py
Executable file
31
manage.py
Executable file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""
|
||||||
|
Main function.
|
||||||
|
|
||||||
|
It does several things:
|
||||||
|
1. Sets default settings module, if it is not set
|
||||||
|
2. Warns if Django is not installed
|
||||||
|
3. Executes any given command
|
||||||
|
"""
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from django.core import management
|
||||||
|
except ImportError as err:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
+ "available on your PYTHONPATH environment variable? Did you "
|
||||||
|
+ "forget to activate a virtual environment?",
|
||||||
|
) from err
|
||||||
|
|
||||||
|
management.execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
2254
poetry.lock
generated
Normal file
2254
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
134
pyproject.toml
Normal file
134
pyproject.toml
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=1.9"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.poetry]
|
||||||
|
name = "django-eav2"
|
||||||
|
description = "Entity-Attribute-Value storage for Django"
|
||||||
|
version = "1.8.1"
|
||||||
|
license = "GNU Lesser General Public License (LGPL), Version 3"
|
||||||
|
packages = [
|
||||||
|
{ include = "eav" }
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
authors = [
|
||||||
|
"Mauro Lizaur <mauro@sdf.org>",
|
||||||
|
]
|
||||||
|
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
|
repository = "https://github.com/jazzband/django-eav2"
|
||||||
|
|
||||||
|
keywords = [
|
||||||
|
"django",
|
||||||
|
"django-eav2",
|
||||||
|
"database",
|
||||||
|
"eav",
|
||||||
|
"sql",
|
||||||
|
]
|
||||||
|
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Environment :: Web Environment",
|
||||||
|
"Framework :: Django",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)",
|
||||||
|
"Programming Language :: Python",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Topic :: Database",
|
||||||
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
|
"Framework :: Django",
|
||||||
|
"Framework :: Django :: 4.2",
|
||||||
|
"Framework :: Django :: 5.1",
|
||||||
|
"Framework :: Django :: 5.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.semantic_release]
|
||||||
|
version_variable = [
|
||||||
|
"pyproject.toml:version"
|
||||||
|
]
|
||||||
|
branch = "master"
|
||||||
|
upload_to_pypi = false
|
||||||
|
upload_to_release = false
|
||||||
|
build_command = "pip install poetry && poetry build"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.9"
|
||||||
|
django = ">=4.2,<5.3"
|
||||||
|
|
||||||
|
[tool.poetry.group.test.dependencies]
|
||||||
|
mypy = "^1.6"
|
||||||
|
ruff = ">=0.6.3,<0.13.0"
|
||||||
|
|
||||||
|
safety = ">=2.3,<4.0"
|
||||||
|
|
||||||
|
pytest = ">=7.4.3,<9.0.0"
|
||||||
|
pytest-cov = ">=4.1,<7.0"
|
||||||
|
pytest-randomly = "^3.15"
|
||||||
|
pytest-django = "^4.5.2"
|
||||||
|
hypothesis = "^6.87.1"
|
||||||
|
|
||||||
|
doc8 = ">=0.11.2,<1.2.0"
|
||||||
|
|
||||||
|
[tool.poetry.group.docs]
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[tool.poetry.group.docs.dependencies]
|
||||||
|
sphinx = ">=5.0,<8.0"
|
||||||
|
sphinx-rtd-theme = ">=1.3,<4.0"
|
||||||
|
sphinx-autodoc-typehints = ">=1.19.5,<3.0.0"
|
||||||
|
m2r2 = "^0.3"
|
||||||
|
tomlkit = ">=0.13.0,<0.14"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
target-version = "py38"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["ALL"]
|
||||||
|
ignore = [
|
||||||
|
"ANN", # Type hints related, let mypy handle these.
|
||||||
|
"ARG", # Unused arguments
|
||||||
|
"D", # Docstrings related
|
||||||
|
"EM101", # "Exception must not use a string literal, assign to variable first"
|
||||||
|
"EM102", # "Exception must not use an f-string literal, assign to variable first"
|
||||||
|
"PD", # Pandas related
|
||||||
|
"Q000", # For now
|
||||||
|
"SIM105", # "Use contextlib.suppress({exception}) instead of try-except-pass"
|
||||||
|
"TRY003", # "Avoid specifying long messages outside the exception class"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-implicit-str-concat]
|
||||||
|
allow-multiline = false
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
# Allow private member access for Registry
|
||||||
|
"eav/registry.py" = ["SLF001"]
|
||||||
|
|
||||||
|
# Migrations are special
|
||||||
|
"**/migrations/*" = ["RUF012"]
|
||||||
|
|
||||||
|
# Sphinx specific
|
||||||
|
"docs/source/conf.py" = ["INP001"]
|
||||||
|
|
||||||
|
# pytest is even more special
|
||||||
|
"tests/*" = [
|
||||||
|
"INP001", # "Add an __init__.py"
|
||||||
|
"PLR2004", # "Magic value used in comparison"
|
||||||
|
"PT009", # "Use a regular assert instead of unittest-style"
|
||||||
|
"PT027", # "Use pytest.raises instead of unittest-style"
|
||||||
|
"S101", # "Use of assert detected"
|
||||||
|
"SLF001" # "Private member accessed"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.pydocstyle]
|
||||||
|
# Use Google-style docstrings.
|
||||||
|
convention = "google"
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Django>=1.11
|
|
||||||
29
runtests
29
runtests
|
|
@ -1,29 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import django
|
|
||||||
from django.conf import settings
|
|
||||||
from django.test.utils import get_runner
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings'
|
|
||||||
django.setup()
|
|
||||||
TestRunner = get_runner(settings)
|
|
||||||
test_runner = TestRunner()
|
|
||||||
|
|
||||||
if len(sys.argv) == 1 or sys.argv[1] in ['-a', '--all']:
|
|
||||||
tests = [
|
|
||||||
'tests.queries',
|
|
||||||
'tests.registry',
|
|
||||||
'tests.data_validation',
|
|
||||||
'tests.attributes',
|
|
||||||
'tests.misc_models',
|
|
||||||
'tests.set_and_get',
|
|
||||||
'tests.forms'
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
tests = ['tests.{}'.format(arg) for arg in sys.argv[1:]]
|
|
||||||
|
|
||||||
result = test_runner.run_tests(tests)
|
|
||||||
sys.exit(bool(result))
|
|
||||||
75
setup.cfg
Normal file
75
setup.cfg
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# All configuration for plugins and other utils is defined here.
|
||||||
|
# Read more about `setup.cfg`:
|
||||||
|
# https://docs.python.org/3/distutils/configfile.html
|
||||||
|
|
||||||
|
|
||||||
|
[tool:pytest]
|
||||||
|
# Django options:
|
||||||
|
# https://pytest-django.readthedocs.io/en/latest/
|
||||||
|
DJANGO_SETTINGS_MODULE = test_project.settings
|
||||||
|
|
||||||
|
# PYTHONPATH configuration:
|
||||||
|
# https://docs.pytest.org/en/7.0.x/reference/reference.html#confval-pythonpath
|
||||||
|
pythonpath = ./eav
|
||||||
|
|
||||||
|
# py.test options:
|
||||||
|
norecursedirs =
|
||||||
|
*.egg
|
||||||
|
.eggs
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
docs
|
||||||
|
.tox
|
||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
|
||||||
|
# You will need to measure your tests speed with `-n auto` and without it,
|
||||||
|
# so you can see whether it gives you any performance gain, or just gives
|
||||||
|
# you an overhead. See `docs/template/development-process.rst`.
|
||||||
|
addopts =
|
||||||
|
-p no:randomly
|
||||||
|
--strict-markers
|
||||||
|
--strict-config
|
||||||
|
--doctest-modules
|
||||||
|
--cov=eav
|
||||||
|
--cov-report=term-missing:skip-covered
|
||||||
|
--cov-report=html
|
||||||
|
--cov-report=xml
|
||||||
|
--cov-branch
|
||||||
|
--cov-fail-under=90
|
||||||
|
|
||||||
|
|
||||||
|
[coverage:run]
|
||||||
|
# Exclude tox output from coverage calculation
|
||||||
|
omit = */.tox/*
|
||||||
|
|
||||||
|
[coverage:report]
|
||||||
|
skip_covered = True
|
||||||
|
show_missing = True
|
||||||
|
sort = Cover
|
||||||
|
exclude_lines =
|
||||||
|
pragma: no cover
|
||||||
|
# type hinting related code
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
|
||||||
|
|
||||||
|
[mypy]
|
||||||
|
# mypy configurations: https://bit.ly/2zEl9WI
|
||||||
|
|
||||||
|
allow_redefinition = False
|
||||||
|
check_untyped_defs = True
|
||||||
|
disallow_any_explicit = True
|
||||||
|
disallow_any_generics = True
|
||||||
|
disallow_untyped_calls = True
|
||||||
|
ignore_errors = False
|
||||||
|
ignore_missing_imports = True
|
||||||
|
implicit_reexport = False
|
||||||
|
strict_optional = True
|
||||||
|
strict_equality = True
|
||||||
|
local_partial_types = True
|
||||||
|
no_implicit_optional = True
|
||||||
|
warn_no_return = True
|
||||||
|
warn_unused_ignores = True
|
||||||
|
warn_redundant_casts = True
|
||||||
|
warn_unused_configs = True
|
||||||
|
warn_unreachable = True
|
||||||
25
setup.py
25
setup.py
|
|
@ -1,25 +0,0 @@
|
||||||
from setuptools import setup
|
|
||||||
|
|
||||||
setup(
|
|
||||||
name = 'django-eav2',
|
|
||||||
version = __import__('eav').__version__,
|
|
||||||
license = 'GNU Lesser General Public License (LGPL), Version 3',
|
|
||||||
requires = ['python (>= 3.5)', 'django (>= 1.11.14)'],
|
|
||||||
provides = ['eav'],
|
|
||||||
description = 'Entity-Attribute-Value storage for Django',
|
|
||||||
url = 'http://github.com/makimo/django-eav2',
|
|
||||||
packages = ['eav', 'tests'],
|
|
||||||
maintainer = 'Iwo Herka',
|
|
||||||
maintainer_email = 'hi@iwoherka.eu',
|
|
||||||
|
|
||||||
classifiers = [
|
|
||||||
'Development Status :: 3 - Alpha',
|
|
||||||
'Environment :: Web Environment',
|
|
||||||
'Framework :: Django',
|
|
||||||
'Intended Audience :: Developers',
|
|
||||||
'License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)',
|
|
||||||
'Programming Language :: Python',
|
|
||||||
'Topic :: Database',
|
|
||||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
5
test_project/apps.py
Normal file
5
test_project/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppConfig(AppConfig):
|
||||||
|
name = "test_project"
|
||||||
160
test_project/migrations/0001_initial.py
Normal file
160
test_project/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
from test_project.models import MAX_CHARFIELD_LEN
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
"""Initial migration for test_project."""
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ExampleMetaclassModel",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ExampleModel",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="RegisterTestModel",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Patient",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||||
|
("email", models.EmailField(blank=True, max_length=MAX_CHARFIELD_LEN)),
|
||||||
|
(
|
||||||
|
"example",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.deletion.PROTECT,
|
||||||
|
to="test_project.examplemodel",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="M2MModel",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||||
|
("models", models.ManyToManyField(to="test_project.ExampleModel")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Encounter",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("num", models.PositiveSmallIntegerField()),
|
||||||
|
(
|
||||||
|
"patient",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=models.deletion.PROTECT,
|
||||||
|
to="test_project.patient",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Doctor",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=MAX_CHARFIELD_LEN)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
142
test_project/models.py
Normal file
142
test_project/models.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import uuid
|
||||||
|
from typing import Final, final
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from eav.decorators import register_eav
|
||||||
|
from eav.managers import EntityManager
|
||||||
|
from eav.models import EAVModelMeta
|
||||||
|
|
||||||
|
#: Constants
|
||||||
|
MAX_CHARFIELD_LEN: Final = 254
|
||||||
|
|
||||||
|
|
||||||
|
class TestBase(models.Model):
|
||||||
|
"""Base class for test models."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Define common options."""
|
||||||
|
|
||||||
|
app_label = "test_project"
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class DoctorManager(EntityManager):
|
||||||
|
"""
|
||||||
|
Custom manager for the Doctor model.
|
||||||
|
|
||||||
|
This manager extends the EntityManager and provides additional
|
||||||
|
methods specific to the Doctor model, and is expected to be the
|
||||||
|
default manager on the model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_by_name(self, name: str) -> models.QuerySet:
|
||||||
|
"""Returns a QuerySet of doctors with the given name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): The name of the doctor to search for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
models.QuerySet: A QuerySet of doctors with the specified name.
|
||||||
|
"""
|
||||||
|
return self.filter(name=name)
|
||||||
|
|
||||||
|
|
||||||
|
class DoctorSubstringManager(models.Manager):
|
||||||
|
"""
|
||||||
|
Custom manager for the Doctor model.
|
||||||
|
|
||||||
|
This is a second manager used to ensure during testing that it's not replaced
|
||||||
|
as the default manager after eav.register().
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_by_name_contains(self, substring: str) -> models.QuerySet:
|
||||||
|
"""Returns a QuerySet of doctors whose names contain the given substring.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
substring (str): The substring to search for in the doctor's name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
models.QuerySet: A QuerySet of doctors whose names contain the
|
||||||
|
specified substring.
|
||||||
|
"""
|
||||||
|
return self.filter(name__icontains=substring)
|
||||||
|
|
||||||
|
|
||||||
|
@final
|
||||||
|
@register_eav()
|
||||||
|
class Doctor(TestBase):
|
||||||
|
"""Test model using UUID as primary key."""
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
||||||
|
|
||||||
|
objects = DoctorManager()
|
||||||
|
substrings = DoctorSubstringManager()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
@final
|
||||||
|
class Patient(TestBase):
|
||||||
|
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
||||||
|
email = models.EmailField(max_length=MAX_CHARFIELD_LEN, blank=True)
|
||||||
|
example = models.ForeignKey(
|
||||||
|
"ExampleModel",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Encounter(TestBase):
|
||||||
|
num = models.PositiveSmallIntegerField()
|
||||||
|
patient = models.ForeignKey(Patient, on_delete=models.PROTECT)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.patient}: encounter num {self.num}"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
@register_eav()
|
||||||
|
@final
|
||||||
|
class ExampleModel(TestBase):
|
||||||
|
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
@register_eav()
|
||||||
|
@final
|
||||||
|
class M2MModel(TestBase):
|
||||||
|
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
||||||
|
models = models.ManyToManyField(ExampleModel)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
@final
|
||||||
|
class ExampleMetaclassModel(TestBase, metaclass=EAVModelMeta):
|
||||||
|
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
@final
|
||||||
|
class RegisterTestModel(TestBase, metaclass=EAVModelMeta):
|
||||||
|
name = models.CharField(max_length=MAX_CHARFIELD_LEN)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
102
test_project/settings.py
Normal file
102
test_project/settings.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = "secret!" # noqa: S105
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
ALLOWED_HOSTS: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
# Test Project:
|
||||||
|
"test_project.apps.TestAppConfig",
|
||||||
|
# Our app:
|
||||||
|
"eav",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": ":memory:",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||||
|
EAV2_PRIMARY_KEY_FIELD = "django.db.models.AutoField"
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = []
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
|
TIME_ZONE = "UTC"
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_L10N = True
|
||||||
|
|
||||||
|
USE_TZ = False
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = "/static/"
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
import eav
|
|
||||||
from eav.exceptions import IllegalAssignmentException
|
|
||||||
from eav.models import Attribute, Value
|
|
||||||
from eav.registry import EavConfig
|
|
||||||
|
|
||||||
from .models import Encounter, Patient
|
|
||||||
|
|
||||||
|
|
||||||
class Attributes(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
class EncounterEavConfig(EavConfig):
|
|
||||||
manager_attr = 'eav_objects'
|
|
||||||
eav_attr = 'eav_field'
|
|
||||||
generic_relation_attr = 'encounter_eav_values'
|
|
||||||
generic_relation_related_name = 'encounters'
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_attributes(cls):
|
|
||||||
return Attribute.objects.filter(slug__contains='a')
|
|
||||||
|
|
||||||
eav.register(Encounter, EncounterEavConfig)
|
|
||||||
eav.register(Patient)
|
|
||||||
|
|
||||||
Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT)
|
|
||||||
Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT)
|
|
||||||
Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT)
|
|
||||||
Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
eav.unregister(Encounter)
|
|
||||||
eav.unregister(Patient)
|
|
||||||
|
|
||||||
def test_get_attribute_querysets(self):
|
|
||||||
self.assertEqual(Patient._eav_config_cls.get_attributes().count(), 4)
|
|
||||||
self.assertEqual(Encounter._eav_config_cls.get_attributes().count(), 1)
|
|
||||||
|
|
||||||
def test_duplicate_attributs(self):
|
|
||||||
'''
|
|
||||||
Ensure that no two Attributes with the same slug can exist.
|
|
||||||
'''
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT)
|
|
||||||
|
|
||||||
def test_setting_attributes(self):
|
|
||||||
p = Patient.objects.create(name='Jon')
|
|
||||||
e = Encounter.objects.create(patient=p, num=1)
|
|
||||||
p.eav.age = 3
|
|
||||||
p.eav.height = 2.3
|
|
||||||
p.save()
|
|
||||||
e.eav_field.age = 4
|
|
||||||
e.save()
|
|
||||||
self.assertEqual(Value.objects.count(), 3)
|
|
||||||
p = Patient.objects.get(name='Jon')
|
|
||||||
self.assertEqual(p.eav.age, 3)
|
|
||||||
self.assertEqual(p.eav.height, 2.3)
|
|
||||||
e = Encounter.objects.get(num=1)
|
|
||||||
self.assertEqual(e.eav_field.age, 4)
|
|
||||||
|
|
||||||
def test_illegal_assignemnt(self):
|
|
||||||
class EncounterEavConfig(EavConfig):
|
|
||||||
@classmethod
|
|
||||||
def get_attributes(cls):
|
|
||||||
return Attribute.objects.filter(datatype=Attribute.TYPE_INT)
|
|
||||||
|
|
||||||
eav.unregister(Encounter)
|
|
||||||
eav.register(Encounter, EncounterEavConfig)
|
|
||||||
|
|
||||||
p = Patient.objects.create(name='Jon')
|
|
||||||
e = Encounter.objects.create(patient=p, num=1)
|
|
||||||
|
|
||||||
with self.assertRaises(IllegalAssignmentException):
|
|
||||||
e.eav.color = 'red'
|
|
||||||
e.save()
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
|
|
||||||
import eav
|
|
||||||
from eav.models import Attribute, Value, EnumValue, EnumGroup
|
|
||||||
|
|
||||||
from .models import Patient
|
|
||||||
|
|
||||||
|
|
||||||
class DataValidation(TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
eav.register(Patient)
|
|
||||||
|
|
||||||
Attribute.objects.create(name='Age', datatype=Attribute.TYPE_INT)
|
|
||||||
Attribute.objects.create(name='DoB', datatype=Attribute.TYPE_DATE)
|
|
||||||
Attribute.objects.create(name='Height', datatype=Attribute.TYPE_FLOAT)
|
|
||||||
Attribute.objects.create(name='City', datatype=Attribute.TYPE_TEXT)
|
|
||||||
Attribute.objects.create(name='Pregnant?', datatype=Attribute.TYPE_BOOLEAN)
|
|
||||||
Attribute.objects.create(name='User', datatype=Attribute.TYPE_OBJECT)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
eav.unregister(Patient)
|
|
||||||
|
|
||||||
def test_required_field(self):
|
|
||||||
p = Patient(name='Bob')
|
|
||||||
p.eav.age = 5
|
|
||||||
p.save()
|
|
||||||
|
|
||||||
Attribute.objects.create(name='Weight', datatype=Attribute.TYPE_INT, required=True)
|
|
||||||
p.eav.age = 6
|
|
||||||
self.assertRaises(ValidationError, p.save)
|
|
||||||
p = Patient.objects.get(name='Bob')
|
|
||||||
self.assertEqual(p.eav.age, 5)
|
|
||||||
p.eav.weight = 23
|
|
||||||
p.save()
|
|
||||||
p = Patient.objects.get(name='Bob')
|
|
||||||
self.assertEqual(p.eav.weight, 23)
|
|
||||||
|
|
||||||
def test_create_required_field(self):
|
|
||||||
Attribute.objects.create(name='Weight', datatype=Attribute.TYPE_INT, required=True)
|
|
||||||
self.assertRaises(ValidationError,
|
|
||||||
Patient.objects.create,
|
|
||||||
name='Joe', eav__age=5)
|
|
||||||
self.assertEqual(Patient.objects.count(), 0)
|
|
||||||
self.assertEqual(Value.objects.count(), 0)
|
|
||||||
|
|
||||||
Patient.objects.create(name='Joe', eav__weight=2, eav__age=5)
|
|
||||||
self.assertEqual(Patient.objects.count(), 1)
|
|
||||||
self.assertEqual(Value.objects.count(), 2)
|
|
||||||
|
|
||||||
def test_validation_error_create(self):
|
|
||||||
self.assertRaises(ValidationError,
|
|
||||||
Patient.objects.create,
|
|
||||||
name='Joe', eav__age='df')
|
|
||||||
self.assertEqual(Patient.objects.count(), 0)
|
|
||||||
self.assertEqual(Value.objects.count(), 0)
|
|
||||||
|
|
||||||
def test_bad_slug(self):
|
|
||||||
a = Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT)
|
|
||||||
a.slug = 'Color'
|
|
||||||
self.assertRaises(ValidationError, a.save)
|
|
||||||
a.slug = '1st'
|
|
||||||
self.assertRaises(ValidationError, a.save)
|
|
||||||
a.slug = '_st'
|
|
||||||
self.assertRaises(ValidationError, a.save)
|
|
||||||
|
|
||||||
def test_changing_datatypes(self):
|
|
||||||
a = Attribute.objects.create(name='Color', datatype=Attribute.TYPE_INT)
|
|
||||||
a.datatype = Attribute.TYPE_TEXT
|
|
||||||
a.save()
|
|
||||||
Patient.objects.create(name='Bob', eav__color='brown')
|
|
||||||
a.datatype = Attribute.TYPE_INT
|
|
||||||
self.assertRaises(ValidationError, a.save)
|
|
||||||
|
|
||||||
def test_int_validation(self):
|
|
||||||
p = Patient.objects.create(name='Joe')
|
|
||||||
p.eav.age = 'bad'
|
|
||||||
self.assertRaises(ValidationError, p.save)
|
|
||||||
p.eav.age = 15
|
|
||||||
p.save()
|
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.age, 15)
|
|
||||||
|
|
||||||
def test_date_validation(self):
|
|
||||||
p = Patient.objects.create(name='Joe')
|
|
||||||
p.eav.dob = '12'
|
|
||||||
self.assertRaises(ValidationError, lambda: p.save())
|
|
||||||
p.eav.dob = 15
|
|
||||||
self.assertRaises(ValidationError, lambda: p.save())
|
|
||||||
now = timezone.now()
|
|
||||||
p.eav.dob = now
|
|
||||||
p.save()
|
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.dob, now)
|
|
||||||
today = timezone.now().date()
|
|
||||||
p.eav.dob = today
|
|
||||||
p.save()
|
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.dob.date(), today)
|
|
||||||
|
|
||||||
def test_float_validation(self):
|
|
||||||
p = Patient.objects.create(name='Joe')
|
|
||||||
p.eav.height = 'bad'
|
|
||||||
self.assertRaises(ValidationError, p.save)
|
|
||||||
p.eav.height = 15
|
|
||||||
p.save()
|
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.height, 15)
|
|
||||||
p.eav.height='2.3'
|
|
||||||
p.save()
|
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.height, 2.3)
|
|
||||||
|
|
||||||
def test_text_validation(self):
|
|
||||||
p = Patient.objects.create(name='Joe')
|
|
||||||
p.eav.city = 5
|
|
||||||
self.assertRaises(ValidationError, p.save)
|
|
||||||
p.eav.city = 'El Dorado'
|
|
||||||
p.save()
|
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.city, 'El Dorado')
|
|
||||||
|
|
||||||
def test_bool_validation(self):
|
|
||||||
p = Patient.objects.create(name='Joe')
|
|
||||||
p.eav.pregnant = 5
|
|
||||||
self.assertRaises(ValidationError, p.save)
|
|
||||||
p.eav.pregnant = True
|
|
||||||
p.save()
|
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.pregnant, True)
|
|
||||||
|
|
||||||
def test_object_validation(self):
|
|
||||||
p = Patient.objects.create(name='Joe')
|
|
||||||
p.eav.user = 5
|
|
||||||
self.assertRaises(ValidationError, p.save)
|
|
||||||
p.eav.user = object
|
|
||||||
self.assertRaises(ValidationError, p.save)
|
|
||||||
p.eav.user = User(username='joe')
|
|
||||||
self.assertRaises(ValidationError, p.save)
|
|
||||||
u = User.objects.create(username='joe')
|
|
||||||
p.eav.user = u
|
|
||||||
p.save()
|
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.user, u)
|
|
||||||
|
|
||||||
def test_enum_validation(self):
|
|
||||||
yes = EnumValue.objects.create(value='yes')
|
|
||||||
no = EnumValue.objects.create(value='no')
|
|
||||||
unkown = EnumValue.objects.create(value='unkown')
|
|
||||||
green = EnumValue.objects.create(value='green')
|
|
||||||
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
|
||||||
ynu.enums.add(yes)
|
|
||||||
ynu.enums.add(no)
|
|
||||||
ynu.enums.add(unkown)
|
|
||||||
Attribute.objects.create(name='Fever?', datatype=Attribute.TYPE_ENUM, enum_group=ynu)
|
|
||||||
|
|
||||||
p = Patient.objects.create(name='Joe')
|
|
||||||
p.eav.fever = 5
|
|
||||||
self.assertRaises(ValidationError, p.save)
|
|
||||||
p.eav.fever = object
|
|
||||||
self.assertRaises(ValidationError, p.save)
|
|
||||||
p.eav.fever = 'yes'
|
|
||||||
self.assertRaises(ValidationError, p.save)
|
|
||||||
p.eav.fever = green
|
|
||||||
self.assertRaises(ValidationError, p.save)
|
|
||||||
p.eav.fever = EnumValue(value='yes')
|
|
||||||
self.assertRaises(ValidationError, p.save)
|
|
||||||
p.eav.fever = no
|
|
||||||
p.save()
|
|
||||||
self.assertEqual(Patient.objects.get(pk=p.pk).eav.fever, no)
|
|
||||||
|
|
||||||
def test_enum_datatype_without_enum_group(self):
|
|
||||||
a = Attribute(name='Age Bracket', datatype=Attribute.TYPE_ENUM)
|
|
||||||
self.assertRaises(ValidationError, a.save)
|
|
||||||
yes = EnumValue.objects.create(value='yes')
|
|
||||||
no = EnumValue.objects.create(value='no')
|
|
||||||
unkown = EnumValue.objects.create(value='unkown')
|
|
||||||
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
|
||||||
ynu.enums.add(yes)
|
|
||||||
ynu.enums.add(no)
|
|
||||||
ynu.enums.add(unkown)
|
|
||||||
a = Attribute(name='Age Bracket', datatype=Attribute.TYPE_ENUM, enum_group=ynu)
|
|
||||||
a.save()
|
|
||||||
|
|
||||||
def test_enum_group_on_other_datatype(self):
|
|
||||||
yes = EnumValue.objects.create(value='yes')
|
|
||||||
no = EnumValue.objects.create(value='no')
|
|
||||||
unkown = EnumValue.objects.create(value='unkown')
|
|
||||||
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
|
||||||
ynu.enums.add(yes)
|
|
||||||
ynu.enums.add(no)
|
|
||||||
ynu.enums.add(unkown)
|
|
||||||
a = Attribute(name='color', datatype=Attribute.TYPE_TEXT, enum_group=ynu)
|
|
||||||
self.assertRaises(ValidationError, a.save)
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
from django.test import TestCase
|
|
||||||
from django.contrib.admin.sites import AdminSite
|
|
||||||
|
|
||||||
import eav
|
|
||||||
from eav.admin import *
|
|
||||||
from .models import Patient, M2MModel, ExampleModel
|
|
||||||
from eav.models import Attribute
|
|
||||||
from eav.forms import BaseDynamicEntityForm
|
|
||||||
from django.contrib import admin
|
|
||||||
from django.core.handlers.base import BaseHandler
|
|
||||||
from django.test.client import RequestFactory
|
|
||||||
from django.forms import ModelForm
|
|
||||||
|
|
||||||
|
|
||||||
class MockRequest(RequestFactory):
|
|
||||||
def request(self, **request):
|
|
||||||
"Construct a generic request object."
|
|
||||||
request = RequestFactory.request(self, **request)
|
|
||||||
handler = BaseHandler()
|
|
||||||
handler.load_middleware()
|
|
||||||
for middleware_method in handler._request_middleware:
|
|
||||||
if middleware_method(request):
|
|
||||||
raise Exception("Couldn't create request mock object - "
|
|
||||||
"request middleware returned a response")
|
|
||||||
return request
|
|
||||||
|
|
||||||
|
|
||||||
class MockSuperUser:
|
|
||||||
def __init__(self):
|
|
||||||
self.is_active = True
|
|
||||||
self.is_staff = True
|
|
||||||
|
|
||||||
def has_perm(self, perm):
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
request = MockRequest().request()
|
|
||||||
request.user = MockSuperUser()
|
|
||||||
|
|
||||||
|
|
||||||
class PatientForm(ModelForm):
|
|
||||||
class Meta:
|
|
||||||
model = Patient
|
|
||||||
fields = '__all__'
|
|
||||||
|
|
||||||
|
|
||||||
class M2MModelForm(ModelForm):
|
|
||||||
class Meta:
|
|
||||||
model = M2MModel
|
|
||||||
fields = '__all__'
|
|
||||||
|
|
||||||
|
|
||||||
class Forms(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
eav.register(Patient)
|
|
||||||
Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT)
|
|
||||||
Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT)
|
|
||||||
self.instance = Patient.objects.create(name='Jim Morrison')
|
|
||||||
self.site = AdminSite()
|
|
||||||
|
|
||||||
def test_fields(self):
|
|
||||||
admin = BaseEntityAdmin(Patient, self.site)
|
|
||||||
admin.form = BaseDynamicEntityForm
|
|
||||||
view = admin.change_view(request, str(self.instance.pk))
|
|
||||||
|
|
||||||
own_fields = 1
|
|
||||||
adminform = view.context_data['adminform']
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
len(adminform.form.fields), Attribute.objects.count() + own_fields
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_valid_submit(self):
|
|
||||||
self.instance.eav.color = 'Blue'
|
|
||||||
form = PatientForm(self.instance.__dict__, instance=self.instance)
|
|
||||||
jim = form.save()
|
|
||||||
|
|
||||||
self.assertEqual(jim.eav.color, 'Blue')
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_submit(self):
|
|
||||||
form = PatientForm(dict(color='Blue'), instance=self.instance)
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
jim = form.save()
|
|
||||||
|
|
||||||
|
|
||||||
def test_m2m(self):
|
|
||||||
m2mmodel = M2MModel.objects.create(name='name')
|
|
||||||
model = ExampleModel.objects.create(name='name')
|
|
||||||
form = M2MModelForm(dict(name='Lorem', models=[model.pk]), instance=m2mmodel)
|
|
||||||
form.save()
|
|
||||||
self.assertEqual(len(m2mmodel.models.all()), 1)
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from eav.models import EnumGroup, Attribute, Value
|
|
||||||
|
|
||||||
import eav
|
|
||||||
from .models import Patient
|
|
||||||
|
|
||||||
|
|
||||||
class MiscModels(TestCase):
|
|
||||||
def test_enumgroup_str(self):
|
|
||||||
name = 'Yes / No'
|
|
||||||
e = EnumGroup.objects.create(name=name)
|
|
||||||
self.assertEqual('<EnumGroup Yes / No>', str(e))
|
|
||||||
|
|
||||||
def test_attribute_help_text(self):
|
|
||||||
desc = 'Patient Age'
|
|
||||||
a = Attribute.objects.create(name='age', description=desc, datatype=Attribute.TYPE_INT)
|
|
||||||
self.assertEqual(a.help_text, desc)
|
|
||||||
|
|
||||||
def test_setting_to_none_deletes_value(self):
|
|
||||||
eav.register(Patient)
|
|
||||||
Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT)
|
|
||||||
p = Patient.objects.create(name='Bob', eav__age=5)
|
|
||||||
self.assertEqual(Value.objects.count(), 1)
|
|
||||||
p.eav.age = None
|
|
||||||
p.save()
|
|
||||||
self.assertEqual(Value.objects.count(), 0)
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
from django.db import models
|
|
||||||
from eav.decorators import register_eav
|
|
||||||
|
|
||||||
|
|
||||||
class Patient(models.Model):
|
|
||||||
name = models.CharField(max_length=12)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
class Encounter(models.Model):
|
|
||||||
num = models.PositiveSmallIntegerField()
|
|
||||||
patient = models.ForeignKey(Patient, on_delete=models.PROTECT)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return '%s: encounter num %d' % (self.patient, self.num)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
@register_eav()
|
|
||||||
class ExampleModel(models.Model):
|
|
||||||
name = models.CharField(max_length=12)
|
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
@register_eav()
|
|
||||||
class M2MModel(models.Model):
|
|
||||||
name = models.CharField(max_length=12)
|
|
||||||
models = models.ManyToManyField(ExampleModel)
|
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
return self.name
|
|
||||||
139
tests/queries.py
139
tests/queries.py
|
|
@ -1,139 +0,0 @@
|
||||||
from django.core.exceptions import MultipleObjectsReturned
|
|
||||||
from django.db.models import Q
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
import eav
|
|
||||||
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
|
||||||
|
|
||||||
from .models import Encounter, Patient
|
|
||||||
|
|
||||||
|
|
||||||
class Queries(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
eav.register(Encounter)
|
|
||||||
eav.register(Patient)
|
|
||||||
|
|
||||||
Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT)
|
|
||||||
Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT)
|
|
||||||
Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT)
|
|
||||||
Attribute.objects.create(name='city', datatype=Attribute.TYPE_TEXT)
|
|
||||||
Attribute.objects.create(name='country', datatype=Attribute.TYPE_TEXT)
|
|
||||||
|
|
||||||
self.yes = EnumValue.objects.create(value='yes')
|
|
||||||
self.no = EnumValue.objects.create(value='no')
|
|
||||||
self.unknown = EnumValue.objects.create(value='unknown')
|
|
||||||
|
|
||||||
ynu = EnumGroup.objects.create(name='Yes / No / Unknown')
|
|
||||||
ynu.enums.add(self.yes)
|
|
||||||
ynu.enums.add(self.no)
|
|
||||||
ynu.enums.add(self.unknown)
|
|
||||||
|
|
||||||
Attribute.objects.create(name='fever', datatype=Attribute.TYPE_ENUM, enum_group=ynu)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
eav.unregister(Encounter)
|
|
||||||
eav.unregister(Patient)
|
|
||||||
|
|
||||||
def test_get_or_create_with_eav(self):
|
|
||||||
Patient.objects.get_or_create(name='Bob', eav__age=5)
|
|
||||||
self.assertEqual(Patient.objects.count(), 1)
|
|
||||||
self.assertEqual(Value.objects.count(), 1)
|
|
||||||
Patient.objects.get_or_create(name='Bob', eav__age=5)
|
|
||||||
self.assertEqual(Patient.objects.count(), 1)
|
|
||||||
self.assertEqual(Value.objects.count(), 1)
|
|
||||||
Patient.objects.get_or_create(name='Bob', eav__age=6)
|
|
||||||
self.assertEqual(Patient.objects.count(), 2)
|
|
||||||
self.assertEqual(Value.objects.count(), 2)
|
|
||||||
|
|
||||||
def test_get_with_eav(self):
|
|
||||||
p1, _ = Patient.objects.get_or_create(name='Bob', eav__age=6)
|
|
||||||
self.assertEqual(Patient.objects.get(eav__age=6), p1)
|
|
||||||
|
|
||||||
Patient.objects.create(name='Fred', eav__age=6)
|
|
||||||
self.assertRaises(MultipleObjectsReturned, lambda: Patient.objects.get(eav__age=6))
|
|
||||||
|
|
||||||
def test_filtering_on_normal_and_eav_fields(self):
|
|
||||||
yes = self.yes
|
|
||||||
no = self.no
|
|
||||||
|
|
||||||
data = [
|
|
||||||
# Name, age, fever, city, country.
|
|
||||||
['Anne', 3, no, 'New York', 'USA'],
|
|
||||||
['Bob', 15, no, 'Bamako', 'Mali'],
|
|
||||||
['Cyrill', 15, yes, 'Kisumu', 'Kenya'],
|
|
||||||
['Daniel', 3, no, 'Nice', 'France'],
|
|
||||||
['Eugene', 2, yes, 'France', 'Nice']
|
|
||||||
]
|
|
||||||
|
|
||||||
for row in data:
|
|
||||||
Patient.objects.create(
|
|
||||||
name=row[0],
|
|
||||||
eav__age=row[1],
|
|
||||||
eav__fever=row[2],
|
|
||||||
eav__city=row[3],
|
|
||||||
eav__country=row[4]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check number of objects in DB.
|
|
||||||
self.assertEqual(Patient.objects.count(), 5)
|
|
||||||
self.assertEqual(Value.objects.count(), 20)
|
|
||||||
|
|
||||||
# Nobody
|
|
||||||
q1 = Q(eav__fever=yes) & Q(eav__fever=no)
|
|
||||||
p = Patient.objects.filter(q1)
|
|
||||||
self.assertEqual(p.count(), 0)
|
|
||||||
|
|
||||||
# Anne, Daniel
|
|
||||||
q1 = Q(eav__age__gte=3) # Everyone except Eugene
|
|
||||||
q2 = Q(eav__age__lt=15) # Anne, Daniel, Eugene
|
|
||||||
p = Patient.objects.filter(q2 & q1)
|
|
||||||
self.assertEqual(p.count(), 2)
|
|
||||||
|
|
||||||
# Anne
|
|
||||||
q1 = Q(eav__city__contains='Y') & Q(eav__fever=no)
|
|
||||||
q2 = Q(eav__age=3)
|
|
||||||
p = Patient.objects.filter(q1 & q2)
|
|
||||||
self.assertEqual(p.count(), 1)
|
|
||||||
|
|
||||||
# Anne, Daniel
|
|
||||||
q1 = Q(eav__city__contains='Y', eav__fever=no)
|
|
||||||
q2 = Q(eav__city='Nice')
|
|
||||||
q3 = Q(eav__age=3)
|
|
||||||
p = Patient.objects.filter((q1 | q2) & q3)
|
|
||||||
self.assertEqual(p.count(), 2)
|
|
||||||
|
|
||||||
# Everyone
|
|
||||||
q1 = Q(eav__fever=no) | Q(eav__fever=yes)
|
|
||||||
p = Patient.objects.filter(q1)
|
|
||||||
self.assertEqual(p.count(), 5)
|
|
||||||
|
|
||||||
# Anne, Bob, Daniel
|
|
||||||
q1 = Q(eav__fever=no) # Anne, Bob, Daniel
|
|
||||||
q2 = Q(eav__fever=yes) # Cyrill, Eugene
|
|
||||||
q3 = Q(eav__country__contains='e') # Cyrill, Daniel, Eugene
|
|
||||||
q4 = q2 & q3 # Cyrill, Daniel, Eugene
|
|
||||||
q5 = (q1 | q4) & q1 # Anne, Bob, Daniel
|
|
||||||
p = Patient.objects.filter(q5)
|
|
||||||
self.assertEqual(p.count(), 3)
|
|
||||||
|
|
||||||
# Everyone except Anne
|
|
||||||
q1 = Q(eav__city__contains='Y')
|
|
||||||
p = Patient.objects.exclude(q1)
|
|
||||||
self.assertEqual(p.count(), 4)
|
|
||||||
|
|
||||||
# Anne, Bob, Daniel
|
|
||||||
q1 = Q(eav__city__contains='Y')
|
|
||||||
q2 = Q(eav__fever=no)
|
|
||||||
q3 = q1 | q2
|
|
||||||
p = Patient.objects.filter(q3)
|
|
||||||
self.assertEqual(p.count(), 3)
|
|
||||||
|
|
||||||
# Anne, Daniel
|
|
||||||
q1 = Q(eav__age=3)
|
|
||||||
p = Patient.objects.filter(q1)
|
|
||||||
self.assertEqual(p.count(), 2)
|
|
||||||
|
|
||||||
# Eugene
|
|
||||||
q1 = Q(name__contains='E', eav__fever=yes)
|
|
||||||
p = Patient.objects.filter(q1)
|
|
||||||
self.assertEqual(p.count(), 1)
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
import eav
|
|
||||||
from eav.registry import EavConfig
|
|
||||||
|
|
||||||
from .models import Encounter, ExampleModel, Patient
|
|
||||||
|
|
||||||
|
|
||||||
class RegistryTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def register_encounter(self):
|
|
||||||
class EncounterEav(EavConfig):
|
|
||||||
manager_attr = 'eav_objects'
|
|
||||||
eav_attr = 'eav_field'
|
|
||||||
generic_relation_attr = 'encounter_eav_values'
|
|
||||||
generic_relation_related_name = 'encounters'
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_attributes(cls):
|
|
||||||
return 'testing'
|
|
||||||
|
|
||||||
eav.register(Encounter, EncounterEav)
|
|
||||||
|
|
||||||
def test_registering_with_defaults(self):
|
|
||||||
eav.register(Patient)
|
|
||||||
self.assertTrue(hasattr(Patient, '_eav_config_cls'))
|
|
||||||
self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects')
|
|
||||||
self.assertFalse(Patient._eav_config_cls.manager_only)
|
|
||||||
self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav')
|
|
||||||
self.assertEqual(Patient._eav_config_cls.generic_relation_attr, 'eav_values')
|
|
||||||
self.assertEqual(Patient._eav_config_cls.generic_relation_related_name, None)
|
|
||||||
eav.unregister(Patient)
|
|
||||||
|
|
||||||
def test_registering_overriding_defaults(self):
|
|
||||||
eav.register(Patient)
|
|
||||||
self.register_encounter()
|
|
||||||
self.assertTrue(hasattr(Patient, '_eav_config_cls'))
|
|
||||||
self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects')
|
|
||||||
self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav')
|
|
||||||
|
|
||||||
self.assertTrue(hasattr(Encounter, '_eav_config_cls'))
|
|
||||||
self.assertEqual(Encounter._eav_config_cls.get_attributes(), 'testing')
|
|
||||||
self.assertEqual(Encounter._eav_config_cls.manager_attr, 'eav_objects')
|
|
||||||
self.assertEqual(Encounter._eav_config_cls.eav_attr, 'eav_field')
|
|
||||||
eav.unregister(Patient)
|
|
||||||
eav.unregister(Encounter)
|
|
||||||
|
|
||||||
def test_registering_via_decorator_with_defaults(self):
|
|
||||||
self.assertTrue(hasattr(ExampleModel, '_eav_config_cls'))
|
|
||||||
self.assertEqual(ExampleModel._eav_config_cls.manager_attr, 'objects')
|
|
||||||
self.assertEqual(ExampleModel._eav_config_cls.eav_attr, 'eav')
|
|
||||||
|
|
||||||
def test_unregistering(self):
|
|
||||||
old_mgr = Patient.objects
|
|
||||||
eav.register(Patient)
|
|
||||||
self.assertTrue(Patient.objects.__class__.__name__ == 'EntityManager')
|
|
||||||
eav.unregister(Patient)
|
|
||||||
self.assertFalse(Patient.objects.__class__.__name__ == 'EntityManager')
|
|
||||||
self.assertEqual(Patient.objects, old_mgr)
|
|
||||||
self.assertFalse(hasattr(Patient, '_eav_config_cls'))
|
|
||||||
|
|
||||||
def test_unregistering_via_decorator(self):
|
|
||||||
self.assertTrue(ExampleModel.objects.__class__.__name__ == 'EntityManager')
|
|
||||||
eav.unregister(ExampleModel)
|
|
||||||
self.assertFalse(ExampleModel.objects.__class__.__name__ == 'EntityManager')
|
|
||||||
|
|
||||||
def test_unregistering_unregistered_model_proceeds_silently(self):
|
|
||||||
eav.unregister(Patient)
|
|
||||||
|
|
||||||
def test_double_registering_model_is_harmless(self):
|
|
||||||
eav.register(Patient)
|
|
||||||
eav.register(Patient)
|
|
||||||
|
|
||||||
def test_doesnt_register_nonmodel(self):
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
@eav.decorators.register_eav()
|
|
||||||
class Foo(object):
|
|
||||||
pass
|
|
||||||
183
tests/test_attributes.py
Normal file
183
tests/test_attributes.py
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import string
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.test import TestCase
|
||||||
|
from hypothesis import given, settings
|
||||||
|
from hypothesis import strategies as st
|
||||||
|
from hypothesis.extra import django
|
||||||
|
from hypothesis.strategies import just
|
||||||
|
|
||||||
|
import eav
|
||||||
|
from eav.exceptions import IllegalAssignmentException
|
||||||
|
from eav.models import Attribute, Value
|
||||||
|
from eav.registry import EavConfig
|
||||||
|
from test_project.models import Doctor, Encounter, Patient, RegisterTestModel
|
||||||
|
|
||||||
|
if django_settings.EAV2_PRIMARY_KEY_FIELD == "django.db.models.UUIDField":
|
||||||
|
auto_field_strategy = st.builds(uuid.uuid4, version=4, max_length=32)
|
||||||
|
elif django_settings.EAV2_PRIMARY_KEY_FIELD == "django.db.models.CharField":
|
||||||
|
auto_field_strategy = st.text(min_size=1, max_size=255)
|
||||||
|
else:
|
||||||
|
auto_field_strategy = st.integers(min_value=1, max_value=32)
|
||||||
|
|
||||||
|
|
||||||
|
class Attributes(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
class EncounterEavConfig(EavConfig):
|
||||||
|
manager_attr = "eav_objects"
|
||||||
|
eav_attr = "eav_field"
|
||||||
|
generic_relation_attr = "encounter_eav_values"
|
||||||
|
generic_relation_related_name = "encounters"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_attributes(cls, instance=None):
|
||||||
|
return Attribute.objects.filter(slug__contains="a")
|
||||||
|
|
||||||
|
eav.register(Encounter, EncounterEavConfig)
|
||||||
|
eav.register(Patient)
|
||||||
|
|
||||||
|
Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT)
|
||||||
|
Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT)
|
||||||
|
Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT)
|
||||||
|
Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
eav.unregister(Encounter)
|
||||||
|
eav.unregister(Patient)
|
||||||
|
|
||||||
|
def test_get_attribute_querysets(self):
|
||||||
|
self.assertEqual(Patient._eav_config_cls.get_attributes().count(), 4)
|
||||||
|
self.assertEqual(Encounter._eav_config_cls.get_attributes().count(), 1)
|
||||||
|
|
||||||
|
def test_duplicate_attributs(self):
|
||||||
|
"""
|
||||||
|
Ensure that no two Attributes with the same slug can exist.
|
||||||
|
"""
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT)
|
||||||
|
|
||||||
|
def test_setting_attributes(self):
|
||||||
|
p = Patient.objects.create(name="Jon")
|
||||||
|
e = Encounter.objects.create(patient=p, num=1)
|
||||||
|
|
||||||
|
p.eav.age = 3
|
||||||
|
p.eav.height = 2.3
|
||||||
|
p.save()
|
||||||
|
e.eav_field.age = 4
|
||||||
|
e.save()
|
||||||
|
self.assertEqual(Value.objects.count(), 3)
|
||||||
|
t = RegisterTestModel.objects.create(name="test")
|
||||||
|
t.eav.age = 6
|
||||||
|
t.eav.height = 10
|
||||||
|
t.save()
|
||||||
|
p = Patient.objects.get(name="Jon")
|
||||||
|
self.assertEqual(p.eav.age, 3)
|
||||||
|
self.assertEqual(p.eav.height, 2.3)
|
||||||
|
e = Encounter.objects.get(num=1)
|
||||||
|
self.assertEqual(e.eav_field.age, 4)
|
||||||
|
t = RegisterTestModel.objects.get(name="test")
|
||||||
|
self.assertEqual(t.eav.age, 6)
|
||||||
|
self.assertEqual(t.eav.height, 10)
|
||||||
|
|
||||||
|
# Validate repr of Value for an entity with an INT PK
|
||||||
|
v1 = Value.objects.filter(entity_id=p.pk).first()
|
||||||
|
assert isinstance(repr(v1), str)
|
||||||
|
assert isinstance(str(v1), str)
|
||||||
|
|
||||||
|
def test_illegal_assignemnt(self):
|
||||||
|
class EncounterEavConfig(EavConfig):
|
||||||
|
@classmethod
|
||||||
|
def get_attributes(cls, instance=None):
|
||||||
|
return Attribute.objects.filter(datatype=Attribute.TYPE_INT)
|
||||||
|
|
||||||
|
eav.unregister(Encounter)
|
||||||
|
eav.register(Encounter, EncounterEavConfig)
|
||||||
|
|
||||||
|
p = Patient.objects.create(name="Jon")
|
||||||
|
e = Encounter.objects.create(patient=p, num=1)
|
||||||
|
|
||||||
|
with self.assertRaises(IllegalAssignmentException):
|
||||||
|
e.eav.color = "red"
|
||||||
|
e.save()
|
||||||
|
|
||||||
|
def test_uuid_pk(self):
|
||||||
|
"""Tests for when model pk is UUID."""
|
||||||
|
expected_age = 10
|
||||||
|
d1 = Doctor.objects.create(name="Lu")
|
||||||
|
d1.eav.age = expected_age
|
||||||
|
d1.save()
|
||||||
|
|
||||||
|
assert d1.eav.age == expected_age
|
||||||
|
|
||||||
|
# Validate repr of Value for an entity with a UUID PK
|
||||||
|
v1 = Value.objects.filter(entity_uuid=d1.pk).first()
|
||||||
|
assert isinstance(repr(v1), str)
|
||||||
|
assert isinstance(str(v1), str)
|
||||||
|
|
||||||
|
def test_big_integer(self):
|
||||||
|
"""Tests an integer larger than 32-bit a value."""
|
||||||
|
big_num = 3147483647
|
||||||
|
patient = Patient.objects.create(name="Jon")
|
||||||
|
patient.eav.age = big_num
|
||||||
|
|
||||||
|
patient.save()
|
||||||
|
|
||||||
|
assert patient.eav.age == big_num
|
||||||
|
|
||||||
|
|
||||||
|
class TestAttributeModel(django.TestCase):
|
||||||
|
"""This is a property-based test that ensures model correctness."""
|
||||||
|
|
||||||
|
@given(
|
||||||
|
django.from_model(
|
||||||
|
Attribute,
|
||||||
|
id=auto_field_strategy,
|
||||||
|
datatype=just(Attribute.TYPE_TEXT),
|
||||||
|
enum_group=just(None),
|
||||||
|
slug=just(None), # Let Attribute.save() handle
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@settings(deadline=None)
|
||||||
|
def test_model_properties(self, attribute: Attribute) -> None:
|
||||||
|
"""Tests that instance can be saved and has correct representation."""
|
||||||
|
attribute.full_clean()
|
||||||
|
attribute.save()
|
||||||
|
|
||||||
|
assert attribute
|
||||||
|
|
||||||
|
@given(
|
||||||
|
st.text(
|
||||||
|
alphabet=st.sampled_from(string.ascii_letters + string.digits),
|
||||||
|
min_size=50,
|
||||||
|
max_size=100,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_large_name_input(self, name_value) -> None:
|
||||||
|
"""Ensure proper slug is generated from large name fields."""
|
||||||
|
instance = Attribute.objects.create(
|
||||||
|
name=name_value,
|
||||||
|
datatype=Attribute.TYPE_TEXT,
|
||||||
|
enum_group=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(instance, Attribute)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_attribute_create_with_invalid_slug() -> None:
|
||||||
|
"""
|
||||||
|
Test that creating an Attribute with an invalid slug raises a UserWarning.
|
||||||
|
|
||||||
|
This test ensures that when an Attribute is created with a slug that is not
|
||||||
|
a valid Python identifier, a UserWarning is raised. The warning should
|
||||||
|
indicate that the slug is invalid and suggest updating it.
|
||||||
|
"""
|
||||||
|
with pytest.warns(UserWarning):
|
||||||
|
Attribute.objects.create(
|
||||||
|
name="Test Attribute",
|
||||||
|
slug="123-invalid",
|
||||||
|
datatype=Attribute.TYPE_TEXT,
|
||||||
|
)
|
||||||
216
tests/test_data_validation.py
Normal file
216
tests/test_data_validation.py
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import eav
|
||||||
|
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
||||||
|
from test_project.models import Patient
|
||||||
|
|
||||||
|
|
||||||
|
class DataValidation(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
eav.register(Patient)
|
||||||
|
|
||||||
|
Attribute.objects.create(name="Age", datatype=Attribute.TYPE_INT)
|
||||||
|
Attribute.objects.create(name="DoB", datatype=Attribute.TYPE_DATE)
|
||||||
|
Attribute.objects.create(name="Height", datatype=Attribute.TYPE_FLOAT)
|
||||||
|
Attribute.objects.create(name="City", datatype=Attribute.TYPE_TEXT)
|
||||||
|
Attribute.objects.create(name="Pregnant", datatype=Attribute.TYPE_BOOLEAN)
|
||||||
|
Attribute.objects.create(name="User", datatype=Attribute.TYPE_OBJECT)
|
||||||
|
Attribute.objects.create(name="Extra", datatype=Attribute.TYPE_JSON)
|
||||||
|
Attribute.objects.create(name="Multi", datatype=Attribute.TYPE_CSV)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
eav.unregister(Patient)
|
||||||
|
|
||||||
|
def test_required_field(self):
|
||||||
|
p = Patient(name="Bob")
|
||||||
|
p.eav.age = 5
|
||||||
|
p.save()
|
||||||
|
|
||||||
|
Attribute.objects.create(
|
||||||
|
name="Weight",
|
||||||
|
datatype=Attribute.TYPE_INT,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
p.eav.age = 6
|
||||||
|
self.assertRaises(ValidationError, p.save)
|
||||||
|
p = Patient.objects.get(name="Bob")
|
||||||
|
self.assertEqual(p.eav.age, 5)
|
||||||
|
p.eav.weight = 23
|
||||||
|
p.save()
|
||||||
|
p = Patient.objects.get(name="Bob")
|
||||||
|
self.assertEqual(p.eav.weight, 23)
|
||||||
|
|
||||||
|
def test_create_required_field(self):
|
||||||
|
Attribute.objects.create(
|
||||||
|
name="Weight",
|
||||||
|
datatype=Attribute.TYPE_INT,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
self.assertRaises(
|
||||||
|
ValidationError,
|
||||||
|
Patient.objects.create,
|
||||||
|
name="Joe",
|
||||||
|
eav__age=5,
|
||||||
|
)
|
||||||
|
self.assertEqual(Patient.objects.count(), 0)
|
||||||
|
self.assertEqual(Value.objects.count(), 0)
|
||||||
|
|
||||||
|
Patient.objects.create(name="Joe", eav__weight=2, eav__age=5)
|
||||||
|
self.assertEqual(Patient.objects.count(), 1)
|
||||||
|
self.assertEqual(Value.objects.count(), 2)
|
||||||
|
|
||||||
|
def test_validation_error_create(self):
|
||||||
|
self.assertRaises(
|
||||||
|
ValidationError,
|
||||||
|
Patient.objects.create,
|
||||||
|
name="Joe",
|
||||||
|
eav__age="df",
|
||||||
|
)
|
||||||
|
self.assertEqual(Patient.objects.count(), 0)
|
||||||
|
self.assertEqual(Value.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_changing_datatypes(self):
|
||||||
|
a = Attribute.objects.create(name="Color", datatype=Attribute.TYPE_INT)
|
||||||
|
a.datatype = Attribute.TYPE_TEXT
|
||||||
|
a.save()
|
||||||
|
Patient.objects.create(name="Bob", eav__color="brown")
|
||||||
|
a.datatype = Attribute.TYPE_INT
|
||||||
|
self.assertRaises(ValidationError, a.save)
|
||||||
|
|
||||||
|
def test_int_validation(self):
|
||||||
|
p = Patient.objects.create(name="Joe")
|
||||||
|
p.eav.age = "bad"
|
||||||
|
self.assertRaises(ValidationError, p.save)
|
||||||
|
p.eav.age = 15
|
||||||
|
p.save()
|
||||||
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.age, 15)
|
||||||
|
|
||||||
|
def test_date_validation(self):
|
||||||
|
p = Patient.objects.create(name="Joe")
|
||||||
|
p.eav.dob = "12"
|
||||||
|
self.assertRaises(ValidationError, lambda: p.save())
|
||||||
|
p.eav.dob = 15
|
||||||
|
self.assertRaises(ValidationError, lambda: p.save())
|
||||||
|
now = timezone.now()
|
||||||
|
p.eav.dob = now
|
||||||
|
p.save()
|
||||||
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.dob, now)
|
||||||
|
today = timezone.now().date()
|
||||||
|
p.eav.dob = today
|
||||||
|
p.save()
|
||||||
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.dob.date(), today)
|
||||||
|
|
||||||
|
def test_float_validation(self):
|
||||||
|
p = Patient.objects.create(name="Joe")
|
||||||
|
p.eav.height = "bad"
|
||||||
|
self.assertRaises(ValidationError, p.save)
|
||||||
|
p.eav.height = 15
|
||||||
|
p.save()
|
||||||
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.height, 15)
|
||||||
|
p.eav.height = "2.3"
|
||||||
|
p.save()
|
||||||
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.height, 2.3)
|
||||||
|
|
||||||
|
def test_text_validation(self):
|
||||||
|
p = Patient.objects.create(name="Joe")
|
||||||
|
p.eav.city = 5
|
||||||
|
self.assertRaises(ValidationError, p.save)
|
||||||
|
p.eav.city = "El Dorado"
|
||||||
|
p.save()
|
||||||
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.city, "El Dorado")
|
||||||
|
|
||||||
|
def test_bool_validation(self):
|
||||||
|
p = Patient.objects.create(name="Joe")
|
||||||
|
p.eav.pregnant = 5
|
||||||
|
self.assertRaises(ValidationError, p.save)
|
||||||
|
p.eav.pregnant = True
|
||||||
|
p.save()
|
||||||
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.pregnant, True)
|
||||||
|
|
||||||
|
def test_object_validation(self):
|
||||||
|
p = Patient.objects.create(name="Joe")
|
||||||
|
p.eav.user = 5
|
||||||
|
self.assertRaises(ValidationError, p.save)
|
||||||
|
p.eav.user = object
|
||||||
|
self.assertRaises(ValidationError, p.save)
|
||||||
|
p.eav.user = User(username="joe")
|
||||||
|
self.assertRaises(ValidationError, p.save)
|
||||||
|
u = User.objects.create(username="joe")
|
||||||
|
p.eav.user = u
|
||||||
|
p.save()
|
||||||
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.user, u)
|
||||||
|
|
||||||
|
def test_enum_validation(self):
|
||||||
|
yes = EnumValue.objects.create(value="yes")
|
||||||
|
no = EnumValue.objects.create(value="no")
|
||||||
|
unkown = EnumValue.objects.create(value="unkown")
|
||||||
|
green = EnumValue.objects.create(value="green")
|
||||||
|
ynu = EnumGroup.objects.create(name="Yes / No / Unknown")
|
||||||
|
ynu.values.add(yes)
|
||||||
|
ynu.values.add(no)
|
||||||
|
ynu.values.add(unkown)
|
||||||
|
Attribute.objects.create(
|
||||||
|
name="Fever",
|
||||||
|
datatype=Attribute.TYPE_ENUM,
|
||||||
|
enum_group=ynu,
|
||||||
|
)
|
||||||
|
|
||||||
|
p = Patient.objects.create(name="Joe")
|
||||||
|
p.eav.fever = 5
|
||||||
|
self.assertRaises(ValidationError, p.save)
|
||||||
|
p.eav.fever = object
|
||||||
|
self.assertRaises(ValidationError, p.save)
|
||||||
|
p.eav.fever = green
|
||||||
|
self.assertRaises(ValidationError, p.save)
|
||||||
|
p.eav.fever = EnumValue(value="yes")
|
||||||
|
self.assertRaises(ValidationError, p.save)
|
||||||
|
p.eav.fever = no
|
||||||
|
p.save()
|
||||||
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.fever, no)
|
||||||
|
|
||||||
|
def test_enum_datatype_without_enum_group(self):
|
||||||
|
a = Attribute(name="Age Bracket", datatype=Attribute.TYPE_ENUM)
|
||||||
|
self.assertRaises(ValidationError, a.save)
|
||||||
|
yes = EnumValue.objects.create(value="yes")
|
||||||
|
no = EnumValue.objects.create(value="no")
|
||||||
|
unkown = EnumValue.objects.create(value="unkown")
|
||||||
|
ynu = EnumGroup.objects.create(name="Yes / No / Unknown")
|
||||||
|
ynu.values.add(yes)
|
||||||
|
ynu.values.add(no)
|
||||||
|
ynu.values.add(unkown)
|
||||||
|
a = Attribute(name="Age Bracket", datatype=Attribute.TYPE_ENUM, enum_group=ynu)
|
||||||
|
a.save()
|
||||||
|
|
||||||
|
def test_enum_group_on_other_datatype(self):
|
||||||
|
yes = EnumValue.objects.create(value="yes")
|
||||||
|
no = EnumValue.objects.create(value="no")
|
||||||
|
unkown = EnumValue.objects.create(value="unkown")
|
||||||
|
ynu = EnumGroup.objects.create(name="Yes / No / Unknown")
|
||||||
|
ynu.values.add(yes)
|
||||||
|
ynu.values.add(no)
|
||||||
|
ynu.values.add(unkown)
|
||||||
|
a = Attribute(name="color", datatype=Attribute.TYPE_TEXT, enum_group=ynu)
|
||||||
|
self.assertRaises(ValidationError, a.save)
|
||||||
|
|
||||||
|
def test_json_validation(self):
|
||||||
|
p = Patient.objects.create(name="Joe")
|
||||||
|
p.eav.extra = 5
|
||||||
|
self.assertRaises(ValidationError, p.save)
|
||||||
|
p.eav.extra = {"eyes": "blue", "hair": "brown"}
|
||||||
|
p.save()
|
||||||
|
self.assertEqual(Patient.objects.get(pk=p.pk).eav.extra.get("eyes", ""), "blue")
|
||||||
|
|
||||||
|
def test_csv_validation(self):
|
||||||
|
yes = EnumValue.objects.create(value="yes")
|
||||||
|
p = Patient.objects.create(name="Mike")
|
||||||
|
p.eav.multi = yes
|
||||||
|
self.assertRaises(ValidationError, p.save)
|
||||||
|
p.eav.multi = "one;two;three"
|
||||||
|
p.save()
|
||||||
|
self.assertEqual(
|
||||||
|
Patient.objects.get(pk=p.pk).eav.multi,
|
||||||
|
["one", "two", "three"],
|
||||||
|
)
|
||||||
251
tests/test_forms.py
Normal file
251
tests/test_forms.py
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
import pytest
|
||||||
|
from django.contrib.admin.sites import AdminSite
|
||||||
|
from django.core.handlers.base import BaseHandler
|
||||||
|
from django.forms import ModelForm
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.test.client import RequestFactory
|
||||||
|
|
||||||
|
import eav
|
||||||
|
from eav.admin import BaseEntityAdmin
|
||||||
|
from eav.forms import BaseDynamicEntityForm
|
||||||
|
from eav.models import Attribute, EnumGroup, EnumValue
|
||||||
|
from test_project.models import ExampleModel, M2MModel, Patient
|
||||||
|
|
||||||
|
|
||||||
|
class MockRequest(RequestFactory):
|
||||||
|
def request(self, **request):
|
||||||
|
"Construct a generic request object."
|
||||||
|
request = RequestFactory.request(self, **request)
|
||||||
|
handler = BaseHandler()
|
||||||
|
handler.load_middleware()
|
||||||
|
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
class MockSuperUser:
|
||||||
|
def __init__(self):
|
||||||
|
self.is_active = True
|
||||||
|
self.is_staff = True
|
||||||
|
|
||||||
|
def has_perm(self, perm):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
request = MockRequest().request()
|
||||||
|
request.user = MockSuperUser()
|
||||||
|
|
||||||
|
|
||||||
|
class PatientForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Patient
|
||||||
|
fields = ("name", "email", "example")
|
||||||
|
|
||||||
|
|
||||||
|
class PatientDynamicForm(BaseDynamicEntityForm):
|
||||||
|
class Meta:
|
||||||
|
model = Patient
|
||||||
|
fields = ("name", "email", "example")
|
||||||
|
|
||||||
|
|
||||||
|
class M2MModelForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = M2MModel
|
||||||
|
fields = ("name", "models")
|
||||||
|
|
||||||
|
|
||||||
|
class Forms(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
eav.register(Patient)
|
||||||
|
Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT)
|
||||||
|
Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT)
|
||||||
|
|
||||||
|
self.female = EnumValue.objects.create(value="Female")
|
||||||
|
self.male = EnumValue.objects.create(value="Male")
|
||||||
|
gender_group = EnumGroup.objects.create(name="Gender")
|
||||||
|
gender_group.values.add(self.female, self.male)
|
||||||
|
|
||||||
|
Attribute.objects.create(
|
||||||
|
name="gender",
|
||||||
|
datatype=Attribute.TYPE_ENUM,
|
||||||
|
enum_group=gender_group,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.instance = Patient.objects.create(name="Jim Morrison")
|
||||||
|
|
||||||
|
def test_valid_submit(self):
|
||||||
|
self.instance.eav.color = "Blue"
|
||||||
|
form = PatientForm(self.instance.__dict__, instance=self.instance)
|
||||||
|
jim = form.save()
|
||||||
|
|
||||||
|
self.assertEqual(jim.eav.color, "Blue")
|
||||||
|
|
||||||
|
def test_invalid_submit(self):
|
||||||
|
form = PatientForm({"color": "Blue"}, instance=self.instance)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
form.save()
|
||||||
|
|
||||||
|
def test_valid_enums(self):
|
||||||
|
self.instance.eav.gender = self.female
|
||||||
|
form = PatientForm(self.instance.__dict__, instance=self.instance)
|
||||||
|
rose = form.save()
|
||||||
|
|
||||||
|
self.assertEqual(rose.eav.gender, self.female)
|
||||||
|
|
||||||
|
def test_m2m(self):
|
||||||
|
m2mmodel = M2MModel.objects.create(name="name")
|
||||||
|
model = ExampleModel.objects.create(name="name")
|
||||||
|
form = M2MModelForm({"name": "Lorem", "models": [model.pk]}, instance=m2mmodel)
|
||||||
|
form.save()
|
||||||
|
self.assertEqual(len(m2mmodel.models.all()), 1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patient() -> Patient:
|
||||||
|
"""Return an eav enabled Patient instance."""
|
||||||
|
eav.register(Patient)
|
||||||
|
return Patient.objects.create(name="Jim Morrison")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_attributes() -> None:
|
||||||
|
"""Create some Attributes to use for testing."""
|
||||||
|
Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT)
|
||||||
|
Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("csv_data", "separator"),
|
||||||
|
[
|
||||||
|
("", ";"),
|
||||||
|
("justone", ","),
|
||||||
|
("one;two;three", ";"),
|
||||||
|
("alpha,beta,gamma", ","),
|
||||||
|
(None, ","),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_csvdynamicform(patient, csv_data, separator) -> None:
|
||||||
|
"""Ensure that a TYPE_CSV field works correctly with forms."""
|
||||||
|
Attribute.objects.create(name="csv", datatype=Attribute.TYPE_CSV)
|
||||||
|
patient.eav.csv = csv_data
|
||||||
|
patient.save()
|
||||||
|
patient.refresh_from_db()
|
||||||
|
|
||||||
|
form = PatientDynamicForm(
|
||||||
|
patient.__dict__,
|
||||||
|
instance=patient,
|
||||||
|
)
|
||||||
|
form.fields["csv"].separator = separator
|
||||||
|
assert form.is_valid()
|
||||||
|
jim = form.save()
|
||||||
|
|
||||||
|
expected_result = str(csv_data).split(separator) if csv_data else []
|
||||||
|
assert jim.eav.csv == expected_result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_csvdynamicform_empty(patient) -> None:
|
||||||
|
"""Test to ensure an instance with no eav values is correct."""
|
||||||
|
form = PatientDynamicForm(
|
||||||
|
patient.__dict__,
|
||||||
|
instance=patient,
|
||||||
|
)
|
||||||
|
assert form.is_valid()
|
||||||
|
assert form.save()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.usefixtures("create_attributes")
|
||||||
|
@pytest.mark.parametrize("define_fieldsets", [True, False])
|
||||||
|
def test_entity_admin_form(patient, define_fieldsets):
|
||||||
|
"""Test the BaseEntityAdmin form setup and dynamic fieldsets handling."""
|
||||||
|
admin = BaseEntityAdmin(Patient, AdminSite())
|
||||||
|
admin.readonly_fields = ("email",)
|
||||||
|
admin.form = BaseDynamicEntityForm
|
||||||
|
expected_fieldsets = 2
|
||||||
|
|
||||||
|
if define_fieldsets:
|
||||||
|
# Use all fields in Patient model
|
||||||
|
admin.fieldsets = (
|
||||||
|
(None, {"fields": ["name", "example"]}),
|
||||||
|
("Contact Info", {"fields": ["email"]}),
|
||||||
|
)
|
||||||
|
expected_fieldsets = 3
|
||||||
|
|
||||||
|
view = admin.change_view(request, str(patient.pk))
|
||||||
|
|
||||||
|
adminform = view.context_data["adminform"]
|
||||||
|
|
||||||
|
# Count the total fields in fieldsets
|
||||||
|
total_fields = sum(
|
||||||
|
len(fields_info["fields"]) for _, fields_info in adminform.fieldsets
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3 for 'name', 'email', 'example'
|
||||||
|
expected_fields_count = Attribute.objects.count() + 3
|
||||||
|
|
||||||
|
assert total_fields == expected_fields_count
|
||||||
|
|
||||||
|
# Ensure our fieldset count is correct
|
||||||
|
assert len(adminform.fieldsets) == expected_fieldsets
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_entity_admin_form_no_attributes(patient):
|
||||||
|
"""Test the BaseEntityAdmin form with no Attributes created."""
|
||||||
|
admin = BaseEntityAdmin(Patient, AdminSite())
|
||||||
|
admin.readonly_fields = ("email",)
|
||||||
|
admin.form = BaseDynamicEntityForm
|
||||||
|
|
||||||
|
# Only fields defined in Patient model
|
||||||
|
expected_fields = 3
|
||||||
|
|
||||||
|
view = admin.change_view(request, str(patient.pk))
|
||||||
|
|
||||||
|
adminform = view.context_data["adminform"]
|
||||||
|
|
||||||
|
# Count the total fields in fieldsets
|
||||||
|
total_fields = sum(
|
||||||
|
len(fields_info["fields"]) for _, fields_info in adminform.fieldsets
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3 for 'name', 'email', 'example'
|
||||||
|
assert total_fields == expected_fields
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_dynamic_form_renders_enum_choices():
|
||||||
|
"""
|
||||||
|
Test that enum choices render correctly in BaseDynamicEntityForm.
|
||||||
|
|
||||||
|
This test verifies the fix for issue #648 where enum choices weren't
|
||||||
|
rendering correctly in Django 4.2.17 due to QuerySet unpacking issues.
|
||||||
|
"""
|
||||||
|
# Setup
|
||||||
|
eav.register(Patient)
|
||||||
|
|
||||||
|
# Create enum values and group
|
||||||
|
female = EnumValue.objects.create(value="Female")
|
||||||
|
male = EnumValue.objects.create(value="Male")
|
||||||
|
gender_group = EnumGroup.objects.create(name="Gender")
|
||||||
|
gender_group.values.add(female, male)
|
||||||
|
|
||||||
|
Attribute.objects.create(
|
||||||
|
name="gender",
|
||||||
|
datatype=Attribute.TYPE_ENUM,
|
||||||
|
enum_group=gender_group,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a patient
|
||||||
|
patient = Patient.objects.create(name="Test Patient")
|
||||||
|
|
||||||
|
# Initialize the dynamic form
|
||||||
|
form = PatientDynamicForm(instance=patient)
|
||||||
|
|
||||||
|
# Test rendering - should not raise any exceptions
|
||||||
|
rendered_form = form.as_p()
|
||||||
|
|
||||||
|
# Verify the form rendered and contains the enum choices
|
||||||
|
assert 'name="gender"' in rendered_form
|
||||||
|
assert f'value="{female.pk}">{female.value}' in rendered_form
|
||||||
|
assert f'value="{male.pk}">{male.value}' in rendered_form
|
||||||
76
tests/test_logic.py
Normal file
76
tests/test_logic.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import pytest
|
||||||
|
from hypothesis import given
|
||||||
|
from hypothesis import strategies as st
|
||||||
|
|
||||||
|
from eav.logic.slug import SLUGFIELD_MAX_LENGTH, generate_slug
|
||||||
|
|
||||||
|
|
||||||
|
@given(st.text())
|
||||||
|
def test_generate_slug(name: str) -> None:
|
||||||
|
"""Ensures slug generation works properly."""
|
||||||
|
slug = generate_slug(name)
|
||||||
|
|
||||||
|
assert slug
|
||||||
|
|
||||||
|
|
||||||
|
@given(st.text(min_size=SLUGFIELD_MAX_LENGTH))
|
||||||
|
def test_generate_long_slug_text(name: str) -> None:
|
||||||
|
"""Ensures a slug isn't generated longer than maximum allowed length."""
|
||||||
|
slug = generate_slug(name)
|
||||||
|
|
||||||
|
assert len(slug) <= SLUGFIELD_MAX_LENGTH
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_slug_uniqueness() -> None:
|
||||||
|
"""Test that generate_slug() produces unique slugs for different inputs.
|
||||||
|
|
||||||
|
This test ensures that even similar inputs result in unique slugs,
|
||||||
|
and that the number of unique slugs matches the number of inputs.
|
||||||
|
"""
|
||||||
|
inputs = ["age #", "age %", "age $", "age @", "age!", "age?", "age 😊"]
|
||||||
|
|
||||||
|
generated_slugs: dict[str, str] = {}
|
||||||
|
for input_str in inputs:
|
||||||
|
slug = generate_slug(input_str)
|
||||||
|
assert slug not in generated_slugs.values(), (
|
||||||
|
f"Duplicate slug '{slug}' generated for input '{input_str}'"
|
||||||
|
)
|
||||||
|
generated_slugs[input_str] = slug
|
||||||
|
|
||||||
|
assert len(generated_slugs) == len(
|
||||||
|
inputs,
|
||||||
|
), "Number of unique slugs doesn't match number of inputs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input_str",
|
||||||
|
[
|
||||||
|
"01 age",
|
||||||
|
"? age",
|
||||||
|
"age 😊",
|
||||||
|
"class",
|
||||||
|
"def function",
|
||||||
|
"2nd place",
|
||||||
|
"@username",
|
||||||
|
"user-name",
|
||||||
|
"first.last",
|
||||||
|
"snake_case",
|
||||||
|
"CamelCase",
|
||||||
|
" ", # Empty
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_generate_slug_valid_identifier(input_str: str) -> None:
|
||||||
|
"""Test that generate_slug() produces valid Python identifiers.
|
||||||
|
|
||||||
|
This test ensures that the generated slugs are valid Python identifiers
|
||||||
|
for a variety of input strings, including those with numbers, special
|
||||||
|
characters, emojis, and different naming conventions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_str (str): The input string to test.
|
||||||
|
"""
|
||||||
|
slug = generate_slug(input_str)
|
||||||
|
assert slug.isidentifier(), (
|
||||||
|
f"Generated slug '{slug}' for input '{input_str}' "
|
||||||
|
+ "is not a valid Python identifier"
|
||||||
|
)
|
||||||
70
tests/test_misc_models.py
Normal file
70
tests/test_misc_models.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import pytest
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
import eav
|
||||||
|
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
||||||
|
from test_project.models import Patient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def enumgroup(db):
|
||||||
|
"""Sample `EnumGroup` object for testing."""
|
||||||
|
test_group = EnumGroup.objects.create(name="Yes / No")
|
||||||
|
value_yes = EnumValue.objects.create(value="Yes")
|
||||||
|
value_no = EnumValue.objects.create(value="No")
|
||||||
|
test_group.values.add(value_yes)
|
||||||
|
test_group.values.add(value_no)
|
||||||
|
return test_group
|
||||||
|
|
||||||
|
|
||||||
|
def test_enumgroup_display(enumgroup):
|
||||||
|
"""Test repr() and str() of EnumGroup."""
|
||||||
|
assert f"<EnumGroup {enumgroup.name}>" == repr(enumgroup)
|
||||||
|
assert str(enumgroup) == str(enumgroup.name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_enumvalue_display(enumgroup):
|
||||||
|
"""Test repr() and str() of EnumValue."""
|
||||||
|
test_value = enumgroup.values.first()
|
||||||
|
assert f"<EnumValue {test_value.value}>" == repr(test_value)
|
||||||
|
assert str(test_value) == test_value.value
|
||||||
|
|
||||||
|
|
||||||
|
class MiscModels(TestCase):
|
||||||
|
"""Miscellaneous tests on models."""
|
||||||
|
|
||||||
|
def test_attribute_help_text(self):
|
||||||
|
desc = "Patient Age"
|
||||||
|
a = Attribute.objects.create(
|
||||||
|
name="age",
|
||||||
|
description=desc,
|
||||||
|
datatype=Attribute.TYPE_INT,
|
||||||
|
)
|
||||||
|
self.assertEqual(a.help_text, desc)
|
||||||
|
|
||||||
|
def test_setting_to_none_deletes_value(self):
|
||||||
|
eav.register(Patient)
|
||||||
|
Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT)
|
||||||
|
p = Patient.objects.create(name="Bob", eav__age=5)
|
||||||
|
self.assertEqual(Value.objects.count(), 1)
|
||||||
|
p.eav.age = None
|
||||||
|
p.save()
|
||||||
|
self.assertEqual(Value.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_string_enum_value_assignment(self):
|
||||||
|
yes = EnumValue.objects.create(value="yes")
|
||||||
|
no = EnumValue.objects.create(value="no")
|
||||||
|
ynu = EnumGroup.objects.create(name="Yes / No / Unknown")
|
||||||
|
ynu.values.add(yes)
|
||||||
|
ynu.values.add(no)
|
||||||
|
Attribute.objects.create(
|
||||||
|
name="is_patient",
|
||||||
|
datatype=Attribute.TYPE_ENUM,
|
||||||
|
enum_group=ynu,
|
||||||
|
)
|
||||||
|
eav.register(Patient)
|
||||||
|
p = Patient.objects.create(name="Joe")
|
||||||
|
p.eav.is_patient = "yes"
|
||||||
|
p.save()
|
||||||
|
p = Patient.objects.get(name="Joe") # get from DB again
|
||||||
|
self.assertEqual(p.eav.is_patient, yes)
|
||||||
52
tests/test_natural_keys.py
Normal file
52
tests/test_natural_keys.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
import eav
|
||||||
|
from eav.models import Attribute, EnumGroup, EnumValue, Value
|
||||||
|
from test_project.models import Patient
|
||||||
|
|
||||||
|
|
||||||
|
class ModelTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
eav.register(Patient)
|
||||||
|
Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT)
|
||||||
|
Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT)
|
||||||
|
Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT)
|
||||||
|
Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT)
|
||||||
|
|
||||||
|
EnumGroup.objects.create(name="Yes / No")
|
||||||
|
EnumValue.objects.create(value="yes")
|
||||||
|
EnumValue.objects.create(value="no")
|
||||||
|
EnumValue.objects.create(value="unknown")
|
||||||
|
|
||||||
|
def test_attr_natural_keys(self):
|
||||||
|
attr = Attribute.objects.get(name="age")
|
||||||
|
attr_natural_key = attr.natural_key()
|
||||||
|
attr_retrieved_model = Attribute.objects.get_by_natural_key(*attr_natural_key)
|
||||||
|
self.assertEqual(attr_retrieved_model, attr)
|
||||||
|
|
||||||
|
def test_value_natural_keys(self):
|
||||||
|
p = Patient.objects.create(name="Jon")
|
||||||
|
p.eav.age = 5
|
||||||
|
p.save()
|
||||||
|
|
||||||
|
val = p.eav_values.first()
|
||||||
|
|
||||||
|
value_natural_key = val.natural_key()
|
||||||
|
value_retrieved_model = Value.objects.get_by_natural_key(*value_natural_key)
|
||||||
|
self.assertEqual(value_retrieved_model, val)
|
||||||
|
|
||||||
|
def test_enum_group_natural_keys(self):
|
||||||
|
enum_group = EnumGroup.objects.first()
|
||||||
|
enum_group_natural_key = enum_group.natural_key()
|
||||||
|
enum_group_retrieved_model = EnumGroup.objects.get_by_natural_key(
|
||||||
|
*enum_group_natural_key,
|
||||||
|
)
|
||||||
|
self.assertEqual(enum_group_retrieved_model, enum_group)
|
||||||
|
|
||||||
|
def test_enum_value_natural_keys(self):
|
||||||
|
enum_value = EnumValue.objects.first()
|
||||||
|
enum_value_natural_key = enum_value.natural_key()
|
||||||
|
enum_value_retrieved_model = EnumValue.objects.get_by_natural_key(
|
||||||
|
*enum_value_natural_key,
|
||||||
|
)
|
||||||
|
self.assertEqual(enum_value_retrieved_model, enum_value)
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue