Compare commits
416 Commits
| 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 | 4 weeks ago |
|
|
80b6bc1136 | 4 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 | 5 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 |
@ -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,56 +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_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_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_USE_TLS = True |
||||
|
||||
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', 'Image'] } |
||||
"sync": {}, |
||||
"tournaments": {"exclude": ["Log", "FailedApiCall", "DeviceToken", "Image"]}, |
||||
# 'biz': {}, |
||||
} |
||||
|
||||
SYNC_MODEL_CHILDREN_SHARING = { |
||||
'Match': ['team_scores', 'team_registration', 'player_registrations'] |
||||
"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,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,21 @@ |
||||
### Synchronization quick ReadMe |
||||
|
||||
- Data class must extend BaseModel |
||||
- Admin classes must extend SyncedObjectAdmin to have updates saved in the BaseModel properties |
||||
- The SynchronizationApi defines a get and a post service to POST new data, and GET updates. When performing an operation on a data, a ModelLog instance is created with the related information. When performing a GET, we retrieve the list of ModelLogs to sent the new data to the user. |
||||
- routing.py defines the URL of the websocket where messages are sent when updates are made. URL is by user. |
||||
|
||||
|
||||
### Sharing |
||||
|
||||
- Data can be shared between users. To do that, a new DataAccess object can be created to define the owner, the authorized user, and the object id. |
||||
- By default, the whole hierarchy of objects are shared, from the data parents to all its children. |
||||
- Special data path can be specified for a class by defining a setting |
||||
|
||||
example: |
||||
SYNC_MODEL_CHILDREN_SHARING = { |
||||
'Match': ['team_scores', 'team_registration', 'player_registrations'] |
||||
} |
||||
Here when sharing a Match, we also share objects accessed through the names of the properties to get TeamScore, TeamRegistration and PlayerRegistration. |
||||
|
||||
- It's also possible to exclude a class from being sharable by setting sharable = False in its definition. In PadelClub, Club is the top entity that links all data together, so we don't want the automatic data scanning to share clubs. |
||||
@ -0,0 +1,18 @@ |
||||
# Generated by Django 5.1 on 2025-06-12 13:57 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('sync', '0006_alter_modellog_operation'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='dataaccess', |
||||
name='store_id', |
||||
field=models.CharField(default='', max_length=100), |
||||
), |
||||
] |
||||
@ -0,0 +1,18 @@ |
||||
# Generated by Django 5.1 on 2025-06-26 12:20 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('sync', '0007_dataaccess_store_id'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='dataaccess', |
||||
name='store_id', |
||||
field=models.CharField(blank=True, default='', max_length=100, null=True), |
||||
), |
||||
] |
||||
@ -0,0 +1,17 @@ |
||||
# Generated by Django 5.1 on 2025-08-07 16:51 |
||||
|
||||
from django.db import migrations |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('sync', '0008_alter_dataaccess_store_id'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterModelOptions( |
||||
name='dataaccess', |
||||
options={'verbose_name_plural': 'Data Access'}, |
||||
), |
||||
] |
||||
@ -0,0 +1,194 @@ |
||||
from django.conf import settings |
||||
|
||||
from typing import List, Dict |
||||
from .registry import model_registry |
||||
|
||||
import logging |
||||
|
||||
logger = logging.getLogger(__name__) |
||||
|
||||
class SyncModelChildrenManager: |
||||
""" |
||||
Manager class for handling model children sharing configuration. |
||||
Reads the SYNC_MODEL_CHILDREN_SHARING setting once and builds a bidirectional |
||||
relationship graph for efficient lookup. |
||||
""" |
||||
|
||||
def __init__(self): |
||||
"""Initialize the manager by reading the Django setting and building the relationship graph.""" |
||||
self._model_relationships = getattr( |
||||
settings, |
||||
'SYNC_MODEL_CHILDREN_SHARING', |
||||
{} |
||||
) |
||||
self._relationship_graph = {} |
||||
|
||||
def _build_relationship_graph(self) -> Dict[str, List[List[str]]]: |
||||
""" |
||||
Build a bidirectional relationship graph. |
||||
|
||||
Returns: |
||||
Dict[str, List[List[str]]]: Dictionary where keys are model names and values |
||||
are lists of relationship paths (arrays of relationship names). |
||||
""" |
||||
graph = {} |
||||
|
||||
# Add direct relationships (original models to their children) |
||||
for model_name, relationships in self._model_relationships.items(): |
||||
if model_name not in graph: |
||||
graph[model_name] = [] |
||||
graph[model_name].append(relationships) |
||||
|
||||
# Build reverse relationships (children back to original models) |
||||
for original_model_name, relationships in self._model_relationships.items(): |
||||
|
||||
try: |
||||
current_model = model_registry.get_model(original_model_name) |
||||
if current_model is None: |
||||
print(f'missing {original_model_name}') |
||||
continue |
||||
|
||||
current_reverse_path = [] |
||||
|
||||
for relationship_name in relationships: |
||||
# Get the related model through _meta |
||||
try: |
||||
field = None |
||||
# Try to find the field in the model's _meta |
||||
for f in current_model._meta.get_fields(): |
||||
if hasattr(f, 'related_name') and f.related_name == relationship_name: |
||||
field = f |
||||
break |
||||
elif hasattr(f, 'name') and f.name == relationship_name: |
||||
field = f |
||||
break |
||||
|
||||
if field is None: |
||||
continue |
||||
|
||||
# Get the related model |
||||
if hasattr(field, 'related_model'): |
||||
related_model = field.related_model |
||||
elif hasattr(field, 'model'): |
||||
related_model = field.model |
||||
else: |
||||
continue |
||||
|
||||
related_model_name = related_model.__name__ |
||||
|
||||
# Find the reverse relationship name |
||||
reverse_relationship_name = self._find_reverse_relationship( |
||||
relationship_name, current_model, related_model |
||||
) |
||||
|
||||
if reverse_relationship_name: |
||||
current_reverse_path.append(reverse_relationship_name) |
||||
# Add the reverse path |
||||
if related_model_name not in graph: |
||||
graph[related_model_name] = [] |
||||
|
||||
# The path back is just the reverse relationship name |
||||
graph[related_model_name].append(current_reverse_path[::-1]) # make a reverse copy |
||||
|
||||
current_model = related_model |
||||
|
||||
except Exception as e: |
||||
logger.info(f'error 2 > {e}') |
||||
# Skip problematic relationships |
||||
continue |
||||
|
||||
except Exception as e: |
||||
logger.info(f'error > {e}') |
||||
continue |
||||
|
||||
return graph |
||||
|
||||
def _find_reverse_relationship(self, original_relationship_name, in_model, for_model): |
||||
""" |
||||
Find the reverse relationship name from from_model to to_model. |
||||
|
||||
Args: |
||||
from_model: The model to search relationships from |
||||
to_model: The target model to find relationship to |
||||
original_relationship_name: The original relationship name for context |
||||
|
||||
Returns: |
||||
str or None: The reverse relationship name if found |
||||
""" |
||||
|
||||
# print(f'reverse of {original_relationship_name} in = {in_model} for {for_model} ') |
||||
try: |
||||
for field in for_model._meta.get_fields(): |
||||
# Check ForeignKey, OneToOneField fields |
||||
# print(f'{for_model} : field name = {field.name} / field.related_model = {field.related_model == in_model}') |
||||
if hasattr(field, 'related_model') and field.related_model == in_model: |
||||
return field.name |
||||
### possible improvements to do here if multiple relationships of the same type |
||||
|
||||
|
||||
# Check if this field has a related_name that matches our original relationship |
||||
# if hasattr(field, 'related_name') and field.related_name == original_relationship_name: |
||||
# # This is the reverse of our original relationship |
||||
# return field.name |
||||
# elif not hasattr(field, 'related_name') or field.related_name is None: |
||||
# default_name = f"{in_model._meta.related_query_name}" |
||||
# print(f'no related name: {default_name} / {original_relationship_name.rstrip('s')} ') |
||||
# if default_name == original_relationship_name.rstrip('s'): # Simple heuristic |
||||
# return field.name |
||||
|
||||
# Check reverse relationships |
||||
if hasattr(field, 'field') and hasattr(field.field, 'model'): |
||||
if field.field.model == in_model: |
||||
if field.get_accessor_name() == original_relationship_name: |
||||
return field.field.name |
||||
|
||||
except Exception as e: |
||||
print(f'!! ERROR = {e}') |
||||
pass |
||||
|
||||
return None |
||||
|
||||
def get_relationships(self, model_name: str) -> List[str]: |
||||
""" |
||||
Get the list of direct relationships for a given model name. |
||||
|
||||
Args: |
||||
model_name (str): The name of the model to look up |
||||
|
||||
Returns: |
||||
List[str]: List of relationship names for the model. |
||||
Returns empty list if model is not found. |
||||
""" |
||||
|
||||
return self._model_relationships.get(model_name, []) |
||||
|
||||
def get_relationship_paths(self, model_name: str) -> List[List[str]]: |
||||
""" |
||||
Get all relationship paths for a given model name. |
||||
This includes both direct relationships and reverse paths. |
||||
|
||||
Args: |
||||
model_name (str): The name of the model to look up |
||||
|
||||
Returns: |
||||
List[List[str]]: List of relationship paths (each path is a list of relationship names). |
||||
Returns empty list if model is not found. |
||||
""" |
||||
if not self._relationship_graph: |
||||
self._relationship_graph = self._build_relationship_graph() |
||||
# logger.info(f'self._relationship_graph = {self._relationship_graph}') |
||||
|
||||
return self._relationship_graph.get(model_name, []) |
||||
|
||||
def get_relationship_graph(self) -> Dict[str, List[List[str]]]: |
||||
""" |
||||
Get the complete relationship graph. |
||||
|
||||
Returns: |
||||
Dict[str, List[List[str]]]: The complete relationship graph |
||||
""" |
||||
return self._relationship_graph.copy() |
||||
|
||||
|
||||
# Create a singleton instance to use throughout the application |
||||
sync_model_manager = SyncModelChildrenManager() |
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,222 @@ |
||||
from django.core.management.base import BaseCommand |
||||
from datetime import datetime, timedelta |
||||
import logging |
||||
|
||||
class Command(BaseCommand): |
||||
help = 'Test FFT all tournaments scraping with various filters' |
||||
|
||||
def add_arguments(self, parser): |
||||
parser.add_argument( |
||||
'--sorting', |
||||
type=str, |
||||
default='dateDebut+asc', |
||||
choices=['dateDebut+asc', 'dateDebut+desc', '_DISTANCE_'], |
||||
help='Sorting option (default: dateDebut+asc)' |
||||
) |
||||
parser.add_argument( |
||||
'--page', |
||||
type=int, |
||||
default=0, |
||||
help='Page number to scrape (default: 0)' |
||||
) |
||||
parser.add_argument( |
||||
'--city', |
||||
type=str, |
||||
default='', |
||||
help='City to search around' |
||||
) |
||||
parser.add_argument( |
||||
'--distance', |
||||
type=float, |
||||
default=15.0, |
||||
help='Distance in km (default: 15)' |
||||
) |
||||
parser.add_argument( |
||||
'--categories', |
||||
nargs='*', |
||||
default=[], |
||||
help='Tournament categories to filter by' |
||||
) |
||||
parser.add_argument( |
||||
'--levels', |
||||
nargs='*', |
||||
default=[], |
||||
help='Tournament levels to filter by' |
||||
) |
||||
parser.add_argument( |
||||
'--ages', |
||||
nargs='*', |
||||
default=[], |
||||
help='Age categories to filter by' |
||||
) |
||||
parser.add_argument( |
||||
'--types', |
||||
nargs='*', |
||||
default=[], |
||||
help='Tournament types to filter by' |
||||
) |
||||
parser.add_argument( |
||||
'--national-cup', |
||||
action='store_true', |
||||
help='Filter for national cup tournaments only' |
||||
) |
||||
parser.add_argument( |
||||
'--lat', |
||||
type=float, |
||||
help='Latitude for location-based search' |
||||
) |
||||
parser.add_argument( |
||||
'--lng', |
||||
type=float, |
||||
help='Longitude for location-based search' |
||||
) |
||||
parser.add_argument( |
||||
'--days-ahead', |
||||
type=int, |
||||
default=90, |
||||
help='How many days ahead to search (default: 90)' |
||||
) |
||||
parser.add_argument( |
||||
'--start-date', |
||||
type=str, |
||||
help='Start date in DD/MM/YY format (overrides --days-ahead)' |
||||
) |
||||
parser.add_argument( |
||||
'--end-date', |
||||
type=str, |
||||
help='End date in DD/MM/YY format (overrides --days-ahead)' |
||||
) |
||||
parser.add_argument( |
||||
'--verbose', |
||||
action='store_true', |
||||
help='Enable verbose logging' |
||||
) |
||||
|
||||
def handle(self, *args, **options): |
||||
if options['verbose']: |
||||
logging.basicConfig(level=logging.INFO) |
||||
|
||||
# Extract options |
||||
sorting_option = options['sorting'] |
||||
page = options['page'] |
||||
city = options['city'] |
||||
distance = options['distance'] |
||||
categories = options['categories'] |
||||
levels = options['levels'] |
||||
ages = options['ages'] |
||||
tournament_types = options['types'] |
||||
national_cup = options['national_cup'] |
||||
lat = options['lat'] |
||||
lng = options['lng'] |
||||
verbose = options['verbose'] |
||||
|
||||
# Calculate date range |
||||
if options['start_date'] and options['end_date']: |
||||
start_date_str = options['start_date'] |
||||
end_date_str = options['end_date'] |
||||
else: |
||||
start_date = datetime.now() |
||||
end_date = start_date + timedelta(days=options['days_ahead']) |
||||
start_date_str = start_date.strftime('%d/%m/%y') |
||||
end_date_str = end_date.strftime('%d/%m/%y') |
||||
|
||||
self.stdout.write(self.style.SUCCESS("=== FFT All Tournaments Scraper ===")) |
||||
self.stdout.write(f"Sorting: {sorting_option}") |
||||
self.stdout.write(f"Page: {page}") |
||||
self.stdout.write(f"Date range: {start_date_str} to {end_date_str}") |
||||
self.stdout.write(f"City: {city if city else 'Not specified'}") |
||||
self.stdout.write(f"Distance: {distance} km") |
||||
self.stdout.write(f"Categories: {categories if categories else 'All'}") |
||||
self.stdout.write(f"Levels: {levels if levels else 'All'}") |
||||
self.stdout.write(f"Ages: {ages if ages else 'All'}") |
||||
self.stdout.write(f"Types: {tournament_types if tournament_types else 'All'}") |
||||
self.stdout.write(f"National Cup: {'Yes' if national_cup else 'No'}") |
||||
if lat and lng: |
||||
self.stdout.write(f"Location: {lat}, {lng}") |
||||
self.stdout.write(f"Method: Playwright (Chrome-free)") |
||||
self.stdout.write("") |
||||
|
||||
try: |
||||
from api.utils import scrape_fft_all_tournaments |
||||
self.stdout.write("🚀 Testing general tournament scraping...") |
||||
|
||||
result = scrape_fft_all_tournaments( |
||||
sorting_option=sorting_option, |
||||
page=page, |
||||
start_date=start_date_str, |
||||
end_date=end_date_str, |
||||
city=city, |
||||
distance=distance, |
||||
categories=categories, |
||||
levels=levels, |
||||
lat=lat, |
||||
lng=lng, |
||||
ages=ages, |
||||
tournament_types=tournament_types, |
||||
national_cup=national_cup |
||||
) |
||||
|
||||
# Debug: Show what we got (only in verbose mode) |
||||
if verbose: |
||||
self.stdout.write(f"🔍 Raw result: {result}") |
||||
|
||||
if result: |
||||
tournaments = result.get('tournaments', []) |
||||
self.stdout.write(self.style.SUCCESS(f"✅ SUCCESS: {len(tournaments)} tournaments found")) |
||||
|
||||
if tournaments: |
||||
self.stdout.write("\n📝 Sample tournaments:") |
||||
# Show first 3 tournaments |
||||
for i, tournament in enumerate(tournaments[:3]): |
||||
self.stdout.write(f"\n Tournament {i+1}:") |
||||
self.stdout.write(f" ID: {tournament.get('id')}") |
||||
self.stdout.write(f" Name: {tournament.get('libelle')}") |
||||
self.stdout.write(f" Date: {tournament.get('dateDebut', {}).get('date', 'N/A')}") |
||||
self.stdout.write(f" Club: {tournament.get('nomClub', 'N/A')}") |
||||
self.stdout.write(f" City: {tournament.get('villeEngagement', 'N/A')}") |
||||
self.stdout.write(f" Category: {tournament.get('categorieTournoi', 'N/A')}") |
||||
self.stdout.write(f" Type: {tournament.get('type', 'N/A')}") |
||||
if tournament.get('jugeArbitre'): |
||||
self.stdout.write(f" Judge: {tournament.get('jugeArbitre', {}).get('nom', 'N/A')}") |
||||
|
||||
self.stdout.write(f"\n📊 Summary:") |
||||
self.stdout.write(f" Total tournaments: {len(tournaments)}") |
||||
self.stdout.write(f" Current page: {page}") |
||||
self.stdout.write(f" Total results available: {result.get('total_results', 'Unknown')}") |
||||
|
||||
# Analysis of results |
||||
if tournaments: |
||||
cities = set() |
||||
clubs = set() |
||||
categories = set() |
||||
types = set() |
||||
|
||||
for tournament in tournaments: |
||||
if tournament.get('villeEngagement'): |
||||
cities.add(tournament['villeEngagement']) |
||||
if tournament.get('nomClub'): |
||||
clubs.add(tournament['nomClub']) |
||||
if tournament.get('categorieTournoi'): |
||||
categories.add(tournament['categorieTournoi']) |
||||
if tournament.get('type'): |
||||
types.add(tournament['type']) |
||||
|
||||
self.stdout.write(f"\n🔍 Analysis:") |
||||
self.stdout.write(f" Unique cities: {len(cities)}") |
||||
self.stdout.write(f" Unique clubs: {len(clubs)}") |
||||
self.stdout.write(f" Unique categories: {len(categories)}") |
||||
self.stdout.write(f" Unique types: {len(types)}") |
||||
|
||||
if verbose: |
||||
self.stdout.write(f"\n Cities: {sorted(list(cities))[:10]}") # Show first 10 |
||||
self.stdout.write(f" Categories: {sorted(list(categories))}") |
||||
self.stdout.write(f" Types: {sorted(list(types))}") |
||||
|
||||
else: |
||||
self.stdout.write(self.style.ERROR("❌ FAILED: No tournaments found")) |
||||
|
||||
except Exception as e: |
||||
self.stdout.write(self.style.ERROR(f"❌ ERROR: {e}")) |
||||
import traceback |
||||
if verbose: |
||||
self.stdout.write(traceback.format_exc()) |
||||
@ -0,0 +1,103 @@ |
||||
from django.core.management.base import BaseCommand |
||||
from datetime import datetime, timedelta |
||||
import logging |
||||
|
||||
class Command(BaseCommand): |
||||
help = 'Test FFT tournament scraping with Playwright' |
||||
|
||||
def add_arguments(self, parser): |
||||
parser.add_argument( |
||||
'--club-code', |
||||
type=str, |
||||
default='62130180', |
||||
help='Club code for testing (default: 62130180)' |
||||
) |
||||
parser.add_argument( |
||||
'--club-name', |
||||
type=str, |
||||
default='TENNIS SPORTING CLUB DE CASSIS', |
||||
help='Club name for testing' |
||||
) |
||||
parser.add_argument( |
||||
'--all-pages', |
||||
action='store_true', |
||||
help='Test all pages scraping' |
||||
) |
||||
parser.add_argument( |
||||
'--verbose', |
||||
action='store_true', |
||||
help='Enable verbose logging' |
||||
) |
||||
|
||||
def handle(self, *args, **options): |
||||
if options['verbose']: |
||||
logging.basicConfig(level=logging.INFO) |
||||
|
||||
club_code = options['club_code'] |
||||
club_name = options['club_name'] |
||||
all_pages = options['all_pages'] |
||||
verbose = options['verbose'] |
||||
|
||||
# Calculate date range |
||||
start_date = datetime.now() |
||||
end_date = start_date + timedelta(days=90) |
||||
start_date_str = start_date.strftime('%d/%m/%y') |
||||
end_date_str = end_date.strftime('%d/%m/%y') |
||||
|
||||
self.stdout.write(self.style.SUCCESS("=== FFT Tournament Scraper ===")) |
||||
self.stdout.write(f"Club: {club_name} ({club_code})") |
||||
self.stdout.write(f"Date range: {start_date_str} to {end_date_str}") |
||||
self.stdout.write(f"Method: Playwright (Chrome-free)") |
||||
self.stdout.write("") |
||||
|
||||
try: |
||||
if all_pages: |
||||
from api.utils import scrape_fft_club_tournaments_all_pages |
||||
self.stdout.write("🚀 Testing complete tournament scraping...") |
||||
|
||||
result = scrape_fft_club_tournaments_all_pages( |
||||
club_code=club_code, |
||||
club_name=club_name, |
||||
start_date=start_date_str, |
||||
end_date=end_date_str |
||||
) |
||||
else: |
||||
from api.utils import scrape_fft_club_tournaments |
||||
self.stdout.write("🚀 Testing single page scraping...") |
||||
|
||||
result = scrape_fft_club_tournaments( |
||||
club_code=club_code, |
||||
club_name=club_name, |
||||
start_date=start_date_str, |
||||
end_date=end_date_str, |
||||
page=0 |
||||
) |
||||
|
||||
# Debug: Show what we got (only in verbose mode) |
||||
if verbose: |
||||
self.stdout.write(f"🔍 Raw result: {result}") |
||||
|
||||
if result: |
||||
tournaments = result.get('tournaments', []) |
||||
self.stdout.write(self.style.SUCCESS(f"✅ SUCCESS: {len(tournaments)} tournaments found")) |
||||
|
||||
if tournaments: |
||||
self.stdout.write("\n📝 Sample tournament:") |
||||
sample = tournaments[0] |
||||
self.stdout.write(f" ID: {sample.get('id')}") |
||||
self.stdout.write(f" Name: {sample.get('libelle')}") |
||||
self.stdout.write(f" Date: {sample.get('dateDebut', {}).get('date', 'N/A')}") |
||||
self.stdout.write(f" Judge: {sample.get('jugeArbitre', {}).get('nom', 'N/A')}") |
||||
|
||||
self.stdout.write(f"\n📊 Summary:") |
||||
self.stdout.write(f" Total tournaments: {len(tournaments)}") |
||||
self.stdout.write(f" Pages scraped: {result.get('pages_scraped', 1)}") |
||||
|
||||
else: |
||||
self.stdout.write(self.style.ERROR("❌ FAILED: No tournaments found")) |
||||
|
||||
except Exception as e: |
||||
self.stdout.write(self.style.ERROR(f"❌ ERROR: {e}")) |
||||
import traceback |
||||
if verbose: |
||||
self.stdout.write(traceback.format_exc()) |
||||
@ -1,53 +0,0 @@ |
||||
from django.urls import reverse |
||||
from django.utils import timezone |
||||
import datetime |
||||
|
||||
class ReferrerMiddleware: |
||||
def __init__(self, get_response): |
||||
self.get_response = get_response |
||||
|
||||
def __call__(self, request): |
||||
# Check if the user is anonymous and going to the login page |
||||
if not request.user.is_authenticated and request.path == reverse('login'): |
||||
# Get the referring URL from the HTTP_REFERER header |
||||
referrer = request.META.get('HTTP_REFERER') |
||||
|
||||
# Only store referrer if it exists and is not the login page itself |
||||
if referrer and 'login' not in referrer: |
||||
request.session['login_referrer'] = referrer |
||||
|
||||
response = self.get_response(request) |
||||
return response |
||||
|
||||
class RegistrationCartCleanupMiddleware: |
||||
def __init__(self, get_response): |
||||
self.get_response = get_response |
||||
|
||||
def __call__(self, request): |
||||
self._check_and_clean_expired_cart(request) |
||||
response = self.get_response(request) |
||||
return response |
||||
|
||||
def _check_and_clean_expired_cart(self, request): |
||||
if 'registration_cart_expiry' in request.session: |
||||
try: |
||||
expiry_str = request.session['registration_cart_expiry'] |
||||
expiry = datetime.datetime.fromisoformat(expiry_str) |
||||
if timezone.now() > expiry: |
||||
# Clear expired cart |
||||
keys_to_delete = [ |
||||
'registration_cart_id', |
||||
'registration_tournament_id', |
||||
'registration_cart_players', |
||||
'registration_cart_expiry', |
||||
'registration_mobile_number' |
||||
] |
||||
for key in keys_to_delete: |
||||
if key in request.session: |
||||
del request.session[key] |
||||
request.session.modified = True |
||||
except (ValueError, TypeError): |
||||
# Invalid expiry format, clear it |
||||
if 'registration_cart_expiry' in request.session: |
||||
del request.session['registration_cart_expiry'] |
||||
request.session.modified = True |
||||
@ -0,0 +1,18 @@ |
||||
# Generated by Django 5.1 on 2025-06-23 16:47 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('tournaments', '0128_club_data_access_ids_court_data_access_ids_and_more'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='tournament', |
||||
name='currency_code', |
||||
field=models.CharField(blank=True, default='EUR', max_length=3, null=True), |
||||
), |
||||
] |
||||
@ -0,0 +1,28 @@ |
||||
# Generated by Django 5.1 on 2025-06-25 14:38 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('tournaments', '0129_tournament_currency_code'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='playerregistration', |
||||
name='contact_email', |
||||
field=models.CharField(blank=True, max_length=50, null=True), |
||||
), |
||||
migrations.AddField( |
||||
model_name='playerregistration', |
||||
name='contact_name', |
||||
field=models.CharField(blank=True, max_length=50, null=True), |
||||
), |
||||
migrations.AddField( |
||||
model_name='playerregistration', |
||||
name='contact_phone_number', |
||||
field=models.CharField(blank=True, max_length=50, null=True), |
||||
), |
||||
] |
||||
@ -0,0 +1,18 @@ |
||||
# Generated by Django 5.1 on 2025-07-03 10:01 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('tournaments', '0130_playerregistration_contact_email_and_more'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='playerregistration', |
||||
name='contact_name', |
||||
field=models.CharField(blank=True, max_length=200, null=True), |
||||
), |
||||
] |
||||
@ -0,0 +1,20 @@ |
||||
# Generated by Django 5.1 on 2025-07-09 12:55 |
||||
|
||||
import django.db.models.deletion |
||||
from django.conf import settings |
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('tournaments', '0131_alter_playerregistration_contact_name'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='purchase', |
||||
name='user', |
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchases', to=settings.AUTH_USER_MODEL), |
||||
), |
||||
] |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue