Compare commits

...

881 Commits
shop ... 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 4 weeks 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 1 month ago
Laurent c8ea7699c5 adds prospect option 1 month 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 3 months ago
Laurent c5e910a208 performance #3 3 months ago
Laurent 1e958cca57 improve performance #2 3 months ago
Laurent 0a6b4614fe performance improvement 3 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
Laurent e1e1dca3d6 Fix issue 5 months ago
Laurent cfbda0f0e6 remove junk and make store_id nullable for DataAccess 5 months ago
Laurent a32b2c2abc fix issue with null store_id 5 months ago
Laurent 9c121cb106 fix sharing issue 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
Laurent c8dd481ebd test 5 months ago
Laurent 136a0697c4 test DataAccess keeping 5 months ago
Laurent dd62e2f11e fixes 5 months ago
Laurent b503f7cb33 fix 5 months ago
Laurent e8d92d1216 add mecasnism to get shared instance reverse paths 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 90e7f4216e improvements 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
Laurent fc21dc2b93 improve perf + log 5 months ago
Laurent 9b739c85a2 remove logs 5 months ago
Laurent d97888c887 put DataAccess auto-delete in the pre_delete BaseModel signal 5 months ago
Laurent bf8f103bda simplify log creation 5 months ago
Laurent f82692f13e logs more 5 months ago
Laurent 9a93e2d6ad adds store_id for DataAccess 5 months ago
Laurent 8d1b3dbdc9 add logs for investigation 5 months ago
Laurent efbe72d675 adds atomic transaction for sharing ModelLog 5 months ago
Laurent 2f34664f2d more logs ! 5 months ago
Laurent 156a4ff0ef remove bulk_creation of ModelLog - test 5 months ago
Laurent 83ec420c60 logs again 5 months ago
Laurent 484c3560bc improve loggs 5 months ago
Laurent de1bcb1c71 improve log 5 months ago
Laurent b64a0fb6b6 add signals log 5 months ago
Laurent e3a7096216 add signals log 5 months ago
Laurent e7979427c3 adds TeamScore search by name 5 months ago
Laurent 48df72d2c3 adds can_sync in the user admin 5 months ago
Laurent e230a00d46 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 5 months ago
Laurent 72b0281a07 add raw id field for agents 5 months ago
Razmig Sarkissian 8d814b49c4 Update views.py 5 months ago
Razmig Sarkissian 1385094474 fix live private section 5 months ago
Razmig Sarkissian ace8801ecc add a section for private tournaments for staff members 5 months ago
Razmig Sarkissian ae1a24a083 add view all users and access to dashboard and private toggle for 5 months ago
Razmig Sarkissian 2a61240e0e Update signup_success.html 5 months ago
Razmig Sarkissian 12aa84ebdb add resend verification link system 5 months ago
Razmig Sarkissian aaf4bad035 fix 500 in shop prepare dashboard 5 months ago
Razmig Sarkissian e43e69fa62 add user staff in custom user admin 5 months ago
Razmig Sarkissian acdc1a270c add some data in dashboards 5 months ago
Razmig Sarkissian 621f37791c fix dashboard 5 months ago
Razmig Sarkissian 621639f30e Update admin.py 5 months ago
Razmig Sarkissian d541205f22 add tournaments dashboard 5 months ago
Razmig Sarkissian a641fcced4 post merge commit 5 months ago
Razmig Sarkissian 38843a996a add shop dashboard 5 months ago
Laurent 28f89d3ca8 remove data_access_ids from the admin 5 months ago
Laurent 70f4f343aa fix logs 5 months ago
Laurent 99be99019b fix log 5 months ago
Laurent 26ee2e49d0 sets daily rotating log files 5 months ago
Laurent be260c0496 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 5 months ago
Laurent 2fa01108d8 fix crash 5 months ago
Razmig Sarkissian 03cab14cf2 fix crash with get_tournament missing in place of tournament() in 5 months ago
Laurent 150d44ad0a make migrations for branch sync_v2 5 months ago
Laurent 6a7f685b14 Merge branch 'main' into sync_v2 5 months ago
laurent d18d5dccd5 adds migrations from prod 5 months ago
laurent 868d764031 Merge branch 'sync_v2' of https://gitea.staxriver.com/staxriver/padelclub_backend into sync_v2 5 months ago
laurent 78fdb42bec Revert "prod migrations" 5 months ago
laurent 0098f3e2d5 prod migrations 5 months ago
Laurent 554c3a87de remove migrations 5 months ago
Laurent e81976889f merge main and migrations 5 months ago
Razmig Sarkissian b346fdcfe0 update shop admin panel 5 months ago
Razmig Sarkissian f56b6a3a9f shop : Replace the dropdown with a search box for the user field 5 months ago
Razmig Sarkissian 7f1502dbdf remove some print statement 5 months ago
Laurent 537ef3f259 Adds field to make a user able to synchronize or not 5 months ago
Laurent ee42ebb550 Fix crash where user_id are not strings 5 months ago
Razmig Sarkissian 1ce168847a fix mixte management in registration process 5 months ago
Razmig Sarkissian a7c33a9ee0 add june rankings 5 months ago
Razmig Sarkissian aa73c904a4 Update tournament_registration.py 5 months ago
Laurent 3a0074fe91 Fix issue with relationship changes + websocket sends refactoring 5 months ago
Razmig Sarkissian af83311c9d fix error with points 5 months ago
Razmig Sarkissian 396735e70c fix error with tournament count 5 months ago
Razmig Sarkissian 4668dae605 fix None in place of Event Name 5 months ago
Razmig Sarkissian 82829b31d2 fix piste plural 5 months ago
Razmig Sarkissian 0f6a2c3888 fix css bracket broadcast 5 months ago
Razmig Sarkissian 01d574f012 fix broadcast 5 months ago
Razmig Sarkissian f6186beefc fix max height bracket template 5 months ago
Razmig Sarkissian ba671c41f7 fix float format in fees 5 months ago
Razmig Sarkissian 0e7d746352 fix refund service 5 months ago
Razmig Sarkissian 09f4944f4d fix session checkout team remaining fee 5 months ago
Razmig Sarkissian 95c0337593 Update broadcasted_planning.html 5 months ago
Razmig Sarkissian 8f494ba164 fix planning broadcast 5 months ago
Razmig Sarkissian 30ec5034cd Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 6 months ago
Razmig Sarkissian a82008db1c fix issue with stripe 6 months ago
Laurent 625a882d88 fix issue with sharing 6 months ago
Laurent e4beca4840 minor improvements + crash fix 6 months ago
Razmig Sarkissian 7a0983b0a0 fix issue with court display in broadcast planning and prog not displaying in 6 months ago
Laurent 2655ed740a Fix crash 6 months ago
Laurent 1d8fb1b32a Fix sync bug 6 months ago
Laurent 46dec3c729 improvements for data accesses 6 months ago
Raz 30ca0ef20c fix shop shipping 6 months ago
Raz 0f25b54533 remove a partir de 6 months ago
Raz d15e43d84b add time played stat 6 months ago
Laurent b0bf249e3d possible bug fix 6 months ago
Raz c31b54c0cc add start date hour in tournament row 6 months ago
Raz d242641a0d add start date hour in tournament row 6 months ago
Raz 26707c47dc sort tournaments in event page 6 months ago
Raz 5471acab44 fix register bug 6 months ago
Raz 93c5883774 fix register bug 6 months ago
Raz 2b4013356b Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/padelclub_backend 6 months ago
Raz 603a0e67df fix tournament info event link 6 months ago
Laurent 1117e77bee adds is_canceled to the tournament admin 6 months ago
Raz c7a7375dff fix message 6 months ago
Laurent 21d0c85d27 Fix Match sync config 6 months ago
Raz 290092d879 add model image for club 6 months ago
Laurent 94580cdf73 sync improvements and custom children sharing 6 months ago
Raz 3ce4c16ec3 fix prog display 6 months ago
Raz 9be9c2d037 fix prog display 6 months ago
Raz 406786478b fix prog display 6 months ago
Raz df245c55d4 fix prog display 6 months ago
Raz 4774a2d814 piste au lieu de terrain 6 months ago
Laurent a2a4916c2f cleanup and minor improvements 6 months ago
Raz e4f1590e27 fix planned date display in prog 6 months ago
Raz 00744c7c71 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/padelclub_backend 6 months ago
Raz 4f4b293d52 fix issue with payment and registration 6 months ago
Laurent cb62518031 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 6 months ago
Laurent 1af925c8e7 Changes the sorting of finished tournaments 6 months ago
Raz 7b7c5fdb71 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/padelclub_backend 6 months ago
Raz d468fef6af fix prog display 6 months ago
Laurent a35fa885b8 Adds home button to the tournament navigation 6 months ago
Raz aaebde94b1 fix broadcast stuff 6 months ago
Raz 44776a0e1b fix broadcast stuff 6 months ago
Raz d971d795e4 fix broadcast stuff 6 months ago
Raz 23aaabab39 fix broadcast stuff 6 months ago
Raz 1361f16785 fix broadcast stuff 6 months ago
Raz 523f76b344 fix css 6 months ago
Raz ce0a989105 add unique_random_index in team 6 months ago
Raz 2fbea4eba5 add unique_random_index in team 6 months ago
Raz 0650ebd77f add unique_random_index in team 6 months ago
Raz 0da223f5e6 fix broadcast layout 6 months ago
Raz d93a850822 fix broadcast layout 6 months ago
Raz 158a54d770 fix broadcast layout 6 months ago
Raz 24e1fcab1b fix broadcast layout 6 months ago
Raz 17dc279ccd fix broadcast layout 6 months ago
Raz 3069f9e637 fix broadcast layout 6 months ago
Raz 1db45b7d2c fix broadcast layout 6 months ago
Raz f6f8244212 fix broadcast layout 6 months ago
Raz 3dfd959eb9 fix broadcast layout 6 months ago
Raz ee107ce340 fix broadcast layout 6 months ago
Raz d65c047628 fix broadcast layout 6 months ago
Raz 7742fde718 fix broadcast layout 6 months ago
Raz 6d92dbd491 fix broadcast layout 6 months ago
Raz f8135433dc fix broadcast layout 6 months ago
Raz 9ac210a47b fix broadcast layout 6 months ago
Raz 8acf2cf427 add planning event 6 months ago
Raz 93d5175ab0 add planning event 6 months ago
Raz 514cf96b09 add planning event 6 months ago
Raz a767254437 add planning event 6 months ago
Raz ed8cea7ff8 add planning event 6 months ago
Raz 268258425a add planning event 6 months ago
Raz 5d71da4875 add planning event 6 months ago
Raz 7640b9bb23 add planning event 6 months ago
Raz 536c28d10d add planning event 6 months ago
Raz 23257f2e48 add planning event 6 months ago
Raz 2e417456c7 add planning event 6 months ago
Raz 195051086a add planning event 6 months ago
Raz 7ae1692155 add planning event 6 months ago
Raz f8be173ad8 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/padelclub_backend 6 months ago
Raz c6f5571d43 add planning event 6 months ago
Laurent 47f867c2f7 attempt to fix #2 6 months ago
Laurent 9be436fccf attempt to fix broadcast display 6 months ago
Laurent d474de1edf Fix group stage display 6 months ago
Raz 96fe35a742 add prog to website 6 months ago
Laurent ba4f6652ed attempt #3 6 months ago
Laurent 92e50b55dd attempt #2 6 months ago
Laurent 75c66c98d2 attempt to fix xls-to-csv issu 6 months ago
Laurent 840c42209c adds more logging 6 months ago
Laurent 41e9179693 adds logging for csv issue 6 months ago
Raz 3b48d22473 bigger logo 6 months ago
Raz 7d6d71a44d may 2025 ranking 6 months ago
Raz c74a117c34 fix broadcast sponsor logo 6 months ago
Raz 620c20f9e7 gitignore update 6 months ago
Raz 7c68762178 add sponsor image model 6 months ago
Raz 3b3cf56896 add action in admin shop order panel 6 months ago
Raz 97a7543f9e show email in order admin panel 6 months ago
Raz b7a55e46f7 add ready status in shop order 6 months ago
Raz 900bf9865a fix title cap for shop 6 months ago
Raz 762b79200a fix empty order list 6 months ago
Raz eebf18d1a1 fix empty order list 6 months ago
Raz 6247fee705 add signal message for shop 6 months ago
Raz 8dd3438ace fix some nav links 6 months ago
Raz 6800c1643d add new shop items 6 months ago
Raz cd71834fdf add shipping adress and refund option 6 months ago
Raz e3e6603d65 fix padding bubble 6 months ago
Raz ffdb5ce74c add a mail notification to the umpire when team unregister and display canceled status for a team status in priority 6 months ago
Raz 525681d7ae fix padding bubble 6 months ago
Raz 3cd541977d add animation type variable 6 months ago
Raz fed287ce43 add animation type variable 6 months ago
Laurent 52a0b5ec41 merge main 6 months ago
Raz 3e2cc6afa5 fix lstrip 0 in licence id 6 months ago
Raz 5ef46e5533 fix cascade on shop product with user 6 months ago
Raz 32f9b7e28b Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/padelclub_backend 6 months ago
Raz 0d014760db add creator_full_name in admin event 6 months ago
Laurent 6e3571f27e change user fields order 6 months ago
Laurent f63e0e9456 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 6 months ago
Laurent 2888c3bf54 Adds LogEntry in the django admin 6 months ago
Raz 94c3429bd8 fix broadcast bracket 6 months ago
Raz 054aa07b50 add link under bracket 7 months ago
Raz f19ccc6a5b add link under bracket 7 months ago
Raz b746a0da0c fix my tournaments 7 months ago
Raz d187655e03 add the ability to register animation 7 months ago
Raz 260692da75 add the ability to register animation 7 months ago
Raz 0470d80379 fix bracket template 7 months ago
Raz 348c069817 fix issue with loser bracket bracket template 7 months ago
Raz aea047293e rollback xls-to-csv 7 months ago
Raz 57857ac552 rollback xls-to-csv 7 months ago
Raz 47d68a3e73 rollback xls-to-csv 7 months ago
Raz 71b71938c4 fix error with geteightam date 7 months ago
Raz c95c9f403b fix feedback intern 7 months ago
Raz e708fb8b70 remove top block tournament_info 7 months ago
Raz 80503c3bd7 fix refund on corporate tournament 7 months ago
Raz 3f95477066 fixes 7 months ago
Raz a19afb2299 fix stuff 7 months ago
Raz 174e96402e fix settings online payment 7 months ago
Raz d9982e2bfd Merge branch 'main' into timetoconfirm 7 months ago
Raz 872cf984f3 fix bracket broadcast 7 months ago
Raz f9318d64d7 fix un licensed workflow 7 months ago
Raz 36cc1659a6 fix un licensed workflow 7 months ago
Raz 72970a06ef fix signal crash 7 months ago
Raz 23964328fa merge main 7 months ago
Raz e6f1ab3944 fix club headers 7 months ago
Raz db4fcee479 fix club headers 7 months ago
Raz 8667ea97ac fix mail doubling 7 months ago
Raz 1b39a3f340 fix mail doubling 7 months ago
Raz 7ea7f4ef6c fix mail doubling 7 months ago
Raz 1e9ab3a8e7 fix mail doubling 7 months ago
Raz 9454d932d5 add a confirm if place signal on team reg 7 months ago
Raz dd98096ef9 add a confirm if place signal on team reg 7 months ago
Raz 142f0929c2 add a confirm if place signal on team reg 7 months ago
Raz e011d7bd5a add a confirm if place signal on team reg 7 months ago
Raz 01bd0a03e0 add a confirm if place signal on team reg 7 months ago
Raz 24b4ed949e add a confirm if place signal on team reg 7 months ago
Raz 030bc03154 add a confirm if place signal on team reg 7 months ago
Raz b9eb6382ca add a confirm if place signal on team reg 7 months ago
Raz 03e2b874e8 fix tz stuff and ttc stuff 7 months ago
Raz 7efafb738e fix tz stuff and ttc stuff 7 months ago
Raz c790eb7eb1 fix tz stuff and ttc stuff 7 months ago
Raz ca19750df3 fix tz stuff and ttc stuff 7 months ago
Raz 7264f139ee fix tz stuff and ttc stuff 7 months ago
Raz 255758f02d fix ttc 7 months ago
Raz bb4dd716d1 fix ttc 7 months ago
Raz 5fbfb3922b fix ttc 7 months ago
Laurent d19fdc3bd0 wip 7 months ago
Raz 29bda0d620 fix bg tasks 7 months ago
Raz e282b6b807 fix bg tasks 7 months ago
Raz f76a74f8b7 clean up background_task 7 months ago
Raz 147a69a7df fix ttc bugs 7 months ago
Raz 715972b115 fix ttc bugs 7 months ago
Raz 05296bbb64 fix issue with calculate ttc 7 months ago
Raz 2c38ef7809 add confirm call button 7 months ago
Raz 3e0b51c4c9 fix refund stuff 7 months ago
Raz 01a2c8880b fix payment errors 7 months ago
Raz 840d760ba4 display_tournament when enable_online_registration is True 7 months ago
Raz 1a5e31cb8e set up live testing for time to confirm 7 months ago
Raz fe33401d02 add BACKGROUND_SCHEDULED_TASK_INTERVAL settings 7 months ago
Raz 4d671af2bf fix import 7 months ago
Raz 9f20bae167 fix requirements 7 months ago
Raz 1c5d7ff6d8 clean up migration 7 months ago
Raz 82ec8f8471 fix settings and add api to explain waiting list configs and payment fee 7 months ago
Raz 1c5cc25e49 fix lucky loser everywhere 7 months ago
Raz 840d11ea7e remove players property method and replace it with players_sorted_by_rank, fix and made consistent running footer 7 months ago
Raz 147a736c35 fix css 7 months ago
Raz 99e91ee843 cleanup and fix cancel reg 7 months ago
Raz ef46c71625 add refund api 7 months ago
Raz 444020fbcf add payment_id to unregistered_player 7 months ago
Raz 2908519a84 add registration webhook 7 months ago
Raz e019cea984 clean up 7 months ago
Raz 185aa29f3d Merge branch 'main' into timetoconfirm 7 months ago
Raz 1c0e4e0472 affinage bracket broadcast and remove LL in loser matchs 7 months ago
Laurent aacb64f0f0 Sync fixes 7 months ago
Raz fa3e0c76c3 Merge branch 'main' into timetoconfirm 7 months ago
Raz 4b912f5451 fix shop product color missing 7 months ago
Raz 4af00495b1 fix clean up message 7 months ago
Raz 70d286cb52 Merge branch 'main' into timetoconfirm 7 months ago
Raz 8adceb2628 add session timer management 7 months ago
Raz f2fcd83cc5 clean up 7 months ago
Raz 28e39be609 add payment for registration clean up 7 months ago
Raz 42090b5ab7 add payment for registration 7 months ago
Laurent a0427b81c2 fix issue where matches were displayed for the teams when no match should be shown 7 months ago
Laurent 65a45d209d Fixes issues with data access 7 months ago
Raz a321c4c154 fix registration process 7 months ago
Laurent 98a8bf3d12 Synchronization fix and improvement 7 months ago
Laurent d25893698d fix mail sending stuff 7 months ago
Raz 33c084ba0e Merge branch 'main' into timetoconfirm 7 months ago
Raz 63a9cbd33d Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/padelclub_backend 7 months ago
Raz f01f87e1eb fix settings and shop signal, add admin related_user search 7 months ago
Laurent b9c384e769 Fix typo 7 months ago
Laurent 58383a19df fix typo 7 months ago
Laurent 9af4da81c8 uncomment stuff 7 months ago
Raz 74255f851b Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/padelclub_backend 7 months ago
Raz 1f64fd0a7d add order recap 7 months ago
Laurent 64ae364a3e Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 7 months ago
Laurent 1b3b0afccb Add logs 7 months ago
Raz aa630d1348 fix register tournament 7 months ago
Raz eb05fcb9b1 fix register tournament 7 months ago
Raz 78e324158b merge main 7 months ago
Raz cd661743b8 clean up import 7 months ago
Raz 5337bd9af9 fix april 2025 rankings 7 months ago
Raz 1c68e98089 update april 2025 7 months ago
Raz c2c5f20045 shop update 7 months ago
Raz ae115bc4cb add championship type 7 months ago
Raz ea1504c83e fix forms check for username and email 7 months ago
Raz 68a926b605 fix user licence-id 7 months ago
Raz e397a37b05 fix licence-id and add it to admin 7 months ago
Raz ba16ab06c9 fix licence-id stuff 7 months ago
Raz 3f03ede642 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/padelclub_backend 7 months ago
Raz 3bd4007acc fix licence-id stuff 7 months ago
Laurent 60df932d3a Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 7 months ago
Laurent c6b466144f remove prints 7 months ago
Raz d400a07f88 update shop 7 months ago
Raz bc7b15c070 update shop 7 months ago
Raz 65f4b68078 update shop 7 months ago
Raz 99231d2414 update shop 7 months ago
Raz f047473d33 fix css ipad 7 months ago
Raz d18f78ef45 ranking april 2025 7 months ago
Laurent ee19f9c410 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 7 months ago
Laurent 629d82cf0f Make Purchase extends BaseModel to be synchronizable 7 months ago
Raz 73cc0eba90 update position / rank in tournament row 7 months ago
Raz 5d84a6bd63 update position / rank in tournament row 7 months ago
Raz f7aa474f0f update position / rank in tournament row 7 months ago
Raz f1a223a094 update position / rank in tournament row 7 months ago
Raz f3894c2db5 fix my tournaments 7 months ago
Raz c93a7ebf8b add info box 7 months ago
Raz cbdb2eba15 add products photos 7 months ago
Laurent 9ca5a4028b revert regression 7 months ago
Laurent 23148393e0 minor improvements 7 months ago
Laurent f1cfabcbe6 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 7 months ago
Laurent b540486dbc prints 7 months ago
Raz 4b7882cae7 fix my tournaments 7 months ago
Raz 3d7ab2d371 new style list of tournaments 7 months ago
Raz cea1276369 new style list of tournaments 7 months ago
Raz a98ac905fb new style list of tournaments 7 months ago
Raz 6a257062df new style list of tournaments 7 months ago
Raz 42cf4abbd5 new style list of tournaments 7 months ago
Raz 5d5aba938b new style list of tournaments 7 months ago
Raz 285430d690 new style list of tournaments 7 months ago
Raz b9e0bab3fc Merge branch 'main' into timetoconfirm 8 months ago
Raz 9c456a9357 fix lot of stuff around forms and password reset 8 months ago
Raz d9a696d82e fix crash 8 months ago
Raz 37748596c3 post merge main 8 months ago
Raz e3a9d8ad06 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/padelclub_backend 8 months ago
Raz 15f5aae34f fix signal tournament team reg 8 months ago
Laurent 810c6578bc Fix crash 8 months ago
Laurent 49123a3b58 fix crash 8 months ago
Raz c44a876f8e fix p500+ stuff 8 months ago
Raz dc5a033e63 handle tasks 8 months ago
Raz 48638e76f8 wip 8 months ago
Laurent 21b0a85678 Fix plural of round names 8 months ago
Laurent 3b8a03f92f fix semi finals labl 8 months ago
Laurent a7d1407a2f Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 8 months ago
Laurent 2e443180e9 fix crash 8 months ago
Raz d264179306 fix unisex stuff in shop 8 months ago
Raz b9db62e761 update message 8 months ago
Raz 7eabf9f444 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/padelclub_backend 8 months ago
Raz dbe470c7ff add coupon in shop 8 months ago
Laurent 85d1f54e13 remove logs 8 months ago
Laurent de79174f4d Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 8 months ago
Laurent 1c6c910a8a Fix crash 8 months ago
Raz 7574087bf8 update shop 8 months ago
Raz b8a708ff44 update shop 8 months ago
Raz e9e1f65911 update shop 8 months ago
Laurent db27696ba6 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 8 months ago
Laurent f6279aa7eb fix crash 8 months ago
Raz f77aff3237 fix css 8 months ago
Raz d71d694703 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/padelclub_backend 8 months ago
Laurent fa27058dbb fix quarters and semis label 8 months ago
Raz 6a86f8121a Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/padelclub_backend 8 months ago
Raz f55d123cbf add custom contact management and special rank ruling 8 months ago
Laurent c1154aff9a fix crash 8 months ago
Laurent f1f92f6930 reorder user admin fields 8 months ago
Laurent 8c17bbb5b7 attempt to fix token request issue 8 months ago
Laurent 00d4619428 revert fix 8 months ago
Laurent f84c8ed930 attempt to fix login issue 8 months ago
Laurent 6c9bc3c7f0 fix tournament import 8 months ago
Laurent 59318f1fd1 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 8 months ago
Laurent f1a5b0c44d fix group stage issue 8 months ago
Raz 3c8adb5673 fix login redirect 8 months ago
Raz a2242fcda5 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/padelclub_backend 8 months ago
Raz 34c28ac7a7 fix logout and signup 8 months ago
Laurent 51735f4457 fix crash 8 months ago
Laurent 800a76ecfd Merge branch 'main' into sync 8 months ago
Laurent e75772f361 upgrade required version to 1.2 8 months ago
Raz fe7713cb89 fix hiding bracket if no bracket 8 months ago
Laurent 861d92505c Merge branch 'main' into sync 8 months ago
Laurent 6c69dcb392 no ModelLog creation for step1 of migration 8 months ago
Raz a97d7d621b fix issue with page club 8 months ago
Laurent 9c95e0d4c9 Fix sync for online registrations 8 months ago
Raz 6e1133bf47 fix bracket 8 months ago
Raz 6c0ebc2b50 fix shop signup 8 months ago
Raz 11a2f331b6 fix scroll up when adding to cart 8 months ago
Raz 04eb83ce2c fix rules pdf 8 months ago
Laurent 21ce64d941 Adds link to see group stages match 8 months ago
Laurent abc246595d fix naming crash 8 months ago
Raz bd05efd3b5 attempt to handle multi color image 8 months ago
Raz e4da854380 add email to stripe 8 months ago
Laurent ebcbcffc05 merge main 8 months ago
Laurent aa50d56b21 fix potential login issue 8 months ago
Raz 39dc48b702 improve creator in admin 8 months ago
Raz e31e937974 fix bug 8 months ago
Raz 5451cd6667 fix color name size 8 months ago
Raz d9d2a6fec8 fix color name size 8 months ago
Raz 5f0d4bbb12 build shop 8 months ago
Raz 47db5854d0 fix shop stuff 8 months ago
Raz 4be049db33 add bicolor 8 months ago
Raz 22b6d71169 fix redirection and shop stuff 8 months ago
Raz 69d838d245 clean up shop 8 months ago
Raz 5b7ee2612a remove media file 8 months ago
Raz 30c9ee59b6 Merge branch 'main' into shop 8 months ago
Laurent 57ea6e8e78 create authentication app to separate authentication from business api 8 months ago
Raz 50d8740260 fix nav in team details and wc stuff 8 months ago
Laurent 6b97eaf850 fix name 8 months ago
Laurent 7f37ba729e indent properly 8 months ago
Laurent b34f1b201c rebuild 8 months ago
Laurent ff01f5c77a merge main 8 months ago
Laurent ccb71a884c refactor device_id system through a registry to properly notify devices 8 months ago
Laurent 9d2e2ec912 Fix crashes 8 months ago
Laurent 1e109ab884 urls update 8 months ago
Laurent 673f836bd8 fix crash 8 months ago
Laurent c285bb1a03 Fix relationship naming 8 months ago
Laurent e94d325668 Merge branch 'main' into sync 8 months ago
Laurent 7e84e3ab9e fix naming crash 8 months ago
Laurent 98781235a1 merge 8 months ago
Laurent a0998299ee various fixes and improvements 8 months ago
Laurent 15231d9299 merge main 8 months ago
Raz 971090764e rankings march 2025 8 months ago
Raz bc97ea7833 rankings march 2025 8 months ago
Laurent bfbd7caaa3 merge main 9 months ago
Laurent 7e0027853a adds tournament link for the page title 9 months ago
Laurent 8ada5b50ce title links redirects to the tournament home 9 months ago
Laurent 7a06c70354 fix crash 9 months ago
Laurent 56e3917999 fix match group display 9 months ago
Laurent cba4cb9367 admin minor improvements 9 months ago
Laurent 269ed59e2a Fix bug where deletes failed with the wrong status code 9 months ago
Laurent a219cf1828 fix crash 9 months ago
Laurent 83e2cac00c fix crash 9 months ago
Laurent 75994cc86c merge main 9 months ago
Laurent 864fc8bfcf fix custom migration 9 months ago
Laurent 3d6aa8282a Adds control over when to save ModelLogs 9 months ago
Laurent d9a5e98ad3 Add Device to log the different used devices and make migrations 9 months ago
Laurent a68e003695 revert these class from inheriting BaseModel 9 months ago
Laurent 527a9121c5 better log 9 months ago
Laurent b983b933e7 merge 9 months ago
Laurent 03a2bd9210 Fix issue 9 months ago
Laurent f02ed0c2f0 update 9 months ago
Laurent 71d66e1fe0 fix date issue 9 months ago
Laurent 5452a06571 Fix log ordering 9 months ago
Laurent ce52aa2502 remove many to many relationship between ModelLog and Users 9 months ago
Laurent 8b8e0eb9d5 set ModelLog transactions as atomic 9 months ago
Laurent 5922e1b6ec put auto_now_add on ModelLog date 9 months ago
Laurent 4ad2c228cd Remove model log count 9 months ago
Laurent af025f87fc when the commit happens 9 months ago
Laurent ac1028f590 remake sync migrations 9 months ago
Laurent b3c3899572 test BigAutoField for ModelLog id 9 months ago
Laurent 1effe2ccef exclude device id 9 months ago
Laurent a9962cdce6 shows time of ModelLog 9 months ago
Laurent 317fff6399 show count 9 months ago
Laurent ca6a50229f test count 9 months ago
Laurent 7f54e6910b adds filters for ModelLog 9 months ago
Laurent 5aa4a47340 Attempt to fix issues with missing data 9 months ago
Laurent c8d629924a Adds some id field in the search 9 months ago
Laurent b8adfe2809 Fixes 9 months ago
Laurent 4bd82fcf29 change date format to get thousands 9 months ago
Laurent f1eff69341 Fix admin cascading delete 10 months ago
Laurent 852c0ff18c Fix crash 10 months ago
Laurent 4767a15f1f Remove cascade deletes + fix granting issue + fix broadcast code setting 10 months ago
Laurent 1f4fad4302 fixes 10 months ago
Laurent ea77f2db83 Fixes scheme 10 months ago
Laurent dda0b05251 minor improvements 10 months ago
Laurent ae51d0c5c8 Adds status page for websockets 10 months ago
Laurent ab1713b29e Improvements on CRM 10 months ago
Laurent 53c10644c1 Erase data access objects when their parent is deleted 10 months ago
Laurent 26757c9b59 Update last_update date when changing the user on the admin 10 months ago
Laurent 94efdadfc9 Adds agents to user serializer 10 months ago
Laurent 228b130104 fix sync post service returns 10 months ago
Laurent 0c437c5468 Change sync POST service to handle multiple operations 10 months ago
Laurent ff91b3023e wip for crm 11 months ago
Laurent 81fee53656 improve log 11 months ago
Laurent 0ed5a312bd adds required settings 11 months ago
Laurent dc9d8adc32 fix conflict 11 months ago
Laurent e8908b672d fix set naming 11 months ago
Laurent e5ec7efc5b various fixes 11 months ago
Laurent 3326eae98b merge main + improvements 11 months ago
Laurent 00c44b4eba Manage to provide an order for the revocation hierarchy 11 months ago
Laurent 5caaa7c68b Improvements and fixes 11 months ago
Laurent c19b96e4a3 Fix bug 11 months ago
Laurent 4a8d7c6a5c Fix and improvement 11 months ago
Laurent 8916ddbbeb Fix issue where we need to grant/revoke access on foreign keys 11 months ago
Laurent 9ce87672d9 Adds related_user to BaseModel 11 months ago
Laurent 5008f5588c websocket notification fix + refactoring 11 months ago
Laurent 06e4fba108 Adds the User to the class registry 11 months ago
Laurent 7e6f4d71fa re-add channel_redis 11 months ago
Laurent ea65911a98 remove channel-reids 11 months ago
Laurent 74036e4729 adds channels_redis to installed apps 11 months ago
Laurent 7d488a2019 Adds channels_redis package and configuration 11 months ago
Laurent d73deb9d64 Removes cors dependency 11 months ago
Laurent 98ede4bb93 Put CORS first 11 months ago
Laurent e0da1a3466 Fix settings 11 months ago
Laurent 38b54c9317 Adds CORS setting 11 months ago
Laurent f30ad9a1af Adds corsheader app to fix CORS issues 11 months ago
Laurent 07047cf2d8 test paths 11 months ago
Laurent 6b5f240f09 Fixes for ASGI server 11 months ago
Laurent fccdf32ece Update services to handle the agents system 11 months ago
Laurent d0e49971b5 Fixes and improvements 11 months ago
Laurent d50e391d16 cleanup 11 months ago
Laurent 7bdf38b78d Separate the sync from tournaments by creating a new app 11 months ago
Laurent 9b81dc49e8 cleanup 11 months ago
Laurent acbc4c117b sends data from data access grant in a separate dictionary 12 months ago
Laurent 3bccbb94ce Work on revocations and log date storage 12 months ago
Laurent 4680a09532 Fix isisues 12 months ago
Laurent 3251dc3531 Improvements 12 months ago
Laurent 1f4687f78a many fixes 12 months ago
Laurent 41ba44df98 First working occurence of sync and websockets 12 months ago
Laurent 2d2e17eb70 Adds related_name for inverse relationship + ViewSet now use the parameter store_id to filter by store_id/tournament 1 year ago
Laurent 0829a1e246 data access first working version 1 year ago
Laurent 415f70458c work in progress 1 year ago
Laurent 3783477768 Readd store_id for ModelLog to handle deletions 1 year ago
Laurent d98cb74e1a remove token deletion on disconnect 1 year ago
Laurent 0fe9272002 first commit 1 year ago
  1. 4
      .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. 228
      api/serializers.py
  11. 35
      api/urls.py
  12. 1283
      api/utils.py
  13. 800
      api/views.py
  14. 1
      asgi.py
  15. 0
      authentication/__init__.py
  16. 15
      authentication/admin.py
  17. 6
      authentication/apps.py
  18. 36
      authentication/migrations/0001_initial.py
  19. 18
      authentication/migrations/0002_rename_model_name_device_device_model.py
  20. 0
      authentication/migrations/__init__.py
  21. 2
      authentication/models/__init__.py
  22. 12
      authentication/models/device.py
  23. 13
      authentication/models/login_log.py
  24. 29
      authentication/serializers.py
  25. 0
      authentication/tests.py
  26. 5
      authentication/utils.py
  27. 116
      authentication/views.py
  28. 0
      biz/__init__.py
  29. 534
      biz/admin.py
  30. 70
      biz/admin_urls.py
  31. 4
      biz/apps.py
  32. 163
      biz/filters.py
  33. 61
      biz/forms.py
  34. 103
      biz/migrations/0001_initial.py
  35. 18
      biz/migrations/0002_alter_prospect_email.py
  36. 18
      biz/migrations/0003_alter_activity_status.py
  37. 18
      biz/migrations/0004_prospect_contact_again.py
  38. 38
      biz/migrations/0005_alter_activity_status_campaign.py
  39. 19
      biz/migrations/0006_alter_campaign_id.py
  40. 37
      biz/migrations/0007_prospectgroup_delete_campaign.py
  41. 23
      biz/migrations/0008_alter_activity_declination_reason_and_more.py
  42. 0
      biz/migrations/__init__.py
  43. 4
      biz/mixins.py
  44. 221
      biz/models.py
  45. 0
      biz/services.py
  46. 448
      biz/templates/admin/biz/dashboard.html
  47. 81
      biz/templates/admin/biz/email_users.html
  48. 11
      biz/templates/admin/biz/prospect/change_list.html
  49. 53
      biz/templates/admin/biz/prospect/import_file.html
  50. 29
      biz/templates/admin/biz/select_email_template.html
  51. 58
      biz/templates/biz/add_prospect.html
  52. 4
      biz/templates/biz/base.html
  53. 34
      biz/templates/biz/csv_import.html
  54. 6
      biz/templates/biz/event_form.html
  55. 4
      biz/templates/biz/event_row.html
  56. 29
      biz/templates/biz/events.html
  57. 17
      biz/templates/biz/prospect_form.html
  58. 81
      biz/templates/biz/prospect_list.html
  59. 4
      biz/templates/biz/send_bulk_email.html
  60. 0
      biz/templatetags/__init__.py
  61. 7
      biz/templatetags/crm_tags.py
  62. 3
      biz/tests.py
  63. 12
      biz/urls.py
  64. 284
      biz/views.py
  65. 1
      crm/_instructions/base.md
  66. 96
      crm/admin.py
  67. 18
      crm/filters.py
  68. 40
      crm/forms.py
  69. 94
      crm/migrations/0001_initial.py
  70. 60
      crm/migrations/0002_alter_event_options_alter_prospect_options_and_more.py
  71. 112
      crm/models.py
  72. 10
      crm/static/js/prospect_list.js
  73. 31
      crm/templates/crm/add_prospect.html
  74. 57
      crm/templates/crm/prospect_list.html
  75. 7
      crm/templatetags/crm_tags.py
  76. 186
      crm/views.py
  77. BIN
      media/products/Capture_décran_2025-03-17_à_17.11.10.png
  78. BIN
      media/products/IMG_4957.jpg
  79. BIN
      media/products/IMG_4957_36DeRZt.jpg
  80. BIN
      media/products/IMG_4957_gKJ3yDG.jpg
  81. BIN
      media/products/IMG_4957_korPKtN.jpg
  82. BIN
      media/products/WhatsApp_Image_2025-03-02_at_12.02.15.jpeg
  83. BIN
      media/products/WhatsApp_Image_2025-03-02_at_12.02.15_cfPyazE.jpeg
  84. 26
      padelclub_backend/asgi.py
  85. 1
      padelclub_backend/routing.py
  86. 132
      padelclub_backend/settings.py
  87. 67
      padelclub_backend/settings_app.py
  88. 49
      padelclub_backend/settings_local.py.dist
  89. 31
      padelclub_backend/urls.py
  90. 12
      requirements.txt
  91. 11
      sample_prospects.csv
  92. 387
      shop/admin.py
  93. 18
      shop/cart.py
  94. 17
      shop/forms.py
  95. 202
      shop/management/commands/create_initial_shop_data.py
  96. 18
      shop/migrations/0018_color_secondary_hex_color.py
  97. 18
      shop/migrations/0019_product_description.py
  98. 18
      shop/migrations/0020_product_sku.py
  99. 23
      shop/migrations/0021_alter_color_name_alter_product_sku.py
  100. 21
      shop/migrations/0022_alter_cartitem_options_alter_orderitem_options.py
  101. Some files were not shown because too many files have changed in this diff Show More

4
.gitignore vendored

@ -7,6 +7,7 @@ padelclub_backend/settings_local.py
myenv/
shared/config_local.py
logs/
# Byte-compiled / optimized / DLL files
__pycache__/
@ -173,3 +174,6 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Media files
media/
*/media/

@ -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,23 +1,18 @@
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
from django.contrib.auth import password_validation
from django.utils.translation import gettext_lazy as _
from django.db.utils import IntegrityError
from django.conf import settings
# email
from django.template.loader import render_to_string
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.http import urlsafe_base64_encode
from django.utils.encoding import force_bytes
from django.core.mail import EmailMessage
from django.contrib.sites.shortcuts import get_current_site
from api.tokens import account_activation_token
from shared.cryptography import encryption_util
from tournaments.models.draw_log import DrawLog
from tournaments.models.enums import UserOrigin
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 biz.models import Activity, Prospect, Entity
class EncryptedUserField(serializers.Field):
def to_representation(self, value):
@ -52,11 +47,13 @@ class UserSerializer(serializers.ModelSerializer):
if 'country' in validated_data:
country = validated_data['country']
if CustomUser.objects.filter(username__iexact=validated_data['username'].lower()):
raise IntegrityError("Le nom d'utilisateur existe déjà")
username_lower = validated_data['username'].lower()
if CustomUser.objects.filter(username__iexact=username_lower) | CustomUser.objects.filter(email__iexact=username_lower):
raise serializers.ValidationError("Cet identifiant est déjà utilisé. Veuillez en choisir un autre :)")
user = CustomUser.objects.create_user(
username=validated_data['username'],
last_update=validated_data.get('last_update'),
email=validated_data['email'],
password=validated_data['password'],
first_name=validated_data['first_name'],
@ -78,9 +75,16 @@ class UserSerializer(serializers.ModelSerializer):
loser_bracket_match_format_preference=validated_data.get('loser_bracket_match_format_preference'),
loser_bracket_mode=validated_data.get('loser_bracket_mode'),
origin=UserOrigin.APP,
user_role=None,
registration_payment_mode=validated_data.get('registration_payment_mode', RegistrationPaymentMode.DISABLED),
umpire_custom_mail=validated_data.get('umpire_custom_mail'),
umpire_custom_contact=validated_data.get('umpire_custom_contact'),
umpire_custom_phone=validated_data.get('umpire_custom_phone'),
hide_umpire_mail=validated_data.get('hide_umpire_mail', False),
hide_umpire_phone=validated_data.get('hide_umpire_phone', True),
disable_ranking_federal_ruling=validated_data.get('disable_ranking_federal_ruling', False)
)
if not settings.DEBUG:
self.send_email(self.context['request'], user)
# RegistrationProfile.objects.filter(user=user).send_activation_email()
@ -105,79 +109,162 @@ class UserSerializer(serializers.ModelSerializer):
model = CustomUser
fields = '__all__' # ['id', 'username', 'password', 'umpire_code', 'clubs', 'phone', 'first_name', 'last_name', 'licence_id']
class UserUpdateSerializer(serializers.ModelSerializer):
class CustomUserSerializer(serializers.ModelSerializer): ### the one matching the CustomUser class and used for sync
class Meta:
model = CustomUser
fields = CustomUser.fields_for_update()
class ShortUserSerializer(serializers.ModelSerializer):
class Meta:
model = CustomUser
fields = ['id', 'first_name', 'last_name']
class ClubSerializer(serializers.ModelSerializer):
class Meta:
model = Club
fields = '__all__' # ['id', 'name', 'acronym', 'phone', 'code', 'federal_club_data', 'address', 'city', 'zip_code', 'latitude', 'longitude']
fields = '__all__'
def create(self, validated_data):
user = self.context['request'].user
validated_data['creator'] = user
return super().create(validated_data)
class TournamentSerializer(serializers.ModelSerializer):
class Meta:
model = Tournament
fields = '__all__'
# ['id', 'name', 'event', 'creator_id', 'start_date', 'end_date', 'creation_date',
# 'is_private', 'format', 'group_stage_format', 'round_format', 'loser_round_format', 'bracket_sort_mode',
# 'group_stage_count', 'rank_source_date', 'day_duration', 'team_count', 'team_sorting',
# 'federal_category', 'federal_level_category', 'federal_age_category', 'group_stage_court_count',
# 'seed_count', 'closed_registration_date', 'group_stage_additional_qualified', 'court_count', 'prioritize_club_members',
# 'qualified_per_group_stage', 'teams_per_group_stage']
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 Meta:
#club_id = serializers.PrimaryKeyRelatedField(queryset=Club.objects.all())
model = Event
fields = '__all__'
# ['id', 'club_id', 'date', 'name', 'federal_tournament_data', 'court_count', 'tenup_id',
# 'group_stage_format', 'round_format', 'loser_round_format']
class RoundSerializer(serializers.ModelSerializer):
class Meta:
# tournament_id = serializers.PrimaryKeyRelatedField(queryset=Tournament.objects.all())
# loser_id = serializers.PrimaryKeyRelatedField(queryset=Round.objects.all())
model = Round
fields = '__all__' #['id', 'index', 'tournament_id', 'loser', 'format']
fields = '__all__'
class GroupStageSerializer(serializers.ModelSerializer):
class Meta:
model = GroupStage
fields = '__all__' # ['id', 'index', 'tournament_id', 'format']
fields = '__all__'
class MatchSerializer(serializers.ModelSerializer):
class Meta:
# round_id = serializers.PrimaryKeyRelatedField(queryset=Round.objects.all())
# group_stage_id = serializers.PrimaryKeyRelatedField(queryset=GroupStage.objects.all())
model = Match
fields = '__all__'
# ['id', 'round_id', 'group_stage_id', 'index', 'format', 'court', 'start_date', 'end_date',
# 'serving_team_id', 'winning_team_id', 'losing_team_id']
class TeamScoreSerializer(serializers.ModelSerializer):
class Meta:
# match_id = serializers.PrimaryKeyRelatedField(queryset=Match.objects.all())
# player_registrations_ids = serializers.Man
model = TeamScore
fields = '__all__' # ['id', 'match_id', 'score', 'walk_out', 'lucky_loser', 'player_registrations']
fields = '__all__'
class TeamRegistrationSerializer(serializers.ModelSerializer):
class Meta:
# match_id = serializers.PrimaryKeyRelatedField(queryset=Match.objects.all())
# group_stage_id = serializers.PrimaryKeyRelatedField(queryset=GroupStage.objects.all())
model = TeamRegistration
fields = '__all__'
# ['id', 'group_stage_id', 'registration_date', 'call_date', 'bracket_position',
# 'group_stage_position', 'logo']
class PlayerRegistrationSerializer(serializers.ModelSerializer):
class Meta:
# team_registration_id = serializers.PrimaryKeyRelatedField(queryset=TeamRegistration.objects.all())
# team_state_id = serializers.PrimaryKeyRelatedField(queryset=TeamState.objects.all())
model = PlayerRegistration
fields = '__all__'
# ['id', 'team_registration_id', 'first_name', 'last_name', 'licence_id', 'rank', 'has_paid']
class PurchaseSerializer(serializers.ModelSerializer):
user = EncryptedUserField()
@ -186,31 +273,11 @@ class PurchaseSerializer(serializers.ModelSerializer):
model = Purchase
fields = '__all__'
class ChangePasswordSerializer(serializers.Serializer):
old_password = serializers.CharField(max_length=128, write_only=True, required=True)
new_password1 = serializers.CharField(max_length=128, write_only=True, required=True)
new_password2 = serializers.CharField(max_length=128, write_only=True, required=True)
def validate_old_password(self, value):
def create(self, validated_data):
user = self.context['request'].user
if not user.check_password(value):
raise serializers.ValidationError(
_('Your old password was entered incorrectly. Please enter it again.')
)
return value
validated_data['user'] = user
return super().create(validated_data)
def validate(self, data):
if data['new_password1'] != data['new_password2']:
raise serializers.ValidationError({'new_password2': _("The two password fields didn't match.")})
password_validation.validate_password(data['new_password1'], self.context['request'].user)
return data
def save(self, **kwargs):
password = self.validated_data['new_password1']
user = self.context['request'].user
user.set_password(password)
user.save()
return user
class LiveMatchSerializer(serializers.ModelSerializer):
class Meta:
@ -264,3 +331,34 @@ class UnregisteredPlayerSerializer(serializers.ModelSerializer):
model = UnregisteredPlayer
fields = '__all__'
# ['id', 'team_registration_id', 'first_name', 'last_name', 'licence_id', 'rank', 'has_paid']
class ImageSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField()
def get_image_url(self, obj):
if obj.image:
return self.context['request'].build_absolute_uri(obj.image.url)
return None
class Meta:
model = Image
fields = ['id', 'title', 'description', 'image', 'image_url', 'uploaded_at',
'event', 'image_type']
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__'

@ -3,11 +3,16 @@ from rest_framework import routers
from rest_framework.authtoken.views import obtain_auth_token
from . import views
from sync.views import SynchronizationApi, UserDataAccessApi, DataAccessViewSet
from authentication.views import CustomAuthToken, Logout, ChangePasswordView
router = routers.DefaultRouter()
router.register(r'users', views.UserViewSet)
router.register(r'user-supervisors', views.SupervisorViewSet)
router.register(r'clubs', views.ClubViewSet)
router.register(r'tournaments', views.TournamentViewSet)
router.register(r'tournament-summaries', views.TournamentSummaryViewSet)
router.register(r'images', views.ImageViewSet)
router.register(r'events', views.EventViewSet)
router.register(r'rounds', views.RoundViewSet)
router.register(r'group-stages', views.GroupStageViewSet)
@ -22,20 +27,42 @@ router.register(r'draw-logs', views.DrawLogViewSet)
router.register(r'failed-api-calls', views.FailedApiCallViewSet)
router.register(r'logs', views.LogViewSet)
router.register(r'device-token', views.DeviceTokenViewSet)
router.register(r'data-access', DataAccessViewSet)
router.register(r'unregistered-teams', views.UnregisteredTeamViewSet)
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 = [
path('', include(router.urls)),
path('sync-data/', SynchronizationApi.as_view(), name="data"),
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("user-by-token/", views.user_by_token, name="user_by_token"),
path("change-password/", views.ChangePasswordView.as_view(), name="change_password"),
path('refund-tournament/<str:team_registration_id>/', views.process_refund, name='process-refund'),
path('validate-stripe-account/', views.validate_stripe_account, name='validate_stripe_account'),
path('xls-to-csv/', views.xls_to_csv, name='xls-to-csv'),
path('config/tournament/', views.get_tournament_config, name='tournament-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'),
path('token-auth/', views.CustomAuthToken.as_view()),
path('api-token-logout/', views.Logout.as_view()),
# authentication
path("change-password/", ChangePasswordView.as_view(), name="change_password"),
path('token-auth/', CustomAuthToken.as_view()),
path('api-token-logout/', Logout.as_view()),
# forgotten password
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-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,78 +1,50 @@
from pandas.io.feather_format import pd
from tournaments.models.draw_log import DrawLog
from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, ChangePasswordSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, LiveMatchSerializer, PurchaseSerializer, UserUpdateSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer
from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, UnregisteredTeam, UnregisteredPlayer
from rest_framework import viewsets, permissions
from rest_framework.authtoken.models import Token
from pandas.core.groupby import base
from rest_framework import viewsets
from rest_framework.response import Response
from rest_framework.decorators import api_view
from rest_framework.decorators import api_view, permission_classes
from rest_framework import status
from rest_framework.generics import UpdateAPIView
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
from django.http import Http404
from .authentication import HasAPIKey
from django.contrib.auth import authenticate
from django.conf import settings
from django.http import Http404, HttpResponse, JsonResponse
from django.db.models import Q
from django.core.exceptions import ObjectDoesNotExist
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from django.shortcuts import get_object_or_404
from .permissions import IsClubOwner
from .utils import is_valid_email, check_version_smaller_than_1_1_12
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from shared.discord import send_discord_log_message
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.services.email_service import TournamentEmailService
@method_decorator(csrf_exempt, name='dispatch')
class CustomAuthToken(APIView):
permission_classes = []
from biz.models import Activity, Prospect, Entity
def post(self, request, *args, **kwargs):
username = request.data.get('username')
password = request.data.get('password')
device_id = request.data.get('device_id')
user = authenticate(username=username, password=password)
if user is None and is_valid_email(username) == True:
true_username = self.get_username_from_email(username)
user = authenticate(username=true_username, password=password)
if user is not None:
if user.device_id is None or user.device_id == device_id or user.username == 'apple-test':
user.device_id = device_id
user.save()
from rest_framework import viewsets
from rest_framework.response import Response
from rest_framework.decorators import api_view
from rest_framework import status
from rest_framework.exceptions import MethodNotAllowed
token, created = Token.objects.get_or_create(user=user)
return Response({'token': token.key})
else:
return Response({'error': 'Vous ne pouvez pour l\'instant vous connecter sur plusieurs appareils en même temps. Veuillez vous déconnecter du précédent appareil. Autrement, veuillez contacter le support.'}, status=status.HTTP_403_FORBIDDEN)
else:
return Response({'error': 'L\'utilisateur et le mot de passe de correspondent pas'}, status=status.HTTP_401_UNAUTHORIZED)
from django.http import Http404
from django.db.models import Q
def get_username_from_email(self, email):
try:
user = CustomUser.objects.get(email=email)
return user.username
except ObjectDoesNotExist:
return None
from .permissions import IsClubOwner
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
class Logout(APIView):
permission_classes = (IsAuthenticated,)
from tournaments.services.payment_service import PaymentService
from tournaments.utils.extensions import create_random_filename
def post(self, request, *args, **kwargs):
# request.user.auth_token.delete()
import stripe
import json
import pandas as pd
device_id = request.data.get('device_id')
if request.user.device_id == device_id:
request.user.device_id = None
request.user.save()
import os
import logging
from datetime import datetime
return Response(status=status.HTTP_200_OK)
logger = logging.getLogger(__name__)
@api_view(['GET'])
def user_by_token(request):
@ -88,7 +60,7 @@ class SoftDeleteViewSet(viewsets.ModelViewSet):
class UserViewSet(SoftDeleteViewSet):
queryset = CustomUser.objects.all()
serializer_class = UserUpdateSerializer
serializer_class = CustomUserSerializer
permission_classes = [] # Users are public whereas the other requests are only for logged users
def get_serializer_class(self):
@ -105,6 +77,32 @@ class ClubViewSet(SoftDeleteViewSet):
def perform_create(self, serializer):
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):
queryset = Tournament.objects.all()
serializer_class = TournamentSerializer
@ -112,7 +110,13 @@ class TournamentViewSet(SoftDeleteViewSet):
def get_queryset(self):
if self.request.user.is_anonymous:
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):
serializer.save()
@ -151,20 +155,6 @@ class PurchaseViewSet(SoftDeleteViewSet):
def delete(self, request, pk):
raise MethodNotAllowed('DELETE')
class ChangePasswordView(UpdateAPIView):
serializer_class = ChangePasswordSerializer
def update(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
# if using drf authtoken, create a new token
if hasattr(user, 'auth_token'):
user.auth_token.delete()
token, created = Token.objects.get_or_create(user=user)
# return new token
return Response({'token': token.key}, status=status.HTTP_200_OK)
class EventViewSet(SoftDeleteViewSet):
queryset = Event.objects.all()
serializer_class = EventSerializer
@ -172,14 +162,17 @@ class EventViewSet(SoftDeleteViewSet):
def get_queryset(self):
if self.request.user.is_anonymous:
return []
return self.queryset.filter(creator=self.request.user)
# return self.queryset.filter(creator=self.request.user)
return self.queryset.filter(
Q(creator=self.request.user)
)
class RoundViewSet(SoftDeleteViewSet):
queryset = Round.objects.all()
serializer_class = RoundSerializer
def get_queryset(self):
tournament_id = self.request.query_params.get('tournament')
tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament')
if tournament_id:
return self.queryset.filter(tournament=tournament_id)
if self.request.user:
@ -191,7 +184,7 @@ class GroupStageViewSet(SoftDeleteViewSet):
serializer_class = GroupStageSerializer
def get_queryset(self):
tournament_id = self.request.query_params.get('tournament')
tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament')
if tournament_id:
return self.queryset.filter(tournament=tournament_id)
if self.request.user:
@ -203,7 +196,7 @@ class MatchViewSet(SoftDeleteViewSet):
serializer_class = MatchSerializer
def get_queryset(self):
tournament_id = self.request.query_params.get('tournament')
tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament')
if tournament_id:
return self.queryset.filter(Q(group_stage__tournament=tournament_id) | Q(round__tournament=tournament_id))
if self.request.user:
@ -215,7 +208,7 @@ class TeamScoreViewSet(SoftDeleteViewSet):
serializer_class = TeamScoreSerializer
def get_queryset(self):
tournament_id = self.request.query_params.get('tournament')
tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament')
if tournament_id:
q = Q(team_registration__tournament=tournament_id) | Q(match__group_stage__tournament=tournament_id) | Q(match__round__tournament=tournament_id)
return self.queryset.filter(q)
@ -228,7 +221,7 @@ class TeamRegistrationViewSet(SoftDeleteViewSet):
serializer_class = TeamRegistrationSerializer
def get_queryset(self):
tournament_id = self.request.query_params.get('tournament')
tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament')
if tournament_id:
return self.queryset.filter(tournament=tournament_id)
if self.request.user:
@ -240,7 +233,7 @@ class PlayerRegistrationViewSet(SoftDeleteViewSet):
serializer_class = PlayerRegistrationSerializer
def get_queryset(self):
tournament_id = self.request.query_params.get('tournament')
tournament_id = self.request.query_params.get('store_id') or self.request.query_params.get('tournament')
if tournament_id:
return self.queryset.filter(team_registration__tournament=tournament_id)
if self.request.user:
@ -256,10 +249,13 @@ class DateIntervalViewSet(SoftDeleteViewSet):
serializer_class = DateIntervalSerializer
def get_queryset(self):
if self.request.user:
return self.queryset.filter(event__creator=self.request.user)
if self.request.user.is_anonymous:
return []
return self.queryset.filter(
Q(event__creator=self.request.user)
)
class FailedApiCallViewSet(viewsets.ModelViewSet):
queryset = FailedApiCall.objects.all()
serializer_class = FailedApiCallSerializer
@ -316,7 +312,7 @@ class DrawLogViewSet(SoftDeleteViewSet):
serializer_class = DrawLogSerializer
def get_queryset(self):
tournament_id = self.request.query_params.get('tournament')
tournament_id = self.request.query_params.get('tournament') or self.request.query_params.get('tournament')
if tournament_id:
return self.queryset.filter(tournament=tournament_id)
if self.request.user:
@ -346,3 +342,635 @@ class UnregisteredPlayerViewSet(SoftDeleteViewSet):
if self.request.user:
return self.queryset.filter(unregistered_team__tournament__event__creator=self.request.user)
return []
class SupervisorViewSet(viewsets.ModelViewSet):
queryset = CustomUser.objects.all()
serializer_class = ShortUserSerializer
permission_classes = [] # Users are public whereas the other requests are only for logged users
def get_queryset(self):
return self.request.user.supervisors
class ImageViewSet(viewsets.ModelViewSet):
"""
Viewset for handling event image uploads and retrieval.
This allows umpires/organizers to upload images for events from the iOS app,
which can then be displayed on the event pages.
"""
serializer_class = ImageSerializer
queryset = Image.objects.all()
def get_queryset(self):
queryset = Image.objects.all()
# Filter by event
event_id = self.request.query_params.get('event_id')
image_type = self.request.query_params.get('image_type')
if event_id:
queryset = queryset.filter(event_id=event_id)
if image_type:
queryset = queryset.filter(image_type=image_type)
return queryset
def perform_create(self, serializer):
serializer.save()
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def process_refund(request, team_registration_id):
try:
# Verify the user is the tournament umpire
team_registration = get_object_or_404(TeamRegistration, id=team_registration_id)
if request.user != team_registration.tournament.event.creator:
return Response({
'success': False,
'message': "Vous n'êtes pas autorisé à effectuer ce remboursement"
}, status=403)
payment_service = PaymentService(request)
players_serializer = PlayerRegistrationSerializer(team_registration.players_sorted_by_rank, many=True)
success, message, refund = payment_service.process_refund(team_registration_id, force_refund=True)
return Response({
'success': success,
'message': message,
'players': players_serializer.data
})
except Exception as e:
return Response({
'success': False,
'message': str(e)
}, status=400)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def xls_to_csv(request):
# Check if the request has a file
if 'file' in request.FILES:
uploaded_file = request.FILES['file']
# Save the uploaded file
directory = 'tmp/csv/'
file_path = os.path.join(directory, uploaded_file.name)
file_name = default_storage.save(file_path, ContentFile(uploaded_file.read()))
logger.info(f'file saved at {file_name}')
full_path = default_storage.path(file_name)
logger.info(f'full_path = {full_path}')
# Check available sheets and look for 'inscriptions'
xls = pd.ExcelFile(full_path)
sheet_names = xls.sheet_names
# Determine which sheet to use
target_sheet = 0 # Default to first sheet
if 'inscriptions' in [name.lower() for name in sheet_names]:
for i, name in enumerate(sheet_names):
if name.lower() == 'inscriptions':
target_sheet = i # or use the name directly: target_sheet = name
break
# Convert to csv and save
data_xls = pd.read_excel(full_path, sheet_name=target_sheet, index_col=None)
csv_file_name = create_random_filename('players', 'csv')
output_path = os.path.join(directory, csv_file_name)
full_output_path = default_storage.path(output_path)
data_xls.to_csv(full_output_path, sep=';', index=False, encoding='utf-8')
# Send the processed file back
with default_storage.open(full_output_path, 'rb') as file:
response = HttpResponse(file.read(), content_type='application/octet-stream')
response['Content-Disposition'] = f'attachment; filename="players.csv"'
# Clean up: delete both files
default_storage.delete(file_path)
default_storage.delete(output_path)
return response
else:
return HttpResponse("No file was uploaded", status=400)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_tournament_config(request):
"""Return tournament-related configuration settings"""
config = settings.TOURNAMENT_SETTINGS
return Response({
'time_proximity_rules': config['TIME_PROXIMITY_RULES'],
'waiting_list_rules': config['WAITING_LIST_RULES'],
'business_rules': config['BUSINESS_RULES'],
'minimum_response_time': config['MINIMUM_RESPONSE_TIME']
})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_payment_config(request):
"""Return payment-related configuration settings"""
return Response({
'stripe_fee': getattr(settings, 'STRIPE_FEE', 0)
})
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def create_stripe_connect_account(request):
stripe.api_key = settings.STRIPE_SECRET_KEY
user = request.user
try:
# Create a new Standard account
account = stripe.Account.create(
type='standard',
metadata={
'padelclub_email': user.email,
'platform': 'padelclub'
}
)
return Response({
'success': True,
'account_id': account.id,
})
except Exception as e:
return Response({
'success': False,
'error': str(e)
}, status=400)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def create_stripe_account_link(request):
"""
Create an account link for a Stripe account.
Uses HTTPS URLs only - no custom URL schemes.
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
# Parse request data
data = json.loads(request.body)
account_id = data.get('account_id')
if not account_id:
return Response({
'success': False,
'error': 'No Stripe account ID found'
}, status=400)
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()}"
# print("create_stripe_account_link", base_path)
refresh_url = f"{base_path}/stripe-refresh-account-link/"
return_url = f"{base_path}/stripe-onboarding-complete/"
# Generate the account link URL
account_link = stripe.AccountLink.create(
account=account_id,
refresh_url=refresh_url,
return_url=return_url,
type='account_onboarding',
)
return Response({
'success': True,
'url': account_link.url,
'account_id': account_id
})
except Exception as e:
return Response({
'success': False,
'error': str(e)
}, status=400)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def validate_stripe_account(request):
"""
Validate a Stripe account for a tournament.
Returns validation status and onboarding URL if needed.
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
# Parse the request body
data = json.loads(request.body)
account_id = data.get('account_id')
if not account_id:
return Response({
'valid': False,
'error': 'No account ID found to validate',
'needs_onboarding': True
}, status=200)
try:
# Validate the account with Stripe
account = stripe.Account.retrieve(account_id)
# Check account capabilities
charges_enabled = account.get('charges_enabled', False)
payouts_enabled = account.get('payouts_enabled', False)
details_submitted = account.get('details_submitted', False)
# Determine if the account is valid and ready
is_valid = account.id is not None
can_process_payments = charges_enabled and payouts_enabled
onboarding_complete = details_submitted
needs_onboarding = not (can_process_payments and onboarding_complete)
return Response({
'valid': is_valid,
'can_process_payments': can_process_payments,
'onboarding_complete': onboarding_complete,
'needs_onboarding': needs_onboarding,
'account': {
'id': account.id,
'charges_enabled': charges_enabled,
'payouts_enabled': payouts_enabled,
'details_submitted': details_submitted
}
})
except stripe.error.PermissionError:
# Account doesn't exist or isn't connected to your platform
return Response({
'valid': False,
'error': 'This Stripe account is not connected to your platform or does not exist.',
'needs_onboarding': True,
}, status=200)
except stripe.error.InvalidRequestError:
return Response({
'valid': False,
'error': 'Invalid account ID format',
'needs_onboarding': True,
}, status=200)
except Exception as e:
return Response({
'valid': False,
'error': f'Unexpected error: {str(e)}',
'needs_onboarding': True,
}, 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,15 @@
from django.contrib import admin
from .models import Device, LoginLog
class DeviceAdmin(admin.ModelAdmin):
list_display = ['user', 'device_model', 'last_login', 'id']
readonly_fields = ('last_login',)
ordering = ['-last_login']
class LoginLogAdmin(admin.ModelAdmin):
list_display = ['user', 'device', 'date']
ordering = ['-date']
admin.site.register(Device, DeviceAdmin)
admin.site.register(LoginLog, LoginLogAdmin)

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AuthenticationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'authentication'

@ -0,0 +1,36 @@
# Generated by Django 5.1 on 2025-03-20 14:49
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Device',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('last_login', models.DateTimeField(auto_now=True)),
('model_name', models.CharField(blank=True, max_length=100, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devices', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='LoginLog',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('date', models.DateTimeField(auto_now=True)),
('device', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='login_logs', to='authentication.device')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_logs', to=settings.AUTH_USER_MODEL)),
],
),
]

@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2025-03-20 15:42
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('authentication', '0001_initial'),
]
operations = [
migrations.RenameField(
model_name='device',
old_name='model_name',
new_name='device_model',
),
]

@ -0,0 +1,2 @@
from .device import Device
from .login_log import LoginLog

@ -0,0 +1,12 @@
from django.db import models
import uuid
from django.conf import settings
class Device(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='devices')
last_login = models.DateTimeField(auto_now=True)
device_model = models.CharField(max_length=100, blank=True, null=True)
def __str__(self):
return f"{self.user.username} : {self.device_model}"

@ -0,0 +1,13 @@
from django.db import models
import uuid
from django.conf import settings
from . import Device
class LoginLog(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='login_logs')
device = models.ForeignKey(Device, on_delete=models.SET_NULL, related_name='login_logs', null=True)
date = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.id} > {self.user.username}"

@ -0,0 +1,29 @@
from django.contrib.auth import password_validation
from rest_framework import serializers
class ChangePasswordSerializer(serializers.Serializer):
old_password = serializers.CharField(max_length=128, write_only=True, required=True)
new_password1 = serializers.CharField(max_length=128, write_only=True, required=True)
new_password2 = serializers.CharField(max_length=128, write_only=True, required=True)
def validate_old_password(self, value):
user = self.context['request'].user
if not user.check_password(value):
raise serializers.ValidationError(
_('Your old password was entered incorrectly. Please enter it again.')
)
return value
def validate(self, data):
if data['new_password1'] != data['new_password2']:
raise serializers.ValidationError({'new_password2': _("The two password fields didn't match.")})
password_validation.validate_password(data['new_password1'], self.context['request'].user)
return data
def save(self, **kwargs):
password = self.validated_data['new_password1']
user = self.context['request'].user
user.set_password(password)
user.save()
return user

@ -0,0 +1,5 @@
import re
def is_valid_email(email):
email_regex = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$'
return re.match(email_regex, email) is not None

@ -0,0 +1,116 @@
from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth import authenticate
from django.utils.decorators import method_decorator
from django.core.exceptions import ObjectDoesNotExist
from django.conf import settings
from django.contrib.auth import get_user_model
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework.authtoken.models import Token
from rest_framework import status
from rest_framework.generics import UpdateAPIView
from .utils import is_valid_email
from .models import Device, LoginLog
from .serializers import ChangePasswordSerializer
import logging
CustomUser=get_user_model()
logger = logging.getLogger(__name__)
@method_decorator(csrf_exempt, name='dispatch')
class CustomAuthToken(APIView):
permission_classes = []
def post(self, request, *args, **kwargs):
username = request.data.get('username')
password = request.data.get('password')
device_id = request.data.get('device_id')
# logger.info(f'Login attempt from {username}')
user = authenticate(username=username, password=password)
if user is None and is_valid_email(username) == True:
true_username = self.get_username_from_email(username)
user = authenticate(username=true_username, password=password)
if user:
user.device_id = device_id
user.save()
device_model = request.data.get('device_model')
device = self.create_or_update_device(user, device_id, device_model)
self.create_login_log(user, device)
token, created = Token.objects.get_or_create(user=user)
return Response({'token': token.key})
# if user.device_id is None or user.device_id == device_id or user.username == 'apple-test':
# user.device_id = device_id
# user.save()
# device_model = request.data.get('device_model')
# device = self.create_or_update_device(user, device_id, device_model)
# self.create_login_log(user, device)
# token, created = Token.objects.get_or_create(user=user)
# return Response({'token': token.key})
# else:
# return Response({'error': 'Vous ne pouvez pour l\'instant vous connecter sur plusieurs appareils en même temps. Veuillez vous déconnecter du précédent appareil. Autrement, veuillez contacter le support.'}, status=status.HTTP_403_FORBIDDEN)
else:
return Response({'error': 'L\'utilisateur et le mot de passe de correspondent pas'}, status=status.HTTP_401_UNAUTHORIZED)
def create_or_update_device(self, user, device_id, device_model):
obj, created = Device.objects.update_or_create(
id=device_id,
device_model=device_model,
defaults={
'user': user
}
)
return obj
def create_login_log(self, user, device):
LoginLog.objects.create(user=user, device=device)
def get_username_from_email(self, email):
try:
user = CustomUser.objects.get(email=email)
return user.username
except ObjectDoesNotExist:
return None
class Logout(APIView):
permission_classes = (IsAuthenticated,)
def post(self, request, *args, **kwargs):
# request.user.auth_token.delete()
device_id = request.data.get('device_id')
if request.user.device_id == device_id:
request.user.device_id = None
request.user.save()
Device.objects.filter(id=device_id).delete()
return Response(status=status.HTTP_200_OK)
class ChangePasswordView(UpdateAPIView):
serializer_class = ChangePasswordSerializer
def update(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
# if using drf authtoken, create a new token
if hasattr(user, 'auth_token'):
user.auth_token.delete()
token, created = Token.objects.get_or_create(user=user)
# return new token
return Response({'token': token.key}, status=status.HTTP_200_OK)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,58 @@
{% extends "biz/base.html" %}
{% block content %}
<div class="container padding-bottom">
<div class="grid-x padding-bottom">
<div class="cell medium-6 large-6 padding10 bubble">
<h1 class="title">Add New Prospect</h1>
<form method="post">
{% csrf_token %}
<div class="form-group">
<label for="entity_name">Entité (nom de club...):</label>
<input type="text" name="entity_name" id="entity_name" />
</div>
<div class="form-group">
<label for="first_name">Prénom:</label>
<input type="text" name="first_name" id="first_name" />
</div>
<div class="form-group">
<label for="last_name">Nom:</label>
<input type="text" name="last_name" id="last_name" />
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" name="email" id="email" required />
</div>
<div class="form-group">
<label for="phone">Téléphone:</label>
<input type="text" name="phone" id="phone" />
</div>
<div class="form-group">
<label for="address">Adresse:</label>
<input type="text" name="address" id="address" />
</div>
<div class="form-group">
<label for="zip_code">Code postal:</label>
<input type="text" name="zip_code" id="zip_code" />
</div>
<div class="form-group">
<label for="city">Ville:</label>
<input type="text" name="city" id="city" />
</div>
<button type="submit" class="small-button margin-v20">
Save Prospect
</button>
</form>
</div>
</div>
</div>
{% endblock %}

@ -37,12 +37,14 @@
</script>
<!-- End Matomo Code -->
{% block extra_js %}{% endblock %}
</head>
<body class="wrapper">
<header>
<div class="grid-x">
<div class="medium-6 large-9 cell topblock my-block ">
<div class="medium-6 large-9 cell topblock padding10 ">
<a href="{% url 'index' %}">
<img
src="{% static 'tournaments/images/PadelClub_logo_512.png' %}"

@ -0,0 +1,34 @@
{% extends "biz/base.html" %}
{% block content %}
<div class="container mt-4">
<h2>Import Prospects from CSV</h2>
<div class="card">
<div class="card-body">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-3">
{{ form.as_p }}
</div>
<div class="alert alert-info">
<h5>CSV Format Requirements:</h5>
<p>The CSV file should contain the following columns in order:</p>
<ul>
<li>Column 1: Club Code</li>
<li>Column 2: Last Name</li>
<li>Column 3: First Name</li>
<li>Column 4: Email</li>
<li>Column 9: ZIP Code</li>
<li>Column 10: City</li>
</ul>
</div>
<button type="submit" class="btn btn-primary">Import CSV</button>
</form>
</div>
</div>
</div>
{% endblock %}

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

@ -7,9 +7,9 @@
<div class="right-column">
<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' %}
<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 %} -->
</div>
</div>

@ -1,22 +1,29 @@
{% extends "crm/base.html" %}
{% load crm_tags %}
{% extends "biz/base.html" %}
{% load biz_tags %}
{% block content %}
{% if request.user|is_crm_manager %}
{% if request.user|is_biz_manager %}
<div class="d-flex">
<a href="{% url 'crm:add_event' %}" class="small-button margin-v20">
Add Event
<a href="{% url 'biz:prospect-list' %}" class="small-button margin-v20">
Prospects
</a>
<a href="{% url 'biz:add-event' %}" class="small-button margin-v20">
Ajouter un évènement
</a>
<a href="{% url 'biz:add-prospect' %}" class="small-button margin-v20 left-margin">
Ajouter un prospect
</a>
<a href="{% url 'crm:add_prospect' %}" class="small-button margin-v20 left-margin">
Add Prospect
<a href="{% url 'biz:csv-import' %}" class="small-button margin-v20 left-margin">
Import
</a>
</div>
<div class="container grid-x padding-bottom">
<div class="cell medium-6 large-6 my-block bubble">
<div class="cell medium-6 large-6 padding10 bubble">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="title">Completed Events</h1>
@ -24,7 +31,7 @@
<div class="list-group">
{% for event in completed_events %}
{% include "crm/event_row.html" with event=event %}
{% include "biz/event_row.html" with event=event %}
{% empty %}
<div class="list-group-item">No completed events.</div>
{% endfor %}
@ -32,7 +39,7 @@
</div>
<div class="cell medium-6 large-6 my-block bubble">
<div class="cell medium-6 large-6 padding10 bubble">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="title">Planned Events</h1>
@ -40,7 +47,7 @@
<div class="list-group">
{% for event in planned_events %}
{% include "crm/event_row.html" with event=event %}
{% include "biz/event_row.html" with event=event %}
{% empty %}
<div class="list-group-item">No planned events.</div>
{% endfor %}

@ -0,0 +1,17 @@
{% extends "biz/base.html" %}
{% block head_title %}{{ first_title }}{% endblock %}
{% block first_title %}{{ first_title }}{% endblock %}
{% block second_title %}{{ second_title }}{% endblock %}
{% block content %}
<div class="container padding-bottom bubble"><form method="post">
{% csrf_token %}
{{ form.as_p }}
<button class="small-button" type="submit">
{% if is_edit %}Update{% else %}Add{% endif %} Prospect
</button>
</form>
{% endblock %}

@ -0,0 +1,81 @@
{% extends "biz/base.html" %}
{% load static %}
{% block content %}
<div class="container bubble">
<h2>Prospects</h2>
<div class="">
<div class="">
<form method="get" class="filter-form">
{% for field in filter.form %}
<div class="filter-group">
<label class="filter-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
</div>
{% endfor %}
<div class="filter-buttons">
<button type="submit" class="btn btn-primary">Filter</button>
<a href="{% url 'biz:prospect-list' %}" class="btn btn-secondary">Clear</a>
</div>
</form>
</div>
</div>
<!-- <div class="mb-3">
<a href="{% url 'biz:csv-import' %}" class="btn btn-success">Import CSV</a>
<a href="{% url 'biz:send-bulk-email' %}" class="btn btn-primary">Send Email</a>
</div> -->
<span>{{ filter.qs|length }} résultats</span>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th><input type="checkbox" id="select-all"></th>
<th>Entité</th>
<th>Prénom</th>
<th>Nom</th>
<th>Email</th>
<th>Ville</th>
<th>Statut</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in filter.qs %}
<tr>
<td><input type="checkbox" name="selected_prospects" value="{{ prospect.id }}"></td>
<td>{{ prospect.entity_name }}</td>
<td>{{ prospect.first_name }}</td>
<td>{{ prospect.last_name }}</td>
<td><a href="mailto:{{ prospect.email }}">{{ prospect.email }}</a></td>
<td>{{ prospect.city }} ({{ prospect.zip_code }})</td>
<td>
{% for status in prospect.prospectstatus_set.all %}
<span class="badge bg-primary">{{ status.status.name }}</span>
{% endfor %}
</td>
<td>
<a href="{% url 'biz:edit-prospect' prospect.id %}">
<button class="btn btn-sm btn-secondary">Edit</button>
</a>
<a href="{% url 'biz:add-event-for-prospect' prospect.id %}">
<button class="btn btn-sm btn-secondary">+ Event</button>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'biz/js/prospects.js' %}"></script>
{% endblock %}

@ -1,4 +1,4 @@
{% extends "crm/base.html" %}
{% extends "biz/base.html" %}
{% block content %}
<div class="container mt-4">
@ -41,7 +41,7 @@
</div>
<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>
</div>
{% endblock %}

@ -0,0 +1,7 @@
from django import template
register = template.Library()
@register.filter(name='is_biz_manager')
def is_biz_manager(user):
return user.groups.filter(name='biz Manager').exists()

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

@ -1,15 +1,17 @@
from django.urls import path
from . import views
app_name = 'crm'
app_name = 'biz'
urlpatterns = [
path('', views.EventListView.as_view(), name='planned_events'),path('', views.EventListView.as_view(), name='planned-events'),
path('events/add/', views.EventCreateView.as_view(), name='add_event'),
path('', views.EventListView.as_view(), name='planned_events'),path('', views.EventListView.as_view(), name='events'),
path('events/add/', views.EventCreateView.as_view(), name='add-event'),
path('events/add/<int:prospect_id>/', views.EventCreateView.as_view(), name='add-event-for-prospect'),
path('events/<int:pk>/edit/', views.EditEventView.as_view(), name='edit_event'),
path('events/<int:pk>/start/', views.StartEventView.as_view(), name='start_event'),
path('prospects/', views.ProspectListView.as_view(), name='prospect-list'),
path('add-prospect/', views.add_prospect, name='add_prospect'),
path('prospects/import/', views.CSVImportView.as_view(), name='prospect-import'),
path('prospect/add/', views.prospect_form, name='add-prospect'),
path('prospect/<int:pk>/edit/', views.prospect_form, name='edit-prospect'),
path('prospects/import/', views.CSVImportView.as_view(), name='csv-import'),
path('email/send/', views.SendBulkEmailView.as_view(), name='send-bulk-email'),
]

@ -0,0 +1,284 @@
# views.py
from django.views.generic import FormView, ListView, DetailView, CreateView, UpdateView
from django.views.generic.edit import FormView, BaseUpdateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.decorators import permission_required
from django.contrib import messages
from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse_lazy
from django.http import HttpResponse, HttpResponseRedirect
from django.views import View
from django.utils import timezone
from django.contrib.sites.shortcuts import get_current_site
from django.template.loader import render_to_string
from django.core.mail import send_mail
from django.conf import settings
from django.db import IntegrityError
from .models import Event, Prospect, ActivityType
from .filters import ProspectFilter
from .forms import CSVImportForm
from .mixins import bizAccessMixin
import csv
from io import TextIOWrapper
from datetime import datetime
# @permission_required('biz.view_biz', raise_exception=True)
# def prospect_form(request, pk=None):
# # Get the prospect instance if pk is provided (edit mode)
# prospect = get_object_or_404(Prospect, pk=pk) if pk else None
# if request.method == 'POST':
# form = ProspectForm(request.POST, instance=prospect)
# if form.is_valid():
# prospect = form.save(commit=False)
# if not pk: # New prospect
# prospect.created_by = request.user
# prospect.modified_by = request.user
# prospect.save()
# action = 'updated' if pk else 'added'
# messages.success(request,
# f'Prospect {prospect.entity_name} has been {action} successfully!')
# return redirect('biz:events')
# else:
# form = ProspectForm(instance=prospect)
# context = {
# 'form': form,
# 'is_edit': prospect is not None,
# 'first_title': prospect.entity_name if prospect else 'Add Prospect',
# 'second_title': prospect.full_name() if prospect else None
# }
# return render(request, 'biz/prospect_form.html', context)
# # @permission_required('biz.view_biz', raise_exception=True)
# # def add_prospect(request):
# # if request.method == 'POST':
# # entity_name = request.POST.get('entity_name')
# # first_name = request.POST.get('first_name')
# # last_name = request.POST.get('last_name')
# # email = request.POST.get('email')
# # phone = request.POST.get('phone')
# # address = request.POST.get('address')
# # zip_code = request.POST.get('zip_code')
# # city = request.POST.get('city')
# # # region = request.POST.get('region')
# # try:
# # prospect = Prospect.objects.create(
# # entity_name=entity_name,
# # first_name=first_name,
# # last_name=last_name,
# # email=email,
# # phone=phone,
# # address=address,
# # zip_code=zip_code,
# # city=city,
# # # region=region,
# # created_by=request.user,
# # modified_by=request.user
# # )
# # messages.success(request, f'Prospect {name} has been added successfully!')
# # return redirect('biz:events') # or wherever you want to redirect after success
# # except Exception as e:
# # messages.error(request, f'Error adding prospect: {str(e)}')
# # return render(request, 'biz/add_prospect.html')
# class EventCreateView(bizAccessMixin, CreateView):
# model = Event
# form_class = EventForm
# template_name = 'biz/event_form.html'
# success_url = reverse_lazy('biz:planned_events')
# def get_initial(self):
# initial = super().get_initial()
# prospect_id = self.kwargs.get('prospect_id')
# if prospect_id:
# initial['prospects'] = [prospect_id]
# return initial
# def form_valid(self, form):
# form.instance.created_by = self.request.user
# form.instance.modified_by = self.request.user
# return super().form_valid(form)
# class EditEventView(bizAccessMixin, UpdateView):
# model = Event
# form_class = EventForm
# template_name = 'biz/event_form.html'
# success_url = reverse_lazy('biz:planned_events')
# def form_valid(self, form):
# form.instance.modified_by = self.request.user
# response = super().form_valid(form)
# messages.success(self.request, 'Event updated successfully!')
# return response
# class StartEventView(bizAccessMixin, BaseUpdateView):
# model = Event
# http_method_names = ['post', 'get']
# def get(self, request, *args, **kwargs):
# return self.post(request, *args, **kwargs)
# def post(self, request, *args, **kwargs):
# event = get_object_or_404(Event, pk=kwargs['pk'], status='PLANNED')
# event.status = 'ACTIVE'
# event.save()
# if event.type == 'MAIL':
# return HttpResponseRedirect(
# reverse_lazy('biz:setup_email_campaign', kwargs={'event_id': event.id})
# )
# elif event.type == 'SMS':
# return HttpResponseRedirect(
# reverse_lazy('biz:setup_sms_campaign', kwargs={'event_id': event.id})
# )
# elif event.type == 'PRESS':
# return HttpResponseRedirect(
# reverse_lazy('biz:setup_press_release', kwargs={'event_id': event.id})
# )
# messages.success(request, 'Event started successfully!')
# return HttpResponseRedirect(reverse_lazy('biz:planned_events'))
# class EventListView(bizAccessMixin, ListView):
# model = Event
# template_name = 'biz/events.html'
# context_object_name = 'events' # We won't use this since we're providing custom context
# def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context['planned_events'] = Event.objects.filter(
# status='PLANNED'
# ).order_by('date')
# context['completed_events'] = Event.objects.filter(
# status='COMPLETED'
# ).order_by('-date')
# return context
# class ProspectListView(bizAccessMixin, ListView):
# model = Prospect
# template_name = 'biz/prospect_list.html'
# context_object_name = 'prospects'
# filterset_class = ProspectFilter
# def get_queryset(self):
# return super().get_queryset().prefetch_related('prospectstatus_set__status')
# def get_context_data(self, **kwargs):
# context = super().get_context_data(**kwargs)
# context['filter'] = self.filterset_class(
# self.request.GET,
# queryset=self.get_queryset()
# )
# return context
# class CSVImportView(bizAccessMixin, FormView):
# template_name = 'biz/csv_import.html'
# form_class = CSVImportForm
# success_url = reverse_lazy('prospect-list')
# def form_valid(self, form):
# csv_file = TextIOWrapper(
# form.cleaned_data['csv_file'].file,
# encoding='utf-8-sig' # Handle potential BOM in CSV
# )
# reader = csv.reader(csv_file, delimiter=';') # Using semicolon delimiter
# # Skip header if exists
# next(reader, None)
# created_count = 0
# updated_count = 0
# error_count = 0
# for row in reader:
# try:
# if len(row) < 10: # Ensure we have enough columns
# continue
# # Extract data from correct columns
# entity_name = row[0].strip()
# last_name = row[1].strip()
# first_name = row[2].strip()
# email = row[3].strip()
# phone = row[4].strip()
# zip_code = row[8].strip()
# city = row[9].strip()
# # Try to update existing prospect or create new one
# prospect, created = Prospect.objects.update_or_create(
# email=email, # Use email as unique identifier
# defaults={
# 'entity_name': entity_name,
# 'first_name': first_name,
# 'last_name': last_name,
# 'phone': phone,
# 'zip_code': zip_code,
# 'city': city,
# 'modified_by': self.request.user,
# }
# )
# if created:
# prospect.created_by = self.request.user
# prospect.save()
# created_count += 1
# else:
# updated_count += 1
# except Exception as e:
# error_count += 1
# messages.error(
# self.request,
# f"Error processing row with email {email}: {str(e)}"
# )
# # Add success message
# messages.success(
# self.request,
# f"Import completed: {created_count} created, {updated_count} updated, {error_count} errors"
# )
# return super().form_valid(form)
# class SendBulkEmailView(bizAccessMixin, FormView):
# template_name = 'biz/send_bulk_email.html'
# form_class = BulkEmailForm
# success_url = reverse_lazy('biz:prospect-list')
# def form_valid(self, form):
# prospects = form.cleaned_data['prospects']
# subject = form.cleaned_data['subject']
# content = form.cleaned_data['content']
# # Create Event for this email campaign
# event = Event.objects.create(
# date=datetime.now(),
# type=EventType.MAILING,
# description=f"Bulk email: {subject}",
# status='COMPLETED',
# created_by=self.request.user,
# modified_by=self.request.user
# )
# event.prospects.set(prospects)
# # Send emails
# success_count, error_count = send_bulk_email(
# subject=subject,
# content=content,
# prospects=prospects
# )
# # Show result message
# messages.success(
# self.request,
# f"Sent {success_count} emails successfully. {error_count} failed."
# )
# return super().form_valid(form)

@ -1 +0,0 @@
This is a django customer relationship managemement (CRM) app.

@ -1,96 +0,0 @@
from django.contrib import admin
from django.utils.html import format_html
from .models import (
Prospect,
Status,
ProspectStatus,
Event,
EmailCampaign,
EmailTracker
)
@admin.register(Prospect)
class ProspectAdmin(admin.ModelAdmin):
list_display = ('name', 'email', 'region', 'created_at')
list_filter = ('region', 'created_at')
search_fields = ('name', 'email', 'region')
filter_horizontal = ('users',)
date_hierarchy = 'created_at'
@admin.register(Status)
class StatusAdmin(admin.ModelAdmin):
list_display = ('name', 'created_at')
search_fields = ('name',)
@admin.register(ProspectStatus)
class ProspectStatusAdmin(admin.ModelAdmin):
list_display = ('prospect', 'status', 'created_at')
list_filter = ('status', 'created_at')
search_fields = ('prospect__name', 'prospect__email')
date_hierarchy = 'created_at'
@admin.register(Event)
class EventAdmin(admin.ModelAdmin):
list_display = ('get_event_display', 'type', 'date', 'status', 'created_at')
list_filter = ('type', 'status', 'date')
search_fields = ('description',)
filter_horizontal = ('prospects',)
date_hierarchy = 'date'
def get_event_display(self, obj):
return str(obj)
get_event_display.short_description = 'Event'
@admin.register(EmailCampaign)
class EmailCampaignAdmin(admin.ModelAdmin):
list_display = ('subject', 'event', 'sent_at')
list_filter = ('sent_at',)
search_fields = ('subject', 'content')
date_hierarchy = 'sent_at'
readonly_fields = ('sent_at',)
@admin.register(EmailTracker)
class EmailTrackerAdmin(admin.ModelAdmin):
list_display = (
'campaign',
'prospect',
'tracking_id',
'sent_status',
'opened_status',
'clicked_status'
)
list_filter = ('sent', 'opened', 'clicked')
search_fields = (
'prospect__name',
'prospect__email',
'campaign__subject'
)
readonly_fields = (
'tracking_id', 'sent', 'sent_at',
'opened', 'opened_at',
'clicked', 'clicked_at'
)
date_hierarchy = 'sent_at'
def sent_status(self, obj):
return self._get_status_html(obj.sent, obj.sent_at)
sent_status.short_description = 'Sent'
sent_status.allow_tags = True
def opened_status(self, obj):
return self._get_status_html(obj.opened, obj.opened_at)
opened_status.short_description = 'Opened'
opened_status.allow_tags = True
def clicked_status(self, obj):
return self._get_status_html(obj.clicked, obj.clicked_at)
clicked_status.short_description = 'Clicked'
clicked_status.allow_tags = True
def _get_status_html(self, status, date):
if status:
return format_html(
'<span style="color: green;">✓</span> {}',
date.strftime('%Y-%m-%d %H:%M') if date else ''
)
return format_html('<span style="color: red;">✗</span>')

@ -1,18 +0,0 @@
import django_filters
from .models import Event, Status, Prospect
class ProspectFilter(django_filters.FilterSet):
region = django_filters.CharFilter(lookup_expr='icontains')
events = django_filters.ModelMultipleChoiceFilter(
queryset=Event.objects.all(),
field_name='events',
)
statuses = django_filters.ModelMultipleChoiceFilter(
queryset=Status.objects.all(),
field_name='prospectstatus__status',
)
class Meta:
model = Prospect
fields = ['region', 'events', 'statuses']

@ -1,40 +0,0 @@
from django import forms
from .models import Prospect, Event
import datetime
class SmallTextArea(forms.Textarea):
def __init__(self, *args, **kwargs):
kwargs.setdefault('attrs', {})
kwargs['attrs'].update({
'rows': 2,
'cols': 100,
'style': 'height: 80px; width: 800px;'
})
super().__init__(*args, **kwargs)
class CSVImportForm(forms.Form):
csv_file = forms.FileField()
class BulkEmailForm(forms.Form):
prospects = forms.ModelMultipleChoiceField(
queryset=Prospect.objects.all(),
widget=forms.CheckboxSelectMultiple
)
subject = forms.CharField(max_length=200)
content = forms.CharField(widget=forms.Textarea)
class EventForm(forms.ModelForm):
prospects = forms.ModelMultipleChoiceField(
queryset=Prospect.objects.all(),
widget=forms.SelectMultiple(attrs={'class': 'select2'}),
required=False
)
description = forms.CharField(widget=SmallTextArea)
attachment_text = forms.CharField(widget=SmallTextArea)
class Meta:
model = Event
fields = ['date', 'type', 'description', 'attachment_text', 'prospects', 'status']
widgets = {
'date': forms.DateTimeInput(attrs={'type': 'datetime-local'}),
}

@ -1,94 +0,0 @@
# Generated by Django 4.2.11 on 2024-12-08 15:10
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Prospect',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254, unique=True)),
('name', models.CharField(max_length=200)),
('region', models.CharField(max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Status',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='ProspectStatus',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('prospect', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='crm.prospect')),
('status', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='crm.status')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Event',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateTimeField()),
('type', models.CharField(choices=[('MAIL', 'Mailing List'), ('SMS', 'SMS Campaign'), ('PRESS', 'Press Release')], max_length=10)),
('description', models.TextField()),
('attachment_text', models.TextField(blank=True)),
('status', models.CharField(choices=[('PLANNED', 'Planned'), ('ACTIVE', 'Active'), ('COMPLETED', 'Completed')], max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('prospects', models.ManyToManyField(related_name='events', to='crm.prospect')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='EmailCampaign',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('subject', models.CharField(max_length=200)),
('content', models.TextField()),
('sent_at', models.DateTimeField(blank=True, null=True)),
('event', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='crm.event')),
],
),
migrations.CreateModel(
name='EmailTracker',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tracking_id', models.UUIDField(default=uuid.uuid4, editable=False)),
('sent', models.BooleanField(default=False)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('opened', models.BooleanField(default=False)),
('opened_at', models.DateTimeField(blank=True, null=True)),
('clicked', models.BooleanField(default=False)),
('clicked_at', models.DateTimeField(blank=True, null=True)),
('error_message', models.TextField(blank=True)),
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='crm.emailcampaign')),
('prospect', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='crm.prospect')),
],
options={
'unique_together': {('campaign', 'prospect')},
},
),
]

@ -1,60 +0,0 @@
# Generated by Django 4.2.11 on 2024-12-08 20:58
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('crm', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='event',
options={'ordering': ['-created_at'], 'permissions': [('manage_events', 'Can manage events'), ('view_events', 'Can view events')]},
),
migrations.AlterModelOptions(
name='prospect',
options={'permissions': [('manage_prospects', 'Can manage prospects'), ('view_prospects', 'Can view prospects')]},
),
migrations.AddField(
model_name='event',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_events', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='event',
name='modified_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='event',
name='modified_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_events', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='prospect',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_prospects', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='prospect',
name='modified_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='prospect',
name='modified_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modified_prospects', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='event',
name='date',
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

@ -1,112 +0,0 @@
from django.db import models
from django.contrib.auth import get_user_model
from django.utils import timezone
import uuid
User = get_user_model()
class EventType(models.TextChoices):
MAILING = 'MAIL', 'Mailing List'
SMS = 'SMS', 'SMS Campaign'
PRESS = 'PRESS', 'Press Release'
class Prospect(models.Model):
email = models.EmailField(unique=True)
name = models.CharField(max_length=200)
region = models.CharField(max_length=100)
users = models.ManyToManyField(get_user_model(), blank=True)
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='created_prospects'
)
modified_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='modified_prospects'
)
modified_at = models.DateTimeField(auto_now=True)
class Meta:
permissions = [
("manage_prospects", "Can manage prospects"),
("view_prospects", "Can view prospects"),
]
def __str__(self):
return f"{self.name} ({self.email})"
class Status(models.Model):
name = models.CharField(max_length=100, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class ProspectStatus(models.Model):
prospect = models.ForeignKey(Prospect, on_delete=models.CASCADE)
status = models.ForeignKey(Status, on_delete=models.PROTECT)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
class Event(models.Model):
date = models.DateTimeField(default=timezone.now)
type = models.CharField(max_length=10, choices=EventType.choices)
description = models.TextField()
attachment_text = models.TextField(blank=True)
prospects = models.ManyToManyField(Prospect, related_name='events')
status = models.CharField(max_length=20, choices=[
('PLANNED', 'Planned'),
('ACTIVE', 'Active'),
('COMPLETED', 'Completed'),
])
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='created_events'
)
modified_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='modified_events'
)
modified_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
permissions = [
("manage_events", "Can manage events"),
("view_events", "Can view events"),
]
def __str__(self):
return f"{self.get_type_display()} - {self.date.date()}"
class EmailCampaign(models.Model):
event = models.OneToOneField(Event, on_delete=models.CASCADE)
subject = models.CharField(max_length=200)
content = models.TextField()
sent_at = models.DateTimeField(null=True, blank=True)
class EmailTracker(models.Model):
campaign = models.ForeignKey(EmailCampaign, on_delete=models.CASCADE)
prospect = models.ForeignKey(Prospect, on_delete=models.CASCADE)
tracking_id = models.UUIDField(default=uuid.uuid4, editable=False)
sent = models.BooleanField(default=False)
sent_at = models.DateTimeField(null=True, blank=True)
opened = models.BooleanField(default=False)
opened_at = models.DateTimeField(null=True, blank=True)
clicked = models.BooleanField(default=False)
clicked_at = models.DateTimeField(null=True, blank=True)
error_message = models.TextField(blank=True)
class Meta:
unique_together = ['campaign', 'prospect']

@ -1,10 +0,0 @@
document.addEventListener("DOMContentLoaded", function () {
const selectAll = document.getElementById("select-all");
const prospectCheckboxes = document.getElementsByName("selected_prospects");
selectAll.addEventListener("change", function () {
prospectCheckboxes.forEach((checkbox) => {
checkbox.checked = selectAll.checked;
});
});
});

@ -1,31 +0,0 @@
{% extends "crm/base.html" %} {% block content %}
<div class="container padding-bottom">
<div class="grid-x padding-bottom">
<div class="cell medium-6 large-6 my-block bubble">
<h1 class="title">Add New Prospect</h1>
<form method="post">
{% csrf_token %}
<div class="form-group">
<label for="name">Name:</label>
<input type="text" name="name" id="name" required />
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" name="email" id="email" required />
</div>
<div class="form-group">
<label for="region">Region:</label>
<input type="text" name="region" id="region" required />
</div>
<button type="submit" class="small-button margin-v20">
Save Prospect
</button>
</form>
</div>
</div>
</div>
{% endblock %}

@ -1,57 +0,0 @@
{% extends "crm/base.html" %}
{% block content %}
<div class="container bubble">
<h2>Prospects</h2>
<div class="card mb-4">
<div class="card-body">
<form method="get" class="row g-3">
{{ filter.form }}
<div class="col-12">
<button type="submit" class="btn btn-primary">Filter</button>
<a href="{% url 'crm:prospect-list' %}" class="btn btn-secondary">Clear</a>
</div>
</form>
</div>
</div>
<div class="mb-3">
<a href="{% url 'crm:prospect-import' %}" class="btn btn-success">Import CSV</a>
<a href="{% url 'crm:send-bulk-email' %}" class="btn btn-primary">Send Email</a>
</div>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th><input type="checkbox" id="select-all"></th>
<th>Name</th>
<th>Email</th>
<th>Region</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in prospects %}
<tr>
<td><input type="checkbox" name="selected_prospects" value="{{ prospect.id }}"></td>
<td>{{ prospect.name }}</td>
<td>{{ prospect.email }}</td>
<td>{{ prospect.region }}</td>
<td>
{% for status in prospect.prospectstatus_set.all %}
<span class="badge bg-primary">{{ status.status.name }}</span>
{% endfor %}
</td>
<td>
<button class="btn btn-sm btn-secondary">Edit</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

@ -1,7 +0,0 @@
from django import template
register = template.Library()
@register.filter(name='is_crm_manager')
def is_crm_manager(user):
return user.groups.filter(name='CRM Manager').exists()

@ -1,186 +0,0 @@
# views.py
from django.views.generic import FormView, ListView, DetailView, CreateView, UpdateView
from django.views.generic.edit import FormView, BaseUpdateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.decorators import permission_required
from django.contrib import messages
from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse_lazy
from django.http import HttpResponse, HttpResponseRedirect
from django.views import View
from django.utils import timezone
from django.contrib.sites.shortcuts import get_current_site
from django.template.loader import render_to_string
from django.core.mail import send_mail
from django.conf import settings
from .models import Event, Prospect, EmailTracker, EmailCampaign, EventType
from .filters import ProspectFilter
from .forms import CSVImportForm, BulkEmailForm, EventForm
from .mixins import CRMAccessMixin
import csv
from io import TextIOWrapper
from datetime import datetime
@permission_required('crm.view_crm', raise_exception=True)
def add_prospect(request):
if request.method == 'POST':
name = request.POST.get('name')
email = request.POST.get('email')
region = request.POST.get('region')
try:
prospect = Prospect.objects.create(
name=name,
email=email,
region=region,
created_by=request.user,
modified_by=request.user
)
messages.success(request, f'Prospect {name} has been added successfully!')
return redirect('crm:events') # or wherever you want to redirect after success
except Exception as e:
messages.error(request, f'Error adding prospect: {str(e)}')
return render(request, 'crm/add_prospect.html')
class EventCreateView(CRMAccessMixin, CreateView):
model = Event
form_class = EventForm
template_name = 'crm/event_form.html'
success_url = reverse_lazy('crm:planned_events')
def form_valid(self, form):
form.instance.created_by = self.request.user
form.instance.modified_by = self.request.user
return super().form_valid(form)
class EditEventView(CRMAccessMixin, UpdateView):
model = Event
form_class = EventForm
template_name = 'crm/event_form.html'
success_url = reverse_lazy('crm:planned_events')
def form_valid(self, form):
form.instance.modified_by = self.request.user
response = super().form_valid(form)
messages.success(self.request, 'Event updated successfully!')
return response
class StartEventView(CRMAccessMixin, BaseUpdateView):
model = Event
http_method_names = ['post', 'get']
def get(self, request, *args, **kwargs):
return self.post(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
event = get_object_or_404(Event, pk=kwargs['pk'], status='PLANNED')
event.status = 'ACTIVE'
event.save()
if event.type == 'MAIL':
return HttpResponseRedirect(
reverse_lazy('crm:setup_email_campaign', kwargs={'event_id': event.id})
)
elif event.type == 'SMS':
return HttpResponseRedirect(
reverse_lazy('crm:setup_sms_campaign', kwargs={'event_id': event.id})
)
elif event.type == 'PRESS':
return HttpResponseRedirect(
reverse_lazy('crm:setup_press_release', kwargs={'event_id': event.id})
)
messages.success(request, 'Event started successfully!')
return HttpResponseRedirect(reverse_lazy('crm:planned_events'))
class EventListView(CRMAccessMixin, ListView):
model = Event
template_name = 'crm/events.html'
context_object_name = 'events' # We won't use this since we're providing custom context
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['planned_events'] = Event.objects.filter(
status='PLANNED'
).order_by('date')
context['completed_events'] = Event.objects.filter(
status='COMPLETED'
).order_by('-date')
return context
class ProspectListView(CRMAccessMixin, ListView):
model = Prospect
template_name = 'crm/prospect_list.html'
context_object_name = 'prospects'
filterset_class = ProspectFilter
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['filter'] = self.filterset_class(
self.request.GET,
queryset=self.get_queryset()
)
return context
class CSVImportView(CRMAccessMixin, FormView):
template_name = 'crm/csv_import.html'
form_class = CSVImportForm
success_url = reverse_lazy('prospect-list')
def form_valid(self, form):
csv_file = TextIOWrapper(
form.cleaned_data['csv_file'].file,
encoding='utf-8'
)
reader = csv.DictReader(csv_file)
for row in reader:
Prospect.objects.create(
email=row['email'],
name=row.get('name', ''),
region=row.get('region', ''),
created_by=self.request.user,
modified_by=self.request.user
)
return super().form_valid(form)
class SendBulkEmailView(CRMAccessMixin, FormView):
template_name = 'crm/send_bulk_email.html'
form_class = BulkEmailForm
success_url = reverse_lazy('crm:prospect-list')
def form_valid(self, form):
prospects = form.cleaned_data['prospects']
subject = form.cleaned_data['subject']
content = form.cleaned_data['content']
# Create Event for this email campaign
event = Event.objects.create(
date=datetime.now(),
type=EventType.MAILING,
description=f"Bulk email: {subject}",
status='COMPLETED',
created_by=self.request.user,
modified_by=self.request.user
)
event.prospects.set(prospects)
# Send emails
success_count, error_count = send_bulk_email(
subject=subject,
content=content,
prospects=prospects
)
# Show result message
messages.success(
self.request,
f"Sent {success_count} emails successfully. {error_count} failed."
)
return super().form_valid(form)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

@ -9,8 +9,30 @@ https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/
import os
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'padelclub_backend.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "padelclub_backend.settings")
application = get_asgi_application()
django_asgi_app = get_asgi_application()
from sync.routing import websocket_urlpatterns
application = ProtocolTypeRouter(
{
"http": django_asgi_app,
"websocket": AllowedHostsOriginValidator(
AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
),
}
)
# import os
# from django.core.asgi import get_asgi_application
# os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'padelclub_backend.settings')
# application = get_asgi_application()

@ -31,9 +31,13 @@ ALLOWED_HOSTS = ['*']
# Application definition
INSTALLED_APPS = [
'shop',
'daphne',
'authentication',
'sync',
'tournaments',
# 'crm',
'shop',
'biz',
'api',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@ -44,7 +48,11 @@ INSTALLED_APPS = [
'rest_framework.authtoken',
'dj_rest_auth',
'qr_code',
# 'django_filters',
'channels_redis',
'django_filters',
'background_task',
'rest_framework_api_key',
]
AUTH_USER_MODEL = "tournaments.CustomUser"
@ -81,6 +89,7 @@ TEMPLATES = [
WSGI_APPLICATION = 'padelclub_backend.wsgi.application'
ASGI_APPLICATION = "padelclub_backend.asgi.application"
# Database
# https://docs.djangoproject.com/en/4.1/ref/settings/#databases
@ -95,8 +104,6 @@ DATABASES = {
}
}
# Password validation
# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators
@ -139,6 +146,10 @@ USE_L10N = True
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static/')
# Media files (User uploads)
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media/')
# Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
@ -154,39 +165,82 @@ AUTHENTICATION_BACKENDS = [
]
CSRF_COOKIE_SECURE = True # if using HTTPS
SESSION_COOKIE_SECURE = True # Si vous utilisez HTTPS
STRIPE_MODE = os.environ.get('STRIPE_MODE', 'test') # Default to test mode
# Depending on the mode, use appropriate keys
if STRIPE_MODE == 'test':
STRIPE_PUBLISHABLE_KEY = os.environ.get(
'STRIPE_TEST_PUBLISHABLE_KEY',
'pk_test_51R4LrTPEZkECCx484C2KbmRpcO2ZkZb0NoNi8QJB4X3E5JFu3bvLk4JZQmz9grKbk6O40z3xI8DawHrGyUY0fOT600VEKC9ran'
)
STRIPE_SECRET_KEY = os.environ.get(
'STRIPE_TEST_SECRET_KEY',
'sk_test_51R4LrTPEZkECCx48PkSbEYarhts7J7XNYpS1mJgows5z5dcv38l0G2tImvhXCjzvMgUH9ML0vLMOEPeyUBtYVf5H00Qvz8t3rE'
)
STRIPE_WEBHOOK_SECRET = os.environ.get(
'STRIPE_TEST_WEBHOOK_SECRET',
'whsec_cbaa9c0c7b24041136e063a7d60fb674ec0646b2c4b821512c41a27634d7b1ba'
)
else:
STRIPE_PUBLISHABLE_KEY = os.environ.get('STRIPE_LIVE_PUBLISHABLE_KEY', '')
STRIPE_SECRET_KEY = os.environ.get('STRIPE_LIVE_SECRET_KEY', '')
STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_LIVE_WEBHOOK_SECRET', '')
STRIPE_CURRENCY = 'eur'
# Add managers who should receive internal emails
SHOP_MANAGERS = [
('Razmig Sarkissian', 'razmig@padelclub.app'),
# ('Shop Admin', 'shop-admin@padelclub.app'),
# ('Laurent Morvillier', 'laurent@padelclub.app'),
# ('Xavier Rousset', 'xavier@padelclub.app'),
]
SITE_URL = 'https://padelclub.app'
SHOP_SUPPORT_EMAIL = 'shop-support@padelclub.app'
SESSION_COOKIE_SECURE = True
LOGS_DIR = os.path.join(BASE_DIR, 'logs')
os.makedirs(LOGS_DIR, exist_ok=True)
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
'style': '{',
},
'simple': {
'format': '{levelname} {message}',
'style': '{',
},
},
'handlers': {
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
'file': {
'level': 'DEBUG',
'class': 'logging.FileHandler',
'filename': os.path.join(BASE_DIR, 'django.log'),
'formatter': 'verbose',
},
'rotating_file': {
'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.join(LOGS_DIR, 'django.log'),
'maxBytes': 10 * 1024 * 1024,
'backupCount': 10,
'formatter': 'verbose',
},
'mail_admins': {
'level': 'ERROR',
'class': 'django.utils.log.AdminEmailHandler'
},
},
'loggers': {
'django.request': {
'handlers': ['mail_admins'],
'level': 'ERROR',
'propagate': True,
},
'django': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': True,
},
'tournaments': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': False,
},
'authentication': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': False,
},
'sync': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': False,
},
'api': {
'handlers': ['console', 'file'],
'level': 'INFO',
'propagate': False,
},
},
}
from .settings_local import *
from .settings_app import *
from .settings_local import *

@ -1,43 +1,54 @@
# Rest Framework configuration
REST_FRAMEWORK = {
"DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S.%f%z",
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.BasicAuthentication",
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
]
}
# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
# EMAIL_HOST = 'smtp-xlr.alwaysdata.net'
# EMAIL_PORT = 587
EMAIL_HOST_USER = 'automatic@padelclub.app'
EMAIL_HOST_PASSWORD = 'XLR@Sport@2024'
# EMAIL_USE_TLS = True
DEFAULT_FROM_EMAIL = 'Padel Club <automatic@padelclub.app>'
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'smtp-xlr.alwaysdata.net'
EMAIL_HOST_USER = "automatic@padelclub.app"
EMAIL_HOST_PASSWORD = "XLR@Sport@2024"
DEFAULT_FROM_EMAIL = "Padel Club <automatic@padelclub.app>"
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp-xlr.alwaysdata.net"
EMAIL_PORT = 587
#EMAIL_HOST_USER = 'xlr@alwaysdata.net'
#EMAIL_HOST_PASSWORD = 'XLRSport$2024'
EMAIL_USE_TLS = True
#DEFAULT_FROM_EMAIL = 'Padel Club <xlr@alwaysdata.net>'
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
},
"qr-code": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "qr-code-cache",
"TIMEOUT": 3600,
},
'qr-code': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'qr-code-cache',
'TIMEOUT': 3600
}
QR_CODE_CACHE_ALIAS = "qr-code"
SYNC_APPS = {
"sync": {},
"tournaments": {"exclude": ["Log", "FailedApiCall", "DeviceToken", "Image"]},
# 'biz': {},
}
QR_CODE_CACHE_ALIAS = 'qr-code'
SYNC_MODEL_CHILDREN_SHARING = {
"Match": ["team_scores", "team_registration", "player_registrations"]
}
STRIPE_CURRENCY = "eur"
# Add managers who should receive internal emails
SHOP_MANAGERS = [
("Shop Admin", "shop-admin@padelclub.app"),
# ('Laurent Morvillier', 'laurent@padelclub.app'),
]
SHOP_SITE_ROOT_URL = "https://padelclub.app"
SHOP_SUPPORT_EMAIL = "shop@padelclub.app"

@ -9,6 +9,8 @@ DEBUG = True
ALLOWED_HOSTS = []
CSRF_TRUSTED_ORIGINS = [] # put same than above
SITE_NAME = 'local'
#ADMINS = [('Laurent', 'laurent@padelclub.app'), ('Razmig', 'razmig@padelclub.app')]
@ -17,5 +19,52 @@ DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'ATOMIC_REQUESTS': True,
}
}
# CHANNEL_LAYERS = {
# "default": {
# "BACKEND": "channels.layers.InMemoryChannelLayer"
# }
# }
# CHANNEL_LAYERS = {
# "default": {
# "BACKEND": "channels_redis.core.RedisChannelLayer",
# "CONFIG": {
# "hosts": [("localhost", 8300)],
# },
# },
# }
STRIPE_MODE = 'test'
STRIPE_PUBLISHABLE_KEY = ''
STRIPE_SECRET_KEY = ''
SHOP_STRIPE_WEBHOOK_SECRET = 'whsec_...' # Your existing webhook secret
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
TOURNAMENT_SETTINGS = {
'TIME_PROXIMITY_RULES': {
24: 30, # within 24h → 30 min
48: 60, # within 48h → 60 min
72: 120, # within 72h → 120 min
'default': 240
},
'WAITING_LIST_RULES': {
30: 30, # 30+ teams → 30 min
20: 60, # 20+ teams → 60 min
10: 120, # 10+ teams → 120 min
'default': 240
},
'BUSINESS_RULES': {
'hours': {
'start': 8, # 8:00
'end': 21, # 21:00
}
},
'MINIMUM_RESPONSE_TIME': 30, # requires to be like the BACKGROUND_SCHEDULED_TASK_INTERVAL
}
BACKGROUND_SCHEDULED_TASK_INTERVAL = 30 # minutes
LIVE_TESTING = False

@ -15,17 +15,42 @@ Including another URLconf
"""
from django.contrib import admin
from django.urls import include, path
from django.conf import settings
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 = [
# path('roads/', include(router.urls)),
path("", include("tournaments.urls")),
path('shop/', include('shop.urls')),
# path("crm/", include("crm.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('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
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

@ -1,14 +1,22 @@
Django==4.2.11
Django==5.1
djangorestframework==3.14.0
psycopg2-binary==2.9.9
dj-rest-auth==5.1.0
dj-rest-auth==6.0.0
django-qr-code==4.0.1
pycryptodome==3.20.0
requests==2.31.0
PyJWT==2.8.0
httpx[http2]==0.27.0
channels[daphne]==4.1.0
twisted[http2,tls]==24.11.0
channels-redis==4.2.1
pandas==2.2.2
xlrd==2.0.1
openpyxl==3.1.5
django-filter==24.3
cryptography==41.0.7
stripe==11.6.0
django-background-tasks==1.2.8
Pillow==10.2.0
playwright==1.40.0
djangorestframework-api-key==3.1.0

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

@ -1,13 +1,100 @@
from django.contrib import admin
from .models import Product, Color, Size, Order, OrderItem, GuestUser
from django.shortcuts import render, redirect
from django.utils.html import format_html
from django.urls import path
from django.http import HttpResponseRedirect
from django import forms
from django.db.models import Sum, Count, Avg
from datetime import datetime, timedelta
from django.utils import timezone
from .models import (
Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage,
OrderStatus, ShippingAddress
)
class ShopAdminSite(admin.AdminSite):
site_header = "Shop Administration"
site_title = "Shop Admin Portal"
index_title = "Welcome to Shop Administration"
def index(self, request, extra_context=None):
"""Custom admin index view with dashboard"""
# Calculate order statistics
order_status_data = []
total_orders = Order.objects.count()
total_revenue = Order.objects.aggregate(Sum('total_price'))['total_price__sum'] or 0
# Get data for each status
for status_choice in OrderStatus.choices:
status_code, status_label = status_choice
orders_for_status = Order.objects.filter(status=status_code)
count = orders_for_status.count()
total_amount = orders_for_status.aggregate(Sum('total_price'))['total_price__sum'] or 0
avg_order_value = orders_for_status.aggregate(Avg('total_price'))['total_price__avg'] or 0
percentage = (count / total_orders * 100) if total_orders > 0 else 0
order_status_data.append({
'status': status_code,
'label': status_label,
'count': count,
'total_amount': total_amount,
'avg_order_value': avg_order_value,
'percentage': percentage
})
# Recent activity calculations
now = timezone.now()
today = now.date()
week_ago = today - timedelta(days=7)
month_ago = today - timedelta(days=30)
orders_today = Order.objects.filter(date_ordered__date=today).count()
orders_this_week = Order.objects.filter(date_ordered__date__gte=week_ago).count()
orders_this_month = Order.objects.filter(date_ordered__date__gte=month_ago).count()
orders_to_prepare = Order.objects.filter(status=OrderStatus.PAID).count()
extra_context = extra_context or {}
extra_context.update({
'order_status_data': order_status_data,
'total_orders': total_orders,
'total_revenue': total_revenue,
'orders_today': orders_today,
'orders_this_week': orders_this_week,
'orders_this_month': orders_this_month,
'orders_to_prepare': orders_to_prepare,
})
return render(request, 'admin/shop/dashboard.html', extra_context)
# Create an instance of the custom admin site
shop_admin_site = ShopAdminSite(name='shop_admin')
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("title", "ordering_value", "price", "cut")
search_fields = ["title", "description"] # Enable search for autocomplete
@admin.register(Color)
class ColorAdmin(admin.ModelAdmin):
list_display = ("name",)
list_display = ("color_preview", "name", "ordering", "colorHex", "secondary_hex_color")
list_editable = ("ordering",)
ordering = ["ordering"]
search_fields = ["name"]
list_per_page = 20
def color_preview(self, obj):
if obj.secondary_hex_color:
return format_html(
'<div style="background-image: linear-gradient(to right, {} 50%, {} 50%); '
'width: 60px; height: 30px; border-radius: 15px; border: 1px solid #ddd;"></div>',
obj.colorHex, obj.secondary_hex_color
)
return format_html(
'<div style="background-color: {}; width: 60px; height: 30px; '
'border-radius: 15px; border: 1px solid #ddd;"></div>',
obj.colorHex
)
@admin.register(Size)
class SizeAdmin(admin.ModelAdmin):
@ -15,13 +102,273 @@ class SizeAdmin(admin.ModelAdmin):
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
readonly_fields = ('product', 'quantity', 'color', 'size', 'price')
extra = 1 # Show one extra row for adding new items
autocomplete_fields = ['product'] # Enable product search
fields = ('product', 'quantity', 'color', 'size', 'price')
@admin.register(OrderItem)
class OrderItemAdmin(admin.ModelAdmin):
list_display = ('order', 'product', 'quantity', 'color', 'size', 'price', 'get_total_price')
list_filter = ('product', 'color', 'size', 'order__status')
search_fields = ('order__id', 'product__title', 'order__user__email', 'order__guest_user__email')
autocomplete_fields = ['order', 'product']
list_editable = ('quantity', 'price')
def get_total_price(self, obj):
return obj.get_total_price()
get_total_price.short_description = 'Total Price'
get_total_price.admin_order_field = 'price' # Allows column to be sortable
@admin.register(ShippingAddress)
class ShippingAddressAdmin(admin.ModelAdmin):
list_display = ('street_address', 'city', 'postal_code', 'country')
search_fields = ('street_address', 'city', 'postal_code', 'country')
class ChangeOrderStatusForm(forms.Form):
_selected_action = forms.CharField(widget=forms.MultipleHiddenInput)
status = forms.ChoiceField(choices=OrderStatus.choices, label="New Status")
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ('id', 'date_ordered', 'status', 'total_price')
list_display = ('id', 'get_email', 'date_ordered', 'status', 'total_price', 'get_shipping_address')
inlines = [OrderItemInline]
list_filter = ('status', 'payment_status')
readonly_fields = ('shipping_address_details',)
actions = ['change_order_status']
autocomplete_fields = ['user'] # Add this line for user search functionality
search_fields = ['id', 'user__email', 'user__username', 'guest_user__email'] # Add this line
def get_email(self, obj):
if obj.guest_user:
return obj.guest_user.email
else:
return obj.user.email
get_email.short_description = 'Email'
def get_shipping_address(self, obj):
if obj.shipping_address:
return f"{obj.shipping_address.street_address}, {obj.shipping_address.city}"
return "No shipping address"
get_shipping_address.short_description = 'Shipping Address'
def shipping_address_details(self, obj):
if obj.shipping_address:
return format_html(
"""
<div style="padding: 10px; background-color: #f9f9f9; border-radius: 4px;">
<strong>Street:</strong> {}<br>
{}
<strong>City:</strong> {}<br>
<strong>State:</strong> {}<br>
<strong>Postal Code:</strong> {}<br>
<strong>Country:</strong> {}
</div>
""",
obj.shipping_address.street_address,
f"<strong>Apartment:</strong> {obj.shipping_address.apartment}<br>" if obj.shipping_address.apartment else "",
obj.shipping_address.city,
obj.shipping_address.state,
obj.shipping_address.postal_code,
obj.shipping_address.country,
)
return "No shipping address set"
shipping_address_details.short_description = 'Shipping Address Details'
fieldsets = (
(None, {
'fields': ('user', 'guest_user', 'status', 'payment_status', 'total_price')
}),
('Shipping Information', {
'fields': ('shipping_address_details',),
}),
('Payment Details', {
'fields': ('stripe_payment_intent_id', 'stripe_checkout_session_id', 'stripe_mode'),
'classes': ('collapse',)
}),
('Discount Information', {
'fields': ('coupon', 'discount_amount'),
'classes': ('collapse',)
}),
)
def dashboard_view(self, request):
"""Dashboard view with order statistics"""
# Calculate order statistics
order_status_data = []
total_orders = Order.objects.count()
total_revenue = Order.objects.aggregate(Sum('total_price'))['total_price__sum'] or 0
# Get data for each status
for status_choice in OrderStatus.choices:
status_code, status_label = status_choice
orders_for_status = Order.objects.filter(status=status_code)
count = orders_for_status.count()
total_amount = orders_for_status.aggregate(Sum('total_price'))['total_price__sum'] or 0
avg_order_value = orders_for_status.aggregate(Avg('total_price'))['total_price__avg'] or 0
percentage = (count / total_orders * 100) if total_orders > 0 else 0
order_status_data.append({
'status': status_code,
'label': status_label,
'count': count,
'total_amount': total_amount,
'avg_order_value': avg_order_value,
'percentage': percentage
})
# Recent activity calculations
now = timezone.now()
today = now.date()
week_ago = today - timedelta(days=7)
month_ago = today - timedelta(days=30)
orders_today = Order.objects.filter(date_ordered__date=today).count()
orders_this_week = Order.objects.filter(date_ordered__date__gte=week_ago).count()
orders_this_month = Order.objects.filter(date_ordered__date__gte=month_ago).count()
orders_to_prepare = Order.objects.filter(status=OrderStatus.PAID).count()
context = {
'title': 'Shop Dashboard',
'app_label': 'shop',
'opts': Order._meta,
'order_status_data': order_status_data,
'total_orders': total_orders,
'total_revenue': total_revenue,
'orders_today': orders_today,
'orders_this_week': orders_this_week,
'orders_this_month': orders_this_month,
'orders_to_prepare': orders_to_prepare,
}
return render(request, 'admin/shop/dashboard.html', context)
def changelist_view(self, request, extra_context=None):
# If 'show_preparation' parameter is in the request, show the preparation view
if 'show_preparation' in request.GET:
return self.preparation_view(request)
# Otherwise show the normal change list
extra_context = extra_context or {}
paid_orders_count = Order.objects.filter(status=OrderStatus.PAID).count()
extra_context['paid_orders_count'] = paid_orders_count
return super().changelist_view(request, extra_context=extra_context)
def preparation_view(self, request):
"""View for items that need to be prepared"""
# Get paid orders
orders = Order.objects.filter(status=OrderStatus.PAID).order_by('-date_ordered')
# Group items by product, color, size
items_by_variant = {}
all_items = OrderItem.objects.filter(order__status=OrderStatus.PAID)
for item in all_items:
# Create a key for grouping items
key = (
str(item.product.id),
str(item.color.id) if item.color else 'none',
str(item.size.id) if item.size else 'none'
)
if key not in items_by_variant:
items_by_variant[key] = {
'product': item.product,
'color': item.color,
'size': item.size,
'quantity': 0,
'orders': set()
}
items_by_variant[key]['quantity'] += item.quantity
items_by_variant[key]['orders'].add(item.order.id)
# Convert to list and sort
items_list = list(items_by_variant.values())
items_list.sort(key=lambda x: x['product'].title)
context = {
'title': 'Orders to Prepare',
'app_label': 'shop',
'opts': Order._meta,
'orders': orders,
'items': items_list,
'total_orders': orders.count(),
'total_items': sum(i['quantity'] for i in items_list)
}
return render(
request,
'admin/shop/order/preparation_view.html',
context
)
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('dashboard/', self.admin_site.admin_view(self.dashboard_view), name='shop_order_dashboard'),
path('prepare-all/', self.admin_site.admin_view(self.prepare_all_orders), name='prepare_all_orders'),
path('<int:order_id>/prepare/', self.admin_site.admin_view(self.prepare_order), name='prepare_order'),
path('<int:order_id>/cancel-refund/', self.admin_site.admin_view(self.cancel_and_refund_order), name='cancel_and_refund_order'),
]
return custom_urls + urls
def prepare_all_orders(self, request):
if request.method == 'POST':
Order.objects.filter(status=OrderStatus.PAID).update(status=OrderStatus.PREPARED)
self.message_user(request, "All orders have been marked as prepared.")
return redirect('admin:shop_order_changelist')
def prepare_order(self, request, order_id):
if request.method == 'POST':
order = Order.objects.get(id=order_id)
order.status = OrderStatus.PREPARED
order.save()
self.message_user(request, f"Order #{order_id} has been marked as prepared.")
return redirect('admin:shop_order_changelist')
def cancel_and_refund_order(self, request, order_id):
if request.method == 'POST':
order = Order.objects.get(id=order_id)
try:
# Reuse the cancel_order logic from your views
from .views import cancel_order
cancel_order(request, order_id)
self.message_user(request, f"Order #{order_id} has been cancelled and refunded.")
except Exception as e:
self.message_user(request, f"Error cancelling order: {str(e)}", level='ERROR')
return redirect('admin:shop_order_changelist')
def change_order_status(self, request, queryset):
"""Admin action to change the status of selected orders"""
form = None
if 'apply' in request.POST:
form = ChangeOrderStatusForm(request.POST)
if form.is_valid():
status = form.cleaned_data['status']
count = 0
for order in queryset:
order.status = status
order.save()
count += 1
self.message_user(request, f"{count} orders have been updated to status '{OrderStatus(status).label}'.")
return HttpResponseRedirect(request.get_full_path())
if not form:
form = ChangeOrderStatusForm(initial={'_selected_action': request.POST.getlist('_selected_action')})
context = {
'title': 'Change Order Status',
'orders': queryset,
'form': form,
'action': 'change_order_status'
}
return render(request, 'admin/shop/order/change_status.html', context)
change_order_status.short_description = "Change status for selected orders"
class GuestUserOrderInline(admin.TabularInline):
model = Order
@ -35,3 +382,33 @@ class GuestUserOrderInline(admin.TabularInline):
class GuestUserAdmin(admin.ModelAdmin):
list_display = ('email', 'phone')
inlines = [GuestUserOrderInline]
@admin.register(Coupon)
class CouponAdmin(admin.ModelAdmin):
list_display = ('code', 'discount_amount', 'discount_percent', 'is_active',
'valid_from', 'valid_to', 'current_uses', 'max_uses')
list_filter = ('is_active', 'valid_from', 'valid_to')
search_fields = ('code', 'description')
readonly_fields = ('current_uses', 'created_at', 'stripe_coupon_id')
fieldsets = (
('Basic Information', {
'fields': ('code', 'description', 'is_active')
}),
('Discount', {
'fields': ('discount_amount', 'discount_percent')
}),
('Validity', {
'fields': ('valid_from', 'valid_to', 'max_uses', 'current_uses')
}),
('Stripe Information', {
'fields': ('stripe_coupon_id',),
'classes': ('collapse',)
}),
)
@admin.register(CouponUsage)
class CouponUsageAdmin(admin.ModelAdmin):
list_display = ('coupon', 'user', 'guest_email', 'order', 'used_at')
list_filter = ('used_at',)
search_fields = ('coupon__code', 'user__username', 'user__email', 'guest_email')
readonly_fields = ('used_at',)

@ -74,3 +74,21 @@ def get_cart_item(request, item_id):
return CartItem.objects.get(id=item_id, session_id=cart_id)
except CartItem.DoesNotExist:
raise Exception("Cart item not found")
def transfer_cart(request, old_session_key):
"""
Transfer cart items from an anonymous session to an authenticated user's session
"""
from django.contrib.sessions.models import Session
from django.contrib.sessions.backends.db import SessionStore
# Get the old session
try:
old_session = SessionStore(session_key=old_session_key)
# Check if there are cart items in the old session
if 'cart_items' in old_session:
# Transfer cart items to the new session
request.session['cart_items'] = old_session['cart_items']
request.session.modified = True
except Session.DoesNotExist:
pass

@ -1,5 +1,22 @@
from django import forms
from .models import Coupon
from .models import ShippingAddress
class GuestCheckoutForm(forms.Form):
email = forms.EmailField(required=True)
phone = forms.CharField(max_length=20, required=True, label="Téléphone portable")
class CouponApplyForm(forms.Form):
code = forms.CharField(max_length=50)
class ShippingAddressForm(forms.ModelForm):
class Meta:
model = ShippingAddress
fields = ['street_address', 'apartment', 'city', 'postal_code', 'country']
widgets = {
'street_address': forms.TextInput(attrs={'placeholder': 'Adresse'}),
'apartment': forms.TextInput(attrs={'placeholder': 'Appartement (optionnel)'}),
'city': forms.TextInput(attrs={'placeholder': 'Ville'}),
'postal_code': forms.TextInput(attrs={'placeholder': 'Code postal'}),
'country': forms.TextInput(attrs={'placeholder': 'Pays'}),
}

@ -8,30 +8,48 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs):
# Create colors
self.stdout.write('Creating colors...')
colors = {
'Black': '#000000',
'White': '#FFFFFF',
'Red': '#FF0000',
'Blue': '#0000FF',
'Green': '#00FF00',
'Yellow': '#FFFF00'
}
colors = [
{'name': 'Blanc', 'hex': '#FFFFFF', 'secondary_hex': None, 'ordering': 9},
{'name': 'Blanc / Bleu Sport', 'hex': '#FFFFFF', 'secondary_hex': '#112B44', 'ordering': 10},
{'name': 'Blanc / Gris Clair', 'hex': '#FFFFFF', 'secondary_hex': '#D3D3D3', 'ordering': 12},
{'name': 'Bleu Sport', 'hex': '#112B44', 'secondary_hex': None, 'ordering': 20},
{'name': 'Bleu Sport / Blanc', 'hex': '#112B44', 'secondary_hex': '#FFFFFF', 'ordering': 11},
{'name': 'Bleu Sport / Bleu Sport Chiné', 'hex': '#112B44', 'secondary_hex': '#16395A', 'ordering': 22},
{'name': 'Fuchsia', 'hex': '#C1366B', 'secondary_hex': None, 'ordering': 30},
{'name': 'Corail / Noir', 'hex': '#FF7F50', 'secondary_hex': '#000000', 'ordering': 40},
{'name': 'Gris Foncé Chiné / Noir', 'hex': '#4D4D4D', 'secondary_hex': '#000000', 'ordering': 50},
{'name': 'Olive', 'hex': '#635E53', 'secondary_hex': None, 'ordering': 54},
{'name': 'Kaki Foncé', 'hex': '#707163', 'secondary_hex': None, 'ordering': 55},
{'name': 'Noir', 'hex': '#000000', 'secondary_hex': None, 'ordering': 60},
{'name': 'Noir / Corail', 'hex': '#000000', 'secondary_hex': '#FF7F50', 'ordering': 61},
{'name': 'Noir / Gris Foncé Chiné', 'hex': '#000000', 'secondary_hex': '#4D4D4D', 'ordering': 62},
{'name': 'Rose Clair', 'hex': '#E7C8CF', 'secondary_hex': None, 'ordering': 31},
{'name': 'Sand', 'hex': '#B4A885', 'secondary_hex': None, 'ordering': 32},
]
color_objects = {}
for name, hex_code in colors.items():
for color_data in colors:
color, created = Color.objects.get_or_create(
name=name,
defaults={'colorHex': hex_code}
name=color_data['name'],
defaults={
'colorHex': color_data['hex'],
'secondary_hex_color': color_data['secondary_hex'],
'ordering': color_data['ordering']
}
)
color_objects[name] = color
color_objects[color_data['name']] = color
if created:
self.stdout.write(f'Created color: {name}')
self.stdout.write(f'Created color: {color_data["name"]}')
else:
self.stdout.write(f'Color already exists: {name}')
color.colorHex = color_data['hex']
color.secondary_hex_color = color_data['secondary_hex']
color.ordering = color_data['ordering']
color.save()
self.stdout.write(f'Updated color: {color_data["name"]}')
# Create sizes
self.stdout.write('Creating sizes...')
sizes = ['XS', 'S', 'M', 'L', 'XL', 'XXL']
sizes = ['Taille Unique', 'XS', 'S', 'M', 'L', 'XL', 'XXL', '3XL', '4XL']
size_objects = {}
for name in sizes:
@ -46,38 +64,112 @@ class Command(BaseCommand):
self.stdout.write('Creating products...')
products = [
{
'title': 'Tennis Racket Pro',
'price': 99.99,
'sku': 'PC001',
'title': 'Padel Club Cap',
'description': 'Casquette logo centre',
'price': 25.00,
'ordering_value': 1,
'cut': 2, # Men
'colors': ['Black', 'White', 'Red'],
'sizes': ['M', 'L', 'XL'],
'image_filename': 'hat.jpg' # Just the filename
'cut': 0, # Unisex
'colors': ['Blanc', 'Bleu Sport', 'Noir'],
'sizes': ['Taille Unique'],
'image_filename': 'hat.jpg'
},
{
'title': 'Sports T-Shirt',
'price': 29.99,
'ordering_value': 2,
'sku': 'PC002',
'title': 'Padel Club Hoodie Femme',
'description': 'Hoodie femme logo cœur et dos',
'price': 50.00,
'ordering_value': 10,
'cut': 1,
'colors': ['Blanc', 'Bleu Sport', 'Kaki Foncé', 'Noir', 'Fuchsia'],
'sizes': ['XS', 'S', 'M', 'L', 'XL', 'XXL'],
'image_filename': 'PS_K473_WHITE.png.avif'
},
{
'sku': 'PC003',
'title': 'Padel Club Hoodie Homme',
'description': 'Hoodie homme logo cœur et dos',
'price': 50.00,
'ordering_value': 11,
'cut': 2,
'colors': ['Blanc', 'Bleu Sport', 'Kaki Foncé', 'Noir', 'Fuchsia'],
'sizes': ['XS', 'S', 'M', 'L', 'XL', 'XXL', '3XL', '4XL'],
'image_filename': 'PS_K476_WHITE.png.avif'
},
{
'sku': 'PC004',
'title': 'Débardeur Femme',
'description': 'Débardeur femme avec logo coeur.',
'price': 25.00,
'ordering_value': 20,
'cut': 1, # Women
'colors': ['Black', 'White', 'Blue', 'Red'],
'colors': ['Blanc / Bleu Sport', 'Noir / Corail', 'Noir / Gris Foncé Chiné'],
'sizes': ['XS', 'S', 'M', 'L', 'XL'],
'image_filename': 'tshirt.jpg' # Just the filename
'image_filename': 'PS_PA4031_WHITE-SPORTYNAVY.png.avif'
},
{
'title': 'Kids Tennis Shorts',
'price': 19.99,
'ordering_value': 3,
'cut': 3, # Kids
'colors': ['Blue', 'White'],
'sizes': ['XS', 'S', 'M'],
'image_filename': 'kids_shorts.jpg' # Just the filename
}
'sku': 'PC005',
'title': 'Jupe bicolore Femme',
'description': 'Avec short intégré logo jambe (sauf corail)',
'price': 30.00,
'ordering_value': 30,
'cut': 1, # Women
'colors': ['Blanc / Bleu Sport', 'Bleu Sport / Blanc', 'Corail / Noir', 'Noir / Gris Foncé Chiné'],
'sizes': ['XS', 'S', 'M', 'L', 'XL'],
'image_filename': 'PS_PA1031_WHITE-SPORTYNAVY.png.avif'
},
{
'sku': 'PC006',
'title': 'T-shirt Bicolore Homme',
'description': 'T-shirt bicolore avec logo coeur.',
'price': 25.00,
'ordering_value': 40,
'cut': 2, # Men
'colors': ['Blanc / Gris Clair', 'Bleu Sport / Blanc', 'Bleu Sport / Bleu Sport Chiné', 'Noir', 'Noir / Gris Foncé Chiné'],
'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'],
'image_filename': 'tshirt_h.png'
},
{
'sku': 'PC007',
'title': 'Short Bicolore Homme',
'description': 'Short bicolore avec logo jambe.',
'price': 30.00,
'ordering_value': 50,
'cut': 2, # Men
'colors': ['Blanc / Bleu Sport', 'Blanc / Gris Clair', 'Noir', 'Gris Foncé Chiné / Noir'],
'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'],
'image_filename': 'PS_PA1030_WHITE-SPORTYNAVY.png.avif'
},
{
'sku': 'PC008',
'title': 'T-shirt Simple Femme',
'description': 'T-shirt simple avec logo coeur.',
'price': 20.00,
'ordering_value': 60,
'cut': 1, # Women
'colors': ['Blanc', 'Bleu Sport', 'Sand', 'Noir', 'Kaki Foncé', 'Rose Clair'],
'sizes': ['XS','S', 'M', 'L', 'XL', 'XXL'],
'image_filename': 'PS_PA439_WHITE.png.avif'
},
{
'sku': 'PC009',
'title': 'T-shirt Simple Homme',
'description': 'T-shirt simple avec logo coeur.',
'price': 20.00,
'ordering_value': 61,
'cut': 2, # Men
'colors': ['Blanc', 'Bleu Sport', 'Sand', 'Noir', 'Olive', 'Rose Clair'],
'sizes': ['XS','S', 'M', 'L', 'XL', 'XXL', '3XL'],
'image_filename': 'PS_PA438_WHITE.png.avif'
},
]
for product_data in products:
product, created = Product.objects.get_or_create(
title=product_data['title'],
product, created = Product.objects.update_or_create(
sku=product_data['sku'],
defaults={
'title': product_data['title'],
'description': product_data.get('description', ''),
'price': product_data['price'],
'ordering_value': product_data['ordering_value'],
'cut': product_data['cut']
@ -85,28 +177,30 @@ class Command(BaseCommand):
)
if created:
self.stdout.write(f'Created product: {product_data["title"]}')
# Add colors
for color_name in product_data['colors']:
product.colors.add(color_objects[color_name])
# Add sizes
for size_name in product_data['sizes']:
product.sizes.add(size_objects[size_name])
self.stdout.write(f'Created product: {product_data["sku"]} - {product_data["title"]}')
else:
self.stdout.write(f'Updated product: {product_data["sku"]} - {product_data["title"]}')
# Construct the full path for storage
# Handle the image path
if 'image_filename' in product_data and product_data['image_filename']:
# Construct the URL path to the image
# This uses STATIC_URL from your settings
image_path = f"{settings.STATIC_URL}shop/images/products/{product_data['image_filename']}"
print(image_path)
# Store this path in the database
if product.image != image_path:
product.image = image_path
product.save()
self.stdout.write(f'Updated image path to "{image_path}" for: {product_data["sku"]}')
self.stdout.write(f'Added image path "{image_path}" for: {product_data["title"]}')
else:
self.stdout.write(f'Product already exists: {product_data["title"]}')
# Update colors - first clear existing then add new ones
product.colors.clear()
for color_name in product_data['colors']:
if color_name in color_objects:
product.colors.add(color_objects[color_name])
self.stdout.write(f'Updated colors for: {product_data["sku"]}')
# Update sizes - first clear existing then add new ones
product.sizes.clear()
for size_name in product_data['sizes']:
if size_name in size_objects:
product.sizes.add(size_objects[size_name])
self.stdout.write(f'Updated sizes for: {product_data["sku"]}')
self.stdout.write(self.style.SUCCESS('Successfully created initial shop data'))
self.stdout.write(self.style.SUCCESS('Successfully created/updated shop data'))

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2025-03-21 12:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0017_order_stripe_mode'),
]
operations = [
migrations.AddField(
model_name='color',
name='secondary_hex_color',
field=models.CharField(blank=True, help_text='Secondary color in hex format for split color display', max_length=7, null=True),
),
]

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2025-03-21 12:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0018_color_secondary_hex_color'),
]
operations = [
migrations.AddField(
model_name='product',
name='description',
field=models.TextField(blank=True, help_text='Product description text', null=True),
),
]

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2025-03-21 12:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0019_product_description'),
]
operations = [
migrations.AddField(
model_name='product',
name='sku',
field=models.CharField(default='PC000', help_text='Product SKU (unique identifier)', max_length=50, unique=True),
),
]

@ -0,0 +1,23 @@
# Generated by Django 4.2.11 on 2025-03-21 14:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0020_product_sku'),
]
operations = [
migrations.AlterField(
model_name='color',
name='name',
field=models.CharField(max_length=40, unique=True),
),
migrations.AlterField(
model_name='product',
name='sku',
field=models.CharField(help_text='Product SKU (unique identifier)', max_length=50, unique=True),
),
]

@ -0,0 +1,21 @@
# Generated by Django 5.1 on 2025-03-26 08:47
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('shop', '0021_alter_color_name_alter_product_sku'),
]
operations = [
migrations.AlterModelOptions(
name='cartitem',
options={'ordering': ['product__ordering_value', 'product__cut']},
),
migrations.AlterModelOptions(
name='orderitem',
options={'ordering': ['product__ordering_value', 'product__cut']},
),
]

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

Loading…
Cancel
Save