Compare commits
954 Commits
redesign-t
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
3ce30cf5f7 | 3 days ago |
|
|
cf28db9fd0 | 3 days ago |
|
|
08fd01e119 | 5 days ago |
|
|
f97dbd79cc | 6 days ago |
|
|
240bb3fc25 | 6 days ago |
|
|
5102e4c295 | 6 days ago |
|
|
6be947706e | 1 week ago |
|
|
eb25d0e609 | 1 week ago |
|
|
efdb414345 | 2 weeks ago |
|
|
85c56981a6 | 2 weeks ago |
|
|
f01a681e93 | 2 weeks ago |
|
|
3522ee87f5 | 2 weeks ago |
|
|
e215ca7e1d | 2 weeks ago |
|
|
174c2988b2 | 2 weeks ago |
|
|
ec079e1a7a | 2 weeks ago |
|
|
a31796aad0 | 3 weeks ago |
|
|
7c31c511dd | 3 weeks ago |
|
|
441815d9a8 | 3 weeks ago |
|
|
521acaf747 | 3 weeks ago |
|
|
93a27f9583 | 3 weeks ago |
|
|
d9130b0fdf | 3 weeks ago |
|
|
1218c74d26 | 3 weeks ago |
|
|
49d497d48f | 3 weeks ago |
|
|
0d330f3dcf | 3 weeks ago |
|
|
34924db360 | 3 weeks ago |
|
|
11d6913807 | 3 weeks ago |
|
|
59a39ffd49 | 3 weeks ago |
|
|
7bf560a6a2 | 3 weeks ago |
|
|
77b999fbb3 | 3 weeks ago |
|
|
8de8a9ac49 | 3 weeks ago |
|
|
80b6bc1136 | 3 weeks ago |
|
|
1339b65731 | 4 weeks ago |
|
|
0158ce150d | 4 weeks ago |
|
|
fcb2ef9549 | 4 weeks ago |
|
|
005d8877e7 | 4 weeks ago |
|
|
093015dac6 | 4 weeks ago |
|
|
f152a441d4 | 4 weeks ago |
|
|
ef0e7b6326 | 4 weeks ago |
|
|
1572bed50d | 4 weeks ago |
|
|
4fb9460572 | 4 weeks ago |
|
|
015934c663 | 4 weeks ago |
|
|
16bc3e4428 | 4 weeks ago |
|
|
3db14c6180 | 4 weeks ago |
|
|
6918677009 | 4 weeks ago |
|
|
3b56d59321 | 4 weeks ago |
|
|
7c1c37746c | 4 weeks ago |
|
|
908c0b7dc8 | 1 month ago |
|
|
00228e4c8a | 1 month ago |
|
|
808d65a5a3 | 1 month ago |
|
|
0f7516a617 | 1 month ago |
|
|
1bf31744b5 | 1 month ago |
|
|
4df1ccba28 | 1 month ago |
|
|
108fd9cafa | 1 month ago |
|
|
76b0b02933 | 1 month ago |
|
|
6a8b5c4d97 | 1 month ago |
|
|
69ad1bef02 | 1 month ago |
|
|
632651a5ef | 1 month ago |
|
|
b6de2f5653 | 1 month ago |
|
|
8583523a0e | 1 month ago |
|
|
c6d5af345f | 1 month ago |
|
|
5c36eb8781 | 1 month ago |
|
|
35884b728f | 1 month ago |
|
|
511066ccc8 | 1 month ago |
|
|
c8c54b5ac8 | 1 month ago |
|
|
12ddbb2c29 | 1 month ago |
|
|
2d0dfd1b8f | 1 month ago |
|
|
ff7718d044 | 1 month ago |
|
|
169bc465c5 | 1 month ago |
|
|
7120bddd26 | 1 month ago |
|
|
58557c01aa | 1 month ago |
|
|
c37a6a8c12 | 1 month ago |
|
|
85cdf26fcf | 1 month ago |
|
|
ae4660ffb8 | 1 month ago |
|
|
358f025cc5 | 1 month ago |
|
|
95496508ac | 1 month ago |
|
|
a221bb0090 | 1 month ago |
|
|
a7dd1a4122 | 1 month ago |
|
|
23e1651dad | 1 month ago |
|
|
ff8788c527 | 1 month ago |
|
|
06e8375e15 | 1 month ago |
|
|
9e9d476922 | 1 month ago |
|
|
d9c7a1ae4a | 1 month ago |
|
|
73c18bfbf8 | 1 month ago |
|
|
5d60993748 | 1 month ago |
|
|
1404adc802 | 1 month ago |
|
|
f4d8b1a536 | 1 month ago |
|
|
22b06b4494 | 1 month ago |
|
|
c004325ac8 | 1 month ago |
|
|
a5c9765366 | 1 month ago |
|
|
4fbfce8393 | 1 month ago |
|
|
371bce35d7 | 1 month ago |
|
|
0074548dd4 | 1 month ago |
|
|
708e086ded | 1 month ago |
|
|
60d36278c6 | 1 month ago |
|
|
aa6cb5c84e | 1 month ago |
|
|
4e96ba5a13 | 1 month ago |
|
|
e5ac3750d1 | 1 month ago |
|
|
de5cd64679 | 1 month ago |
|
|
4826b4b8b7 | 1 month ago |
|
|
fe3e224d15 | 1 month ago |
|
|
8f7b21d0de | 1 month ago |
|
|
769f969ba2 | 1 month ago |
|
|
5e45b6d96a | 1 month ago |
|
|
1a9eb14dd9 | 1 month ago |
|
|
a8276d5f4b | 1 month ago |
|
|
c8ea7699c5 | 1 month ago |
|
|
e6756f40dd | 2 months ago |
|
|
9c2fbed0d5 | 2 months ago |
|
|
2c47025a77 | 2 months ago |
|
|
5754a655bc | 2 months ago |
|
|
de4336d13a | 2 months ago |
|
|
a7cbf4c6a6 | 2 months ago |
|
|
34d8fac0d5 | 2 months ago |
|
|
7d997fdb7d | 2 months ago |
|
|
40705b061c | 2 months ago |
|
|
1269b97765 | 2 months ago |
|
|
cf831be3c6 | 2 months ago |
|
|
e0047fbdc3 | 2 months ago |
|
|
9d71efb51a | 2 months ago |
|
|
03f860cf48 | 2 months ago |
|
|
409777f6b6 | 2 months ago |
|
|
8132826866 | 2 months ago |
|
|
33f170dd3e | 2 months ago |
|
|
9a3f92306d | 2 months ago |
|
|
6875081097 | 2 months ago |
|
|
ad6139852d | 2 months ago |
|
|
e6a6268143 | 2 months ago |
|
|
7709409a63 | 2 months ago |
|
|
1019c20890 | 2 months ago |
|
|
721650a8b6 | 2 months ago |
|
|
c0d97721dd | 2 months ago |
|
|
8c4799c1e6 | 2 months ago |
|
|
b1690b44c9 | 2 months ago |
|
|
05c70c94e1 | 2 months ago |
|
|
8c8cc21895 | 2 months ago |
|
|
c41eadfe36 | 2 months ago |
|
|
309e3d7ee1 | 2 months ago |
|
|
89e68c3033 | 2 months ago |
|
|
b3a20f69f4 | 2 months ago |
|
|
3af02f98a7 | 2 months ago |
|
|
e1e2fb08ef | 2 months ago |
|
|
146dae4039 | 2 months ago |
|
|
d4de2ae399 | 2 months ago |
|
|
f1c02a7d1b | 2 months ago |
|
|
147c8e9ba3 | 2 months ago |
|
|
09282698cf | 2 months ago |
|
|
26b7aea651 | 2 months ago |
|
|
289e8e8e8c | 2 months ago |
|
|
07d5d20800 | 2 months ago |
|
|
99a722c63c | 2 months ago |
|
|
7fec722362 | 2 months ago |
|
|
46001fc4e7 | 2 months ago |
|
|
17d5a1e7a5 | 2 months ago |
|
|
d5b2591925 | 2 months ago |
|
|
9717e71988 | 2 months ago |
|
|
ac76622995 | 2 months ago |
|
|
754c1d5796 | 2 months ago |
|
|
9666a998c4 | 2 months ago |
|
|
b2bd41b737 | 2 months ago |
|
|
980e5f6420 | 2 months ago |
|
|
75a00c0fa9 | 2 months ago |
|
|
319efc28f5 | 2 months ago |
|
|
334bcad30f | 2 months ago |
|
|
30b17810e9 | 2 months ago |
|
|
1482f7f670 | 2 months ago |
|
|
5320d0a5be | 2 months ago |
|
|
10168de3cd | 2 months ago |
|
|
c4be3c9ce2 | 2 months ago |
|
|
42bdb3bfed | 2 months ago |
|
|
bc792ed470 | 2 months ago |
|
|
6759ce7af8 | 2 months ago |
|
|
08ada3d771 | 2 months ago |
|
|
82900cfe5e | 2 months ago |
|
|
eee03f7708 | 2 months ago |
|
|
b71ac1c645 | 2 months ago |
|
|
08f78e7de4 | 2 months ago |
|
|
8faf79fd94 | 2 months ago |
|
|
0af9609075 | 2 months ago |
|
|
d934230880 | 2 months ago |
|
|
d0fb9fcac6 | 2 months ago |
|
|
aa39b9f08b | 2 months ago |
|
|
caad565056 | 2 months ago |
|
|
095a446675 | 2 months ago |
|
|
213527592e | 2 months ago |
|
|
bedd752824 | 2 months ago |
|
|
47c50780a4 | 2 months ago |
|
|
2463c8f8c8 | 2 months ago |
|
|
108d2d6451 | 2 months ago |
|
|
183d0ee6ec | 2 months ago |
|
|
61a6f88e6f | 2 months ago |
|
|
c0d18ed9a1 | 2 months ago |
|
|
aa7c9bc5aa | 2 months ago |
|
|
f5a4a18ee5 | 2 months ago |
|
|
586de4431f | 2 months ago |
|
|
731c3fc1fb | 2 months ago |
|
|
ae57ec7bcd | 2 months ago |
|
|
39beecd9cd | 2 months ago |
|
|
d92849edfc | 2 months ago |
|
|
b0b1abd972 | 2 months ago |
|
|
2c34296a5e | 2 months ago |
|
|
26a2465dfb | 2 months ago |
|
|
73571702db | 2 months ago |
|
|
34530f94c5 | 2 months ago |
|
|
1e5bd9a072 | 2 months ago |
|
|
17d6313042 | 2 months ago |
|
|
debf60bc9b | 2 months ago |
|
|
bcf5017169 | 2 months ago |
|
|
31b87cea2f | 2 months ago |
|
|
84a7047053 | 2 months ago |
|
|
758bd60c93 | 2 months ago |
|
|
e58ec43999 | 2 months ago |
|
|
8af498186c | 2 months ago |
|
|
28a0219507 | 2 months ago |
|
|
3146b16962 | 2 months ago |
|
|
ae4c330b1a | 2 months ago |
|
|
664b461e6d | 2 months ago |
|
|
0a017d7957 | 2 months ago |
|
|
9b763e0ed5 | 2 months ago |
|
|
ecda64402f | 3 months ago |
|
|
0da7941390 | 3 months ago |
|
|
a6ca96d2e9 | 3 months ago |
|
|
0fae81c45f | 3 months ago |
|
|
79b857f7c9 | 3 months ago |
|
|
1ba15438ca | 3 months ago |
|
|
f3d41d3b5f | 3 months ago |
|
|
2da21d67b2 | 3 months ago |
|
|
1409fe309b | 3 months ago |
|
|
17f455137c | 3 months ago |
|
|
6401914b74 | 3 months ago |
|
|
f613b44152 | 3 months ago |
|
|
ed7031ef24 | 3 months ago |
|
|
e4d008956a | 3 months ago |
|
|
35acba7332 | 3 months ago |
|
|
e8ee0fdb42 | 3 months ago |
|
|
93a60ab675 | 3 months ago |
|
|
86a677d8d8 | 3 months ago |
|
|
24b93f98f3 | 3 months ago |
|
|
f1c6df49da | 3 months ago |
|
|
1559f954c6 | 3 months ago |
|
|
1174196713 | 3 months ago |
|
|
80f8a28eae | 3 months ago |
|
|
058a1fd1b3 | 3 months ago |
|
|
99f6e54be2 | 3 months ago |
|
|
7dfdd66712 | 3 months ago |
|
|
d46da8509f | 3 months ago |
|
|
8de30d96ff | 3 months ago |
|
|
061bfe4795 | 3 months ago |
|
|
ffbf8cf288 | 3 months ago |
|
|
455928f600 | 3 months ago |
|
|
4636b12deb | 3 months ago |
|
|
83c7e7a97c | 3 months ago |
|
|
3c41da77bc | 3 months ago |
|
|
66d935e83e | 3 months ago |
|
|
59f5f093d1 | 3 months ago |
|
|
bb86086235 | 3 months ago |
|
|
67879ed6ed | 3 months ago |
|
|
966beb6599 | 3 months ago |
|
|
7fa16f23c4 | 3 months ago |
|
|
cf0b33aa2a | 3 months ago |
|
|
a1becd8455 | 3 months ago |
|
|
c6e431a0d3 | 3 months ago |
|
|
06e9daba59 | 3 months ago |
|
|
c5e910a208 | 3 months ago |
|
|
1e958cca57 | 3 months ago |
|
|
0a6b4614fe | 3 months ago |
|
|
b00e01a674 | 4 months ago |
|
|
77e635dda5 | 4 months ago |
|
|
cfed9030c0 | 4 months ago |
|
|
38d7c0f293 | 4 months ago |
|
|
3f2d8bab9a | 4 months ago |
|
|
6ce616cdf3 | 4 months ago |
|
|
e415e1d574 | 4 months ago |
|
|
0719c9bcd4 | 4 months ago |
|
|
c1a62cf4e6 | 4 months ago |
|
|
0ecf9c1fff | 4 months ago |
|
|
becf62d34c | 4 months ago |
|
|
e1ffd348d4 | 4 months ago |
|
|
1c8ff0c71a | 4 months ago |
|
|
9c84751738 | 4 months ago |
|
|
f3ba9d4fc9 | 4 months ago |
|
|
0ebce12199 | 4 months ago |
|
|
36e791efe9 | 4 months ago |
|
|
c703fe3d91 | 4 months ago |
|
|
95b6390f79 | 4 months ago |
|
|
555fbd59f9 | 4 months ago |
|
|
4ab605bbc9 | 4 months ago |
|
|
c9ad392ce1 | 4 months ago |
|
|
d66d14e178 | 4 months ago |
|
|
96dfc77856 | 4 months ago |
|
|
2a7736c044 | 4 months ago |
|
|
2670369a2b | 4 months ago |
|
|
206ff43ae3 | 4 months ago |
|
|
fd289a4887 | 4 months ago |
|
|
823741e458 | 4 months ago |
|
|
8b384ab607 | 4 months ago |
|
|
a0812b9a09 | 4 months ago |
|
|
6e25edb545 | 4 months ago |
|
|
5dbc70976c | 4 months ago |
|
|
ed0989d01f | 4 months ago |
|
|
d03cb78175 | 4 months ago |
|
|
ca35dd1ac3 | 4 months ago |
|
|
b1702f6557 | 4 months ago |
|
|
5611450360 | 4 months ago |
|
|
35079e1cb1 | 4 months ago |
|
|
d3dd2d8ac4 | 4 months ago |
|
|
16a233f977 | 4 months ago |
|
|
78457e1428 | 4 months ago |
|
|
86bf0bb356 | 4 months ago |
|
|
3227ebc5b7 | 4 months ago |
|
|
891a06df28 | 4 months ago |
|
|
c167f21a96 | 4 months ago |
|
|
e8768c4980 | 4 months ago |
|
|
3891a34242 | 4 months ago |
|
|
8092f69713 | 4 months ago |
|
|
9db872e35c | 4 months ago |
|
|
4c1ebaf780 | 4 months ago |
|
|
edead66c2e | 4 months ago |
|
|
e1671892a0 | 4 months ago |
|
|
285292ac55 | 4 months ago |
|
|
ff0fb01246 | 4 months ago |
|
|
f988fc1c06 | 4 months ago |
|
|
576bc2f273 | 4 months ago |
|
|
590a652e83 | 4 months ago |
|
|
a02cf1ee9a | 4 months ago |
|
|
8002a7a456 | 4 months ago |
|
|
3d92efb936 | 4 months ago |
|
|
0a5e360daf | 4 months ago |
|
|
fb1707c585 | 4 months ago |
|
|
9032f94cdd | 4 months ago |
|
|
b3a59f5aa2 | 4 months ago |
|
|
9c80016575 | 4 months ago |
|
|
555ac0dc40 | 4 months ago |
|
|
66293bfa05 | 4 months ago |
|
|
71e3cdc788 | 4 months ago |
|
|
b7e22ddfae | 4 months ago |
|
|
a655471384 | 4 months ago |
|
|
fe62c93671 | 4 months ago |
|
|
09620693e8 | 4 months ago |
|
|
ff1fbde52b | 4 months ago |
|
|
81ea23395b | 4 months ago |
|
|
6f8176c6bf | 4 months ago |
|
|
fa2f7bf45f | 4 months ago |
|
|
d6924f61c4 | 4 months ago |
|
|
6d27c10add | 4 months ago |
|
|
92dfca9547 | 4 months ago |
|
|
25c3b3b71b | 4 months ago |
|
|
e47ef1427d | 4 months ago |
|
|
d6a754b053 | 4 months ago |
|
|
2ef88b0803 | 5 months ago |
|
|
e1e1dca3d6 | 5 months ago |
|
|
cfbda0f0e6 | 5 months ago |
|
|
a32b2c2abc | 5 months ago |
|
|
9c121cb106 | 5 months ago |
|
|
f65fac9661 | 5 months ago |
|
|
17f59c1fcb | 5 months ago |
|
|
dcc945ee6a | 5 months ago |
|
|
c8dd481ebd | 5 months ago |
|
|
136a0697c4 | 5 months ago |
|
|
dd62e2f11e | 5 months ago |
|
|
b503f7cb33 | 5 months ago |
|
|
e8d92d1216 | 5 months ago |
|
|
a9a5fc87fb | 5 months ago |
|
|
1f2514b903 | 5 months ago |
|
|
b5b5d4e0f5 | 5 months ago |
|
|
6483a0add2 | 5 months ago |
|
|
41cda04ba9 | 5 months ago |
|
|
cfc10d0e24 | 5 months ago |
|
|
882302ab4e | 5 months ago |
|
|
42c29cf3f6 | 5 months ago |
|
|
2bff89b3c2 | 5 months ago |
|
|
b00d5885cc | 5 months ago |
|
|
b7429d9809 | 5 months ago |
|
|
90e7f4216e | 5 months ago |
|
|
75978ae281 | 5 months ago |
|
|
55f333013b | 5 months ago |
|
|
e906d37b23 | 5 months ago |
|
|
fc21dc2b93 | 5 months ago |
|
|
9b739c85a2 | 5 months ago |
|
|
d97888c887 | 5 months ago |
|
|
bf8f103bda | 5 months ago |
|
|
f82692f13e | 5 months ago |
|
|
9a93e2d6ad | 5 months ago |
|
|
8d1b3dbdc9 | 5 months ago |
|
|
efbe72d675 | 5 months ago |
|
|
2f34664f2d | 5 months ago |
|
|
156a4ff0ef | 5 months ago |
|
|
83ec420c60 | 5 months ago |
|
|
484c3560bc | 5 months ago |
|
|
de1bcb1c71 | 5 months ago |
|
|
b64a0fb6b6 | 5 months ago |
|
|
e3a7096216 | 5 months ago |
|
|
e7979427c3 | 5 months ago |
|
|
48df72d2c3 | 5 months ago |
|
|
e230a00d46 | 5 months ago |
|
|
72b0281a07 | 5 months ago |
|
|
8d814b49c4 | 5 months ago |
|
|
1385094474 | 5 months ago |
|
|
ace8801ecc | 5 months ago |
|
|
ae1a24a083 | 5 months ago |
|
|
2a61240e0e | 5 months ago |
|
|
12aa84ebdb | 5 months ago |
|
|
aaf4bad035 | 5 months ago |
|
|
e43e69fa62 | 5 months ago |
|
|
acdc1a270c | 5 months ago |
|
|
621f37791c | 5 months ago |
|
|
621639f30e | 5 months ago |
|
|
d541205f22 | 5 months ago |
|
|
a641fcced4 | 5 months ago |
|
|
38843a996a | 5 months ago |
|
|
28f89d3ca8 | 5 months ago |
|
|
70f4f343aa | 5 months ago |
|
|
99be99019b | 5 months ago |
|
|
26ee2e49d0 | 5 months ago |
|
|
be260c0496 | 5 months ago |
|
|
2fa01108d8 | 5 months ago |
|
|
03cab14cf2 | 5 months ago |
|
|
150d44ad0a | 5 months ago |
|
|
6a7f685b14 | 5 months ago |
|
|
d18d5dccd5 | 5 months ago |
|
|
868d764031 | 5 months ago |
|
|
78fdb42bec | 5 months ago |
|
|
0098f3e2d5 | 5 months ago |
|
|
554c3a87de | 5 months ago |
|
|
e81976889f | 5 months ago |
|
|
b346fdcfe0 | 5 months ago |
|
|
f56b6a3a9f | 5 months ago |
|
|
7f1502dbdf | 5 months ago |
|
|
537ef3f259 | 5 months ago |
|
|
ee42ebb550 | 5 months ago |
|
|
1ce168847a | 5 months ago |
|
|
a7c33a9ee0 | 5 months ago |
|
|
aa73c904a4 | 5 months ago |
|
|
3a0074fe91 | 5 months ago |
|
|
af83311c9d | 5 months ago |
|
|
396735e70c | 5 months ago |
|
|
4668dae605 | 5 months ago |
|
|
82829b31d2 | 5 months ago |
|
|
0f6a2c3888 | 5 months ago |
|
|
01d574f012 | 5 months ago |
|
|
f6186beefc | 5 months ago |
|
|
ba671c41f7 | 5 months ago |
|
|
0e7d746352 | 5 months ago |
|
|
09f4944f4d | 5 months ago |
|
|
95c0337593 | 5 months ago |
|
|
8f494ba164 | 5 months ago |
|
|
30ec5034cd | 5 months ago |
|
|
a82008db1c | 5 months ago |
|
|
625a882d88 | 6 months ago |
|
|
e4beca4840 | 6 months ago |
|
|
7a0983b0a0 | 6 months ago |
|
|
2655ed740a | 6 months ago |
|
|
1d8fb1b32a | 6 months ago |
|
|
46dec3c729 | 6 months ago |
|
|
30ca0ef20c | 6 months ago |
|
|
0f25b54533 | 6 months ago |
|
|
d15e43d84b | 6 months ago |
|
|
b0bf249e3d | 6 months ago |
|
|
c31b54c0cc | 6 months ago |
|
|
d242641a0d | 6 months ago |
|
|
26707c47dc | 6 months ago |
|
|
5471acab44 | 6 months ago |
|
|
93c5883774 | 6 months ago |
|
|
2b4013356b | 6 months ago |
|
|
603a0e67df | 6 months ago |
|
|
1117e77bee | 6 months ago |
|
|
c7a7375dff | 6 months ago |
|
|
21d0c85d27 | 6 months ago |
|
|
290092d879 | 6 months ago |
|
|
94580cdf73 | 6 months ago |
|
|
3ce4c16ec3 | 6 months ago |
|
|
9be9c2d037 | 6 months ago |
|
|
406786478b | 6 months ago |
|
|
df245c55d4 | 6 months ago |
|
|
4774a2d814 | 6 months ago |
|
|
a2a4916c2f | 6 months ago |
|
|
e4f1590e27 | 6 months ago |
|
|
00744c7c71 | 6 months ago |
|
|
4f4b293d52 | 6 months ago |
|
|
cb62518031 | 6 months ago |
|
|
1af925c8e7 | 6 months ago |
|
|
7b7c5fdb71 | 6 months ago |
|
|
d468fef6af | 6 months ago |
|
|
a35fa885b8 | 6 months ago |
|
|
aaebde94b1 | 6 months ago |
|
|
44776a0e1b | 6 months ago |
|
|
d971d795e4 | 6 months ago |
|
|
23aaabab39 | 6 months ago |
|
|
1361f16785 | 6 months ago |
|
|
523f76b344 | 6 months ago |
|
|
ce0a989105 | 6 months ago |
|
|
2fbea4eba5 | 6 months ago |
|
|
0650ebd77f | 6 months ago |
|
|
0da223f5e6 | 6 months ago |
|
|
d93a850822 | 6 months ago |
|
|
158a54d770 | 6 months ago |
|
|
24e1fcab1b | 6 months ago |
|
|
17dc279ccd | 6 months ago |
|
|
3069f9e637 | 6 months ago |
|
|
1db45b7d2c | 6 months ago |
|
|
f6f8244212 | 6 months ago |
|
|
3dfd959eb9 | 6 months ago |
|
|
ee107ce340 | 6 months ago |
|
|
d65c047628 | 6 months ago |
|
|
7742fde718 | 6 months ago |
|
|
6d92dbd491 | 6 months ago |
|
|
f8135433dc | 6 months ago |
|
|
9ac210a47b | 6 months ago |
|
|
8acf2cf427 | 6 months ago |
|
|
93d5175ab0 | 6 months ago |
|
|
514cf96b09 | 6 months ago |
|
|
a767254437 | 6 months ago |
|
|
ed8cea7ff8 | 6 months ago |
|
|
268258425a | 6 months ago |
|
|
5d71da4875 | 6 months ago |
|
|
7640b9bb23 | 6 months ago |
|
|
536c28d10d | 6 months ago |
|
|
23257f2e48 | 6 months ago |
|
|
2e417456c7 | 6 months ago |
|
|
195051086a | 6 months ago |
|
|
7ae1692155 | 6 months ago |
|
|
f8be173ad8 | 6 months ago |
|
|
c6f5571d43 | 6 months ago |
|
|
47f867c2f7 | 6 months ago |
|
|
9be436fccf | 6 months ago |
|
|
d474de1edf | 6 months ago |
|
|
96fe35a742 | 6 months ago |
|
|
ba4f6652ed | 6 months ago |
|
|
92e50b55dd | 6 months ago |
|
|
75c66c98d2 | 6 months ago |
|
|
840c42209c | 6 months ago |
|
|
41e9179693 | 6 months ago |
|
|
3b48d22473 | 6 months ago |
|
|
7d6d71a44d | 6 months ago |
|
|
c74a117c34 | 6 months ago |
|
|
620c20f9e7 | 6 months ago |
|
|
7c68762178 | 6 months ago |
|
|
3b3cf56896 | 6 months ago |
|
|
97a7543f9e | 6 months ago |
|
|
b7a55e46f7 | 6 months ago |
|
|
900bf9865a | 6 months ago |
|
|
762b79200a | 6 months ago |
|
|
eebf18d1a1 | 6 months ago |
|
|
6247fee705 | 6 months ago |
|
|
8dd3438ace | 6 months ago |
|
|
6800c1643d | 6 months ago |
|
|
cd71834fdf | 6 months ago |
|
|
e3e6603d65 | 6 months ago |
|
|
ffdb5ce74c | 6 months ago |
|
|
525681d7ae | 6 months ago |
|
|
3cd541977d | 6 months ago |
|
|
fed287ce43 | 6 months ago |
|
|
52a0b5ec41 | 6 months ago |
|
|
3e2cc6afa5 | 6 months ago |
|
|
5ef46e5533 | 6 months ago |
|
|
32f9b7e28b | 6 months ago |
|
|
0d014760db | 6 months ago |
|
|
6e3571f27e | 6 months ago |
|
|
f63e0e9456 | 6 months ago |
|
|
2888c3bf54 | 6 months ago |
|
|
94c3429bd8 | 6 months ago |
|
|
054aa07b50 | 7 months ago |
|
|
f19ccc6a5b | 7 months ago |
|
|
b746a0da0c | 7 months ago |
|
|
d187655e03 | 7 months ago |
|
|
260692da75 | 7 months ago |
|
|
0470d80379 | 7 months ago |
|
|
348c069817 | 7 months ago |
|
|
aea047293e | 7 months ago |
|
|
57857ac552 | 7 months ago |
|
|
47d68a3e73 | 7 months ago |
|
|
71b71938c4 | 7 months ago |
|
|
c95c9f403b | 7 months ago |
|
|
e708fb8b70 | 7 months ago |
|
|
80503c3bd7 | 7 months ago |
|
|
3f95477066 | 7 months ago |
|
|
a19afb2299 | 7 months ago |
|
|
174e96402e | 7 months ago |
|
|
d9982e2bfd | 7 months ago |
|
|
872cf984f3 | 7 months ago |
|
|
f9318d64d7 | 7 months ago |
|
|
36cc1659a6 | 7 months ago |
|
|
72970a06ef | 7 months ago |
|
|
23964328fa | 7 months ago |
|
|
e6f1ab3944 | 7 months ago |
|
|
db4fcee479 | 7 months ago |
|
|
8667ea97ac | 7 months ago |
|
|
1b39a3f340 | 7 months ago |
|
|
7ea7f4ef6c | 7 months ago |
|
|
1e9ab3a8e7 | 7 months ago |
|
|
9454d932d5 | 7 months ago |
|
|
dd98096ef9 | 7 months ago |
|
|
142f0929c2 | 7 months ago |
|
|
e011d7bd5a | 7 months ago |
|
|
01bd0a03e0 | 7 months ago |
|
|
24b4ed949e | 7 months ago |
|
|
030bc03154 | 7 months ago |
|
|
b9eb6382ca | 7 months ago |
|
|
03e2b874e8 | 7 months ago |
|
|
7efafb738e | 7 months ago |
|
|
c790eb7eb1 | 7 months ago |
|
|
ca19750df3 | 7 months ago |
|
|
7264f139ee | 7 months ago |
|
|
255758f02d | 7 months ago |
|
|
bb4dd716d1 | 7 months ago |
|
|
5fbfb3922b | 7 months ago |
|
|
d19fdc3bd0 | 7 months ago |
|
|
29bda0d620 | 7 months ago |
|
|
e282b6b807 | 7 months ago |
|
|
f76a74f8b7 | 7 months ago |
|
|
147a69a7df | 7 months ago |
|
|
715972b115 | 7 months ago |
|
|
05296bbb64 | 7 months ago |
|
|
2c38ef7809 | 7 months ago |
|
|
3e0b51c4c9 | 7 months ago |
|
|
01a2c8880b | 7 months ago |
|
|
840d760ba4 | 7 months ago |
|
|
1a5e31cb8e | 7 months ago |
|
|
fe33401d02 | 7 months ago |
|
|
4d671af2bf | 7 months ago |
|
|
9f20bae167 | 7 months ago |
|
|
1c5d7ff6d8 | 7 months ago |
|
|
82ec8f8471 | 7 months ago |
|
|
1c5cc25e49 | 7 months ago |
|
|
840d11ea7e | 7 months ago |
|
|
147a736c35 | 7 months ago |
|
|
99e91ee843 | 7 months ago |
|
|
ef46c71625 | 7 months ago |
|
|
444020fbcf | 7 months ago |
|
|
2908519a84 | 7 months ago |
|
|
e019cea984 | 7 months ago |
|
|
185aa29f3d | 7 months ago |
|
|
1c0e4e0472 | 7 months ago |
|
|
aacb64f0f0 | 7 months ago |
|
|
fa3e0c76c3 | 7 months ago |
|
|
4b912f5451 | 7 months ago |
|
|
4af00495b1 | 7 months ago |
|
|
70d286cb52 | 7 months ago |
|
|
8adceb2628 | 7 months ago |
|
|
f2fcd83cc5 | 7 months ago |
|
|
28e39be609 | 7 months ago |
|
|
42090b5ab7 | 7 months ago |
|
|
a0427b81c2 | 7 months ago |
|
|
65a45d209d | 7 months ago |
|
|
a321c4c154 | 7 months ago |
|
|
98a8bf3d12 | 7 months ago |
|
|
d25893698d | 7 months ago |
|
|
33c084ba0e | 7 months ago |
|
|
63a9cbd33d | 7 months ago |
|
|
f01f87e1eb | 7 months ago |
|
|
b9c384e769 | 7 months ago |
|
|
58383a19df | 7 months ago |
|
|
9af4da81c8 | 7 months ago |
|
|
74255f851b | 7 months ago |
|
|
1f64fd0a7d | 7 months ago |
|
|
64ae364a3e | 7 months ago |
|
|
1b3b0afccb | 7 months ago |
|
|
aa630d1348 | 7 months ago |
|
|
eb05fcb9b1 | 7 months ago |
|
|
78e324158b | 7 months ago |
|
|
cd661743b8 | 7 months ago |
|
|
5337bd9af9 | 7 months ago |
|
|
1c68e98089 | 7 months ago |
|
|
c2c5f20045 | 7 months ago |
|
|
ae115bc4cb | 7 months ago |
|
|
ea1504c83e | 7 months ago |
|
|
68a926b605 | 7 months ago |
|
|
e397a37b05 | 7 months ago |
|
|
ba16ab06c9 | 7 months ago |
|
|
3f03ede642 | 7 months ago |
|
|
3bd4007acc | 7 months ago |
|
|
60df932d3a | 7 months ago |
|
|
c6b466144f | 7 months ago |
|
|
d400a07f88 | 7 months ago |
|
|
bc7b15c070 | 7 months ago |
|
|
65f4b68078 | 7 months ago |
|
|
99231d2414 | 7 months ago |
|
|
f047473d33 | 7 months ago |
|
|
d18f78ef45 | 7 months ago |
|
|
ee19f9c410 | 7 months ago |
|
|
629d82cf0f | 7 months ago |
|
|
73cc0eba90 | 7 months ago |
|
|
5d84a6bd63 | 7 months ago |
|
|
f7aa474f0f | 7 months ago |
|
|
f1a223a094 | 7 months ago |
|
|
f3894c2db5 | 7 months ago |
|
|
c93a7ebf8b | 7 months ago |
|
|
cbdb2eba15 | 7 months ago |
|
|
9ca5a4028b | 7 months ago |
|
|
23148393e0 | 7 months ago |
|
|
f1cfabcbe6 | 7 months ago |
|
|
b540486dbc | 7 months ago |
|
|
4b7882cae7 | 7 months ago |
|
|
3d7ab2d371 | 7 months ago |
|
|
cea1276369 | 7 months ago |
|
|
a98ac905fb | 7 months ago |
|
|
6a257062df | 7 months ago |
|
|
42cf4abbd5 | 7 months ago |
|
|
5d5aba938b | 7 months ago |
|
|
285430d690 | 7 months ago |
|
|
b9e0bab3fc | 8 months ago |
|
|
9c456a9357 | 8 months ago |
|
|
d9a696d82e | 8 months ago |
|
|
37748596c3 | 8 months ago |
|
|
e3a9d8ad06 | 8 months ago |
|
|
15f5aae34f | 8 months ago |
|
|
810c6578bc | 8 months ago |
|
|
49123a3b58 | 8 months ago |
|
|
c44a876f8e | 8 months ago |
|
|
dc5a033e63 | 8 months ago |
|
|
48638e76f8 | 8 months ago |
|
|
21b0a85678 | 8 months ago |
|
|
3b8a03f92f | 8 months ago |
|
|
a7d1407a2f | 8 months ago |
|
|
2e443180e9 | 8 months ago |
|
|
d264179306 | 8 months ago |
|
|
b9db62e761 | 8 months ago |
|
|
7eabf9f444 | 8 months ago |
|
|
dbe470c7ff | 8 months ago |
|
|
85d1f54e13 | 8 months ago |
|
|
de79174f4d | 8 months ago |
|
|
1c6c910a8a | 8 months ago |
|
|
7574087bf8 | 8 months ago |
|
|
b8a708ff44 | 8 months ago |
|
|
e9e1f65911 | 8 months ago |
|
|
db27696ba6 | 8 months ago |
|
|
f6279aa7eb | 8 months ago |
|
|
f77aff3237 | 8 months ago |
|
|
d71d694703 | 8 months ago |
|
|
fa27058dbb | 8 months ago |
|
|
6a86f8121a | 8 months ago |
|
|
f55d123cbf | 8 months ago |
|
|
c1154aff9a | 8 months ago |
|
|
f1f92f6930 | 8 months ago |
|
|
8c17bbb5b7 | 8 months ago |
|
|
00d4619428 | 8 months ago |
|
|
f84c8ed930 | 8 months ago |
|
|
6c9bc3c7f0 | 8 months ago |
|
|
59318f1fd1 | 8 months ago |
|
|
f1a5b0c44d | 8 months ago |
|
|
3c8adb5673 | 8 months ago |
|
|
a2242fcda5 | 8 months ago |
|
|
34c28ac7a7 | 8 months ago |
|
|
51735f4457 | 8 months ago |
|
|
800a76ecfd | 8 months ago |
|
|
e75772f361 | 8 months ago |
|
|
fe7713cb89 | 8 months ago |
|
|
861d92505c | 8 months ago |
|
|
6c69dcb392 | 8 months ago |
|
|
a97d7d621b | 8 months ago |
|
|
9c95e0d4c9 | 8 months ago |
|
|
6e1133bf47 | 8 months ago |
|
|
6c0ebc2b50 | 8 months ago |
|
|
11a2f331b6 | 8 months ago |
|
|
04eb83ce2c | 8 months ago |
|
|
21ce64d941 | 8 months ago |
|
|
abc246595d | 8 months ago |
|
|
bd05efd3b5 | 8 months ago |
|
|
e4da854380 | 8 months ago |
|
|
ebcbcffc05 | 8 months ago |
|
|
aa50d56b21 | 8 months ago |
|
|
39dc48b702 | 8 months ago |
|
|
e31e937974 | 8 months ago |
|
|
5451cd6667 | 8 months ago |
|
|
d9d2a6fec8 | 8 months ago |
|
|
5f0d4bbb12 | 8 months ago |
|
|
47db5854d0 | 8 months ago |
|
|
4be049db33 | 8 months ago |
|
|
22b6d71169 | 8 months ago |
|
|
69d838d245 | 8 months ago |
|
|
5b7ee2612a | 8 months ago |
|
|
30c9ee59b6 | 8 months ago |
|
|
6e624a2b88 | 8 months ago |
|
|
6272fcc3e6 | 8 months ago |
|
|
8777706865 | 8 months ago |
|
|
9a3b920058 | 8 months ago |
|
|
4345e5a8fd | 8 months ago |
|
|
57ea6e8e78 | 8 months ago |
|
|
1a181f7d0e | 8 months ago |
|
|
aa2cdbcca9 | 8 months ago |
|
|
9177b59198 | 8 months ago |
|
|
039a3448f6 | 8 months ago |
|
|
34197e7c1a | 8 months ago |
|
|
cc0f9d64e0 | 8 months ago |
|
|
8365e548be | 8 months ago |
|
|
50d8740260 | 8 months ago |
|
|
027ce2d9bc | 8 months ago |
|
|
6b97eaf850 | 8 months ago |
|
|
7f37ba729e | 8 months ago |
|
|
ca9c37f2c8 | 8 months ago |
|
|
b34f1b201c | 8 months ago |
|
|
ff01f5c77a | 8 months ago |
|
|
ccb71a884c | 8 months ago |
|
|
7f31708d86 | 8 months ago |
|
|
8d8fa1a7d3 | 8 months ago |
|
|
b6624ca2ca | 8 months ago |
|
|
9d2e2ec912 | 8 months ago |
|
|
621a00f13d | 8 months ago |
|
|
9d75185c9d | 8 months ago |
|
|
1dd69b9519 | 8 months ago |
|
|
b651ff0bb5 | 8 months ago |
|
|
787f2d25d1 | 8 months ago |
|
|
5c3301a9a5 | 8 months ago |
|
|
67a2a3bdce | 8 months ago |
|
|
14abf81de5 | 8 months ago |
|
|
9cf9e07bd9 | 8 months ago |
|
|
1e109ab884 | 8 months ago |
|
|
a408a56b69 | 8 months ago |
|
|
673f836bd8 | 8 months ago |
|
|
c285bb1a03 | 8 months ago |
|
|
e94d325668 | 8 months ago |
|
|
7e84e3ab9e | 8 months ago |
|
|
5f2b4c19d6 | 8 months ago |
|
|
6d1e3dfc5f | 8 months ago |
|
|
98781235a1 | 8 months ago |
|
|
8122e79255 | 8 months ago |
|
|
24abc795e1 | 8 months ago |
|
|
f251f4e734 | 8 months ago |
|
|
686a2a1c57 | 8 months ago |
|
|
eb87f47784 | 8 months ago |
|
|
0812ab1641 | 8 months ago |
|
|
49449a4b33 | 8 months ago |
|
|
69bf346f2e | 8 months ago |
|
|
b13a8cdbba | 8 months ago |
|
|
e7a334cf2a | 8 months ago |
|
|
0a4efc69d7 | 8 months ago |
|
|
190391a0bd | 8 months ago |
|
|
119b24dcf5 | 8 months ago |
|
|
d26d57e782 | 8 months ago |
|
|
e1eca7f232 | 8 months ago |
|
|
a0998299ee | 8 months ago |
|
|
acbcc13f66 | 8 months ago |
|
|
826af36fae | 8 months ago |
|
|
7bd135a3c8 | 8 months ago |
|
|
0ee1d0dfd8 | 8 months ago |
|
|
1472cb5b7e | 8 months ago |
|
|
7aa0fb7591 | 8 months ago |
|
|
95c83163a2 | 8 months ago |
|
|
60dd2ca335 | 8 months ago |
|
|
067e5351fa | 8 months ago |
|
|
862fca2b2c | 8 months ago |
|
|
6272e11f21 | 8 months ago |
|
|
8cb08cd33b | 8 months ago |
|
|
8074739d22 | 8 months ago |
|
|
daa3418f35 | 8 months ago |
|
|
f394bb5eab | 8 months ago |
|
|
d5a6625b9a | 8 months ago |
|
|
00ea79f88a | 8 months ago |
|
|
194a7c8ba3 | 8 months ago |
|
|
15231d9299 | 8 months ago |
|
|
c69f7385f9 | 8 months ago |
|
|
530a60b0e1 | 8 months ago |
|
|
4cbb9f0ddc | 8 months ago |
|
|
28161b1fbb | 8 months ago |
|
|
149ee005f2 | 8 months ago |
|
|
d9fcdf95cb | 8 months ago |
|
|
ff3f2e8490 | 8 months ago |
|
|
e8344951f8 | 8 months ago |
|
|
9e3152c01a | 8 months ago |
|
|
648e5877a2 | 8 months ago |
|
|
22a5207af6 | 8 months ago |
|
|
971090764e | 8 months ago |
|
|
bc97ea7833 | 8 months ago |
|
|
bfbd7caaa3 | 9 months ago |
|
|
7e0027853a | 9 months ago |
|
|
8ada5b50ce | 9 months ago |
|
|
7a06c70354 | 9 months ago |
|
|
56e3917999 | 9 months ago |
|
|
cba4cb9367 | 9 months ago |
|
|
269ed59e2a | 9 months ago |
|
|
a219cf1828 | 9 months ago |
|
|
83e2cac00c | 9 months ago |
|
|
75994cc86c | 9 months ago |
|
|
864fc8bfcf | 9 months ago |
|
|
3d6aa8282a | 9 months ago |
|
|
d9a5e98ad3 | 9 months ago |
|
|
a68e003695 | 9 months ago |
|
|
527a9121c5 | 9 months ago |
|
|
b983b933e7 | 9 months ago |
|
|
03a2bd9210 | 9 months ago |
|
|
f02ed0c2f0 | 9 months ago |
|
|
71d66e1fe0 | 9 months ago |
|
|
5452a06571 | 9 months ago |
|
|
ce52aa2502 | 9 months ago |
|
|
8b8e0eb9d5 | 9 months ago |
|
|
5922e1b6ec | 9 months ago |
|
|
4ad2c228cd | 9 months ago |
|
|
af025f87fc | 9 months ago |
|
|
ac1028f590 | 9 months ago |
|
|
b3c3899572 | 9 months ago |
|
|
1effe2ccef | 9 months ago |
|
|
a9962cdce6 | 9 months ago |
|
|
317fff6399 | 9 months ago |
|
|
ca6a50229f | 9 months ago |
|
|
7f54e6910b | 9 months ago |
|
|
5aa4a47340 | 9 months ago |
|
|
c8d629924a | 9 months ago |
|
|
b8adfe2809 | 9 months ago |
|
|
4bd82fcf29 | 9 months ago |
|
|
f1eff69341 | 10 months ago |
|
|
852c0ff18c | 10 months ago |
|
|
4767a15f1f | 10 months ago |
|
|
1f4fad4302 | 10 months ago |
|
|
ea77f2db83 | 10 months ago |
|
|
dda0b05251 | 10 months ago |
|
|
ae51d0c5c8 | 10 months ago |
|
|
ab1713b29e | 10 months ago |
|
|
53c10644c1 | 10 months ago |
|
|
26757c9b59 | 10 months ago |
|
|
94efdadfc9 | 10 months ago |
|
|
228b130104 | 10 months ago |
|
|
0c437c5468 | 10 months ago |
|
|
ff91b3023e | 11 months ago |
|
|
81fee53656 | 11 months ago |
|
|
0ed5a312bd | 11 months ago |
|
|
dc9d8adc32 | 11 months ago |
|
|
e8908b672d | 11 months ago |
|
|
e5ec7efc5b | 11 months ago |
|
|
3326eae98b | 11 months ago |
|
|
00c44b4eba | 11 months ago |
|
|
5caaa7c68b | 11 months ago |
|
|
c19b96e4a3 | 11 months ago |
|
|
4a8d7c6a5c | 11 months ago |
|
|
8916ddbbeb | 11 months ago |
|
|
9ce87672d9 | 11 months ago |
|
|
5008f5588c | 11 months ago |
|
|
06e4fba108 | 11 months ago |
|
|
7e6f4d71fa | 11 months ago |
|
|
ea65911a98 | 11 months ago |
|
|
74036e4729 | 11 months ago |
|
|
7d488a2019 | 11 months ago |
|
|
d73deb9d64 | 11 months ago |
|
|
98ede4bb93 | 11 months ago |
|
|
e0da1a3466 | 11 months ago |
|
|
38b54c9317 | 11 months ago |
|
|
f30ad9a1af | 11 months ago |
|
|
07047cf2d8 | 11 months ago |
|
|
6b5f240f09 | 11 months ago |
|
|
fccdf32ece | 11 months ago |
|
|
d0e49971b5 | 11 months ago |
|
|
d50e391d16 | 11 months ago |
|
|
7bdf38b78d | 11 months ago |
|
|
9b81dc49e8 | 11 months ago |
|
|
acbc4c117b | 12 months ago |
|
|
3bccbb94ce | 12 months ago |
|
|
4680a09532 | 12 months ago |
|
|
3251dc3531 | 12 months ago |
|
|
1f4687f78a | 12 months ago |
|
|
41ba44df98 | 12 months ago |
|
|
2d2e17eb70 | 1 year ago |
|
|
0829a1e246 | 1 year ago |
|
|
415f70458c | 1 year ago |
|
|
3783477768 | 1 year ago |
|
|
d98cb74e1a | 1 year ago |
|
|
0fe9272002 | 1 year ago |
@ -0,0 +1,10 @@ |
|||||||
|
This is a django project that is used for padel tournaments management. |
||||||
|
Here are the different apps: |
||||||
|
- api: the api is used to communicate with the mobile app |
||||||
|
- authentication: regroups authentications services |
||||||
|
- biz: it's our CRM project to manage customers |
||||||
|
- shop: the website that hosts the shop |
||||||
|
- sync: the project used to synchronize the data between apps and the backend |
||||||
|
- tournaments: the main website the display everything about the padel tournaments |
||||||
|
|
||||||
|
In production, the project runs with ASGI because we use websockets in the sync app. |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
from django.contrib import admin |
||||||
|
from rest_framework_api_key.admin import APIKeyModelAdmin |
||||||
|
from rest_framework_api_key.models import APIKey as DefaultAPIKey |
||||||
|
from .models import APIKey |
||||||
|
|
||||||
|
# Unregister the default APIKey admin |
||||||
|
admin.site.unregister(DefaultAPIKey) |
||||||
|
|
||||||
|
|
||||||
|
@admin.register(APIKey) |
||||||
|
class APIKeyAdmin(APIKeyModelAdmin): |
||||||
|
list_display = [*APIKeyModelAdmin.list_display, "user"] |
||||||
|
list_filter = [*APIKeyModelAdmin.list_filter, "user"] |
||||||
|
search_fields = [*APIKeyModelAdmin.search_fields, "user__username", "user__email"] |
||||||
|
raw_id_fields = ['user'] |
||||||
|
|
||||||
|
def get_form(self, request, obj=None, **kwargs): |
||||||
|
form = super().get_form(request, obj, **kwargs) |
||||||
|
# Make user field required |
||||||
|
if 'user' in form.base_fields: |
||||||
|
form.base_fields['user'].required = True |
||||||
|
return form |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
from django.apps import AppConfig |
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig): |
||||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||||
|
name = 'api' |
||||||
|
verbose_name = 'API' |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
from rest_framework_api_key.permissions import BaseHasAPIKey |
||||||
|
from .models import APIKey |
||||||
|
|
||||||
|
|
||||||
|
class HasAPIKey(BaseHasAPIKey): |
||||||
|
model = APIKey |
||||||
|
|
||||||
|
def has_permission(self, request, view): |
||||||
|
# First check if we have a valid API key |
||||||
|
has_api_key = super().has_permission(request, view) |
||||||
|
|
||||||
|
if has_api_key: |
||||||
|
# Get the API key from the request |
||||||
|
key = self.get_key(request) |
||||||
|
if key: |
||||||
|
try: |
||||||
|
api_key = APIKey.objects.get_from_key(key) |
||||||
|
# Set the request.user to the user associated with the API key |
||||||
|
request.user = api_key.user |
||||||
|
return True |
||||||
|
except APIKey.DoesNotExist: |
||||||
|
pass |
||||||
|
|
||||||
|
return False |
||||||
@ -0,0 +1,36 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-09-17 07:49 |
||||||
|
|
||||||
|
import django.db.models.deletion |
||||||
|
from django.conf import settings |
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
initial = True |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.CreateModel( |
||||||
|
name='APIKey', |
||||||
|
fields=[ |
||||||
|
('id', models.CharField(editable=False, max_length=150, primary_key=True, serialize=False, unique=True)), |
||||||
|
('prefix', models.CharField(editable=False, max_length=8, unique=True)), |
||||||
|
('hashed_key', models.CharField(editable=False, max_length=150)), |
||||||
|
('created', models.DateTimeField(auto_now_add=True, db_index=True)), |
||||||
|
('name', models.CharField(default=None, help_text='A free-form name for the API key. Need not be unique. 50 characters max.', max_length=50)), |
||||||
|
('revoked', models.BooleanField(blank=True, default=False, help_text='If the API key is revoked, clients cannot use it anymore. (This cannot be undone.)')), |
||||||
|
('expiry_date', models.DateTimeField(blank=True, help_text='Once API key expires, clients cannot use it anymore.', null=True, verbose_name='Expires')), |
||||||
|
('user', models.ForeignKey(help_text='The user this API key belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'verbose_name': 'API Key', |
||||||
|
'verbose_name_plural': 'API Keys', |
||||||
|
'ordering': ('-created',), |
||||||
|
'abstract': False, |
||||||
|
}, |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
from django.db import models |
||||||
|
from rest_framework_api_key.models import AbstractAPIKey |
||||||
|
from tournaments.models import CustomUser |
||||||
|
|
||||||
|
|
||||||
|
class APIKey(AbstractAPIKey): |
||||||
|
""" |
||||||
|
API Key model linked to a specific user. |
||||||
|
This allows filtering API access based on the user associated with the API key. |
||||||
|
""" |
||||||
|
user = models.ForeignKey( |
||||||
|
CustomUser, |
||||||
|
on_delete=models.CASCADE, |
||||||
|
related_name='api_keys', |
||||||
|
help_text='The user this API key belongs to' |
||||||
|
) |
||||||
|
|
||||||
|
class Meta(AbstractAPIKey.Meta): |
||||||
|
verbose_name = "API Key" |
||||||
|
verbose_name_plural = "API Keys" |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return f"API Key for {self.user.username}" |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,15 @@ |
|||||||
|
from django.contrib import admin |
||||||
|
|
||||||
|
from .models import Device, LoginLog |
||||||
|
|
||||||
|
class DeviceAdmin(admin.ModelAdmin): |
||||||
|
list_display = ['user', 'device_model', 'last_login', 'id'] |
||||||
|
readonly_fields = ('last_login',) |
||||||
|
ordering = ['-last_login'] |
||||||
|
|
||||||
|
class LoginLogAdmin(admin.ModelAdmin): |
||||||
|
list_display = ['user', 'device', 'date'] |
||||||
|
ordering = ['-date'] |
||||||
|
|
||||||
|
admin.site.register(Device, DeviceAdmin) |
||||||
|
admin.site.register(LoginLog, LoginLogAdmin) |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from django.apps import AppConfig |
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationConfig(AppConfig): |
||||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||||
|
name = 'authentication' |
||||||
@ -0,0 +1,36 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-03-20 14:49 |
||||||
|
|
||||||
|
import django.db.models.deletion |
||||||
|
import uuid |
||||||
|
from django.conf import settings |
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
initial = True |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.CreateModel( |
||||||
|
name='Device', |
||||||
|
fields=[ |
||||||
|
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), |
||||||
|
('last_login', models.DateTimeField(auto_now=True)), |
||||||
|
('model_name', models.CharField(blank=True, max_length=100, null=True)), |
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devices', to=settings.AUTH_USER_MODEL)), |
||||||
|
], |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='LoginLog', |
||||||
|
fields=[ |
||||||
|
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), |
||||||
|
('date', models.DateTimeField(auto_now=True)), |
||||||
|
('device', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='login_logs', to='authentication.device')), |
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_logs', to=settings.AUTH_USER_MODEL)), |
||||||
|
], |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-03-20 15:42 |
||||||
|
|
||||||
|
from django.db import migrations |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('authentication', '0001_initial'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.RenameField( |
||||||
|
model_name='device', |
||||||
|
old_name='model_name', |
||||||
|
new_name='device_model', |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
from .device import Device |
||||||
|
from .login_log import LoginLog |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
from django.db import models |
||||||
|
import uuid |
||||||
|
from django.conf import settings |
||||||
|
|
||||||
|
class Device(models.Model): |
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) |
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='devices') |
||||||
|
last_login = models.DateTimeField(auto_now=True) |
||||||
|
device_model = models.CharField(max_length=100, blank=True, null=True) |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return f"{self.user.username} : {self.device_model}" |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
from django.db import models |
||||||
|
import uuid |
||||||
|
from django.conf import settings |
||||||
|
from . import Device |
||||||
|
|
||||||
|
class LoginLog(models.Model): |
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) |
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='login_logs') |
||||||
|
device = models.ForeignKey(Device, on_delete=models.SET_NULL, related_name='login_logs', null=True) |
||||||
|
date = models.DateTimeField(auto_now=True) |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return f"{self.id} > {self.user.username}" |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
from django.contrib.auth import password_validation |
||||||
|
|
||||||
|
from rest_framework import serializers |
||||||
|
|
||||||
|
class ChangePasswordSerializer(serializers.Serializer): |
||||||
|
old_password = serializers.CharField(max_length=128, write_only=True, required=True) |
||||||
|
new_password1 = serializers.CharField(max_length=128, write_only=True, required=True) |
||||||
|
new_password2 = serializers.CharField(max_length=128, write_only=True, required=True) |
||||||
|
|
||||||
|
def validate_old_password(self, value): |
||||||
|
user = self.context['request'].user |
||||||
|
if not user.check_password(value): |
||||||
|
raise serializers.ValidationError( |
||||||
|
_('Your old password was entered incorrectly. Please enter it again.') |
||||||
|
) |
||||||
|
return value |
||||||
|
|
||||||
|
def validate(self, data): |
||||||
|
if data['new_password1'] != data['new_password2']: |
||||||
|
raise serializers.ValidationError({'new_password2': _("The two password fields didn't match.")}) |
||||||
|
password_validation.validate_password(data['new_password1'], self.context['request'].user) |
||||||
|
return data |
||||||
|
|
||||||
|
def save(self, **kwargs): |
||||||
|
password = self.validated_data['new_password1'] |
||||||
|
user = self.context['request'].user |
||||||
|
user.set_password(password) |
||||||
|
user.save() |
||||||
|
return user |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
import re |
||||||
|
|
||||||
|
def is_valid_email(email): |
||||||
|
email_regex = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$' |
||||||
|
return re.match(email_regex, email) is not None |
||||||
@ -0,0 +1,116 @@ |
|||||||
|
from django.shortcuts import render |
||||||
|
from django.views.decorators.csrf import csrf_exempt |
||||||
|
from django.contrib.auth import authenticate |
||||||
|
from django.utils.decorators import method_decorator |
||||||
|
from django.core.exceptions import ObjectDoesNotExist |
||||||
|
from django.conf import settings |
||||||
|
from django.contrib.auth import get_user_model |
||||||
|
|
||||||
|
from rest_framework.views import APIView |
||||||
|
from rest_framework.response import Response |
||||||
|
from rest_framework.permissions import IsAuthenticated |
||||||
|
from rest_framework.authtoken.models import Token |
||||||
|
from rest_framework import status |
||||||
|
from rest_framework.generics import UpdateAPIView |
||||||
|
|
||||||
|
from .utils import is_valid_email |
||||||
|
from .models import Device, LoginLog |
||||||
|
|
||||||
|
from .serializers import ChangePasswordSerializer |
||||||
|
import logging |
||||||
|
|
||||||
|
CustomUser=get_user_model() |
||||||
|
logger = logging.getLogger(__name__) |
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch') |
||||||
|
class CustomAuthToken(APIView): |
||||||
|
permission_classes = [] |
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs): |
||||||
|
username = request.data.get('username') |
||||||
|
password = request.data.get('password') |
||||||
|
device_id = request.data.get('device_id') |
||||||
|
|
||||||
|
# logger.info(f'Login attempt from {username}') |
||||||
|
user = authenticate(username=username, password=password) |
||||||
|
|
||||||
|
if user is None and is_valid_email(username) == True: |
||||||
|
true_username = self.get_username_from_email(username) |
||||||
|
user = authenticate(username=true_username, password=password) |
||||||
|
|
||||||
|
if user: |
||||||
|
user.device_id = device_id |
||||||
|
user.save() |
||||||
|
|
||||||
|
device_model = request.data.get('device_model') |
||||||
|
device = self.create_or_update_device(user, device_id, device_model) |
||||||
|
self.create_login_log(user, device) |
||||||
|
|
||||||
|
token, created = Token.objects.get_or_create(user=user) |
||||||
|
return Response({'token': token.key}) |
||||||
|
|
||||||
|
# if user.device_id is None or user.device_id == device_id or user.username == 'apple-test': |
||||||
|
# user.device_id = device_id |
||||||
|
# user.save() |
||||||
|
|
||||||
|
# device_model = request.data.get('device_model') |
||||||
|
|
||||||
|
# device = self.create_or_update_device(user, device_id, device_model) |
||||||
|
# self.create_login_log(user, device) |
||||||
|
|
||||||
|
# token, created = Token.objects.get_or_create(user=user) |
||||||
|
# return Response({'token': token.key}) |
||||||
|
# else: |
||||||
|
# return Response({'error': 'Vous ne pouvez pour l\'instant vous connecter sur plusieurs appareils en même temps. Veuillez vous déconnecter du précédent appareil. Autrement, veuillez contacter le support.'}, status=status.HTTP_403_FORBIDDEN) |
||||||
|
|
||||||
|
else: |
||||||
|
return Response({'error': 'L\'utilisateur et le mot de passe de correspondent pas'}, status=status.HTTP_401_UNAUTHORIZED) |
||||||
|
|
||||||
|
def create_or_update_device(self, user, device_id, device_model): |
||||||
|
obj, created = Device.objects.update_or_create( |
||||||
|
id=device_id, |
||||||
|
device_model=device_model, |
||||||
|
defaults={ |
||||||
|
'user': user |
||||||
|
} |
||||||
|
) |
||||||
|
return obj |
||||||
|
|
||||||
|
def create_login_log(self, user, device): |
||||||
|
LoginLog.objects.create(user=user, device=device) |
||||||
|
|
||||||
|
def get_username_from_email(self, email): |
||||||
|
try: |
||||||
|
user = CustomUser.objects.get(email=email) |
||||||
|
return user.username |
||||||
|
except ObjectDoesNotExist: |
||||||
|
return None |
||||||
|
|
||||||
|
class Logout(APIView): |
||||||
|
permission_classes = (IsAuthenticated,) |
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs): |
||||||
|
# request.user.auth_token.delete() |
||||||
|
|
||||||
|
device_id = request.data.get('device_id') |
||||||
|
if request.user.device_id == device_id: |
||||||
|
request.user.device_id = None |
||||||
|
request.user.save() |
||||||
|
|
||||||
|
Device.objects.filter(id=device_id).delete() |
||||||
|
|
||||||
|
return Response(status=status.HTTP_200_OK) |
||||||
|
|
||||||
|
class ChangePasswordView(UpdateAPIView): |
||||||
|
serializer_class = ChangePasswordSerializer |
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs): |
||||||
|
serializer = self.get_serializer(data=request.data) |
||||||
|
serializer.is_valid(raise_exception=True) |
||||||
|
user = serializer.save() |
||||||
|
# if using drf authtoken, create a new token |
||||||
|
if hasattr(user, 'auth_token'): |
||||||
|
user.auth_token.delete() |
||||||
|
token, created = Token.objects.get_or_create(user=user) |
||||||
|
# return new token |
||||||
|
return Response({'token': token.key}, status=status.HTTP_200_OK) |
||||||
@ -0,0 +1,534 @@ |
|||||||
|
from django.http import HttpResponseRedirect |
||||||
|
from django.contrib import admin |
||||||
|
from django.urls import path, reverse |
||||||
|
from django.contrib import messages |
||||||
|
from django.shortcuts import render, redirect |
||||||
|
from django.contrib.auth import get_user_model |
||||||
|
from django.utils.html import format_html |
||||||
|
from django.core.mail import send_mail |
||||||
|
from django.db.models import Q, Max, Subquery, OuterRef |
||||||
|
|
||||||
|
import csv |
||||||
|
import io |
||||||
|
import time |
||||||
|
import logging |
||||||
|
|
||||||
|
from .models import Entity, Prospect, Activity, Status, ActivityType, EmailTemplate, DeclinationReason, ProspectGroup |
||||||
|
from .forms import FileImportForm, EmailTemplateSelectionForm |
||||||
|
from .filters import ContactAgainFilter, ProspectStatusFilter, StaffUserFilter, ProspectProfileFilter, ProspectDeclineReasonFilter, ProspectGroupFilter, PhoneFilter |
||||||
|
|
||||||
|
from tournaments.models import CustomUser |
||||||
|
from tournaments.models.enums import UserOrigin |
||||||
|
from sync.admin import SyncedObjectAdmin |
||||||
|
|
||||||
|
User = get_user_model() |
||||||
|
|
||||||
|
logger = logging.getLogger(__name__) |
||||||
|
|
||||||
|
class ProspectInline(admin.StackedInline): |
||||||
|
model = Prospect.entities.through |
||||||
|
extra = 1 |
||||||
|
verbose_name = "Prospect" |
||||||
|
verbose_name_plural = "Prospects" |
||||||
|
autocomplete_fields = ['prospect'] |
||||||
|
|
||||||
|
@admin.register(Entity) |
||||||
|
class EntityAdmin(SyncedObjectAdmin): |
||||||
|
list_display = ('name', 'address', 'zip_code', 'city') |
||||||
|
search_fields = ('name', 'address', 'zip_code', 'city') |
||||||
|
# filter_horizontal = ('prospects',) |
||||||
|
inlines = [ProspectInline] |
||||||
|
|
||||||
|
@admin.register(EmailTemplate) |
||||||
|
class EmailTemplateAdmin(SyncedObjectAdmin): |
||||||
|
list_display = ('name', 'subject', 'body') |
||||||
|
search_fields = ('name', 'subject') |
||||||
|
exclude = ('data_access_ids', 'activities',) |
||||||
|
|
||||||
|
def contacted_by_sms(modeladmin, request, queryset): |
||||||
|
create_default_activity_for_prospect(modeladmin, request, queryset, ActivityType.SMS, Status.CONTACTED, None) |
||||||
|
contacted_by_sms.short_description = "Contacted by SMS" |
||||||
|
|
||||||
|
def mark_as_inbound(modeladmin, request, queryset): |
||||||
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.INBOUND, None) |
||||||
|
mark_as_inbound.short_description = "Mark as inbound" |
||||||
|
|
||||||
|
def mark_as_customer(modeladmin, request, queryset): |
||||||
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.CUSTOMER, None) |
||||||
|
mark_as_customer.short_description = "Mark as customer" |
||||||
|
|
||||||
|
def mark_as_should_test(modeladmin, request, queryset): |
||||||
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.SHOULD_TEST, None) |
||||||
|
mark_as_should_test.short_description = "Mark as should test" |
||||||
|
|
||||||
|
def mark_as_testing(modeladmin, request, queryset): |
||||||
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.TESTING, None) |
||||||
|
mark_as_testing.short_description = "Mark as testing" |
||||||
|
|
||||||
|
def declined_too_expensive(modeladmin, request, queryset): |
||||||
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.TOO_EXPENSIVE) |
||||||
|
declined_too_expensive.short_description = "Declined too expensive" |
||||||
|
|
||||||
|
def declined_use_something_else(modeladmin, request, queryset): |
||||||
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.USE_OTHER_PRODUCT) |
||||||
|
declined_use_something_else.short_description = "Declined use something else" |
||||||
|
|
||||||
|
def declined_android_user(modeladmin, request, queryset): |
||||||
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.USE_ANDROID) |
||||||
|
declined_android_user.short_description = "Declined use Android" |
||||||
|
|
||||||
|
def mark_as_have_account(modeladmin, request, queryset): |
||||||
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.HAVE_CREATED_ACCOUNT, None) |
||||||
|
mark_as_have_account.short_description = "Mark as having an account" |
||||||
|
|
||||||
|
def mark_as_not_concerned(modeladmin, request, queryset): |
||||||
|
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.NOT_CONCERNED, None) |
||||||
|
mark_as_not_concerned.short_description = "Mark as not concerned" |
||||||
|
|
||||||
|
def create_default_activity_for_prospect(modeladmin, request, queryset, type, status, reason): |
||||||
|
for prospect in queryset: |
||||||
|
activity = Activity.objects.create( |
||||||
|
type=type, |
||||||
|
status=status, |
||||||
|
declination_reason=reason, |
||||||
|
related_user = request.user |
||||||
|
) |
||||||
|
activity.prospects.add(prospect) |
||||||
|
|
||||||
|
modeladmin.message_user( |
||||||
|
request, |
||||||
|
f'{queryset.count()} prospects were marked as {status}.' |
||||||
|
) |
||||||
|
|
||||||
|
def create_activity_for_prospect(modeladmin, request, queryset): |
||||||
|
# Only allow single selection |
||||||
|
if queryset.count() != 1: |
||||||
|
messages.error(request, "Please select exactly one prospect.") |
||||||
|
return |
||||||
|
|
||||||
|
prospect = queryset.first() |
||||||
|
|
||||||
|
# Build the URL with pre-populated fields |
||||||
|
url = reverse('admin:biz_activity_add') |
||||||
|
url += f'?prospect={prospect.id}' |
||||||
|
return redirect(url) |
||||||
|
create_activity_for_prospect.short_description = "Create activity" |
||||||
|
|
||||||
|
@admin.register(Prospect) |
||||||
|
class ProspectAdmin(SyncedObjectAdmin): |
||||||
|
readonly_fields = ['related_activities', 'entity_names', 'current_status', 'id'] |
||||||
|
fieldsets = [ |
||||||
|
(None, { |
||||||
|
'fields': ['related_activities', 'id', 'entity_names', 'first_name', 'last_name', 'email', 'phone', 'contact_again', 'official_user', 'name_unsure', 'entities', 'related_user'] |
||||||
|
}), |
||||||
|
] |
||||||
|
list_display = ('first_name', 'last_name', 'entity_names', 'phone', 'last_update_date', 'current_status', 'contact_again') |
||||||
|
|
||||||
|
list_filter = (ContactAgainFilter, ProspectStatusFilter, ProspectDeclineReasonFilter, ProspectGroupFilter, PhoneFilter, 'creation_date', StaffUserFilter, 'source', ProspectProfileFilter) |
||||||
|
search_fields = ('first_name', 'last_name', 'email', 'phone') |
||||||
|
date_hierarchy = 'creation_date' |
||||||
|
change_list_template = "admin/biz/prospect/change_list.html" |
||||||
|
ordering = ['-last_update'] |
||||||
|
filter_horizontal = ['entities'] |
||||||
|
actions = ['send_email', create_activity_for_prospect, mark_as_inbound, contacted_by_sms, mark_as_should_test, mark_as_testing, mark_as_customer, mark_as_have_account, declined_too_expensive, declined_use_something_else, declined_android_user, mark_as_not_concerned] |
||||||
|
autocomplete_fields = ['official_user', 'related_user'] |
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change): |
||||||
|
if obj.related_user is None: |
||||||
|
obj.related_user = request.user |
||||||
|
super().save_model(request, obj, form, change) |
||||||
|
|
||||||
|
def last_update_date(self, obj): |
||||||
|
return obj.last_update.date() if obj.last_update else None |
||||||
|
last_update_date.short_description = 'Last Update' |
||||||
|
last_update_date.admin_order_field = 'last_update' |
||||||
|
|
||||||
|
def related_activities(self, obj): |
||||||
|
activities = obj.activities.all() |
||||||
|
if activities: |
||||||
|
activity_links = [] |
||||||
|
for activity in activities: |
||||||
|
url = f"/kingdom/biz/activity/{activity.id}/change/" |
||||||
|
activity_links.append(f'<a href="{url}">{activity.html_desc()}</a>') |
||||||
|
return format_html('<br>'.join(activity_links)) |
||||||
|
return "No events" |
||||||
|
related_activities.short_description = "Related Activities" |
||||||
|
|
||||||
|
def get_urls(self): |
||||||
|
urls = super().get_urls() |
||||||
|
custom_urls = [ |
||||||
|
path('dashboard/', self.admin_site.admin_view(self.dashboard), name='biz_dashboard'), |
||||||
|
path('import_file/', self.admin_site.admin_view(self.import_file), name='import_file'), |
||||||
|
path('import_app_users/', self.admin_site.admin_view(self.import_app_users), name='import_app_users'), |
||||||
|
path('cleanup/', self.admin_site.admin_view(self.cleanup), name='cleanup'), |
||||||
|
] |
||||||
|
return custom_urls + urls |
||||||
|
|
||||||
|
def dashboard(self, request): |
||||||
|
""" |
||||||
|
Dashboard view showing prospects organized by status columns |
||||||
|
""" |
||||||
|
# Get filter parameter - if 'my' is true, filter by current user |
||||||
|
filter_my = request.GET.get('my', 'false') == 'true' |
||||||
|
|
||||||
|
# Base queryset |
||||||
|
base_queryset = Prospect.objects.select_related().prefetch_related('entities', 'activities') |
||||||
|
|
||||||
|
# Apply user filter if requested |
||||||
|
if filter_my: |
||||||
|
base_queryset = base_queryset.filter(related_user=request.user) |
||||||
|
|
||||||
|
# Helper function to get prospects by status |
||||||
|
def get_prospects_by_status(statuses): |
||||||
|
# Get the latest activity status for each prospect |
||||||
|
latest_activity = Activity.objects.filter( |
||||||
|
prospects=OuterRef('pk'), |
||||||
|
status__isnull=False |
||||||
|
).order_by('-creation_date') |
||||||
|
|
||||||
|
prospects = base_queryset.annotate( |
||||||
|
latest_status=Subquery(latest_activity.values('status')[:1]) |
||||||
|
).filter( |
||||||
|
latest_status__in=statuses |
||||||
|
).order_by('last_update') |
||||||
|
|
||||||
|
return prospects |
||||||
|
|
||||||
|
# Get prospects for each column |
||||||
|
should_test_prospects = get_prospects_by_status([Status.SHOULD_TEST]) |
||||||
|
testing_prospects = get_prospects_by_status([Status.TESTING]) |
||||||
|
responded_prospects = get_prospects_by_status([Status.RESPONDED]) |
||||||
|
others_prospects = get_prospects_by_status([Status.INBOUND, Status.SHOULD_BUY]) |
||||||
|
|
||||||
|
# Get prospects with contact_again date set, sorted by oldest first |
||||||
|
contact_again_prospects = base_queryset.filter( |
||||||
|
contact_again__isnull=False |
||||||
|
).order_by('contact_again') |
||||||
|
|
||||||
|
context = { |
||||||
|
'title': 'CRM Dashboard', |
||||||
|
'should_test_prospects': should_test_prospects, |
||||||
|
'testing_prospects': testing_prospects, |
||||||
|
'responded_prospects': responded_prospects, |
||||||
|
'others_prospects': others_prospects, |
||||||
|
'contact_again_prospects': contact_again_prospects, |
||||||
|
'filter_my': filter_my, |
||||||
|
'opts': self.model._meta, |
||||||
|
'has_view_permission': self.has_view_permission(request), |
||||||
|
} |
||||||
|
|
||||||
|
return render(request, 'admin/biz/dashboard.html', context) |
||||||
|
|
||||||
|
def cleanup(self, request): |
||||||
|
Entity.objects.all().delete() |
||||||
|
Prospect.objects.all().delete() |
||||||
|
Activity.objects.all().delete() |
||||||
|
|
||||||
|
messages.success(request, 'cleanup biz objects') |
||||||
|
return redirect('admin:biz_prospect_changelist') |
||||||
|
|
||||||
|
def import_app_users(self, request): |
||||||
|
users = CustomUser.objects.filter(origin=UserOrigin.APP) |
||||||
|
|
||||||
|
created_count = 0 |
||||||
|
for user in users: |
||||||
|
is_customer = user.purchases.count() > 0 |
||||||
|
entity_name = user.latest_event_club_name() |
||||||
|
|
||||||
|
prospect, prospect_created = Prospect.objects.get_or_create( |
||||||
|
email=user.email, |
||||||
|
defaults={ |
||||||
|
'first_name': user.first_name, |
||||||
|
'last_name': user.last_name, |
||||||
|
'phone': user.phone, |
||||||
|
'name_unsure': False, |
||||||
|
'official_user': user, |
||||||
|
'source': 'App', |
||||||
|
} |
||||||
|
) |
||||||
|
if entity_name: |
||||||
|
entity, entity_created = Entity.objects.get_or_create( |
||||||
|
name=entity_name, |
||||||
|
defaults={'name': entity_name} |
||||||
|
) |
||||||
|
prospect.entities.add(entity) |
||||||
|
|
||||||
|
if is_customer: |
||||||
|
activity = Activity.objects.create( |
||||||
|
status=Status.CUSTOMER, |
||||||
|
) |
||||||
|
activity.prospects.add(prospect) |
||||||
|
if prospect_created: |
||||||
|
created_count += 1 |
||||||
|
|
||||||
|
messages.success(request, f'Imported {created_count} app users into prospects') |
||||||
|
return redirect('admin:biz_prospect_changelist') |
||||||
|
|
||||||
|
def import_file(self, request): |
||||||
|
""" |
||||||
|
Handle file import - displays form and processes file upload |
||||||
|
""" |
||||||
|
if request.method == 'POST': |
||||||
|
form = FileImportForm(request.POST, request.FILES) |
||||||
|
if form.is_valid(): |
||||||
|
# Call the import_csv method with the uploaded file |
||||||
|
try: |
||||||
|
result = self.import_csv(form.cleaned_data['file'], form.cleaned_data['source']) |
||||||
|
messages.success(request, f'File imported successfully: {result}') |
||||||
|
return redirect('admin:biz_prospect_changelist') |
||||||
|
except Exception as e: |
||||||
|
messages.error(request, f'Error importing file: {str(e)}') |
||||||
|
else: |
||||||
|
messages.error(request, 'Please correct the errors below.') |
||||||
|
else: |
||||||
|
form = FileImportForm() |
||||||
|
|
||||||
|
context = { |
||||||
|
'form': form, |
||||||
|
'title': 'Import File', |
||||||
|
'app_label': self.model._meta.app_label, |
||||||
|
'opts': self.model._meta, |
||||||
|
'has_change_permission': self.has_change_permission(request), |
||||||
|
} |
||||||
|
return render(request, 'admin/biz/prospect/import_file.html', context) |
||||||
|
|
||||||
|
def import_csv(self, file, source): |
||||||
|
""" |
||||||
|
Process the uploaded CSV file |
||||||
|
CSV format: entity_name,last_name,first_name,email,phone,attachment_text,status,related_user |
||||||
|
""" |
||||||
|
try: |
||||||
|
# Read the file content |
||||||
|
file_content = file.read().decode('utf-8') |
||||||
|
csv_reader = csv.reader(io.StringIO(file_content), delimiter=';') |
||||||
|
created_prospects = 0 |
||||||
|
updated_prospects = 0 |
||||||
|
created_entities = 0 |
||||||
|
created_events = 0 |
||||||
|
|
||||||
|
for row in csv_reader: |
||||||
|
print(f'>>> row size is {len(row)}') |
||||||
|
|
||||||
|
if len(row) < 5: |
||||||
|
print(f'>>> WARNING: row size is {len(row)}: {row}') |
||||||
|
continue # Skip rows that don't have enough columns |
||||||
|
|
||||||
|
entity_name = row[0].strip() |
||||||
|
last_name = row[1].strip() |
||||||
|
first_name = row[2].strip() |
||||||
|
email = row[3].strip() |
||||||
|
phone = row[4].strip() if row[4].strip() else None |
||||||
|
if phone and not phone.startswith('0'): |
||||||
|
phone = '0' + phone |
||||||
|
# attachment_text = row[5].strip() if row[5].strip() else None |
||||||
|
# status_text = row[6].strip() if row[6].strip() else None |
||||||
|
# related_user_name = row[7].strip() if row[7].strip() else None |
||||||
|
|
||||||
|
# Create or get Entity |
||||||
|
entity = None |
||||||
|
if entity_name: |
||||||
|
entity, entity_created = Entity.objects.get_or_create( |
||||||
|
name=entity_name, |
||||||
|
defaults={'name': entity_name} |
||||||
|
) |
||||||
|
if entity_created: |
||||||
|
created_entities += 1 |
||||||
|
|
||||||
|
# Get related user if provided |
||||||
|
# related_user = None |
||||||
|
# if related_user_name: |
||||||
|
# try: |
||||||
|
# related_user = User.objects.get(username=related_user_name) |
||||||
|
# except User.DoesNotExist: |
||||||
|
# # Try to find by first name if username doesn't exist |
||||||
|
# related_user = User.objects.filter(first_name__icontains=related_user_name).first() |
||||||
|
|
||||||
|
# Create or update Prospect |
||||||
|
prospect, prospect_created = Prospect.objects.get_or_create( |
||||||
|
email=email, |
||||||
|
defaults={ |
||||||
|
'first_name': first_name, |
||||||
|
'last_name': last_name, |
||||||
|
'phone': phone, |
||||||
|
'name_unsure': False, |
||||||
|
'source': source, |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
if prospect_created: |
||||||
|
created_prospects += 1 |
||||||
|
# else: |
||||||
|
# # Check if names are different and mark as name_unsure |
||||||
|
# if (prospect.first_name != first_name or prospect.last_name != last_name): |
||||||
|
# prospect.name_unsure = True |
||||||
|
# # Update related_user if provided |
||||||
|
# if related_user: |
||||||
|
# prospect.related_user = related_user |
||||||
|
# prospect.save() |
||||||
|
# updated_prospects += 1 |
||||||
|
|
||||||
|
# Associate entity with prospect |
||||||
|
if entity: |
||||||
|
prospect.entities.add(entity) |
||||||
|
|
||||||
|
# Create Event if attachment_text or status is provided |
||||||
|
# if attachment_text or status_text: |
||||||
|
# # Map status text to Status enum |
||||||
|
# status_value = None |
||||||
|
# declination_reason = None |
||||||
|
# if status_text: |
||||||
|
# if 'CONTACTED' in status_text: |
||||||
|
# status_value = Status.CONTACTED |
||||||
|
# elif 'RESPONDED' in status_text: |
||||||
|
# status_value = Status.RESPONDED |
||||||
|
# elif 'SHOULD_TEST' in status_text: |
||||||
|
# status_value = Status.SHOULD_TEST |
||||||
|
# elif 'CUSTOMER' in status_text: |
||||||
|
# status_value = Status.CUSTOMER |
||||||
|
# elif 'TESTING' in status_text: |
||||||
|
# status_value = Status.TESTING |
||||||
|
# elif 'LOST' in status_text: |
||||||
|
# status_value = Status.LOST |
||||||
|
# elif 'DECLINED_TOO_EXPENSIVE' in status_text: |
||||||
|
# status_value = Status.DECLINED |
||||||
|
# declination_reason = DeclinationReason.TOO_EXPENSIVE |
||||||
|
# elif 'USE_OTHER_PRODUCT' in status_text: |
||||||
|
# status_value = Status.DECLINED |
||||||
|
# declination_reason = DeclinationReason.USE_OTHER_PRODUCT |
||||||
|
# elif 'USE_ANDROID' in status_text: |
||||||
|
# status_value = Status.DECLINED |
||||||
|
# declination_reason = DeclinationReason.USE_ANDROID |
||||||
|
# elif 'NOK' in status_text: |
||||||
|
# status_value = Status.DECLINED |
||||||
|
# declination_reason = DeclinationReason.UNKNOWN |
||||||
|
# elif 'DECLINED_UNRELATED' in status_text: |
||||||
|
# status_value = Status.DECLINED_UNRELATED |
||||||
|
|
||||||
|
# activity = Activity.objects.create( |
||||||
|
# type=ActivityType.SMS, |
||||||
|
# attachment_text=attachment_text, |
||||||
|
# status=status_value, |
||||||
|
# declination_reason=declination_reason, |
||||||
|
# description=f"Imported from CSV - Status: {status_text}" if status_text else "Imported from CSV" |
||||||
|
# ) |
||||||
|
# activity.prospects.add(prospect) |
||||||
|
# created_events += 1 |
||||||
|
|
||||||
|
result = f"Successfully imported: {created_prospects} new prospects, {updated_prospects} updated prospects, {created_entities} new entities, {created_events} new events" |
||||||
|
return result |
||||||
|
|
||||||
|
except Exception as e: |
||||||
|
raise Exception(f"Error processing CSV file: {str(e)}") |
||||||
|
|
||||||
|
def send_email(self, request, queryset): |
||||||
|
|
||||||
|
logger.info('send_email to prospects form initiated...') |
||||||
|
|
||||||
|
if 'apply' in request.POST: |
||||||
|
form = EmailTemplateSelectionForm(request.POST) |
||||||
|
if form.is_valid(): |
||||||
|
email_template = form.cleaned_data['email_template'] |
||||||
|
|
||||||
|
sent_count, failed_count = self.process_selected_items_with_template(request, queryset, email_template) |
||||||
|
|
||||||
|
if failed_count > 0: |
||||||
|
self.message_user(request, f"Email sent to {sent_count} prospects, {failed_count} failed using the '{email_template.name}' template.", messages.WARNING) |
||||||
|
else: |
||||||
|
self.message_user(request, f"Email sent to {sent_count} prospects using the '{email_template.name}' template.", messages.SUCCESS) |
||||||
|
return HttpResponseRedirect(request.get_full_path()) |
||||||
|
else: |
||||||
|
form = EmailTemplateSelectionForm() |
||||||
|
|
||||||
|
return render(request, 'admin/biz/select_email_template.html', { |
||||||
|
'prospects': queryset, |
||||||
|
'form': form, |
||||||
|
'title': 'Send Email to Prospects' |
||||||
|
}) |
||||||
|
send_email.short_description = "Send email" |
||||||
|
|
||||||
|
def process_selected_items_with_template(self, request, queryset, email_template): |
||||||
|
|
||||||
|
sent_count = 0 |
||||||
|
error_emails = [] |
||||||
|
all_emails = [] |
||||||
|
|
||||||
|
logger.info(f'Sending email to {queryset.count()} users...') |
||||||
|
|
||||||
|
for prospect in queryset: |
||||||
|
mail_body = email_template.body.replace( |
||||||
|
'{{name}}', |
||||||
|
f' {prospect.first_name}' if prospect.first_name and len(prospect.first_name) > 0 else '' |
||||||
|
) |
||||||
|
# mail_body = email_template.body.replace('{{name}}', prospect.first_name) |
||||||
|
all_emails.append(prospect.email) |
||||||
|
|
||||||
|
try: |
||||||
|
send_mail( |
||||||
|
email_template.subject, |
||||||
|
mail_body, |
||||||
|
request.user.email, |
||||||
|
[prospect.email], |
||||||
|
fail_silently=False, |
||||||
|
) |
||||||
|
sent_count += 1 |
||||||
|
|
||||||
|
activity = Activity.objects.create( |
||||||
|
type=ActivityType.MAIL, |
||||||
|
status=Status.CONTACTED, |
||||||
|
description=f"Email sent: {email_template.subject}" |
||||||
|
) |
||||||
|
activity.prospects.add(prospect) |
||||||
|
except Exception as e: |
||||||
|
error_emails.append(prospect.email) |
||||||
|
logger.error(f'Failed to send email to {prospect.email}: {str(e)}') |
||||||
|
|
||||||
|
time.sleep(1) |
||||||
|
|
||||||
|
if error_emails: |
||||||
|
logger.error(f'Failed to send emails to: {error_emails}') |
||||||
|
|
||||||
|
return sent_count, len(error_emails) |
||||||
|
|
||||||
|
@admin.register(ProspectGroup) |
||||||
|
class ProspectGroupAdmin(SyncedObjectAdmin): |
||||||
|
list_display = ('name', 'user_count') |
||||||
|
date_hierarchy = 'creation_date' |
||||||
|
raw_id_fields = ['related_user'] |
||||||
|
|
||||||
|
@admin.register(Activity) |
||||||
|
class ActivityAdmin(SyncedObjectAdmin): |
||||||
|
# raw_id_fields = ['prospects'] |
||||||
|
list_display = ('prospect_names', 'last_update', 'status', 'type', 'description', 'attachment_text', ) |
||||||
|
list_filter = ('status', 'type') |
||||||
|
search_fields = ('attachment_text',) |
||||||
|
date_hierarchy = 'last_update' |
||||||
|
autocomplete_fields = ['prospects', 'related_user'] |
||||||
|
|
||||||
|
def get_form(self, request, obj=None, **kwargs): |
||||||
|
form = super().get_form(request, obj, **kwargs) |
||||||
|
|
||||||
|
# Pre-populate fields from URL parameters |
||||||
|
if 'prospect' in request.GET: |
||||||
|
try: |
||||||
|
prospect_id = request.GET['prospect'] |
||||||
|
prospect = Prospect.objects.get(id=prospect_id) |
||||||
|
form.base_fields['prospects'].initial = [prospect] |
||||||
|
form.base_fields['related_user'].initial = request.user |
||||||
|
|
||||||
|
# You can set other fields based on the prospect |
||||||
|
# form.base_fields['title'].initial = f"Event for {prospect.}" |
||||||
|
# form.base_fields['status'].initial = 'pending' |
||||||
|
|
||||||
|
except (Prospect.DoesNotExist, ValueError): |
||||||
|
pass |
||||||
|
|
||||||
|
return form |
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change): |
||||||
|
if obj.related_user is None: |
||||||
|
obj.related_user = request.user |
||||||
|
super().save_model(request, obj, form, change) |
||||||
|
|
||||||
|
def get_event_display(self, obj): |
||||||
|
return str(obj) |
||||||
|
get_event_display.short_description = 'Activity' |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
from django.urls import path |
||||||
|
from django.http import HttpResponse |
||||||
|
from tournaments.models import CustomUser |
||||||
|
from tournaments.models.enums import UserOrigin |
||||||
|
from django.core.mail import send_mail |
||||||
|
|
||||||
|
import time |
||||||
|
|
||||||
|
def users_list(with_tournaments): |
||||||
|
return CustomUser.objects.filter(origin=UserOrigin.APP).exclude(purchase__isnull=False).filter(events__isnull=with_tournaments) |
||||||
|
|
||||||
|
def email_users_with_tournaments_count(request): |
||||||
|
users = users_list(False) |
||||||
|
emails = [user.email for user in users] |
||||||
|
return HttpResponse(f'users = {len(users)}, \n\nemails = {emails}') |
||||||
|
|
||||||
|
def email_users_count(request): |
||||||
|
users = users_list(True) |
||||||
|
emails = [user.email for user in users] |
||||||
|
return HttpResponse(f'users = {len(users)}, \n\nemails = {emails}') |
||||||
|
|
||||||
|
def email_users_view(request): |
||||||
|
return email_users(request, users_list(True), 0) |
||||||
|
|
||||||
|
def email_users_with_tournaments(request): |
||||||
|
return email_users(request, users_list(False), 1) |
||||||
|
|
||||||
|
def email_users(request, users, template_index): |
||||||
|
|
||||||
|
users = users_list(True) |
||||||
|
|
||||||
|
subject = 'check Padel Club' |
||||||
|
from_email = 'laurent@padelclub.app' |
||||||
|
|
||||||
|
sent_count = 0 |
||||||
|
error_emails = [] |
||||||
|
all_emails = [] |
||||||
|
|
||||||
|
for user in users: |
||||||
|
mail_body = template(user, template_index) # f'Bonjour {user.first_name}, cool la vie ?' |
||||||
|
all_emails.append(user.email) |
||||||
|
|
||||||
|
try: |
||||||
|
send_mail( |
||||||
|
subject, |
||||||
|
mail_body, |
||||||
|
from_email, |
||||||
|
[user.email], |
||||||
|
fail_silently=False, |
||||||
|
) |
||||||
|
sent_count += 1 |
||||||
|
except Exception as e: |
||||||
|
error_emails.append(user.email) |
||||||
|
|
||||||
|
time.sleep(1) |
||||||
|
|
||||||
|
return HttpResponse(f'users = {len(users)}, sent = {sent_count}, errors = {len(error_emails)}, \n\nemails = {all_emails}, \n\nerror emails = {error_emails}') |
||||||
|
|
||||||
|
def template(user, index): |
||||||
|
if index == 0: |
||||||
|
return f'Bonjour {user.first_name}, \n\n' |
||||||
|
else: |
||||||
|
return f'Bonjour {user.first_name}, \n\nJe te remercie d\'avoir téléchargé Padel Club. J\'ai pu voir que tu avais créé quelques tournois mais sans aller plus loin, est-ce que tu pourrais me dire ce qui t\'as freiné ?\n\nLaurent Morvillier' |
||||||
|
|
||||||
|
urlpatterns = [ |
||||||
|
path('email_users/', email_users_view, name='biz_email_users'), |
||||||
|
path('email_users_count/', email_users_count, name='biz_email_count'), |
||||||
|
path('email_users_with_tournaments_count/', email_users_with_tournaments_count, name='biz_email_with_tournaments_count'), |
||||||
|
path('email_users_with_tournaments/', email_users_with_tournaments, name='email_users_with_tournaments'), |
||||||
|
] |
||||||
@ -1,5 +1,5 @@ |
|||||||
from django.apps import AppConfig |
from django.apps import AppConfig |
||||||
|
|
||||||
class CrmConfig(AppConfig): |
class BizConfig(AppConfig): |
||||||
default_auto_field = 'django.db.models.BigAutoField' |
default_auto_field = 'django.db.models.BigAutoField' |
||||||
name = 'crm' |
name = 'biz' |
||||||
@ -0,0 +1,163 @@ |
|||||||
|
from xml.dom import Node |
||||||
|
import django_filters |
||||||
|
from django.db.models import Max, F, Q |
||||||
|
from django.contrib.auth import get_user_model |
||||||
|
from django.contrib import admin |
||||||
|
from django.utils import timezone |
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta |
||||||
|
|
||||||
|
from .models import Activity, Prospect, Status, DeclinationReason, ProspectGroup |
||||||
|
|
||||||
|
User = get_user_model() |
||||||
|
|
||||||
|
class ProspectFilter(django_filters.FilterSet): |
||||||
|
zip_code = django_filters.CharFilter(lookup_expr='istartswith', label='Code postal') |
||||||
|
activities = django_filters.ModelMultipleChoiceFilter( |
||||||
|
queryset=Activity.objects.all(), |
||||||
|
field_name='activities', |
||||||
|
) |
||||||
|
city = django_filters.CharFilter(lookup_expr='icontains', label='Ville') |
||||||
|
name = django_filters.CharFilter(method='filter_name', label='Nom') |
||||||
|
|
||||||
|
def filter_name(self, queryset, name, value): |
||||||
|
return queryset.filter( |
||||||
|
Q(first_name__icontains=value) | Q(last_name__icontains=value) | Q(entity_name__icontains=value) |
||||||
|
) |
||||||
|
|
||||||
|
class Meta: |
||||||
|
model = Prospect |
||||||
|
fields = ['name', 'city', 'activities', 'zip_code'] |
||||||
|
|
||||||
|
class StaffUserFilter(admin.SimpleListFilter): |
||||||
|
title = 'staff user' |
||||||
|
parameter_name = 'user' |
||||||
|
|
||||||
|
def lookups(self, request, model_admin): |
||||||
|
staff_users = User.objects.filter(is_staff=True) |
||||||
|
return [(user.id, user.username) for user in staff_users] |
||||||
|
|
||||||
|
def queryset(self, request, queryset): |
||||||
|
# Filter the queryset based on the selected user ID |
||||||
|
if self.value(): |
||||||
|
return queryset.filter(related_user__id=self.value()) |
||||||
|
return queryset |
||||||
|
|
||||||
|
class ProspectProfileFilter(admin.SimpleListFilter): |
||||||
|
title = 'Prospect profiles' # displayed in the admin UI |
||||||
|
parameter_name = 'profile' # URL parameter |
||||||
|
|
||||||
|
def lookups(self, request, model_admin): |
||||||
|
return ( |
||||||
|
('tournament_at_least_1_month_old', 'tournaments > 1 month old'), |
||||||
|
('no_tournaments', 'No tournaments'), |
||||||
|
) |
||||||
|
|
||||||
|
def queryset(self, request, queryset): |
||||||
|
if not self.value(): |
||||||
|
return queryset |
||||||
|
|
||||||
|
two_months_ago = timezone.now().date() - relativedelta(months=2) |
||||||
|
|
||||||
|
if self.value() == 'tournament_at_least_2_month_old': |
||||||
|
return queryset.filter( |
||||||
|
official_user__isnull=False, |
||||||
|
official_user__events__creation_date__lte=two_months_ago |
||||||
|
) |
||||||
|
elif self.value() == 'no_tournaments': |
||||||
|
return queryset.filter( |
||||||
|
official_user__isnull=False, |
||||||
|
official_user__events__isnull=True |
||||||
|
) |
||||||
|
|
||||||
|
class ProspectStatusFilter(admin.SimpleListFilter): |
||||||
|
title = 'Status' |
||||||
|
parameter_name = 'status' |
||||||
|
|
||||||
|
def lookups(self, request, model_admin): |
||||||
|
return [(tag.name, tag.value) for tag in Status] |
||||||
|
|
||||||
|
def queryset(self, request, queryset): |
||||||
|
if self.value() == Status.NONE: |
||||||
|
return queryset.filter(activities__isnull=True) |
||||||
|
elif self.value(): |
||||||
|
prospects_with_status = [] |
||||||
|
for prospect in queryset: |
||||||
|
if prospect.current_status() == self.value(): |
||||||
|
prospects_with_status.append(prospect.id) |
||||||
|
return queryset.filter(id__in=prospects_with_status) |
||||||
|
else: |
||||||
|
return queryset |
||||||
|
|
||||||
|
class ProspectDeclineReasonFilter(admin.SimpleListFilter): |
||||||
|
title = 'Decline reason' |
||||||
|
parameter_name = 'reason' |
||||||
|
|
||||||
|
def lookups(self, request, model_admin): |
||||||
|
return [(tag.name, tag.value) for tag in DeclinationReason] |
||||||
|
|
||||||
|
def queryset(self, request, queryset): |
||||||
|
if self.value(): |
||||||
|
# Get prospects whose most recent activity has the selected status |
||||||
|
return queryset.filter( |
||||||
|
activities__declination_reason=self.value() |
||||||
|
).annotate( |
||||||
|
latest_activity_date=Max('activities__creation_date') |
||||||
|
).filter( |
||||||
|
activities__creation_date=F('latest_activity_date'), |
||||||
|
activities__declination_reason=self.value() |
||||||
|
).distinct() |
||||||
|
else: |
||||||
|
return queryset |
||||||
|
|
||||||
|
class ProspectGroupFilter(admin.SimpleListFilter): |
||||||
|
title = 'ProspectGroup' |
||||||
|
parameter_name = 'prospect_group' |
||||||
|
|
||||||
|
def lookups(self, request, model_admin): |
||||||
|
prospect_groups = ProspectGroup.objects.all().order_by('-creation_date') |
||||||
|
return [(group.id, group.name) for group in prospect_groups] |
||||||
|
|
||||||
|
def queryset(self, request, queryset): |
||||||
|
if self.value(): |
||||||
|
return queryset.filter(prospect_groups__id=self.value()) |
||||||
|
return queryset |
||||||
|
|
||||||
|
class ContactAgainFilter(admin.SimpleListFilter): |
||||||
|
title = 'Contact again' # or whatever you want |
||||||
|
parameter_name = 'contact_again' |
||||||
|
|
||||||
|
def lookups(self, request, model_admin): |
||||||
|
return ( |
||||||
|
('1', 'Should be contacted'), |
||||||
|
# ('0', 'Is null'), |
||||||
|
) |
||||||
|
|
||||||
|
def queryset(self, request, queryset): |
||||||
|
if self.value() == '1': |
||||||
|
return queryset.filter(contact_again__isnull=False) |
||||||
|
# if self.value() == '0': |
||||||
|
# return queryset.filter(my_field__isnull=True) |
||||||
|
return queryset |
||||||
|
|
||||||
|
class PhoneFilter(admin.SimpleListFilter): |
||||||
|
title = 'Phone number' |
||||||
|
parameter_name = 'phone_filter' |
||||||
|
|
||||||
|
def lookups(self, request, model_admin): |
||||||
|
return ( |
||||||
|
('exclude_mobile', 'Exclude mobile (06/07)'), |
||||||
|
('mobile_only', 'Mobile only (06/07)'), |
||||||
|
) |
||||||
|
|
||||||
|
def queryset(self, request, queryset): |
||||||
|
if self.value() == 'exclude_mobile': |
||||||
|
return queryset.exclude( |
||||||
|
Q(phone__startswith='06') | Q(phone__startswith='07') |
||||||
|
) |
||||||
|
elif self.value() == 'mobile_only': |
||||||
|
return queryset.filter( |
||||||
|
Q(phone__startswith='06') | Q(phone__startswith='07') |
||||||
|
) |
||||||
|
|
||||||
|
return queryset |
||||||
@ -0,0 +1,61 @@ |
|||||||
|
from django import forms |
||||||
|
|
||||||
|
from .models import EmailTemplate |
||||||
|
|
||||||
|
# class SmallTextArea(forms.Textarea): |
||||||
|
# def __init__(self, *args, **kwargs): |
||||||
|
# kwargs.setdefault('attrs', {}) |
||||||
|
# kwargs['attrs'].update({ |
||||||
|
# 'rows': 2, |
||||||
|
# 'cols': 100, |
||||||
|
# 'style': 'height: 80px; width: 800px;' |
||||||
|
# }) |
||||||
|
# super().__init__(*args, **kwargs) |
||||||
|
|
||||||
|
# class ProspectForm(forms.ModelForm): |
||||||
|
# class Meta: |
||||||
|
# model = Prospect |
||||||
|
# fields = ['entity_name', 'first_name', 'last_name', 'email', |
||||||
|
# 'phone', 'address', 'zip_code', 'city'] |
||||||
|
|
||||||
|
# class BulkEmailForm(forms.Form): |
||||||
|
# prospects = forms.ModelMultipleChoiceField( |
||||||
|
# queryset=Prospect.objects.all(), |
||||||
|
# widget=forms.CheckboxSelectMultiple |
||||||
|
# ) |
||||||
|
# subject = forms.CharField(max_length=200) |
||||||
|
# content = forms.CharField(widget=forms.Textarea) |
||||||
|
|
||||||
|
# class EventForm(forms.ModelForm): |
||||||
|
# prospects = forms.ModelMultipleChoiceField( |
||||||
|
# queryset=Prospect.objects.all(), |
||||||
|
# widget=forms.SelectMultiple(attrs={'class': 'select2'}), |
||||||
|
# required=False |
||||||
|
# ) |
||||||
|
# description = forms.CharField(widget=SmallTextArea) |
||||||
|
# attachment_text = forms.CharField(widget=SmallTextArea) |
||||||
|
|
||||||
|
# class Meta: |
||||||
|
# model = Event |
||||||
|
# fields = ['date', 'type', 'description', 'attachment_text', 'prospects', 'status'] |
||||||
|
# widgets = { |
||||||
|
# 'date': forms.DateTimeInput(attrs={'type': 'datetime-local'}), |
||||||
|
# } |
||||||
|
|
||||||
|
class FileImportForm(forms.Form): |
||||||
|
source = forms.CharField(max_length=200) |
||||||
|
file = forms.FileField( |
||||||
|
label='Select file to import', |
||||||
|
help_text='Choose a file to upload and process', |
||||||
|
widget=forms.FileInput(attrs={'accept': '.csv,.xlsx,.xls,.txt'}) |
||||||
|
) |
||||||
|
|
||||||
|
class CSVImportForm(forms.Form): |
||||||
|
csv_file = forms.FileField() |
||||||
|
|
||||||
|
class EmailTemplateSelectionForm(forms.Form): |
||||||
|
email_template = forms.ModelChoiceField( |
||||||
|
queryset=EmailTemplate.objects.all(), |
||||||
|
empty_label="Select an email template...", |
||||||
|
widget=forms.Select(attrs={'class': 'form-control'}) |
||||||
|
) |
||||||
@ -0,0 +1,103 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-07-20 10:20 |
||||||
|
|
||||||
|
import django.db.models.deletion |
||||||
|
import django.utils.timezone |
||||||
|
import uuid |
||||||
|
from django.conf import settings |
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
initial = True |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.CreateModel( |
||||||
|
name='Activity', |
||||||
|
fields=[ |
||||||
|
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||||
|
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||||
|
('data_access_ids', models.JSONField(default=list)), |
||||||
|
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), |
||||||
|
('status', models.CharField(blank=True, choices=[('NONE', 'None'), ('INBOUND', 'Inbound'), ('CONTACTED', 'Contacted'), ('RESPONDED', 'Responded'), ('SHOULD_TEST', 'Should test'), ('TESTING', 'Testing'), ('CUSTOMER', 'Customer'), ('LOST', 'Lost customer'), ('DECLINED', 'Declined'), ('DECLINED_UNRELATED', 'Declined without significance')], max_length=50, null=True)), |
||||||
|
('declination_reason', models.CharField(blank=True, choices=[('TOO_EXPENSIVE', 'Too expensive'), ('USE_OTHER_PRODUCT', 'Use other product'), ('USE_ANDROID', 'Use Android'), ('UNKNOWN', 'Unknown')], max_length=50, null=True)), |
||||||
|
('type', models.CharField(blank=True, choices=[('MAIL', 'Mail'), ('SMS', 'SMS'), ('CALL', 'Call'), ('PRESS', 'Press Release'), ('WORD_OF_MOUTH', 'Word of mouth')], max_length=20, null=True)), |
||||||
|
('description', models.TextField(blank=True, null=True)), |
||||||
|
('attachment_text', models.TextField(blank=True, null=True)), |
||||||
|
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'verbose_name_plural': 'Activities', |
||||||
|
'ordering': ['-creation_date'], |
||||||
|
}, |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='EmailTemplate', |
||||||
|
fields=[ |
||||||
|
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||||
|
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||||
|
('data_access_ids', models.JSONField(default=list)), |
||||||
|
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), |
||||||
|
('name', models.CharField(max_length=100)), |
||||||
|
('subject', models.CharField(max_length=200)), |
||||||
|
('body', models.TextField(blank=True, null=True)), |
||||||
|
('activities', models.ManyToManyField(blank=True, related_name='email_templates', to='biz.activity')), |
||||||
|
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'abstract': False, |
||||||
|
}, |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='Entity', |
||||||
|
fields=[ |
||||||
|
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||||
|
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||||
|
('data_access_ids', models.JSONField(default=list)), |
||||||
|
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), |
||||||
|
('name', models.CharField(blank=True, max_length=200, null=True)), |
||||||
|
('address', models.CharField(blank=True, max_length=200, null=True)), |
||||||
|
('zip_code', models.CharField(blank=True, max_length=20, null=True)), |
||||||
|
('city', models.CharField(blank=True, max_length=500, null=True)), |
||||||
|
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
('official_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), |
||||||
|
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'verbose_name_plural': 'Entities', |
||||||
|
}, |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='Prospect', |
||||||
|
fields=[ |
||||||
|
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||||
|
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||||
|
('data_access_ids', models.JSONField(default=list)), |
||||||
|
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), |
||||||
|
('first_name', models.CharField(blank=True, max_length=200, null=True)), |
||||||
|
('last_name', models.CharField(blank=True, max_length=200, null=True)), |
||||||
|
('email', models.EmailField(max_length=254, unique=True)), |
||||||
|
('phone', models.CharField(blank=True, max_length=25, null=True)), |
||||||
|
('name_unsure', models.BooleanField(default=False)), |
||||||
|
('source', models.CharField(blank=True, max_length=100, null=True)), |
||||||
|
('entities', models.ManyToManyField(blank=True, related_name='prospects', to='biz.entity')), |
||||||
|
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
('official_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), |
||||||
|
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'abstract': False, |
||||||
|
}, |
||||||
|
), |
||||||
|
migrations.AddField( |
||||||
|
model_name='activity', |
||||||
|
name='prospects', |
||||||
|
field=models.ManyToManyField(related_name='activities', to='biz.prospect'), |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-07-31 15:56 |
||||||
|
|
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('biz', '0001_initial'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.AlterField( |
||||||
|
model_name='prospect', |
||||||
|
name='email', |
||||||
|
field=models.EmailField(blank=True, max_length=254, null=True, unique=True), |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-08-07 16:51 |
||||||
|
|
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('biz', '0002_alter_prospect_email'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.AlterField( |
||||||
|
model_name='activity', |
||||||
|
name='status', |
||||||
|
field=models.CharField(blank=True, choices=[('NONE', 'None'), ('INBOUND', 'Inbound'), ('CONTACTED', 'Contacted'), ('RESPONDED', 'Responded'), ('SHOULD_TEST', 'Should test'), ('TESTING', 'Testing'), ('CUSTOMER', 'Customer'), ('LOST', 'Lost customer'), ('DECLINED', 'Declined'), ('NOT_CONCERNED', 'Not concerned'), ('SHOULD_BUY', 'Should buy')], max_length=50, null=True), |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-09-04 12:42 |
||||||
|
|
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('biz', '0003_alter_activity_status'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.AddField( |
||||||
|
model_name='prospect', |
||||||
|
name='contact_again', |
||||||
|
field=models.DateTimeField(blank=True, null=True), |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,38 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-09-22 12:34 |
||||||
|
|
||||||
|
import django.db.models.deletion |
||||||
|
import django.utils.timezone |
||||||
|
from django.conf import settings |
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('biz', '0004_prospect_contact_again'), |
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.AlterField( |
||||||
|
model_name='activity', |
||||||
|
name='status', |
||||||
|
field=models.CharField(blank=True, choices=[('NONE', 'None'), ('INBOUND', 'Inbound'), ('CONTACTED', 'Contacted'), ('RESPONDED', 'Responded'), ('SHOULD_TEST', 'Should test'), ('TESTING', 'Testing'), ('CUSTOMER', 'Customer'), ('LOST', 'Lost customer'), ('DECLINED', 'Declined'), ('NOT_CONCERNED', 'Not concerned'), ('SHOULD_BUY', 'Should buy'), ('HAVE_CREATED_ACCOUNT', 'Have created account')], max_length=50, null=True), |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='Campaign', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||||
|
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||||
|
('data_access_ids', models.JSONField(default=list)), |
||||||
|
('name', models.CharField(blank=True, max_length=200, null=True)), |
||||||
|
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
('prospects', models.ManyToManyField(blank=True, related_name='campaigns', to='biz.prospect')), |
||||||
|
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'abstract': False, |
||||||
|
}, |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-09-22 13:10 |
||||||
|
|
||||||
|
import uuid |
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('biz', '0005_alter_activity_status_campaign'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
# migrations.AlterField( |
||||||
|
# model_name='campaign', |
||||||
|
# name='id', |
||||||
|
# field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False), |
||||||
|
# ), |
||||||
|
] |
||||||
@ -0,0 +1,37 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-09-22 14:08 |
||||||
|
|
||||||
|
import django.db.models.deletion |
||||||
|
import django.utils.timezone |
||||||
|
import uuid |
||||||
|
from django.conf import settings |
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('biz', '0006_alter_campaign_id'), |
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.CreateModel( |
||||||
|
name='ProspectGroup', |
||||||
|
fields=[ |
||||||
|
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)), |
||||||
|
('last_update', models.DateTimeField(default=django.utils.timezone.now)), |
||||||
|
('data_access_ids', models.JSONField(default=list)), |
||||||
|
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), |
||||||
|
('name', models.CharField(blank=True, max_length=200, null=True)), |
||||||
|
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
('prospects', models.ManyToManyField(blank=True, related_name='prospect_groups', to='biz.prospect')), |
||||||
|
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), |
||||||
|
], |
||||||
|
options={ |
||||||
|
'abstract': False, |
||||||
|
}, |
||||||
|
), |
||||||
|
migrations.DeleteModel( |
||||||
|
name='Campaign', |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
# Generated by Django 5.1 on 2025-10-15 07:46 |
||||||
|
|
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('biz', '0007_prospectgroup_delete_campaign'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.AlterField( |
||||||
|
model_name='activity', |
||||||
|
name='declination_reason', |
||||||
|
field=models.CharField(blank=True, choices=[('TOO_EXPENSIVE', 'Too expensive'), ('USE_OTHER_PRODUCT', 'Use other product'), ('USE_ANDROID', 'Use Android'), ('TOO_FEW_TOURNAMENTS', 'Too few tournaments'), ('NOT_INTERESTED', 'Not interested'), ('UNKNOWN', 'Unknown')], max_length=50, null=True), |
||||||
|
), |
||||||
|
migrations.AlterField( |
||||||
|
model_name='activity', |
||||||
|
name='type', |
||||||
|
field=models.CharField(blank=True, choices=[('MAIL', 'Mail'), ('SMS', 'SMS'), ('CALL', 'Call'), ('PRESS', 'Press Release'), ('WORD_OF_MOUTH', 'Word of mouth'), ('WHATS_APP', 'WhatsApp')], max_length=20, null=True), |
||||||
|
), |
||||||
|
] |
||||||
@ -1,6 +1,6 @@ |
|||||||
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin |
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin |
||||||
from django.core.exceptions import PermissionDenied |
from django.core.exceptions import PermissionDenied |
||||||
|
|
||||||
class CRMAccessMixin(LoginRequiredMixin, UserPassesTestMixin): |
class bizAccessMixin(LoginRequiredMixin, UserPassesTestMixin): |
||||||
def test_func(self): |
def test_func(self): |
||||||
return self.request.user.groups.filter(name='CRM Manager').exists() |
return self.request.user.groups.filter(name='biz Manager').exists() |
||||||
@ -0,0 +1,221 @@ |
|||||||
|
from typing import Self |
||||||
|
from django.db import models |
||||||
|
from django.contrib.auth import get_user_model |
||||||
|
|
||||||
|
from django.db.models.signals import m2m_changed |
||||||
|
from django.dispatch import receiver |
||||||
|
from django.utils import timezone |
||||||
|
|
||||||
|
import uuid |
||||||
|
|
||||||
|
from sync.models import BaseModel |
||||||
|
|
||||||
|
User = get_user_model() |
||||||
|
|
||||||
|
class Status(models.TextChoices): |
||||||
|
NONE = 'NONE', 'None' |
||||||
|
INBOUND = 'INBOUND', 'Inbound' |
||||||
|
CONTACTED = 'CONTACTED', 'Contacted' |
||||||
|
RESPONDED = 'RESPONDED', 'Responded' |
||||||
|
SHOULD_TEST = 'SHOULD_TEST', 'Should test' |
||||||
|
TESTING = 'TESTING', 'Testing' |
||||||
|
CUSTOMER = 'CUSTOMER', 'Customer' |
||||||
|
LOST = 'LOST', 'Lost customer' |
||||||
|
DECLINED = 'DECLINED', 'Declined' |
||||||
|
# DECLINED_UNRELATED = 'DECLINED_UNRELATED', 'Declined without significance' |
||||||
|
NOT_CONCERNED = 'NOT_CONCERNED', 'Not concerned' |
||||||
|
SHOULD_BUY = 'SHOULD_BUY', 'Should buy' |
||||||
|
HAVE_CREATED_ACCOUNT = 'HAVE_CREATED_ACCOUNT', 'Have created account' |
||||||
|
|
||||||
|
class DeclinationReason(models.TextChoices): |
||||||
|
TOO_EXPENSIVE = 'TOO_EXPENSIVE', 'Too expensive' |
||||||
|
USE_OTHER_PRODUCT = 'USE_OTHER_PRODUCT', 'Use other product' |
||||||
|
USE_ANDROID = 'USE_ANDROID', 'Use Android' |
||||||
|
TOO_FEW_TOURNAMENTS = 'TOO_FEW_TOURNAMENTS', 'Too few tournaments' |
||||||
|
NOT_INTERESTED = 'NOT_INTERESTED', 'Not interested' |
||||||
|
UNKNOWN = 'UNKNOWN', 'Unknown' |
||||||
|
|
||||||
|
class ActivityType(models.TextChoices): |
||||||
|
MAIL = 'MAIL', 'Mail' |
||||||
|
SMS = 'SMS', 'SMS' |
||||||
|
CALL = 'CALL', 'Call' |
||||||
|
PRESS = 'PRESS', 'Press Release' |
||||||
|
WORD_OF_MOUTH = 'WORD_OF_MOUTH', 'Word of mouth' |
||||||
|
WHATS_APP = 'WHATS_APP', 'WhatsApp' |
||||||
|
|
||||||
|
class Entity(BaseModel): |
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) |
||||||
|
name = models.CharField(max_length=200, null=True, blank=True) |
||||||
|
address = models.CharField(max_length=200, null=True, blank=True) |
||||||
|
zip_code = models.CharField(max_length=20, null=True, blank=True) |
||||||
|
city = models.CharField(max_length=500, null=True, blank=True) |
||||||
|
official_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) |
||||||
|
# status = models.IntegerField(default=Status.NONE, choices=Status.choices) |
||||||
|
|
||||||
|
def delete_dependencies(self): |
||||||
|
pass |
||||||
|
|
||||||
|
class Meta: |
||||||
|
verbose_name_plural = "Entities" |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return self.name |
||||||
|
|
||||||
|
class Prospect(BaseModel): |
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) |
||||||
|
first_name = models.CharField(max_length=200, null=True, blank=True) |
||||||
|
last_name = models.CharField(max_length=200, null=True, blank=True) |
||||||
|
email = models.EmailField(unique=True, null=True, blank=True) |
||||||
|
phone = models.CharField(max_length=25, null=True, blank=True) |
||||||
|
name_unsure = models.BooleanField(default=False) |
||||||
|
official_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) |
||||||
|
|
||||||
|
entities = models.ManyToManyField(Entity, blank=True, related_name='prospects') |
||||||
|
source = models.CharField(max_length=100, null=True, blank=True) |
||||||
|
contact_again = models.DateTimeField(null=True, blank=True) |
||||||
|
|
||||||
|
def delete_dependencies(self): |
||||||
|
pass |
||||||
|
|
||||||
|
# class Meta: |
||||||
|
# permissions = [ |
||||||
|
# ("manage_prospects", "Can manage prospects"), |
||||||
|
# ("view_prospects", "Can view prospects"), |
||||||
|
# ] |
||||||
|
|
||||||
|
def current_status(self): |
||||||
|
last_activity = self.activities.exclude(status=None).order_by('-creation_date').first() |
||||||
|
if last_activity: |
||||||
|
return last_activity.status |
||||||
|
return Status.NONE |
||||||
|
|
||||||
|
def current_activity_type(self): |
||||||
|
last_activity = self.activities.exclude(type=None).order_by('-creation_date').first() |
||||||
|
if last_activity: |
||||||
|
return last_activity.type |
||||||
|
return None |
||||||
|
|
||||||
|
def current_text(self): |
||||||
|
last_activity = self.activities.exclude(status=None).order_by('-creation_date').first() |
||||||
|
if last_activity: |
||||||
|
return last_activity.attachment_text |
||||||
|
return '' |
||||||
|
|
||||||
|
def current_declination_reason(self): |
||||||
|
last_activity = self.activities.exclude(status=None).order_by('-creation_date').first() |
||||||
|
if last_activity: |
||||||
|
return last_activity.declination_reason |
||||||
|
return None |
||||||
|
|
||||||
|
def entity_names(self): |
||||||
|
entity_names = [entity.name for entity in self.entities.all()] |
||||||
|
return " - ".join(entity_names) |
||||||
|
|
||||||
|
def full_name(self): |
||||||
|
if self.first_name and self.last_name: |
||||||
|
return f'{self.first_name} {self.last_name}' |
||||||
|
elif self.first_name: |
||||||
|
return self.first_name |
||||||
|
elif self.last_name: |
||||||
|
return self.last_name |
||||||
|
else: |
||||||
|
return 'no name' |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return self.full_name() |
||||||
|
|
||||||
|
class Activity(BaseModel): |
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) |
||||||
|
status = models.CharField(max_length=50, choices=Status.choices, null=True, blank=True) |
||||||
|
declination_reason = models.CharField(max_length=50, choices=DeclinationReason.choices, null=True, blank=True) |
||||||
|
type = models.CharField(max_length=20, choices=ActivityType.choices, null=True, blank=True) |
||||||
|
description = models.TextField(null=True, blank=True) |
||||||
|
attachment_text = models.TextField(null=True, blank=True) |
||||||
|
prospects = models.ManyToManyField(Prospect, related_name='activities') |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
if self.status: |
||||||
|
return self.status |
||||||
|
elif self.type: |
||||||
|
return self.type |
||||||
|
else: |
||||||
|
return f'desc = {self.description}, attachment_text = {self.attachment_text}' |
||||||
|
|
||||||
|
def delete_dependencies(self): |
||||||
|
pass |
||||||
|
|
||||||
|
def save(self, *args, **kwargs): |
||||||
|
super().save(*args, **kwargs) |
||||||
|
# Update last_update for all related prospects when activity is saved |
||||||
|
self.prospects.update(last_update=timezone.now()) |
||||||
|
|
||||||
|
class Meta: |
||||||
|
verbose_name_plural = "Activities" |
||||||
|
ordering = ['-creation_date'] |
||||||
|
|
||||||
|
# def __str__(self): |
||||||
|
# return f"{self.get_type_display()} - {self.creation_date.date()}" |
||||||
|
|
||||||
|
def html_desc(self): |
||||||
|
fields = [field for field in [self.creation_date.strftime("%d/%m/%Y %H:%M"), self.status, self.declination_reason, self.attachment_text, self.description, self.type] if field is not None] |
||||||
|
html = '<table><tr>' |
||||||
|
for field in fields: |
||||||
|
html += f'<td style="padding:0px 5px;">{field}</td>' |
||||||
|
html += '</tr></table>' |
||||||
|
return html |
||||||
|
|
||||||
|
def prospect_names(self): |
||||||
|
prospect_names = [prospect.full_name() for prospect in self.prospects.all()] |
||||||
|
return ", ".join(prospect_names) |
||||||
|
|
||||||
|
@receiver(m2m_changed, sender=Activity.prospects.through) |
||||||
|
def update_prospect_last_update(sender, instance, action, pk_set, **kwargs): |
||||||
|
instance.prospects.update(last_update=timezone.now(),contact_again=None) |
||||||
|
|
||||||
|
class EmailTemplate(BaseModel): |
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) |
||||||
|
name = models.CharField(max_length=100) |
||||||
|
subject = models.CharField(max_length=200) |
||||||
|
body = models.TextField(null=True, blank=True) |
||||||
|
activities = models.ManyToManyField(Activity, blank=True, related_name='email_templates') |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return self.name |
||||||
|
|
||||||
|
def delete_dependencies(self): |
||||||
|
pass |
||||||
|
|
||||||
|
class ProspectGroup(BaseModel): |
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) |
||||||
|
name = models.CharField(max_length=200, null=True, blank=True) |
||||||
|
prospects = models.ManyToManyField(Prospect, blank=True, related_name='prospect_groups') |
||||||
|
|
||||||
|
def user_count(self): |
||||||
|
return self.prospects.count() |
||||||
|
|
||||||
|
def __str__(self): |
||||||
|
return self.name |
||||||
|
|
||||||
|
def delete_dependencies(self): |
||||||
|
pass |
||||||
|
|
||||||
|
# class EmailCampaign(models.Model): |
||||||
|
# event = models.OneToOneField(Event, on_delete=models.CASCADE) |
||||||
|
# subject = models.CharField(max_length=200) |
||||||
|
# content = models.TextField() |
||||||
|
# sent_at = models.DateTimeField(null=True, blank=True) |
||||||
|
|
||||||
|
# class EmailTracker(models.Model): |
||||||
|
# campaign = models.ForeignKey(EmailCampaign, on_delete=models.CASCADE) |
||||||
|
# prospect = models.ForeignKey(Prospect, on_delete=models.CASCADE) |
||||||
|
# tracking_id = models.UUIDField(default=uuid.uuid4, editable=False) |
||||||
|
# sent = models.BooleanField(default=False) |
||||||
|
# sent_at = models.DateTimeField(null=True, blank=True) |
||||||
|
# opened = models.BooleanField(default=False) |
||||||
|
# opened_at = models.DateTimeField(null=True, blank=True) |
||||||
|
# clicked = models.BooleanField(default=False) |
||||||
|
# clicked_at = models.DateTimeField(null=True, blank=True) |
||||||
|
# error_message = models.TextField(blank=True) |
||||||
|
|
||||||
|
# class Meta: |
||||||
|
# unique_together = ['campaign', 'prospect'] |
||||||
@ -0,0 +1,448 @@ |
|||||||
|
{% extends "admin/base_site.html" %} |
||||||
|
{% load static %} |
||||||
|
|
||||||
|
{% block extrahead %} |
||||||
|
{{ block.super }} |
||||||
|
<style> |
||||||
|
.dashboard-container { |
||||||
|
padding: 20px; |
||||||
|
} |
||||||
|
|
||||||
|
.filter-switch { |
||||||
|
margin-bottom: 20px; |
||||||
|
padding: 15px; |
||||||
|
background: #f8f8f8; |
||||||
|
border: 1px solid #ddd; |
||||||
|
border-radius: 4px; |
||||||
|
} |
||||||
|
|
||||||
|
.filter-switch label { |
||||||
|
font-weight: bold; |
||||||
|
margin-right: 10px; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.filter-switch input[type="checkbox"] { |
||||||
|
cursor: pointer; |
||||||
|
width: 18px; |
||||||
|
height: 18px; |
||||||
|
vertical-align: middle; |
||||||
|
} |
||||||
|
|
||||||
|
.status-section { |
||||||
|
margin-bottom: 30px; |
||||||
|
background: white; |
||||||
|
border: 1px solid #ddd; |
||||||
|
border-radius: 4px; |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
|
||||||
|
.status-header { |
||||||
|
background: #417690; |
||||||
|
color: white; |
||||||
|
padding: 12px 15px; |
||||||
|
font-weight: bold; |
||||||
|
font-size: 14px; |
||||||
|
} |
||||||
|
|
||||||
|
.status-header .status-name { |
||||||
|
font-size: 16px; |
||||||
|
margin-right: 10px; |
||||||
|
} |
||||||
|
|
||||||
|
.status-header .count { |
||||||
|
font-size: 13px; |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table { |
||||||
|
width: 100%; |
||||||
|
border-collapse: collapse; |
||||||
|
table-layout: fixed; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table thead { |
||||||
|
background: #f9f9f9; |
||||||
|
border-bottom: 2px solid #ddd; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table thead th { |
||||||
|
padding: 10px 12px; |
||||||
|
text-align: left; |
||||||
|
font-weight: 600; |
||||||
|
font-size: 13px; |
||||||
|
color: #666; |
||||||
|
border-bottom: 1px solid #ddd; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table th:nth-child(1), |
||||||
|
.prospect-table td:nth-child(1) { |
||||||
|
width: 225px; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table th:nth-child(2), |
||||||
|
.prospect-table td:nth-child(2) { |
||||||
|
width: auto; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table th:nth-child(3), |
||||||
|
.prospect-table td:nth-child(3) { |
||||||
|
width: 120px; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table th:nth-child(4), |
||||||
|
.prospect-table td:nth-child(4) { |
||||||
|
width: 140px; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table th:nth-child(5), |
||||||
|
.prospect-table td:nth-child(5) { |
||||||
|
width: 130px; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table th:nth-child(6), |
||||||
|
.prospect-table td:nth-child(6) { |
||||||
|
width: 130px; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table th.actions-col, |
||||||
|
.prospect-table td.actions-col { |
||||||
|
width: 80px; |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
|
||||||
|
.add-activity-btn { |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
width: 24px; |
||||||
|
height: 24px; |
||||||
|
background: #70bf2b; |
||||||
|
color: white !important; |
||||||
|
text-decoration: none !important; |
||||||
|
border-radius: 50%; |
||||||
|
font-size: 18px; |
||||||
|
font-weight: bold; |
||||||
|
transition: background-color 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.add-activity-btn:hover { |
||||||
|
background: #5fa624; |
||||||
|
color: white !important; |
||||||
|
text-decoration: none !important; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table tbody tr { |
||||||
|
border-bottom: 1px solid #eee; |
||||||
|
transition: background-color 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table tbody tr:hover { |
||||||
|
background: #f5f5f5; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table tbody td { |
||||||
|
padding: 10px 12px; |
||||||
|
font-size: 13px; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table tbody td a { |
||||||
|
color: #417690; |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-table tbody td a:hover { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-name { |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-entity { |
||||||
|
color: #666; |
||||||
|
font-style: italic; |
||||||
|
overflow: hidden; |
||||||
|
text-overflow: ellipsis; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-date { |
||||||
|
color: #666; |
||||||
|
white-space: nowrap; |
||||||
|
} |
||||||
|
|
||||||
|
.prospect-status { |
||||||
|
display: inline-block; |
||||||
|
padding: 3px 8px; |
||||||
|
background: #e8f4f8; |
||||||
|
border-radius: 3px; |
||||||
|
font-size: 11px; |
||||||
|
color: #417690; |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
.empty-state { |
||||||
|
padding: 40px 15px; |
||||||
|
text-align: center; |
||||||
|
color: #999; |
||||||
|
font-style: italic; |
||||||
|
} |
||||||
|
</style> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="dashboard-container"> |
||||||
|
|
||||||
|
<!-- Quick Actions --> |
||||||
|
<div class="quick-actions" style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 12px; padding: 25px; margin-bottom: 20px;"> |
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 15px;"> |
||||||
|
<a href="{% url 'admin:biz_prospect_changelist' %}" |
||||||
|
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;"> |
||||||
|
Prospects |
||||||
|
</a> |
||||||
|
<a href="{% url 'admin:biz_activity_changelist' %}" |
||||||
|
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;"> |
||||||
|
Activities |
||||||
|
</a> |
||||||
|
<a href="{% url 'admin:biz_entity_changelist' %}" |
||||||
|
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;"> |
||||||
|
Entities |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="filter-switch"> |
||||||
|
<label for="my-prospects-toggle"> |
||||||
|
<input type="checkbox" id="my-prospects-toggle" {% if filter_my %}checked{% endif %}> |
||||||
|
Show only my prospects |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- CONTACT AGAIN Section --> |
||||||
|
<div class="status-section"> |
||||||
|
<div class="status-header"> |
||||||
|
<span class="status-name">CONTACT AGAIN</span> |
||||||
|
<span class="count">({{ contact_again_prospects.count }})</span> |
||||||
|
</div> |
||||||
|
{% if contact_again_prospects %} |
||||||
|
<table class="prospect-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Name</th> |
||||||
|
<th>Entity</th> |
||||||
|
<th>Phone</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Contact Again</th> |
||||||
|
<th>Last Update</th> |
||||||
|
<th class="actions-col">Actions</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for prospect in contact_again_prospects %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name"> |
||||||
|
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td class="prospect-entity">{{ prospect.entity_names }}</td> |
||||||
|
<td>{{ prospect.phone|default:"-" }}</td> |
||||||
|
<td><span class="prospect-status">{{ prospect.current_status }}</span></td> |
||||||
|
<td class="prospect-date">{{ prospect.contact_again|date:"d/m/Y" }}</td> |
||||||
|
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td> |
||||||
|
<td class="actions-col"> |
||||||
|
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
{% else %} |
||||||
|
<div class="empty-state">No prospects</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- SHOULD_TEST Section --> |
||||||
|
<div class="status-section"> |
||||||
|
<div class="status-header"> |
||||||
|
<span class="status-name">SHOULD TEST</span> |
||||||
|
<span class="count">({{ should_test_prospects.count }})</span> |
||||||
|
</div> |
||||||
|
{% if should_test_prospects %} |
||||||
|
<table class="prospect-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Name</th> |
||||||
|
<th>Entity</th> |
||||||
|
<th>Phone</th> |
||||||
|
<th>Activity Type</th> |
||||||
|
<th>Last Update</th> |
||||||
|
<th class="actions-col">Actions</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for prospect in should_test_prospects %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name"> |
||||||
|
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td class="prospect-entity">{{ prospect.entity_names }}</td> |
||||||
|
<td>{{ prospect.phone|default:"-" }}</td> |
||||||
|
<td>{{ prospect.current_activity_type|default:"-" }}</td> |
||||||
|
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td> |
||||||
|
<td class="actions-col"> |
||||||
|
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
{% else %} |
||||||
|
<div class="empty-state">No prospects</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- TESTING Section --> |
||||||
|
<div class="status-section"> |
||||||
|
<div class="status-header"> |
||||||
|
<span class="status-name">TESTING</span> |
||||||
|
<span class="count">({{ testing_prospects.count }})</span> |
||||||
|
</div> |
||||||
|
{% if testing_prospects %} |
||||||
|
<table class="prospect-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Name</th> |
||||||
|
<th>Entity</th> |
||||||
|
<th>Phone</th> |
||||||
|
<th>Activity Type</th> |
||||||
|
<th>Last Update</th> |
||||||
|
<th class="actions-col">Actions</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for prospect in testing_prospects %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name"> |
||||||
|
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td class="prospect-entity">{{ prospect.entity_names }}</td> |
||||||
|
<td>{{ prospect.phone|default:"-" }}</td> |
||||||
|
<td>{{ prospect.current_activity_type|default:"-" }}</td> |
||||||
|
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td> |
||||||
|
<td class="actions-col"> |
||||||
|
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
{% else %} |
||||||
|
<div class="empty-state">No prospects</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- OTHERS Section --> |
||||||
|
<div class="status-section"> |
||||||
|
<div class="status-header"> |
||||||
|
<span class="status-name">OTHERS</span> |
||||||
|
<span class="count">({{ others_prospects.count }})</span> |
||||||
|
</div> |
||||||
|
{% if others_prospects %} |
||||||
|
<table class="prospect-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Name</th> |
||||||
|
<th>Entity</th> |
||||||
|
<th>Phone</th> |
||||||
|
<th>Status</th> |
||||||
|
<th>Activity Type</th> |
||||||
|
<th>Last Update</th> |
||||||
|
<th class="actions-col">Actions</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for prospect in others_prospects %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name"> |
||||||
|
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td class="prospect-entity">{{ prospect.entity_names }}</td> |
||||||
|
<td>{{ prospect.phone|default:"-" }}</td> |
||||||
|
<td><span class="prospect-status">{{ prospect.current_status }}</span></td> |
||||||
|
<td>{{ prospect.current_activity_type|default:"-" }}</td> |
||||||
|
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td> |
||||||
|
<td class="actions-col"> |
||||||
|
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
{% else %} |
||||||
|
<div class="empty-state">No prospects</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- RESPONDED Section --> |
||||||
|
<div class="status-section"> |
||||||
|
<div class="status-header"> |
||||||
|
<span class="status-name">RESPONDED</span> |
||||||
|
<span class="count">({{ responded_prospects.count }})</span> |
||||||
|
</div> |
||||||
|
{% if responded_prospects %} |
||||||
|
<table class="prospect-table"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Name</th> |
||||||
|
<th>Entity</th> |
||||||
|
<th>Phone</th> |
||||||
|
<th>Activity Type</th> |
||||||
|
<th>Last Update</th> |
||||||
|
<th class="actions-col">Actions</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for prospect in responded_prospects %} |
||||||
|
<tr> |
||||||
|
<td> |
||||||
|
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name"> |
||||||
|
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }} |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
<td class="prospect-entity">{{ prospect.entity_names }}</td> |
||||||
|
<td>{{ prospect.phone|default:"-" }}</td> |
||||||
|
<td>{{ prospect.current_activity_type|default:"-" }}</td> |
||||||
|
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td> |
||||||
|
<td class="actions-col"> |
||||||
|
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
{% else %} |
||||||
|
<div class="empty-state">No prospects</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
document.getElementById('my-prospects-toggle').addEventListener('change', function(e) { |
||||||
|
const url = new URL(window.location); |
||||||
|
if (e.target.checked) { |
||||||
|
url.searchParams.set('my', 'true'); |
||||||
|
} else { |
||||||
|
url.searchParams.delete('my'); |
||||||
|
} |
||||||
|
window.location.href = url.toString(); |
||||||
|
}); |
||||||
|
</script> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,81 @@ |
|||||||
|
{% extends "admin/base_site.html" %} |
||||||
|
{% load i18n admin_urls static admin_list %} |
||||||
|
|
||||||
|
{% block title %}Email Users{% endblock %} |
||||||
|
|
||||||
|
{% block breadcrumbs %} |
||||||
|
<div class="breadcrumbs"> |
||||||
|
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a> |
||||||
|
› Email Users |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="module filtered"> |
||||||
|
<h2>Filter Users for Email</h2> |
||||||
|
|
||||||
|
<form method="post" action="{% url 'admin:email_users' %}"> |
||||||
|
{% csrf_token %} |
||||||
|
|
||||||
|
<div class="form-row"> |
||||||
|
<div class="field-box"> |
||||||
|
<label for="user_origin">User Origin:</label> |
||||||
|
<select name="user_origin" id="user_origin" class="vTextField"> |
||||||
|
<option value="">All Origins</option> |
||||||
|
{% for choice in user_origin_choices %} |
||||||
|
<option value="{{ choice.0 }}" {% if choice.0 == selected_origin %}selected{% endif %}> |
||||||
|
{{ choice.1 }} |
||||||
|
</option> |
||||||
|
{% endfor %} |
||||||
|
</select> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-row"> |
||||||
|
<div class="field-box"> |
||||||
|
<label for="has_purchase"> |
||||||
|
<input type="checkbox" name="has_purchase" id="has_purchase" value="1" |
||||||
|
{% if has_purchase %}checked{% endif %}> |
||||||
|
User has made a purchase |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-row"> |
||||||
|
<input type="submit" value="Filter Users" class="default" name="_filter"> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
|
||||||
|
{% if filtered_users %} |
||||||
|
<div class="results"> |
||||||
|
<h3>Filtered Users ({{ filtered_users|length }} found)</h3> |
||||||
|
<div class="module"> |
||||||
|
<table cellspacing="0"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Email</th> |
||||||
|
<th>Origin</th> |
||||||
|
<th>Has Purchase</th> |
||||||
|
<th>Date Joined</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for user in filtered_users %} |
||||||
|
<tr class="{% cycle 'row1' 'row2' %}"> |
||||||
|
<td>{{ user.email }}</td> |
||||||
|
<td>{{ user.get_origin_display }}</td> |
||||||
|
<td>{{ user.has_purchase|yesno:"Yes,No" }}</td> |
||||||
|
<td>{{ user.date_joined|date:"M d, Y" }}</td> |
||||||
|
</tr> |
||||||
|
{% empty %} |
||||||
|
<tr> |
||||||
|
<td colspan="4">No users found matching criteria.</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
{% extends "admin/change_list.html" %} |
||||||
|
|
||||||
|
{% block object-tools-items %} |
||||||
|
{{ block.super }} |
||||||
|
<li> |
||||||
|
<a href="{% url 'admin:biz_dashboard' %}" class="viewlink" style="margin-right: 5px;">Dashboard</a> |
||||||
|
<a href="{% url 'admin:import_file' %}" class="addlink" style="margin-right: 5px;">Import</a> |
||||||
|
<a href="{% url 'admin:import_app_users' %}" class="addlink" style="margin-right: 5px;">Import App Users</a> |
||||||
|
<!--<a href="{% url 'admin:cleanup' %}" class="deletelink" style="margin-right: 5px;">Reset</a>--> |
||||||
|
</li> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
<!-- templates/admin/import_file.html --> |
||||||
|
{% extends "admin/base_site.html" %} |
||||||
|
{% load i18n static %} |
||||||
|
|
||||||
|
{% block title %}{% trans 'Import File' %}{% endblock %} |
||||||
|
|
||||||
|
{% block breadcrumbs %} |
||||||
|
<div class="breadcrumbs"> |
||||||
|
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a> |
||||||
|
› {% trans 'Import File' %} |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="module"> |
||||||
|
<form method="post" enctype="multipart/form-data" novalidate> |
||||||
|
{% csrf_token %} |
||||||
|
|
||||||
|
<div class="form-row"> |
||||||
|
<div class="field-box"> |
||||||
|
{{ form.source.label_tag }} |
||||||
|
{{ form.source }} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="field-box"> |
||||||
|
{{ form.file.label_tag }} |
||||||
|
{{ form.file }} |
||||||
|
{% if form.file.help_text %} |
||||||
|
<div class="help">{{ form.file.help_text }}</div> |
||||||
|
{% endif %} |
||||||
|
{% if form.file.errors %} |
||||||
|
<ul class="errorlist"> |
||||||
|
{% for error in form.file.errors %} |
||||||
|
<li>{{ error }}</li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="submit-row"> |
||||||
|
<input type="submit" value="{% trans 'Import File' %}" class="default" /> |
||||||
|
<a href="{% url 'admin:index' %}" class="button cancel-link">{% trans 'Cancel' %}</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="module"> |
||||||
|
<h2>{% trans 'Instructions' %}</h2> |
||||||
|
<p>{% trans 'Select a file to import and click "Import File" to process it.' %}</p> |
||||||
|
<p>{% trans 'Supported file formats: CSV, Excel (XLSX, XLS), and Text files.' %}</p> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
{% extends "admin/base_site.html" %} |
||||||
|
{% load i18n admin_urls %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div id="content-main"> |
||||||
|
<form action="" method="post"> |
||||||
|
{% csrf_token %} |
||||||
|
<h2>{{ title }}</h2> |
||||||
|
|
||||||
|
<p>You have selected the following prospects:</p> |
||||||
|
<ul> |
||||||
|
{% for prospect in prospects %} |
||||||
|
<li>{{ prospect.name }} ({{ prospect.email }})</li> |
||||||
|
<input type="hidden" name="_selected_action" value="{{ prospect.pk }}" /> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
|
||||||
|
<fieldset class="module aligned"> |
||||||
|
<h2>Select an email template:</h2> |
||||||
|
{{ form.as_p }} |
||||||
|
</fieldset> |
||||||
|
|
||||||
|
<div class="submit-row"> |
||||||
|
<input type="hidden" name="action" value="send_email" /> |
||||||
|
<input type="submit" name="apply" value="Send Email" class="default" /> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,58 @@ |
|||||||
|
{% extends "biz/base.html" %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="container padding-bottom"> |
||||||
|
<div class="grid-x padding-bottom"> |
||||||
|
<div class="cell medium-6 large-6 padding10 bubble"> |
||||||
|
<h1 class="title">Add New Prospect</h1> |
||||||
|
|
||||||
|
<form method="post"> |
||||||
|
{% csrf_token %} |
||||||
|
<div class="form-group"> |
||||||
|
<label for="entity_name">Entité (nom de club...):</label> |
||||||
|
<input type="text" name="entity_name" id="entity_name" /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-group"> |
||||||
|
<label for="first_name">Prénom:</label> |
||||||
|
<input type="text" name="first_name" id="first_name" /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-group"> |
||||||
|
<label for="last_name">Nom:</label> |
||||||
|
<input type="text" name="last_name" id="last_name" /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-group"> |
||||||
|
<label for="email">Email:</label> |
||||||
|
<input type="email" name="email" id="email" required /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-group"> |
||||||
|
<label for="phone">Téléphone:</label> |
||||||
|
<input type="text" name="phone" id="phone" /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-group"> |
||||||
|
<label for="address">Adresse:</label> |
||||||
|
<input type="text" name="address" id="address" /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-group"> |
||||||
|
<label for="zip_code">Code postal:</label> |
||||||
|
<input type="text" name="zip_code" id="zip_code" /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-group"> |
||||||
|
<label for="city">Ville:</label> |
||||||
|
<input type="text" name="city" id="city" /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<button type="submit" class="small-button margin-v20"> |
||||||
|
Save Prospect |
||||||
|
</button> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,34 @@ |
|||||||
|
{% extends "biz/base.html" %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="container mt-4"> |
||||||
|
<h2>Import Prospects from CSV</h2> |
||||||
|
|
||||||
|
<div class="card"> |
||||||
|
<div class="card-body"> |
||||||
|
<form method="post" enctype="multipart/form-data"> |
||||||
|
{% csrf_token %} |
||||||
|
|
||||||
|
<div class="mb-3"> |
||||||
|
{{ form.as_p }} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="alert alert-info"> |
||||||
|
<h5>CSV Format Requirements:</h5> |
||||||
|
<p>The CSV file should contain the following columns in order:</p> |
||||||
|
<ul> |
||||||
|
<li>Column 1: Club Code</li> |
||||||
|
<li>Column 2: Last Name</li> |
||||||
|
<li>Column 3: First Name</li> |
||||||
|
<li>Column 4: Email</li> |
||||||
|
<li>Column 9: ZIP Code</li> |
||||||
|
<li>Column 10: City</li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Import CSV</button> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
{% extends "biz/base.html" %} |
||||||
|
|
||||||
|
{% block head_title %}{{ first_title }}{% endblock %} |
||||||
|
{% block first_title %}{{ first_title }}{% endblock %} |
||||||
|
{% block second_title %}{{ second_title }}{% endblock %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
|
||||||
|
<div class="container padding-bottom bubble"><form method="post"> |
||||||
|
{% csrf_token %} |
||||||
|
{{ form.as_p }} |
||||||
|
<button class="small-button" type="submit"> |
||||||
|
{% if is_edit %}Update{% else %}Add{% endif %} Prospect |
||||||
|
</button> |
||||||
|
</form> |
||||||
|
|
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,81 @@ |
|||||||
|
{% extends "biz/base.html" %} |
||||||
|
|
||||||
|
{% load static %} |
||||||
|
|
||||||
|
{% block content %} |
||||||
|
<div class="container bubble"> |
||||||
|
<h2>Prospects</h2> |
||||||
|
|
||||||
|
<div class=""> |
||||||
|
<div class=""> |
||||||
|
|
||||||
|
<form method="get" class="filter-form"> |
||||||
|
{% for field in filter.form %} |
||||||
|
<div class="filter-group"> |
||||||
|
<label class="filter-label" for="{{ field.id_for_label }}">{{ field.label }}</label> |
||||||
|
{{ field }} |
||||||
|
</div> |
||||||
|
{% endfor %} |
||||||
|
<div class="filter-buttons"> |
||||||
|
<button type="submit" class="btn btn-primary">Filter</button> |
||||||
|
<a href="{% url 'biz:prospect-list' %}" class="btn btn-secondary">Clear</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- <div class="mb-3"> |
||||||
|
<a href="{% url 'biz:csv-import' %}" class="btn btn-success">Import CSV</a> |
||||||
|
<a href="{% url 'biz:send-bulk-email' %}" class="btn btn-primary">Send Email</a> |
||||||
|
</div> --> |
||||||
|
|
||||||
|
<span>{{ filter.qs|length }} résultats</span> |
||||||
|
|
||||||
|
<div class="table-responsive"> |
||||||
|
<table class="table table-striped"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th><input type="checkbox" id="select-all"></th> |
||||||
|
<th>Entité</th> |
||||||
|
<th>Prénom</th> |
||||||
|
<th>Nom</th> |
||||||
|
<th>Email</th> |
||||||
|
<th>Ville</th> |
||||||
|
<th>Statut</th> |
||||||
|
<th>Actions</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{% for prospect in filter.qs %} |
||||||
|
<tr> |
||||||
|
<td><input type="checkbox" name="selected_prospects" value="{{ prospect.id }}"></td> |
||||||
|
<td>{{ prospect.entity_name }}</td> |
||||||
|
<td>{{ prospect.first_name }}</td> |
||||||
|
<td>{{ prospect.last_name }}</td> |
||||||
|
<td><a href="mailto:{{ prospect.email }}">{{ prospect.email }}</a></td> |
||||||
|
<td>{{ prospect.city }} ({{ prospect.zip_code }})</td> |
||||||
|
<td> |
||||||
|
{% for status in prospect.prospectstatus_set.all %} |
||||||
|
<span class="badge bg-primary">{{ status.status.name }}</span> |
||||||
|
{% endfor %} |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
<a href="{% url 'biz:edit-prospect' prospect.id %}"> |
||||||
|
<button class="btn btn-sm btn-secondary">Edit</button> |
||||||
|
</a> |
||||||
|
|
||||||
|
<a href="{% url 'biz:add-event-for-prospect' prospect.id %}"> |
||||||
|
<button class="btn btn-sm btn-secondary">+ Event</button> |
||||||
|
</a> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{% endfor %} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block extra_js %} |
||||||
|
<script src="{% static 'biz/js/prospects.js' %}"></script> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
from django import template |
||||||
|
|
||||||
|
register = template.Library() |
||||||
|
|
||||||
|
@register.filter(name='is_biz_manager') |
||||||
|
def is_biz_manager(user): |
||||||
|
return user.groups.filter(name='biz Manager').exists() |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
from django.test import TestCase |
||||||
|
|
||||||
|
# Create your tests here. |
||||||
@ -1,15 +1,17 @@ |
|||||||
from django.urls import path |
from django.urls import path |
||||||
from . import views |
from . import views |
||||||
|
|
||||||
app_name = 'crm' |
app_name = 'biz' |
||||||
|
|
||||||
urlpatterns = [ |
urlpatterns = [ |
||||||
path('', views.EventListView.as_view(), name='planned_events'),path('', views.EventListView.as_view(), name='planned-events'), |
path('', views.EventListView.as_view(), name='planned_events'),path('', views.EventListView.as_view(), name='events'), |
||||||
path('events/add/', views.EventCreateView.as_view(), name='add_event'), |
path('events/add/', views.EventCreateView.as_view(), name='add-event'), |
||||||
|
path('events/add/<int:prospect_id>/', views.EventCreateView.as_view(), name='add-event-for-prospect'), |
||||||
path('events/<int:pk>/edit/', views.EditEventView.as_view(), name='edit_event'), |
path('events/<int:pk>/edit/', views.EditEventView.as_view(), name='edit_event'), |
||||||
path('events/<int:pk>/start/', views.StartEventView.as_view(), name='start_event'), |
path('events/<int:pk>/start/', views.StartEventView.as_view(), name='start_event'), |
||||||
path('prospects/', views.ProspectListView.as_view(), name='prospect-list'), |
path('prospects/', views.ProspectListView.as_view(), name='prospect-list'), |
||||||
path('add-prospect/', views.add_prospect, name='add_prospect'), |
path('prospect/add/', views.prospect_form, name='add-prospect'), |
||||||
path('prospects/import/', views.CSVImportView.as_view(), name='prospect-import'), |
path('prospect/<int:pk>/edit/', views.prospect_form, name='edit-prospect'), |
||||||
|
path('prospects/import/', views.CSVImportView.as_view(), name='csv-import'), |
||||||
path('email/send/', views.SendBulkEmailView.as_view(), name='send-bulk-email'), |
path('email/send/', views.SendBulkEmailView.as_view(), name='send-bulk-email'), |
||||||
] |
] |
||||||
@ -0,0 +1,284 @@ |
|||||||
|
# views.py |
||||||
|
from django.views.generic import FormView, ListView, DetailView, CreateView, UpdateView |
||||||
|
from django.views.generic.edit import FormView, BaseUpdateView |
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin |
||||||
|
from django.contrib.auth.decorators import permission_required |
||||||
|
from django.contrib import messages |
||||||
|
from django.shortcuts import render, redirect, get_object_or_404 |
||||||
|
from django.urls import reverse_lazy |
||||||
|
from django.http import HttpResponse, HttpResponseRedirect |
||||||
|
from django.views import View |
||||||
|
from django.utils import timezone |
||||||
|
from django.contrib.sites.shortcuts import get_current_site |
||||||
|
from django.template.loader import render_to_string |
||||||
|
from django.core.mail import send_mail |
||||||
|
from django.conf import settings |
||||||
|
from django.db import IntegrityError |
||||||
|
|
||||||
|
from .models import Event, Prospect, ActivityType |
||||||
|
from .filters import ProspectFilter |
||||||
|
from .forms import CSVImportForm |
||||||
|
|
||||||
|
from .mixins import bizAccessMixin |
||||||
|
|
||||||
|
import csv |
||||||
|
from io import TextIOWrapper |
||||||
|
from datetime import datetime |
||||||
|
|
||||||
|
# @permission_required('biz.view_biz', raise_exception=True) |
||||||
|
# def prospect_form(request, pk=None): |
||||||
|
# # Get the prospect instance if pk is provided (edit mode) |
||||||
|
# prospect = get_object_or_404(Prospect, pk=pk) if pk else None |
||||||
|
|
||||||
|
# if request.method == 'POST': |
||||||
|
# form = ProspectForm(request.POST, instance=prospect) |
||||||
|
# if form.is_valid(): |
||||||
|
# prospect = form.save(commit=False) |
||||||
|
# if not pk: # New prospect |
||||||
|
# prospect.created_by = request.user |
||||||
|
# prospect.modified_by = request.user |
||||||
|
# prospect.save() |
||||||
|
|
||||||
|
# action = 'updated' if pk else 'added' |
||||||
|
# messages.success(request, |
||||||
|
# f'Prospect {prospect.entity_name} has been {action} successfully!') |
||||||
|
# return redirect('biz:events') |
||||||
|
# else: |
||||||
|
# form = ProspectForm(instance=prospect) |
||||||
|
|
||||||
|
# context = { |
||||||
|
# 'form': form, |
||||||
|
# 'is_edit': prospect is not None, |
||||||
|
# 'first_title': prospect.entity_name if prospect else 'Add Prospect', |
||||||
|
# 'second_title': prospect.full_name() if prospect else None |
||||||
|
# } |
||||||
|
# return render(request, 'biz/prospect_form.html', context) |
||||||
|
|
||||||
|
# # @permission_required('biz.view_biz', raise_exception=True) |
||||||
|
# # def add_prospect(request): |
||||||
|
# # if request.method == 'POST': |
||||||
|
# # entity_name = request.POST.get('entity_name') |
||||||
|
# # first_name = request.POST.get('first_name') |
||||||
|
# # last_name = request.POST.get('last_name') |
||||||
|
# # email = request.POST.get('email') |
||||||
|
# # phone = request.POST.get('phone') |
||||||
|
# # address = request.POST.get('address') |
||||||
|
# # zip_code = request.POST.get('zip_code') |
||||||
|
# # city = request.POST.get('city') |
||||||
|
# # # region = request.POST.get('region') |
||||||
|
|
||||||
|
# # try: |
||||||
|
# # prospect = Prospect.objects.create( |
||||||
|
# # entity_name=entity_name, |
||||||
|
# # first_name=first_name, |
||||||
|
# # last_name=last_name, |
||||||
|
# # email=email, |
||||||
|
# # phone=phone, |
||||||
|
# # address=address, |
||||||
|
# # zip_code=zip_code, |
||||||
|
# # city=city, |
||||||
|
# # # region=region, |
||||||
|
# # created_by=request.user, |
||||||
|
# # modified_by=request.user |
||||||
|
# # ) |
||||||
|
# # messages.success(request, f'Prospect {name} has been added successfully!') |
||||||
|
# # return redirect('biz:events') # or wherever you want to redirect after success |
||||||
|
# # except Exception as e: |
||||||
|
# # messages.error(request, f'Error adding prospect: {str(e)}') |
||||||
|
|
||||||
|
# # return render(request, 'biz/add_prospect.html') |
||||||
|
|
||||||
|
# class EventCreateView(bizAccessMixin, CreateView): |
||||||
|
# model = Event |
||||||
|
# form_class = EventForm |
||||||
|
# template_name = 'biz/event_form.html' |
||||||
|
# success_url = reverse_lazy('biz:planned_events') |
||||||
|
|
||||||
|
# def get_initial(self): |
||||||
|
# initial = super().get_initial() |
||||||
|
# prospect_id = self.kwargs.get('prospect_id') |
||||||
|
# if prospect_id: |
||||||
|
# initial['prospects'] = [prospect_id] |
||||||
|
# return initial |
||||||
|
|
||||||
|
# def form_valid(self, form): |
||||||
|
# form.instance.created_by = self.request.user |
||||||
|
# form.instance.modified_by = self.request.user |
||||||
|
# return super().form_valid(form) |
||||||
|
|
||||||
|
# class EditEventView(bizAccessMixin, UpdateView): |
||||||
|
# model = Event |
||||||
|
# form_class = EventForm |
||||||
|
# template_name = 'biz/event_form.html' |
||||||
|
# success_url = reverse_lazy('biz:planned_events') |
||||||
|
|
||||||
|
# def form_valid(self, form): |
||||||
|
# form.instance.modified_by = self.request.user |
||||||
|
# response = super().form_valid(form) |
||||||
|
# messages.success(self.request, 'Event updated successfully!') |
||||||
|
# return response |
||||||
|
|
||||||
|
# class StartEventView(bizAccessMixin, BaseUpdateView): |
||||||
|
# model = Event |
||||||
|
# http_method_names = ['post', 'get'] |
||||||
|
|
||||||
|
# def get(self, request, *args, **kwargs): |
||||||
|
# return self.post(request, *args, **kwargs) |
||||||
|
|
||||||
|
# def post(self, request, *args, **kwargs): |
||||||
|
# event = get_object_or_404(Event, pk=kwargs['pk'], status='PLANNED') |
||||||
|
# event.status = 'ACTIVE' |
||||||
|
# event.save() |
||||||
|
|
||||||
|
# if event.type == 'MAIL': |
||||||
|
# return HttpResponseRedirect( |
||||||
|
# reverse_lazy('biz:setup_email_campaign', kwargs={'event_id': event.id}) |
||||||
|
# ) |
||||||
|
# elif event.type == 'SMS': |
||||||
|
# return HttpResponseRedirect( |
||||||
|
# reverse_lazy('biz:setup_sms_campaign', kwargs={'event_id': event.id}) |
||||||
|
# ) |
||||||
|
# elif event.type == 'PRESS': |
||||||
|
# return HttpResponseRedirect( |
||||||
|
# reverse_lazy('biz:setup_press_release', kwargs={'event_id': event.id}) |
||||||
|
# ) |
||||||
|
|
||||||
|
# messages.success(request, 'Event started successfully!') |
||||||
|
# return HttpResponseRedirect(reverse_lazy('biz:planned_events')) |
||||||
|
|
||||||
|
# class EventListView(bizAccessMixin, ListView): |
||||||
|
# model = Event |
||||||
|
# template_name = 'biz/events.html' |
||||||
|
# context_object_name = 'events' # We won't use this since we're providing custom context |
||||||
|
|
||||||
|
# def get_context_data(self, **kwargs): |
||||||
|
# context = super().get_context_data(**kwargs) |
||||||
|
# context['planned_events'] = Event.objects.filter( |
||||||
|
# status='PLANNED' |
||||||
|
# ).order_by('date') |
||||||
|
# context['completed_events'] = Event.objects.filter( |
||||||
|
# status='COMPLETED' |
||||||
|
# ).order_by('-date') |
||||||
|
# return context |
||||||
|
|
||||||
|
# class ProspectListView(bizAccessMixin, ListView): |
||||||
|
# model = Prospect |
||||||
|
# template_name = 'biz/prospect_list.html' |
||||||
|
# context_object_name = 'prospects' |
||||||
|
# filterset_class = ProspectFilter |
||||||
|
|
||||||
|
# def get_queryset(self): |
||||||
|
# return super().get_queryset().prefetch_related('prospectstatus_set__status') |
||||||
|
|
||||||
|
# def get_context_data(self, **kwargs): |
||||||
|
# context = super().get_context_data(**kwargs) |
||||||
|
# context['filter'] = self.filterset_class( |
||||||
|
# self.request.GET, |
||||||
|
# queryset=self.get_queryset() |
||||||
|
# ) |
||||||
|
# return context |
||||||
|
|
||||||
|
# class CSVImportView(bizAccessMixin, FormView): |
||||||
|
# template_name = 'biz/csv_import.html' |
||||||
|
# form_class = CSVImportForm |
||||||
|
# success_url = reverse_lazy('prospect-list') |
||||||
|
|
||||||
|
# def form_valid(self, form): |
||||||
|
# csv_file = TextIOWrapper( |
||||||
|
# form.cleaned_data['csv_file'].file, |
||||||
|
# encoding='utf-8-sig' # Handle potential BOM in CSV |
||||||
|
# ) |
||||||
|
# reader = csv.reader(csv_file, delimiter=';') # Using semicolon delimiter |
||||||
|
|
||||||
|
# # Skip header if exists |
||||||
|
# next(reader, None) |
||||||
|
|
||||||
|
# created_count = 0 |
||||||
|
# updated_count = 0 |
||||||
|
# error_count = 0 |
||||||
|
|
||||||
|
# for row in reader: |
||||||
|
# try: |
||||||
|
# if len(row) < 10: # Ensure we have enough columns |
||||||
|
# continue |
||||||
|
|
||||||
|
# # Extract data from correct columns |
||||||
|
# entity_name = row[0].strip() |
||||||
|
# last_name = row[1].strip() |
||||||
|
# first_name = row[2].strip() |
||||||
|
# email = row[3].strip() |
||||||
|
# phone = row[4].strip() |
||||||
|
# zip_code = row[8].strip() |
||||||
|
# city = row[9].strip() |
||||||
|
|
||||||
|
# # Try to update existing prospect or create new one |
||||||
|
# prospect, created = Prospect.objects.update_or_create( |
||||||
|
# email=email, # Use email as unique identifier |
||||||
|
# defaults={ |
||||||
|
# 'entity_name': entity_name, |
||||||
|
# 'first_name': first_name, |
||||||
|
# 'last_name': last_name, |
||||||
|
# 'phone': phone, |
||||||
|
# 'zip_code': zip_code, |
||||||
|
# 'city': city, |
||||||
|
# 'modified_by': self.request.user, |
||||||
|
# } |
||||||
|
# ) |
||||||
|
|
||||||
|
# if created: |
||||||
|
# prospect.created_by = self.request.user |
||||||
|
# prospect.save() |
||||||
|
# created_count += 1 |
||||||
|
# else: |
||||||
|
# updated_count += 1 |
||||||
|
|
||||||
|
# except Exception as e: |
||||||
|
# error_count += 1 |
||||||
|
# messages.error( |
||||||
|
# self.request, |
||||||
|
# f"Error processing row with email {email}: {str(e)}" |
||||||
|
# ) |
||||||
|
|
||||||
|
# # Add success message |
||||||
|
# messages.success( |
||||||
|
# self.request, |
||||||
|
# f"Import completed: {created_count} created, {updated_count} updated, {error_count} errors" |
||||||
|
# ) |
||||||
|
|
||||||
|
# return super().form_valid(form) |
||||||
|
|
||||||
|
# class SendBulkEmailView(bizAccessMixin, FormView): |
||||||
|
# template_name = 'biz/send_bulk_email.html' |
||||||
|
# form_class = BulkEmailForm |
||||||
|
# success_url = reverse_lazy('biz:prospect-list') |
||||||
|
|
||||||
|
# def form_valid(self, form): |
||||||
|
# prospects = form.cleaned_data['prospects'] |
||||||
|
# subject = form.cleaned_data['subject'] |
||||||
|
# content = form.cleaned_data['content'] |
||||||
|
|
||||||
|
# # Create Event for this email campaign |
||||||
|
# event = Event.objects.create( |
||||||
|
# date=datetime.now(), |
||||||
|
# type=EventType.MAILING, |
||||||
|
# description=f"Bulk email: {subject}", |
||||||
|
# status='COMPLETED', |
||||||
|
# created_by=self.request.user, |
||||||
|
# modified_by=self.request.user |
||||||
|
# ) |
||||||
|
# event.prospects.set(prospects) |
||||||
|
|
||||||
|
# # Send emails |
||||||
|
# success_count, error_count = send_bulk_email( |
||||||
|
# subject=subject, |
||||||
|
# content=content, |
||||||
|
# prospects=prospects |
||||||
|
# ) |
||||||
|
|
||||||
|
# # Show result message |
||||||
|
# messages.success( |
||||||
|
# self.request, |
||||||
|
# f"Sent {success_count} emails successfully. {error_count} failed." |
||||||
|
# ) |
||||||
|
|
||||||
|
# return super().form_valid(form) |
||||||
@ -1 +0,0 @@ |
|||||||
This is a django customer relationship managemement (CRM) app. |
|
||||||
@ -1,96 +0,0 @@ |
|||||||
from django.contrib import admin |
|
||||||
from django.utils.html import format_html |
|
||||||
from .models import ( |
|
||||||
Prospect, |
|
||||||
Status, |
|
||||||
ProspectStatus, |
|
||||||
Event, |
|
||||||
EmailCampaign, |
|
||||||
EmailTracker |
|
||||||
) |
|
||||||
|
|
||||||
@admin.register(Prospect) |
|
||||||
class ProspectAdmin(admin.ModelAdmin): |
|
||||||
list_display = ('name', 'email', 'region', 'created_at') |
|
||||||
list_filter = ('region', 'created_at') |
|
||||||
search_fields = ('name', 'email', 'region') |
|
||||||
filter_horizontal = ('users',) |
|
||||||
date_hierarchy = 'created_at' |
|
||||||
|
|
||||||
@admin.register(Status) |
|
||||||
class StatusAdmin(admin.ModelAdmin): |
|
||||||
list_display = ('name', 'created_at') |
|
||||||
search_fields = ('name',) |
|
||||||
|
|
||||||
@admin.register(ProspectStatus) |
|
||||||
class ProspectStatusAdmin(admin.ModelAdmin): |
|
||||||
list_display = ('prospect', 'status', 'created_at') |
|
||||||
list_filter = ('status', 'created_at') |
|
||||||
search_fields = ('prospect__name', 'prospect__email') |
|
||||||
date_hierarchy = 'created_at' |
|
||||||
|
|
||||||
@admin.register(Event) |
|
||||||
class EventAdmin(admin.ModelAdmin): |
|
||||||
list_display = ('get_event_display', 'type', 'date', 'status', 'created_at') |
|
||||||
list_filter = ('type', 'status', 'date') |
|
||||||
search_fields = ('description',) |
|
||||||
filter_horizontal = ('prospects',) |
|
||||||
date_hierarchy = 'date' |
|
||||||
|
|
||||||
def get_event_display(self, obj): |
|
||||||
return str(obj) |
|
||||||
get_event_display.short_description = 'Event' |
|
||||||
|
|
||||||
@admin.register(EmailCampaign) |
|
||||||
class EmailCampaignAdmin(admin.ModelAdmin): |
|
||||||
list_display = ('subject', 'event', 'sent_at') |
|
||||||
list_filter = ('sent_at',) |
|
||||||
search_fields = ('subject', 'content') |
|
||||||
date_hierarchy = 'sent_at' |
|
||||||
readonly_fields = ('sent_at',) |
|
||||||
|
|
||||||
@admin.register(EmailTracker) |
|
||||||
class EmailTrackerAdmin(admin.ModelAdmin): |
|
||||||
list_display = ( |
|
||||||
'campaign', |
|
||||||
'prospect', |
|
||||||
'tracking_id', |
|
||||||
'sent_status', |
|
||||||
'opened_status', |
|
||||||
'clicked_status' |
|
||||||
) |
|
||||||
list_filter = ('sent', 'opened', 'clicked') |
|
||||||
search_fields = ( |
|
||||||
'prospect__name', |
|
||||||
'prospect__email', |
|
||||||
'campaign__subject' |
|
||||||
) |
|
||||||
readonly_fields = ( |
|
||||||
'tracking_id', 'sent', 'sent_at', |
|
||||||
'opened', 'opened_at', |
|
||||||
'clicked', 'clicked_at' |
|
||||||
) |
|
||||||
date_hierarchy = 'sent_at' |
|
||||||
|
|
||||||
def sent_status(self, obj): |
|
||||||
return self._get_status_html(obj.sent, obj.sent_at) |
|
||||||
sent_status.short_description = 'Sent' |
|
||||||
sent_status.allow_tags = True |
|
||||||
|
|
||||||
def opened_status(self, obj): |
|
||||||
return self._get_status_html(obj.opened, obj.opened_at) |
|
||||||
opened_status.short_description = 'Opened' |
|
||||||
opened_status.allow_tags = True |
|
||||||
|
|
||||||
def clicked_status(self, obj): |
|
||||||
return self._get_status_html(obj.clicked, obj.clicked_at) |
|
||||||
clicked_status.short_description = 'Clicked' |
|
||||||
clicked_status.allow_tags = True |
|
||||||
|
|
||||||
def _get_status_html(self, status, date): |
|
||||||
if status: |
|
||||||
return format_html( |
|
||||||
'<span style="color: green;">✓</span> {}', |
|
||||||
date.strftime('%Y-%m-%d %H:%M') if date else '' |
|
||||||
) |
|
||||||
return format_html('<span style="color: red;">✗</span>') |
|
||||||
@ -1,18 +0,0 @@ |
|||||||
import django_filters |
|
||||||
|
|
||||||
from .models import Event, Status, Prospect |
|
||||||
|
|
||||||
class ProspectFilter(django_filters.FilterSet): |
|
||||||
region = django_filters.CharFilter(lookup_expr='icontains') |
|
||||||
events = django_filters.ModelMultipleChoiceFilter( |
|
||||||
queryset=Event.objects.all(), |
|
||||||
field_name='events', |
|
||||||
) |
|
||||||
statuses = django_filters.ModelMultipleChoiceFilter( |
|
||||||
queryset=Status.objects.all(), |
|
||||||
field_name='prospectstatus__status', |
|
||||||
) |
|
||||||
|
|
||||||
class Meta: |
|
||||||
model = Prospect |
|
||||||
fields = ['region', 'events', 'statuses'] |
|
||||||
@ -1,40 +0,0 @@ |
|||||||
from django import forms |
|
||||||
from .models import Prospect, Event |
|
||||||
import datetime |
|
||||||
|
|
||||||
class SmallTextArea(forms.Textarea): |
|
||||||
def __init__(self, *args, **kwargs): |
|
||||||
kwargs.setdefault('attrs', {}) |
|
||||||
kwargs['attrs'].update({ |
|
||||||
'rows': 2, |
|
||||||
'cols': 100, |
|
||||||
'style': 'height: 80px; width: 800px;' |
|
||||||
}) |
|
||||||
super().__init__(*args, **kwargs) |
|
||||||
|
|
||||||
class CSVImportForm(forms.Form): |
|
||||||
csv_file = forms.FileField() |
|
||||||
|
|
||||||
class BulkEmailForm(forms.Form): |
|
||||||
prospects = forms.ModelMultipleChoiceField( |
|
||||||
queryset=Prospect.objects.all(), |
|
||||||
widget=forms.CheckboxSelectMultiple |
|
||||||
) |
|
||||||
subject = forms.CharField(max_length=200) |
|
||||||
content = forms.CharField(widget=forms.Textarea) |
|
||||||
|
|
||||||
class EventForm(forms.ModelForm): |
|
||||||
prospects = forms.ModelMultipleChoiceField( |
|
||||||
queryset=Prospect.objects.all(), |
|
||||||
widget=forms.SelectMultiple(attrs={'class': 'select2'}), |
|
||||||
required=False |
|
||||||
) |
|
||||||
description = forms.CharField(widget=SmallTextArea) |
|
||||||
attachment_text = forms.CharField(widget=SmallTextArea) |
|
||||||
|
|
||||||
class Meta: |
|
||||||
model = Event |
|
||||||
fields = ['date', 'type', 'description', 'attachment_text', 'prospects', 'status'] |
|
||||||
widgets = { |
|
||||||
'date': forms.DateTimeInput(attrs={'type': 'datetime-local'}), |
|
||||||
} |
|
||||||
@ -1,94 +0,0 @@ |
|||||||
# Generated by Django 4.2.11 on 2024-12-08 15:10 |
|
||||||
|
|
||||||
from django.conf import settings |
|
||||||
from django.db import migrations, models |
|
||||||
import django.db.models.deletion |
|
||||||
import uuid |
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration): |
|
||||||
|
|
||||||
initial = True |
|
||||||
|
|
||||||
dependencies = [ |
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
|
||||||
] |
|
||||||
|
|
||||||
operations = [ |
|
||||||
migrations.CreateModel( |
|
||||||
name='Prospect', |
|
||||||
fields=[ |
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
||||||
('email', models.EmailField(max_length=254, unique=True)), |
|
||||||
('name', models.CharField(max_length=200)), |
|
||||||
('region', models.CharField(max_length=100)), |
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)), |
|
||||||
('users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)), |
|
||||||
], |
|
||||||
), |
|
||||||
migrations.CreateModel( |
|
||||||
name='Status', |
|
||||||
fields=[ |
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
||||||
('name', models.CharField(max_length=100, unique=True)), |
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)), |
|
||||||
], |
|
||||||
), |
|
||||||
migrations.CreateModel( |
|
||||||
name='ProspectStatus', |
|
||||||
fields=[ |
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)), |
|
||||||
('prospect', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='crm.prospect')), |
|
||||||
('status', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='crm.status')), |
|
||||||
], |
|
||||||
options={ |
|
||||||
'ordering': ['-created_at'], |
|
||||||
}, |
|
||||||
), |
|
||||||
migrations.CreateModel( |
|
||||||
name='Event', |
|
||||||
fields=[ |
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
||||||
('date', models.DateTimeField()), |
|
||||||
('type', models.CharField(choices=[('MAIL', 'Mailing List'), ('SMS', 'SMS Campaign'), ('PRESS', 'Press Release')], max_length=10)), |
|
||||||
('description', models.TextField()), |
|
||||||
('attachment_text', models.TextField(blank=True)), |
|
||||||
('status', models.CharField(choices=[('PLANNED', 'Planned'), ('ACTIVE', 'Active'), ('COMPLETED', 'Completed')], max_length=20)), |
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)), |
|
||||||
('prospects', models.ManyToManyField(related_name='events', to='crm.prospect')), |
|
||||||
], |
|
||||||
options={ |
|
||||||
'ordering': ['-created_at'], |
|
||||||
}, |
|
||||||
), |
|
||||||
migrations.CreateModel( |
|
||||||
name='EmailCampaign', |
|
||||||
fields=[ |
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
||||||
('subject', models.CharField(max_length=200)), |
|
||||||
('content', models.TextField()), |
|
||||||
('sent_at', models.DateTimeField(blank=True, null=True)), |
|
||||||
('event', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='crm.event')), |
|
||||||
], |
|
||||||
), |
|
||||||
migrations.CreateModel( |
|
||||||
name='EmailTracker', |
|
||||||
fields=[ |
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|
||||||
('tracking_id', models.UUIDField(default=uuid.uuid4, editable=False)), |
|
||||||
('sent', models.BooleanField(default=False)), |
|
||||||
('sent_at', models.DateTimeField(blank=True, null=True)), |
|
||||||
('opened', models.BooleanField(default=False)), |
|
||||||
('opened_at', models.DateTimeField(blank=True, null=True)), |
|
||||||
('clicked', models.BooleanField(default=False)), |
|
||||||
('clicked_at', models.DateTimeField(blank=True, null=True)), |
|
||||||
('error_message', models.TextField(blank=True)), |
|
||||||
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='crm.emailcampaign')), |
|
||||||
('prospect', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='crm.prospect')), |
|
||||||
], |
|
||||||
options={ |
|
||||||
'unique_together': {('campaign', 'prospect')}, |
|
||||||
}, |
|
||||||
), |
|
||||||
] |
|
||||||
@ -1,60 +0,0 @@ |
|||||||
# Generated by Django 4.2.11 on 2024-12-08 20:58 |
|
||||||
|
|
||||||
from django.conf import settings |
|
||||||
from django.db import migrations, models |
|
||||||
import django.db.models.deletion |
|
||||||
import django.utils.timezone |
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration): |
|
||||||
|
|
||||||
dependencies = [ |
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
|
||||||
('crm', '0001_initial'), |
|
||||||
] |
|
||||||
|
|
||||||
operations = [ |
|
||||||
migrations.AlterModelOptions( |
|
||||||
name='event', |
|
||||||
options={'ordering': ['-created_at'], 'permissions': [('manage_events', 'Can manage events'), ('view_events', 'Can view events')]}, |
|
||||||
), |
|
||||||
migrations.AlterModelOptions( |
|
||||||
name='prospect', |
|
||||||
options={'permissions': [('manage_prospects', 'Can manage prospects'), ('view_prospects', 'Can view prospects')]}, |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='event', |
|
||||||
name='created_by', |
|
||||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_events', to=settings.AUTH_USER_MODEL), |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='event', |
|
||||||
name='modified_at', |
|
||||||
field=models.DateTimeField(auto_now=True), |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='event', |
|
||||||
name='modified_by', |
|
||||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_events', to=settings.AUTH_USER_MODEL), |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='prospect', |
|
||||||
name='created_by', |
|
||||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_prospects', to=settings.AUTH_USER_MODEL), |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='prospect', |
|
||||||
name='modified_at', |
|
||||||
field=models.DateTimeField(auto_now=True), |
|
||||||
), |
|
||||||
migrations.AddField( |
|
||||||
model_name='prospect', |
|
||||||
name='modified_by', |
|
||||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_prospects', to=settings.AUTH_USER_MODEL), |
|
||||||
), |
|
||||||
migrations.AlterField( |
|
||||||
model_name='event', |
|
||||||
name='date', |
|
||||||
field=models.DateTimeField(default=django.utils.timezone.now), |
|
||||||
), |
|
||||||
] |
|
||||||
@ -1,112 +0,0 @@ |
|||||||
from django.db import models |
|
||||||
from django.contrib.auth import get_user_model |
|
||||||
from django.utils import timezone |
|
||||||
import uuid |
|
||||||
|
|
||||||
User = get_user_model() |
|
||||||
|
|
||||||
class EventType(models.TextChoices): |
|
||||||
MAILING = 'MAIL', 'Mailing List' |
|
||||||
SMS = 'SMS', 'SMS Campaign' |
|
||||||
PRESS = 'PRESS', 'Press Release' |
|
||||||
|
|
||||||
class Prospect(models.Model): |
|
||||||
email = models.EmailField(unique=True) |
|
||||||
name = models.CharField(max_length=200) |
|
||||||
region = models.CharField(max_length=100) |
|
||||||
users = models.ManyToManyField(get_user_model(), blank=True) |
|
||||||
created_at = models.DateTimeField(auto_now_add=True) |
|
||||||
created_by = models.ForeignKey( |
|
||||||
User, |
|
||||||
on_delete=models.SET_NULL, |
|
||||||
null=True, |
|
||||||
related_name='created_prospects' |
|
||||||
) |
|
||||||
modified_by = models.ForeignKey( |
|
||||||
User, |
|
||||||
on_delete=models.SET_NULL, |
|
||||||
null=True, |
|
||||||
related_name='modified_prospects' |
|
||||||
) |
|
||||||
modified_at = models.DateTimeField(auto_now=True) |
|
||||||
|
|
||||||
class Meta: |
|
||||||
permissions = [ |
|
||||||
("manage_prospects", "Can manage prospects"), |
|
||||||
("view_prospects", "Can view prospects"), |
|
||||||
] |
|
||||||
|
|
||||||
def __str__(self): |
|
||||||
return f"{self.name} ({self.email})" |
|
||||||
|
|
||||||
class Status(models.Model): |
|
||||||
name = models.CharField(max_length=100, unique=True) |
|
||||||
created_at = models.DateTimeField(auto_now_add=True) |
|
||||||
|
|
||||||
def __str__(self): |
|
||||||
return self.name |
|
||||||
|
|
||||||
class ProspectStatus(models.Model): |
|
||||||
prospect = models.ForeignKey(Prospect, on_delete=models.CASCADE) |
|
||||||
status = models.ForeignKey(Status, on_delete=models.PROTECT) |
|
||||||
created_at = models.DateTimeField(auto_now_add=True) |
|
||||||
|
|
||||||
class Meta: |
|
||||||
ordering = ['-created_at'] |
|
||||||
|
|
||||||
class Event(models.Model): |
|
||||||
date = models.DateTimeField(default=timezone.now) |
|
||||||
type = models.CharField(max_length=10, choices=EventType.choices) |
|
||||||
description = models.TextField() |
|
||||||
attachment_text = models.TextField(blank=True) |
|
||||||
prospects = models.ManyToManyField(Prospect, related_name='events') |
|
||||||
status = models.CharField(max_length=20, choices=[ |
|
||||||
('PLANNED', 'Planned'), |
|
||||||
('ACTIVE', 'Active'), |
|
||||||
('COMPLETED', 'Completed'), |
|
||||||
]) |
|
||||||
created_at = models.DateTimeField(auto_now_add=True) |
|
||||||
created_by = models.ForeignKey( |
|
||||||
User, |
|
||||||
on_delete=models.SET_NULL, |
|
||||||
null=True, |
|
||||||
related_name='created_events' |
|
||||||
) |
|
||||||
modified_by = models.ForeignKey( |
|
||||||
User, |
|
||||||
on_delete=models.SET_NULL, |
|
||||||
null=True, |
|
||||||
related_name='modified_events' |
|
||||||
) |
|
||||||
modified_at = models.DateTimeField(auto_now=True) |
|
||||||
|
|
||||||
class Meta: |
|
||||||
ordering = ['-created_at'] |
|
||||||
permissions = [ |
|
||||||
("manage_events", "Can manage events"), |
|
||||||
("view_events", "Can view events"), |
|
||||||
] |
|
||||||
|
|
||||||
def __str__(self): |
|
||||||
return f"{self.get_type_display()} - {self.date.date()}" |
|
||||||
|
|
||||||
class EmailCampaign(models.Model): |
|
||||||
event = models.OneToOneField(Event, on_delete=models.CASCADE) |
|
||||||
subject = models.CharField(max_length=200) |
|
||||||
content = models.TextField() |
|
||||||
sent_at = models.DateTimeField(null=True, blank=True) |
|
||||||
|
|
||||||
class EmailTracker(models.Model): |
|
||||||
campaign = models.ForeignKey(EmailCampaign, on_delete=models.CASCADE) |
|
||||||
prospect = models.ForeignKey(Prospect, on_delete=models.CASCADE) |
|
||||||
tracking_id = models.UUIDField(default=uuid.uuid4, editable=False) |
|
||||||
sent = models.BooleanField(default=False) |
|
||||||
sent_at = models.DateTimeField(null=True, blank=True) |
|
||||||
opened = models.BooleanField(default=False) |
|
||||||
opened_at = models.DateTimeField(null=True, blank=True) |
|
||||||
clicked = models.BooleanField(default=False) |
|
||||||
clicked_at = models.DateTimeField(null=True, blank=True) |
|
||||||
error_message = models.TextField(blank=True) |
|
||||||
|
|
||||||
class Meta: |
|
||||||
unique_together = ['campaign', 'prospect'] |
|
||||||
@ -1,10 +0,0 @@ |
|||||||
document.addEventListener("DOMContentLoaded", function () { |
|
||||||
const selectAll = document.getElementById("select-all"); |
|
||||||
const prospectCheckboxes = document.getElementsByName("selected_prospects"); |
|
||||||
|
|
||||||
selectAll.addEventListener("change", function () { |
|
||||||
prospectCheckboxes.forEach((checkbox) => { |
|
||||||
checkbox.checked = selectAll.checked; |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
||||||
@ -1,31 +0,0 @@ |
|||||||
{% extends "crm/base.html" %} {% block content %} |
|
||||||
<div class="container padding-bottom"> |
|
||||||
<div class="grid-x padding-bottom"> |
|
||||||
<div class="cell medium-6 large-6 my-block bubble"> |
|
||||||
<h1 class="title">Add New Prospect</h1> |
|
||||||
|
|
||||||
<form method="post"> |
|
||||||
{% csrf_token %} |
|
||||||
<div class="form-group"> |
|
||||||
<label for="name">Name:</label> |
|
||||||
<input type="text" name="name" id="name" required /> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="form-group"> |
|
||||||
<label for="email">Email:</label> |
|
||||||
<input type="email" name="email" id="email" required /> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="form-group"> |
|
||||||
<label for="region">Region:</label> |
|
||||||
<input type="text" name="region" id="region" required /> |
|
||||||
</div> |
|
||||||
|
|
||||||
<button type="submit" class="small-button margin-v20"> |
|
||||||
Save Prospect |
|
||||||
</button> |
|
||||||
</form> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
{% endblock %} |
|
||||||
@ -1,57 +0,0 @@ |
|||||||
{% extends "crm/base.html" %} |
|
||||||
|
|
||||||
{% block content %} |
|
||||||
<div class="container bubble"> |
|
||||||
<h2>Prospects</h2> |
|
||||||
|
|
||||||
<div class="card mb-4"> |
|
||||||
<div class="card-body"> |
|
||||||
<form method="get" class="row g-3"> |
|
||||||
{{ filter.form }} |
|
||||||
<div class="col-12"> |
|
||||||
<button type="submit" class="btn btn-primary">Filter</button> |
|
||||||
<a href="{% url 'crm:prospect-list' %}" class="btn btn-secondary">Clear</a> |
|
||||||
</div> |
|
||||||
</form> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="mb-3"> |
|
||||||
<a href="{% url 'crm:prospect-import' %}" class="btn btn-success">Import CSV</a> |
|
||||||
<a href="{% url 'crm:send-bulk-email' %}" class="btn btn-primary">Send Email</a> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="table-responsive"> |
|
||||||
<table class="table table-striped"> |
|
||||||
<thead> |
|
||||||
<tr> |
|
||||||
<th><input type="checkbox" id="select-all"></th> |
|
||||||
<th>Name</th> |
|
||||||
<th>Email</th> |
|
||||||
<th>Region</th> |
|
||||||
<th>Status</th> |
|
||||||
<th>Actions</th> |
|
||||||
</tr> |
|
||||||
</thead> |
|
||||||
<tbody> |
|
||||||
{% for prospect in prospects %} |
|
||||||
<tr> |
|
||||||
<td><input type="checkbox" name="selected_prospects" value="{{ prospect.id }}"></td> |
|
||||||
<td>{{ prospect.name }}</td> |
|
||||||
<td>{{ prospect.email }}</td> |
|
||||||
<td>{{ prospect.region }}</td> |
|
||||||
<td> |
|
||||||
{% for status in prospect.prospectstatus_set.all %} |
|
||||||
<span class="badge bg-primary">{{ status.status.name }}</span> |
|
||||||
{% endfor %} |
|
||||||
</td> |
|
||||||
<td> |
|
||||||
<button class="btn btn-sm btn-secondary">Edit</button> |
|
||||||
</td> |
|
||||||
</tr> |
|
||||||
{% endfor %} |
|
||||||
</tbody> |
|
||||||
</table> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
{% endblock %} |
|
||||||
@ -1,7 +0,0 @@ |
|||||||
from django import template |
|
||||||
|
|
||||||
register = template.Library() |
|
||||||
|
|
||||||
@register.filter(name='is_crm_manager') |
|
||||||
def is_crm_manager(user): |
|
||||||
return user.groups.filter(name='CRM Manager').exists() |
|
||||||
@ -1,186 +0,0 @@ |
|||||||
# views.py |
|
||||||
from django.views.generic import FormView, ListView, DetailView, CreateView, UpdateView |
|
||||||
from django.views.generic.edit import FormView, BaseUpdateView |
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin |
|
||||||
from django.contrib.auth.decorators import permission_required |
|
||||||
from django.contrib import messages |
|
||||||
from django.shortcuts import render, redirect, get_object_or_404 |
|
||||||
from django.urls import reverse_lazy |
|
||||||
from django.http import HttpResponse, HttpResponseRedirect |
|
||||||
from django.views import View |
|
||||||
from django.utils import timezone |
|
||||||
from django.contrib.sites.shortcuts import get_current_site |
|
||||||
from django.template.loader import render_to_string |
|
||||||
from django.core.mail import send_mail |
|
||||||
from django.conf import settings |
|
||||||
|
|
||||||
from .models import Event, Prospect, EmailTracker, EmailCampaign, EventType |
|
||||||
from .filters import ProspectFilter |
|
||||||
from .forms import CSVImportForm, BulkEmailForm, EventForm |
|
||||||
|
|
||||||
from .mixins import CRMAccessMixin |
|
||||||
|
|
||||||
import csv |
|
||||||
from io import TextIOWrapper |
|
||||||
from datetime import datetime |
|
||||||
|
|
||||||
@permission_required('crm.view_crm', raise_exception=True) |
|
||||||
def add_prospect(request): |
|
||||||
if request.method == 'POST': |
|
||||||
name = request.POST.get('name') |
|
||||||
email = request.POST.get('email') |
|
||||||
region = request.POST.get('region') |
|
||||||
|
|
||||||
try: |
|
||||||
prospect = Prospect.objects.create( |
|
||||||
name=name, |
|
||||||
email=email, |
|
||||||
region=region, |
|
||||||
created_by=request.user, |
|
||||||
modified_by=request.user |
|
||||||
) |
|
||||||
messages.success(request, f'Prospect {name} has been added successfully!') |
|
||||||
return redirect('crm:events') # or wherever you want to redirect after success |
|
||||||
except Exception as e: |
|
||||||
messages.error(request, f'Error adding prospect: {str(e)}') |
|
||||||
|
|
||||||
return render(request, 'crm/add_prospect.html') |
|
||||||
|
|
||||||
class EventCreateView(CRMAccessMixin, CreateView): |
|
||||||
model = Event |
|
||||||
form_class = EventForm |
|
||||||
template_name = 'crm/event_form.html' |
|
||||||
success_url = reverse_lazy('crm:planned_events') |
|
||||||
|
|
||||||
def form_valid(self, form): |
|
||||||
form.instance.created_by = self.request.user |
|
||||||
form.instance.modified_by = self.request.user |
|
||||||
return super().form_valid(form) |
|
||||||
|
|
||||||
class EditEventView(CRMAccessMixin, UpdateView): |
|
||||||
model = Event |
|
||||||
form_class = EventForm |
|
||||||
template_name = 'crm/event_form.html' |
|
||||||
success_url = reverse_lazy('crm:planned_events') |
|
||||||
|
|
||||||
def form_valid(self, form): |
|
||||||
form.instance.modified_by = self.request.user |
|
||||||
response = super().form_valid(form) |
|
||||||
messages.success(self.request, 'Event updated successfully!') |
|
||||||
return response |
|
||||||
|
|
||||||
class StartEventView(CRMAccessMixin, BaseUpdateView): |
|
||||||
model = Event |
|
||||||
http_method_names = ['post', 'get'] |
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs): |
|
||||||
return self.post(request, *args, **kwargs) |
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs): |
|
||||||
event = get_object_or_404(Event, pk=kwargs['pk'], status='PLANNED') |
|
||||||
event.status = 'ACTIVE' |
|
||||||
event.save() |
|
||||||
|
|
||||||
if event.type == 'MAIL': |
|
||||||
return HttpResponseRedirect( |
|
||||||
reverse_lazy('crm:setup_email_campaign', kwargs={'event_id': event.id}) |
|
||||||
) |
|
||||||
elif event.type == 'SMS': |
|
||||||
return HttpResponseRedirect( |
|
||||||
reverse_lazy('crm:setup_sms_campaign', kwargs={'event_id': event.id}) |
|
||||||
) |
|
||||||
elif event.type == 'PRESS': |
|
||||||
return HttpResponseRedirect( |
|
||||||
reverse_lazy('crm:setup_press_release', kwargs={'event_id': event.id}) |
|
||||||
) |
|
||||||
|
|
||||||
messages.success(request, 'Event started successfully!') |
|
||||||
return HttpResponseRedirect(reverse_lazy('crm:planned_events')) |
|
||||||
|
|
||||||
class EventListView(CRMAccessMixin, ListView): |
|
||||||
model = Event |
|
||||||
template_name = 'crm/events.html' |
|
||||||
context_object_name = 'events' # We won't use this since we're providing custom context |
|
||||||
|
|
||||||
def get_context_data(self, **kwargs): |
|
||||||
context = super().get_context_data(**kwargs) |
|
||||||
context['planned_events'] = Event.objects.filter( |
|
||||||
status='PLANNED' |
|
||||||
).order_by('date') |
|
||||||
context['completed_events'] = Event.objects.filter( |
|
||||||
status='COMPLETED' |
|
||||||
).order_by('-date') |
|
||||||
return context |
|
||||||
|
|
||||||
class ProspectListView(CRMAccessMixin, ListView): |
|
||||||
model = Prospect |
|
||||||
template_name = 'crm/prospect_list.html' |
|
||||||
context_object_name = 'prospects' |
|
||||||
filterset_class = ProspectFilter |
|
||||||
|
|
||||||
def get_context_data(self, **kwargs): |
|
||||||
context = super().get_context_data(**kwargs) |
|
||||||
context['filter'] = self.filterset_class( |
|
||||||
self.request.GET, |
|
||||||
queryset=self.get_queryset() |
|
||||||
) |
|
||||||
return context |
|
||||||
|
|
||||||
class CSVImportView(CRMAccessMixin, FormView): |
|
||||||
template_name = 'crm/csv_import.html' |
|
||||||
form_class = CSVImportForm |
|
||||||
success_url = reverse_lazy('prospect-list') |
|
||||||
|
|
||||||
def form_valid(self, form): |
|
||||||
csv_file = TextIOWrapper( |
|
||||||
form.cleaned_data['csv_file'].file, |
|
||||||
encoding='utf-8' |
|
||||||
) |
|
||||||
reader = csv.DictReader(csv_file) |
|
||||||
|
|
||||||
for row in reader: |
|
||||||
Prospect.objects.create( |
|
||||||
email=row['email'], |
|
||||||
name=row.get('name', ''), |
|
||||||
region=row.get('region', ''), |
|
||||||
created_by=self.request.user, |
|
||||||
modified_by=self.request.user |
|
||||||
) |
|
||||||
|
|
||||||
return super().form_valid(form) |
|
||||||
|
|
||||||
class SendBulkEmailView(CRMAccessMixin, FormView): |
|
||||||
template_name = 'crm/send_bulk_email.html' |
|
||||||
form_class = BulkEmailForm |
|
||||||
success_url = reverse_lazy('crm:prospect-list') |
|
||||||
|
|
||||||
def form_valid(self, form): |
|
||||||
prospects = form.cleaned_data['prospects'] |
|
||||||
subject = form.cleaned_data['subject'] |
|
||||||
content = form.cleaned_data['content'] |
|
||||||
|
|
||||||
# Create Event for this email campaign |
|
||||||
event = Event.objects.create( |
|
||||||
date=datetime.now(), |
|
||||||
type=EventType.MAILING, |
|
||||||
description=f"Bulk email: {subject}", |
|
||||||
status='COMPLETED', |
|
||||||
created_by=self.request.user, |
|
||||||
modified_by=self.request.user |
|
||||||
) |
|
||||||
event.prospects.set(prospects) |
|
||||||
|
|
||||||
# Send emails |
|
||||||
success_count, error_count = send_bulk_email( |
|
||||||
subject=subject, |
|
||||||
content=content, |
|
||||||
prospects=prospects |
|
||||||
) |
|
||||||
|
|
||||||
# Show result message |
|
||||||
messages.success( |
|
||||||
self.request, |
|
||||||
f"Sent {success_count} emails successfully. {error_count} failed." |
|
||||||
) |
|
||||||
|
|
||||||
return super().form_valid(form) |
|
||||||
@ -0,0 +1 @@ |
|||||||
|
|
||||||
@ -1,43 +1,54 @@ |
|||||||
|
|
||||||
# Rest Framework configuration |
# Rest Framework configuration |
||||||
REST_FRAMEWORK = { |
REST_FRAMEWORK = { |
||||||
|
"DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S.%f%z", |
||||||
# Use Django's standard `django.contrib.auth` permissions, |
# Use Django's standard `django.contrib.auth` permissions, |
||||||
# or allow read-only access for unauthenticated users. |
# or allow read-only access for unauthenticated users. |
||||||
'DEFAULT_PERMISSION_CLASSES': [ |
"DEFAULT_PERMISSION_CLASSES": [ |
||||||
'rest_framework.permissions.IsAuthenticated', |
"rest_framework.permissions.IsAuthenticated", |
||||||
|
], |
||||||
|
"DEFAULT_AUTHENTICATION_CLASSES": [ |
||||||
|
"rest_framework.authentication.BasicAuthentication", |
||||||
|
"rest_framework.authentication.TokenAuthentication", |
||||||
|
"rest_framework.authentication.SessionAuthentication", |
||||||
], |
], |
||||||
'DEFAULT_AUTHENTICATION_CLASSES': [ |
|
||||||
'rest_framework.authentication.BasicAuthentication', |
|
||||||
'rest_framework.authentication.TokenAuthentication', |
|
||||||
'rest_framework.authentication.SessionAuthentication', |
|
||||||
] |
|
||||||
} |
} |
||||||
|
|
||||||
# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' |
EMAIL_HOST_USER = "automatic@padelclub.app" |
||||||
# EMAIL_HOST = 'smtp-xlr.alwaysdata.net' |
EMAIL_HOST_PASSWORD = "XLR@Sport@2024" |
||||||
# EMAIL_PORT = 587 |
DEFAULT_FROM_EMAIL = "Padel Club <automatic@padelclub.app>" |
||||||
EMAIL_HOST_USER = 'automatic@padelclub.app' |
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" |
||||||
EMAIL_HOST_PASSWORD = 'XLR@Sport@2024' |
EMAIL_HOST = "smtp-xlr.alwaysdata.net" |
||||||
# EMAIL_USE_TLS = True |
|
||||||
DEFAULT_FROM_EMAIL = 'Padel Club <automatic@padelclub.app>' |
|
||||||
|
|
||||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' |
|
||||||
EMAIL_HOST = 'smtp-xlr.alwaysdata.net' |
|
||||||
EMAIL_PORT = 587 |
EMAIL_PORT = 587 |
||||||
#EMAIL_HOST_USER = 'xlr@alwaysdata.net' |
|
||||||
#EMAIL_HOST_PASSWORD = 'XLRSport$2024' |
|
||||||
EMAIL_USE_TLS = True |
EMAIL_USE_TLS = True |
||||||
#DEFAULT_FROM_EMAIL = 'Padel Club <xlr@alwaysdata.net>' |
|
||||||
|
|
||||||
CACHES = { |
CACHES = { |
||||||
'default': { |
"default": { |
||||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', |
"BACKEND": "django.core.cache.backends.locmem.LocMemCache", |
||||||
}, |
}, |
||||||
'qr-code': { |
"qr-code": { |
||||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', |
"BACKEND": "django.core.cache.backends.locmem.LocMemCache", |
||||||
'LOCATION': 'qr-code-cache', |
"LOCATION": "qr-code-cache", |
||||||
'TIMEOUT': 3600 |
"TIMEOUT": 3600, |
||||||
} |
}, |
||||||
|
} |
||||||
|
|
||||||
|
QR_CODE_CACHE_ALIAS = "qr-code" |
||||||
|
|
||||||
|
SYNC_APPS = { |
||||||
|
"sync": {}, |
||||||
|
"tournaments": {"exclude": ["Log", "FailedApiCall", "DeviceToken", "Image"]}, |
||||||
|
# 'biz': {}, |
||||||
|
} |
||||||
|
|
||||||
|
SYNC_MODEL_CHILDREN_SHARING = { |
||||||
|
"Match": ["team_scores", "team_registration", "player_registrations"] |
||||||
} |
} |
||||||
|
|
||||||
QR_CODE_CACHE_ALIAS = 'qr-code' |
STRIPE_CURRENCY = "eur" |
||||||
|
# Add managers who should receive internal emails |
||||||
|
SHOP_MANAGERS = [ |
||||||
|
("Shop Admin", "shop-admin@padelclub.app"), |
||||||
|
# ('Laurent Morvillier', 'laurent@padelclub.app'), |
||||||
|
] |
||||||
|
SHOP_SITE_ROOT_URL = "https://padelclub.app" |
||||||
|
SHOP_SUPPORT_EMAIL = "shop@padelclub.app" |
||||||
|
|||||||
@ -1,14 +1,22 @@ |
|||||||
Django==4.2.11 |
Django==5.1 |
||||||
djangorestframework==3.14.0 |
djangorestframework==3.14.0 |
||||||
psycopg2-binary==2.9.9 |
psycopg2-binary==2.9.9 |
||||||
dj-rest-auth==5.1.0 |
dj-rest-auth==6.0.0 |
||||||
django-qr-code==4.0.1 |
django-qr-code==4.0.1 |
||||||
pycryptodome==3.20.0 |
pycryptodome==3.20.0 |
||||||
requests==2.31.0 |
requests==2.31.0 |
||||||
PyJWT==2.8.0 |
PyJWT==2.8.0 |
||||||
httpx[http2]==0.27.0 |
httpx[http2]==0.27.0 |
||||||
|
channels[daphne]==4.1.0 |
||||||
|
twisted[http2,tls]==24.11.0 |
||||||
|
channels-redis==4.2.1 |
||||||
pandas==2.2.2 |
pandas==2.2.2 |
||||||
xlrd==2.0.1 |
xlrd==2.0.1 |
||||||
openpyxl==3.1.5 |
openpyxl==3.1.5 |
||||||
django-filter==24.3 |
django-filter==24.3 |
||||||
cryptography==41.0.7 |
cryptography==41.0.7 |
||||||
|
stripe==11.6.0 |
||||||
|
django-background-tasks==1.2.8 |
||||||
|
Pillow==10.2.0 |
||||||
|
playwright==1.40.0 |
||||||
|
djangorestframework-api-key==3.1.0 |
||||||
|
|||||||
|
unable to load file from base commit
|
@ -0,0 +1,414 @@ |
|||||||
|
from django.contrib import admin |
||||||
|
from django.shortcuts import render, redirect |
||||||
|
from django.utils.html import format_html |
||||||
|
from django.urls import path |
||||||
|
from django.http import HttpResponseRedirect |
||||||
|
from django import forms |
||||||
|
from django.db.models import Sum, Count, Avg |
||||||
|
from datetime import datetime, timedelta |
||||||
|
from django.utils import timezone |
||||||
|
|
||||||
|
from .models import ( |
||||||
|
Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage, |
||||||
|
OrderStatus, ShippingAddress |
||||||
|
) |
||||||
|
|
||||||
|
class ShopAdminSite(admin.AdminSite): |
||||||
|
site_header = "Shop Administration" |
||||||
|
site_title = "Shop Admin Portal" |
||||||
|
index_title = "Welcome to Shop Administration" |
||||||
|
|
||||||
|
def index(self, request, extra_context=None): |
||||||
|
"""Custom admin index view with dashboard""" |
||||||
|
# Calculate order statistics |
||||||
|
order_status_data = [] |
||||||
|
total_orders = Order.objects.count() |
||||||
|
total_revenue = Order.objects.aggregate(Sum('total_price'))['total_price__sum'] or 0 |
||||||
|
|
||||||
|
# Get data for each status |
||||||
|
for status_choice in OrderStatus.choices: |
||||||
|
status_code, status_label = status_choice |
||||||
|
orders_for_status = Order.objects.filter(status=status_code) |
||||||
|
count = orders_for_status.count() |
||||||
|
total_amount = orders_for_status.aggregate(Sum('total_price'))['total_price__sum'] or 0 |
||||||
|
avg_order_value = orders_for_status.aggregate(Avg('total_price'))['total_price__avg'] or 0 |
||||||
|
percentage = (count / total_orders * 100) if total_orders > 0 else 0 |
||||||
|
|
||||||
|
order_status_data.append({ |
||||||
|
'status': status_code, |
||||||
|
'label': status_label, |
||||||
|
'count': count, |
||||||
|
'total_amount': total_amount, |
||||||
|
'avg_order_value': avg_order_value, |
||||||
|
'percentage': percentage |
||||||
|
}) |
||||||
|
|
||||||
|
# Recent activity calculations |
||||||
|
now = timezone.now() |
||||||
|
today = now.date() |
||||||
|
week_ago = today - timedelta(days=7) |
||||||
|
month_ago = today - timedelta(days=30) |
||||||
|
|
||||||
|
orders_today = Order.objects.filter(date_ordered__date=today).count() |
||||||
|
orders_this_week = Order.objects.filter(date_ordered__date__gte=week_ago).count() |
||||||
|
orders_this_month = Order.objects.filter(date_ordered__date__gte=month_ago).count() |
||||||
|
orders_to_prepare = Order.objects.filter(status=OrderStatus.PAID).count() |
||||||
|
|
||||||
|
extra_context = extra_context or {} |
||||||
|
extra_context.update({ |
||||||
|
'order_status_data': order_status_data, |
||||||
|
'total_orders': total_orders, |
||||||
|
'total_revenue': total_revenue, |
||||||
|
'orders_today': orders_today, |
||||||
|
'orders_this_week': orders_this_week, |
||||||
|
'orders_this_month': orders_this_month, |
||||||
|
'orders_to_prepare': orders_to_prepare, |
||||||
|
}) |
||||||
|
|
||||||
|
return render(request, 'admin/shop/dashboard.html', extra_context) |
||||||
|
|
||||||
|
# Create an instance of the custom admin site |
||||||
|
shop_admin_site = ShopAdminSite(name='shop_admin') |
||||||
|
|
||||||
|
@admin.register(Product) |
||||||
|
class ProductAdmin(admin.ModelAdmin): |
||||||
|
list_display = ("title", "ordering_value", "price", "cut") |
||||||
|
search_fields = ["title", "description"] # Enable search for autocomplete |
||||||
|
|
||||||
|
@admin.register(Color) |
||||||
|
class ColorAdmin(admin.ModelAdmin): |
||||||
|
list_display = ("color_preview", "name", "ordering", "colorHex", "secondary_hex_color") |
||||||
|
list_editable = ("ordering",) |
||||||
|
ordering = ["ordering"] |
||||||
|
search_fields = ["name"] |
||||||
|
list_per_page = 20 |
||||||
|
|
||||||
|
def color_preview(self, obj): |
||||||
|
if obj.secondary_hex_color: |
||||||
|
return format_html( |
||||||
|
'<div style="background-image: linear-gradient(to right, {} 50%, {} 50%); ' |
||||||
|
'width: 60px; height: 30px; border-radius: 15px; border: 1px solid #ddd;"></div>', |
||||||
|
obj.colorHex, obj.secondary_hex_color |
||||||
|
) |
||||||
|
return format_html( |
||||||
|
'<div style="background-color: {}; width: 60px; height: 30px; ' |
||||||
|
'border-radius: 15px; border: 1px solid #ddd;"></div>', |
||||||
|
obj.colorHex |
||||||
|
) |
||||||
|
|
||||||
|
@admin.register(Size) |
||||||
|
class SizeAdmin(admin.ModelAdmin): |
||||||
|
list_display = ("name",) |
||||||
|
|
||||||
|
class OrderItemInline(admin.TabularInline): |
||||||
|
model = OrderItem |
||||||
|
extra = 1 # Show one extra row for adding new items |
||||||
|
autocomplete_fields = ['product'] # Enable product search |
||||||
|
fields = ('product', 'quantity', 'color', 'size', 'price') |
||||||
|
|
||||||
|
@admin.register(OrderItem) |
||||||
|
class OrderItemAdmin(admin.ModelAdmin): |
||||||
|
list_display = ('order', 'product', 'quantity', 'color', 'size', 'price', 'get_total_price') |
||||||
|
list_filter = ('product', 'color', 'size', 'order__status') |
||||||
|
search_fields = ('order__id', 'product__title', 'order__user__email', 'order__guest_user__email') |
||||||
|
autocomplete_fields = ['order', 'product'] |
||||||
|
list_editable = ('quantity', 'price') |
||||||
|
|
||||||
|
def get_total_price(self, obj): |
||||||
|
return obj.get_total_price() |
||||||
|
get_total_price.short_description = 'Total Price' |
||||||
|
get_total_price.admin_order_field = 'price' # Allows column to be sortable |
||||||
|
|
||||||
|
@admin.register(ShippingAddress) |
||||||
|
class ShippingAddressAdmin(admin.ModelAdmin): |
||||||
|
list_display = ('street_address', 'city', 'postal_code', 'country') |
||||||
|
search_fields = ('street_address', 'city', 'postal_code', 'country') |
||||||
|
|
||||||
|
class ChangeOrderStatusForm(forms.Form): |
||||||
|
_selected_action = forms.CharField(widget=forms.MultipleHiddenInput) |
||||||
|
status = forms.ChoiceField(choices=OrderStatus.choices, label="New Status") |
||||||
|
|
||||||
|
@admin.register(Order) |
||||||
|
class OrderAdmin(admin.ModelAdmin): |
||||||
|
list_display = ('id', 'get_email', 'date_ordered', 'status', 'total_price', 'get_shipping_address') |
||||||
|
inlines = [OrderItemInline] |
||||||
|
list_filter = ('status', 'payment_status') |
||||||
|
readonly_fields = ('shipping_address_details',) |
||||||
|
actions = ['change_order_status'] |
||||||
|
autocomplete_fields = ['user'] # Add this line for user search functionality |
||||||
|
search_fields = ['id', 'user__email', 'user__username', 'guest_user__email'] # Add this line |
||||||
|
|
||||||
|
def get_email(self, obj): |
||||||
|
if obj.guest_user: |
||||||
|
return obj.guest_user.email |
||||||
|
else: |
||||||
|
return obj.user.email |
||||||
|
get_email.short_description = 'Email' |
||||||
|
|
||||||
|
def get_shipping_address(self, obj): |
||||||
|
if obj.shipping_address: |
||||||
|
return f"{obj.shipping_address.street_address}, {obj.shipping_address.city}" |
||||||
|
return "No shipping address" |
||||||
|
get_shipping_address.short_description = 'Shipping Address' |
||||||
|
|
||||||
|
def shipping_address_details(self, obj): |
||||||
|
if obj.shipping_address: |
||||||
|
return format_html( |
||||||
|
""" |
||||||
|
<div style="padding: 10px; background-color: #f9f9f9; border-radius: 4px;"> |
||||||
|
<strong>Street:</strong> {}<br> |
||||||
|
{} |
||||||
|
<strong>City:</strong> {}<br> |
||||||
|
<strong>State:</strong> {}<br> |
||||||
|
<strong>Postal Code:</strong> {}<br> |
||||||
|
<strong>Country:</strong> {} |
||||||
|
</div> |
||||||
|
""", |
||||||
|
obj.shipping_address.street_address, |
||||||
|
f"<strong>Apartment:</strong> {obj.shipping_address.apartment}<br>" if obj.shipping_address.apartment else "", |
||||||
|
obj.shipping_address.city, |
||||||
|
obj.shipping_address.state, |
||||||
|
obj.shipping_address.postal_code, |
||||||
|
obj.shipping_address.country, |
||||||
|
) |
||||||
|
return "No shipping address set" |
||||||
|
shipping_address_details.short_description = 'Shipping Address Details' |
||||||
|
|
||||||
|
fieldsets = ( |
||||||
|
(None, { |
||||||
|
'fields': ('user', 'guest_user', 'status', 'payment_status', 'total_price') |
||||||
|
}), |
||||||
|
('Shipping Information', { |
||||||
|
'fields': ('shipping_address_details',), |
||||||
|
}), |
||||||
|
('Payment Details', { |
||||||
|
'fields': ('stripe_payment_intent_id', 'stripe_checkout_session_id', 'stripe_mode'), |
||||||
|
'classes': ('collapse',) |
||||||
|
}), |
||||||
|
('Discount Information', { |
||||||
|
'fields': ('coupon', 'discount_amount'), |
||||||
|
'classes': ('collapse',) |
||||||
|
}), |
||||||
|
) |
||||||
|
|
||||||
|
def dashboard_view(self, request): |
||||||
|
"""Dashboard view with order statistics""" |
||||||
|
# Calculate order statistics |
||||||
|
order_status_data = [] |
||||||
|
total_orders = Order.objects.count() |
||||||
|
total_revenue = Order.objects.aggregate(Sum('total_price'))['total_price__sum'] or 0 |
||||||
|
|
||||||
|
# Get data for each status |
||||||
|
for status_choice in OrderStatus.choices: |
||||||
|
status_code, status_label = status_choice |
||||||
|
orders_for_status = Order.objects.filter(status=status_code) |
||||||
|
count = orders_for_status.count() |
||||||
|
total_amount = orders_for_status.aggregate(Sum('total_price'))['total_price__sum'] or 0 |
||||||
|
avg_order_value = orders_for_status.aggregate(Avg('total_price'))['total_price__avg'] or 0 |
||||||
|
percentage = (count / total_orders * 100) if total_orders > 0 else 0 |
||||||
|
|
||||||
|
order_status_data.append({ |
||||||
|
'status': status_code, |
||||||
|
'label': status_label, |
||||||
|
'count': count, |
||||||
|
'total_amount': total_amount, |
||||||
|
'avg_order_value': avg_order_value, |
||||||
|
'percentage': percentage |
||||||
|
}) |
||||||
|
|
||||||
|
# Recent activity calculations |
||||||
|
now = timezone.now() |
||||||
|
today = now.date() |
||||||
|
week_ago = today - timedelta(days=7) |
||||||
|
month_ago = today - timedelta(days=30) |
||||||
|
|
||||||
|
orders_today = Order.objects.filter(date_ordered__date=today).count() |
||||||
|
orders_this_week = Order.objects.filter(date_ordered__date__gte=week_ago).count() |
||||||
|
orders_this_month = Order.objects.filter(date_ordered__date__gte=month_ago).count() |
||||||
|
orders_to_prepare = Order.objects.filter(status=OrderStatus.PAID).count() |
||||||
|
|
||||||
|
context = { |
||||||
|
'title': 'Shop Dashboard', |
||||||
|
'app_label': 'shop', |
||||||
|
'opts': Order._meta, |
||||||
|
'order_status_data': order_status_data, |
||||||
|
'total_orders': total_orders, |
||||||
|
'total_revenue': total_revenue, |
||||||
|
'orders_today': orders_today, |
||||||
|
'orders_this_week': orders_this_week, |
||||||
|
'orders_this_month': orders_this_month, |
||||||
|
'orders_to_prepare': orders_to_prepare, |
||||||
|
} |
||||||
|
|
||||||
|
return render(request, 'admin/shop/dashboard.html', context) |
||||||
|
|
||||||
|
def changelist_view(self, request, extra_context=None): |
||||||
|
# If 'show_preparation' parameter is in the request, show the preparation view |
||||||
|
if 'show_preparation' in request.GET: |
||||||
|
return self.preparation_view(request) |
||||||
|
|
||||||
|
# Otherwise show the normal change list |
||||||
|
extra_context = extra_context or {} |
||||||
|
paid_orders_count = Order.objects.filter(status=OrderStatus.PAID).count() |
||||||
|
extra_context['paid_orders_count'] = paid_orders_count |
||||||
|
return super().changelist_view(request, extra_context=extra_context) |
||||||
|
|
||||||
|
def preparation_view(self, request): |
||||||
|
"""View for items that need to be prepared""" |
||||||
|
# Get paid orders |
||||||
|
orders = Order.objects.filter(status=OrderStatus.PAID).order_by('-date_ordered') |
||||||
|
|
||||||
|
# Group items by product, color, size |
||||||
|
items_by_variant = {} |
||||||
|
all_items = OrderItem.objects.filter(order__status=OrderStatus.PAID) |
||||||
|
|
||||||
|
for item in all_items: |
||||||
|
# Create a key for grouping items |
||||||
|
key = ( |
||||||
|
str(item.product.id), |
||||||
|
str(item.color.id) if item.color else 'none', |
||||||
|
str(item.size.id) if item.size else 'none' |
||||||
|
) |
||||||
|
|
||||||
|
if key not in items_by_variant: |
||||||
|
items_by_variant[key] = { |
||||||
|
'product': item.product, |
||||||
|
'color': item.color, |
||||||
|
'size': item.size, |
||||||
|
'quantity': 0, |
||||||
|
'orders': set() |
||||||
|
} |
||||||
|
|
||||||
|
items_by_variant[key]['quantity'] += item.quantity |
||||||
|
items_by_variant[key]['orders'].add(item.order.id) |
||||||
|
|
||||||
|
# Convert to list and sort |
||||||
|
items_list = list(items_by_variant.values()) |
||||||
|
items_list.sort(key=lambda x: x['product'].title) |
||||||
|
|
||||||
|
context = { |
||||||
|
'title': 'Orders to Prepare', |
||||||
|
'app_label': 'shop', |
||||||
|
'opts': Order._meta, |
||||||
|
'orders': orders, |
||||||
|
'items': items_list, |
||||||
|
'total_orders': orders.count(), |
||||||
|
'total_items': sum(i['quantity'] for i in items_list) |
||||||
|
} |
||||||
|
|
||||||
|
return render( |
||||||
|
request, |
||||||
|
'admin/shop/order/preparation_view.html', |
||||||
|
context |
||||||
|
) |
||||||
|
|
||||||
|
def get_urls(self): |
||||||
|
urls = super().get_urls() |
||||||
|
custom_urls = [ |
||||||
|
path('dashboard/', self.admin_site.admin_view(self.dashboard_view), name='shop_order_dashboard'), |
||||||
|
path('prepare-all/', self.admin_site.admin_view(self.prepare_all_orders), name='prepare_all_orders'), |
||||||
|
path('<int:order_id>/prepare/', self.admin_site.admin_view(self.prepare_order), name='prepare_order'), |
||||||
|
path('<int:order_id>/cancel-refund/', self.admin_site.admin_view(self.cancel_and_refund_order), name='cancel_and_refund_order'), |
||||||
|
] |
||||||
|
return custom_urls + urls |
||||||
|
|
||||||
|
def prepare_all_orders(self, request): |
||||||
|
if request.method == 'POST': |
||||||
|
Order.objects.filter(status=OrderStatus.PAID).update(status=OrderStatus.PREPARED) |
||||||
|
self.message_user(request, "All orders have been marked as prepared.") |
||||||
|
return redirect('admin:shop_order_changelist') |
||||||
|
|
||||||
|
def prepare_order(self, request, order_id): |
||||||
|
if request.method == 'POST': |
||||||
|
order = Order.objects.get(id=order_id) |
||||||
|
order.status = OrderStatus.PREPARED |
||||||
|
order.save() |
||||||
|
self.message_user(request, f"Order #{order_id} has been marked as prepared.") |
||||||
|
return redirect('admin:shop_order_changelist') |
||||||
|
|
||||||
|
def cancel_and_refund_order(self, request, order_id): |
||||||
|
if request.method == 'POST': |
||||||
|
order = Order.objects.get(id=order_id) |
||||||
|
try: |
||||||
|
# Reuse the cancel_order logic from your views |
||||||
|
from .views import cancel_order |
||||||
|
cancel_order(request, order_id) |
||||||
|
self.message_user(request, f"Order #{order_id} has been cancelled and refunded.") |
||||||
|
except Exception as e: |
||||||
|
self.message_user(request, f"Error cancelling order: {str(e)}", level='ERROR') |
||||||
|
return redirect('admin:shop_order_changelist') |
||||||
|
|
||||||
|
def change_order_status(self, request, queryset): |
||||||
|
"""Admin action to change the status of selected orders""" |
||||||
|
form = None |
||||||
|
|
||||||
|
if 'apply' in request.POST: |
||||||
|
form = ChangeOrderStatusForm(request.POST) |
||||||
|
|
||||||
|
if form.is_valid(): |
||||||
|
status = form.cleaned_data['status'] |
||||||
|
count = 0 |
||||||
|
|
||||||
|
for order in queryset: |
||||||
|
order.status = status |
||||||
|
order.save() |
||||||
|
count += 1 |
||||||
|
|
||||||
|
self.message_user(request, f"{count} orders have been updated to status '{OrderStatus(status).label}'.") |
||||||
|
return HttpResponseRedirect(request.get_full_path()) |
||||||
|
|
||||||
|
if not form: |
||||||
|
form = ChangeOrderStatusForm(initial={'_selected_action': request.POST.getlist('_selected_action')}) |
||||||
|
|
||||||
|
context = { |
||||||
|
'title': 'Change Order Status', |
||||||
|
'orders': queryset, |
||||||
|
'form': form, |
||||||
|
'action': 'change_order_status' |
||||||
|
} |
||||||
|
return render(request, 'admin/shop/order/change_status.html', context) |
||||||
|
|
||||||
|
change_order_status.short_description = "Change status for selected orders" |
||||||
|
|
||||||
|
class GuestUserOrderInline(admin.TabularInline): |
||||||
|
model = Order |
||||||
|
extra = 0 |
||||||
|
readonly_fields = ('date_ordered', 'total_price') |
||||||
|
can_delete = False |
||||||
|
show_change_link = True |
||||||
|
exclude = ('user',) # Exclude the user field from the inline display |
||||||
|
|
||||||
|
@admin.register(GuestUser) |
||||||
|
class GuestUserAdmin(admin.ModelAdmin): |
||||||
|
list_display = ('email', 'phone') |
||||||
|
inlines = [GuestUserOrderInline] |
||||||
|
|
||||||
|
@admin.register(Coupon) |
||||||
|
class CouponAdmin(admin.ModelAdmin): |
||||||
|
list_display = ('code', 'discount_amount', 'discount_percent', 'is_active', |
||||||
|
'valid_from', 'valid_to', 'current_uses', 'max_uses') |
||||||
|
list_filter = ('is_active', 'valid_from', 'valid_to') |
||||||
|
search_fields = ('code', 'description') |
||||||
|
readonly_fields = ('current_uses', 'created_at', 'stripe_coupon_id') |
||||||
|
fieldsets = ( |
||||||
|
('Basic Information', { |
||||||
|
'fields': ('code', 'description', 'is_active') |
||||||
|
}), |
||||||
|
('Discount', { |
||||||
|
'fields': ('discount_amount', 'discount_percent') |
||||||
|
}), |
||||||
|
('Validity', { |
||||||
|
'fields': ('valid_from', 'valid_to', 'max_uses', 'current_uses') |
||||||
|
}), |
||||||
|
('Stripe Information', { |
||||||
|
'fields': ('stripe_coupon_id',), |
||||||
|
'classes': ('collapse',) |
||||||
|
}), |
||||||
|
) |
||||||
|
|
||||||
|
@admin.register(CouponUsage) |
||||||
|
class CouponUsageAdmin(admin.ModelAdmin): |
||||||
|
list_display = ('coupon', 'user', 'guest_email', 'order', 'used_at') |
||||||
|
list_filter = ('used_at',) |
||||||
|
search_fields = ('coupon__code', 'user__username', 'user__email', 'guest_email') |
||||||
|
readonly_fields = ('used_at',) |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
from django.apps import AppConfig |
||||||
|
|
||||||
|
|
||||||
|
class ShopConfig(AppConfig): |
||||||
|
default_auto_field = 'django.db.models.BigAutoField' |
||||||
|
name = 'shop' |
||||||
|
def ready(self): |
||||||
|
import shop.signals # Import signals to ensure they're connected |
||||||
@ -0,0 +1,94 @@ |
|||||||
|
from .models import CartItem, Product, Color, Size |
||||||
|
|
||||||
|
def get_or_create_cart_id(request): |
||||||
|
"""Get the cart ID from the session or create a new one""" |
||||||
|
if 'cart_id' not in request.session: |
||||||
|
request.session['cart_id'] = request.session.session_key or request.session.create() |
||||||
|
return request.session['cart_id'] |
||||||
|
|
||||||
|
def get_cart_items(request): |
||||||
|
"""Get all cart items for the current session""" |
||||||
|
cart_id = get_or_create_cart_id(request) |
||||||
|
return CartItem.objects.filter(session_id=cart_id) |
||||||
|
|
||||||
|
def add_to_cart(request, product_id, quantity=1, color_id=None, size_id=None): |
||||||
|
"""Add a product to the cart or update its quantity""" |
||||||
|
product = Product.objects.get(id=product_id) |
||||||
|
cart_id = get_or_create_cart_id(request) |
||||||
|
|
||||||
|
color = Color.objects.get(id=color_id) if color_id else None |
||||||
|
size = Size.objects.get(id=size_id) if size_id else None |
||||||
|
|
||||||
|
try: |
||||||
|
# Try to get existing cart item with the same product, color, and size |
||||||
|
cart_item = CartItem.objects.get( |
||||||
|
product=product, |
||||||
|
session_id=cart_id, |
||||||
|
color=color, |
||||||
|
size=size |
||||||
|
) |
||||||
|
cart_item.quantity += quantity |
||||||
|
cart_item.save() |
||||||
|
except CartItem.DoesNotExist: |
||||||
|
# Create new cart item |
||||||
|
cart_item = CartItem.objects.create( |
||||||
|
product=product, |
||||||
|
quantity=quantity, |
||||||
|
session_id=cart_id, |
||||||
|
color=color, |
||||||
|
size=size |
||||||
|
) |
||||||
|
|
||||||
|
return cart_item |
||||||
|
|
||||||
|
def remove_from_cart(request, product_id): |
||||||
|
"""Remove a product from the cart""" |
||||||
|
cart_id = get_or_create_cart_id(request) |
||||||
|
CartItem.objects.filter(product_id=product_id, session_id=cart_id).delete() |
||||||
|
|
||||||
|
def update_cart_item(request, product_id, quantity): |
||||||
|
"""Update the quantity of a cart item""" |
||||||
|
cart_id = get_or_create_cart_id(request) |
||||||
|
cart_item = CartItem.objects.get(product_id=product_id, session_id=cart_id) |
||||||
|
|
||||||
|
if quantity > 0: |
||||||
|
cart_item.quantity = quantity |
||||||
|
cart_item.save() |
||||||
|
else: |
||||||
|
cart_item.delete() |
||||||
|
|
||||||
|
def get_cart_total(request): |
||||||
|
"""Calculate the total price of all items in the cart""" |
||||||
|
return sum(item.product.price * item.quantity for item in get_cart_items(request)) |
||||||
|
|
||||||
|
def clear_cart(request): |
||||||
|
"""Clear the cart""" |
||||||
|
cart_id = get_or_create_cart_id(request) |
||||||
|
CartItem.objects.filter(session_id=cart_id).delete() |
||||||
|
|
||||||
|
# Add this function to your cart.py file |
||||||
|
def get_cart_item(request, item_id): |
||||||
|
"""Get a specific cart item by its ID""" |
||||||
|
cart_id = get_or_create_cart_id(request) |
||||||
|
try: |
||||||
|
return CartItem.objects.get(id=item_id, session_id=cart_id) |
||||||
|
except CartItem.DoesNotExist: |
||||||
|
raise Exception("Cart item not found") |
||||||
|
|
||||||
|
def transfer_cart(request, old_session_key): |
||||||
|
""" |
||||||
|
Transfer cart items from an anonymous session to an authenticated user's session |
||||||
|
""" |
||||||
|
from django.contrib.sessions.models import Session |
||||||
|
from django.contrib.sessions.backends.db import SessionStore |
||||||
|
|
||||||
|
# Get the old session |
||||||
|
try: |
||||||
|
old_session = SessionStore(session_key=old_session_key) |
||||||
|
# Check if there are cart items in the old session |
||||||
|
if 'cart_items' in old_session: |
||||||
|
# Transfer cart items to the new session |
||||||
|
request.session['cart_items'] = old_session['cart_items'] |
||||||
|
request.session.modified = True |
||||||
|
except Session.DoesNotExist: |
||||||
|
pass |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
from django.conf import settings |
||||||
|
|
||||||
|
def stripe_context(request): |
||||||
|
"""Add Stripe-related context variables to templates""" |
||||||
|
stripe_mode = getattr(settings, 'STRIPE_MODE', 'test') |
||||||
|
return { |
||||||
|
'STRIPE_PUBLISHABLE_KEY': settings.STRIPE_PUBLISHABLE_KEY, |
||||||
|
'STRIPE_MODE': stripe_mode, |
||||||
|
'STRIPE_IS_TEST_MODE': stripe_mode == 'test', |
||||||
|
} |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
from django import forms |
||||||
|
from .models import Coupon |
||||||
|
from .models import ShippingAddress |
||||||
|
|
||||||
|
class GuestCheckoutForm(forms.Form): |
||||||
|
email = forms.EmailField(required=True) |
||||||
|
phone = forms.CharField(max_length=20, required=True, label="Téléphone portable") |
||||||
|
|
||||||
|
class CouponApplyForm(forms.Form): |
||||||
|
code = forms.CharField(max_length=50) |
||||||
|
|
||||||
|
class ShippingAddressForm(forms.ModelForm): |
||||||
|
class Meta: |
||||||
|
model = ShippingAddress |
||||||
|
fields = ['street_address', 'apartment', 'city', 'postal_code', 'country'] |
||||||
|
widgets = { |
||||||
|
'street_address': forms.TextInput(attrs={'placeholder': 'Adresse'}), |
||||||
|
'apartment': forms.TextInput(attrs={'placeholder': 'Appartement (optionnel)'}), |
||||||
|
'city': forms.TextInput(attrs={'placeholder': 'Ville'}), |
||||||
|
'postal_code': forms.TextInput(attrs={'placeholder': 'Code postal'}), |
||||||
|
'country': forms.TextInput(attrs={'placeholder': 'Pays'}), |
||||||
|
} |
||||||
@ -0,0 +1,206 @@ |
|||||||
|
from django.core.management.base import BaseCommand |
||||||
|
from shop.models import Color, Size, Product |
||||||
|
from django.conf import settings |
||||||
|
|
||||||
|
class Command(BaseCommand): |
||||||
|
help = 'Creates initial data for the shop' |
||||||
|
|
||||||
|
def handle(self, *args, **kwargs): |
||||||
|
# Create colors |
||||||
|
self.stdout.write('Creating colors...') |
||||||
|
colors = [ |
||||||
|
{'name': 'Blanc', 'hex': '#FFFFFF', 'secondary_hex': None, 'ordering': 9}, |
||||||
|
{'name': 'Blanc / Bleu Sport', 'hex': '#FFFFFF', 'secondary_hex': '#112B44', 'ordering': 10}, |
||||||
|
{'name': 'Blanc / Gris Clair', 'hex': '#FFFFFF', 'secondary_hex': '#D3D3D3', 'ordering': 12}, |
||||||
|
{'name': 'Bleu Sport', 'hex': '#112B44', 'secondary_hex': None, 'ordering': 20}, |
||||||
|
{'name': 'Bleu Sport / Blanc', 'hex': '#112B44', 'secondary_hex': '#FFFFFF', 'ordering': 11}, |
||||||
|
{'name': 'Bleu Sport / Bleu Sport Chiné', 'hex': '#112B44', 'secondary_hex': '#16395A', 'ordering': 22}, |
||||||
|
{'name': 'Fuchsia', 'hex': '#C1366B', 'secondary_hex': None, 'ordering': 30}, |
||||||
|
{'name': 'Corail / Noir', 'hex': '#FF7F50', 'secondary_hex': '#000000', 'ordering': 40}, |
||||||
|
{'name': 'Gris Foncé Chiné / Noir', 'hex': '#4D4D4D', 'secondary_hex': '#000000', 'ordering': 50}, |
||||||
|
{'name': 'Olive', 'hex': '#635E53', 'secondary_hex': None, 'ordering': 54}, |
||||||
|
{'name': 'Kaki Foncé', 'hex': '#707163', 'secondary_hex': None, 'ordering': 55}, |
||||||
|
{'name': 'Noir', 'hex': '#000000', 'secondary_hex': None, 'ordering': 60}, |
||||||
|
{'name': 'Noir / Corail', 'hex': '#000000', 'secondary_hex': '#FF7F50', 'ordering': 61}, |
||||||
|
{'name': 'Noir / Gris Foncé Chiné', 'hex': '#000000', 'secondary_hex': '#4D4D4D', 'ordering': 62}, |
||||||
|
{'name': 'Rose Clair', 'hex': '#E7C8CF', 'secondary_hex': None, 'ordering': 31}, |
||||||
|
{'name': 'Sand', 'hex': '#B4A885', 'secondary_hex': None, 'ordering': 32}, |
||||||
|
] |
||||||
|
|
||||||
|
color_objects = {} |
||||||
|
for color_data in colors: |
||||||
|
color, created = Color.objects.get_or_create( |
||||||
|
name=color_data['name'], |
||||||
|
defaults={ |
||||||
|
'colorHex': color_data['hex'], |
||||||
|
'secondary_hex_color': color_data['secondary_hex'], |
||||||
|
'ordering': color_data['ordering'] |
||||||
|
} |
||||||
|
) |
||||||
|
color_objects[color_data['name']] = color |
||||||
|
if created: |
||||||
|
self.stdout.write(f'Created color: {color_data["name"]}') |
||||||
|
else: |
||||||
|
color.colorHex = color_data['hex'] |
||||||
|
color.secondary_hex_color = color_data['secondary_hex'] |
||||||
|
color.ordering = color_data['ordering'] |
||||||
|
color.save() |
||||||
|
self.stdout.write(f'Updated color: {color_data["name"]}') |
||||||
|
|
||||||
|
# Create sizes |
||||||
|
self.stdout.write('Creating sizes...') |
||||||
|
sizes = ['Taille Unique', 'XS', 'S', 'M', 'L', 'XL', 'XXL', '3XL', '4XL'] |
||||||
|
|
||||||
|
size_objects = {} |
||||||
|
for name in sizes: |
||||||
|
size, created = Size.objects.get_or_create(name=name) |
||||||
|
size_objects[name] = size |
||||||
|
if created: |
||||||
|
self.stdout.write(f'Created size: {name}') |
||||||
|
else: |
||||||
|
self.stdout.write(f'Size already exists: {name}') |
||||||
|
|
||||||
|
# Create products |
||||||
|
self.stdout.write('Creating products...') |
||||||
|
products = [ |
||||||
|
{ |
||||||
|
'sku': 'PC001', |
||||||
|
'title': 'Padel Club Cap', |
||||||
|
'description': 'Casquette logo centre', |
||||||
|
'price': 25.00, |
||||||
|
'ordering_value': 1, |
||||||
|
'cut': 0, # Unisex |
||||||
|
'colors': ['Blanc', 'Bleu Sport', 'Noir'], |
||||||
|
'sizes': ['Taille Unique'], |
||||||
|
'image_filename': 'hat.jpg' |
||||||
|
}, |
||||||
|
{ |
||||||
|
'sku': 'PC002', |
||||||
|
'title': 'Padel Club Hoodie Femme', |
||||||
|
'description': 'Hoodie femme logo cœur et dos', |
||||||
|
'price': 50.00, |
||||||
|
'ordering_value': 10, |
||||||
|
'cut': 1, |
||||||
|
'colors': ['Blanc', 'Bleu Sport', 'Kaki Foncé', 'Noir', 'Fuchsia'], |
||||||
|
'sizes': ['XS', 'S', 'M', 'L', 'XL', 'XXL'], |
||||||
|
'image_filename': 'PS_K473_WHITE.png.avif' |
||||||
|
}, |
||||||
|
{ |
||||||
|
'sku': 'PC003', |
||||||
|
'title': 'Padel Club Hoodie Homme', |
||||||
|
'description': 'Hoodie homme logo cœur et dos', |
||||||
|
'price': 50.00, |
||||||
|
'ordering_value': 11, |
||||||
|
'cut': 2, |
||||||
|
'colors': ['Blanc', 'Bleu Sport', 'Kaki Foncé', 'Noir', 'Fuchsia'], |
||||||
|
'sizes': ['XS', 'S', 'M', 'L', 'XL', 'XXL', '3XL', '4XL'], |
||||||
|
'image_filename': 'PS_K476_WHITE.png.avif' |
||||||
|
}, |
||||||
|
{ |
||||||
|
'sku': 'PC004', |
||||||
|
'title': 'Débardeur Femme', |
||||||
|
'description': 'Débardeur femme avec logo coeur.', |
||||||
|
'price': 25.00, |
||||||
|
'ordering_value': 20, |
||||||
|
'cut': 1, # Women |
||||||
|
'colors': ['Blanc / Bleu Sport', 'Noir / Corail', 'Noir / Gris Foncé Chiné'], |
||||||
|
'sizes': ['XS', 'S', 'M', 'L', 'XL'], |
||||||
|
'image_filename': 'PS_PA4031_WHITE-SPORTYNAVY.png.avif' |
||||||
|
}, |
||||||
|
{ |
||||||
|
'sku': 'PC005', |
||||||
|
'title': 'Jupe bicolore Femme', |
||||||
|
'description': 'Avec short intégré logo jambe (sauf corail)', |
||||||
|
'price': 30.00, |
||||||
|
'ordering_value': 30, |
||||||
|
'cut': 1, # Women |
||||||
|
'colors': ['Blanc / Bleu Sport', 'Bleu Sport / Blanc', 'Corail / Noir', 'Noir / Gris Foncé Chiné'], |
||||||
|
'sizes': ['XS', 'S', 'M', 'L', 'XL'], |
||||||
|
'image_filename': 'PS_PA1031_WHITE-SPORTYNAVY.png.avif' |
||||||
|
}, |
||||||
|
{ |
||||||
|
'sku': 'PC006', |
||||||
|
'title': 'T-shirt Bicolore Homme', |
||||||
|
'description': 'T-shirt bicolore avec logo coeur.', |
||||||
|
'price': 25.00, |
||||||
|
'ordering_value': 40, |
||||||
|
'cut': 2, # Men |
||||||
|
'colors': ['Blanc / Gris Clair', 'Bleu Sport / Blanc', 'Bleu Sport / Bleu Sport Chiné', 'Noir', 'Noir / Gris Foncé Chiné'], |
||||||
|
'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'], |
||||||
|
'image_filename': 'tshirt_h.png' |
||||||
|
}, |
||||||
|
{ |
||||||
|
'sku': 'PC007', |
||||||
|
'title': 'Short Bicolore Homme', |
||||||
|
'description': 'Short bicolore avec logo jambe.', |
||||||
|
'price': 30.00, |
||||||
|
'ordering_value': 50, |
||||||
|
'cut': 2, # Men |
||||||
|
'colors': ['Blanc / Bleu Sport', 'Blanc / Gris Clair', 'Noir', 'Gris Foncé Chiné / Noir'], |
||||||
|
'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'], |
||||||
|
'image_filename': 'PS_PA1030_WHITE-SPORTYNAVY.png.avif' |
||||||
|
}, |
||||||
|
{ |
||||||
|
'sku': 'PC008', |
||||||
|
'title': 'T-shirt Simple Femme', |
||||||
|
'description': 'T-shirt simple avec logo coeur.', |
||||||
|
'price': 20.00, |
||||||
|
'ordering_value': 60, |
||||||
|
'cut': 1, # Women |
||||||
|
'colors': ['Blanc', 'Bleu Sport', 'Sand', 'Noir', 'Kaki Foncé', 'Rose Clair'], |
||||||
|
'sizes': ['XS','S', 'M', 'L', 'XL', 'XXL'], |
||||||
|
'image_filename': 'PS_PA439_WHITE.png.avif' |
||||||
|
}, |
||||||
|
{ |
||||||
|
'sku': 'PC009', |
||||||
|
'title': 'T-shirt Simple Homme', |
||||||
|
'description': 'T-shirt simple avec logo coeur.', |
||||||
|
'price': 20.00, |
||||||
|
'ordering_value': 61, |
||||||
|
'cut': 2, # Men |
||||||
|
'colors': ['Blanc', 'Bleu Sport', 'Sand', 'Noir', 'Olive', 'Rose Clair'], |
||||||
|
'sizes': ['XS','S', 'M', 'L', 'XL', 'XXL', '3XL'], |
||||||
|
'image_filename': 'PS_PA438_WHITE.png.avif' |
||||||
|
}, |
||||||
|
] |
||||||
|
|
||||||
|
for product_data in products: |
||||||
|
product, created = Product.objects.update_or_create( |
||||||
|
sku=product_data['sku'], |
||||||
|
defaults={ |
||||||
|
'title': product_data['title'], |
||||||
|
'description': product_data.get('description', ''), |
||||||
|
'price': product_data['price'], |
||||||
|
'ordering_value': product_data['ordering_value'], |
||||||
|
'cut': product_data['cut'] |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
if created: |
||||||
|
self.stdout.write(f'Created product: {product_data["sku"]} - {product_data["title"]}') |
||||||
|
else: |
||||||
|
self.stdout.write(f'Updated product: {product_data["sku"]} - {product_data["title"]}') |
||||||
|
|
||||||
|
# Handle the image path |
||||||
|
if 'image_filename' in product_data and product_data['image_filename']: |
||||||
|
image_path = f"{settings.STATIC_URL}shop/images/products/{product_data['image_filename']}" |
||||||
|
if product.image != image_path: |
||||||
|
product.image = image_path |
||||||
|
product.save() |
||||||
|
self.stdout.write(f'Updated image path to "{image_path}" for: {product_data["sku"]}') |
||||||
|
|
||||||
|
# Update colors - first clear existing then add new ones |
||||||
|
product.colors.clear() |
||||||
|
for color_name in product_data['colors']: |
||||||
|
if color_name in color_objects: |
||||||
|
product.colors.add(color_objects[color_name]) |
||||||
|
self.stdout.write(f'Updated colors for: {product_data["sku"]}') |
||||||
|
|
||||||
|
# Update sizes - first clear existing then add new ones |
||||||
|
product.sizes.clear() |
||||||
|
for size_name in product_data['sizes']: |
||||||
|
if size_name in size_objects: |
||||||
|
product.sizes.add(size_objects[size_name]) |
||||||
|
self.stdout.write(f'Updated sizes for: {product_data["sku"]}') |
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('Successfully created/updated shop data')) |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
# Generated by Django 4.2.11 on 2025-03-17 17:27 |
||||||
|
|
||||||
|
from django.conf import settings |
||||||
|
from django.db import migrations, models |
||||||
|
import django.db.models.deletion |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
initial = True |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.CreateModel( |
||||||
|
name='Color', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('name', models.CharField(choices=[('Red', 'Red'), ('Blue', 'Blue'), ('Green', 'Green'), ('Black', 'Black'), ('White', 'White')], max_length=10, unique=True)), |
||||||
|
], |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='Size', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('name', models.CharField(choices=[('S', 'Small'), ('M', 'Medium'), ('L', 'Large'), ('XL', 'X-Large')], max_length=5, unique=True)), |
||||||
|
], |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='Product', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('title', models.CharField(max_length=200)), |
||||||
|
('image', models.ImageField(blank=True, null=True, upload_to='products/')), |
||||||
|
('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10)), |
||||||
|
('colors', models.ManyToManyField(blank=True, related_name='products', to='shop.color')), |
||||||
|
('sizes', models.ManyToManyField(blank=True, related_name='products', to='shop.size')), |
||||||
|
], |
||||||
|
), |
||||||
|
migrations.CreateModel( |
||||||
|
name='CartItem', |
||||||
|
fields=[ |
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||||
|
('quantity', models.PositiveIntegerField(default=1)), |
||||||
|
('session_id', models.CharField(blank=True, max_length=255, null=True)), |
||||||
|
('date_added', models.DateTimeField(auto_now_add=True)), |
||||||
|
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.product')), |
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), |
||||||
|
], |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,39 @@ |
|||||||
|
# Generated by Django 4.2.11 on 2025-03-17 17:31 |
||||||
|
|
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('shop', '0001_initial'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.RenameModel( |
||||||
|
old_name='Color', |
||||||
|
new_name='ProductColor', |
||||||
|
), |
||||||
|
migrations.RenameModel( |
||||||
|
old_name='Size', |
||||||
|
new_name='ProductSize', |
||||||
|
), |
||||||
|
migrations.RemoveField( |
||||||
|
model_name='product', |
||||||
|
name='colors', |
||||||
|
), |
||||||
|
migrations.RemoveField( |
||||||
|
model_name='product', |
||||||
|
name='sizes', |
||||||
|
), |
||||||
|
migrations.AddField( |
||||||
|
model_name='product', |
||||||
|
name='product_colors', |
||||||
|
field=models.ManyToManyField(blank=True, to='shop.productcolor'), |
||||||
|
), |
||||||
|
migrations.AddField( |
||||||
|
model_name='product', |
||||||
|
name='product_sizes', |
||||||
|
field=models.ManyToManyField(blank=True, to='shop.productsize'), |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,39 @@ |
|||||||
|
# Generated by Django 4.2.11 on 2025-03-17 17:33 |
||||||
|
|
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('shop', '0002_rename_color_productcolor_rename_size_productsize_and_more'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.RenameModel( |
||||||
|
old_name='ProductColor', |
||||||
|
new_name='Color', |
||||||
|
), |
||||||
|
migrations.RenameModel( |
||||||
|
old_name='ProductSize', |
||||||
|
new_name='Size', |
||||||
|
), |
||||||
|
migrations.RemoveField( |
||||||
|
model_name='product', |
||||||
|
name='product_colors', |
||||||
|
), |
||||||
|
migrations.RemoveField( |
||||||
|
model_name='product', |
||||||
|
name='product_sizes', |
||||||
|
), |
||||||
|
migrations.AddField( |
||||||
|
model_name='product', |
||||||
|
name='colors', |
||||||
|
field=models.ManyToManyField(blank=True, related_name='products', to='shop.color'), |
||||||
|
), |
||||||
|
migrations.AddField( |
||||||
|
model_name='product', |
||||||
|
name='sizes', |
||||||
|
field=models.ManyToManyField(blank=True, related_name='products', to='shop.size'), |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
# Generated by Django 4.2.11 on 2025-03-18 08:00 |
||||||
|
|
||||||
|
from django.db import migrations, models |
||||||
|
import django.db.models.deletion |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('shop', '0003_rename_productcolor_color_rename_productsize_size_and_more'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.AddField( |
||||||
|
model_name='cartitem', |
||||||
|
name='color', |
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.color'), |
||||||
|
), |
||||||
|
migrations.AddField( |
||||||
|
model_name='cartitem', |
||||||
|
name='size', |
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.size'), |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
# Generated by Django 4.2.11 on 2025-03-18 08:59 |
||||||
|
|
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('shop', '0004_cartitem_color_cartitem_size'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.AlterField( |
||||||
|
model_name='color', |
||||||
|
name='name', |
||||||
|
field=models.CharField(choices=[('Red', 'Red'), ('Blue', 'Blue'), ('Green', 'Green'), ('Black', 'Black'), ('White', 'White')], max_length=20, unique=True), |
||||||
|
), |
||||||
|
migrations.AlterField( |
||||||
|
model_name='size', |
||||||
|
name='name', |
||||||
|
field=models.CharField(choices=[('S', 'Small'), ('M', 'Medium'), ('L', 'Large'), ('XL', 'X-Large'), ('SINGLE', 'Unique')], max_length=20, unique=True), |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
# Generated by Django 4.2.11 on 2025-03-18 13:46 |
||||||
|
|
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('shop', '0005_alter_color_name_alter_size_name'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.AlterModelOptions( |
||||||
|
name='product', |
||||||
|
options={'ordering': ['order']}, |
||||||
|
), |
||||||
|
migrations.AddField( |
||||||
|
model_name='product', |
||||||
|
name='order', |
||||||
|
field=models.IntegerField(default=0), |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
# Generated by Django 4.2.11 on 2025-03-18 13:49 |
||||||
|
|
||||||
|
from django.db import migrations, models |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('shop', '0006_alter_product_options_product_order'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.AddField( |
||||||
|
model_name='product', |
||||||
|
name='cut', |
||||||
|
field=models.IntegerField(choices=[(1, 'Women'), (2, 'Men'), (3, 'Kids')], default=2), |
||||||
|
), |
||||||
|
] |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
# Generated by Django 4.2.11 on 2025-03-18 14:24 |
||||||
|
|
||||||
|
from django.db import migrations |
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration): |
||||||
|
|
||||||
|
dependencies = [ |
||||||
|
('shop', '0007_product_cut'), |
||||||
|
] |
||||||
|
|
||||||
|
operations = [ |
||||||
|
migrations.AlterModelOptions( |
||||||
|
name='product', |
||||||
|
options={'ordering': ['ordering_value', 'cut']}, |
||||||
|
), |
||||||
|
migrations.RenameField( |
||||||
|
model_name='product', |
||||||
|
old_name='order', |
||||||
|
new_name='ordering_value', |
||||||
|
), |
||||||
|
] |
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue