Compare commits

..

366 Commits
sync3 ... main

Author SHA1 Message Date
Laurent 3ce30cf5f7 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 3 days ago
Laurent cf28db9fd0 avoid getting mails for get_object_or_404 failures 3 days ago
Razmig Sarkissian 08fd01e119 add nov 2025 rank 6 days ago
Razmig Sarkissian f97dbd79cc Enhance Club Selection Form in EventAdmin Action 7 days ago
Razmig Sarkissian 240bb3fc25 Add admin template for setting club for multiple events 7 days ago
Razmig Sarkissian 5102e4c295 Add set club bulk action to EventAdmin 7 days ago
Razmig Sarkissian 6be947706e Increase tournament listing limit from 50 to 100 1 week ago
Razmig Sarkissian eb25d0e609 Add get_last_name method for anonymous players 1 week ago
Razmig Sarkissian efdb414345 Remove synchronization check when creating ModelLogs 2 weeks ago
Razmig Sarkissian 85c56981a6 Add null check to umpire_mail method 2 weeks ago
Razmig Sarkissian f01a681e93 Add null check for event and event creator in umpire_phone 2 weeks ago
Razmig Sarkissian 3522ee87f5 Update player_registration.py 2 weeks ago
Razmig Sarkissian e215ca7e1d Add "My Team" link to tournament navigation for authenticated users 2 weeks ago
Razmig Sarkissian 174c2988b2 Update Padel rankings unranked male values 2 weeks ago
Razmig Sarkissian ec079e1a7a Add debug logging to Round and Tournament Bracket fix annelise issue 2 weeks ago
Laurent a31796aad0 fix date issue 3 weeks ago
Laurent 7c31c511dd revert 3 weeks ago
Laurent 441815d9a8 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 3 weeks ago
Laurent 521acaf747 fix issue 3 weeks ago
Razmig Sarkissian 93a27f9583 Modify team unregistration to cancel registration instead of deleting 3 weeks ago
Laurent d9130b0fdf updates last_update on each save 3 weeks ago
Laurent 1218c74d26 fix WS notification issue 3 weeks ago
Razmig Sarkissian 49d497d48f Cancel Team Registration by Deleting Team Registration 3 weeks ago
Razmig Sarkissian 0d330f3dcf Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 3 weeks ago
Razmig Sarkissian 34924db360 Add user-initiated registration cancellation logic 3 weeks ago
Razmig Sarkissian 11d6913807 Improve Payment Service: Add Transaction Safety and Error Handling 3 weeks ago
Laurent 59a39ffd49 Adds round index filter for TeamScore 3 weeks ago
Laurent 7bf560a6a2 sync get improvements and logs 3 weeks ago
Laurent 77b999fbb3 logs update 3 weeks ago
Razmig Sarkissian 8de8a9ac49 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 weeks ago
Razmig Sarkissian 80b6bc1136 Fix email filtering for tournament registrations 4 weeks ago
Laurent 1339b65731 Fix crash 4 weeks ago
Razmig Sarkissian 0158ce150d Add API endpoint to get payment link for team registration 4 weeks ago
Razmig Sarkissian fcb2ef9549 Add force_send option to resend registration emails 4 weeks ago
Razmig Sarkissian 005d8877e7 Add migration for new fields in Activity and Tournament models 4 weeks ago
Razmig Sarkissian 093015dac6 Add custom club name field to Tournament model 4 weeks ago
Razmig Sarkissian f152a441d4 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 weeks ago
Razmig Sarkissian ef0e7b6326 Add missing player information from authenticated user profile 4 weeks ago
Laurent 1572bed50d fix margin 4 weeks ago
Laurent 4fb9460572 Adds distinct email in the dashboard 4 weeks ago
Laurent 015934c663 change TeamRegistrationAdmin 4 weeks ago
Laurent 16bc3e4428 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 weeks ago
Laurent 3db14c6180 attempt to fix crash 4 weeks ago
Razmig Sarkissian 6918677009 Fix user age calculation and handle 'N/A' birth year 4 weeks ago
Razmig Sarkissian 3b56d59321 update ranking oct 2025 accuracy 4 weeks ago
Razmig Sarkissian 7c1c37746c Improve webhook handling and validation logic 1 month ago
Laurent 908c0b7dc8 Activity changes update the last_update field of the Prospect 1 month ago
Laurent 00228e4c8a Add links to the prospects dashboard 1 month ago
Laurent 808d65a5a3 adds a plus button to add activities to prospect in the dashboard, and setting related_user if needed 1 month ago
Laurent 0f7516a617 update dashboard with the contact again list 1 month ago
Laurent 1bf31744b5 change many to many to autocomplete in the admin 1 month ago
Laurent 4df1ccba28 dashboard improvements 1 month ago
Laurent 108fd9cafa UI improvements for the prospect dashboard 1 month ago
Laurent 76b0b02933 Adds prospects dashboard 1 month ago
Laurent 6a8b5c4d97 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Laurent 69ad1bef02 Adds Whatsapp as contact mean 1 month ago
Razmig Sarkissian 632651a5ef Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Razmig Sarkissian b6de2f5653 Only serve media files in development mode 1 month ago
Laurent 8583523a0e Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Laurent c6d5af345f Adds declination reasons 1 month ago
Razmig Sarkissian 5c36eb8781 Add umpire data export functionality 1 month ago
Razmig Sarkissian 35884b728f Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Razmig Sarkissian 511066ccc8 Add support for single player tournament registration 1 month ago
Laurent c8c54b5ac8 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Laurent 12ddbb2c29 update PurchaseAdmin 1 month ago
Razmig Sarkissian 2d0dfd1b8f Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Razmig Sarkissian ff7718d044 add oct 2025 rankings 1 month ago
Laurent 169bc465c5 change param list 1 month ago
Laurent 7120bddd26 adds phone to prospect list 1 month ago
Laurent 58557c01aa fix sync crash 1 month ago
Laurent c37a6a8c12 Adds mobile only filter for prospects 1 month ago
Laurent 85cdf26fcf update activity admin 1 month ago
Razmig Sarkissian ae4660ffb8 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Razmig Sarkissian 358f025cc5 Remove debug logging statements 1 month ago
Laurent 95496508ac test 1 month ago
Laurent a221bb0090 adds log to sync signal 1 month ago
Laurent a7dd1a4122 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Laurent 23e1651dad update 1 month ago
Laurent ff8788c527 remove biz objects from sync 1 month ago
Razmig Sarkissian 06e8375e15 Remove duplicate media file serving pattern 1 month ago
Razmig Sarkissian 9e9d476922 Add media files serving configuration for production 1 month ago
Razmig Sarkissian d9c7a1ae4a Serve media files in all environments 1 month ago
Laurent 73c18bfbf8 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Laurent 5d60993748 Adds ModelLog creation for DataAccess to be shared between concerned users 1 month ago
Razmig Sarkissian 1404adc802 Replace payment link endpoints with resend email option 1 month ago
Razmig Sarkissian f4d8b1a536 Add support for corporate tournament Stripe payments 1 month ago
Razmig Sarkissian 22b06b4494 Remove cancel_url from Stripe checkout params 1 month ago
Razmig Sarkissian c004325ac8 Remove unused tournament model fields 1 month ago
Razmig Sarkissian a5c9765366 Add Stripe payment links for tournament registration 1 month ago
Razmig Sarkissian 4fbfce8393 Fix player contact email handling across service methods 1 month ago
Razmig Sarkissian 371bce35d7 Refactor contact information handling in tournament registration 1 month ago
Laurent 0074548dd4 exclude activities from EmailTemplate admin 1 month ago
Laurent 708e086ded update name replacement in emails 1 month ago
Laurent 60d36278c6 order activities by last_update 1 month ago
Laurent aa6cb5c84e remove logs 1 month ago
Laurent 4e96ba5a13 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Laurent e5ac3750d1 fixes sharing/revocations issues 1 month ago
Razmig Sarkissian de5cd64679 Add HTTPS requirement for production Stripe account links 1 month ago
Razmig Sarkissian 4826b4b8b7 Add debug print statement for Stripe account link base path 1 month ago
Razmig Sarkissian fe3e224d15 Update CustomUserAdmin to use date_joined instead of creation_date 1 month ago
Razmig Sarkissian 8f7b21d0de Add 'creation_date' to CustomUserAdmin user fields 1 month ago
Razmig Sarkissian 769f969ba2 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Razmig Sarkissian 5e45b6d96a Refactor Tournament Summary Serializer for enhanced readability 1 month ago
Laurent 1a9eb14dd9 reorder filters 1 month ago
Laurent a8276d5f4b Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent c8ea7699c5 adds prospect option 2 months ago
Razmig Sarkissian e6756f40dd Remove unused BeautifulSoup import from utils.py 2 months ago
Razmig Sarkissian 9c2fbed0d5 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 2c47025a77 Refactor Playwright scraping with environment-specific browser and 2 months ago
Laurent 5754a655bc Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent de4336d13a add search by phone for prospect 2 months ago
Razmig Sarkissian a7cbf4c6a6 Refactor FFT tournament scraping using Playwright with detailed error 2 months ago
Razmig Sarkissian 34d8fac0d5 Refactor FFT tournament scraping with Queue-It fallback 2 months ago
Razmig Sarkissian 7d997fdb7d Remove hardcoded page wait timeouts 2 months ago
Razmig Sarkissian 40705b061c Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 1269b97765 add waiting fft scraping 2 months ago
Laurent cf831be3c6 remove reset button 2 months ago
Laurent e0047fbdc3 ease search for prospects 2 months ago
Laurent 9d71efb51a adds an activity when sending an email 2 months ago
Laurent 03f860cf48 improve mail sending error management 2 months ago
Laurent 409777f6b6 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent 8132826866 renamed agents into supervisors and added organizers who can create tournaments 2 months ago
Razmig Sarkissian 33f170dd3e Replace timezone.localtime with local_planned_start_date method 2 months ago
Razmig Sarkissian 9a3f92306d Replace timezone.localtime with local planned start date method 2 months ago
Razmig Sarkissian 6875081097 Fix double butterfly mode condition for rounds count 2 months ago
Razmig Sarkissian ad6139852d Improve player name display in tournaments 2 months ago
Razmig Sarkissian e6a6268143 Fix tournament registration fee calculation 2 months ago
Razmig Sarkissian 7709409a63 Fix tournament cart fee calculation logic 2 months ago
Razmig Sarkissian 1019c20890 Fix fee calculation for tournament registration 2 months ago
Razmig Sarkissian 721650a8b6 Fix name display with null checks 2 months ago
Razmig Sarkissian c0d97721dd Fix calculation of tournament registration fee 2 months ago
Laurent 8c4799c1e6 fix problem with event admin 2 months ago
Laurent b1690b44c9 adds prospect filter to remove mobile phone prospect 2 months ago
Laurent 05c70c94e1 hide data_access_ids 2 months ago
Laurent 8c8cc21895 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent c41eadfe36 small improvements on new stuff 2 months ago
Razmig Sarkissian 309e3d7ee1 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 89e68c3033 Fix double butterfly match positioning in quarterfinals 2 months ago
Laurent b3a20f69f4 fix issue 2 months ago
Laurent 3af02f98a7 remove id change 2 months ago
Laurent e1e2fb08ef rename Campaign into ProspectGroup 2 months ago
Laurent 146dae4039 update gitignore 2 months ago
Laurent d4de2ae399 replace int id by uuid id + bonus 2 months ago
Laurent f1c02a7d1b Adds new Campaign model to make groups of users + way of creating them from users 2 months ago
Laurent 147c8e9ba3 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent 09282698cf Adds sharable property to BaseModel classes 2 months ago
Razmig Sarkissian 26b7aea651 Move online registration block inside custom animation check 2 months ago
Razmig Sarkissian 289e8e8e8c Hide online registration for custom animation tournaments 2 months ago
Razmig Sarkissian 07d5d20800 fix special custom animation stuff 2 months ago
Razmig Sarkissian 99a722c63c Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 7fec722362 Add time indication for matches in broadcast bracket 2 months ago
Laurent 46001fc4e7 fix admin issue 2 months ago
Laurent 17d5a1e7a5 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent d5b2591925 add action to convert users into prospects 2 months ago
Razmig Sarkissian 9717e71988 Change IntegrityError to ValidationError in user serializer 2 months ago
Razmig Sarkissian ac76622995 Add min_start_date filter to Tournament summary view 2 months ago
Razmig Sarkissian 754c1d5796 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 9666a998c4 Fix bracket rendering when both parents are disabled 2 months ago
Laurent b2bd41b737 admin update 2 months ago
Laurent 980e5f6420 add raw_id for events 2 months ago
Laurent 75a00c0fa9 add raw_id fields for tournament 2 months ago
Laurent 319efc28f5 adds id of DataAccess in the admin 2 months ago
Laurent 334bcad30f Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent 30b17810e9 update offer 2 months ago
Razmig Sarkissian 1482f7f670 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 5320d0a5be Remove ReferrerMiddleware and move logic to login view 2 months ago
Laurent 10168de3cd adds filter for user that have or dont have a prospect with the same email 2 months ago
Laurent c4be3c9ce2 add filter for user with/without purchases 2 months ago
Laurent 42bdb3bfed Add filter for user that have created an event 2 months ago
Laurent bc792ed470 make user a raw_id field 2 months ago
Laurent 6759ce7af8 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent 08ada3d771 adds api keys for api usage 2 months ago
Razmig Sarkissian 82900cfe5e Add confirmation time to registration deadline 2 months ago
Razmig Sarkissian eee03f7708 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian b71ac1c645 Remove Registration Cart Cleanup Middleware 2 months ago
Laurent 08f78e7de4 adds first draft for tournament summaries api 2 months ago
Laurent 8faf79fd94 adds claude.md 2 months ago
Laurent 0af9609075 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent d934230880 adds event_count for user in the dashboard 2 months ago
Razmig Sarkissian d0fb9fcac6 Add support for custom tournament animation type 2 months ago
Razmig Sarkissian aa39b9f08b Update email service to handle single-player tournament inscription 2 months ago
Razmig Sarkissian caad565056 Conditionally serve media files in development mode 2 months ago
Razmig Sarkissian 095a446675 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 213527592e Remove DEBUG condition for serving media files 2 months ago
Laurent bedd752824 tournament api returns tournament created by the user 2 months ago
Laurent 47c50780a4 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent 2463c8f8c8 put the shop back online 2 months ago
Razmig Sarkissian 108d2d6451 Extend tournament broadcast search window to 72 hours 2 months ago
Razmig Sarkissian 183d0ee6ec Improve tournament broadcast selection logic 2 months ago
Razmig Sarkissian 61a6f88e6f Update broadcasted_auto.html 2 months ago
Razmig Sarkissian c0d18ed9a1 Add retry mechanism for tournament autobroadcast 2 months ago
Razmig Sarkissian aa7c9bc5aa Update auto-broadcast completion logic for tournaments 2 months ago
Razmig Sarkissian f5a4a18ee5 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 586de4431f Remove Broadcasted Auto Event Template 2 months ago
Laurent 731c3fc1fb Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent ae57ec7bcd improved admin 2 months ago
Razmig Sarkissian 39beecd9cd Change team name class from bold to semibold 2 months ago
Razmig Sarkissian d92849edfc Remove large class from date table cell 2 months ago
Razmig Sarkissian b0b1abd972 Fix tournament list order in automatic broadcast view 2 months ago
Razmig Sarkissian 2c34296a5e Add short name support for animation tournaments 2 months ago
Razmig Sarkissian 26a2465dfb Improve club broadcast page title with broadcast code 2 months ago
Razmig Sarkissian 73571702db Add error handling for tournament data fetching 2 months ago
Razmig Sarkissian 34530f94c5 Add null checks and handle empty lists in broadcast templates 2 months ago
Razmig Sarkissian 1e5bd9a072 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 17d6313042 Add automatic broadcast features and UI improvements 2 months ago
Laurent debf60bc9b Adds a contact_again field for prospects 2 months ago
Razmig Sarkissian bcf5017169 Fix handling of expired team registration sessions 2 months ago
Razmig Sarkissian 31b87cea2f Add payment session expiration and logging 2 months ago
Razmig Sarkissian 84a7047053 Add get_registration_fee methods to registration models 2 months ago
Razmig Sarkissian 758bd60c93 Add Activity status options and fix DataAccess model name 2 months ago
Razmig Sarkissian e58ec43999 Add retry logic and stats for padel rankings download 2 months ago
Razmig Sarkissian 8af498186c Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 28a0219507 Fix index out of bounds error in group stage teams lookup 2 months ago
Laurent 3146b16962 remove other shop link 2 months ago
Laurent ae4c330b1a remove shop from nav bar 2 months ago
Razmig Sarkissian 664b461e6d fix sept ranking 2025 2 months ago
Razmig Sarkissian 0a017d7957 Update max players and unranked values in rankings file 2 months ago
Razmig Sarkissian 9b763e0ed5 September 2025 Rankings 2 months ago
Razmig Sarkissian ecda64402f Add is_anonymous field to PlayerRegistration model 3 months ago
Razmig Sarkissian 0da7941390 Add Anonymous Player Support 3 months ago
Razmig Sarkissian a6ca96d2e9 Add season year parameter for 2026 tournament rules 3 months ago
Razmig Sarkissian 0fae81c45f Update player data in CLASSEMENT-PADEL-MESSIEURS-08-2025.csv 3 months ago
Razmig Sarkissian 79b857f7c9 Fix age calculation to use tournament start date 3 months ago
Razmig Sarkissian 1ba15438ca Update Padel tournament guide PDFs 3 months ago
Razmig Sarkissian f3d41d3b5f Rename Club hidden field to admin_visible 3 months ago
Razmig Sarkissian 2da21d67b2 Add hidden field to Club model 3 months ago
Razmig Sarkissian 1409fe309b Update views.py 3 months ago
Razmig Sarkissian 17f455137c Update admin.py 3 months ago
Razmig Sarkissian 6401914b74 Update views.py 3 months ago
Razmig Sarkissian f613b44152 Update views.py 3 months ago
Razmig Sarkissian ed7031ef24 Update tournament.py 3 months ago
Razmig Sarkissian e4d008956a 08 2025 rankings 3 months ago
Laurent 35acba7332 add android user quick action 3 months ago
Laurent e8ee0fdb42 remove DECLINED_UNRELATED #2 3 months ago
Laurent 93a60ab675 remove DECLINED_UNRELATED 3 months ago
Laurent 86a677d8d8 add Not concerned prospect status 3 months ago
Laurent 24b93f98f3 fix plural for admin 3 months ago
Laurent f1c6df49da adds date for ModelLog 3 months ago
Laurent 1559f954c6 show date not datetime for prospects 3 months ago
Laurent 1174196713 update for prospect list 3 months ago
Laurent 80f8a28eae fix prospect filtering 3 months ago
Laurent 058a1fd1b3 adds a SHOULD_BUY status 3 months ago
Laurent 99f6e54be2 fix crash 3 months ago
Laurent 7dfdd66712 put crm serializers inside the api 3 months ago
Laurent d46da8509f fix prospect name 3 months ago
Laurent 8de30d96ff make prospect email nullable 3 months ago
Laurent 061bfe4795 renamed crm serializers 3 months ago
Laurent ffbf8cf288 add mark as inbound action 3 months ago
Laurent 455928f600 update league concept 3 months ago
Laurent 4636b12deb improve club list 3 months ago
Laurent 83c7e7a97c order clubs by name 3 months ago
Laurent 3c41da77bc improve club list page 3 months ago
Laurent 66d935e83e fix #2 3 months ago
Laurent 59f5f093d1 fix 3 months ago
Laurent bb86086235 fix missing import 3 months ago
Laurent 67879ed6ed improvements 3 months ago
Laurent 966beb6599 adds month/year filtering for tournament lists 3 months ago
Laurent 7fa16f23c4 fix 3 months ago
Laurent cf0b33aa2a add more prefetches 3 months ago
Laurent a1becd8455 fix claude issue 3 months ago
Laurent c6e431a0d3 improved performance with claude 3 months ago
Laurent 06e9daba59 improve performance #4 4 months ago
Laurent c5e910a208 performance #3 4 months ago
Laurent 1e958cca57 improve performance #2 4 months ago
Laurent 0a6b4614fe performance improvement 4 months ago
Laurent b00e01a674 add email link 4 months ago
Laurent 77e635dda5 improve users list 4 months ago
Laurent cfed9030c0 update label 4 months ago
Laurent 38d7c0f293 dashboard update 4 months ago
Laurent 3f2d8bab9a update dashboard 4 months ago
Laurent 6ce616cdf3 dashboard update 4 months ago
Laurent e415e1d574 fix shortcuts 4 months ago
Laurent 0719c9bcd4 update admin 4 months ago
Laurent c1a62cf4e6 rename bizdev into biz 4 months ago
Laurent 0ecf9c1fff put uuid instead of default id file 4 months ago
Laurent becf62d34c hot fix 4 months ago
Laurent e1ffd348d4 change ids to uuid + fix 4 months ago
Laurent 1c8ff0c71a Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 months ago
Laurent 9c84751738 Fix issue when creating an activity from a prospect 4 months ago
Razmig Sarkissian f3ba9d4fc9 Reorder conditions in display_matches method for clarity 4 months ago
Razmig Sarkissian 0ebce12199 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 months ago
Razmig Sarkissian 36e791efe9 Fix tournament display matches logic 4 months ago
Laurent c703fe3d91 add prospects adding to the EntityAdmin 4 months ago
Laurent 95b6390f79 fix other names 4 months ago
Laurent 555fbd59f9 fix action naming 4 months ago
Laurent 4ab605bbc9 add Inbound status + fixes 4 months ago
Laurent c9ad392ce1 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 months ago
Laurent d66d14e178 improve prospect search with entities 4 months ago
Razmig Sarkissian 96dfc77856 Fix team positioning check method in Tournament model 4 months ago
Razmig Sarkissian 2a7736c044 Add method to format time period for tournament description 4 months ago
Razmig Sarkissian 2670369a2b Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 months ago
Razmig Sarkissian 206ff43ae3 Add tournament team positioning check for live view 4 months ago
Laurent fd289a4887 remove related_user from Prospect admin 4 months ago
Laurent 823741e458 improvement in admin 4 months ago
Laurent 8b384ab607 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 months ago
Laurent a0812b9a09 fixes 4 months ago
Razmig Sarkissian 6e25edb545 Add null check for tournament in match formatting 4 months ago
Laurent 5dbc70976c adds bizdev to syncable project 4 months ago
Laurent ed0989d01f adds logging 4 months ago
Laurent d03cb78175 Improve clubs selection in the user admin 4 months ago
Laurent ca35dd1ac3 fix typo 4 months ago
Laurent b1702f6557 CRM improvements 4 months ago
Laurent 5611450360 Fix quick actions 4 months ago
Laurent 35079e1cb1 VRM Improvements 4 months ago
Laurent d3dd2d8ac4 add Reset button 4 months ago
Laurent 16a233f977 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 months ago
Laurent 78457e1428 fixes and improvements 4 months ago
Razmig Sarkissian 86bf0bb356 Update match display logic for group pagination 4 months ago
Razmig Sarkissian 3227ebc5b7 fix issue with planning and crash received by mail 4 months ago
Razmig Sarkissian 891a06df28 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 months ago
Razmig Sarkissian c167f21a96 fix urls fft search 4 months ago
Laurent e8768c4980 adds import for send_mail 4 months ago
Laurent 3891a34242 add logging 4 months ago
Laurent 8092f69713 merge 4 months ago
Laurent 9db872e35c renamed crm into bizdev 4 months ago
laurent 4c1ebaf780 crm migration 4 months ago
laurent edead66c2e timezone migration 4 months ago
Laurent e1671892a0 fix mere 4 months ago
Laurent 285292ac55 merge 4 months ago
Laurent ff0fb01246 CRM update 4 months ago
Razmig Sarkissian f988fc1c06 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 months ago
Razmig Sarkissian 576bc2f273 fix fft search 4 months ago
Razmig Sarkissian 590a652e83 Update utils.py 4 months ago
Laurent a02cf1ee9a Adds bottom margin for logo for better display on phones 4 months ago
Laurent 8002a7a456 add new features 4 months ago
Laurent 3d92efb936 filter by payment id 4 months ago
Laurent 0a5e360daf attempt to fix search 4 months ago
Laurent fb1707c585 add email handler for logging 4 months ago
Laurent 9032f94cdd renamed service 4 months ago
Laurent b3a59f5aa2 adds a service to request if the user has a hierarchy that can pay 4 months ago
Laurent 9c80016575 update dashboard 4 months ago
Laurent 555ac0dc40 use H-1 as the date to determine whether a tournament has started or not 4 months ago
Laurent 66293bfa05 attempt to fix future tournaments #2 4 months ago
Laurent 71e3cdc788 attempt to fix future tournaments 4 months ago
Laurent b7e22ddfae fix attempt for tournaments in progress in other timezones 4 months ago
Razmig Sarkissian a655471384 Remove Programmation links from broadcast templates 4 months ago
Razmig Sarkissian fe62c93671 fix ratings july 4 months ago
Razmig Sarkissian 09620693e8 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 months ago
Razmig Sarkissian ff1fbde52b add some debug tools for gathering fft data 4 months ago
Razmig Sarkissian 81ea23395b Update CLASSEMENT-PADEL-DAMES-07-2025.csv 4 months ago
Laurent 6f8176c6bf Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 months ago
Laurent fa2f7bf45f adds SEO meta data in html pages 4 months ago
Razmig Sarkissian d6924f61c4 add july rankings 4 months ago
Razmig Sarkissian 6d27c10add Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 months ago
Razmig Sarkissian 92dfca9547 Add contact fields to player registration model 4 months ago
Laurent 25c3b3b71b Merge branch 'main' into sync3 4 months ago
Razmig Sarkissian e47ef1427d add debug logging and comment for nouvelle caledonie disabling of 4 months ago
Razmig Sarkissian d6a754b053 fix issue with new caledonie phone numbers 5 months ago
Razmig Sarkissian 2ef88b0803 disable account token verification 5 months ago
Razmig Sarkissian f65fac9661 add multi currency layer for tournament fee 5 months ago
Razmig Sarkissian 17f59c1fcb Update register_tournament.html 5 months ago
Razmig Sarkissian dcc945ee6a fix register tz issue 5 months ago
Razmig Sarkissian a9a5fc87fb fix event hide team mode 5 months ago
Razmig Sarkissian 1f2514b903 Update tournaments_list.html 5 months ago
Razmig Sarkissian b5b5d4e0f5 add event info and prog link if possible 5 months ago
Razmig Sarkissian 6483a0add2 add live section 5 months ago
Razmig Sarkissian 41cda04ba9 Update match.py 5 months ago
Razmig Sarkissian cfc10d0e24 fix dashboard and long cells date 5 months ago
Razmig Sarkissian 882302ab4e Update views.py 5 months ago
Razmig Sarkissian 42c29cf3f6 Update views.py 5 months ago
Razmig Sarkissian 2bff89b3c2 fix private tournament showdown 5 months ago
Razmig Sarkissian b00d5885cc Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 5 months ago
Razmig Sarkissian b7429d9809 fix unranked value june 2025 5 months ago
Laurent 75978ae281 put related_user as raw_id_field 5 months ago
Laurent 55f333013b Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 5 months ago
Laurent e906d37b23 fix useless related_user setting 5 months ago
  1. 1
      .gitignore
  2. 10
      CLAUDE.md
  3. 0
      api/__init__.py
  4. 22
      api/admin.py
  5. 7
      api/apps.py
  6. 24
      api/authentication.py
  7. 36
      api/migrations/0001_initial.py
  8. 0
      api/migrations/__init__.py
  9. 23
      api/models.py
  10. 126
      api/serializers.py
  11. 15
      api/urls.py
  12. 1283
      api/utils.py
  13. 436
      api/views.py
  14. 0
      biz/__init__.py
  15. 534
      biz/admin.py
  16. 70
      biz/admin_urls.py
  17. 4
      biz/apps.py
  18. 163
      biz/filters.py
  19. 61
      biz/forms.py
  20. 103
      biz/migrations/0001_initial.py
  21. 18
      biz/migrations/0002_alter_prospect_email.py
  22. 18
      biz/migrations/0003_alter_activity_status.py
  23. 18
      biz/migrations/0004_prospect_contact_again.py
  24. 38
      biz/migrations/0005_alter_activity_status_campaign.py
  25. 19
      biz/migrations/0006_alter_campaign_id.py
  26. 37
      biz/migrations/0007_prospectgroup_delete_campaign.py
  27. 23
      biz/migrations/0008_alter_activity_declination_reason_and_more.py
  28. 0
      biz/migrations/__init__.py
  29. 4
      biz/mixins.py
  30. 221
      biz/models.py
  31. 0
      biz/services.py
  32. 448
      biz/templates/admin/biz/dashboard.html
  33. 81
      biz/templates/admin/biz/email_users.html
  34. 11
      biz/templates/admin/biz/prospect/change_list.html
  35. 53
      biz/templates/admin/biz/prospect/import_file.html
  36. 29
      biz/templates/admin/biz/select_email_template.html
  37. 2
      biz/templates/biz/add_prospect.html
  38. 0
      biz/templates/biz/base.html
  39. 2
      biz/templates/biz/csv_import.html
  40. 4
      biz/templates/biz/event_form.html
  41. 4
      biz/templates/biz/event_row.html
  42. 18
      biz/templates/biz/events.html
  43. 2
      biz/templates/biz/prospect_form.html
  44. 14
      biz/templates/biz/prospect_list.html
  45. 4
      biz/templates/biz/send_bulk_email.html
  46. 0
      biz/templatetags/__init__.py
  47. 7
      biz/templatetags/crm_tags.py
  48. 0
      biz/tests.py
  49. 2
      biz/urls.py
  50. 284
      biz/views.py
  51. 1
      crm/_instructions/base.md
  52. 96
      crm/admin.py
  53. 23
      crm/filters.py
  54. 46
      crm/forms.py
  55. 94
      crm/migrations/0001_initial.py
  56. 60
      crm/migrations/0002_alter_event_options_alter_prospect_options_and_more.py
  57. 32
      crm/migrations/0003_remove_prospect_region_prospect_address_and_more.py
  58. 32
      crm/migrations/0004_remove_prospect_name_prospect_entity_name_and_more.py
  59. 18
      crm/migrations/0005_prospect_phone.py
  60. 120
      crm/models.py
  61. 6
      crm/static/crm/js/prospects.js
  62. 7
      crm/templatetags/crm_tags.py
  63. 284
      crm/views.py
  64. 16
      padelclub_backend/settings.py
  65. 60
      padelclub_backend/settings_app.py
  66. 1
      padelclub_backend/settings_local.py.dist
  67. 21
      padelclub_backend/urls.py
  68. 2
      requirements.txt
  69. 11
      sample_prospects.csv
  70. 2
      shop/templates/shop/product_list.html
  71. 21
      sync/README.md
  72. 12
      sync/admin.py
  73. 17
      sync/migrations/0009_alter_dataaccess_options.py
  74. 2
      sync/model_manager.py
  75. 27
      sync/models/base.py
  76. 5
      sync/models/data_access.py
  77. 19
      sync/registry.py
  78. 29
      sync/signals.py
  79. 7
      sync/utils.py
  80. 14
      sync/views.py
  81. 14
      sync/ws_sender.py
  82. 177
      tournaments/admin.py
  83. 2529
      tournaments/admin_utils.py
  84. 7
      tournaments/custom_views.py
  85. 83
      tournaments/filters.py
  86. 25
      tournaments/forms.py
  87. 1235
      tournaments/management/commands/analyze_rankings.py
  88. 222
      tournaments/management/commands/test_fft_all_tournaments.py
  89. 103
      tournaments/management/commands/test_fft_scraper.py
  90. 53
      tournaments/middleware.py
  91. 18
      tournaments/migrations/0129_tournament_currency_code.py
  92. 28
      tournaments/migrations/0130_playerregistration_contact_email_and_more.py
  93. 18
      tournaments/migrations/0131_alter_playerregistration_contact_name.py
  94. 20
      tournaments/migrations/0132_alter_purchase_user.py
  95. 18
      tournaments/migrations/0133_alter_club_timezone.py
  96. 18
      tournaments/migrations/0134_alter_club_timezone.py
  97. 18
      tournaments/migrations/0135_club_hidden.py
  98. 18
      tournaments/migrations/0136_rename_hidden_club_admin_visible.py
  99. 23
      tournaments/migrations/0137_playerregistration_is_anonymous_and_more.py
  100. 28
      tournaments/migrations/0138_remove_customuser_agents_customuser_supervisors_and_more.py
  101. Some files were not shown because too many files have changed in this diff Show More

1
.gitignore vendored

@ -7,6 +7,7 @@ padelclub_backend/settings_local.py
myenv/ myenv/
shared/config_local.py shared/config_local.py
logs/
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/

@ -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}"

@ -1,10 +1,5 @@
from rest_framework import serializers from rest_framework import serializers
from tournaments.models.court import Court
from tournaments.models import Club, LiveMatch, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall, DateInterval, Log, DeviceToken, UnregisteredTeam, UnregisteredPlayer, Image
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.conf import settings
# email
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.http import urlsafe_base64_encode from django.utils.http import urlsafe_base64_encode
from django.utils.encoding import force_bytes from django.utils.encoding import force_bytes
@ -12,10 +7,12 @@ from django.core.mail import EmailMessage
from django.contrib.sites.shortcuts import get_current_site from django.contrib.sites.shortcuts import get_current_site
from api.tokens import account_activation_token from api.tokens import account_activation_token
from shared.cryptography import encryption_util from shared.cryptography import encryption_util
from tournaments.models.draw_log import DrawLog
from tournaments.models import Club, LiveMatch, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall, DateInterval, Log, DeviceToken, UnregisteredTeam, UnregisteredPlayer, Image, DrawLog, Court
from tournaments.models.enums import UserOrigin, RegistrationPaymentMode from tournaments.models.enums import UserOrigin, RegistrationPaymentMode
from biz.models import Activity, Prospect, Entity
class EncryptedUserField(serializers.Field): class EncryptedUserField(serializers.Field):
def to_representation(self, value): def to_representation(self, value):
@ -52,7 +49,7 @@ class UserSerializer(serializers.ModelSerializer):
username_lower = validated_data['username'].lower() username_lower = validated_data['username'].lower()
if CustomUser.objects.filter(username__iexact=username_lower) | CustomUser.objects.filter(email__iexact=username_lower): if CustomUser.objects.filter(username__iexact=username_lower) | CustomUser.objects.filter(email__iexact=username_lower):
raise IntegrityError("Cet identifiant est déjà utilisé. Veuillez en choisir un autre :)") raise serializers.ValidationError("Cet identifiant est déjà utilisé. Veuillez en choisir un autre :)")
user = CustomUser.objects.create_user( user = CustomUser.objects.create_user(
username=validated_data['username'], username=validated_data['username'],
@ -137,6 +134,102 @@ class TournamentSerializer(serializers.ModelSerializer):
model = Tournament model = Tournament
fields = '__all__' fields = '__all__'
class TournamentSummarySerializer(serializers.ModelSerializer):
# English field names for all the information described in the comment
tournament_name = serializers.SerializerMethodField()
tournament_information = serializers.CharField(source='information', read_only=True)
start_date = serializers.SerializerMethodField()
end_date = serializers.SerializerMethodField()
tournament_category = serializers.SerializerMethodField() # P25/P100/P250 as string
tournament_type = serializers.SerializerMethodField() # homme/femme/mixte as string
tournament_age_category = serializers.SerializerMethodField() # U10, U12, Senior, +45, etc. as string
max_teams = serializers.IntegerField(source='team_count', read_only=True)
registered_teams_count = serializers.SerializerMethodField()
tournament_status = serializers.SerializerMethodField() # status as string
registration_link = serializers.SerializerMethodField()
umpire_name = serializers.SerializerMethodField()
umpire_phone = serializers.SerializerMethodField()
umpire_email = serializers.SerializerMethodField()
class Meta:
model = Tournament
fields = [
'id',
'tournament_name',
'tournament_information',
'start_date',
'end_date',
'tournament_category',
'tournament_type',
'tournament_age_category',
'max_teams',
'registered_teams_count',
'tournament_status',
'registration_link',
'umpire_name',
'umpire_phone',
'umpire_email'
]
def get_start_date(self, obj):
"""Get formatted start date"""
return obj.local_start_date()
def get_end_date(self, obj):
"""Get formatted end date"""
return obj.local_end_date()
def get_tournament_name(self, obj):
"""Get the tournament name"""
return obj.name or obj.name_and_event()
def get_tournament_category(self, obj):
"""Get tournament category as string label (P25, P100, P250, etc.)"""
return obj.level()
def get_tournament_type(self, obj):
"""Get tournament type as string label (homme, femme, mixte)"""
return obj.category()
def get_tournament_age_category(self, obj):
"""Get tournament age category as string label (U10, U12, Senior, +45, etc.)"""
return obj.age()
def get_registered_teams_count(self, obj):
"""Get number of registered teams"""
return len(obj.teams(False))
def get_tournament_status(self, obj):
"""Get tournament status as string"""
return obj.get_tournament_status()
def get_registration_link(self, obj):
"""Get appropriate link based on tournament status"""
# This will need to be adapted based on your URL structure
# For now, returning a placeholder that you can customize
status = obj.get_online_registration_status()
base_url = "https://padelclub.app/"
if status.value in [1, 3, 5]: # OPEN, NOT_STARTED, WAITING_LIST_POSSIBLE
return f"{base_url}tournament/{obj.id}/info/"
elif status.value == 7: # IN_PROGRESS
return f"{base_url}tournament/{obj.id}/live/"
elif status.value == 8: # ENDED_WITH_RESULTS
return f"{base_url}tournament/{obj.id}/rankings/"
else:
return f"{base_url}tournament/{obj.id}/info/"
def get_umpire_name(self, obj):
"""Get umpire/referee name"""
return obj.umpire_contact()
def get_umpire_phone(self, obj):
"""Get umpire phone number"""
return obj.umpire_phone()
def get_umpire_email(self, obj):
"""Get umpire email address"""
return obj.umpire_mail()
class EventSerializer(serializers.ModelSerializer): class EventSerializer(serializers.ModelSerializer):
class Meta: class Meta:
#club_id = serializers.PrimaryKeyRelatedField(queryset=Club.objects.all()) #club_id = serializers.PrimaryKeyRelatedField(queryset=Club.objects.all())
@ -252,3 +345,20 @@ class ImageSerializer(serializers.ModelSerializer):
fields = ['id', 'title', 'description', 'image', 'image_url', 'uploaded_at', fields = ['id', 'title', 'description', 'image', 'image_url', 'uploaded_at',
'event', 'image_type'] 'event', 'image_type']
read_only_fields = ['id', 'uploaded_at', 'image_url'] read_only_fields = ['id', 'uploaded_at', 'image_url']
### CRM
class ActivitySerializer(serializers.ModelSerializer):
class Meta:
model = Activity
fields = '__all__'
class ProspectSerializer(serializers.ModelSerializer):
class Meta:
model = Prospect
fields = '__all__'
class EntitySerializer(serializers.ModelSerializer):
class Meta:
model = Entity
fields = '__all__'

@ -8,9 +8,10 @@ from authentication.views import CustomAuthToken, Logout, ChangePasswordView
router = routers.DefaultRouter() router = routers.DefaultRouter()
router.register(r'users', views.UserViewSet) router.register(r'users', views.UserViewSet)
router.register(r'user-agents', views.ShortUserViewSet) router.register(r'user-supervisors', views.SupervisorViewSet)
router.register(r'clubs', views.ClubViewSet) router.register(r'clubs', views.ClubViewSet)
router.register(r'tournaments', views.TournamentViewSet) router.register(r'tournaments', views.TournamentViewSet)
router.register(r'tournament-summaries', views.TournamentSummaryViewSet)
router.register(r'images', views.ImageViewSet) router.register(r'images', views.ImageViewSet)
router.register(r'events', views.EventViewSet) router.register(r'events', views.EventViewSet)
router.register(r'rounds', views.RoundViewSet) router.register(r'rounds', views.RoundViewSet)
@ -29,12 +30,17 @@ router.register(r'device-token', views.DeviceTokenViewSet)
router.register(r'data-access', DataAccessViewSet) router.register(r'data-access', DataAccessViewSet)
router.register(r'unregistered-teams', views.UnregisteredTeamViewSet) router.register(r'unregistered-teams', views.UnregisteredTeamViewSet)
router.register(r'unregistered-players', views.UnregisteredPlayerViewSet) router.register(r'unregistered-players', views.UnregisteredPlayerViewSet)
### biz
router.register(r'crm-prospects', views.CRMProspectViewSet)
router.register(r'crm-entities', views.CRMEntityViewSet)
router.register(r'crm-activities', views.CRMActivityViewSet)
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
path('sync-data/', SynchronizationApi.as_view(), name="data"), path('sync-data/', SynchronizationApi.as_view(), name="data"),
path('data-access-content/', UserDataAccessApi.as_view(), name="data-access-content"), path('data-access-content/', UserDataAccessApi.as_view(), name="data-access-content"),
path("is_granted_unlimited_access/", views.is_granted_unlimited_access, name="is-granted-unlimited-access"),
path('api-token-auth/', obtain_auth_token, name='api_token_auth'), path('api-token-auth/', obtain_auth_token, name='api_token_auth'),
path("user-by-token/", views.user_by_token, name="user_by_token"), path("user-by-token/", views.user_by_token, name="user_by_token"),
@ -43,6 +49,10 @@ urlpatterns = [
path('xls-to-csv/', views.xls_to_csv, name='xls-to-csv'), path('xls-to-csv/', views.xls_to_csv, name='xls-to-csv'),
path('config/tournament/', views.get_tournament_config, name='tournament-config'), path('config/tournament/', views.get_tournament_config, name='tournament-config'),
path('config/payment/', views.get_payment_config, name='payment-config'), path('config/payment/', views.get_payment_config, name='payment-config'),
path('fft/club-tournaments/', views.get_fft_club_tournaments, name='get-fft-club-tournaments'),
path('fft/all-tournaments/', views.get_fft_all_tournaments, name='get-fft-all-tournaments'),
path('fft/umpire/<str:tournament_id>/', views.get_fft_umpire_data, name='get-fft-umpire-data'),
path('fft/federal-clubs/', views.get_fft_federal_clubs, name='get-fft-federal-clubs'),
# authentication # authentication
path("change-password/", ChangePasswordView.as_view(), name="change_password"), path("change-password/", ChangePasswordView.as_view(), name="change_password"),
@ -53,5 +63,6 @@ urlpatterns = [
path('dj-rest-auth/', include('dj_rest_auth.urls')), path('dj-rest-auth/', include('dj_rest_auth.urls')),
path('stripe/create-account/', views.create_stripe_connect_account, name='create_stripe_account'), path('stripe/create-account/', views.create_stripe_connect_account, name='create_stripe_account'),
path('stripe/create-account-link/', views.create_stripe_account_link, name='create_account_link'), path('stripe/create-account-link/', views.create_stripe_account_link, name='create_account_link'),
path('resend-payment-email/<str:team_registration_id>/', views.resend_payment_email, name='resend-payment-email'),
path('payment-link/<str:team_registration_id>/', views.get_payment_link, name='get-payment-link'),
] ]

File diff suppressed because it is too large Load Diff

@ -1,5 +1,24 @@
from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer, ImageSerializer from pandas.core.groupby import base
from rest_framework import viewsets
from rest_framework.response import Response
from rest_framework.decorators import api_view, permission_classes
from rest_framework import status
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.permissions import IsAuthenticated
from .authentication import HasAPIKey
from django.conf import settings
from django.http import Http404, HttpResponse, JsonResponse
from django.db.models import Q
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from django.shortcuts import get_object_or_404
from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer, ImageSerializer, ActivitySerializer, ProspectSerializer, EntitySerializer, TournamentSummarySerializer
from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image
from tournaments.services.email_service import TournamentEmailService
from biz.models import Activity, Prospect, Entity
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.response import Response from rest_framework.response import Response
@ -11,25 +30,19 @@ from django.http import Http404
from django.db.models import Q from django.db.models import Q
from .permissions import IsClubOwner from .permissions import IsClubOwner
from .utils import check_version_smaller_than_1_1_12 from .utils import check_version_smaller_than_1_1_12, scrape_fft_club_tournaments, scrape_fft_club_tournaments_all_pages, get_umpire_data, scrape_fft_all_tournaments, scrape_fft_all_tournaments_concurrent, scrape_federal_clubs
from shared.discord import send_discord_log_message from shared.discord import send_discord_log_message
from rest_framework.decorators import permission_classes
from rest_framework.permissions import IsAuthenticated
from django.shortcuts import get_object_or_404
from tournaments.services.payment_service import PaymentService from tournaments.services.payment_service import PaymentService
from django.conf import settings from tournaments.utils.extensions import create_random_filename
import stripe import stripe
import json import json
import pandas as pd import pandas as pd
from tournaments.utils.extensions import create_random_filename
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
import os import os
from django.http import HttpResponse
import logging import logging
from datetime import datetime
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -64,6 +77,32 @@ class ClubViewSet(SoftDeleteViewSet):
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(creator=self.request.user) serializer.save(creator=self.request.user)
class TournamentSummaryViewSet(SoftDeleteViewSet):
queryset = Tournament.objects.all()
serializer_class = TournamentSummarySerializer
permission_classes = [HasAPIKey]
def get_queryset(self):
if self.request.user.is_anonymous:
return Tournament.objects.none()
queryset = self.queryset.filter(
Q(event__creator=self.request.user) | Q(related_user=self.request.user)
).distinct()
# Add min_start_date filtering
min_start_date = self.request.query_params.get('min_start_date')
if min_start_date:
try:
# Parse the date string (assumes ISO format: YYYY-MM-DD)
min_date = datetime.fromisoformat(min_start_date).date()
queryset = queryset.filter(start_date__gte=min_date)
except (ValueError, TypeError):
# If date parsing fails, ignore the filter
pass
return queryset
class TournamentViewSet(SoftDeleteViewSet): class TournamentViewSet(SoftDeleteViewSet):
queryset = Tournament.objects.all() queryset = Tournament.objects.all()
serializer_class = TournamentSerializer serializer_class = TournamentSerializer
@ -71,7 +110,13 @@ class TournamentViewSet(SoftDeleteViewSet):
def get_queryset(self): def get_queryset(self):
if self.request.user.is_anonymous: if self.request.user.is_anonymous:
return [] return []
return self.queryset.filter(event__creator=self.request.user) return self.queryset.filter(
Q(event__creator=self.request.user))
return self.queryset.filter(
Q(event__creator=self.request.user) | Q(related_user=self.request.user)
).distinct()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save() serializer.save()
@ -298,13 +343,13 @@ class UnregisteredPlayerViewSet(SoftDeleteViewSet):
return self.queryset.filter(unregistered_team__tournament__event__creator=self.request.user) return self.queryset.filter(unregistered_team__tournament__event__creator=self.request.user)
return [] return []
class ShortUserViewSet(viewsets.ModelViewSet): class SupervisorViewSet(viewsets.ModelViewSet):
queryset = CustomUser.objects.all() queryset = CustomUser.objects.all()
serializer_class = ShortUserSerializer serializer_class = ShortUserSerializer
permission_classes = [] # Users are public whereas the other requests are only for logged users permission_classes = [] # Users are public whereas the other requests are only for logged users
def get_queryset(self): def get_queryset(self):
return self.request.user.agents return self.request.user.supervisors
class ImageViewSet(viewsets.ModelViewSet): class ImageViewSet(viewsets.ModelViewSet):
""" """
@ -475,8 +520,13 @@ def create_stripe_account_link(request):
}, status=400) }, status=400)
try: try:
# Force HTTPS for production Stripe calls
if hasattr(settings, 'STRIPE_MODE') and settings.STRIPE_MODE == 'live':
base_path = f"https://{request.get_host()}"
else:
base_path = f"{request.scheme}://{request.get_host()}" base_path = f"{request.scheme}://{request.get_host()}"
# print("create_stripe_account_link", base_path)
refresh_url = f"{base_path}/stripe-refresh-account-link/" refresh_url = f"{base_path}/stripe-refresh-account-link/"
return_url = f"{base_path}/stripe-onboarding-complete/" return_url = f"{base_path}/stripe-onboarding-complete/"
@ -568,3 +618,359 @@ def validate_stripe_account(request):
'error': f'Unexpected error: {str(e)}', 'error': f'Unexpected error: {str(e)}',
'needs_onboarding': True, 'needs_onboarding': True,
}, status=200) }, status=200)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def resend_payment_email(request, team_registration_id):
"""
Resend the registration confirmation email (which includes payment info/link)
"""
try:
team_registration = TeamRegistration.objects.get(id=team_registration_id)
tournament = team_registration.tournament
TournamentEmailService.send_registration_confirmation(
request,
tournament,
team_registration,
waiting_list_position=-1,
force_send=True
)
return Response({
'success': True,
'message': 'Email de paiement renvoyé'
})
except TeamRegistration.DoesNotExist:
return Response({'error': 'Team not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_payment_link(request, team_registration_id):
"""
Get payment link for a team registration.
Only accessible by the umpire (tournament creator).
"""
try:
team_registration = get_object_or_404(TeamRegistration, id=team_registration_id)
# Check if the user is the umpire (creator) of the tournament
if request.user != team_registration.tournament.event.creator:
return Response({
'success': False,
'message': "Vous n'êtes pas autorisé à accéder à ce lien de paiement"
}, status=403)
# Create payment link
payment_link = PaymentService.create_payment_link(team_registration.id)
if payment_link:
return Response({
'success': True,
'payment_link': payment_link
})
else:
return Response({
'success': False,
'message': 'Impossible de créer le lien de paiement'
}, status=500)
except TeamRegistration.DoesNotExist:
return Response({'error': 'Team not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def is_granted_unlimited_access(request):
can_create = False
if request.user and request.user.is_anonymous == False and request.user.organising_for:
for owner in request.user.organising_for.all():
purchases = Purchase.objects.filter(user=owner,product_id='app.padelclub.tournament.subscription.unlimited')
for purchase in purchases:
if purchase.is_active():
can_create = True
return JsonResponse({'can_create': can_create}, status=status.HTTP_200_OK)
@api_view(['GET', 'POST'])
@permission_classes([])
def get_fft_club_tournaments(request):
"""
API endpoint to get tournaments for a specific club
Handles pagination automatically to get all results
"""
try:
if request.method == 'POST':
data = request.data
else:
data = request.GET
club_code = data.get('club_code', '62130180')
club_name = data.get('club_name', 'TENNIS SPORTING CLUB DE CASSIS')
start_date = data.get('start_date')
end_date = data.get('end_date')
paginate = data.get('paginate', 'true').lower() == 'true'
if paginate:
# Get all pages automatically (matching Swift behavior)
result = scrape_fft_club_tournaments_all_pages(
club_code=club_code,
club_name=club_name,
start_date=start_date,
end_date=end_date
)
else:
# Get single page
page = int(data.get('page', 0))
result = scrape_fft_club_tournaments(
club_code=club_code,
club_name=club_name,
start_date=start_date,
end_date=end_date,
page=page
)
if result:
return JsonResponse({
'success': True,
'tournaments': result.get('tournaments', []),
'total_results': result.get('total_results', 0),
'current_count': result.get('current_count', 0),
'pages_scraped': result.get('pages_scraped', 1),
'message': f'Successfully scraped {len(result.get("tournaments", []))} tournaments'
}, status=status.HTTP_200_OK)
else:
return JsonResponse({
'success': False,
'tournaments': [],
'total_results': 0,
'current_count': 0,
'message': 'Failed to scrape club tournaments'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception as e:
logger.error(f"Error in get_fft_club_tournaments endpoint: {e}")
return JsonResponse({
'success': False,
'tournaments': [],
'message': f'Unexpected error: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
@permission_classes([])
def get_fft_umpire_data(request, tournament_id):
"""
API endpoint to get umpire data for a specific tournament
Returns data that can be used to populate japPhoneNumber field
"""
try:
name, email, phone = get_umpire_data(tournament_id)
return JsonResponse({
'name': name,
'email': email,
'phone': phone
}, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error in get_fft_umpire_data endpoint: {e}")
return JsonResponse({
'success': False,
'umpire': None,
'japPhoneNumber': None,
'message': f'Unexpected error: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET', 'POST'])
@permission_classes([])
def get_fft_all_tournaments(request):
"""
API endpoint to get tournaments with smart pagination:
- page=0: Returns first page + metadata about total pages
- page>0: Returns all remaining pages concurrently
"""
try:
if request.method == 'POST':
data = request.data
else:
data = request.GET
# Extract parameters
sorting_option = data.get('sorting_option', 'dateDebut+asc')
page = int(data.get('page', 0))
start_date = data.get('start_date')
end_date = data.get('end_date')
city = data.get('city', '')
distance = int(data.get('distance', 15))
categories = data.getlist('categories') if hasattr(data, 'getlist') else data.get('categories', [])
levels = data.getlist('levels') if hasattr(data, 'getlist') else data.get('levels', [])
lat = data.get('lat')
lng = data.get('lng')
ages = data.getlist('ages') if hasattr(data, 'getlist') else data.get('ages', [])
tournament_types = data.getlist('types') if hasattr(data, 'getlist') else data.get('types', [])
national_cup = data.get('national_cup', 'false').lower() == 'true'
max_workers = int(data.get('max_workers', 5))
if page == 0:
# Handle first page individually
result = scrape_fft_all_tournaments(
sorting_option=sorting_option,
page=0,
start_date=start_date,
end_date=end_date,
city=city,
distance=distance,
categories=categories,
levels=levels,
lat=lat,
lng=lng,
ages=ages,
tournament_types=tournament_types,
national_cup=national_cup
)
if result:
tournaments = result.get('tournaments', [])
total_results = result.get('total_results', 0)
results_per_page = len(tournaments)
# Calculate total pages
if results_per_page > 0:
total_pages = (total_results + results_per_page - 1) // results_per_page
else:
total_pages = 1
return JsonResponse({
'success': True,
'tournaments': tournaments,
'total_results': total_results,
'current_count': len(tournaments),
'page': 0,
'total_pages': total_pages,
'has_more_pages': total_pages > 1,
'message': f'Successfully scraped page 0: {len(tournaments)} tournaments. Total: {total_results} across {total_pages} pages.'
}, status=status.HTTP_200_OK)
else:
return JsonResponse({
'success': False,
'tournaments': [],
'total_results': 0,
'current_count': 0,
'page': 0,
'total_pages': 0,
'has_more_pages': False,
'message': 'Failed to scrape first page'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
else:
# Handle all remaining pages concurrently
result = scrape_fft_all_tournaments_concurrent(
sorting_option=sorting_option,
start_date=start_date,
end_date=end_date,
city=city,
distance=distance,
categories=categories,
levels=levels,
lat=lat,
lng=lng,
ages=ages,
tournament_types=tournament_types,
national_cup=national_cup,
max_workers=max_workers
)
if result:
return JsonResponse({
'success': True,
'tournaments': result.get('tournaments', []),
'total_results': result.get('total_results', 0),
'current_count': result.get('current_count', 0),
'pages_scraped': result.get('pages_scraped', 0),
'message': f'Successfully scraped {result.get("pages_scraped", 0)} remaining pages concurrently: {len(result.get("tournaments", []))} tournaments'
}, status=status.HTTP_200_OK)
else:
return JsonResponse({
'success': False,
'tournaments': [],
'total_results': 0,
'current_count': 0,
'pages_scraped': 0,
'message': 'Failed to scrape remaining pages'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception as e:
logger.error(f"Error in get_fft_all_tournaments endpoint: {e}")
return JsonResponse({
'success': False,
'tournaments': [],
'message': f'Unexpected error: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET', 'POST'])
@permission_classes([])
def get_fft_federal_clubs(request):
"""
API endpoint to get federal clubs with filters
"""
try:
if request.method == 'POST':
data = request.data
else:
data = request.GET
# Extract parameters - matching the Swift query parameters
country = data.get('country', 'fr')
city = data.get('city', '')
radius = float(data.get('radius', 15))
latitude = data.get('lat')
longitude = data.get('lng')
# Convert latitude and longitude to float if provided
if latitude:
latitude = float(latitude)
if longitude:
longitude = float(longitude)
result = scrape_federal_clubs(
country=country,
city=city,
latitude=latitude,
longitude=longitude,
radius=radius
)
if result:
# Return the result directly as JSON (already in correct format)
return JsonResponse(result, status=status.HTTP_200_OK)
else:
# Return error in expected format
return JsonResponse({
"typeRecherche": "clubs",
"nombreResultat": 0,
"club_markers": []
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception as e:
logger.error(f"Error in get_fft_federal_clubs endpoint: {e}")
return JsonResponse({
"typeRecherche": "clubs",
"nombreResultat": 0,
"club_markers": []
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
### biz
class CRMActivityViewSet(SoftDeleteViewSet):
queryset = Activity.objects.all()
serializer_class = ActivitySerializer
class CRMProspectViewSet(SoftDeleteViewSet):
queryset = Prospect.objects.all()
serializer_class = ProspectSerializer
class CRMEntityViewSet(SoftDeleteViewSet):
queryset = Entity.objects.all()
serializer_class = EntitySerializer

@ -0,0 +1,534 @@
from django.http import HttpResponseRedirect
from django.contrib import admin
from django.urls import path, reverse
from django.contrib import messages
from django.shortcuts import render, redirect
from django.contrib.auth import get_user_model
from django.utils.html import format_html
from django.core.mail import send_mail
from django.db.models import Q, Max, Subquery, OuterRef
import csv
import io
import time
import logging
from .models import Entity, Prospect, Activity, Status, ActivityType, EmailTemplate, DeclinationReason, ProspectGroup
from .forms import FileImportForm, EmailTemplateSelectionForm
from .filters import ContactAgainFilter, ProspectStatusFilter, StaffUserFilter, ProspectProfileFilter, ProspectDeclineReasonFilter, ProspectGroupFilter, PhoneFilter
from tournaments.models import CustomUser
from tournaments.models.enums import UserOrigin
from sync.admin import SyncedObjectAdmin
User = get_user_model()
logger = logging.getLogger(__name__)
class ProspectInline(admin.StackedInline):
model = Prospect.entities.through
extra = 1
verbose_name = "Prospect"
verbose_name_plural = "Prospects"
autocomplete_fields = ['prospect']
@admin.register(Entity)
class EntityAdmin(SyncedObjectAdmin):
list_display = ('name', 'address', 'zip_code', 'city')
search_fields = ('name', 'address', 'zip_code', 'city')
# filter_horizontal = ('prospects',)
inlines = [ProspectInline]
@admin.register(EmailTemplate)
class EmailTemplateAdmin(SyncedObjectAdmin):
list_display = ('name', 'subject', 'body')
search_fields = ('name', 'subject')
exclude = ('data_access_ids', 'activities',)
def contacted_by_sms(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, ActivityType.SMS, Status.CONTACTED, None)
contacted_by_sms.short_description = "Contacted by SMS"
def mark_as_inbound(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.INBOUND, None)
mark_as_inbound.short_description = "Mark as inbound"
def mark_as_customer(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.CUSTOMER, None)
mark_as_customer.short_description = "Mark as customer"
def mark_as_should_test(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.SHOULD_TEST, None)
mark_as_should_test.short_description = "Mark as should test"
def mark_as_testing(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.TESTING, None)
mark_as_testing.short_description = "Mark as testing"
def declined_too_expensive(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.TOO_EXPENSIVE)
declined_too_expensive.short_description = "Declined too expensive"
def declined_use_something_else(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.USE_OTHER_PRODUCT)
declined_use_something_else.short_description = "Declined use something else"
def declined_android_user(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.USE_ANDROID)
declined_android_user.short_description = "Declined use Android"
def mark_as_have_account(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.HAVE_CREATED_ACCOUNT, None)
mark_as_have_account.short_description = "Mark as having an account"
def mark_as_not_concerned(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.NOT_CONCERNED, None)
mark_as_not_concerned.short_description = "Mark as not concerned"
def create_default_activity_for_prospect(modeladmin, request, queryset, type, status, reason):
for prospect in queryset:
activity = Activity.objects.create(
type=type,
status=status,
declination_reason=reason,
related_user = request.user
)
activity.prospects.add(prospect)
modeladmin.message_user(
request,
f'{queryset.count()} prospects were marked as {status}.'
)
def create_activity_for_prospect(modeladmin, request, queryset):
# Only allow single selection
if queryset.count() != 1:
messages.error(request, "Please select exactly one prospect.")
return
prospect = queryset.first()
# Build the URL with pre-populated fields
url = reverse('admin:biz_activity_add')
url += f'?prospect={prospect.id}'
return redirect(url)
create_activity_for_prospect.short_description = "Create activity"
@admin.register(Prospect)
class ProspectAdmin(SyncedObjectAdmin):
readonly_fields = ['related_activities', 'entity_names', 'current_status', 'id']
fieldsets = [
(None, {
'fields': ['related_activities', 'id', 'entity_names', 'first_name', 'last_name', 'email', 'phone', 'contact_again', 'official_user', 'name_unsure', 'entities', 'related_user']
}),
]
list_display = ('first_name', 'last_name', 'entity_names', 'phone', 'last_update_date', 'current_status', 'contact_again')
list_filter = (ContactAgainFilter, ProspectStatusFilter, ProspectDeclineReasonFilter, ProspectGroupFilter, PhoneFilter, 'creation_date', StaffUserFilter, 'source', ProspectProfileFilter)
search_fields = ('first_name', 'last_name', 'email', 'phone')
date_hierarchy = 'creation_date'
change_list_template = "admin/biz/prospect/change_list.html"
ordering = ['-last_update']
filter_horizontal = ['entities']
actions = ['send_email', create_activity_for_prospect, mark_as_inbound, contacted_by_sms, mark_as_should_test, mark_as_testing, mark_as_customer, mark_as_have_account, declined_too_expensive, declined_use_something_else, declined_android_user, mark_as_not_concerned]
autocomplete_fields = ['official_user', 'related_user']
def save_model(self, request, obj, form, change):
if obj.related_user is None:
obj.related_user = request.user
super().save_model(request, obj, form, change)
def last_update_date(self, obj):
return obj.last_update.date() if obj.last_update else None
last_update_date.short_description = 'Last Update'
last_update_date.admin_order_field = 'last_update'
def related_activities(self, obj):
activities = obj.activities.all()
if activities:
activity_links = []
for activity in activities:
url = f"/kingdom/biz/activity/{activity.id}/change/"
activity_links.append(f'<a href="{url}">{activity.html_desc()}</a>')
return format_html('<br>'.join(activity_links))
return "No events"
related_activities.short_description = "Related Activities"
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('dashboard/', self.admin_site.admin_view(self.dashboard), name='biz_dashboard'),
path('import_file/', self.admin_site.admin_view(self.import_file), name='import_file'),
path('import_app_users/', self.admin_site.admin_view(self.import_app_users), name='import_app_users'),
path('cleanup/', self.admin_site.admin_view(self.cleanup), name='cleanup'),
]
return custom_urls + urls
def dashboard(self, request):
"""
Dashboard view showing prospects organized by status columns
"""
# Get filter parameter - if 'my' is true, filter by current user
filter_my = request.GET.get('my', 'false') == 'true'
# Base queryset
base_queryset = Prospect.objects.select_related().prefetch_related('entities', 'activities')
# Apply user filter if requested
if filter_my:
base_queryset = base_queryset.filter(related_user=request.user)
# Helper function to get prospects by status
def get_prospects_by_status(statuses):
# Get the latest activity status for each prospect
latest_activity = Activity.objects.filter(
prospects=OuterRef('pk'),
status__isnull=False
).order_by('-creation_date')
prospects = base_queryset.annotate(
latest_status=Subquery(latest_activity.values('status')[:1])
).filter(
latest_status__in=statuses
).order_by('last_update')
return prospects
# Get prospects for each column
should_test_prospects = get_prospects_by_status([Status.SHOULD_TEST])
testing_prospects = get_prospects_by_status([Status.TESTING])
responded_prospects = get_prospects_by_status([Status.RESPONDED])
others_prospects = get_prospects_by_status([Status.INBOUND, Status.SHOULD_BUY])
# Get prospects with contact_again date set, sorted by oldest first
contact_again_prospects = base_queryset.filter(
contact_again__isnull=False
).order_by('contact_again')
context = {
'title': 'CRM Dashboard',
'should_test_prospects': should_test_prospects,
'testing_prospects': testing_prospects,
'responded_prospects': responded_prospects,
'others_prospects': others_prospects,
'contact_again_prospects': contact_again_prospects,
'filter_my': filter_my,
'opts': self.model._meta,
'has_view_permission': self.has_view_permission(request),
}
return render(request, 'admin/biz/dashboard.html', context)
def cleanup(self, request):
Entity.objects.all().delete()
Prospect.objects.all().delete()
Activity.objects.all().delete()
messages.success(request, 'cleanup biz objects')
return redirect('admin:biz_prospect_changelist')
def import_app_users(self, request):
users = CustomUser.objects.filter(origin=UserOrigin.APP)
created_count = 0
for user in users:
is_customer = user.purchases.count() > 0
entity_name = user.latest_event_club_name()
prospect, prospect_created = Prospect.objects.get_or_create(
email=user.email,
defaults={
'first_name': user.first_name,
'last_name': user.last_name,
'phone': user.phone,
'name_unsure': False,
'official_user': user,
'source': 'App',
}
)
if entity_name:
entity, entity_created = Entity.objects.get_or_create(
name=entity_name,
defaults={'name': entity_name}
)
prospect.entities.add(entity)
if is_customer:
activity = Activity.objects.create(
status=Status.CUSTOMER,
)
activity.prospects.add(prospect)
if prospect_created:
created_count += 1
messages.success(request, f'Imported {created_count} app users into prospects')
return redirect('admin:biz_prospect_changelist')
def import_file(self, request):
"""
Handle file import - displays form and processes file upload
"""
if request.method == 'POST':
form = FileImportForm(request.POST, request.FILES)
if form.is_valid():
# Call the import_csv method with the uploaded file
try:
result = self.import_csv(form.cleaned_data['file'], form.cleaned_data['source'])
messages.success(request, f'File imported successfully: {result}')
return redirect('admin:biz_prospect_changelist')
except Exception as e:
messages.error(request, f'Error importing file: {str(e)}')
else:
messages.error(request, 'Please correct the errors below.')
else:
form = FileImportForm()
context = {
'form': form,
'title': 'Import File',
'app_label': self.model._meta.app_label,
'opts': self.model._meta,
'has_change_permission': self.has_change_permission(request),
}
return render(request, 'admin/biz/prospect/import_file.html', context)
def import_csv(self, file, source):
"""
Process the uploaded CSV file
CSV format: entity_name,last_name,first_name,email,phone,attachment_text,status,related_user
"""
try:
# Read the file content
file_content = file.read().decode('utf-8')
csv_reader = csv.reader(io.StringIO(file_content), delimiter=';')
created_prospects = 0
updated_prospects = 0
created_entities = 0
created_events = 0
for row in csv_reader:
print(f'>>> row size is {len(row)}')
if len(row) < 5:
print(f'>>> WARNING: row size is {len(row)}: {row}')
continue # Skip rows that don't have enough columns
entity_name = row[0].strip()
last_name = row[1].strip()
first_name = row[2].strip()
email = row[3].strip()
phone = row[4].strip() if row[4].strip() else None
if phone and not phone.startswith('0'):
phone = '0' + phone
# attachment_text = row[5].strip() if row[5].strip() else None
# status_text = row[6].strip() if row[6].strip() else None
# related_user_name = row[7].strip() if row[7].strip() else None
# Create or get Entity
entity = None
if entity_name:
entity, entity_created = Entity.objects.get_or_create(
name=entity_name,
defaults={'name': entity_name}
)
if entity_created:
created_entities += 1
# Get related user if provided
# related_user = None
# if related_user_name:
# try:
# related_user = User.objects.get(username=related_user_name)
# except User.DoesNotExist:
# # Try to find by first name if username doesn't exist
# related_user = User.objects.filter(first_name__icontains=related_user_name).first()
# Create or update Prospect
prospect, prospect_created = Prospect.objects.get_or_create(
email=email,
defaults={
'first_name': first_name,
'last_name': last_name,
'phone': phone,
'name_unsure': False,
'source': source,
}
)
if prospect_created:
created_prospects += 1
# else:
# # Check if names are different and mark as name_unsure
# if (prospect.first_name != first_name or prospect.last_name != last_name):
# prospect.name_unsure = True
# # Update related_user if provided
# if related_user:
# prospect.related_user = related_user
# prospect.save()
# updated_prospects += 1
# Associate entity with prospect
if entity:
prospect.entities.add(entity)
# Create Event if attachment_text or status is provided
# if attachment_text or status_text:
# # Map status text to Status enum
# status_value = None
# declination_reason = None
# if status_text:
# if 'CONTACTED' in status_text:
# status_value = Status.CONTACTED
# elif 'RESPONDED' in status_text:
# status_value = Status.RESPONDED
# elif 'SHOULD_TEST' in status_text:
# status_value = Status.SHOULD_TEST
# elif 'CUSTOMER' in status_text:
# status_value = Status.CUSTOMER
# elif 'TESTING' in status_text:
# status_value = Status.TESTING
# elif 'LOST' in status_text:
# status_value = Status.LOST
# elif 'DECLINED_TOO_EXPENSIVE' in status_text:
# status_value = Status.DECLINED
# declination_reason = DeclinationReason.TOO_EXPENSIVE
# elif 'USE_OTHER_PRODUCT' in status_text:
# status_value = Status.DECLINED
# declination_reason = DeclinationReason.USE_OTHER_PRODUCT
# elif 'USE_ANDROID' in status_text:
# status_value = Status.DECLINED
# declination_reason = DeclinationReason.USE_ANDROID
# elif 'NOK' in status_text:
# status_value = Status.DECLINED
# declination_reason = DeclinationReason.UNKNOWN
# elif 'DECLINED_UNRELATED' in status_text:
# status_value = Status.DECLINED_UNRELATED
# activity = Activity.objects.create(
# type=ActivityType.SMS,
# attachment_text=attachment_text,
# status=status_value,
# declination_reason=declination_reason,
# description=f"Imported from CSV - Status: {status_text}" if status_text else "Imported from CSV"
# )
# activity.prospects.add(prospect)
# created_events += 1
result = f"Successfully imported: {created_prospects} new prospects, {updated_prospects} updated prospects, {created_entities} new entities, {created_events} new events"
return result
except Exception as e:
raise Exception(f"Error processing CSV file: {str(e)}")
def send_email(self, request, queryset):
logger.info('send_email to prospects form initiated...')
if 'apply' in request.POST:
form = EmailTemplateSelectionForm(request.POST)
if form.is_valid():
email_template = form.cleaned_data['email_template']
sent_count, failed_count = self.process_selected_items_with_template(request, queryset, email_template)
if failed_count > 0:
self.message_user(request, f"Email sent to {sent_count} prospects, {failed_count} failed using the '{email_template.name}' template.", messages.WARNING)
else:
self.message_user(request, f"Email sent to {sent_count} prospects using the '{email_template.name}' template.", messages.SUCCESS)
return HttpResponseRedirect(request.get_full_path())
else:
form = EmailTemplateSelectionForm()
return render(request, 'admin/biz/select_email_template.html', {
'prospects': queryset,
'form': form,
'title': 'Send Email to Prospects'
})
send_email.short_description = "Send email"
def process_selected_items_with_template(self, request, queryset, email_template):
sent_count = 0
error_emails = []
all_emails = []
logger.info(f'Sending email to {queryset.count()} users...')
for prospect in queryset:
mail_body = email_template.body.replace(
'{{name}}',
f' {prospect.first_name}' if prospect.first_name and len(prospect.first_name) > 0 else ''
)
# mail_body = email_template.body.replace('{{name}}', prospect.first_name)
all_emails.append(prospect.email)
try:
send_mail(
email_template.subject,
mail_body,
request.user.email,
[prospect.email],
fail_silently=False,
)
sent_count += 1
activity = Activity.objects.create(
type=ActivityType.MAIL,
status=Status.CONTACTED,
description=f"Email sent: {email_template.subject}"
)
activity.prospects.add(prospect)
except Exception as e:
error_emails.append(prospect.email)
logger.error(f'Failed to send email to {prospect.email}: {str(e)}')
time.sleep(1)
if error_emails:
logger.error(f'Failed to send emails to: {error_emails}')
return sent_count, len(error_emails)
@admin.register(ProspectGroup)
class ProspectGroupAdmin(SyncedObjectAdmin):
list_display = ('name', 'user_count')
date_hierarchy = 'creation_date'
raw_id_fields = ['related_user']
@admin.register(Activity)
class ActivityAdmin(SyncedObjectAdmin):
# raw_id_fields = ['prospects']
list_display = ('prospect_names', 'last_update', 'status', 'type', 'description', 'attachment_text', )
list_filter = ('status', 'type')
search_fields = ('attachment_text',)
date_hierarchy = 'last_update'
autocomplete_fields = ['prospects', 'related_user']
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
# Pre-populate fields from URL parameters
if 'prospect' in request.GET:
try:
prospect_id = request.GET['prospect']
prospect = Prospect.objects.get(id=prospect_id)
form.base_fields['prospects'].initial = [prospect]
form.base_fields['related_user'].initial = request.user
# You can set other fields based on the prospect
# form.base_fields['title'].initial = f"Event for {prospect.}"
# form.base_fields['status'].initial = 'pending'
except (Prospect.DoesNotExist, ValueError):
pass
return form
def save_model(self, request, obj, form, change):
if obj.related_user is None:
obj.related_user = request.user
super().save_model(request, obj, form, change)
def get_event_display(self, obj):
return str(obj)
get_event_display.short_description = 'Activity'

@ -0,0 +1,70 @@
from django.urls import path
from django.http import HttpResponse
from tournaments.models import CustomUser
from tournaments.models.enums import UserOrigin
from django.core.mail import send_mail
import time
def users_list(with_tournaments):
return CustomUser.objects.filter(origin=UserOrigin.APP).exclude(purchase__isnull=False).filter(events__isnull=with_tournaments)
def email_users_with_tournaments_count(request):
users = users_list(False)
emails = [user.email for user in users]
return HttpResponse(f'users = {len(users)}, \n\nemails = {emails}')
def email_users_count(request):
users = users_list(True)
emails = [user.email for user in users]
return HttpResponse(f'users = {len(users)}, \n\nemails = {emails}')
def email_users_view(request):
return email_users(request, users_list(True), 0)
def email_users_with_tournaments(request):
return email_users(request, users_list(False), 1)
def email_users(request, users, template_index):
users = users_list(True)
subject = 'check Padel Club'
from_email = 'laurent@padelclub.app'
sent_count = 0
error_emails = []
all_emails = []
for user in users:
mail_body = template(user, template_index) # f'Bonjour {user.first_name}, cool la vie ?'
all_emails.append(user.email)
try:
send_mail(
subject,
mail_body,
from_email,
[user.email],
fail_silently=False,
)
sent_count += 1
except Exception as e:
error_emails.append(user.email)
time.sleep(1)
return HttpResponse(f'users = {len(users)}, sent = {sent_count}, errors = {len(error_emails)}, \n\nemails = {all_emails}, \n\nerror emails = {error_emails}')
def template(user, index):
if index == 0:
return f'Bonjour {user.first_name}, \n\n'
else:
return f'Bonjour {user.first_name}, \n\nJe te remercie d\'avoir téléchargé Padel Club. J\'ai pu voir que tu avais créé quelques tournois mais sans aller plus loin, est-ce que tu pourrais me dire ce qui t\'as freiné ?\n\nLaurent Morvillier'
urlpatterns = [
path('email_users/', email_users_view, name='biz_email_users'),
path('email_users_count/', email_users_count, name='biz_email_count'),
path('email_users_with_tournaments_count/', email_users_with_tournaments_count, name='biz_email_with_tournaments_count'),
path('email_users_with_tournaments/', email_users_with_tournaments, name='email_users_with_tournaments'),
]

@ -1,5 +1,5 @@
from django.apps import AppConfig from django.apps import AppConfig
class CrmConfig(AppConfig): class BizConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'crm' name = 'biz'

@ -0,0 +1,163 @@
from xml.dom import Node
import django_filters
from django.db.models import Max, F, Q
from django.contrib.auth import get_user_model
from django.contrib import admin
from django.utils import timezone
from dateutil.relativedelta import relativedelta
from .models import Activity, Prospect, Status, DeclinationReason, ProspectGroup
User = get_user_model()
class ProspectFilter(django_filters.FilterSet):
zip_code = django_filters.CharFilter(lookup_expr='istartswith', label='Code postal')
activities = django_filters.ModelMultipleChoiceFilter(
queryset=Activity.objects.all(),
field_name='activities',
)
city = django_filters.CharFilter(lookup_expr='icontains', label='Ville')
name = django_filters.CharFilter(method='filter_name', label='Nom')
def filter_name(self, queryset, name, value):
return queryset.filter(
Q(first_name__icontains=value) | Q(last_name__icontains=value) | Q(entity_name__icontains=value)
)
class Meta:
model = Prospect
fields = ['name', 'city', 'activities', 'zip_code']
class StaffUserFilter(admin.SimpleListFilter):
title = 'staff user'
parameter_name = 'user'
def lookups(self, request, model_admin):
staff_users = User.objects.filter(is_staff=True)
return [(user.id, user.username) for user in staff_users]
def queryset(self, request, queryset):
# Filter the queryset based on the selected user ID
if self.value():
return queryset.filter(related_user__id=self.value())
return queryset
class ProspectProfileFilter(admin.SimpleListFilter):
title = 'Prospect profiles' # displayed in the admin UI
parameter_name = 'profile' # URL parameter
def lookups(self, request, model_admin):
return (
('tournament_at_least_1_month_old', 'tournaments > 1 month old'),
('no_tournaments', 'No tournaments'),
)
def queryset(self, request, queryset):
if not self.value():
return queryset
two_months_ago = timezone.now().date() - relativedelta(months=2)
if self.value() == 'tournament_at_least_2_month_old':
return queryset.filter(
official_user__isnull=False,
official_user__events__creation_date__lte=two_months_ago
)
elif self.value() == 'no_tournaments':
return queryset.filter(
official_user__isnull=False,
official_user__events__isnull=True
)
class ProspectStatusFilter(admin.SimpleListFilter):
title = 'Status'
parameter_name = 'status'
def lookups(self, request, model_admin):
return [(tag.name, tag.value) for tag in Status]
def queryset(self, request, queryset):
if self.value() == Status.NONE:
return queryset.filter(activities__isnull=True)
elif self.value():
prospects_with_status = []
for prospect in queryset:
if prospect.current_status() == self.value():
prospects_with_status.append(prospect.id)
return queryset.filter(id__in=prospects_with_status)
else:
return queryset
class ProspectDeclineReasonFilter(admin.SimpleListFilter):
title = 'Decline reason'
parameter_name = 'reason'
def lookups(self, request, model_admin):
return [(tag.name, tag.value) for tag in DeclinationReason]
def queryset(self, request, queryset):
if self.value():
# Get prospects whose most recent activity has the selected status
return queryset.filter(
activities__declination_reason=self.value()
).annotate(
latest_activity_date=Max('activities__creation_date')
).filter(
activities__creation_date=F('latest_activity_date'),
activities__declination_reason=self.value()
).distinct()
else:
return queryset
class ProspectGroupFilter(admin.SimpleListFilter):
title = 'ProspectGroup'
parameter_name = 'prospect_group'
def lookups(self, request, model_admin):
prospect_groups = ProspectGroup.objects.all().order_by('-creation_date')
return [(group.id, group.name) for group in prospect_groups]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(prospect_groups__id=self.value())
return queryset
class ContactAgainFilter(admin.SimpleListFilter):
title = 'Contact again' # or whatever you want
parameter_name = 'contact_again'
def lookups(self, request, model_admin):
return (
('1', 'Should be contacted'),
# ('0', 'Is null'),
)
def queryset(self, request, queryset):
if self.value() == '1':
return queryset.filter(contact_again__isnull=False)
# if self.value() == '0':
# return queryset.filter(my_field__isnull=True)
return queryset
class PhoneFilter(admin.SimpleListFilter):
title = 'Phone number'
parameter_name = 'phone_filter'
def lookups(self, request, model_admin):
return (
('exclude_mobile', 'Exclude mobile (06/07)'),
('mobile_only', 'Mobile only (06/07)'),
)
def queryset(self, request, queryset):
if self.value() == 'exclude_mobile':
return queryset.exclude(
Q(phone__startswith='06') | Q(phone__startswith='07')
)
elif self.value() == 'mobile_only':
return queryset.filter(
Q(phone__startswith='06') | Q(phone__startswith='07')
)
return queryset

@ -0,0 +1,61 @@
from django import forms
from .models import EmailTemplate
# class SmallTextArea(forms.Textarea):
# def __init__(self, *args, **kwargs):
# kwargs.setdefault('attrs', {})
# kwargs['attrs'].update({
# 'rows': 2,
# 'cols': 100,
# 'style': 'height: 80px; width: 800px;'
# })
# super().__init__(*args, **kwargs)
# class ProspectForm(forms.ModelForm):
# class Meta:
# model = Prospect
# fields = ['entity_name', 'first_name', 'last_name', 'email',
# 'phone', 'address', 'zip_code', 'city']
# class BulkEmailForm(forms.Form):
# prospects = forms.ModelMultipleChoiceField(
# queryset=Prospect.objects.all(),
# widget=forms.CheckboxSelectMultiple
# )
# subject = forms.CharField(max_length=200)
# content = forms.CharField(widget=forms.Textarea)
# class EventForm(forms.ModelForm):
# prospects = forms.ModelMultipleChoiceField(
# queryset=Prospect.objects.all(),
# widget=forms.SelectMultiple(attrs={'class': 'select2'}),
# required=False
# )
# description = forms.CharField(widget=SmallTextArea)
# attachment_text = forms.CharField(widget=SmallTextArea)
# class Meta:
# model = Event
# fields = ['date', 'type', 'description', 'attachment_text', 'prospects', 'status']
# widgets = {
# 'date': forms.DateTimeInput(attrs={'type': 'datetime-local'}),
# }
class FileImportForm(forms.Form):
source = forms.CharField(max_length=200)
file = forms.FileField(
label='Select file to import',
help_text='Choose a file to upload and process',
widget=forms.FileInput(attrs={'accept': '.csv,.xlsx,.xls,.txt'})
)
class CSVImportForm(forms.Form):
csv_file = forms.FileField()
class EmailTemplateSelectionForm(forms.Form):
email_template = forms.ModelChoiceField(
queryset=EmailTemplate.objects.all(),
empty_label="Select an email template...",
widget=forms.Select(attrs={'class': 'form-control'})
)

@ -0,0 +1,103 @@
# Generated by Django 5.1 on 2025-07-20 10:20
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Activity',
fields=[
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('last_update', models.DateTimeField(default=django.utils.timezone.now)),
('data_access_ids', models.JSONField(default=list)),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('status', models.CharField(blank=True, choices=[('NONE', 'None'), ('INBOUND', 'Inbound'), ('CONTACTED', 'Contacted'), ('RESPONDED', 'Responded'), ('SHOULD_TEST', 'Should test'), ('TESTING', 'Testing'), ('CUSTOMER', 'Customer'), ('LOST', 'Lost customer'), ('DECLINED', 'Declined'), ('DECLINED_UNRELATED', 'Declined without significance')], max_length=50, null=True)),
('declination_reason', models.CharField(blank=True, choices=[('TOO_EXPENSIVE', 'Too expensive'), ('USE_OTHER_PRODUCT', 'Use other product'), ('USE_ANDROID', 'Use Android'), ('UNKNOWN', 'Unknown')], max_length=50, null=True)),
('type', models.CharField(blank=True, choices=[('MAIL', 'Mail'), ('SMS', 'SMS'), ('CALL', 'Call'), ('PRESS', 'Press Release'), ('WORD_OF_MOUTH', 'Word of mouth')], max_length=20, null=True)),
('description', models.TextField(blank=True, null=True)),
('attachment_text', models.TextField(blank=True, null=True)),
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'Activities',
'ordering': ['-creation_date'],
},
),
migrations.CreateModel(
name='EmailTemplate',
fields=[
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('last_update', models.DateTimeField(default=django.utils.timezone.now)),
('data_access_ids', models.JSONField(default=list)),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)),
('subject', models.CharField(max_length=200)),
('body', models.TextField(blank=True, null=True)),
('activities', models.ManyToManyField(blank=True, related_name='email_templates', to='biz.activity')),
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Entity',
fields=[
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('last_update', models.DateTimeField(default=django.utils.timezone.now)),
('data_access_ids', models.JSONField(default=list)),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('name', models.CharField(blank=True, max_length=200, null=True)),
('address', models.CharField(blank=True, max_length=200, null=True)),
('zip_code', models.CharField(blank=True, max_length=20, null=True)),
('city', models.CharField(blank=True, max_length=500, null=True)),
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('official_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'Entities',
},
),
migrations.CreateModel(
name='Prospect',
fields=[
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('last_update', models.DateTimeField(default=django.utils.timezone.now)),
('data_access_ids', models.JSONField(default=list)),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('first_name', models.CharField(blank=True, max_length=200, null=True)),
('last_name', models.CharField(blank=True, max_length=200, null=True)),
('email', models.EmailField(max_length=254, unique=True)),
('phone', models.CharField(blank=True, max_length=25, null=True)),
('name_unsure', models.BooleanField(default=False)),
('source', models.CharField(blank=True, max_length=100, null=True)),
('entities', models.ManyToManyField(blank=True, related_name='prospects', to='biz.entity')),
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('official_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='activity',
name='prospects',
field=models.ManyToManyField(related_name='activities', to='biz.prospect'),
),
]

@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2025-07-31 15:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='prospect',
name='email',
field=models.EmailField(blank=True, max_length=254, null=True, unique=True),
),
]

@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2025-08-07 16:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0002_alter_prospect_email'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='status',
field=models.CharField(blank=True, choices=[('NONE', 'None'), ('INBOUND', 'Inbound'), ('CONTACTED', 'Contacted'), ('RESPONDED', 'Responded'), ('SHOULD_TEST', 'Should test'), ('TESTING', 'Testing'), ('CUSTOMER', 'Customer'), ('LOST', 'Lost customer'), ('DECLINED', 'Declined'), ('NOT_CONCERNED', 'Not concerned'), ('SHOULD_BUY', 'Should buy')], max_length=50, null=True),
),
]

@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2025-09-04 12:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0003_alter_activity_status'),
]
operations = [
migrations.AddField(
model_name='prospect',
name='contact_again',
field=models.DateTimeField(blank=True, null=True),
),
]

@ -0,0 +1,38 @@
# Generated by Django 5.1 on 2025-09-22 12:34
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0004_prospect_contact_again'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='activity',
name='status',
field=models.CharField(blank=True, choices=[('NONE', 'None'), ('INBOUND', 'Inbound'), ('CONTACTED', 'Contacted'), ('RESPONDED', 'Responded'), ('SHOULD_TEST', 'Should test'), ('TESTING', 'Testing'), ('CUSTOMER', 'Customer'), ('LOST', 'Lost customer'), ('DECLINED', 'Declined'), ('NOT_CONCERNED', 'Not concerned'), ('SHOULD_BUY', 'Should buy'), ('HAVE_CREATED_ACCOUNT', 'Have created account')], max_length=50, null=True),
),
migrations.CreateModel(
name='Campaign',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('last_update', models.DateTimeField(default=django.utils.timezone.now)),
('data_access_ids', models.JSONField(default=list)),
('name', models.CharField(blank=True, max_length=200, null=True)),
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('prospects', models.ManyToManyField(blank=True, related_name='campaigns', to='biz.prospect')),
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

@ -0,0 +1,19 @@
# Generated by Django 5.1 on 2025-09-22 13:10
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0005_alter_activity_status_campaign'),
]
operations = [
# migrations.AlterField(
# model_name='campaign',
# name='id',
# field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
# ),
]

@ -0,0 +1,37 @@
# Generated by Django 5.1 on 2025-09-22 14:08
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0006_alter_campaign_id'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ProspectGroup',
fields=[
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('last_update', models.DateTimeField(default=django.utils.timezone.now)),
('data_access_ids', models.JSONField(default=list)),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('name', models.CharField(blank=True, max_length=200, null=True)),
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('prospects', models.ManyToManyField(blank=True, related_name='prospect_groups', to='biz.prospect')),
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.DeleteModel(
name='Campaign',
),
]

@ -0,0 +1,23 @@
# Generated by Django 5.1 on 2025-10-15 07:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0007_prospectgroup_delete_campaign'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='declination_reason',
field=models.CharField(blank=True, choices=[('TOO_EXPENSIVE', 'Too expensive'), ('USE_OTHER_PRODUCT', 'Use other product'), ('USE_ANDROID', 'Use Android'), ('TOO_FEW_TOURNAMENTS', 'Too few tournaments'), ('NOT_INTERESTED', 'Not interested'), ('UNKNOWN', 'Unknown')], max_length=50, null=True),
),
migrations.AlterField(
model_name='activity',
name='type',
field=models.CharField(blank=True, choices=[('MAIL', 'Mail'), ('SMS', 'SMS'), ('CALL', 'Call'), ('PRESS', 'Press Release'), ('WORD_OF_MOUTH', 'Word of mouth'), ('WHATS_APP', 'WhatsApp')], max_length=20, null=True),
),
]

@ -1,6 +1,6 @@
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
class CRMAccessMixin(LoginRequiredMixin, UserPassesTestMixin): class bizAccessMixin(LoginRequiredMixin, UserPassesTestMixin):
def test_func(self): def test_func(self):
return self.request.user.groups.filter(name='CRM Manager').exists() return self.request.user.groups.filter(name='biz Manager').exists()

@ -0,0 +1,221 @@
from typing import Self
from django.db import models
from django.contrib.auth import get_user_model
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from django.utils import timezone
import uuid
from sync.models import BaseModel
User = get_user_model()
class Status(models.TextChoices):
NONE = 'NONE', 'None'
INBOUND = 'INBOUND', 'Inbound'
CONTACTED = 'CONTACTED', 'Contacted'
RESPONDED = 'RESPONDED', 'Responded'
SHOULD_TEST = 'SHOULD_TEST', 'Should test'
TESTING = 'TESTING', 'Testing'
CUSTOMER = 'CUSTOMER', 'Customer'
LOST = 'LOST', 'Lost customer'
DECLINED = 'DECLINED', 'Declined'
# DECLINED_UNRELATED = 'DECLINED_UNRELATED', 'Declined without significance'
NOT_CONCERNED = 'NOT_CONCERNED', 'Not concerned'
SHOULD_BUY = 'SHOULD_BUY', 'Should buy'
HAVE_CREATED_ACCOUNT = 'HAVE_CREATED_ACCOUNT', 'Have created account'
class DeclinationReason(models.TextChoices):
TOO_EXPENSIVE = 'TOO_EXPENSIVE', 'Too expensive'
USE_OTHER_PRODUCT = 'USE_OTHER_PRODUCT', 'Use other product'
USE_ANDROID = 'USE_ANDROID', 'Use Android'
TOO_FEW_TOURNAMENTS = 'TOO_FEW_TOURNAMENTS', 'Too few tournaments'
NOT_INTERESTED = 'NOT_INTERESTED', 'Not interested'
UNKNOWN = 'UNKNOWN', 'Unknown'
class ActivityType(models.TextChoices):
MAIL = 'MAIL', 'Mail'
SMS = 'SMS', 'SMS'
CALL = 'CALL', 'Call'
PRESS = 'PRESS', 'Press Release'
WORD_OF_MOUTH = 'WORD_OF_MOUTH', 'Word of mouth'
WHATS_APP = 'WHATS_APP', 'WhatsApp'
class Entity(BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
name = models.CharField(max_length=200, null=True, blank=True)
address = models.CharField(max_length=200, null=True, blank=True)
zip_code = models.CharField(max_length=20, null=True, blank=True)
city = models.CharField(max_length=500, null=True, blank=True)
official_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
# status = models.IntegerField(default=Status.NONE, choices=Status.choices)
def delete_dependencies(self):
pass
class Meta:
verbose_name_plural = "Entities"
def __str__(self):
return self.name
class Prospect(BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
first_name = models.CharField(max_length=200, null=True, blank=True)
last_name = models.CharField(max_length=200, null=True, blank=True)
email = models.EmailField(unique=True, null=True, blank=True)
phone = models.CharField(max_length=25, null=True, blank=True)
name_unsure = models.BooleanField(default=False)
official_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
entities = models.ManyToManyField(Entity, blank=True, related_name='prospects')
source = models.CharField(max_length=100, null=True, blank=True)
contact_again = models.DateTimeField(null=True, blank=True)
def delete_dependencies(self):
pass
# class Meta:
# permissions = [
# ("manage_prospects", "Can manage prospects"),
# ("view_prospects", "Can view prospects"),
# ]
def current_status(self):
last_activity = self.activities.exclude(status=None).order_by('-creation_date').first()
if last_activity:
return last_activity.status
return Status.NONE
def current_activity_type(self):
last_activity = self.activities.exclude(type=None).order_by('-creation_date').first()
if last_activity:
return last_activity.type
return None
def current_text(self):
last_activity = self.activities.exclude(status=None).order_by('-creation_date').first()
if last_activity:
return last_activity.attachment_text
return ''
def current_declination_reason(self):
last_activity = self.activities.exclude(status=None).order_by('-creation_date').first()
if last_activity:
return last_activity.declination_reason
return None
def entity_names(self):
entity_names = [entity.name for entity in self.entities.all()]
return " - ".join(entity_names)
def full_name(self):
if self.first_name and self.last_name:
return f'{self.first_name} {self.last_name}'
elif self.first_name:
return self.first_name
elif self.last_name:
return self.last_name
else:
return 'no name'
def __str__(self):
return self.full_name()
class Activity(BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
status = models.CharField(max_length=50, choices=Status.choices, null=True, blank=True)
declination_reason = models.CharField(max_length=50, choices=DeclinationReason.choices, null=True, blank=True)
type = models.CharField(max_length=20, choices=ActivityType.choices, null=True, blank=True)
description = models.TextField(null=True, blank=True)
attachment_text = models.TextField(null=True, blank=True)
prospects = models.ManyToManyField(Prospect, related_name='activities')
def __str__(self):
if self.status:
return self.status
elif self.type:
return self.type
else:
return f'desc = {self.description}, attachment_text = {self.attachment_text}'
def delete_dependencies(self):
pass
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# Update last_update for all related prospects when activity is saved
self.prospects.update(last_update=timezone.now())
class Meta:
verbose_name_plural = "Activities"
ordering = ['-creation_date']
# def __str__(self):
# return f"{self.get_type_display()} - {self.creation_date.date()}"
def html_desc(self):
fields = [field for field in [self.creation_date.strftime("%d/%m/%Y %H:%M"), self.status, self.declination_reason, self.attachment_text, self.description, self.type] if field is not None]
html = '<table><tr>'
for field in fields:
html += f'<td style="padding:0px 5px;">{field}</td>'
html += '</tr></table>'
return html
def prospect_names(self):
prospect_names = [prospect.full_name() for prospect in self.prospects.all()]
return ", ".join(prospect_names)
@receiver(m2m_changed, sender=Activity.prospects.through)
def update_prospect_last_update(sender, instance, action, pk_set, **kwargs):
instance.prospects.update(last_update=timezone.now(),contact_again=None)
class EmailTemplate(BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
name = models.CharField(max_length=100)
subject = models.CharField(max_length=200)
body = models.TextField(null=True, blank=True)
activities = models.ManyToManyField(Activity, blank=True, related_name='email_templates')
def __str__(self):
return self.name
def delete_dependencies(self):
pass
class ProspectGroup(BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
name = models.CharField(max_length=200, null=True, blank=True)
prospects = models.ManyToManyField(Prospect, blank=True, related_name='prospect_groups')
def user_count(self):
return self.prospects.count()
def __str__(self):
return self.name
def delete_dependencies(self):
pass
# class EmailCampaign(models.Model):
# event = models.OneToOneField(Event, on_delete=models.CASCADE)
# subject = models.CharField(max_length=200)
# content = models.TextField()
# sent_at = models.DateTimeField(null=True, blank=True)
# class EmailTracker(models.Model):
# campaign = models.ForeignKey(EmailCampaign, on_delete=models.CASCADE)
# prospect = models.ForeignKey(Prospect, on_delete=models.CASCADE)
# tracking_id = models.UUIDField(default=uuid.uuid4, editable=False)
# sent = models.BooleanField(default=False)
# sent_at = models.DateTimeField(null=True, blank=True)
# opened = models.BooleanField(default=False)
# opened_at = models.DateTimeField(null=True, blank=True)
# clicked = models.BooleanField(default=False)
# clicked_at = models.DateTimeField(null=True, blank=True)
# error_message = models.TextField(blank=True)
# class Meta:
# unique_together = ['campaign', 'prospect']

@ -0,0 +1,448 @@
{% extends "admin/base_site.html" %}
{% load static %}
{% block extrahead %}
{{ block.super }}
<style>
.dashboard-container {
padding: 20px;
}
.filter-switch {
margin-bottom: 20px;
padding: 15px;
background: #f8f8f8;
border: 1px solid #ddd;
border-radius: 4px;
}
.filter-switch label {
font-weight: bold;
margin-right: 10px;
cursor: pointer;
}
.filter-switch input[type="checkbox"] {
cursor: pointer;
width: 18px;
height: 18px;
vertical-align: middle;
}
.status-section {
margin-bottom: 30px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
}
.status-header {
background: #417690;
color: white;
padding: 12px 15px;
font-weight: bold;
font-size: 14px;
}
.status-header .status-name {
font-size: 16px;
margin-right: 10px;
}
.status-header .count {
font-size: 13px;
opacity: 0.9;
}
.prospect-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.prospect-table thead {
background: #f9f9f9;
border-bottom: 2px solid #ddd;
}
.prospect-table thead th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: #666;
border-bottom: 1px solid #ddd;
}
.prospect-table th:nth-child(1),
.prospect-table td:nth-child(1) {
width: 225px;
}
.prospect-table th:nth-child(2),
.prospect-table td:nth-child(2) {
width: auto;
}
.prospect-table th:nth-child(3),
.prospect-table td:nth-child(3) {
width: 120px;
}
.prospect-table th:nth-child(4),
.prospect-table td:nth-child(4) {
width: 140px;
}
.prospect-table th:nth-child(5),
.prospect-table td:nth-child(5) {
width: 130px;
}
.prospect-table th:nth-child(6),
.prospect-table td:nth-child(6) {
width: 130px;
}
.prospect-table th.actions-col,
.prospect-table td.actions-col {
width: 80px;
text-align: center;
}
.add-activity-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: #70bf2b;
color: white !important;
text-decoration: none !important;
border-radius: 50%;
font-size: 18px;
font-weight: bold;
transition: background-color 0.2s;
}
.add-activity-btn:hover {
background: #5fa624;
color: white !important;
text-decoration: none !important;
}
.prospect-table tbody tr {
border-bottom: 1px solid #eee;
transition: background-color 0.2s;
}
.prospect-table tbody tr:hover {
background: #f5f5f5;
}
.prospect-table tbody td {
padding: 10px 12px;
font-size: 13px;
}
.prospect-table tbody td a {
color: #417690;
text-decoration: none;
}
.prospect-table tbody td a:hover {
text-decoration: underline;
}
.prospect-name {
font-weight: 500;
}
.prospect-entity {
color: #666;
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
}
.prospect-date {
color: #666;
white-space: nowrap;
}
.prospect-status {
display: inline-block;
padding: 3px 8px;
background: #e8f4f8;
border-radius: 3px;
font-size: 11px;
color: #417690;
font-weight: 500;
}
.empty-state {
padding: 40px 15px;
text-align: center;
color: #999;
font-style: italic;
}
</style>
{% endblock %}
{% block content %}
<div class="dashboard-container">
<!-- Quick Actions -->
<div class="quick-actions" style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 12px; padding: 25px; margin-bottom: 20px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 15px;">
<a href="{% url 'admin:biz_prospect_changelist' %}"
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Prospects
</a>
<a href="{% url 'admin:biz_activity_changelist' %}"
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Activities
</a>
<a href="{% url 'admin:biz_entity_changelist' %}"
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Entities
</a>
</div>
</div>
<div class="filter-switch">
<label for="my-prospects-toggle">
<input type="checkbox" id="my-prospects-toggle" {% if filter_my %}checked{% endif %}>
Show only my prospects
</label>
</div>
<!-- CONTACT AGAIN Section -->
<div class="status-section">
<div class="status-header">
<span class="status-name">CONTACT AGAIN</span>
<span class="count">({{ contact_again_prospects.count }})</span>
</div>
{% if contact_again_prospects %}
<table class="prospect-table">
<thead>
<tr>
<th>Name</th>
<th>Entity</th>
<th>Phone</th>
<th>Status</th>
<th>Contact Again</th>
<th>Last Update</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in contact_again_prospects %}
<tr>
<td>
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name">
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }}
</a>
</td>
<td class="prospect-entity">{{ prospect.entity_names }}</td>
<td>{{ prospect.phone|default:"-" }}</td>
<td><span class="prospect-status">{{ prospect.current_status }}</span></td>
<td class="prospect-date">{{ prospect.contact_again|date:"d/m/Y" }}</td>
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td>
<td class="actions-col">
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No prospects</div>
{% endif %}
</div>
<!-- SHOULD_TEST Section -->
<div class="status-section">
<div class="status-header">
<span class="status-name">SHOULD TEST</span>
<span class="count">({{ should_test_prospects.count }})</span>
</div>
{% if should_test_prospects %}
<table class="prospect-table">
<thead>
<tr>
<th>Name</th>
<th>Entity</th>
<th>Phone</th>
<th>Activity Type</th>
<th>Last Update</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in should_test_prospects %}
<tr>
<td>
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name">
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }}
</a>
</td>
<td class="prospect-entity">{{ prospect.entity_names }}</td>
<td>{{ prospect.phone|default:"-" }}</td>
<td>{{ prospect.current_activity_type|default:"-" }}</td>
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td>
<td class="actions-col">
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No prospects</div>
{% endif %}
</div>
<!-- TESTING Section -->
<div class="status-section">
<div class="status-header">
<span class="status-name">TESTING</span>
<span class="count">({{ testing_prospects.count }})</span>
</div>
{% if testing_prospects %}
<table class="prospect-table">
<thead>
<tr>
<th>Name</th>
<th>Entity</th>
<th>Phone</th>
<th>Activity Type</th>
<th>Last Update</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in testing_prospects %}
<tr>
<td>
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name">
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }}
</a>
</td>
<td class="prospect-entity">{{ prospect.entity_names }}</td>
<td>{{ prospect.phone|default:"-" }}</td>
<td>{{ prospect.current_activity_type|default:"-" }}</td>
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td>
<td class="actions-col">
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No prospects</div>
{% endif %}
</div>
<!-- OTHERS Section -->
<div class="status-section">
<div class="status-header">
<span class="status-name">OTHERS</span>
<span class="count">({{ others_prospects.count }})</span>
</div>
{% if others_prospects %}
<table class="prospect-table">
<thead>
<tr>
<th>Name</th>
<th>Entity</th>
<th>Phone</th>
<th>Status</th>
<th>Activity Type</th>
<th>Last Update</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in others_prospects %}
<tr>
<td>
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name">
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }}
</a>
</td>
<td class="prospect-entity">{{ prospect.entity_names }}</td>
<td>{{ prospect.phone|default:"-" }}</td>
<td><span class="prospect-status">{{ prospect.current_status }}</span></td>
<td>{{ prospect.current_activity_type|default:"-" }}</td>
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td>
<td class="actions-col">
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No prospects</div>
{% endif %}
</div>
<!-- RESPONDED Section -->
<div class="status-section">
<div class="status-header">
<span class="status-name">RESPONDED</span>
<span class="count">({{ responded_prospects.count }})</span>
</div>
{% if responded_prospects %}
<table class="prospect-table">
<thead>
<tr>
<th>Name</th>
<th>Entity</th>
<th>Phone</th>
<th>Activity Type</th>
<th>Last Update</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in responded_prospects %}
<tr>
<td>
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name">
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }}
</a>
</td>
<td class="prospect-entity">{{ prospect.entity_names }}</td>
<td>{{ prospect.phone|default:"-" }}</td>
<td>{{ prospect.current_activity_type|default:"-" }}</td>
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td>
<td class="actions-col">
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No prospects</div>
{% endif %}
</div>
</div>
<script>
document.getElementById('my-prospects-toggle').addEventListener('change', function(e) {
const url = new URL(window.location);
if (e.target.checked) {
url.searchParams.set('my', 'true');
} else {
url.searchParams.delete('my');
}
window.location.href = url.toString();
});
</script>
{% endblock %}

@ -0,0 +1,81 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static admin_list %}
{% block title %}Email Users{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; 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>
&rsaquo; {% 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 %} {% block content %}
<div class="container padding-bottom"> <div class="container padding-bottom">

@ -1,4 +1,4 @@
{% extends "crm/base.html" %} {% extends "biz/base.html" %}
{% block content %} {% block content %}
<div class="container mt-4"> <div class="container mt-4">

@ -1,4 +1,4 @@
{% extends "crm/base.html" %} {% block content %} {% extends "biz/base.html" %} {% block content %}
<div class="container"> <div class="container">
<div class="grid-x padding-bottom"> <div class="grid-x padding-bottom">
<div class="cell medium-6 large-6 padding10 bubble"> <div class="cell medium-6 large-6 padding10 bubble">
@ -14,7 +14,7 @@
Save Event Save Event
</button> </button>
<a <a
href="{% url 'crm:planned_events' %}" href="{% url 'biz:planned_events' %}"
class="btn btn-secondary" class="btn btn-secondary"
>Cancel</a >Cancel</a
> >

@ -7,9 +7,9 @@
<div class="right-column"> <div class="right-column">
<span>{{ event.date|date:"d/m/Y H:i" }}</span> <span>{{ event.date|date:"d/m/Y H:i" }}</span>
<a href="{% url 'crm:edit_event' event.id %}" class="small-button">Edit</a> <a href="{% url 'biz:edit_event' event.id %}" class="small-button">Edit</a>
<!-- {% if event.status == 'PLANNED' %} <!-- {% if event.status == 'PLANNED' %}
<a href="{% url 'crm:start_event' event.id %}" class="small-button">Start</a> <a href="{% url 'biz:start_event' event.id %}" class="small-button">Start</a>
{% endif %} --> {% endif %} -->
</div> </div>
</div> </div>

@ -1,23 +1,23 @@
{% extends "crm/base.html" %} {% extends "biz/base.html" %}
{% load crm_tags %} {% load biz_tags %}
{% block content %} {% block content %}
{% if request.user|is_crm_manager %} {% if request.user|is_biz_manager %}
<div class="d-flex"> <div class="d-flex">
<a href="{% url 'crm:prospect-list' %}" class="small-button margin-v20"> <a href="{% url 'biz:prospect-list' %}" class="small-button margin-v20">
Prospects Prospects
</a> </a>
<a href="{% url 'crm:add-event' %}" class="small-button margin-v20"> <a href="{% url 'biz:add-event' %}" class="small-button margin-v20">
Ajouter un évènement Ajouter un évènement
</a> </a>
<a href="{% url 'crm:add-prospect' %}" class="small-button margin-v20 left-margin"> <a href="{% url 'biz:add-prospect' %}" class="small-button margin-v20 left-margin">
Ajouter un prospect Ajouter un prospect
</a> </a>
<a href="{% url 'crm:csv-import' %}" class="small-button margin-v20 left-margin"> <a href="{% url 'biz:csv-import' %}" class="small-button margin-v20 left-margin">
Import Import
</a> </a>
</div> </div>
@ -31,7 +31,7 @@
<div class="list-group"> <div class="list-group">
{% for event in completed_events %} {% for event in completed_events %}
{% include "crm/event_row.html" with event=event %} {% include "biz/event_row.html" with event=event %}
{% empty %} {% empty %}
<div class="list-group-item">No completed events.</div> <div class="list-group-item">No completed events.</div>
{% endfor %} {% endfor %}
@ -47,7 +47,7 @@
<div class="list-group"> <div class="list-group">
{% for event in planned_events %} {% for event in planned_events %}
{% include "crm/event_row.html" with event=event %} {% include "biz/event_row.html" with event=event %}
{% empty %} {% empty %}
<div class="list-group-item">No planned events.</div> <div class="list-group-item">No planned events.</div>
{% endfor %} {% endfor %}

@ -1,4 +1,4 @@
{% extends "crm/base.html" %} {% extends "biz/base.html" %}
{% block head_title %}{{ first_title }}{% endblock %} {% block head_title %}{{ first_title }}{% endblock %}
{% block first_title %}{{ first_title }}{% endblock %} {% block first_title %}{{ first_title }}{% endblock %}

@ -1,4 +1,4 @@
{% extends "crm/base.html" %} {% extends "biz/base.html" %}
{% load static %} {% load static %}
@ -18,15 +18,15 @@
{% endfor %} {% endfor %}
<div class="filter-buttons"> <div class="filter-buttons">
<button type="submit" class="btn btn-primary">Filter</button> <button type="submit" class="btn btn-primary">Filter</button>
<a href="{% url 'crm:prospect-list' %}" class="btn btn-secondary">Clear</a> <a href="{% url 'biz:prospect-list' %}" class="btn btn-secondary">Clear</a>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<!-- <div class="mb-3"> <!-- <div class="mb-3">
<a href="{% url 'crm:csv-import' %}" class="btn btn-success">Import CSV</a> <a href="{% url 'biz:csv-import' %}" class="btn btn-success">Import CSV</a>
<a href="{% url 'crm:send-bulk-email' %}" class="btn btn-primary">Send Email</a> <a href="{% url 'biz:send-bulk-email' %}" class="btn btn-primary">Send Email</a>
</div> --> </div> -->
<span>{{ filter.qs|length }} résultats</span> <span>{{ filter.qs|length }} résultats</span>
@ -60,11 +60,11 @@
{% endfor %} {% endfor %}
</td> </td>
<td> <td>
<a href="{% url 'crm:edit-prospect' prospect.id %}"> <a href="{% url 'biz:edit-prospect' prospect.id %}">
<button class="btn btn-sm btn-secondary">Edit</button> <button class="btn btn-sm btn-secondary">Edit</button>
</a> </a>
<a href="{% url 'crm:add-event-for-prospect' prospect.id %}"> <a href="{% url 'biz:add-event-for-prospect' prospect.id %}">
<button class="btn btn-sm btn-secondary">+ Event</button> <button class="btn btn-sm btn-secondary">+ Event</button>
</a> </a>
</td> </td>
@ -77,5 +77,5 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="{% static 'crm/js/prospects.js' %}"></script> <script src="{% static 'biz/js/prospects.js' %}"></script>
{% endblock %} {% endblock %}

@ -1,4 +1,4 @@
{% extends "crm/base.html" %} {% extends "biz/base.html" %}
{% block content %} {% block content %}
<div class="container mt-4"> <div class="container mt-4">
@ -41,7 +41,7 @@
</div> </div>
<button type="submit" class="btn btn-primary">Send Email</button> <button type="submit" class="btn btn-primary">Send Email</button>
<a href="{% url 'crm:prospect-list' %}" class="btn btn-secondary">Cancel</a> <a href="{% url 'biz:prospect-list' %}" class="btn btn-secondary">Cancel</a>
</form> </form>
</div> </div>
{% endblock %} {% 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 django.urls import path
from . import views from . import views
app_name = 'crm' app_name = 'biz'
urlpatterns = [ urlpatterns = [
path('', views.EventListView.as_view(), name='planned_events'),path('', views.EventListView.as_view(), name='events'), 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)

@ -36,7 +36,8 @@ INSTALLED_APPS = [
'sync', 'sync',
'tournaments', 'tournaments',
'shop', 'shop',
# 'crm', 'biz',
'api',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
@ -50,6 +51,7 @@ INSTALLED_APPS = [
'channels_redis', 'channels_redis',
'django_filters', 'django_filters',
'background_task', 'background_task',
'rest_framework_api_key',
] ]
@ -63,9 +65,6 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'tournaments.middleware.ReferrerMiddleware', # Add this line
'tournaments.middleware.RegistrationCartCleanupMiddleware',
] ]
ROOT_URLCONF = 'padelclub_backend.urls' ROOT_URLCONF = 'padelclub_backend.urls'
@ -204,8 +203,17 @@ LOGGING = {
'backupCount': 10, 'backupCount': 10,
'formatter': 'verbose', 'formatter': 'verbose',
}, },
'mail_admins': {
'level': 'ERROR',
'class': 'django.utils.log.AdminEmailHandler'
},
}, },
'loggers': { 'loggers': {
'django.request': {
'handlers': ['mail_admins'],
'level': 'ERROR',
'propagate': True,
},
'django': { 'django': {
'handlers': ['console', 'file'], 'handlers': ['console', 'file'],
'level': 'INFO', 'level': 'INFO',

@ -1,56 +1,54 @@
# Rest Framework configuration # Rest Framework configuration
REST_FRAMEWORK = { 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, # Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users. # or allow read-only access for unauthenticated users.
'DEFAULT_PERMISSION_CLASSES': [ "DEFAULT_PERMISSION_CLASSES": [
'rest_framework.permissions.IsAuthenticated', "rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.BasicAuthentication",
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
], ],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
]
} }
EMAIL_HOST_USER = 'automatic@padelclub.app' EMAIL_HOST_USER = "automatic@padelclub.app"
EMAIL_HOST_PASSWORD = 'XLR@Sport@2024' EMAIL_HOST_PASSWORD = "XLR@Sport@2024"
DEFAULT_FROM_EMAIL = 'Padel Club <automatic@padelclub.app>' DEFAULT_FROM_EMAIL = "Padel Club <automatic@padelclub.app>"
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = 'smtp-xlr.alwaysdata.net' EMAIL_HOST = "smtp-xlr.alwaysdata.net"
EMAIL_PORT = 587 EMAIL_PORT = 587
EMAIL_USE_TLS = True EMAIL_USE_TLS = True
CACHES = { CACHES = {
'default': { "default": {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', "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_APPS = {
'sync': {}, "sync": {},
'tournaments': { 'exclude': ['Log', 'FailedApiCall', 'DeviceToken', 'Image'] } "tournaments": {"exclude": ["Log", "FailedApiCall", "DeviceToken", "Image"]},
# 'biz': {},
} }
SYNC_MODEL_CHILDREN_SHARING = { 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 # Add managers who should receive internal emails
SHOP_MANAGERS = [ SHOP_MANAGERS = [
('Shop Admin', 'shop-admin@padelclub.app'), ("Shop Admin", "shop-admin@padelclub.app"),
# ('Laurent Morvillier', 'laurent@padelclub.app'), # ('Laurent Morvillier', 'laurent@padelclub.app'),
# ('Xavier Rousset', 'xavier@padelclub.app'),
] ]
SHOP_SITE_ROOT_URL = 'https://padelclub.app' SHOP_SITE_ROOT_URL = "https://padelclub.app"
SHOP_SUPPORT_EMAIL = 'shop@padelclub.app' SHOP_SUPPORT_EMAIL = "shop@padelclub.app"

@ -42,6 +42,7 @@ STRIPE_PUBLISHABLE_KEY = ''
STRIPE_SECRET_KEY = '' STRIPE_SECRET_KEY = ''
SHOP_STRIPE_WEBHOOK_SECRET = 'whsec_...' # Your existing webhook secret SHOP_STRIPE_WEBHOOK_SECRET = 'whsec_...' # Your existing webhook secret
TOURNAMENT_STRIPE_WEBHOOK_SECRET = 'whsec_...' # New webhook secret for tournaments TOURNAMENT_STRIPE_WEBHOOK_SECRET = 'whsec_...' # New webhook secret for tournaments
XLR_STRIPE_WEBHOOK_SECRET = 'whsec_...' # New webhook secret for padel club
STRIPE_FEE = 0.0075 STRIPE_FEE = 0.0075
TOURNAMENT_SETTINGS = { TOURNAMENT_SETTINGS = {
'TIME_PROXIMITY_RULES': { 'TIME_PROXIMITY_RULES': {

@ -18,18 +18,39 @@ from django.urls import include, path
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from tournaments.admin_utils import download_french_padel_rankings, debug_tools_page, test_player_details_apis, explore_fft_api_endpoints, get_player_license_info, bulk_license_lookup, search_player_by_name, enrich_rankings_with_licenses, gather_monthly_tournaments_and_umpires
urlpatterns = [ urlpatterns = [
path("", include("tournaments.urls")), path("", include("tournaments.urls")),
path('shop/', include('shop.urls')), path('shop/', include('shop.urls')),
# path("crm/", include("crm.urls")), # path("crm/", include("crm.urls")),
path('roads/', include("api.urls")), path('roads/', include("api.urls")),
path('kingdom/debug/', debug_tools_page, name='debug_tools'),
path('kingdom/debug/enrich-rankings-with-licenses/', enrich_rankings_with_licenses, name='enrich_rankings_with_licenses'),
path('kingdom/debug/search-player-by-name/', search_player_by_name, name='search_player_by_name'),
path('kingdom/debug/download-french-padel-rankings/', download_french_padel_rankings, name='download_french_padel_rankings'),
path('kingdom/debug/test-player-apis/', test_player_details_apis, name='test_player_apis'),
path('kingdom/debug/player-license-lookup/', get_player_license_info, name='player_license_lookup'),
path('kingdom/debug/bulk-license-lookup/', bulk_license_lookup, name='bulk_license_lookup'),
path('kingdom/debug/explore-api-endpoints/', explore_fft_api_endpoints, name='explore_api_endpoints'),
# path('kingdom/biz/', include('biz.admin_urls')),
path('kingdom/', admin.site.urls), path('kingdom/', admin.site.urls),
path('api-auth/', include('rest_framework.urls')), path('api-auth/', include('rest_framework.urls')),
path('dj-auth/', include('django.contrib.auth.urls')), path('dj-auth/', include('django.contrib.auth.urls')),
path(
"kingdom/debug/gather-monthly-umpires/",
gather_monthly_tournaments_and_umpires,
name="gather_monthly_umpires",
),
] ]
def email_users_view(request):
return render(request, 'admin/crm/email_users.html', {
'title': 'Email Users',
})
# Serve media files in development # Serve media files in development
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

@ -18,3 +18,5 @@ cryptography==41.0.7
stripe==11.6.0 stripe==11.6.0
django-background-tasks==1.2.8 django-background-tasks==1.2.8
Pillow==10.2.0 Pillow==10.2.0
playwright==1.40.0
djangorestframework-api-key==3.1.0

@ -0,0 +1,11 @@
first_name,last_name,email,phone
John,Doe,john.doe@example.com,+33123456789
Jane,Smith,jane.smith@example.com,+33987654321
Pierre,Martin,pierre.martin@example.com,+33456789123
Marie,Dubois,marie.dubois@example.com,+33789123456
Carlos,Rodriguez,carlos.rodriguez@example.com,+34612345678
Sophie,Leroy,sophie.leroy@example.com,+33234567890
Michel,Bernard,michel.bernard@example.com,+33345678901
Laura,Garcia,laura.garcia@example.com,+34723456789
Thomas,Petit,thomas.petit@example.com,+33456789012
Emma,Moreau,emma.moreau@example.com,+33567890123
unable to load file from base commit

@ -9,7 +9,7 @@
{% include 'shop/partials/navigation_base.html' %} {% include 'shop/partials/navigation_base.html' %}
<div class="info-box" style="border-left: 4px solid #f39200; padding: 12px; margin: 20px 12px;"> <div class="info-box" style="border-left: 4px solid #f39200; padding: 12px; margin: 20px 12px;">
<h3 style="color: #505050; margin-top: 0;">Bienvenue sur la boutique Padel Club des copains !</h3> <h3 style="color: #505050; margin-top: 0;">Bienvenue sur la boutique Padel Club !</h3>
<p style="margin-top: 10px; margin-bottom: 0;"><strong>Photos :</strong> Les photos des vêtements n'ont pas encore tous le logo, la description indique où il se situera. <p style="margin-top: 10px; margin-bottom: 0;"><strong>Photos :</strong> Les photos des vêtements n'ont pas encore tous le logo, la description indique où il se situera.
<p style="margin-top: 10px; margin-bottom: 0;"><strong>Livraison :</strong> Commandez en ligne et récupérez votre commande en main propre lors de votre prochaine session de padel au club. La livraison peut être possible !</p> <p style="margin-top: 10px; margin-bottom: 0;"><strong>Livraison :</strong> Commandez en ligne et récupérez votre commande en main propre lors de votre prochaine session de padel au club. La livraison peut être possible !</p>
</div> </div>

@ -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.

@ -6,13 +6,12 @@ from .models import BaseModel, ModelLog, DataAccess
class SyncedObjectAdmin(admin.ModelAdmin): class SyncedObjectAdmin(admin.ModelAdmin):
exclude = ('data_access_ids',) exclude = ('data_access_ids',)
raw_id_fields = ['related_user', 'last_updated_by']
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
if isinstance(obj, BaseModel): if isinstance(obj, BaseModel):
obj.last_updated_by = request.user obj.last_updated_by = request.user
obj.last_update = timezone.now() # obj.last_update = timezone.now()
if obj.related_user is None:
obj.related_user = request.user
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
def delete_model(self, request, obj): def delete_model(self, request, obj):
@ -25,13 +24,14 @@ class SyncedObjectAdmin(admin.ModelAdmin):
queryset.delete() queryset.delete()
class ModelLogAdmin(admin.ModelAdmin): class ModelLogAdmin(admin.ModelAdmin):
list_display = ['user', 'formatted_time', 'operation', 'model_id', 'model_name', 'count'] list_display = ['user', 'formatted_time', 'model_name', 'operation', 'model_id', 'device_id']
list_filter = ['user', 'operation', 'model_name'] list_filter = ['operation', 'model_name', 'user']
ordering = ['-date'] ordering = ['-date']
search_fields = ['model_id'] search_fields = ['model_id']
readonly_fields = ['date']
class DataAccessAdmin(SyncedObjectAdmin): class DataAccessAdmin(SyncedObjectAdmin):
list_display = ['related_user', 'get_shared_users', 'model_name', 'model_id'] list_display = ['id', 'related_user', 'get_shared_users', 'model_name', 'model_id']
list_filter = ['related_user', 'shared_with'] list_filter = ['related_user', 'shared_with']
ordering = ['-granted_at'] ordering = ['-granted_at']

@ -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'},
),
]

@ -116,7 +116,7 @@ class SyncModelChildrenManager:
str or None: The reverse relationship name if found str or None: The reverse relationship name if found
""" """
print(f'reverse of {original_relationship_name} in = {in_model} for {for_model} ') # print(f'reverse of {original_relationship_name} in = {in_model} for {for_model} ')
try: try:
for field in for_model._meta.get_fields(): for field in for_model._meta.get_fields():
# Check ForeignKey, OneToOneField fields # Check ForeignKey, OneToOneField fields

@ -16,10 +16,13 @@ class BaseModel(models.Model):
last_updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL, related_name='+') last_updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL, related_name='+')
data_access_ids = models.JSONField(default=list) data_access_ids = models.JSONField(default=list)
sharable = True
class Meta: class Meta:
abstract = True abstract = True
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.last_update = now()
if self.related_user is None: if self.related_user is None:
self.related_user = self.find_related_user() self.related_user = self.find_related_user()
if self._state.adding: if self._state.adding:
@ -39,6 +42,8 @@ class BaseModel(models.Model):
} }
def update_data_access_list(self): def update_data_access_list(self):
if self.sharable == False:
return
related_instances = self.sharing_related_instances() related_instances = self.sharing_related_instances()
data_access_ids = set() data_access_ids = set()
for instance in related_instances: for instance in related_instances:
@ -46,21 +51,18 @@ class BaseModel(models.Model):
data_access_ids.update(instance.data_access_ids) data_access_ids.update(instance.data_access_ids)
# print(f'related_instances = {related_instances}') # print(f'related_instances = {related_instances}')
# data_access_ids = [instance.data_access_ids for instance in related_instances if isinstance(instance, BaseModel)]
# data_access_ids.extend(self.data_access_ids)
self.data_access_ids = list(data_access_ids) self.data_access_ids = list(data_access_ids)
# DataAccess = apps.get_model('sync', 'DataAccess')
# data_accesses = DataAccess.objects.filter(model_id__in=related_ids)
# for data_access in data_accesses:
# self.add_data_access_relation(data_access)
def add_data_access_relation(self, data_access): def add_data_access_relation(self, data_access):
if self.sharable == False:
return
str_id = str(data_access.id) str_id = str(data_access.id)
if str_id not in self.data_access_ids: if str_id not in self.data_access_ids:
self.data_access_ids.append(str_id) self.data_access_ids.append(str_id)
def remove_data_access_relation(self, data_access): def remove_data_access_relation(self, data_access):
if self.sharable == False:
return
try: try:
self.data_access_ids.remove(str(data_access.id)) self.data_access_ids.remove(str(data_access.id))
except ValueError: except ValueError:
@ -131,9 +133,8 @@ class BaseModel(models.Model):
children_by_model = self.get_children_by_model() children_by_model = self.get_children_by_model()
for queryset in children_by_model.values(): for queryset in children_by_model.values():
for child in queryset: for child in queryset:
children.append(child)
# Recursively get children of children
if isinstance(child, BaseModel): if isinstance(child, BaseModel):
children.append(child)
children.extend(child.get_recursive_children(processed_objects)) children.extend(child.get_recursive_children(processed_objects))
return children return children
@ -190,7 +191,7 @@ class BaseModel(models.Model):
for parent in parents_by_model.values(): for parent in parents_by_model.values():
if isinstance(parent, BaseModel): if isinstance(parent, BaseModel):
if parent.related_user: if parent.related_user:
print(f'related_user found in {parent}') print(f'*** related_user found in {parent}')
return parent.related_user return parent.related_user
else: else:
return parent.find_related_user(processed_objects) return parent.find_related_user(processed_objects)
@ -219,13 +220,14 @@ class BaseModel(models.Model):
children = self.get_shared_children_from_relationships(relationships, processed_objects) children = self.get_shared_children_from_relationships(relationships, processed_objects)
instances.extend(children) instances.extend(children)
else: else:
instances.extend(self.get_recursive_children(processed_objects)) children = self.get_recursive_children(processed_objects)
instances.extend(children)
return instances return instances
def get_shared_children_from_relationships(self, relationships, processed_objects): def get_shared_children_from_relationships(self, relationships, processed_objects):
print(f'>>> {self.__class__.__name__} : relationships = {relationships}') # print(f'>>> {self.__class__.__name__} : relationships = {relationships}')
current = [self] current = [self]
for relationship in relationships: for relationship in relationships:
# print(f'> relationship = {relationship}') # print(f'> relationship = {relationship}')
@ -240,7 +242,6 @@ class BaseModel(models.Model):
values.extend(value.all()) values.extend(value.all())
else: else:
processed_objects.add(value) processed_objects.add(value)
values.append(value) values.append(value)
current = values current = values

@ -20,6 +20,9 @@ class DataAccess(BaseModel):
store_id = models.CharField(max_length=100, default="", blank=True, null=True) # a value matching LeStorage directory sub-stores. Matches the name of the directory. store_id = models.CharField(max_length=100, default="", blank=True, null=True) # a value matching LeStorage directory sub-stores. Matches the name of the directory.
granted_at = models.DateTimeField(auto_now_add=True) granted_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name_plural = "Data Access"
def delete_dependencies(self): def delete_dependencies(self):
pass pass
@ -74,7 +77,7 @@ class DataAccess(BaseModel):
with transaction.atomic(): with transaction.atomic():
for instance in related_instance: for instance in related_instance:
logger.info(f'adds DataAccess to {instance.__class__.__name__}') # logger.info(f'adds DataAccess to {instance.__class__.__name__}')
if isinstance(instance, BaseModel): if isinstance(instance, BaseModel):
instance.add_data_access_relation(self) instance.add_data_access_relation(self)
instance.save() instance.save()

@ -91,24 +91,35 @@ class RelatedUsersRegistry:
def register(self, instance_id, users): def register(self, instance_id, users):
"""Register a device_id for a model instance ID.""" """Register a device_id for a model instance ID."""
# logger.info(f'USER REGISTRY register {instance_id} : {users}')
with self._lock: with self._lock:
instance_id_str = str(instance_id) instance_id_str = str(instance_id)
if instance_id_str in self._registry: if instance_id_str in self._registry:
existing_users = self._registry[instance_id_str] existing_users = self._registry[instance_id_str]
self._registry[instance_id_str] = existing_users.union(users) self._registry[instance_id_str] = existing_users.union(users)
else: else:
self._registry[instance_id_str] = users self._registry[instance_id_str] = set(users) # we convert to set because transmitted query_set are emptying themselves
def get_users(self, instance_id): def get_users(self, instance_id):
"""Get the device_id for a model instance ID.""" """Get the device_id for a model instance ID."""
with self._lock: with self._lock:
return self._registry.get(str(instance_id)) instance_id_str = str(instance_id)
if instance_id_str in self._registry:
# logger.info(f'###### get_users exists ! {instance_id}')
return self._registry[instance_id_str]
else:
# logger.info(f'####### get_users {instance_id} not referenced !')
return {}
# return self._registry.get(instance_id_str)
def unregister(self, instance_id): def unregister(self, instance_id):
"""Remove an instance from the registry.""" """Remove an instance from the registry."""
# logger.info(f'USER REGISTRY unregister {instance_id}')
with self._lock: with self._lock:
if instance_id in self._registry: instance_id_str = str(instance_id)
del self._registry[instance_id] if instance_id_str in self._registry:
del self._registry[instance_id_str]
# Global instance # Global instance
related_users_registry = RelatedUsersRegistry() related_users_registry = RelatedUsersRegistry()

@ -8,7 +8,7 @@ from authentication.models import Device
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .ws_sender import websocket_sender from .ws_sender import websocket_sender
from .registry import device_registry, related_users_registry from .registry import device_registry, related_users_registry, model_registry
import logging import logging
import traceback import traceback
@ -24,7 +24,7 @@ def presave_handler(sender, instance, **kwargs):
try: try:
# some other classes are excluded in settings_app.py: SYNC_APPS # some other classes are excluded in settings_app.py: SYNC_APPS
if not isinstance(instance, (BaseModel, User)) or isinstance(instance, DataAccess): if not isinstance(instance, (BaseModel, User)):
return return
signal = kwargs.get('signal') signal = kwargs.get('signal')
@ -54,11 +54,17 @@ def synchronization_notifications(sender, instance, created=False, **kwargs):
if not isinstance(instance, BaseModel) and not isinstance(instance, User): if not isinstance(instance, BaseModel) and not isinstance(instance, User):
return return
model_name = instance.__class__.__name__
if model_registry.get_model(model_name) is None:
return
try: try:
process_foreign_key_changes(sender, instance, **kwargs) process_foreign_key_changes(sender, instance, **kwargs)
signal = kwargs.get('signal') signal = kwargs.get('signal')
save_model_log_if_possible(instance, signal, created) save_model_log_if_possible(instance, signal, created)
notify_impacted_users(instance) notify_impacted_users(instance)
# print(f'!!!!! related_users_registry.unregister for {instance.__class__.__name__} / {signal}')
related_users_registry.unregister(instance.id) related_users_registry.unregister(instance.id)
except Exception as e: except Exception as e:
logger.info(f'*** ERROR2: {e}') logger.info(f'*** ERROR2: {e}')
@ -68,6 +74,7 @@ def synchronization_notifications(sender, instance, created=False, **kwargs):
def notify_impacted_users(instance): def notify_impacted_users(instance):
device_id = device_registry.get_device_id(instance.id) device_id = device_registry.get_device_id(instance.id)
users = related_users_registry.get_users(instance.id) users = related_users_registry.get_users(instance.id)
logger.info(f'>>> notify_impacted_users: {users} for {instance.id}')
if users: if users:
user_ids = [user.id for user in users] user_ids = [user.id for user in users]
@ -95,7 +102,6 @@ def save_model_log_if_possible(instance, signal, created):
else: else:
operation = ModelOperation.DELETE operation = ModelOperation.DELETE
model_name = instance.__class__.__name__
store_id = None store_id = None
if isinstance(instance, SideStoreModel): if isinstance(instance, SideStoreModel):
store_id = instance.store_id store_id = instance.store_id
@ -106,6 +112,7 @@ def save_model_log_if_possible(instance, signal, created):
# user_ids = [user.id for user in users] # user_ids = [user.id for user in users]
# # print(f'users to notify: {user_ids}') # # print(f'users to notify: {user_ids}')
# instance._users_to_notify = user_ids # save this for the post_save signal # instance._users_to_notify = user_ids # save this for the post_save signal
model_name = instance.__class__.__name__
save_model_log(users, operation, model_name, instance.id, store_id) save_model_log(users, operation, model_name, instance.id, store_id)
else: else:
@ -133,11 +140,11 @@ def save_model_log(users, model_operation, model_name, model_id, store_id):
created_logs.append(model_log.id) created_logs.append(model_log.id)
# Immediate verification within transaction # Immediate verification within transaction
immediate_count = ModelLog.objects.filter(id__in=created_logs).count() # immediate_count = ModelLog.objects.filter(id__in=created_logs).count()
# logger.info(f'*** Within transaction: Created {len(created_logs)}, found {immediate_count}') # logger.info(f'*** Within transaction: Created {len(created_logs)}, found {immediate_count}')
# Verification after transaction commits # Verification after transaction commits
persisted_count = ModelLog.objects.filter(id__in=created_logs).count() # persisted_count = ModelLog.objects.filter(id__in=created_logs).count()
# logger.info(f'*** After transaction: Created {len(created_logs)}, persisted {persisted_count}') # logger.info(f'*** After transaction: Created {len(created_logs)}, persisted {persisted_count}')
except Exception as e: except Exception as e:
@ -267,6 +274,8 @@ def handle_shared_with_changes(sender, instance, action, pk_set, **kwargs):
# print(f'm2m changed = {pk_set}') # print(f'm2m changed = {pk_set}')
users = User.objects.filter(id__in=pk_set) users = User.objects.filter(id__in=pk_set)
save_model_log(users, ModelOperation.PUT, DataAccess.__name__, instance.id, None)
with transaction.atomic(): with transaction.atomic():
if action == "post_add": if action == "post_add":
instance.create_access_log(users, 'SHARED_ACCESS') instance.create_access_log(users, 'SHARED_ACCESS')
@ -274,6 +283,7 @@ def handle_shared_with_changes(sender, instance, action, pk_set, **kwargs):
instance.create_access_log(users, 'REVOKED_ACCESS') instance.create_access_log(users, 'REVOKED_ACCESS')
device_id = device_registry.get_device_id(instance.id) device_id = device_registry.get_device_id(instance.id)
# logger.info(f'*** DataAccess m2m_changed > send message to : {pk_set}')
websocket_sender.send_message(pk_set, device_id) websocket_sender.send_message(pk_set, device_id)
# for user_id in pk_set: # for user_id in pk_set:
@ -297,9 +307,12 @@ def data_access_post_save(sender, instance, **kwargs):
@receiver(pre_delete, sender=DataAccess) @receiver(pre_delete, sender=DataAccess)
def revoke_access_after_delete(sender, instance, **kwargs): def revoke_access_after_delete(sender, instance, **kwargs):
# logger.info(f'.........PRE_DELETE DATAACCESS = {instance.id}..........')
try: try:
instance.cleanup_references() instance.cleanup_references()
instance.create_revoke_access_log() instance.create_revoke_access_log()
# logger.info(f'*** users to notify data access delete: {instance.shared_with.all()}')
related_users_registry.register(instance.id, instance.shared_with.all()) related_users_registry.register(instance.id, instance.shared_with.all())
instance._user = instance.related_user instance._user = instance.related_user
@ -320,6 +333,8 @@ def data_access_post_delete(sender, instance, **kwargs):
logger.info(f'*** ERROR5: {e}') logger.info(f'*** ERROR5: {e}')
logger.info(traceback.format_exc()) logger.info(traceback.format_exc())
raise raise
# logger.info(f'.........POST_DELETE END DATAACCESS = {instance.id}..........')
def related_users(instance): def related_users(instance):
users = set() users = set()
@ -332,8 +347,8 @@ def related_users(instance):
for data_access in data_access_list: for data_access in data_access_list:
users.add(data_access.related_user) users.add(data_access.related_user)
users.update(data_access.shared_with.all()) users.update(data_access.shared_with.all())
if isinstance(instance, DataAccess):
users.update(instance.shared_with.all()) # print(f'find users for {instance.__class__.__name__}, count = {len(users)}')
return {user for user in users if user is not None} return {user for user in users if user is not None}

@ -5,6 +5,9 @@ from .models import BaseModel, SideStoreModel
import random import random
import string import string
import logging
logger = logging.getLogger(__name__)
def build_serializer_class(model_name): def build_serializer_class(model_name):
@ -39,6 +42,7 @@ def get_data(model_name, model_id):
def get_serialized_data_by_id(model_name, model_id): def get_serialized_data_by_id(model_name, model_id):
# print(f'model_name = {model_name}') # print(f'model_name = {model_name}')
model = model_registry.get_model(model_name) model = model_registry.get_model(model_name)
# logger.info(f'model for {model_name} = {model}')
instance = model.objects.get(id=model_id) instance = model.objects.get(id=model_id)
serializer = get_serializer(instance, model_name) serializer = get_serializer(instance, model_name)
return serializer.data return serializer.data
@ -97,10 +101,11 @@ class HierarchyOrganizer:
self.add_related_children(instance) self.add_related_children(instance)
def add_related_children(self, instance): def add_related_children(self, instance):
instance.get_shared_children(self.children) self.children = instance.get_shared_children(set())
def grouped_children(self): def grouped_children(self):
grouped = defaultdict(list) grouped = defaultdict(list)
for instance in self.children: for instance in self.children:
class_name = instance.__class__.__name__ class_name = instance.__class__.__name__
grouped[class_name].append(instance.data_identifier_dict()) grouped[class_name].append(instance.data_identifier_dict())

@ -251,7 +251,7 @@ class SynchronizationApi(APIView):
return Response({"error": f"Invalid date format for last_update: {decoded_last_update}"}, return Response({"error": f"Invalid date format for last_update: {decoded_last_update}"},
status=status.HTTP_400_BAD_REQUEST) status=status.HTTP_400_BAD_REQUEST)
print(f'>>> GET last modifications since: {last_update_str} / converted = {last_update}') print(f'>>> {request.user.username} : GET last modifications since: {last_update_str} / converted = {last_update}')
device_id = request.query_params.get('device_id') device_id = request.query_params.get('device_id')
logs = self.query_model_logs(last_update, request.user, device_id) logs = self.query_model_logs(last_update, request.user, device_id)
@ -287,7 +287,6 @@ class LogProcessingResult:
def process_logs(self, logs): def process_logs(self, logs):
"""Process logs to collect basic operations and handle grant/revoke efficiently.""" """Process logs to collect basic operations and handle grant/revoke efficiently."""
for log in logs: for log in logs:
self.last_log_date = log.date
try: try:
if log.operation in ['POST', 'PUT', 'RELATIONSHIP_SET']: if log.operation in ['POST', 'PUT', 'RELATIONSHIP_SET']:
data = get_serialized_data_by_id(log.model_name, log.model_id) data = get_serialized_data_by_id(log.model_name, log.model_id)
@ -324,7 +323,10 @@ class LogProcessingResult:
self.shared_relationship_sets[log.model_name][log.model_id] = data self.shared_relationship_sets[log.model_name][log.model_id] = data
elif log.operation == 'SHARED_RELATIONSHIP_REMOVED': elif log.operation == 'SHARED_RELATIONSHIP_REMOVED':
self.shared_relationship_removals[log.model_name].append(log.data_identifier_dict()) self.shared_relationship_removals[log.model_name].append(log.data_identifier_dict())
self.last_log_date = log.date # set dates after having retrieved informations
except ObjectDoesNotExist: except ObjectDoesNotExist:
logger.warning(f'log processing failed, unable to find {log.model_name} : {log.model_id}')
pass pass
# Convert updates dict to list for each model # Convert updates dict to list for each model
@ -387,17 +389,17 @@ class LogProcessingResult:
# First, collect all revocations # First, collect all revocations
for model_name, items in self.revoke_info.items(): for model_name, items in self.revoke_info.items():
revocations[model_name].extend(items) revocations[model_name].extend(items)
logger.info(f'$$$ process_revocations for {model_name}, items = {len(items)}') # logger.info(f'$$$ process_revocations for {model_name}, items = {len(items)}')
# Process parent hierarchies for each revoked item # Process parent hierarchies for each revoked item
model = model_registry.get_model(model_name) model = model_registry.get_model(model_name)
for item in items: for item in items:
logger.info(f'$$$ item revoked = {item}') # logger.info(f'$$$ item revoked = {item}')
try: try:
instance = model.objects.get(id=item['model_id']) instance = model.objects.get(id=item['model_id'])
logger.info(f'$$$ process revoked item parents of {model_name} : {item['model_id']}') # logger.info(f'$$$ process revoked item parents of {model_name} : {item['model_id']}')
revocated_relations_organizer.add_relations(instance) revocated_relations_organizer.add_relations(instance)
except model.DoesNotExist: except model.DoesNotExist:
@ -413,6 +415,8 @@ class LogProcessingResult:
shared, grants = self.process_shared() shared, grants = self.process_shared()
revocations, revocated_relations_organizer = self.process_revocations() revocations, revocated_relations_organizer = self.process_revocations()
# print(f'self.revocations = {dict(revocations)}')
# print(f'self.revocated_relations_organizer = {revocated_relations_organizer.get_organized_data()}')
# print(f'self.deletions = {dict(self.deletions)}') # print(f'self.deletions = {dict(self.deletions)}')
# print(f'self.shared_relationship_sets = {self.shared_relationship_sets}') # print(f'self.shared_relationship_sets = {self.shared_relationship_sets}')
# print(f'self.shared_relationship_removals = {self.shared_relationship_removals}') # print(f'self.shared_relationship_removals = {self.shared_relationship_removals}')

@ -1,6 +1,10 @@
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from threading import Timer, Lock from threading import Timer, Lock
import logging
logger = logging.getLogger(__name__)
class WebSocketSender: class WebSocketSender:
""" """
@ -55,19 +59,23 @@ class WebSocketSender:
user_ids_key = frozenset(user_ids) user_ids_key = frozenset(user_ids)
if not user_ids_key: # No users to send to if not user_ids_key: # No users to send to
logger.info(f'WARNING: no user ids : {user_ids}')
return return
with self._debounce_lock: with self._debounce_lock:
timer_device_id = device_id
if user_ids_key in self._debounce_registry: if user_ids_key in self._debounce_registry:
old_timer, _ = self._debounce_registry[user_ids_key] old_timer, old_device_id = self._debounce_registry[user_ids_key]
old_timer.cancel() old_timer.cancel()
if old_device_id != device_id: # we want to notify all devices if there all multiple ones
timer_device_id = None
new_timer = Timer( new_timer = Timer(
self._buffer_timeout, self._buffer_timeout,
self._handle_debounced_action, self._handle_debounced_action,
args=[user_ids_key, device_id] args=[user_ids_key, timer_device_id]
) )
self._debounce_registry[user_ids_key] = (new_timer, device_id) # Store new timer and latest device_id self._debounce_registry[user_ids_key] = (new_timer, device_id)
new_timer.start() new_timer.start()
def _handle_debounced_action(self, user_ids_key, device_id): def _handle_debounced_action(self, user_ids_key, device_id):

@ -1,39 +1,46 @@
from django.contrib import admin from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from django.utils import timezone from django.utils import timezone
from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION
from django.utils.html import escape from django.utils.html import escape
from django.urls import reverse, path # Add path import from django.urls import reverse, path
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.shortcuts import render # Add this import from django.shortcuts import render
from django.db.models import Sum, Count, Avg, Q # Add these imports from django.db.models import Avg, Count
from datetime import datetime, timedelta # Add this import from datetime import timedelta, datetime
from biz.models import Prospect, ProspectGroup
from .models import Club, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, Court, DateInterval, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image from .models import Club, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, Court, DateInterval, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image
from .forms import CustomUserCreationForm, CustomUserChangeForm from .forms import CustomUserCreationForm, CustomUserChangeForm
from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter, UserWithEventsFilter, UserWithPurchasesFilter, UserWithProspectFilter, TeamScoreRoundIndexFilter
from sync.admin import SyncedObjectAdmin from sync.admin import SyncedObjectAdmin
import logging
logger = logging.getLogger(__name__)
class CustomUserAdmin(UserAdmin): class CustomUserAdmin(UserAdmin):
form = CustomUserChangeForm form = CustomUserChangeForm
add_form = CustomUserCreationForm add_form = CustomUserCreationForm
model = CustomUser model = CustomUser
search_fields = ['username', 'email', 'phone', 'first_name', 'last_name', 'licence_id'] search_fields = ['username', 'email', 'phone', 'first_name', 'last_name', 'licence_id']
filter_horizontal = ('clubs',)
actions = ['convert_to_prospect', 'create_group']
list_display = ['email', 'first_name', 'last_name', 'username', 'date_joined', 'latest_event_club_name', 'is_active', 'event_count', 'origin', 'registration_payment_mode', 'licence_id'] list_display = ['email', 'first_name', 'last_name', 'username', 'date_joined', 'latest_event_club_name', 'is_active', 'event_count', 'origin', 'registration_payment_mode', 'licence_id']
list_filter = ['is_active', 'origin'] list_filter = ['is_active', 'origin', UserWithEventsFilter, UserWithPurchasesFilter, UserWithProspectFilter]
ordering = ['-date_joined'] ordering = ['-date_joined']
raw_id_fields = ['agents'] autocomplete_fields = ['supervisors', 'organizers']
fieldsets = [ fieldsets = [
(None, {'fields': ['id', 'username', 'email', 'password', 'first_name', 'last_name', 'is_active']}), (None, {'fields': ['id', 'username', 'email', 'password', 'first_name', 'last_name', 'is_active', 'date_joined']}),
('Permissions', {'fields': ['is_staff', 'is_superuser', 'groups', 'user_permissions']}), ('Permissions', {'fields': ['is_staff', 'is_superuser', 'groups', 'user_permissions']}),
('Personal Info', {'fields': ['registration_payment_mode', 'clubs', 'country', 'phone', 'licence_id', 'umpire_code']}), ('Personal Info', {'fields': ['registration_payment_mode', 'clubs', 'country', 'phone', 'licence_id', 'umpire_code']}),
('Tournament Settings', {'fields': [ ('Tournament Settings', {'fields': [
'summons_message_body', 'summons_message_signature', 'summons_available_payment_methods', 'summons_message_body', 'summons_message_signature', 'summons_available_payment_methods',
'summons_display_format', 'summons_display_entry_fee', 'summons_use_full_custom_message', 'summons_display_format', 'summons_display_entry_fee', 'summons_use_full_custom_message',
'match_formats_default_duration', 'bracket_match_format_preference', 'group_stage_match_format_preference', 'match_formats_default_duration', 'bracket_match_format_preference', 'group_stage_match_format_preference',
'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode', 'origin', 'agents', 'should_synchronize', 'can_synchronize' 'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode', 'origin', 'supervisors', 'organizers', 'should_synchronize', 'can_synchronize'
]}), ]}),
] ]
@ -51,15 +58,69 @@ class CustomUserAdmin(UserAdmin):
obj.last_update = timezone.now() obj.last_update = timezone.now()
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
def create_group(self, request, queryset):
prospects = []
source_value = f"auto_created_{datetime.now().strftime('%Y-%m-%d_%H:%M')}"
for user in queryset:
prospect = Prospect.objects.filter(email=user.email).first()
if prospect:
prospects.append(prospect)
else:
prospect = Prospect.objects.create(
first_name=user.first_name,
last_name=user.last_name,
email=user.email,
phone=user.phone,
official_user=user,
source=source_value
)
prospects.append(prospect)
prospect_group = ProspectGroup.objects.create(
name=f"{datetime.now().strftime('%Y-%m-%d_%H:%M')}",
)
prospect_group.prospects.add(*prospects)
messages.success(request, f'Created prospect group {prospect_group.name} with {queryset.count()} prospects')
create_group.short_description = "Create group with selection"
def convert_to_prospect(self, request, queryset):
created_count = 0
skipped_count = 0
source_value = f"user_conversion_{datetime.now().strftime('%Y-%m-%d_%H:%M')}"
for user in queryset:
if user.email and Prospect.objects.filter(email=user.email).exists():
skipped_count += 1
continue
prospect = Prospect.objects.create(
first_name=user.first_name,
last_name=user.last_name,
email=user.email,
phone=user.phone,
official_user=user,
source=source_value
)
created_count += 1
if created_count > 0:
messages.success(request, f'{created_count} prospect(s) successfully created.')
if skipped_count > 0:
messages.warning(request, f'{skipped_count} user(s) skipped (prospect with same email already exists).')
convert_to_prospect.short_description = "Convert selected users to prospects"
class EventAdmin(SyncedObjectAdmin): class EventAdmin(SyncedObjectAdmin):
list_display = ['creation_date', 'name', 'club', 'creator', 'creator_full_name', 'tenup_id', 'display_images'] list_display = ['creation_date', 'name', 'club', 'creator', 'tenup_id', 'display_images']
list_filter = ['creator', 'tenup_id'] list_filter = ['creator', 'club', 'tenup_id']
raw_id_fields = ['creator'] search_fields = ['name', 'club__name', 'creator__email']
raw_id_fields = ['related_user', 'creator', 'club']
ordering = ['-creation_date'] ordering = ['-creation_date']
readonly_fields = ['display_images_preview'] readonly_fields = ['display_images_preview']
actions = ['set_club_action']
fieldsets = [ fieldsets = [
(None, {'fields': ['name', 'club', 'creator', 'creation_date', 'tenup_id']}), (None, {'fields': ['last_update', 'related_user', 'name', 'club', 'creator', 'creation_date', 'tenup_id']}),
('Images', {'fields': ['display_images_preview'], 'classes': ['collapse']}), ('Images', {'fields': ['display_images_preview'], 'classes': ['collapse']}),
] ]
@ -86,11 +147,80 @@ class EventAdmin(SyncedObjectAdmin):
return mark_safe(html) return mark_safe(html)
display_images_preview.short_description = 'Images Preview' display_images_preview.short_description = 'Images Preview'
def set_club_action(self, request, queryset):
"""Action to set club for selected events"""
from django import forms
from django.contrib.admin.widgets import ForeignKeyRawIdWidget
from django.contrib.admin import helpers
from django.core.exceptions import ValidationError
class ClubSelectionForm(forms.Form):
_selected_action = forms.CharField(widget=forms.MultipleHiddenInput)
action = forms.CharField(widget=forms.HiddenInput)
club_id = forms.CharField(
label='Club',
required=True,
help_text='Enter Club ID or use the search icon to find a club',
widget=ForeignKeyRawIdWidget(
Event._meta.get_field('club').remote_field,
self.admin_site
)
)
def clean_club_id(self):
club_id = self.cleaned_data['club_id']
try:
club = Club.objects.get(pk=club_id)
return club
except Club.DoesNotExist:
raise ValidationError(f'Club with ID {club_id} does not exist.')
except (ValueError, TypeError) as e:
raise ValidationError(f'Invalid Club ID format: {club_id}')
if 'apply' in request.POST:
form = ClubSelectionForm(request.POST)
if form.is_valid():
club = form.cleaned_data['club_id'] # This is now a Club instance
updated_count = queryset.update(club=club)
self.message_user(
request,
f'Successfully updated {updated_count} event(s) with club: {club.name}',
messages.SUCCESS
)
return None
else:
# Show form errors
self.message_user(
request,
f'Form validation failed. Errors: {form.errors}',
messages.ERROR
)
# Initial form display
form = ClubSelectionForm(initial={
'_selected_action': request.POST.getlist(helpers.ACTION_CHECKBOX_NAME),
'action': 'set_club_action',
})
context = {
'form': form,
'events': queryset,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
'action_name': 'set_club_action',
'title': 'Set Club for Events',
'media': form.media,
'has_change_permission': True,
}
return render(request, 'admin/tournaments/set_club_action.html', context)
set_club_action.short_description = "Set club for selected events"
class TournamentAdmin(SyncedObjectAdmin): class TournamentAdmin(SyncedObjectAdmin):
list_display = ['display_name', 'event', 'is_private', 'start_date', 'payment', 'creator', 'is_canceled'] list_display = ['display_name', 'event', 'is_private', 'start_date', 'payment', 'creator', 'is_canceled']
list_filter = [StartDateRangeFilter, 'is_deleted', 'event__creator'] list_filter = [StartDateRangeFilter, 'is_deleted', 'event__creator']
ordering = ['-start_date'] ordering = ['-start_date']
search_fields = ['id'] search_fields = ['id', 'federal_level_category']
raw_id_fields = ['last_updated_by', 'event']
def dashboard_view(self, request): def dashboard_view(self, request):
"""Tournament dashboard view with comprehensive statistics""" """Tournament dashboard view with comprehensive statistics"""
@ -179,6 +309,10 @@ class TournamentAdmin(SyncedObjectAdmin):
avg_teams=Avg('tournament__team_count') avg_teams=Avg('tournament__team_count')
)['avg_teams'] or 0 )['avg_teams'] or 0
email_count = PlayerRegistration.objects.aggregate(
total=Count('email', distinct=True)
)['total']
avg_entry_fee = Tournament.objects.exclude(is_deleted=True).aggregate( avg_entry_fee = Tournament.objects.exclude(is_deleted=True).aggregate(
avg_fee=Avg('entry_fee') avg_fee=Avg('entry_fee')
)['avg_fee'] or 0 )['avg_fee'] or 0
@ -190,7 +324,7 @@ class TournamentAdmin(SyncedObjectAdmin):
users_app = CustomUser.objects.filter(origin=2).count() # APP users_app = CustomUser.objects.filter(origin=2).count() # APP
# Recent User Registrations # Recent User Registrations
recent_users = CustomUser.objects.all().order_by('-date_joined')[:10] recent_app_users = CustomUser.objects.filter(origin=2).order_by('-date_joined')[:10]
# New users by period # New users by period
users_today = CustomUser.objects.filter(date_joined__date=today).count() users_today = CustomUser.objects.filter(date_joined__date=today).count()
@ -263,6 +397,7 @@ class TournamentAdmin(SyncedObjectAdmin):
'tournaments_with_payment': tournaments_with_payment, 'tournaments_with_payment': tournaments_with_payment,
'avg_teams_per_tournament': round(avg_teams_per_tournament, 1), 'avg_teams_per_tournament': round(avg_teams_per_tournament, 1),
'avg_entry_fee': round(avg_entry_fee, 2), 'avg_entry_fee': round(avg_entry_fee, 2),
'email_count': email_count,
# User statistics # User statistics
'total_users': total_users, 'total_users': total_users,
@ -272,7 +407,7 @@ class TournamentAdmin(SyncedObjectAdmin):
'users_today': users_today, 'users_today': users_today,
'users_this_week': users_this_week, 'users_this_week': users_this_week,
'users_this_month': users_this_month, 'users_this_month': users_this_month,
'recent_users': recent_users, 'recent_app_users': recent_app_users,
# Purchase statistics # Purchase statistics
'total_purchases': total_purchases, 'total_purchases': total_purchases,
@ -295,12 +430,13 @@ class TeamRegistrationAdmin(SyncedObjectAdmin):
list_display = ['player_names', 'group_stage', 'name', 'tournament', 'registration_date'] list_display = ['player_names', 'group_stage', 'name', 'tournament', 'registration_date']
list_filter = [SimpleTournamentListFilter] list_filter = [SimpleTournamentListFilter]
search_fields = ['id'] search_fields = ['id']
raw_id_fields = ['related_user', 'tournament']
class TeamScoreAdmin(SyncedObjectAdmin): class TeamScoreAdmin(SyncedObjectAdmin):
list_display = ['team_registration', 'score', 'walk_out', 'match'] list_display = ['team_registration', 'score', 'walk_out', 'match']
list_filter = [TeamScoreTournamentListFilter] list_filter = [TeamScoreRoundIndexFilter, TeamScoreTournamentListFilter]
search_fields = ['id', 'team_registration__player_registrations__first_name', 'team_registration__player_registrations__last_name'] search_fields = ['id', 'team_registration__player_registrations__first_name', 'team_registration__player_registrations__last_name']
raw_id_fields = ['team_registration', 'match'] # Add this line raw_id_fields = ['team_registration', 'match']
list_per_page = 50 # Controls pagination on the list view list_per_page = 50 # Controls pagination on the list view
def get_queryset(self, request): def get_queryset(self, request):
@ -322,7 +458,7 @@ class RoundAdmin(SyncedObjectAdmin):
class PlayerRegistrationAdmin(SyncedObjectAdmin): class PlayerRegistrationAdmin(SyncedObjectAdmin):
list_display = ['first_name', 'last_name', 'licence_id', 'rank'] list_display = ['first_name', 'last_name', 'licence_id', 'rank']
search_fields = ['id', 'first_name', 'last_name', 'licence_id__icontains'] search_fields = ['id', 'first_name', 'last_name', 'licence_id__icontains']
list_filter = ['registered_online', TeamScoreTournamentListFilter] list_filter = ['registered_online', 'payment_id', TeamScoreTournamentListFilter]
ordering = ['last_name', 'first_name'] ordering = ['last_name', 'first_name']
raw_id_fields = ['team_registration'] # Add this line raw_id_fields = ['team_registration'] # Add this line
list_per_page = 50 # Controls pagination on the list view list_per_page = 50 # Controls pagination on the list view
@ -357,6 +493,7 @@ class PurchaseAdmin(SyncedObjectAdmin):
list_display = ['id', 'user', 'product_id', 'quantity', 'purchase_date', 'revocation_date', 'expiration_date'] list_display = ['id', 'user', 'product_id', 'quantity', 'purchase_date', 'revocation_date', 'expiration_date']
list_filter = ['user'] list_filter = ['user']
ordering = ['-purchase_date'] ordering = ['-purchase_date']
raw_id_fields = ['user']
class CourtAdmin(SyncedObjectAdmin): class CourtAdmin(SyncedObjectAdmin):
list_display = ['index', 'name', 'club'] list_display = ['index', 'name', 'club']

File diff suppressed because it is too large Load Diff

@ -43,6 +43,13 @@ class CustomLoginView(auth_views.LoginView):
return context return context
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
# Capture referrer for anonymous users (replaces middleware functionality)
if not request.user.is_authenticated:
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
# Clear any potential password reset session data # Clear any potential password reset session data
keys_to_clear = [key for key in request.session.keys() keys_to_clear = [key for key in request.session.keys()
if 'reset' in key or 'password' in key] if 'reset' in key or 'password' in key]

@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from .models import Tournament, Match from .models import Tournament, Match, Round
from django.db.models import Q from django.db.models import Q, Count
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
@ -135,3 +135,82 @@ class StartDateRangeFilter(admin.SimpleListFilter):
start_date__gte=today - timedelta(days=3), start_date__gte=today - timedelta(days=3),
start_date__lte=today + timedelta(days=3) start_date__lte=today + timedelta(days=3)
) )
class UserWithEventsFilter(admin.SimpleListFilter):
title = _('has events')
parameter_name = 'has_events'
def lookups(self, request, model_admin):
return (
('yes', _('Has events')),
('no', _('No events')),
)
def queryset(self, request, queryset):
if self.value() == 'yes':
return queryset.annotate(events_count=Count('events')).filter(events_count__gt=0)
elif self.value() == 'no':
return queryset.annotate(events_count=Count('events')).filter(events_count=0)
return queryset
class UserWithPurchasesFilter(admin.SimpleListFilter):
title = _('has purchases')
parameter_name = 'has_purchases'
def lookups(self, request, model_admin):
return (
('yes', _('Has purchases')),
('no', _('No purchases')),
)
def queryset(self, request, queryset):
if self.value() == 'yes':
return queryset.annotate(purchases_count=Count('purchases')).filter(purchases_count__gt=0)
elif self.value() == 'no':
return queryset.annotate(purchases_count=Count('purchases')).filter(purchases_count=0)
return queryset
class UserWithProspectFilter(admin.SimpleListFilter):
title = _('has prospect')
parameter_name = 'has_prospect'
def lookups(self, request, model_admin):
return (
('yes', _('Has prospect')),
('no', _('No prospect')),
)
def queryset(self, request, queryset):
from biz.models import Prospect
if self.value() == 'yes':
prospect_emails = Prospect.objects.values_list('email', flat=True).filter(email__isnull=False)
return queryset.filter(email__in=prospect_emails)
elif self.value() == 'no':
prospect_emails = Prospect.objects.values_list('email', flat=True).filter(email__isnull=False)
return queryset.exclude(email__in=prospect_emails)
return queryset
class TeamScoreRoundIndexFilter(admin.SimpleListFilter):
title = _("Round Index")
parameter_name = "round_index"
def lookups(self, request, model_admin):
# Get distinct round indexes from matches that have team scores
round_indexes = Round.objects.filter(
matches__team_scores__isnull=False
).values_list('index', flat=True).distinct().order_by('index')
# Create lookup tuples with round names
lookups = []
for index in round_indexes:
round_obj = Round.objects.filter(index=index, parent__isnull=True).first()
if round_obj:
lookups.append((index, round_obj.name()))
else:
lookups.append((index, f"Index {index}"))
return lookups
def queryset(self, request, queryset):
if self.value():
return queryset.filter(match__round__index=self.value())
return queryset

@ -77,10 +77,10 @@ class SimpleCustomUserCreationForm(UserCreationForm):
def clean_phone(self): def clean_phone(self):
phone = self.cleaned_data.get('phone') phone = self.cleaned_data.get('phone')
if phone: if phone:
# Remove all spaces # Remove all spaces, dots, dashes, and parentheses
phone = phone.replace(' ', '') phone = re.sub(r'[\s\.\-\(\)]', '', phone)
# Basic regex for phone numbers, matching common formats # Basic regex for phone numbers, allowing 6-15 digits for international numbers
if not re.match(r"^\+?\d{10,15}$", phone): if not re.match(r"^\+?\d{6,15}$", phone):
raise forms.ValidationError("Entrer un numéro de téléphone valide.") raise forms.ValidationError("Entrer un numéro de téléphone valide.")
return phone return phone
@ -171,7 +171,7 @@ class SimpleForm(forms.Form):
class TournamentRegistrationForm(forms.Form): class TournamentRegistrationForm(forms.Form):
#first_name = forms.CharField(label='Prénom', max_length=50) #first_name = forms.CharField(label='Prénom', max_length=50)
#last_name = forms.CharField(label='Nom', max_length=50) #last_name = forms.CharField(label='Nom', max_length=50)
email = forms.EmailField(label='E-mail', widget=forms.EmailInput(attrs={'readonly': 'readonly'})) email = forms.EmailField(label='E-mail')
mobile_number = forms.CharField( mobile_number = forms.CharField(
label='Téléphone', label='Téléphone',
max_length=15, max_length=15,
@ -181,10 +181,9 @@ class TournamentRegistrationForm(forms.Form):
def clean_mobile_number(self): def clean_mobile_number(self):
mobile_number = self.cleaned_data.get('mobile_number') mobile_number = self.cleaned_data.get('mobile_number')
if mobile_number: if mobile_number:
# Basic regex for mobile numbers, matching common formats # Remove spaces, dots, dashes, and parentheses from the number first
# Remove spaces from the number first mobile_number = re.sub(r'[\s\.\-\(\)]', '', mobile_number)
mobile_number = mobile_number.replace(' ', '') if not re.match(r"^\+?\d{6,15}$", mobile_number):
if not re.match(r"^\+?\d{10,15}$", mobile_number):
raise forms.ValidationError("Entrer un numéro de téléphone valide.") raise forms.ValidationError("Entrer un numéro de téléphone valide.")
return mobile_number return mobile_number
@ -292,10 +291,10 @@ class ProfileUpdateForm(forms.ModelForm):
def clean_phone(self): def clean_phone(self):
phone = self.cleaned_data.get('phone') phone = self.cleaned_data.get('phone')
if phone: if phone:
# Remove all spaces # Remove all spaces, dots, dashes, and parentheses
phone = phone.replace(' ', '') phone = re.sub(r'[\s\.\-\(\)]', '', phone)
# Basic regex for phone numbers, matching common formats # Basic regex for phone numbers, allowing 6-15 digits for international numbers
if not re.match(r"^\+?\d{10,15}$", phone): if not re.match(r"^\+?\d{6,15}$", phone):
raise forms.ValidationError("Entrer un numéro de téléphone valide.") raise forms.ValidationError("Entrer un numéro de téléphone valide.")
return phone return phone

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),
),
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -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 = [
('tournaments', '0134_alter_club_timezone'),
]
operations = [
migrations.AddField(
model_name='club',
name='hidden',
field=models.BooleanField(default=False),
),
]

@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2025-08-07 16:57
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0135_club_hidden'),
]
operations = [
migrations.RenameField(
model_name='club',
old_name='hidden',
new_name='admin_visible',
),
]

@ -0,0 +1,23 @@
# Generated by Django 5.1 on 2025-08-26 08:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0136_rename_hidden_club_admin_visible'),
]
operations = [
migrations.AddField(
model_name='playerregistration',
name='is_anonymous',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='tournament',
name='federal_age_category',
field=models.IntegerField(choices=[(0, ''), (100, 'U10'), (120, 'U12'), (140, 'U14'), (160, 'U16'), (180, 'U18'), (200, 'Senior'), (450, '+45 ans'), (550, '+55 ans')], default=200),
),
]

@ -0,0 +1,28 @@
# Generated by Django 5.1 on 2025-09-24 14:19
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0137_playerregistration_is_anonymous_and_more'),
]
operations = [
migrations.RemoveField(
model_name='customuser',
name='agents',
),
migrations.AddField(
model_name='customuser',
name='supervisors',
field=models.ManyToManyField(blank=True, related_name='supervising_for', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='tournament',
name='animation_type',
field=models.IntegerField(choices=[(0, 'Tournoi'), (1, 'Mêlée'), (2, 'Classement'), (3, 'Consolation'), (4, 'Custom')], default=0),
),
]

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save