Compare commits
577 Commits
timetoconf
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
3ce30cf5f7 | 3 days ago |
|
|
cf28db9fd0 | 3 days ago |
|
|
08fd01e119 | 6 days ago |
|
|
f97dbd79cc | 7 days ago |
|
|
240bb3fc25 | 7 days ago |
|
|
5102e4c295 | 7 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 | 6 months ago |
|
|
a82008db1c | 6 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 |
|
|
d19fdc3bd0 | 7 months ago |
|
|
aacb64f0f0 | 7 months ago |
|
|
65a45d209d | 7 months ago |
|
|
98a8bf3d12 | 7 months ago |
|
|
d25893698d | 7 months ago |
|
|
58383a19df | 7 months ago |
|
|
9af4da81c8 | 7 months 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,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 |
||||
|
||||
class CrmConfig(AppConfig): |
||||
class BizConfig(AppConfig): |
||||
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.core.exceptions import PermissionDenied |
||||
|
||||
class CRMAccessMixin(LoginRequiredMixin, UserPassesTestMixin): |
||||
class bizAccessMixin(LoginRequiredMixin, UserPassesTestMixin): |
||||
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 %} |
||||
@ -1,4 +1,4 @@ |
||||
{% extends "crm/base.html" %} |
||||
{% extends "biz/base.html" %} |
||||
|
||||
{% block content %} |
||||
<div class="container padding-bottom"> |
||||
@ -1,4 +1,4 @@ |
||||
{% extends "crm/base.html" %} |
||||
{% extends "biz/base.html" %} |
||||
|
||||
{% block content %} |
||||
<div class="container mt-4"> |
||||
@ -1,4 +1,4 @@ |
||||
{% extends "crm/base.html" %} |
||||
{% extends "biz/base.html" %} |
||||
|
||||
{% block head_title %}{{ first_title }}{% endblock %} |
||||
{% block first_title %}{{ first_title }}{% 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() |
||||
@ -1,7 +1,7 @@ |
||||
from django.urls import path |
||||
from . import views |
||||
|
||||
app_name = 'crm' |
||||
app_name = 'biz' |
||||
|
||||
urlpatterns = [ |
||||
path('', views.EventListView.as_view(), name='planned_events'),path('', views.EventListView.as_view(), name='events'), |
||||
@ -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 = ('entity_name', 'first_name', 'last_name', 'email', 'address', 'zip_code', 'city', 'created_at') |
||||
list_filter = ('zip_code', 'created_at') |
||||
search_fields = ('entity_name', 'first_name', 'last_name', 'email', 'zip_code', 'city') |
||||
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,23 +0,0 @@ |
||||
import django_filters |
||||
from django.db.models import Q |
||||
|
||||
from .models import Event, Status, Prospect |
||||
|
||||
|
||||
class ProspectFilter(django_filters.FilterSet): |
||||
zip_code = django_filters.CharFilter(lookup_expr='istartswith', label='Code postal') |
||||
events = django_filters.ModelMultipleChoiceFilter( |
||||
queryset=Event.objects.all(), |
||||
field_name='events', |
||||
) |
||||
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', 'events', 'zip_code'] |
||||
@ -1,46 +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 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 CSVImportForm(forms.Form): |
||||
csv_file = forms.FileField() |
||||
@ -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,32 +0,0 @@ |
||||
# Generated by Django 5.1 on 2024-12-16 15:43 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('crm', '0002_alter_event_options_alter_prospect_options_and_more'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.RemoveField( |
||||
model_name='prospect', |
||||
name='region', |
||||
), |
||||
migrations.AddField( |
||||
model_name='prospect', |
||||
name='address', |
||||
field=models.CharField(blank=True, max_length=200, null=True), |
||||
), |
||||
migrations.AddField( |
||||
model_name='prospect', |
||||
name='city', |
||||
field=models.CharField(blank=True, max_length=500, null=True), |
||||
), |
||||
migrations.AddField( |
||||
model_name='prospect', |
||||
name='zip_code', |
||||
field=models.CharField(blank=True, max_length=20, null=True), |
||||
), |
||||
] |
||||
@ -1,32 +0,0 @@ |
||||
# Generated by Django 5.1 on 2024-12-16 16:24 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('crm', '0003_remove_prospect_region_prospect_address_and_more'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.RemoveField( |
||||
model_name='prospect', |
||||
name='name', |
||||
), |
||||
migrations.AddField( |
||||
model_name='prospect', |
||||
name='entity_name', |
||||
field=models.CharField(blank=True, max_length=200, null=True), |
||||
), |
||||
migrations.AddField( |
||||
model_name='prospect', |
||||
name='first_name', |
||||
field=models.CharField(blank=True, max_length=200, null=True), |
||||
), |
||||
migrations.AddField( |
||||
model_name='prospect', |
||||
name='last_name', |
||||
field=models.CharField(blank=True, max_length=200, null=True), |
||||
), |
||||
] |
||||
@ -1,18 +0,0 @@ |
||||
# Generated by Django 5.1 on 2024-12-16 16:49 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('crm', '0004_remove_prospect_name_prospect_entity_name_and_more'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='prospect', |
||||
name='phone', |
||||
field=models.CharField(blank=True, max_length=25, null=True), |
||||
), |
||||
] |
||||
@ -1,120 +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) |
||||
entity_name = models.CharField(max_length=200, null=True, blank=True) |
||||
first_name = models.CharField(max_length=200, null=True, blank=True) |
||||
last_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) |
||||
phone = models.CharField(max_length=25, null=True, blank=True) |
||||
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 full_name(self): |
||||
return f'{self.first_name} {self.last_name}' |
||||
|
||||
def __str__(self): |
||||
return ' - '.join(filter(None, [self.entity_name, self.first_name, self.last_name, f"({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,6 +0,0 @@ |
||||
document.getElementById("select-all").addEventListener("change", function () { |
||||
const checkboxes = document.getElementsByName("selected_prospects"); |
||||
for (let checkbox of checkboxes) { |
||||
checkbox.checked = this.checked; |
||||
} |
||||
}); |
||||
@ -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,284 +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 django.db import IntegrityError |
||||
|
||||
from .models import Event, Prospect, EmailTracker, EmailCampaign, EventType |
||||
from .filters import ProspectFilter |
||||
from .forms import ProspectForm, 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 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('crm: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, 'crm/prospect_form.html', context) |
||||
|
||||
# @permission_required('crm.view_crm', 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('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 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(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_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(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-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(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) |
||||
@ -1,60 +1,54 @@ |
||||
|
||||
|
||||
# Rest Framework configuration |
||||
REST_FRAMEWORK = { |
||||
'DATETIME_FORMAT': "%Y-%m-%dT%H:%M:%S.%f%z", |
||||
"DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S.%f%z", |
||||
# Use Django's standard `django.contrib.auth` permissions, |
||||
# or allow read-only access for unauthenticated users. |
||||
'DEFAULT_PERMISSION_CLASSES': [ |
||||
'rest_framework.permissions.IsAuthenticated', |
||||
"DEFAULT_PERMISSION_CLASSES": [ |
||||
"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 = 'smtp-xlr.alwaysdata.net' |
||||
# EMAIL_PORT = 587 |
||||
EMAIL_HOST_USER = 'automatic@padelclub.app' |
||||
EMAIL_HOST_PASSWORD = 'XLR@Sport@2024' |
||||
# 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_HOST_USER = "automatic@padelclub.app" |
||||
EMAIL_HOST_PASSWORD = "XLR@Sport@2024" |
||||
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_HOST_USER = 'xlr@alwaysdata.net' |
||||
#EMAIL_HOST_PASSWORD = 'XLRSport$2024' |
||||
EMAIL_USE_TLS = True |
||||
#DEFAULT_FROM_EMAIL = 'Padel Club <xlr@alwaysdata.net>' |
||||
|
||||
CACHES = { |
||||
'default': { |
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', |
||||
"default": { |
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache", |
||||
}, |
||||
"qr-code": { |
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache", |
||||
"LOCATION": "qr-code-cache", |
||||
"TIMEOUT": 3600, |
||||
}, |
||||
'qr-code': { |
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', |
||||
'LOCATION': 'qr-code-cache', |
||||
'TIMEOUT': 3600 |
||||
} |
||||
} |
||||
|
||||
QR_CODE_CACHE_ALIAS = 'qr-code' |
||||
QR_CODE_CACHE_ALIAS = "qr-code" |
||||
|
||||
SYNC_APPS = { |
||||
'sync': {}, |
||||
'tournaments': { 'exclude': ['Log', 'FailedApiCall', 'DeviceToken'] } |
||||
"sync": {}, |
||||
"tournaments": {"exclude": ["Log", "FailedApiCall", "DeviceToken", "Image"]}, |
||||
# 'biz': {}, |
||||
} |
||||
|
||||
SYNC_MODEL_CHILDREN_SHARING = { |
||||
"Match": ["team_scores", "team_registration", "player_registrations"] |
||||
} |
||||
|
||||
STRIPE_CURRENCY = 'eur' |
||||
STRIPE_CURRENCY = "eur" |
||||
# Add managers who should receive internal emails |
||||
SHOP_MANAGERS = [ |
||||
('Shop Admin', 'shop-admin@padelclub.app'), |
||||
("Shop Admin", "shop-admin@padelclub.app"), |
||||
# ('Laurent Morvillier', 'laurent@padelclub.app'), |
||||
# ('Xavier Rousset', 'xavier@padelclub.app'), |
||||
] |
||||
SHOP_SITE_ROOT_URL = 'https://padelclub.app' |
||||
SHOP_SUPPORT_EMAIL = 'shop@padelclub.app' |
||||
SHOP_SITE_ROOT_URL = "https://padelclub.app" |
||||
SHOP_SUPPORT_EMAIL = "shop@padelclub.app" |
||||
|
||||
|
unable to load file from base commit
|
@ -0,0 +1,21 @@ |
||||
# Generated by Django 5.1 on 2025-05-01 05:59 |
||||
|
||||
import django.db.models.deletion |
||||
from django.conf import settings |
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0025_alter_product_cut'), |
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='order', |
||||
name='user', |
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), |
||||
), |
||||
] |
||||
@ -0,0 +1,41 @@ |
||||
# Generated by Django 5.1 on 2025-05-06 10:21 |
||||
|
||||
import django.db.models.deletion |
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0026_alter_order_user'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.CreateModel( |
||||
name='ShippingAddress', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('street_address', models.CharField(max_length=255)), |
||||
('apartment', models.CharField(blank=True, max_length=50, null=True)), |
||||
('city', models.CharField(max_length=100)), |
||||
('state', models.CharField(blank=True, max_length=100, null=True)), |
||||
('postal_code', models.CharField(max_length=20)), |
||||
('country', models.CharField(max_length=100)), |
||||
], |
||||
), |
||||
migrations.AlterField( |
||||
model_name='order', |
||||
name='payment_status', |
||||
field=models.CharField(choices=[('UNPAID', 'Unpaid'), ('PAID', 'Paid'), ('FAILED', 'Failed'), ('REFUNDED', 'Refunded')], default='UNPAID', max_length=20), |
||||
), |
||||
migrations.AlterField( |
||||
model_name='order', |
||||
name='status', |
||||
field=models.CharField(choices=[('PENDING', 'Pending'), ('PAID', 'Paid'), ('SHIPPED', 'Shipped'), ('DELIVERED', 'Delivered'), ('CANCELED', 'Canceled'), ('REFUNDED', 'Refunded'), ('PREPARED', 'Prepared')], default='PENDING', max_length=20), |
||||
), |
||||
migrations.AddField( |
||||
model_name='order', |
||||
name='shipping_address', |
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.shippingaddress'), |
||||
), |
||||
] |
||||
@ -0,0 +1,18 @@ |
||||
# Generated by Django 5.1 on 2025-05-06 19:39 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0027_shippingaddress_alter_order_payment_status_and_more'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='order', |
||||
name='status', |
||||
field=models.CharField(choices=[('PENDING', 'Pending'), ('PAID', 'Paid'), ('SHIPPED', 'Shipped'), ('DELIVERED', 'Delivered'), ('CANCELED', 'Canceled'), ('REFUNDED', 'Refunded'), ('PREPARED', 'Prepared'), ('READY', 'Ready')], default='PENDING', max_length=20), |
||||
), |
||||
] |
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,163 @@ |
||||
{% extends "admin/base_site.html" %} |
||||
{% load admin_urls %} |
||||
|
||||
{% block title %}Shop Dashboard{% endblock %} |
||||
|
||||
{% block breadcrumbs %} |
||||
<div class="breadcrumbs"> |
||||
<a href="{% url 'admin:index' %}">Home</a> |
||||
› <a href="{% url 'admin:app_list' app_label='shop' %}">Shop</a> |
||||
› Dashboard |
||||
</div> |
||||
{% endblock %} |
||||
|
||||
{% block content %} |
||||
<div class="dashboard"> |
||||
<div class="dashboard-stats" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; margin: 20px 0;"> |
||||
|
||||
<!-- Order Status Cards --> |
||||
<div class="stat-card" style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px;"> |
||||
<h3 style="margin: 0 0 15px 0; color: #495057;">Orders by Status</h3> |
||||
<div class="status-list"> |
||||
{% for status_data in order_status_data %} |
||||
<div class="status-item" style="display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #e9ecef;"> |
||||
<span style="font-weight: 500; color: #495057;">{{ status_data.label }}</span> |
||||
<div style="display: flex; align-items: center; gap: 10px;"> |
||||
<span class="count" style="background: #007bff; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: bold;"> |
||||
{{ status_data.count }} |
||||
</span> |
||||
<span class="total" style="color: #28a745; font-weight: 500;"> |
||||
€{{ status_data.total_amount|floatformat:2 }} |
||||
</span> |
||||
</div> |
||||
</div> |
||||
{% endfor %} |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Total Summary Card --> |
||||
<div class="stat-card" style="background: linear-gradient(135deg, #007bff, #0056b3); color: white; border-radius: 8px; padding: 20px;"> |
||||
<h3 style="margin: 0 0 15px 0;">Total Summary</h3> |
||||
<div class="summary-stats"> |
||||
<div style="margin-bottom: 10px;"> |
||||
<div style="font-size: 24px; font-weight: bold;">{{ total_orders }}</div> |
||||
<div style="opacity: 0.9;">Total Orders</div> |
||||
</div> |
||||
<div> |
||||
<div style="font-size: 24px; font-weight: bold;">€{{ total_revenue|floatformat:2 }}</div> |
||||
<div style="opacity: 0.9;">Total Revenue</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Recent Activity Card --> |
||||
<div class="stat-card" style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px;"> |
||||
<h3 style="margin: 0 0 15px 0; color: #495057;">Recent Activity</h3> |
||||
<div class="recent-stats"> |
||||
<div style="margin-bottom: 10px;"> |
||||
<div style="font-size: 18px; font-weight: bold; color: #28a745;">{{ orders_today }}</div> |
||||
<div style="color: #6c757d; font-size: 14px;">Orders Today</div> |
||||
</div> |
||||
<div style="margin-bottom: 10px;"> |
||||
<div style="font-size: 18px; font-weight: bold; color: #ffc107;">{{ orders_this_week }}</div> |
||||
<div style="color: #6c757d; font-size: 14px;">Orders This Week</div> |
||||
</div> |
||||
<div> |
||||
<div style="font-size: 18px; font-weight: bold; color: #17a2b8;">{{ orders_this_month }}</div> |
||||
<div style="color: #6c757d; font-size: 14px;">Orders This Month</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Quick Actions Card --> |
||||
<div class="stat-card" style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px;"> |
||||
<h3 style="margin: 0 0 15px 0; color: #495057;">Quick Actions</h3> |
||||
<div class="quick-actions"> |
||||
<a href="{% url 'admin:shop_order_changelist' %}" |
||||
style="display: block; padding: 8px 12px; margin-bottom: 8px; background: #007bff; color: white; text-decoration: none; border-radius: 4px; text-align: center;"> |
||||
View All Orders |
||||
</a> |
||||
<a href="{% url 'admin:shop_order_changelist' %}?show_preparation=1" |
||||
style="display: block; padding: 8px 12px; margin-bottom: 8px; background: #28a745; color: white; text-decoration: none; border-radius: 4px; text-align: center;"> |
||||
Orders to Prepare ({{ orders_to_prepare }}) |
||||
</a> |
||||
<a href="{% url 'admin:shop_product_changelist' %}" |
||||
style="display: block; padding: 8px 12px; margin-bottom: 8px; background: #6c757d; color: white; text-decoration: none; border-radius: 4px; text-align: center;"> |
||||
Manage Products |
||||
</a> |
||||
<a href="{% url 'admin:shop_coupon_changelist' %}" |
||||
style="display: block; padding: 8px 12px; background: #ffc107; color: #212529; text-decoration: none; border-radius: 4px; text-align: center;"> |
||||
Manage Coupons |
||||
</a> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Detailed Status Breakdown --> |
||||
<div class="detailed-breakdown" style="background: white; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px; margin-top: 20px;"> |
||||
<h3 style="margin: 0 0 20px 0; color: #495057;">Status Breakdown</h3> |
||||
<div style="overflow-x: auto;"> |
||||
<table style="width: 100%; border-collapse: collapse;"> |
||||
<thead> |
||||
<tr style="background: #f8f9fa;"> |
||||
<th style="padding: 12px; text-align: left; border-bottom: 2px solid #dee2e6;">Status</th> |
||||
<th style="padding: 12px; text-align: center; border-bottom: 2px solid #dee2e6;">Count</th> |
||||
<th style="padding: 12px; text-align: center; border-bottom: 2px solid #dee2e6;">Percentage</th> |
||||
<th style="padding: 12px; text-align: right; border-bottom: 2px solid #dee2e6;">Total Value</th> |
||||
<th style="padding: 12px; text-align: right; border-bottom: 2px solid #dee2e6;">Avg Order Value</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{% for status_data in order_status_data %} |
||||
<tr style="border-bottom: 1px solid #dee2e6;"> |
||||
<td style="padding: 12px; font-weight: 500;">{{ status_data.label }}</td> |
||||
<td style="padding: 12px; text-align: center;"> |
||||
<span style="background: #007bff; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px;"> |
||||
{{ status_data.count }} |
||||
</span> |
||||
</td> |
||||
<td style="padding: 12px; text-align: center;">{{ status_data.percentage|floatformat:1 }}%</td> |
||||
<td style="padding: 12px; text-align: right; color: #28a745; font-weight: 500;"> |
||||
€{{ status_data.total_amount|floatformat:2 }} |
||||
</td> |
||||
<td style="padding: 12px; text-align: right;"> |
||||
{% if status_data.count > 0 %} |
||||
€{{ status_data.avg_order_value|floatformat:2 }} |
||||
{% else %} |
||||
€0.00 |
||||
{% endif %} |
||||
</td> |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<style> |
||||
.dashboard { |
||||
max-width: 1200px; |
||||
margin: 0 auto; |
||||
} |
||||
|
||||
.stat-card { |
||||
transition: transform 0.2s ease, box-shadow 0.2s ease; |
||||
} |
||||
|
||||
.stat-card:hover { |
||||
transform: translateY(-2px); |
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1); |
||||
} |
||||
|
||||
.quick-actions a:hover { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
@media (max-width: 768px) { |
||||
.dashboard-stats { |
||||
grid-template-columns: 1fr !important; |
||||
} |
||||
} |
||||
</style> |
||||
{% endblock %} |
||||
@ -0,0 +1,32 @@ |
||||
{% extends "admin/base_site.html" %} |
||||
{% load i18n l10n admin_urls %} |
||||
|
||||
{% block content %} |
||||
<div class="module"> |
||||
<h2>{{ title }}</h2> |
||||
<div> |
||||
<p>Change status for the following {{ orders|length }} orders:</p> |
||||
<ul> |
||||
{% for order in orders %} |
||||
<li>{{ order }}</li> |
||||
{% endfor %} |
||||
</ul> |
||||
</div> |
||||
|
||||
<form action="{% url 'admin:shop_order_changelist' %}" method="post"> |
||||
{% csrf_token %} |
||||
<input type="hidden" name="action" value="{{ action }}" /> |
||||
|
||||
{{ form.as_p }} |
||||
|
||||
{% for obj in orders %} |
||||
<input type="hidden" name="_selected_action" value="{{ obj.pk }}" /> |
||||
{% endfor %} |
||||
|
||||
<div class="actions"> |
||||
<input type="submit" name="apply" value="Change Status" class="default" /> |
||||
<a href="{% url 'admin:shop_order_changelist' %}" class="button cancel-link">Cancel</a> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
{% endblock %} |
||||
@ -0,0 +1,81 @@ |
||||
{% extends 'tournaments/base.html' %} |
||||
|
||||
{% block head_title %}Mes Commandes{% endblock %} |
||||
{% block first_title %}Padel Club{% endblock %} |
||||
{% block second_title %}Mes Commandes{% endblock %} |
||||
|
||||
{% block content %} |
||||
{% include 'shop/partials/navigation_base.html' %} |
||||
|
||||
<div class="grid-x"> |
||||
<div class="cell medium-12 large-6 padding10"> |
||||
<h1 class="club padding10">Mes Commandes</h1> |
||||
<div class="bubble"> |
||||
{% if orders %} |
||||
<table class="order-table"> |
||||
<tbody> |
||||
{% for order in orders %} |
||||
<tr class="{% cycle 'odd-row' 'even-row' %}"> |
||||
<td class="text-left">Commande #{{ order.id }}</td> |
||||
<td> |
||||
<a href="{% url 'shop:order_detail' order.id %}" class="view-btn">Détails</a> |
||||
</td> |
||||
<td class="text-left">{{ order.date_ordered|date:"d/m/Y H:i" }}</td> |
||||
<td class="text-left"> |
||||
{% if order.status == 'PENDING' %} |
||||
<span class="status-badge pending">En attente</span> |
||||
{% elif order.status == 'PAID' %} |
||||
<span class="status-badge paid">Payée</span> |
||||
{% elif order.status == 'PREPARED' %} |
||||
<span class="status-badge prepared">En cours de préparation</span> |
||||
{% elif order.status == 'SHIPPED' %} |
||||
<span class="status-badge shipped">Expédiée</span> |
||||
{% elif order.status == 'DELIVERED' %} |
||||
<span class="status-badge delivered">Livrée</span> |
||||
{% elif order.status == 'CANCELED' %} |
||||
<span class="status-badge canceled">Annulée</span> |
||||
{% elif order.status == 'REFUNDED' %} |
||||
<span class="status-badge refunded">Remboursée</span> |
||||
{% endif %} |
||||
</td> |
||||
<td class="price-column"> |
||||
{% if order.discount_amount > 0 %} |
||||
<span class="original-price">{{ order.total_price }}€</span> |
||||
<span class="discounted-price">{{ order.get_total_after_discount }}€</span> |
||||
{% else %} |
||||
{{ order.total_price }}€ |
||||
{% endif %} |
||||
</td> |
||||
<td class="actions"> |
||||
{% if order.status == 'PENDING' or order.status == 'PAID' %} |
||||
<form method="post" action="{% url 'shop:cancel_order' order.id %}" class="inline-form" onsubmit="return confirm('Êtes-vous sûr de vouloir annuler cette commande?');"> |
||||
{% csrf_token %} |
||||
<button type="submit" class="remove-btn">Annuler</button> |
||||
</form> |
||||
{% endif %} |
||||
</td> |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
{% else %} |
||||
<div class="empty-orders"> |
||||
<p>Vous n'avez pas encore de commandes.</p> |
||||
<a href="{% url 'shop:product_list' %}" class="checkout-button confirm-nav-button">Découvrir la boutique</a> |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
{% if messages %} |
||||
<div class="messages"> |
||||
{% for message in messages %} |
||||
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}"> |
||||
{{ message }} |
||||
</div> |
||||
{% endfor %} |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
</div> |
||||
|
||||
{% endblock %} |
||||
@ -0,0 +1,150 @@ |
||||
{% extends 'tournaments/base.html' %} |
||||
|
||||
{% block head_title %}Détail de commande{% endblock %} |
||||
{% block first_title %}Padel Club{% endblock %} |
||||
{% block second_title %}Détail de commande{% endblock %} |
||||
|
||||
{% block content %} |
||||
{% include 'shop/partials/navigation_base.html' %} |
||||
|
||||
<div class="grid-x"> |
||||
<div class="cell medium-8 large-8 padding10"> |
||||
<h1 class="club padding10">Commande #{{ order.id }}</h1> |
||||
<div class="bubble"> |
||||
<div class="order-meta"> |
||||
<div class="order-date"> |
||||
<strong>Date:</strong> {{ order.date_ordered|date:"d/m/Y H:i" }} |
||||
</div> |
||||
<div class="order-status"> |
||||
<strong>Statut:</strong> |
||||
{% if order.status == 'PENDING' %} |
||||
<span class="status-badge pending">En attente</span> |
||||
{% elif order.status == 'PREPARED' %} |
||||
<span class="status-badge prepared">En cours de préparation</span> |
||||
{% elif order.status == 'PAID' %} |
||||
<span class="status-badge paid">Payée</span> |
||||
{% elif order.status == 'SHIPPED' %} |
||||
<span class="status-badge shipped">Expédiée</span> |
||||
{% elif order.status == 'DELIVERED' %} |
||||
<span class="status-badge delivered">Livrée</span> |
||||
{% elif order.status == 'CANCELED' %} |
||||
<span class="status-badge canceled">Annulée</span> |
||||
{% elif order.status == 'REFUNDED' %} |
||||
<span class="status-badge refunded">Remboursée</span> |
||||
{% endif %} |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="order-items-section"> |
||||
<h3>Produits</h3> |
||||
{% with items=order_items total_quantity=total_quantity total_price=order.total_price %} |
||||
{% include 'shop/partials/order_items_display.html' with items=items total_quantity=total_quantity total_price=total_price edit_mode=False cancel_mode=order.is_cancellable %} |
||||
{% endwith %} |
||||
</div> |
||||
|
||||
<div class="coupon-section"> |
||||
<div>Adresse de livraison (dans la mesure du possible)</div> |
||||
{% if order.shipping_address %} |
||||
<div class="address-details" style="margin: 10px 0; padding: 10px; background: #f8f9fa; border-radius: 4px;"> |
||||
<p style="margin: 0;">{{ order.shipping_address.street_address }}</p> |
||||
{% if order.shipping_address.apartment %} |
||||
<p style="margin: 5px 0;">{{ order.shipping_address.apartment }}</p> |
||||
{% endif %} |
||||
<p style="margin: 0;">{{ order.shipping_address.postal_code }} {{ order.shipping_address.city }}, {{ order.shipping_address.country }}</p> |
||||
</div> |
||||
{% if order.shipping_address_can_be_edited %} |
||||
<button class="edit-address-btn confirm-nav-button" style="margin-top: 10px;" onclick="toggleAddressForm()">Modifier l'adresse</button> |
||||
|
||||
<div id="address-form-container" style="display: none; margin-top: 10px;"> |
||||
<form method="post" action="{% url 'shop:update_shipping_address' order.id %}" class="coupon-form"> |
||||
{% csrf_token %} |
||||
<input type="text" name="street_address" class="address-input" style="width: 100%;" placeholder="Adresse" value="{{ order.shipping_address.street_address }}"> |
||||
<div style="margin: 10px 0;"> |
||||
<input type="text" name="apartment" class="address-input" style="width: 100%;" placeholder="Appartement (optionnel)" value="{{ order.shipping_address.apartment|default:'' }}"> |
||||
</div> |
||||
<div style="display: flex; gap: 10px;"> |
||||
<input type="text" name="postal_code" class="address-input" style="width: 25%;" placeholder="Code postal" value="{{ order.shipping_address.postal_code }}"> |
||||
<input type="text" name="city" class="address-input" style="width: 25%;" placeholder="Ville" value="{{ order.shipping_address.city }}"> |
||||
<input type="text" name="country" class="address-input" style="width: 25%;" placeholder="Pays" value="{{ order.shipping_address.country }}"> |
||||
</div> |
||||
<button type="submit" class="save-btn confirm-nav-button">Enregistrer</button> |
||||
</form> |
||||
</div> |
||||
{% endif %} |
||||
{% else %} |
||||
<p>Aucune adresse de livraison renseignée</p> |
||||
{% if order.shipping_address_can_be_edited %} |
||||
<button class="add-address-btn confirm-nav-button" onclick="toggleAddressForm()">Ajouter une adresse</button> |
||||
<div id="address-form-container" style="display: none; margin-top: 10px;"> |
||||
<form method="post" action="{% url 'shop:update_shipping_address' order.id %}" class="coupon-form"> |
||||
{% csrf_token %} |
||||
<input type="text" name="street_address" class="address-input" style="width: 100%;" placeholder="Adresse"> |
||||
<div style="margin: 10px 0;"> |
||||
<input type="text" name="apartment" class="address-input" style="width: 100%;" placeholder="Appartement (optionnel)"> |
||||
</div> |
||||
<div style="display: flex; gap: 10px;"> |
||||
<input type="text" name="postal_code" class="address-input" style="width: 25%;" placeholder="Code postal"> |
||||
<input type="text" name="city" class="address-input" style="width: 25%;" placeholder="Ville"> |
||||
<input type="text" name="country" class="address-input" style="width: 25%;" placeholder="Pays"> |
||||
</div> |
||||
<button type="submit" class="save-btn confirm-nav-button">Enregistrer</button> |
||||
</form> |
||||
</div> |
||||
{% endif %} |
||||
{% endif %} |
||||
</div> |
||||
|
||||
{% if order.discount_amount > 0 %} |
||||
<div class="discount-section"> |
||||
<div class="discount-row"> |
||||
<span>Sous-total:</span> |
||||
<span class="price-value">{{ order.total_price }}€</span> |
||||
</div> |
||||
<div class="discount-row"> |
||||
<span>Réduction:</span> |
||||
<span class="price-value">-{{ order.discount_amount }}€</span> |
||||
</div> |
||||
<div class="discount-row total-row"> |
||||
<span>Total final:</span> |
||||
<span class="price-value">{{ order.get_total_after_discount }}€</span> |
||||
</div> |
||||
|
||||
{% if order.coupon %} |
||||
<div class="coupon-info"> |
||||
Coupon appliqué: {{ order.coupon.code }} |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
{% endif %} |
||||
|
||||
<div class="order-actions"> |
||||
{% if order.status == 'PENDING' or order.status == 'PAID' %} |
||||
<form method="post" action="{% url 'shop:cancel_order' order.id %}" class="inline-form" onsubmit="return confirm('Êtes-vous sûr de vouloir annuler cette commande? Cette action est irréversible.');"> |
||||
{% csrf_token %} |
||||
<button type="submit" class="remove-btn">Annuler la commande</button> |
||||
</form> |
||||
{% endif %} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
{% if messages %} |
||||
<div class="messages"> |
||||
{% for message in messages %} |
||||
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}"> |
||||
{{ message }} |
||||
</div> |
||||
{% endfor %} |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
<script> |
||||
function toggleAddressForm() { |
||||
const formContainer = document.getElementById('address-form-container'); |
||||
const isHidden = formContainer.style.display === 'none'; |
||||
formContainer.style.display = isHidden ? 'block' : 'none'; |
||||
} |
||||
</script> |
||||
|
||||
{% endblock %} |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue