Compare commits

...

223 Commits

Author SHA1 Message Date
Egor c24c668299 Update Dockerfile 2025-10-07 06:47:33 +03:00
Egor 41df40eed3 Update docker-registry.yml 2025-10-07 06:47:26 +03:00
Egor b1975cc3ff Update docker-hub.yml 2025-10-07 06:47:14 +03:00
Egor 369e2c3c36 Merge pull request #853 from Fr1ngg/dev2
Дополнительные методы API для работы со страницами из ИНФО + вынесение хостов в конфигурации для платежек
2025-10-07 06:35:19 +03:00
Egor e2ec4b05db Merge pull request #852 from Fr1ngg/r6u84x-bedolaga/extract-host-to-bot-configuration
Allow configuring webhook hosts
2025-10-07 06:32:21 +03:00
Egor 7493b13348 Allow configuring YooKassa webhook host 2025-10-07 06:32:03 +03:00
Egor 0a65aa16cd Merge pull request #851 from Fr1ngg/461rmg-bedolaga/add-api-endpoints-for-offer-faq-terms
Allow FAQ creation API to pass display order and status
2025-10-07 06:29:22 +03:00
Egor 64a4ece0fe Allow FAQ creation API to pass display order and status 2025-10-07 06:28:26 +03:00
Egor 71664cca3e Merge pull request #850 from Fr1ngg/revert-847-avd1an-bedolaga/add-api-endpoints-for-offer-faq-terms
Revert "Add informational pages endpoints to admin web API"
2025-10-07 06:28:09 +03:00
Egor b21177f685 Revert "Add informational pages endpoints to admin web API" 2025-10-07 06:28:00 +03:00
Egor cde6aa4c26 Merge pull request #849 from Fr1ngg/revert-848-5sy6f4-bedolaga/extract-host-to-bot-configuration
Revert "Allow configuring Tribute webhook host"
2025-10-07 06:26:51 +03:00
Egor 5264fc0d90 Revert "Allow configuring Tribute webhook host" 2025-10-07 06:26:37 +03:00
Egor 7aa2004f27 Merge pull request #848 from Fr1ngg/5sy6f4-bedolaga/extract-host-to-bot-configuration
Allow configuring Tribute webhook host
2025-10-07 06:23:26 +03:00
Egor 40a34d1e55 Allow configuring Tribute webhook host 2025-10-07 06:22:41 +03:00
Egor 4204b459e2 Merge pull request #847 from Fr1ngg/avd1an-bedolaga/add-api-endpoints-for-offer-faq-terms
Add informational pages endpoints to admin web API
2025-10-07 06:21:03 +03:00
Egor cbe80e9447 Add pages management endpoints to web API 2025-10-07 06:20:44 +03:00
Egor 0cfd8d3c7b Merge pull request #846 from Fr1ngg/dev2
FAQ, Политика Конф, Оферта
2025-10-07 06:08:48 +03:00
Egor ddfa61c56f Merge pull request #845 from Fr1ngg/revert-842-revert-839-g9mnci-bedolaga/add-faq-button-to-bot-info-section
Revert "Revert "Add FAQ management and user menu support""
2025-10-07 06:03:01 +03:00
Egor 7e340bc13a Revert "Revert "Add FAQ management and user menu support"" 2025-10-07 06:02:50 +03:00
Egor f7dd3be27c Merge pull request #844 from Fr1ngg/rcd0rc-bedolaga/add-offer-button-to-bot-info-section
Fix admin offer preview HTML truncation
2025-10-07 06:02:38 +03:00
Egor 3ff06ed9d1 Use paginator for admin offer preview 2025-10-07 06:02:19 +03:00
Egor 08d6a26b23 Merge pull request #843 from Fr1ngg/revert-841-ptoawe-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 06:01:38 +03:00
Egor 37e472d78a Revert "Add configurable public offer info section" 2025-10-07 06:01:25 +03:00
Egor 8cc04e01e9 Merge pull request #842 from Fr1ngg/revert-839-g9mnci-bedolaga/add-faq-button-to-bot-info-section
Revert "Add FAQ management and user menu support"
2025-10-07 06:01:18 +03:00
Egor c199d04b91 Revert "Add FAQ management and user menu support" 2025-10-07 06:01:09 +03:00
Egor 102d5d0a9c Merge pull request #839 from Fr1ngg/g9mnci-bedolaga/add-faq-button-to-bot-info-section
Add FAQ management and user menu support
2025-10-07 05:52:53 +03:00
Egor 8e7b44811e Merge pull request #841 from Fr1ngg/ptoawe-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:52:37 +03:00
Egor ab1be9943b Fix HTML paginator preserving entities 2025-10-07 05:52:19 +03:00
Egor 61b702272b Merge pull request #840 from Fr1ngg/revert-838-5gl2xr-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 05:51:59 +03:00
Egor 84bce0043d Revert "Add configurable public offer info section" 2025-10-07 05:51:51 +03:00
Egor f54528f2d5 Add FAQ management and user menu support 2025-10-07 05:47:41 +03:00
Egor b66911f4f7 Merge pull request #838 from Fr1ngg/5gl2xr-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:45:04 +03:00
Egor 352c14331d Handle HTML tags when paginating public offer text 2025-10-07 05:44:16 +03:00
Egor 7ea91f344c Merge pull request #837 from Fr1ngg/revert-836-t8dpxo-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 05:40:05 +03:00
Egor d2d5b6c823 Revert "Add configurable public offer info section" 2025-10-07 05:39:54 +03:00
Egor 4405c2287a Merge pull request #836 from Fr1ngg/t8dpxo-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:27:38 +03:00
Egor 288a72f739 Fix per-language public offer disabling 2025-10-07 05:27:21 +03:00
Egor 60391a8b89 Merge pull request #835 from Fr1ngg/revert-834-plexvn-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 05:27:06 +03:00
Egor f2d4da9514 Revert "Add configurable public offer info section" 2025-10-07 05:26:57 +03:00
Egor 648ad24e6d Merge pull request #834 from Fr1ngg/plexvn-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:17:12 +03:00
Egor 5d28cd9930 Add configurable public offer info section 2025-10-07 05:16:53 +03:00
Egor 43974d1685 Merge pull request #833 from Fr1ngg/kq9kol-bedolaga/add-config-policy-button-to-bot-info
Add privacy policy info menu and admin controls
2025-10-07 04:57:06 +03:00
Egor 39ccc7fb4a Add privacy policy section and admin management 2025-10-07 04:56:45 +03:00
Egor d391a46b0e Merge pull request #831 from Fr1ngg/dev2
Расширения функционала отправки промо предложений / отправка конкретному юзеру
2025-10-07 02:39:35 +03:00
Egor 59dd638dd7 Merge pull request #830 from Fr1ngg/bedolaga-5bnc62
Fix promo offer durations and active discount display
2025-10-07 02:35:47 +03:00
Egor 3062061270 Fix promo offer durations and active discount display 2025-10-07 02:34:47 +03:00
Egor 5b0a64964e Merge pull request #829 from Fr1ngg/revert-828-bedolaga-48se3m
Revert "Add detailed promo offer status for user view"
2025-10-07 02:33:52 +03:00
Egor 70e3517d3a Revert "Add detailed promo offer status for user view" 2025-10-07 02:33:44 +03:00
Egor 08b64abb03 Merge pull request #828 from Fr1ngg/bedolaga-48se3m
Add detailed promo offer status for user view
2025-10-07 02:21:14 +03:00
Egor 84a3179974 Add detailed promo offer status for user view 2025-10-07 02:21:02 +03:00
Egor fa56ad8791 Merge pull request #827 from Fr1ngg/revert-826-bedolaga-ztqa24
Revert "Improve promo offer user view with status details"
2025-10-07 02:20:45 +03:00
Egor f584cb78f1 Revert "Improve promo offer user view with status details" 2025-10-07 02:20:36 +03:00
Egor ff3ac2b9f3 Merge pull request #826 from Fr1ngg/bedolaga-ztqa24
Improve promo offer user view with status details
2025-10-07 02:19:06 +03:00
Egor deabcfba28 Enhance promo offer user detail stats 2025-10-07 02:18:45 +03:00
Egor 85775604a8 Merge pull request #825 from Fr1ngg/revert-824-bedolaga-pzccm9
Revert "Fix promo offer localization placeholder mismatch"
2025-10-07 02:16:00 +03:00
Egor bf9b99818d Revert "Fix promo offer localization placeholder mismatch" 2025-10-07 02:15:52 +03:00
Egor 93a8c182bc Merge pull request #824 from Fr1ngg/bedolaga-pzccm9
Fix promo offer localization placeholder mismatch
2025-10-07 02:04:54 +03:00
Egor 7febaf9db2 Fix promo offer profile summary placeholders 2025-10-07 02:04:38 +03:00
Egor 21583279c6 Merge pull request #823 from Fr1ngg/revert-822-bedolaga-b9dizq
Revert "Cleanup promo offer user search prompt and add localization"
2025-10-07 02:04:11 +03:00
Egor df11e09d3a Revert "Cleanup promo offer user search prompt and add localization" 2025-10-07 02:04:02 +03:00
Egor 0b2ebbcdd8 Merge pull request #822 from Fr1ngg/bedolaga-b9dizq
Cleanup promo offer user search prompt and add localization
2025-10-07 01:57:12 +03:00
Egor 28be0954f9 Fix promo offer user search UX and translations 2025-10-07 01:56:59 +03:00
Egor 44164b2948 Merge pull request #821 from Fr1ngg/revert-820-bedolaga-72teet
Revert "Add user-targeted promo offer sending workflow"
2025-10-07 01:56:25 +03:00
Egor d9d4f171f6 Revert "Add user-targeted promo offer sending workflow" 2025-10-07 01:56:17 +03:00
Egor 4c73d1d0b6 Merge pull request #820 from Fr1ngg/bedolaga-72teet
Add user-targeted promo offer sending workflow
2025-10-07 01:41:34 +03:00
Egor 0bf71971f9 feat: add user-targeted promo offer sending 2025-10-07 01:41:15 +03:00
Egor ff19fb8950 Merge pull request #819 from Fr1ngg/revert-818-bedolaga-pm5a67
Revert "Add promo template editing workflow and restrictions"
2025-10-07 01:21:32 +03:00
Egor 68a37fcf72 Revert "Add promo template editing workflow and restrictions" 2025-10-07 01:21:20 +03:00
Egor 2caf16ccc6 Merge pull request #818 from Fr1ngg/bedolaga-pm5a67
Add promo template editing workflow and restrictions
2025-10-07 01:19:23 +03:00
Egor b38ee36698 Add promo template editing workflow and restrictions 2025-10-07 01:19:05 +03:00
Egor 3ee9c3de7b Merge pull request #817 from Fr1ngg/revert-816-bedolaga-m8ijxh
Revert "Improve admin promo template restrictions and editing"
2025-10-07 01:18:50 +03:00
Egor 0fce289926 Revert "Improve admin promo template restrictions and editing" 2025-10-07 01:18:42 +03:00
Egor b1e7f83d11 Merge pull request #816 from Fr1ngg/bedolaga-m8ijxh
Improve admin promo template restrictions and editing
2025-10-07 01:17:09 +03:00
Egor ff28f82240 Refine admin promo template workflow 2025-10-07 01:16:44 +03:00
Egor d5082be405 Merge pull request #815 from Fr1ngg/revert-814-bedolaga
Revert "Add per-user promo template sending in admin panel"
2025-10-07 01:16:13 +03:00
Egor 3e830a32ea Revert "Add per-user promo template sending in admin panel" 2025-10-07 01:16:05 +03:00
Egor c8a99ceed7 Merge pull request #814 from Fr1ngg/bedolaga
Add per-user promo template sending in admin panel
2025-10-07 00:19:18 +03:00
Egor 602ddd4013 feat(admin): manage user promo offers 2025-10-07 00:18:59 +03:00
Egor 33443955c7 Merge pull request #811 from Fr1ngg/dev2
Dev2
2025-10-06 04:48:28 +03:00
Egor 8f8b6ac1fe Merge pull request #810 from Fr1ngg/bedolaga/add-info-section-to-main-page
Add info section to main menu
2025-10-06 04:46:16 +03:00
Egor 8bb0795b1e Add info section to main menu 2025-10-06 04:45:54 +03:00
Egor 0055560d3e Merge pull request #809 from Fr1ngg/dev2
api promo offers
2025-10-06 04:37:05 +03:00
Egor dcc209deea Merge pull request #808 from Fr1ngg/bedolaga/add-api-endpoints-for-promo-offers
Add promo offer management endpoints
2025-10-06 04:27:40 +03:00
Egor a2cb7527f8 feat(api): add promo offers endpoints and docs 2025-10-06 04:27:21 +03:00
Egor 523c6ab69c Merge pull request #807 from Fr1ngg/dev2
Фикс промо предложений
2025-10-06 02:58:38 +03:00
Egor 91220b0b5f Merge pull request #806 from Fr1ngg/main
w
2025-10-06 02:57:53 +03:00
Egor f87dac8efa Merge pull request #805 from Fr1ngg/bedolaga/fix-discount-claim-error
Handle custom placeholders in discount claim message
2025-10-06 02:53:03 +03:00
Egor 44b75be97a Handle missing placeholders in discount claim message 2025-10-06 02:52:44 +03:00
Egor 500ed4cdf9 Update Dockerfile 2025-10-06 01:59:41 +03:00
Egor 498bd77076 Update docker-registry.yml 2025-10-06 01:59:29 +03:00
Egor 29afb6e67f Update docker-hub.yml 2025-10-06 01:59:16 +03:00
Egor 01d10b58dd Merge pull request #804 from Fr1ngg/dev2
Рассылка промопредложений + выдача сквадов(серверов) на время
2025-10-06 01:50:20 +03:00
Egor cf7aed9abf Merge pull request #803 from Fr1ngg/main
w
2025-10-06 01:46:02 +03:00
Egor 1b57531b28 Merge pull request #802 from Fr1ngg/bedolaga/fix-telegram-button-privacy-restriction-error-ptdrym
Handle Telegram privacy restriction fallback
2025-10-06 01:37:31 +03:00
Egor 375c096e82 Handle Telegram privacy restriction fallback 2025-10-06 01:37:12 +03:00
Egor 89b0daae1d Merge pull request #800 from Fr1ngg/bedolaga/fix-bot-main-message-for-test-server-lplld3
Escape braces in promo hint server names
2025-10-06 01:25:44 +03:00
Egor 754c21a32f Escape braces in promo hint server names 2025-10-06 01:25:26 +03:00
Egor 4928cdde43 Merge pull request #799 from Fr1ngg/revert-798-bedolaga/fix-bot-main-message-for-test-server-36fdi2
Revert "Escape test server names in promo hint"
2025-10-06 01:25:14 +03:00
Egor 057e5f6ab0 Revert "Escape test server names in promo hint" 2025-10-06 01:25:03 +03:00
Egor 6d66d6f627 Merge pull request #798 from Fr1ngg/bedolaga/fix-bot-main-message-for-test-server-36fdi2
Escape test server names in promo hint
2025-10-06 01:15:58 +03:00
Egor be28e9bec4 Escape server names in promo hint 2025-10-06 01:15:42 +03:00
Egor b923c4bcaa Merge pull request #797 from Fr1ngg/revert-796-bedolaga/fix-bot-main-message-for-test-server
Revert "Display test server names in promo hint"
2025-10-06 01:15:26 +03:00
Egor 3a749c1864 Revert "Display test server names in promo hint" 2025-10-06 01:14:58 +03:00
Egor d16d5b7918 Merge pull request #796 from Fr1ngg/bedolaga/fix-bot-main-message-for-test-server
Display test server names in promo hint
2025-10-06 01:09:23 +03:00
Egor 8484515722 Show test server names in promo hint 2025-10-06 01:08:53 +03:00
Egor b9efff6e97 Update ru.json 2025-10-06 01:01:41 +03:00
Egor 061557c438 Update ru.json 2025-10-06 00:58:53 +03:00
Egor df09ccfbd3 Merge pull request #795 from Fr1ngg/bedolaga-4ou2e3
Display promo offer details on main menu
2025-10-06 00:49:26 +03:00
Egor 16c7e140d0 Guard promo offer extra_data lookups 2025-10-06 00:49:09 +03:00
Egor 967924a1ff Merge pull request #794 from Fr1ngg/revert-793-revert-790-revert-789-bedolaga-ly8q4p
Revert "Revert "Revert "Display promo offer details on main menu"""
2025-10-06 00:48:55 +03:00
Egor e028a25df2 Revert "Revert "Revert "Display promo offer details on main menu""" 2025-10-06 00:48:46 +03:00
Egor d3fcd11201 Merge pull request #793 from Fr1ngg/revert-790-revert-789-bedolaga-ly8q4p
Revert "Revert "Display promo offer details on main menu""
2025-10-06 00:42:56 +03:00
Egor 97b9056b0b Revert "Revert "Display promo offer details on main menu"" 2025-10-06 00:42:46 +03:00
Egor 00ba52672b Merge pull request #792 from Fr1ngg/revert-791-bedolaga-g6lr1v
Revert "Add active promo info to main menu"
2025-10-06 00:42:01 +03:00
Egor efe16b4269 Revert "Add active promo info to main menu" 2025-10-06 00:41:42 +03:00
Egor 5e94ad2189 Merge pull request #791 from Fr1ngg/bedolaga-g6lr1v
Add active promo info to main menu
2025-10-06 00:37:37 +03:00
Egor 53bb582c81 Add active promo info to main menu 2025-10-06 00:37:19 +03:00
Egor 4c20ec19ab Merge pull request #790 from Fr1ngg/revert-789-bedolaga-ly8q4p
Revert "Display promo offer details on main menu"
2025-10-06 00:36:36 +03:00
Egor a7042ea012 Revert "Display promo offer details on main menu" 2025-10-06 00:36:26 +03:00
Egor f700285415 Merge pull request #789 from Fr1ngg/bedolaga-ly8q4p
Display promo offer details on main menu
2025-10-06 00:32:44 +03:00
Egor ed1fc22f6b Show promo offer details on main menu 2025-10-06 00:32:27 +03:00
Egor c3c0c764fd Merge pull request #788 from Fr1ngg/bedolaga/update-promo-offer-message-and-add-status-bar
Add promo offer timer to subscription flows
2025-10-06 00:10:22 +03:00
Egor 746e810c58 Add promo offer timer and updated discount messaging 2025-10-06 00:10:06 +03:00
Egor 2df5d21a9e Merge pull request #787 from Fr1ngg/bedolaga/fix-subscription-button-text-for-trial-users
Fix promo purchase discount CTA for trials
2025-10-05 23:35:36 +03:00
Egor d664b99e1c Adjust promo discount CTA for purchase offers 2025-10-05 23:35:09 +03:00
Egor 74fe3b09a2 Merge pull request #786 from Fr1ngg/revert-785-bedolaga-8svrvz
Revert "Adjust discount claim CTA for purchase offers"
2025-10-05 17:28:03 +03:00
Egor efea0e8414 Revert "Adjust discount claim CTA for purchase offers" 2025-10-05 17:27:54 +03:00
Egor 1dc4d0e04d Merge pull request #785 from Fr1ngg/bedolaga-8svrvz
Adjust discount claim CTA for purchase offers
2025-10-05 17:24:41 +03:00
Egor 84f06a96b3 Adjust discount claim CTA for purchase offers 2025-10-05 17:24:25 +03:00
Egor 4a7cc3306a Merge pull request #783 from Fr1ngg/bedolaga/add-duration-for-subscription-promo-discounts
Add expiration control for promo offer discounts
2025-10-05 17:01:38 +03:00
Egor 5d576bc727 Add expiration control for promo offer discounts 2025-10-05 17:01:11 +03:00
Egor 19a7a8e6b1 Merge pull request #782 from Fr1ngg/bedolaga/update-promo-message-buttons
Adjust promo offer keyboards to add close button
2025-10-05 06:11:11 +03:00
Egor 064f64afd0 Restrict promo offer keyboards to activation and close 2025-10-05 06:10:49 +03:00
Egor bfa5e5aab9 Merge pull request #781 from Fr1ngg/bedolaga-zcm3jb
Fix discount activation CTA to extend subscription
2025-10-05 05:47:29 +03:00
Egor f59bda57cf Adjust discount activation CTA 2025-10-05 05:46:55 +03:00
Egor 6887c265ad Merge pull request #780 from Fr1ngg/bedolaga/fix-chat-message-retention-during-promo-editing
Удаление сообщений администратора при редактировании промо-предложений
2025-10-05 05:31:06 +03:00
Egor 7db31ff5b5 Удаление пользовательских сообщений при редактировании промо 2025-10-05 05:30:45 +03:00
Egor 6bee1bb85d Merge pull request #779 from Fr1ngg/bedolaga/add-active-trials-category-to-promotions
Add active trial segment to purchase discount offer
2025-10-05 05:11:01 +03:00
Egor c604c5f6ff Add active trial segment to purchase promo 2025-10-05 05:10:45 +03:00
Egor ed5e6aa550 Merge pull request #777 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers-qglaer
fix: rollback session after promo log failures
2025-10-05 04:06:21 +03:00
Egor 6b78d0f63e Merge pull request #778 from Fr1ngg/revert-776-bedolaga/add-paginated-log-for-promo-offers-nmhsmh
Revert "fix: restore promo test access claim flow"
2025-10-05 04:05:56 +03:00
Egor fea1819ef8 Revert "fix: restore promo test access claim flow" 2025-10-05 04:05:47 +03:00
Egor 45586277ab fix: rollback session after promo log failures 2025-10-05 04:05:43 +03:00
Egor 8ff3383246 Merge pull request #776 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers-nmhsmh
fix: restore promo test access claim flow
2025-10-05 03:54:57 +03:00
Egor 7879dc5e43 fix: define newly added test access squads 2025-10-05 03:54:39 +03:00
Egor 392084e436 Merge pull request #775 from Fr1ngg/revert-774-revert-771-revert-770-bedolaga/add-paginated-log-for-promo-offers
Revert "Revert "Revert "feat: add promo offer operation logs"""
2025-10-05 03:53:28 +03:00
Egor 256e1cb778 Revert "Revert "Revert "feat: add promo offer operation logs""" 2025-10-05 03:53:16 +03:00
Egor e131f77de3 Merge pull request #774 from Fr1ngg/revert-771-revert-770-bedolaga/add-paginated-log-for-promo-offers
Revert "Revert "feat: add promo offer operation logs""
2025-10-05 03:46:36 +03:00
Egor a60082e5e5 Revert "Revert "feat: add promo offer operation logs"" 2025-10-05 03:46:23 +03:00
Egor 81bb8f8f4a Merge pull request #773 from Fr1ngg/revert-772-bedolaga/add-paginated-log-for-promo-offers-3spy1o
Revert "Add promo offer activity log in admin panel"
2025-10-05 03:46:05 +03:00
Egor a9af75d9df Revert "Add promo offer activity log in admin panel" 2025-10-05 03:45:55 +03:00
Egor 30758848fa Merge pull request #772 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers-3spy1o
Add promo offer activity log in admin panel
2025-10-05 03:44:46 +03:00
Egor 00c024bd2c Add promo offer activity log in admin panel 2025-10-05 03:44:28 +03:00
Egor a69d28fb13 Merge pull request #771 from Fr1ngg/revert-770-bedolaga/add-paginated-log-for-promo-offers
Revert "feat: add promo offer operation logs"
2025-10-05 03:44:03 +03:00
Egor ac818ccf34 Revert "feat: add promo offer operation logs" 2025-10-05 03:43:53 +03:00
Egor 533335bad9 Merge pull request #770 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers
feat: add promo offer operation logs
2025-10-05 03:32:40 +03:00
Egor 37c99ae1fe feat: add promo offer operation logs 2025-10-05 03:32:24 +03:00
Egor d9b232b87b Merge pull request #769 from Fr1ngg/bedolaga/add-buttons-for-subscription-promotions
Enhance promo offer messaging and safeguards
2025-10-04 17:06:41 +03:00
Egor b8df611d0d Enhance promo offer flows 2025-10-04 17:06:24 +03:00
Egor e0b5beed02 Merge pull request #768 from Fr1ngg/bedolaga/add-purchase-button-to-discount-message
Fix promo discount message button and checkout crash
2025-10-04 16:43:04 +03:00
Egor f92cf97f63 Fix promo discount claim actions 2025-10-04 16:42:44 +03:00
Egor 9ae11bc525 Merge pull request #767 from Fr1ngg/bedolaga/update-promo-offer-functionality-in-admin-panel
Adjust promo test offers with server targeting
2025-10-04 16:29:38 +03:00
Egor ef1426a938 Adjust promo test offers with server targeting 2025-10-04 16:29:23 +03:00
Egor 456b02e2d9 Merge pull request #766 from Fr1ngg/bedolaga/enhance-promo-offer-functionality-onp0w2
Ensure subscription service reloads RemnaWave configuration
2025-10-04 16:12:25 +03:00
Egor abb156a7b1 Fix RemnaWave config refresh in subscription service 2025-10-04 16:12:03 +03:00
Egor ca8917a43c Merge pull request #765 from Fr1ngg/revert-764-bedolaga/enhance-promo-offer-functionality-mxpx1o
Revert "Fix test access sync and add menu navigation"
2025-10-04 16:08:54 +03:00
Egor 8ce75007ff Revert "Fix test access sync and add menu navigation" 2025-10-04 16:08:45 +03:00
Egor 76ced9aeeb Merge pull request #764 from Fr1ngg/bedolaga/enhance-promo-offer-functionality-mxpx1o
Fix test access sync and add menu navigation
2025-10-04 13:10:40 +03:00
Egor 42eeb67039 Fix test access sync and add back to menu button 2025-10-04 13:10:23 +03:00
Egor c11be659b3 Merge pull request #763 from Fr1ngg/revert-762-bedolaga/enhance-promo-offer-functionality-wktrm4
Revert "Improve test access promo syncing and messaging"
2025-10-04 13:09:58 +03:00
Egor 2a44d98015 Revert "Improve test access promo syncing and messaging" 2025-10-04 13:09:48 +03:00
Egor 885fa2fb1a Merge pull request #762 from Fr1ngg/bedolaga/enhance-promo-offer-functionality-wktrm4
Improve test access promo syncing and messaging
2025-10-04 13:07:00 +03:00
Egor 8bcc2135ca Handle Remnawave sync failures for test access offers 2025-10-04 13:06:43 +03:00
Egor a26d2fb50b Merge pull request #761 from Fr1ngg/revert-760-bedolaga/enhance-promo-offer-functionality
Revert "Add admin squad picker for test access promo offers"
2025-10-04 13:06:18 +03:00
Egor ad3a8b3af3 Revert "Add admin squad picker for test access promo offers" 2025-10-04 13:06:09 +03:00
Egor 918a450e63 Merge pull request #760 from Fr1ngg/bedolaga/enhance-promo-offer-functionality
Add admin squad picker for test access promo offers
2025-10-04 12:52:52 +03:00
Egor 596053c720 feat: add squad picker for test promo offers 2025-10-04 12:52:27 +03:00
Egor abb83b43bc Merge pull request #759 from Fr1ngg/bedolaga/fix-promo-discount-handling-and-messages
Fix promo discount consumption race and refresh promo offer texts
2025-10-04 12:25:22 +03:00
Egor 6f67a36a58 Fix promo discount consumption and admin copy 2025-10-04 12:25:00 +03:00
Egor 9cbc9bb3d5 Merge pull request #758 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-5mdcu6
Honor promo offer discounts in autopay renewals
2025-10-04 12:10:34 +03:00
Egor 1cbf3c5643 Recalculate promo offer discount during subscription confirmation 2025-10-04 12:10:04 +03:00
Egor 8f35d56e6a Merge pull request #757 from Fr1ngg/revert-756-bedolaga/update-promo-offers-to-apply-percentage-discount-3jskcs
Revert "Honor promo offer discounts in autopay renewals"
2025-10-04 12:09:13 +03:00
Egor b5773ba668 Revert "Honor promo offer discounts in autopay renewals" 2025-10-04 12:09:04 +03:00
Egor fa5847ee64 Merge pull request #756 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-3jskcs
Honor promo offer discounts in autopay renewals
2025-10-04 11:53:09 +03:00
Egor b561fbc9e5 Apply promo offer discounts to autopay renewals 2025-10-04 11:52:38 +03:00
Egor 305a0b6b95 Merge pull request #755 from Fr1ngg/revert-754-bedolaga/update-promo-offers-to-apply-percentage-discount-u1051x
Revert "Fix promo offer validation to use pre-discount totals"
2025-10-04 11:52:02 +03:00
Egor 12a16097cf Revert "Fix promo offer validation to use pre-discount totals" 2025-10-04 11:51:52 +03:00
Egor c8986bea40 Merge pull request #754 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-u1051x
Fix promo offer validation to use pre-discount totals
2025-10-04 11:43:00 +03:00
Egor bcf01ecf6a Fix promo offer validation to use pre-discount totals 2025-10-04 11:42:43 +03:00
Egor a334fbffb7 Merge pull request #753 from Fr1ngg/revert-752-bedolaga/update-promo-offers-to-apply-percentage-discount-lt6fpc
Revert "Refactor promo offers to apply stackable discounts"
2025-10-04 11:42:16 +03:00
Egor fc8f55195f Revert "Refactor promo offers to apply stackable discounts" 2025-10-04 11:42:06 +03:00
Egor dbd79cbc65 Merge pull request #752 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-lt6fpc
Refactor promo offers to apply stackable discounts
2025-10-04 11:35:06 +03:00
Egor 436686dd1b Ensure promo offers use stackable discounts 2025-10-04 11:34:46 +03:00
Egor 227e754f4a Merge pull request #751 from Fr1ngg/revert-750-bedolaga/update-promo-offers-to-apply-percentage-discount-c11ife
Revert "Implement percent-based promo discounts"
2025-10-04 11:34:31 +03:00
Egor 3d43358b86 Revert "Implement percent-based promo discounts" 2025-10-04 11:34:22 +03:00
Egor f05f32b92e Merge pull request #750 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-c11ife
Implement percent-based promo discounts
2025-10-04 11:32:43 +03:00
Egor 7628b9aaa7 Implement percent-based promo discounts 2025-10-04 11:31:55 +03:00
Egor 9ab8f1dee9 Merge pull request #748 from Fr1ngg/revert-747-bedolaga/update-promo-offers-to-apply-percentage-discount
Revert "Implement stackable percent-based promo offers"
2025-10-04 11:31:05 +03:00
Egor 092ea034ec Revert "Implement stackable percent-based promo offers" 2025-10-04 11:30:57 +03:00
Egor c4e5261b31 Merge pull request #747 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount
Implement stackable percent-based promo offers
2025-10-04 11:29:04 +03:00
Egor 2baf2b9abb Adjust promo offer templates for percent discounts 2025-10-04 11:28:47 +03:00
Egor 87a6b05e54 Merge pull request #746 from Fr1ngg/revert-745-bedolaga/update-promo-offers-for-discounts-rwpidj
Revert "Implement percent-based promo offers"
2025-10-04 11:05:56 +03:00
Egor 0925dfdf80 Revert "Implement percent-based promo offers" 2025-10-04 11:05:47 +03:00
Egor cf294fae0a Merge pull request #745 from Fr1ngg/bedolaga/update-promo-offers-for-discounts-rwpidj
Implement percent-based promo offers
2025-10-04 10:58:35 +03:00
Egor 12e77cd6bf Implement percent-based promo offers 2025-10-04 10:57:00 +03:00
Egor 867ae1b807 Merge pull request #744 from Fr1ngg/revert-743-bedolaga/update-promo-offers-for-discounts-9jz4ar
Revert "Clear expired promo discounts when preparing checkout summaries"
2025-10-04 10:55:45 +03:00
Egor db9f91b336 Revert "Clear expired promo discounts when preparing checkout summaries" 2025-10-04 10:55:32 +03:00
Egor df0792d4dd Merge pull request #743 from Fr1ngg/bedolaga/update-promo-offers-for-discounts-9jz4ar
Clear expired promo discounts when preparing checkout summaries
2025-10-04 10:54:35 +03:00
Egor 0e0569fe23 Clear expired promo discounts before checkout 2025-10-04 10:53:09 +03:00
Egor daa37370e3 Merge pull request #741 from Fr1ngg/bedolaga/fix-invalid-values-error-for-active-subscriptions
Fix parsing of promo offer send callbacks
2025-10-04 10:19:36 +03:00
Egor b35c731ba5 Fix promo offer segment parsing 2025-10-04 10:19:05 +03:00
Egor 06879089a1 Merge pull request #740 from Fr1ngg/bedolaga/fix-missing-join-in-promo-segment-query
Fix promo segment join for no subscription users
2025-10-04 10:14:01 +03:00
Egor b96d530808 Fix promo segment no subscription join 2025-10-04 10:13:45 +03:00
Egor 8f3c8f37c1 Merge pull request #739 from Fr1ngg/bedolaga/add-promotional-offers-feature-to-bot-mgwac3
Add admin promo offer management and targeted promotions
2025-10-04 10:05:54 +03:00
Egor e4c7a9cf15 Add admin promo offers and targeted discount workflows 2025-10-04 10:05:31 +03:00
Egor 935e2356db Merge pull request #738 from Fr1ngg/revert-737-bedolaga/add-promotional-offers-feature-to-bot
Revert "Implement targeted promotional offer campaigns"
2025-10-04 10:04:59 +03:00
Egor 3b392a671d Revert "Implement targeted promotional offer campaigns" 2025-10-04 10:04:49 +03:00
Egor 4ceb09d609 Merge pull request #737 from Fr1ngg/bedolaga/add-promotional-offers-feature-to-bot
Implement targeted promotional offer campaigns
2025-10-04 09:59:41 +03:00
Egor 3f8ea7f8fd Add promotional offer broadcasting support 2025-10-04 09:59:17 +03:00
Egor 95525f2132 Update README.md 2025-10-04 08:03:44 +03:00
Egor d15b46dede Merge pull request #736 from Legacyyy777/DEV
Мулен 3
2025-10-04 07:44:54 +03:00
Legacyyy777 7129625ab5 Merge branch 'Fr1ngg:main' into DEV 2025-10-04 09:23:31 +05:00
Legacyyy777 e480356447 Оптимизирована обработка подключения к БД в WebhookServer. Удален цикл async for, добавлена обработка исключений при получении соединения с БД. 2025-10-04 09:21:49 +05:00
49 changed files with 10921 additions and 218 deletions
+2
View File
@@ -161,6 +161,7 @@ TRIBUTE_ENABLED=false
TRIBUTE_API_KEY=
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_HOST=0.0.0.0
TRIBUTE_WEBHOOK_PORT=8081
# YooKassa (https://yookassa.ru)
@@ -211,6 +212,7 @@ YOOKASSA_PAYMENT_SUBJECT=service
# Webhook настройки
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_HOST=0.0.0.0
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.4.3-$(git rev-parse --short HEAD)"
VERSION="v2.4.5-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.4.3-dev-$(git rev-parse --short HEAD)"
VERSION="v2.4.5-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.4.3-pr-$(git rev-parse --short HEAD)"
VERSION="v2.4.5-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.4.3-$(git rev-parse --short HEAD)"
VERSION="v2.4.5-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.4.3-dev-$(git rev-parse --short HEAD)"
VERSION="v2.4.5-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.4.3-pr-$(git rev-parse --short HEAD)"
VERSION="v2.4.5-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.4.3"
ARG VERSION="v2.4.5"
ARG BUILD_DATE
ARG VCS_REF
+3 -1
View File
@@ -155,7 +155,7 @@ docker compose logs
2. Убедитесь, что входящий трафик на **80/tcp и 443/tcp** открыт (брандмауэр, облачный фаервол).
3. В `.env` пропишите корректные URL, чтобы бот формировал ссылки с HTTPS-доменами:
```env
WEBHOOK_URL=https://hooks.example.com/webhook
WEBHOOK_URL=https://hooks.example.com
WEB_API_ENABLED=true
WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com
MINIAPP_CUSTOM_URL=https://miniapp.example.com
@@ -710,6 +710,7 @@ TRIBUTE_ENABLED=false
TRIBUTE_API_KEY=
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_HOST=0.0.0.0
TRIBUTE_WEBHOOK_PORT=8081
# YooKassa (https://yookassa.ru)
@@ -760,6 +761,7 @@ YOOKASSA_PAYMENT_SUBJECT=service
# Webhook настройки
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_HOST=0.0.0.0
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
+8
View File
@@ -33,6 +33,7 @@ from app.handlers.admin import (
maintenance as admin_maintenance,
promo_groups as admin_promo_groups,
campaigns as admin_campaigns,
promo_offers as admin_promo_offers,
user_messages as admin_user_messages,
updates as admin_updates,
backup as admin_backup,
@@ -42,6 +43,9 @@ from app.handlers.admin import (
reports as admin_reports,
bot_configuration as admin_bot_configuration,
pricing as admin_pricing,
privacy_policy as admin_privacy_policy,
public_offer as admin_public_offer,
faq as admin_faq,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -137,6 +141,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_statistics.register_handlers(dp)
admin_promo_groups.register_handlers(dp)
admin_campaigns.register_handlers(dp)
admin_promo_offers.register_handlers(dp)
admin_maintenance.register_handlers(dp)
admin_user_messages.register_handlers(dp)
admin_updates.register_handlers(dp)
@@ -147,6 +152,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_reports.register_handlers(dp)
admin_bot_configuration.register_handlers(dp)
admin_pricing.register_handlers(dp)
admin_privacy_policy.register_handlers(dp)
admin_public_offer.register_handlers(dp)
admin_faq.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
+3 -1
View File
@@ -149,6 +149,7 @@ class Settings(BaseSettings):
TRIBUTE_API_KEY: Optional[str] = None
TRIBUTE_DONATE_LINK: Optional[str] = None
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
TRIBUTE_WEBHOOK_HOST: str = "0.0.0.0"
TRIBUTE_WEBHOOK_PORT: int = 8081
YOOKASSA_ENABLED: bool = False
@@ -158,9 +159,10 @@ class Settings(BaseSettings):
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = None
YOOKASSA_VAT_CODE: int = 1
YOOKASSA_SBP_ENABLED: bool = False
YOOKASSA_PAYMENT_MODE: str = "full_payment"
YOOKASSA_PAYMENT_MODE: str = "full_payment"
YOOKASSA_PAYMENT_SUBJECT: str = "service"
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
YOOKASSA_WEBHOOK_PORT: int = 8082
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
+164 -4
View File
@@ -1,11 +1,18 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Optional
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.database.models import DiscountOffer
logger = logging.getLogger(__name__)
async def upsert_discount_offer(
db: AsyncSession,
@@ -16,6 +23,8 @@ async def upsert_discount_offer(
discount_percent: int,
bonus_amount_kopeks: int,
valid_hours: int,
effect_type: str = "percent_discount",
extra_data: Optional[dict] = None,
) -> DiscountOffer:
"""Create or refresh a discount offer for a user."""
@@ -37,6 +46,8 @@ async def upsert_discount_offer(
offer.bonus_amount_kopeks = bonus_amount_kopeks
offer.expires_at = expires_at
offer.subscription_id = subscription_id
offer.effect_type = effect_type
offer.extra_data = extra_data
else:
offer = DiscountOffer(
user_id=user_id,
@@ -46,6 +57,8 @@ async def upsert_discount_offer(
bonus_amount_kopeks=bonus_amount_kopeks,
expires_at=expires_at,
is_active=True,
effect_type=effect_type,
extra_data=extra_data,
)
db.add(offer)
@@ -56,16 +69,103 @@ async def upsert_discount_offer(
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
result = await db.execute(
select(DiscountOffer).where(DiscountOffer.id == offer_id)
select(DiscountOffer)
.options(
selectinload(DiscountOffer.user),
selectinload(DiscountOffer.subscription),
)
.where(DiscountOffer.id == offer_id)
)
return result.scalar_one_or_none()
async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer:
async def list_discount_offers(
db: AsyncSession,
*,
offset: int = 0,
limit: int = 50,
user_id: Optional[int] = None,
notification_type: Optional[str] = None,
is_active: Optional[bool] = None,
) -> List[DiscountOffer]:
stmt = (
select(DiscountOffer)
.options(
selectinload(DiscountOffer.user),
selectinload(DiscountOffer.subscription),
)
.order_by(DiscountOffer.created_at.desc())
.offset(offset)
.limit(limit)
)
if user_id is not None:
stmt = stmt.where(DiscountOffer.user_id == user_id)
if notification_type:
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
if is_active is not None:
stmt = stmt.where(DiscountOffer.is_active == is_active)
result = await db.execute(stmt)
return result.scalars().all()
async def count_discount_offers(
db: AsyncSession,
*,
user_id: Optional[int] = None,
notification_type: Optional[str] = None,
is_active: Optional[bool] = None,
) -> int:
stmt = select(func.count(DiscountOffer.id))
if user_id is not None:
stmt = stmt.where(DiscountOffer.user_id == user_id)
if notification_type:
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
if is_active is not None:
stmt = stmt.where(DiscountOffer.is_active == is_active)
result = await db.execute(stmt)
return int(result.scalar() or 0)
async def mark_offer_claimed(
db: AsyncSession,
offer: DiscountOffer,
*,
details: Optional[dict] = None,
) -> DiscountOffer:
offer.claimed_at = datetime.utcnow()
offer.is_active = False
await db.commit()
await db.refresh(offer)
try:
await log_promo_offer_action(
db,
user_id=offer.user_id,
offer_id=offer.id,
action="claimed",
source=offer.notification_type,
percent=offer.discount_percent,
effect_type=offer.effect_type,
details=details,
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer claim log for offer %s: %s",
offer.id,
exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer claim log failure: %s",
rollback_error,
)
return offer
@@ -82,9 +182,69 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
return 0
count = 0
log_payloads = []
for offer in offers:
offer.is_active = False
count += 1
log_payloads.append(
{
"user_id": offer.user_id,
"offer_id": offer.id,
"source": offer.notification_type,
"percent": offer.discount_percent,
"effect_type": offer.effect_type,
}
)
await db.commit()
for payload in log_payloads:
if not payload.get("user_id"):
continue
try:
await log_promo_offer_action(
db,
user_id=payload["user_id"],
offer_id=payload["offer_id"],
action="disabled",
source=payload.get("source"),
percent=payload.get("percent"),
effect_type=payload.get("effect_type"),
details={"reason": "offer_expired"},
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer disable log for offer %s: %s",
payload.get("offer_id"),
exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer disable log failure: %s",
rollback_error,
)
return count
async def get_latest_claimed_offer_for_user(
db: AsyncSession,
user_id: int,
source: Optional[str] = None,
) -> Optional[DiscountOffer]:
stmt = (
select(DiscountOffer)
.where(
DiscountOffer.user_id == user_id,
DiscountOffer.claimed_at.isnot(None),
)
.order_by(DiscountOffer.claimed_at.desc())
)
if source:
stmt = stmt.where(DiscountOffer.notification_type == source)
result = await db.execute(stmt)
return result.scalars().first()
+150
View File
@@ -0,0 +1,150 @@
import logging
from datetime import datetime
from typing import Iterable, Optional
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FaqPage, FaqSetting
logger = logging.getLogger(__name__)
async def get_faq_setting(db: AsyncSession, language: str) -> Optional[FaqSetting]:
result = await db.execute(
select(FaqSetting).where(FaqSetting.language == language)
)
return result.scalar_one_or_none()
async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
setting = await get_faq_setting(db, language)
if setting:
setting.is_enabled = bool(enabled)
setting.updated_at = datetime.utcnow()
else:
setting = FaqSetting(
language=language,
is_enabled=bool(enabled),
)
db.add(setting)
await db.commit()
await db.refresh(setting)
logger.info(
"✅ Статус FAQ для языка %s обновлен: %s",
language,
"enabled" if setting.is_enabled else "disabled",
)
return setting
async def upsert_faq_setting(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
return await set_faq_enabled(db, language, enabled)
async def get_faq_pages(
db: AsyncSession,
language: str,
*,
include_inactive: bool = False,
) -> list[FaqPage]:
query = select(FaqPage).where(FaqPage.language == language)
if not include_inactive:
query = query.where(FaqPage.is_active.is_(True))
query = query.order_by(FaqPage.display_order.asc(), FaqPage.id.asc())
result = await db.execute(query)
pages = list(result.scalars().all())
return pages
async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> Optional[FaqPage]:
result = await db.execute(select(FaqPage).where(FaqPage.id == page_id))
return result.scalar_one_or_none()
async def create_faq_page(
db: AsyncSession,
*,
language: str,
title: str,
content: str,
display_order: Optional[int] = None,
is_active: bool = True,
) -> FaqPage:
if display_order is None:
result = await db.execute(
select(func.max(FaqPage.display_order)).where(FaqPage.language == language)
)
max_order = result.scalar() or 0
display_order = max_order + 1
page = FaqPage(
language=language,
title=title,
content=content,
display_order=display_order,
is_active=is_active,
)
db.add(page)
await db.commit()
await db.refresh(page)
logger.info("✅ Создана страница FAQ %s для языка %s", page.id, language)
return page
async def update_faq_page(
db: AsyncSession,
page: FaqPage,
*,
title: Optional[str] = None,
content: Optional[str] = None,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
) -> FaqPage:
if title is not None:
page.title = title
if content is not None:
page.content = content
if display_order is not None:
page.display_order = display_order
if is_active is not None:
page.is_active = bool(is_active)
page.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(page)
logger.info("✅ Страница FAQ %s обновлена", page.id)
return page
async def delete_faq_page(db: AsyncSession, page_id: int) -> None:
await db.execute(delete(FaqPage).where(FaqPage.id == page_id))
await db.commit()
logger.info("🗑️ Страница FAQ %s удалена", page_id)
async def bulk_update_order(
db: AsyncSession,
pages: Iterable[tuple[int, int]],
) -> None:
for page_id, order in pages:
await db.execute(
update(FaqPage)
.where(FaqPage.id == page_id)
.values(display_order=order, updated_at=datetime.utcnow())
)
await db.commit()
+79
View File
@@ -0,0 +1,79 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PrivacyPolicy
logger = logging.getLogger(__name__)
async def get_privacy_policy(db: AsyncSession, language: str) -> Optional[PrivacyPolicy]:
result = await db.execute(
select(PrivacyPolicy).where(PrivacyPolicy.language == language)
)
return result.scalar_one_or_none()
async def upsert_privacy_policy(
db: AsyncSession,
language: str,
content: str,
*,
enable_if_new: bool = True,
) -> PrivacyPolicy:
policy = await get_privacy_policy(db, language)
if policy:
policy.content = content or ""
policy.updated_at = datetime.utcnow()
else:
policy = PrivacyPolicy(
language=language,
content=content or "",
is_enabled=True if enable_if_new else False,
)
db.add(policy)
await db.commit()
await db.refresh(policy)
logger.info(
"✅ Политика конфиденциальности для языка %s обновлена (ID: %s)",
language,
policy.id,
)
return policy
async def set_privacy_policy_enabled(
db: AsyncSession,
language: str,
enabled: bool,
) -> PrivacyPolicy:
policy = await get_privacy_policy(db, language)
if policy:
policy.is_enabled = bool(enabled)
policy.updated_at = datetime.utcnow()
else:
policy = PrivacyPolicy(
language=language,
content="",
is_enabled=bool(enabled),
)
db.add(policy)
await db.commit()
await db.refresh(policy)
logger.info(
"✅ Статус политики конфиденциальности для языка %s обновлен: %s",
language,
"enabled" if policy.is_enabled else "disabled",
)
return policy
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import logging
from typing import Dict, List, Optional, Tuple
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoOfferLog
logger = logging.getLogger(__name__)
async def log_promo_offer_action(
db: AsyncSession,
*,
user_id: Optional[int],
offer_id: Optional[int],
action: str,
source: Optional[str] = None,
percent: Optional[int] = None,
effect_type: Optional[str] = None,
details: Optional[Dict[str, object]] = None,
commit: bool = True,
) -> PromoOfferLog:
"""Persist a promo offer log entry."""
entry = PromoOfferLog(
user_id=user_id,
offer_id=offer_id,
action=action,
source=source,
percent=percent,
effect_type=effect_type,
details=(details or {}).copy(),
)
db.add(entry)
if commit:
try:
await db.commit()
await db.refresh(entry)
except Exception:
logger.exception("Failed to commit promo offer log entry")
raise
return entry
async def list_promo_offer_logs(
db: AsyncSession,
offset: int = 0,
limit: int = 20,
*,
user_id: Optional[int] = None,
offer_id: Optional[int] = None,
action: Optional[str] = None,
source: Optional[str] = None,
) -> Tuple[List[PromoOfferLog], int]:
stmt = (
select(PromoOfferLog)
.options(
selectinload(PromoOfferLog.user),
selectinload(PromoOfferLog.offer),
)
.order_by(PromoOfferLog.created_at.desc())
.offset(offset)
.limit(limit)
)
if user_id is not None:
stmt = stmt.where(PromoOfferLog.user_id == user_id)
if offer_id is not None:
stmt = stmt.where(PromoOfferLog.offer_id == offer_id)
if action:
stmt = stmt.where(PromoOfferLog.action == action)
if source:
stmt = stmt.where(PromoOfferLog.source == source)
result = await db.execute(stmt)
logs = result.scalars().all()
count_stmt = select(func.count(PromoOfferLog.id))
if user_id is not None:
count_stmt = count_stmt.where(PromoOfferLog.user_id == user_id)
if offer_id is not None:
count_stmt = count_stmt.where(PromoOfferLog.offer_id == offer_id)
if action:
count_stmt = count_stmt.where(PromoOfferLog.action == action)
if source:
count_stmt = count_stmt.where(PromoOfferLog.source == source)
total = (await db.execute(count_stmt)).scalar() or 0
return logs, total
+224
View File
@@ -0,0 +1,224 @@
from __future__ import annotations
from datetime import datetime
from typing import Iterable, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PromoOfferTemplate
UPDATED_TEMPLATE_MESSAGES = {
"extend_discount": (
"💎 Экономия {discount_percent}% при продлении\n\n"
"Скидка суммируется с промогруппой и действует один раз.\n"
"Срок действия предложения — {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
"purchase_discount": (
"🎯 Вернитесь со скидкой {discount_percent}%\n\n"
"Скидка суммируется с промогруппой и действует один раз.\n"
"Предложение действует {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
}
LEGACY_TEMPLATE_MESSAGES = {
"extend_discount": (
"💎 <b>Экономия {discount_percent}% при продлении</b>\n\n"
"Активируйте предложение и получите дополнительную скидку на оплату продления. "
"Она суммируется с вашими промогрупповыми скидками и действует один раз.\n"
"Срок действия предложения — {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
"purchase_discount": (
"🎯 <b>Вернитесь со скидкой {discount_percent}%</b>\n\n"
"После активации мы применим дополнительную скидку к вашей следующей оплате подписки. "
"Скидка суммируется с промогруппой и действует один раз.\n"
"Предложение действует {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
}
DEFAULT_TEMPLATES: tuple[dict, ...] = (
{
"offer_type": "test_access",
"name": "Тестовые сервера",
"message_text": (
"🔥 <b>Испытайте новые сервера</b>\n\n"
"Активируйте предложение и получите временный доступ к дополнительным сквадам на {test_duration_hours} ч.\n"
"Предложение действительно {valid_hours} ч."
),
"button_text": "🚀 Попробовать серверы",
"valid_hours": 24,
"discount_percent": 0,
"bonus_amount_kopeks": 0,
"active_discount_hours": None,
"test_duration_hours": 24,
"test_squad_uuids": [],
},
{
"offer_type": "extend_discount",
"name": "Скидка на продление",
"message_text": UPDATED_TEMPLATE_MESSAGES["extend_discount"],
"button_text": "🎁 Получить скидку",
"valid_hours": 24,
"discount_percent": 20,
"bonus_amount_kopeks": 0,
"active_discount_hours": 24,
"test_duration_hours": None,
"test_squad_uuids": [],
},
{
"offer_type": "purchase_discount",
"name": "Скидка на покупку",
"message_text": UPDATED_TEMPLATE_MESSAGES["purchase_discount"],
"button_text": "🎁 Забрать скидку",
"valid_hours": 48,
"discount_percent": 25,
"bonus_amount_kopeks": 0,
"active_discount_hours": 48,
"test_duration_hours": None,
"test_squad_uuids": [],
},
)
def _format_template_fields(payload: dict) -> dict:
data = dict(payload)
data.setdefault("valid_hours", 24)
data.setdefault("discount_percent", 0)
data.setdefault("bonus_amount_kopeks", 0)
data.setdefault("active_discount_hours", None)
data.setdefault("test_duration_hours", None)
data.setdefault("test_squad_uuids", [])
return data
async def ensure_default_templates(db: AsyncSession, *, created_by: Optional[int] = None) -> List[PromoOfferTemplate]:
templates: List[PromoOfferTemplate] = []
for template_data in DEFAULT_TEMPLATES:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == template_data["offer_type"])
)
existing = result.scalars().first()
if existing:
new_message = UPDATED_TEMPLATE_MESSAGES.get(template_data["offer_type"])
legacy_message = LEGACY_TEMPLATE_MESSAGES.get(template_data["offer_type"])
should_update = False
if new_message and legacy_message and existing.message_text == legacy_message:
should_update = True
elif new_message and (
"{bonus_amount" in existing.message_text or "Мы начислим" in existing.message_text
):
should_update = True
if should_update and new_message:
existing.message_text = new_message
existing.updated_at = datetime.utcnow()
await db.flush()
target_active_hours = template_data.get("active_discount_hours")
if (
target_active_hours is not None
and target_active_hours > 0
and not existing.active_discount_hours
):
existing.active_discount_hours = target_active_hours
existing.updated_at = datetime.utcnow()
await db.flush()
templates.append(existing)
continue
payload = _format_template_fields(template_data)
template = PromoOfferTemplate(
name=payload["name"],
offer_type=payload["offer_type"],
message_text=payload["message_text"],
button_text=payload["button_text"],
valid_hours=payload["valid_hours"],
discount_percent=payload["discount_percent"],
bonus_amount_kopeks=payload["bonus_amount_kopeks"],
active_discount_hours=payload["active_discount_hours"],
test_duration_hours=payload["test_duration_hours"],
test_squad_uuids=payload["test_squad_uuids"],
is_active=True,
created_by=created_by,
)
db.add(template)
await db.flush()
templates.append(template)
await db.commit()
return templates
async def list_promo_offer_templates(db: AsyncSession) -> List[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).order_by(PromoOfferTemplate.offer_type, PromoOfferTemplate.id)
)
return result.scalars().all()
async def get_promo_offer_template_by_id(db: AsyncSession, template_id: int) -> Optional[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.id == template_id)
)
return result.scalar_one_or_none()
async def get_promo_offer_template_by_type(db: AsyncSession, offer_type: str) -> Optional[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == offer_type)
)
return result.scalar_one_or_none()
async def update_promo_offer_template(
db: AsyncSession,
template: PromoOfferTemplate,
*,
name: Optional[str] = None,
message_text: Optional[str] = None,
button_text: Optional[str] = None,
valid_hours: Optional[int] = None,
discount_percent: Optional[int] = None,
bonus_amount_kopeks: Optional[int] = None,
active_discount_hours: Optional[int] = None,
test_duration_hours: Optional[int] = None,
test_squad_uuids: Optional[Iterable[str]] = None,
is_active: Optional[bool] = None,
) -> PromoOfferTemplate:
if name is not None:
template.name = name
if message_text is not None:
template.message_text = message_text
if button_text is not None:
template.button_text = button_text
if valid_hours is not None:
template.valid_hours = valid_hours
if discount_percent is not None:
template.discount_percent = discount_percent
if bonus_amount_kopeks is not None:
template.bonus_amount_kopeks = bonus_amount_kopeks
if active_discount_hours is not None:
template.active_discount_hours = active_discount_hours
if test_duration_hours is not None:
template.test_duration_hours = test_duration_hours
if test_squad_uuids is not None:
template.test_squad_uuids = list(test_squad_uuids)
if is_active is not None:
template.is_active = is_active
template.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(template)
return template
+79
View File
@@ -0,0 +1,79 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PublicOffer
logger = logging.getLogger(__name__)
async def get_public_offer(db: AsyncSession, language: str) -> Optional[PublicOffer]:
result = await db.execute(
select(PublicOffer).where(PublicOffer.language == language)
)
return result.scalar_one_or_none()
async def upsert_public_offer(
db: AsyncSession,
language: str,
content: str,
*,
enable_if_new: bool = True,
) -> PublicOffer:
offer = await get_public_offer(db, language)
if offer:
offer.content = content or ""
offer.updated_at = datetime.utcnow()
else:
offer = PublicOffer(
language=language,
content=content or "",
is_enabled=True if enable_if_new else False,
)
db.add(offer)
await db.commit()
await db.refresh(offer)
logger.info(
"✅ Публичная оферта для языка %s обновлена (ID: %s)",
language,
offer.id,
)
return offer
async def set_public_offer_enabled(
db: AsyncSession,
language: str,
enabled: bool,
) -> PublicOffer:
offer = await get_public_offer(db, language)
if offer:
offer.is_enabled = bool(enabled)
offer.updated_at = datetime.utcnow()
else:
offer = PublicOffer(
language=language,
content="",
is_enabled=bool(enabled),
)
db.add(offer)
await db.commit()
await db.refresh(offer)
logger.info(
"✅ Статус публичной оферты для языка %s обновлен: %s",
language,
"enabled" if offer.is_enabled else "disabled",
)
return offer
+219 -2
View File
@@ -12,6 +12,7 @@ from app.database.models import (
User,
UserStatus,
Subscription,
SubscriptionStatus,
Transaction,
PromoGroup,
PaymentMethod,
@@ -19,6 +20,8 @@ from app.database.models import (
)
from app.config import settings
from app.database.crud.promo_group import get_default_promo_group
from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.utils.validators import sanitize_telegram_name
logger = logging.getLogger(__name__)
@@ -272,6 +275,8 @@ async def subtract_user_balance(
description: str,
create_transaction: bool = False,
payment_method: Optional[PaymentMethod] = None,
*,
consume_promo_offer: bool = False,
) -> bool:
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
@@ -279,15 +284,57 @@ async def subtract_user_balance(
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
logger.error(f" 📝 Описание: {description}")
log_context: Optional[Dict[str, object]] = None
if consume_promo_offer:
try:
current_percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
current_percent = 0
if current_percent > 0:
source = getattr(user, "promo_offer_discount_source", None)
log_context = {
"offer_id": None,
"percent": current_percent,
"source": source,
"effect_type": None,
"details": {
"reason": "manual_charge",
"description": description,
"amount_kopeks": amount_kopeks,
},
}
try:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception as lookup_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to fetch latest claimed promo offer for user %s: %s",
user.id,
lookup_error,
)
offer = None
if offer:
log_context["offer_id"] = offer.id
log_context["effect_type"] = offer.effect_type
if not log_context["percent"] and offer.discount_percent:
log_context["percent"] = offer.discount_percent
if user.balance_kopeks < amount_kopeks:
logger.error(f" ❌ НЕДОСТАТОЧНО СРЕДСТВ!")
return False
try:
old_balance = user.balance_kopeks
user.balance_kopeks -= amount_kopeks
if consume_promo_offer and getattr(user, "promo_offer_discount_percent", 0):
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
@@ -305,6 +352,32 @@ async def subtract_user_balance(
payment_method=payment_method,
)
if consume_promo_offer and log_context:
try:
await log_promo_offer_action(
db,
user_id=user.id,
offer_id=log_context.get("offer_id"),
action="consumed",
source=log_context.get("source"),
percent=log_context.get("percent"),
effect_type=log_context.get("effect_type"),
details=log_context.get("details"),
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer consumption log for user %s: %s",
user.id,
log_error,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer consumption log failure: %s",
rollback_error,
)
logger.error(f" ✅ Средства списаны: {old_balance}{user.balance_kopeks}")
return True
@@ -314,6 +387,97 @@ async def subtract_user_balance(
return False
async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
select(User).where(
User.promo_offer_discount_percent > 0,
User.promo_offer_discount_expires_at.isnot(None),
User.promo_offer_discount_expires_at <= now,
)
)
users = result.scalars().all()
if not users:
return 0
log_payloads: List[Dict[str, object]] = []
for user in users:
try:
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
percent = 0
source = getattr(user, "promo_offer_discount_source", None)
offer_id = None
effect_type = None
if source:
try:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception as lookup_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to fetch latest claimed promo offer for user %s during expiration cleanup: %s",
user.id,
lookup_error,
)
offer = None
if offer:
offer_id = offer.id
effect_type = offer.effect_type
if not percent and offer.discount_percent:
percent = offer.discount_percent
log_payloads.append(
{
"user_id": user.id,
"offer_id": offer_id,
"source": source,
"percent": percent,
"effect_type": effect_type,
}
)
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = now
await db.commit()
for payload in log_payloads:
user_id = payload.get("user_id")
if not user_id:
continue
try:
await log_promo_offer_action(
db,
user_id=user_id,
offer_id=payload.get("offer_id"),
action="disabled",
source=payload.get("source"),
percent=payload.get("percent"),
effect_type=payload.get("effect_type"),
details={"reason": "offer_expired"},
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to log promo offer expiration for user %s: %s",
user_id,
log_error,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer expiration log failure: %s",
rollback_error,
)
return len(users)
async def get_users_list(
db: AsyncSession,
offset: int = 0,
@@ -515,6 +679,59 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
return result.scalars().all()
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
now = datetime.utcnow()
base_query = (
select(User)
.options(selectinload(User.subscription))
.where(User.status == UserStatus.ACTIVE.value)
)
if segment == "no_subscription":
query = (
base_query.outerjoin(Subscription, Subscription.user_id == User.id)
.where(Subscription.id.is_(None))
)
else:
query = base_query.join(Subscription)
if segment == "paid_active":
query = query.where(
Subscription.is_trial == False, # noqa: E712
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now,
)
elif segment == "paid_expired":
query = query.where(
Subscription.is_trial == False, # noqa: E712
or_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= now,
),
)
elif segment == "trial_active":
query = query.where(
Subscription.is_trial == True, # noqa: E712
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now,
)
elif segment == "trial_expired":
query = query.where(
Subscription.is_trial == True, # noqa: E712
or_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= now,
),
)
else:
logger.warning("Неизвестный сегмент для промо: %s", segment)
return []
result = await db.execute(query.order_by(User.id))
return result.scalars().unique().all()
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
threshold_date = datetime.utcnow() - timedelta(days=months * 30)
+115 -3
View File
@@ -386,9 +386,13 @@ class User(Base):
transactions = relationship("Transaction", back_populates="user")
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
discount_offers = relationship("DiscountOffer", back_populates="user")
promo_offer_logs = relationship("PromoOfferLog", back_populates="user")
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
promo_offer_discount_percent = Column(Integer, nullable=False, default=0)
promo_offer_discount_source = Column(String(100), nullable=True)
promo_offer_discount_expires_at = Column(DateTime, nullable=True)
last_remnawave_sync = Column(DateTime, nullable=True)
trojan_password = Column(String(255), nullable=True)
vless_uuid = Column(String(255), nullable=True)
@@ -453,6 +457,7 @@ class Subscription(Base):
user = relationship("User", back_populates="subscription")
discount_offers = relationship("DiscountOffer", back_populates="subscription")
temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription")
@property
def is_active(self) -> bool:
@@ -739,9 +744,9 @@ class Squad(Base):
class ServiceRule(Base):
__tablename__ = "service_rules"
id = Column(Integer, primary_key=True, index=True)
order = Column(Integer, default=0)
title = Column(String(255), nullable=False)
@@ -755,9 +760,54 @@ class ServiceRule(Base):
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class PrivacyPolicy(Base):
__tablename__ = "privacy_policies"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, unique=True)
content = Column(Text, nullable=False)
is_enabled = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class PublicOffer(Base):
__tablename__ = "public_offers"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, unique=True)
content = Column(Text, nullable=False)
is_enabled = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class FaqSetting(Base):
__tablename__ = "faq_settings"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, unique=True)
is_enabled = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class FaqPage(Base):
__tablename__ = "faq_pages"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, index=True)
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False)
display_order = Column(Integer, default=0, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class SystemSetting(Base):
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(255), unique=True, nullable=False)
value = Column(Text, nullable=True)
@@ -811,11 +861,73 @@ class DiscountOffer(Base):
expires_at = Column(DateTime, nullable=False)
claimed_at = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
effect_type = Column(String(50), nullable=False, default="percent_discount")
extra_data = Column(JSON, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="discount_offers")
subscription = relationship("Subscription", back_populates="discount_offers")
logs = relationship("PromoOfferLog", back_populates="offer")
class PromoOfferTemplate(Base):
__tablename__ = "promo_offer_templates"
__table_args__ = (
Index("ix_promo_offer_templates_type", "offer_type"),
)
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
offer_type = Column(String(50), nullable=False)
message_text = Column(Text, nullable=False)
button_text = Column(String(255), nullable=False)
valid_hours = Column(Integer, nullable=False, default=24)
discount_percent = Column(Integer, nullable=False, default=0)
bonus_amount_kopeks = Column(Integer, nullable=False, default=0)
active_discount_hours = Column(Integer, nullable=True)
test_duration_hours = Column(Integer, nullable=True)
test_squad_uuids = Column(JSON, default=list)
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
creator = relationship("User")
class SubscriptionTemporaryAccess(Base):
__tablename__ = "subscription_temporary_access"
id = Column(Integer, primary_key=True, index=True)
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False)
offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="CASCADE"), nullable=False)
squad_uuid = Column(String(255), nullable=False)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=func.now())
deactivated_at = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
was_already_connected = Column(Boolean, default=False, nullable=False)
subscription = relationship("Subscription", back_populates="temporary_accesses")
offer = relationship("DiscountOffer")
class PromoOfferLog(Base):
__tablename__ = "promo_offer_logs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="SET NULL"), nullable=True, index=True)
action = Column(String(50), nullable=False)
source = Column(String(100), nullable=True)
percent = Column(Integer, nullable=True)
effect_type = Column(String(50), nullable=True)
details = Column(JSON, nullable=True)
created_at = Column(DateTime, default=func.now())
user = relationship("User", back_populates="promo_offer_logs")
offer = relationship("DiscountOffer", back_populates="logs")
class BroadcastHistory(Base):
__tablename__ = "broadcast_history"
+758
View File
@@ -728,6 +728,8 @@ async def create_discount_offers_table():
expires_at DATETIME NOT NULL,
claimed_at DATETIME NULL,
is_active BOOLEAN NOT NULL DEFAULT 1,
effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount',
extra_data TEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
@@ -751,6 +753,8 @@ async def create_discount_offers_table():
expires_at TIMESTAMP NOT NULL,
claimed_at TIMESTAMP NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount',
extra_data JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
@@ -772,6 +776,8 @@ async def create_discount_offers_table():
expires_at DATETIME NOT NULL,
claimed_at DATETIME NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount',
extra_data JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_discount_offers_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
@@ -793,6 +799,422 @@ async def create_discount_offers_table():
logger.error(f"Ошибка создания таблицы discount_offers: {e}")
return False
async def ensure_discount_offer_columns():
try:
effect_exists = await check_column_exists('discount_offers', 'effect_type')
extra_exists = await check_column_exists('discount_offers', 'extra_data')
if effect_exists and extra_exists:
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if not effect_exists:
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'"
))
elif db_type == 'mysql':
await conn.execute(text(
"ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'"
))
else:
raise ValueError(f"Unsupported database type: {db_type}")
if not extra_exists:
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE discount_offers ADD COLUMN extra_data TEXT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE discount_offers ADD COLUMN extra_data JSON NULL"
))
elif db_type == 'mysql':
await conn.execute(text(
"ALTER TABLE discount_offers ADD COLUMN extra_data JSON NULL"
))
else:
raise ValueError(f"Unsupported database type: {db_type}")
logger.info("✅ Колонки effect_type и extra_data для discount_offers проверены")
return True
except Exception as e:
logger.error(f"Ошибка обновления колонок discount_offers: {e}")
return False
async def ensure_user_promo_offer_discount_columns():
try:
percent_exists = await check_column_exists('users', 'promo_offer_discount_percent')
source_exists = await check_column_exists('users', 'promo_offer_discount_source')
expires_exists = await check_column_exists('users', 'promo_offer_discount_expires_at')
if percent_exists and source_exists and expires_exists:
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if not percent_exists:
column_def = 'INTEGER NOT NULL DEFAULT 0'
if db_type == 'mysql':
column_def = 'INT NOT NULL DEFAULT 0'
await conn.execute(text(
f"ALTER TABLE users ADD COLUMN promo_offer_discount_percent {column_def}"
))
if not source_exists:
if db_type == 'sqlite':
column_def = 'TEXT NULL'
elif db_type == 'postgresql':
column_def = 'VARCHAR(100) NULL'
elif db_type == 'mysql':
column_def = 'VARCHAR(100) NULL'
else:
raise ValueError(f"Unsupported database type: {db_type}")
await conn.execute(text(
f"ALTER TABLE users ADD COLUMN promo_offer_discount_source {column_def}"
))
if not expires_exists:
if db_type == 'sqlite':
column_def = 'DATETIME NULL'
elif db_type == 'postgresql':
column_def = 'TIMESTAMP NULL'
elif db_type == 'mysql':
column_def = 'DATETIME NULL'
else:
raise ValueError(f"Unsupported database type: {db_type}")
await conn.execute(text(
f"ALTER TABLE users ADD COLUMN promo_offer_discount_expires_at {column_def}"
))
logger.info("✅ Колонки promo_offer_discount_* для users проверены")
return True
except Exception as e:
logger.error(f"Ошибка обновления колонок promo_offer_discount_*: {e}")
return False
async def ensure_promo_offer_template_active_duration_column() -> bool:
try:
column_exists = await check_column_exists('promo_offer_templates', 'active_discount_hours')
async with engine.begin() as conn:
db_type = await get_database_type()
if not column_exists:
if db_type == 'sqlite':
column_def = 'INTEGER NULL'
elif db_type == 'postgresql':
column_def = 'INTEGER NULL'
elif db_type == 'mysql':
column_def = 'INT NULL'
else:
raise ValueError(f"Unsupported database type: {db_type}")
await conn.execute(text(
f"ALTER TABLE promo_offer_templates ADD COLUMN active_discount_hours {column_def}"
))
await conn.execute(text(
"UPDATE promo_offer_templates "
"SET active_discount_hours = valid_hours "
"WHERE offer_type IN ('extend_discount', 'purchase_discount') "
"AND (active_discount_hours IS NULL OR active_discount_hours <= 0)"
))
logger.info("✅ Колонка active_discount_hours в promo_offer_templates актуальна")
return True
except Exception as e:
logger.error(f"Ошибка обновления active_discount_hours в promo_offer_templates: {e}")
return False
async def migrate_discount_offer_effect_types():
try:
async with engine.begin() as conn:
await conn.execute(text(
"UPDATE discount_offers SET effect_type = 'percent_discount' "
"WHERE effect_type = 'balance_bonus'"
))
logger.info("✅ Типы эффектов discount_offers обновлены на percent_discount")
return True
except Exception as e:
logger.error(f"Ошибка обновления типов эффектов discount_offers: {e}")
return False
async def reset_discount_offer_bonuses():
try:
async with engine.begin() as conn:
await conn.execute(text(
"UPDATE discount_offers SET bonus_amount_kopeks = 0 WHERE bonus_amount_kopeks <> 0"
))
await conn.execute(text(
"UPDATE promo_offer_templates SET bonus_amount_kopeks = 0 WHERE bonus_amount_kopeks <> 0"
))
logger.info("✅ Бонусы промо-предложений сброшены до нуля")
return True
except Exception as e:
logger.error(f"Ошибка обнуления бонусов промо-предложений: {e}")
return False
async def create_promo_offer_templates_table():
table_exists = await check_table_exists('promo_offer_templates')
if table_exists:
logger.info("Таблица promo_offer_templates уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE promo_offer_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
offer_type VARCHAR(50) NOT NULL,
message_text TEXT NOT NULL,
button_text VARCHAR(255) NOT NULL,
valid_hours INTEGER NOT NULL DEFAULT 24,
discount_percent INTEGER NOT NULL DEFAULT 0,
bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0,
active_discount_hours INTEGER NULL,
test_duration_hours INTEGER NULL,
test_squad_uuids TEXT NULL,
is_active BOOLEAN NOT NULL DEFAULT 1,
created_by INTEGER NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX ix_promo_offer_templates_type ON promo_offer_templates(offer_type);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE IF NOT EXISTS promo_offer_templates (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
offer_type VARCHAR(50) NOT NULL,
message_text TEXT NOT NULL,
button_text VARCHAR(255) NOT NULL,
valid_hours INTEGER NOT NULL DEFAULT 24,
discount_percent INTEGER NOT NULL DEFAULT 0,
bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0,
active_discount_hours INTEGER NULL,
test_duration_hours INTEGER NULL,
test_squad_uuids JSON NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS ix_promo_offer_templates_type ON promo_offer_templates(offer_type);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE IF NOT EXISTS promo_offer_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
offer_type VARCHAR(50) NOT NULL,
message_text TEXT NOT NULL,
button_text VARCHAR(255) NOT NULL,
valid_hours INT NOT NULL DEFAULT 24,
discount_percent INT NOT NULL DEFAULT 0,
bonus_amount_kopeks INT NOT NULL DEFAULT 0,
active_discount_hours INT NULL,
test_duration_hours INT NULL,
test_squad_uuids JSON NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_by INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX ix_promo_offer_templates_type ON promo_offer_templates(offer_type);
"""
else:
raise ValueError(f"Unsupported database type: {db_type}")
await conn.execute(text(create_sql))
logger.info("✅ Таблица promo_offer_templates успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы promo_offer_templates: {e}")
return False
async def create_promo_offer_logs_table() -> bool:
table_exists = await check_table_exists('promo_offer_logs')
if table_exists:
logger.info("Таблица promo_offer_logs уже существует")
return True
try:
db_type = await get_database_type()
async with engine.begin() as conn:
if db_type == 'sqlite':
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS promo_offer_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
offer_id INTEGER NULL REFERENCES discount_offers(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL,
source VARCHAR(100) NULL,
percent INTEGER NULL,
effect_type VARCHAR(50) NULL,
details JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_created_at ON promo_offer_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_user_id ON promo_offer_logs(user_id);
"""))
elif db_type == 'postgresql':
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS promo_offer_logs (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
offer_id INTEGER REFERENCES discount_offers(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL,
source VARCHAR(100),
percent INTEGER,
effect_type VARCHAR(50),
details JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_created_at ON promo_offer_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_user_id ON promo_offer_logs(user_id);
"""))
elif db_type == 'mysql':
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS promo_offer_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
offer_id INT NULL,
action VARCHAR(50) NOT NULL,
source VARCHAR(100) NULL,
percent INT NULL,
effect_type VARCHAR(50) NULL,
details JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_promo_offer_logs_users FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_promo_offer_logs_offers FOREIGN KEY (offer_id) REFERENCES discount_offers(id) ON DELETE SET NULL
);
CREATE INDEX ix_promo_offer_logs_created_at ON promo_offer_logs(created_at DESC);
CREATE INDEX ix_promo_offer_logs_user_id ON promo_offer_logs(user_id);
"""))
else:
logger.warning("Неизвестный тип БД для создания promo_offer_logs: %s", db_type)
return False
logger.info("✅ Таблица promo_offer_logs успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы promo_offer_logs: {e}")
return False
async def create_subscription_temporary_access_table():
table_exists = await check_table_exists('subscription_temporary_access')
if table_exists:
logger.info("Таблица subscription_temporary_access уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE subscription_temporary_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subscription_id INTEGER NOT NULL,
offer_id INTEGER NOT NULL,
squad_uuid VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deactivated_at DATETIME NULL,
is_active BOOLEAN NOT NULL DEFAULT 1,
was_already_connected BOOLEAN NOT NULL DEFAULT 0,
FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE,
FOREIGN KEY(offer_id) REFERENCES discount_offers(id) ON DELETE CASCADE
);
CREATE INDEX ix_temp_access_subscription ON subscription_temporary_access(subscription_id);
CREATE INDEX ix_temp_access_offer ON subscription_temporary_access(offer_id);
CREATE INDEX ix_temp_access_active ON subscription_temporary_access(is_active, expires_at);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE IF NOT EXISTS subscription_temporary_access (
id SERIAL PRIMARY KEY,
subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
offer_id INTEGER NOT NULL REFERENCES discount_offers(id) ON DELETE CASCADE,
squad_uuid VARCHAR(255) NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deactivated_at TIMESTAMP NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
was_already_connected BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS ix_temp_access_subscription ON subscription_temporary_access(subscription_id);
CREATE INDEX IF NOT EXISTS ix_temp_access_offer ON subscription_temporary_access(offer_id);
CREATE INDEX IF NOT EXISTS ix_temp_access_active ON subscription_temporary_access(is_active, expires_at);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE IF NOT EXISTS subscription_temporary_access (
id INT AUTO_INCREMENT PRIMARY KEY,
subscription_id INT NOT NULL,
offer_id INT NOT NULL,
squad_uuid VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deactivated_at DATETIME NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
was_already_connected BOOLEAN NOT NULL DEFAULT FALSE,
FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE,
FOREIGN KEY(offer_id) REFERENCES discount_offers(id) ON DELETE CASCADE
);
CREATE INDEX ix_temp_access_subscription ON subscription_temporary_access(subscription_id);
CREATE INDEX ix_temp_access_offer ON subscription_temporary_access(offer_id);
CREATE INDEX ix_temp_access_active ON subscription_temporary_access(is_active, expires_at);
"""
else:
raise ValueError(f"Unsupported database type: {db_type}")
await conn.execute(text(create_sql))
logger.info("✅ Таблица subscription_temporary_access успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы subscription_temporary_access: {e}")
return False
async def create_user_messages_table():
table_exists = await check_table_exists('user_messages')
if table_exists:
@@ -2078,6 +2500,226 @@ async def create_web_api_tokens_table() -> bool:
return False
async def create_privacy_policies_table() -> bool:
table_exists = await check_table_exists("privacy_policies")
if table_exists:
logger.info("️ Таблица privacy_policies уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_sql = """
CREATE TABLE privacy_policies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
language VARCHAR(10) NOT NULL UNIQUE,
content TEXT NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
"""
elif db_type == "postgresql":
create_sql = """
CREATE TABLE privacy_policies (
id SERIAL PRIMARY KEY,
language VARCHAR(10) NOT NULL UNIQUE,
content TEXT NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
"""
else:
create_sql = """
CREATE TABLE privacy_policies (
id INT AUTO_INCREMENT PRIMARY KEY,
language VARCHAR(10) NOT NULL UNIQUE,
content TEXT NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
"""
await conn.execute(text(create_sql))
logger.info("✅ Таблица privacy_policies создана")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы privacy_policies: {error}")
return False
async def create_public_offers_table() -> bool:
table_exists = await check_table_exists("public_offers")
if table_exists:
logger.info("️ Таблица public_offers уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_sql = """
CREATE TABLE public_offers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
language VARCHAR(10) NOT NULL UNIQUE,
content TEXT NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
"""
elif db_type == "postgresql":
create_sql = """
CREATE TABLE public_offers (
id SERIAL PRIMARY KEY,
language VARCHAR(10) NOT NULL UNIQUE,
content TEXT NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
"""
else:
create_sql = """
CREATE TABLE public_offers (
id INT AUTO_INCREMENT PRIMARY KEY,
language VARCHAR(10) NOT NULL UNIQUE,
content TEXT NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
"""
await conn.execute(text(create_sql))
logger.info("✅ Таблица public_offers создана")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы public_offers: {error}")
return False
async def create_faq_settings_table() -> bool:
table_exists = await check_table_exists("faq_settings")
if table_exists:
logger.info("️ Таблица faq_settings уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_sql = """
CREATE TABLE faq_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
language VARCHAR(10) NOT NULL UNIQUE,
is_enabled BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
"""
elif db_type == "postgresql":
create_sql = """
CREATE TABLE faq_settings (
id SERIAL PRIMARY KEY,
language VARCHAR(10) NOT NULL UNIQUE,
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
"""
else:
create_sql = """
CREATE TABLE faq_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
language VARCHAR(10) NOT NULL UNIQUE,
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
"""
await conn.execute(text(create_sql))
logger.info("✅ Таблица faq_settings создана")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы faq_settings: {error}")
return False
async def create_faq_pages_table() -> bool:
table_exists = await check_table_exists("faq_pages")
if table_exists:
logger.info("️ Таблица faq_pages уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_sql = """
CREATE TABLE faq_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
language VARCHAR(10) NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
display_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_faq_pages_language ON faq_pages(language);
"""
elif db_type == "postgresql":
create_sql = """
CREATE TABLE faq_pages (
id SERIAL PRIMARY KEY,
language VARCHAR(10) NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
display_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_faq_pages_language ON faq_pages(language);
CREATE INDEX idx_faq_pages_order ON faq_pages(language, display_order);
"""
else:
create_sql = """
CREATE TABLE faq_pages (
id INT AUTO_INCREMENT PRIMARY KEY,
language VARCHAR(10) NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
display_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE INDEX idx_faq_pages_language ON faq_pages(language);
CREATE INDEX idx_faq_pages_order ON faq_pages(language, display_order);
"""
await conn.execute(text(create_sql))
logger.info("✅ Таблица faq_pages создана")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы faq_pages: {error}")
return False
async def ensure_default_web_api_token() -> bool:
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
if not default_token:
@@ -2160,6 +2802,34 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей web_api_tokens")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PRIVACY_POLICIES ===")
privacy_policies_ready = await create_privacy_policies_table()
if privacy_policies_ready:
logger.info("✅ Таблица privacy_policies готова")
else:
logger.warning("⚠️ Проблемы с таблицей privacy_policies")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PUBLIC_OFFERS ===")
public_offers_ready = await create_public_offers_table()
if public_offers_ready:
logger.info("✅ Таблица public_offers готова")
else:
logger.warning("⚠️ Проблемы с таблицей public_offers")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ FAQ_SETTINGS ===")
faq_settings_ready = await create_faq_settings_table()
if faq_settings_ready:
logger.info("✅ Таблица faq_settings готова")
else:
logger.warning("⚠️ Проблемы с таблицей faq_settings")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ FAQ_PAGES ===")
faq_pages_ready = await create_faq_pages_table()
if faq_pages_ready:
logger.info("✅ Таблица faq_pages готова")
else:
logger.warning("⚠️ Проблемы с таблицей faq_pages")
logger.info("=== ПРОВЕРКА БАЗОВЫХ ТОКЕНОВ ВЕБ-API ===")
default_token_ready = await ensure_default_web_api_token()
if default_token_ready:
@@ -2201,6 +2871,57 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей discount_offers")
discount_columns_ready = await ensure_discount_offer_columns()
if discount_columns_ready:
logger.info("✅ Колонки discount_offers в актуальном состоянии")
else:
logger.warning("⚠️ Не удалось обновить колонки discount_offers")
user_discount_columns_ready = await ensure_user_promo_offer_discount_columns()
if user_discount_columns_ready:
logger.info("✅ Колонки пользовательских промо-скидок готовы")
else:
logger.warning("⚠️ Не удалось обновить пользовательские промо-скидки")
effect_types_updated = await migrate_discount_offer_effect_types()
if effect_types_updated:
logger.info("✅ Типы эффектов промо-предложений обновлены")
else:
logger.warning("⚠️ Не удалось обновить типы эффектов промо-предложений")
bonuses_reset = await reset_discount_offer_bonuses()
if bonuses_reset:
logger.info("✅ Бонусные начисления промо-предложений отключены")
else:
logger.warning("⚠️ Не удалось обнулить бонусы промо-предложений")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PROMO_OFFER_TEMPLATES ===")
promo_templates_created = await create_promo_offer_templates_table()
if promo_templates_created:
logger.info("✅ Таблица promo_offer_templates готова")
else:
logger.warning("⚠️ Проблемы с таблицей promo_offer_templates")
template_columns_ready = await ensure_promo_offer_template_active_duration_column()
if template_columns_ready:
logger.info("✅ Колонка active_discount_hours промо-предложений готова")
else:
logger.warning("⚠️ Не удалось обновить колонку active_discount_hours промо-предложений")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PROMO_OFFER_LOGS ===")
promo_logs_created = await create_promo_offer_logs_table()
if promo_logs_created:
logger.info("✅ Таблица promo_offer_logs готова")
else:
logger.warning("⚠️ Проблемы с таблицей promo_offer_logs")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SUBSCRIPTION_TEMPORARY_ACCESS ===")
temp_access_created = await create_subscription_temporary_access_table()
if temp_access_created:
logger.info("✅ Таблица subscription_temporary_access готова")
else:
logger.warning("⚠️ Проблемы с таблицей subscription_temporary_access")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===")
user_messages_created = await create_user_messages_table()
if user_messages_created:
@@ -2395,13 +3116,25 @@ async def check_migration_status():
"subscription_conversions_table": False,
"promo_groups_table": False,
"server_promo_groups_table": False,
"privacy_policies_table": False,
"public_offers_table": False,
"users_promo_group_column": False,
"promo_groups_period_discounts_column": False,
"promo_groups_auto_assign_column": False,
"promo_groups_addon_discount_column": False,
"users_auto_promo_group_assigned_column": False,
"users_auto_promo_group_threshold_column": False,
"users_promo_offer_discount_percent_column": False,
"users_promo_offer_discount_source_column": False,
"users_promo_offer_discount_expires_column": False,
"subscription_crypto_link_column": False,
"discount_offers_table": False,
"discount_offers_effect_column": False,
"discount_offers_extra_column": False,
"promo_offer_templates_table": False,
"promo_offer_templates_active_discount_column": False,
"promo_offer_logs_table": False,
"subscription_temporary_access_table": False,
}
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
@@ -2409,10 +3142,20 @@ async def check_migration_status():
status["cryptobot_table"] = await check_table_exists('cryptobot_payments')
status["user_messages_table"] = await check_table_exists('user_messages')
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
status["privacy_policies_table"] = await check_table_exists('privacy_policies')
status["public_offers_table"] = await check_table_exists('public_offers')
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
status["promo_groups_table"] = await check_table_exists('promo_groups')
status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups')
status["discount_offers_table"] = await check_table_exists('discount_offers')
status["discount_offers_effect_column"] = await check_column_exists('discount_offers', 'effect_type')
status["discount_offers_extra_column"] = await check_column_exists('discount_offers', 'extra_data')
status["promo_offer_templates_table"] = await check_table_exists('promo_offer_templates')
status["promo_offer_templates_active_discount_column"] = await check_column_exists('promo_offer_templates', 'active_discount_hours')
status["promo_offer_logs_table"] = await check_table_exists('promo_offer_logs')
status["subscription_temporary_access_table"] = await check_table_exists('subscription_temporary_access')
status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled')
status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id')
status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts')
@@ -2420,6 +3163,9 @@ async def check_migration_status():
status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons')
status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned')
status["users_auto_promo_group_threshold_column"] = await check_column_exists('users', 'auto_promo_group_threshold_kopeks')
status["users_promo_offer_discount_percent_column"] = await check_column_exists('users', 'promo_offer_discount_percent')
status["users_promo_offer_discount_source_column"] = await check_column_exists('users', 'promo_offer_discount_source')
status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at')
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
media_fields_exist = (
@@ -2447,6 +3193,8 @@ async def check_migration_status():
"cryptobot_table": "Таблица CryptoBot payments",
"user_messages_table": "Таблица пользовательских сообщений",
"welcome_texts_table": "Таблица приветственных текстов",
"privacy_policies_table": "Таблица политик конфиденциальности",
"public_offers_table": "Таблица публичных оферт",
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
"subscription_conversions_table": "Таблица конверсий подписок",
@@ -2459,7 +3207,17 @@ async def check_migration_status():
"promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп",
"users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей",
"users_auto_promo_group_threshold_column": "Порог последней авто-промогруппы у пользователей",
"users_promo_offer_discount_percent_column": "Колонка процента промо-скидки у пользователей",
"users_promo_offer_discount_source_column": "Колонка источника промо-скидки у пользователей",
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
"discount_offers_table": "Таблица discount_offers",
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
"discount_offers_extra_column": "Колонка extra_data в discount_offers",
"promo_offer_templates_table": "Таблица promo_offer_templates",
"promo_offer_templates_active_discount_column": "Колонка active_discount_hours в promo_offer_templates",
"promo_offer_logs_table": "Таблица promo_offer_logs",
"subscription_temporary_access_table": "Таблица subscription_temporary_access",
}
for check_key, check_status in status.items():
+40 -20
View File
@@ -66,20 +66,37 @@ class WebhookServer:
self.site = web.TCPSite(
self.runner,
host='0.0.0.0',
host=settings.TRIBUTE_WEBHOOK_HOST,
port=settings.TRIBUTE_WEBHOOK_PORT
)
await self.site.start()
logger.info(f"Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
logger.info(f"Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
logger.info(
"Webhook сервер запущен на %s:%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
)
logger.info(
"Tribute webhook URL: http://%s:%s%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.TRIBUTE_WEBHOOK_PATH,
)
if settings.is_mulenpay_enabled():
logger.info(
f"Mulen Pay webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
"Mulen Pay webhook URL: http://%s:%s%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.MULENPAY_WEBHOOK_PATH,
)
if settings.is_cryptobot_enabled():
logger.info(f"CryptoBot webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
logger.info(
"CryptoBot webhook URL: http://%s:%s%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.CRYPTOBOT_WEBHOOK_PATH,
)
except Exception as e:
logger.error(f"Ошибка запуска webhook сервера: {e}")
@@ -133,20 +150,23 @@ class WebhookServer:
payment_service = PaymentService(self.bot)
async for db in get_db():
try:
success = await payment_service.process_mulenpay_callback(db, payload)
if success:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
except Exception as error:
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
finally:
break
# Получаем соединение с БД
db_generator = get_db()
db = await db_generator.__anext__()
# Если не удалось получить соединение с БД
return web.json_response({"status": "error", "reason": "database_error"}, status=500)
try:
success = await payment_service.process_mulenpay_callback(db, payload)
if success:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
except Exception as error:
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
finally:
try:
await db_generator.__anext__()
except StopAsyncIteration:
pass
except Exception as error:
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
+13 -4
View File
@@ -218,15 +218,24 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
await runner.setup()
site = web.TCPSite(
runner,
host='0.0.0.0',
runner,
host=settings.YOOKASSA_WEBHOOK_HOST,
port=settings.YOOKASSA_WEBHOOK_PORT
)
await site.start()
logger.info(f"✅ YooKassa webhook сервер запущен на порту {settings.YOOKASSA_WEBHOOK_PORT}")
logger.info(f"🎯 YooKassa webhook URL: http://0.0.0.0:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
logger.info(
" YooKassa webhook сервер запущен на %s:%s",
settings.YOOKASSA_WEBHOOK_HOST,
settings.YOOKASSA_WEBHOOK_PORT,
)
logger.info(
"🎯 YooKassa webhook URL: http://%s:%s%s",
settings.YOOKASSA_WEBHOOK_HOST,
settings.YOOKASSA_WEBHOOK_PORT,
settings.YOOKASSA_WEBHOOK_PATH,
)
try:
while True:
File diff suppressed because it is too large Load Diff
+4 -8
View File
@@ -226,18 +226,16 @@ def _build_notification_preview_message(language: str, notification_type: str):
elif notification_type == "expired_2d":
percent = NotificationSettingsService.get_second_wave_discount_percent()
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
template = texts.get(
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
(
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
"Предложение действует до {expires_at}."
"Активируйте предложение, чтобы получить дополнительную скидку. "
"Она суммируется с вашей промогруппой и действует до {expires_at}."
),
)
message = template.format(
percent=percent,
bonus=settings.format_price(bonus_amount),
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
trigger_days=3,
)
@@ -273,18 +271,16 @@ def _build_notification_preview_message(language: str, notification_type: str):
percent = NotificationSettingsService.get_third_wave_discount_percent()
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
trigger_days = NotificationSettingsService.get_third_wave_trigger_days()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
template = texts.get(
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
(
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
"Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. "
"Скидка действует до {expires_at}."
"Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. "
"Она суммируется с промогруппой и действует до {expires_at}."
),
)
message = template.format(
percent=percent,
bonus=settings.format_price(bonus_amount),
trigger_days=trigger_days,
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
)
+512
View File
@@ -0,0 +1,512 @@
import html
import logging
from datetime import datetime
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.localization.texts import get_texts
from app.services.privacy_policy_service import PrivacyPolicyService
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.validators import validate_html_tags, get_html_help_text
logger = logging.getLogger(__name__)
def _format_timestamp(value: datetime | None) -> str:
if not value:
return ""
try:
return value.strftime("%d.%m.%Y %H:%M")
except Exception:
return ""
async def _build_overview(
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
policy = await PrivacyPolicyService.get_policy(
db,
db_user.language,
fallback=False,
)
normalized_language = PrivacyPolicyService.normalize_language(db_user.language)
has_content = bool(policy and policy.content and policy.content.strip())
description = texts.t(
"ADMIN_PRIVACY_POLICY_DESCRIPTION",
"Политика конфиденциальности отображается в разделе «Инфо».",
)
status_text = texts.t(
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED",
"⚠️ Показ политики выключен или текст отсутствует.",
)
if policy and policy.is_enabled and has_content:
status_text = texts.t(
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED",
"✅ Политика активна и показывается пользователям.",
)
elif policy and policy.is_enabled:
status_text = texts.t(
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY",
"⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
)
updated_at = _format_timestamp(getattr(policy, "updated_at", None))
updated_block = ""
if updated_at:
updated_block = texts.t(
"ADMIN_PRIVACY_POLICY_UPDATED_AT",
"Последнее обновление: {timestamp}",
).format(timestamp=updated_at)
preview_block = texts.t(
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY",
"Текст ещё не задан.",
)
if has_content:
preview_title = texts.t(
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE",
"<b>Превью текста:</b>",
)
preview_raw = policy.content.strip()
preview_trimmed = preview_raw[:400]
if len(preview_raw) > 400:
preview_trimmed += "..."
preview_block = (
f"{preview_title}\n"
f"<code>{html.escape(preview_trimmed)}</code>"
)
language_block = texts.t(
"ADMIN_PRIVACY_POLICY_LANGUAGE",
"Язык: <code>{lang}</code>",
).format(lang=normalized_language)
header = texts.t(
"ADMIN_PRIVACY_POLICY_HEADER",
"🛡️ <b>Политика конфиденциальности</b>",
)
actions_prompt = texts.t(
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT",
"Выберите действие:",
)
message_parts = [
header,
description,
language_block,
status_text,
]
if updated_block:
message_parts.append(updated_block)
message_parts.append(preview_block)
message_parts.append(actions_prompt)
overview_text = "\n\n".join(part for part in message_parts if part)
buttons: list[list[types.InlineKeyboardButton]] = []
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_privacy_policy_edit",
)
])
if has_content:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON",
"👀 Просмотреть текущий текст",
),
callback_data="admin_privacy_policy_view",
)
])
toggle_text = texts.t(
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON",
"✅ Включить показ",
)
if policy and policy.is_enabled:
toggle_text = texts.t(
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON",
"🚫 Отключить показ",
)
buttons.append([
types.InlineKeyboardButton(
text=toggle_text,
callback_data="admin_privacy_policy_toggle",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_privacy_policy_help",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.BACK,
callback_data="admin_submenu_settings",
)
])
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), policy
@admin_required
@error_handler
async def show_privacy_policy_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer()
@admin_required
@error_handler
async def toggle_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
updated_policy = await PrivacyPolicyService.toggle_enabled(db, db_user.language)
logger.info(
"Админ %s переключил показ политики конфиденциальности: %s",
db_user.telegram_id,
"enabled" if updated_policy.is_enabled else "disabled",
)
status_message = (
texts.t("ADMIN_PRIVACY_POLICY_ENABLED", "✅ Политика включена")
if updated_policy.is_enabled
else texts.t("ADMIN_PRIVACY_POLICY_DISABLED", "🚫 Политика отключена")
)
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer(status_message)
@admin_required
@error_handler
async def start_edit_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
policy = await PrivacyPolicyService.get_policy(
db,
db_user.language,
fallback=False,
)
current_preview = ""
if policy and policy.content:
preview = policy.content.strip()[:400]
if len(policy.content.strip()) > 400:
preview += "..."
current_preview = (
texts.t(
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW",
"Текущий текст (превью):",
)
+ f"\n<code>{html.escape(preview)}</code>\n\n"
)
prompt = texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT",
"Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
)
hint = texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_HINT",
"Используйте /html_help для справки по тегам.",
)
message_text = (
f"📝 <b>{texts.t('ADMIN_PRIVACY_POLICY_EDIT_TITLE', 'Редактирование политики')}</b>\n\n"
f"{current_preview}{prompt}\n\n{hint}"
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_privacy_policy_help",
)
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PRIVACY_POLICY_CANCEL", "❌ Отмена"),
callback_data="admin_privacy_policy_cancel",
)
],
]
)
await callback.message.edit_text(message_text, reply_markup=keyboard)
await state.set_state(AdminStates.editing_privacy_policy)
await callback.answer()
@admin_required
@error_handler
async def cancel_edit_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
await state.clear()
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer()
@admin_required
@error_handler
async def process_privacy_policy_edit(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
new_text = message.text or ""
if len(new_text) > 4000:
await message.answer(
texts.t(
"ADMIN_PRIVACY_POLICY_TOO_LONG",
"❌ Текст политики слишком длинный. Максимум 4000 символов.",
)
)
return
is_valid, error_message = validate_html_tags(new_text)
if not is_valid:
await message.answer(
texts.t(
"ADMIN_PRIVACY_POLICY_HTML_ERROR",
"❌ Ошибка в HTML: {error}",
).format(error=error_message)
)
return
await PrivacyPolicyService.save_policy(db, db_user.language, new_text)
logger.info(
"Админ %s обновил текст политики конфиденциальности (%d символов)",
db_user.telegram_id,
len(new_text),
)
await state.clear()
success_text = texts.t(
"ADMIN_PRIVACY_POLICY_SAVED",
"✅ Политика конфиденциальности обновлена.",
)
reply_markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
"⬅️ К настройкам политики",
),
callback_data="admin_privacy_policy",
)
]
]
)
await message.answer(success_text, reply_markup=reply_markup)
@admin_required
@error_handler
async def view_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
policy = await PrivacyPolicyService.get_policy(
db,
db_user.language,
fallback=False,
)
if not policy or not policy.content or not policy.content.strip():
await callback.answer(
texts.t(
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT",
"Текст политики пока не задан.",
),
show_alert=True,
)
return
content = policy.content.strip()
truncated = False
max_length = 3800
if len(content) > max_length:
content = content[: max_length - 3] + "..."
truncated = True
header = texts.t(
"ADMIN_PRIVACY_POLICY_VIEW_TITLE",
"👀 <b>Текущий текст политики</b>",
)
note = ""
if truncated:
note = texts.t(
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED",
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
"⬅️ К настройкам политики",
),
callback_data="admin_privacy_policy",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_privacy_policy_edit",
)
],
]
)
await callback.message.edit_text(
f"{header}\n\n{content}{note}",
reply_markup=keyboard,
)
await callback.answer()
@admin_required
@error_handler
async def show_privacy_policy_html_help(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
help_text = get_html_help_text()
current_state = await state.get_state()
buttons: list[list[types.InlineKeyboardButton]] = []
if current_state == AdminStates.editing_privacy_policy.state:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT",
"⬅️ Назад к редактированию",
),
callback_data="admin_privacy_policy_edit",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
"⬅️ К настройкам политики",
),
callback_data="admin_privacy_policy",
)
])
await callback.message.edit_text(
help_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_privacy_policy_management,
F.data == "admin_privacy_policy",
)
dp.callback_query.register(
toggle_privacy_policy,
F.data == "admin_privacy_policy_toggle",
)
dp.callback_query.register(
start_edit_privacy_policy,
F.data == "admin_privacy_policy_edit",
)
dp.callback_query.register(
cancel_edit_privacy_policy,
F.data == "admin_privacy_policy_cancel",
)
dp.callback_query.register(
view_privacy_policy,
F.data == "admin_privacy_policy_view",
)
dp.callback_query.register(
show_privacy_policy_html_help,
F.data == "admin_privacy_policy_help",
)
dp.message.register(
process_privacy_policy_edit,
AdminStates.editing_privacy_policy,
)
File diff suppressed because it is too large Load Diff
+530
View File
@@ -0,0 +1,530 @@
import html
import logging
from datetime import datetime
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.localization.texts import get_texts
from app.services.public_offer_service import PublicOfferService
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.validators import validate_html_tags, get_html_help_text
logger = logging.getLogger(__name__)
def _format_timestamp(value: datetime | None) -> str:
if not value:
return ""
try:
return value.strftime("%d.%m.%Y %H:%M")
except Exception:
return ""
async def _build_overview(
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
offer = await PublicOfferService.get_offer(
db,
db_user.language,
fallback=False,
)
normalized_language = PublicOfferService.normalize_language(db_user.language)
has_content = bool(offer and offer.content and offer.content.strip())
description = texts.t(
"ADMIN_PUBLIC_OFFER_DESCRIPTION",
"Публичная оферта отображается в разделе «Инфо».",
)
status_text = texts.t(
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED",
"⚠️ Показ оферты выключен или текст отсутствует.",
)
if offer and offer.is_enabled and has_content:
status_text = texts.t(
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED",
"✅ Оферта активна и показывается пользователям.",
)
elif offer and offer.is_enabled:
status_text = texts.t(
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY",
"⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
)
updated_at = _format_timestamp(getattr(offer, "updated_at", None))
updated_block = ""
if updated_at:
updated_block = texts.t(
"ADMIN_PUBLIC_OFFER_UPDATED_AT",
"Последнее обновление: {timestamp}",
).format(timestamp=updated_at)
preview_block = texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY",
"Текст ещё не задан.",
)
if has_content:
preview_title = texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE",
"<b>Превью текста:</b>",
)
preview_raw = offer.content.strip()
preview_trimmed = preview_raw[:400]
if len(preview_raw) > 400:
preview_trimmed += "..."
preview_block = (
f"{preview_title}\n"
f"<code>{html.escape(preview_trimmed)}</code>"
)
language_block = texts.t(
"ADMIN_PUBLIC_OFFER_LANGUAGE",
"Язык: <code>{lang}</code>",
).format(lang=normalized_language)
header = texts.t(
"ADMIN_PUBLIC_OFFER_HEADER",
"📄 <b>Публичная оферта</b>",
)
actions_prompt = texts.t(
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT",
"Выберите действие:",
)
message_parts = [
header,
description,
language_block,
status_text,
]
if updated_block:
message_parts.append(updated_block)
message_parts.append(preview_block)
message_parts.append(actions_prompt)
overview_text = "\n\n".join(part for part in message_parts if part)
buttons: list[list[types.InlineKeyboardButton]] = []
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_public_offer_edit",
)
])
if has_content:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON",
"👀 Просмотреть текущий текст",
),
callback_data="admin_public_offer_view",
)
])
toggle_text = texts.t(
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON",
"✅ Включить показ",
)
if offer and offer.is_enabled:
toggle_text = texts.t(
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON",
"🚫 Отключить показ",
)
buttons.append([
types.InlineKeyboardButton(
text=toggle_text,
callback_data="admin_public_offer_toggle",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_public_offer_help",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.BACK,
callback_data="admin_submenu_settings",
)
])
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), offer
@admin_required
@error_handler
async def show_public_offer_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer()
@admin_required
@error_handler
async def toggle_public_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
updated_offer = await PublicOfferService.toggle_enabled(db, db_user.language)
logger.info(
"Админ %s переключил показ публичной оферты: %s",
db_user.telegram_id,
"enabled" if updated_offer.is_enabled else "disabled",
)
status_message = (
texts.t("ADMIN_PUBLIC_OFFER_ENABLED", "✅ Оферта включена")
if updated_offer.is_enabled
else texts.t("ADMIN_PUBLIC_OFFER_DISABLED", "🚫 Оферта отключена")
)
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer(status_message)
@admin_required
@error_handler
async def start_edit_public_offer(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
offer = await PublicOfferService.get_offer(
db,
db_user.language,
fallback=False,
)
current_preview = ""
if offer and offer.content:
preview = offer.content.strip()[:400]
if len(offer.content.strip()) > 400:
preview += "..."
current_preview = (
texts.t(
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW",
"Текущий текст (превью):",
)
+ f"\n<code>{html.escape(preview)}</code>\n\n"
)
prompt = texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT",
"Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
)
hint = texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_HINT",
"Используйте /html_help для справки по тегам.",
)
message_text = (
f"📝 <b>{texts.t('ADMIN_PUBLIC_OFFER_EDIT_TITLE', 'Редактирование оферты')}</b>\n\n"
f"{current_preview}{prompt}\n\n{hint}"
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_public_offer_help",
)
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PUBLIC_OFFER_CANCEL", "❌ Отмена"),
callback_data="admin_public_offer_cancel",
)
],
]
)
await callback.message.edit_text(message_text, reply_markup=keyboard)
await state.set_state(AdminStates.editing_public_offer)
await callback.answer()
@admin_required
@error_handler
async def cancel_edit_public_offer(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
await state.clear()
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer(
get_texts(db_user.language).t(
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED",
"Редактирование оферты отменено.",
)
)
@admin_required
@error_handler
async def process_public_offer_edit(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
new_text = message.text or ""
if len(new_text) > 4000:
await message.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_TOO_LONG",
"❌ Текст оферты слишком длинный. Максимум 4000 символов.",
)
)
return
is_valid, error_message = validate_html_tags(new_text)
if not is_valid:
await message.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_HTML_ERROR",
"❌ Ошибка в HTML: {error}",
).format(error=error_message)
)
return
await PublicOfferService.save_offer(db, db_user.language, new_text)
logger.info(
"Админ %s обновил текст публичной оферты (%d символов)",
db_user.telegram_id,
len(new_text),
)
await state.clear()
success_text = texts.t(
"ADMIN_PUBLIC_OFFER_SAVED",
"✅ Публичная оферта обновлена.",
)
reply_markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
"⬅️ К настройкам оферты",
),
callback_data="admin_public_offer",
)
]
]
)
await message.answer(success_text, reply_markup=reply_markup)
@admin_required
@error_handler
async def view_public_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
offer = await PublicOfferService.get_offer(
db,
db_user.language,
fallback=False,
)
if not offer or not offer.content or not offer.content.strip():
await callback.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
"Текст оферты пока не задан.",
),
show_alert=True,
)
return
content = offer.content.strip()
max_length = 3800
pages = PublicOfferService.split_content_into_pages(
content,
max_length=max_length,
)
if not pages:
await callback.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
"Текст оферты пока не задан.",
),
show_alert=True,
)
return
preview = pages[0]
truncated = len(pages) > 1
header = texts.t(
"ADMIN_PUBLIC_OFFER_VIEW_TITLE",
"👀 <b>Текущий текст оферты</b>",
)
note = ""
if truncated:
note = texts.t(
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED",
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
"⬅️ К настройкам оферты",
),
callback_data="admin_public_offer",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_public_offer_edit",
)
],
]
)
await callback.message.edit_text(
f"{header}\n\n{preview}{note}",
reply_markup=keyboard,
)
await callback.answer()
@admin_required
@error_handler
async def show_public_offer_html_help(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
help_text = get_html_help_text()
current_state = await state.get_state()
buttons: list[list[types.InlineKeyboardButton]] = []
if current_state == AdminStates.editing_public_offer.state:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT",
"⬅️ Назад к редактированию",
),
callback_data="admin_public_offer_edit",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
"⬅️ К настройкам оферты",
),
callback_data="admin_public_offer",
)
])
await callback.message.edit_text(
help_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_public_offer_management,
F.data == "admin_public_offer",
)
dp.callback_query.register(
toggle_public_offer,
F.data == "admin_public_offer_toggle",
)
dp.callback_query.register(
start_edit_public_offer,
F.data == "admin_public_offer_edit",
)
dp.callback_query.register(
cancel_edit_public_offer,
F.data == "admin_public_offer_cancel",
)
dp.callback_query.register(
view_public_offer,
F.data == "admin_public_offer_view",
)
dp.callback_query.register(
show_public_offer_html_help,
F.data == "admin_public_offer_help",
)
dp.message.register(
process_public_offer_edit,
AdminStates.editing_public_offer,
)
+488 -2
View File
@@ -1,3 +1,4 @@
import html
import logging
from aiogram import Dispatcher, types, F
from aiogram.filters import StateFilter
@@ -7,7 +8,11 @@ from datetime import datetime
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id, update_user
from app.keyboards.inline import get_main_menu_keyboard, get_language_selection_keyboard
from app.keyboards.inline import (
get_main_menu_keyboard,
get_language_selection_keyboard,
get_info_menu_keyboard,
)
from app.localization.texts import get_texts, get_rules
from app.database.models import User
from app.database.crud.user_message import get_random_active_message
@@ -17,6 +22,13 @@ from app.services.subscription_checkout_service import (
)
from app.utils.photo_message import edit_or_answer_photo
from app.services.support_settings_service import SupportSettingsService
from app.utils.promo_offer import (
build_promo_offer_hint,
build_test_access_hint,
)
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService
from app.services.faq_service import FaqService
logger = logging.getLogger(__name__)
@@ -91,6 +103,416 @@ async def show_service_rules(
await callback.answer()
async def show_info_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
header = texts.t("MENU_INFO_HEADER", "️ <b>Инфо</b>")
prompt = texts.t("MENU_INFO_PROMPT", "Выберите раздел:")
caption = f"{header}\n\n{prompt}" if prompt else header
privacy_enabled = await PrivacyPolicyService.is_policy_enabled(db, db_user.language)
public_offer_enabled = await PublicOfferService.is_offer_enabled(db, db_user.language)
faq_enabled = await FaqService.is_enabled(db, db_user.language)
await edit_or_answer_photo(
callback=callback,
caption=caption,
keyboard=get_info_menu_keyboard(
language=db_user.language,
show_privacy_policy=privacy_enabled,
show_public_offer=public_offer_enabled,
show_faq=faq_enabled,
),
parse_mode="HTML",
)
await callback.answer()
async def show_faq_pages(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
pages = await FaqService.get_pages(db, db_user.language)
if not pages:
await callback.answer(
texts.t("FAQ_NOT_AVAILABLE", "FAQ временно недоступен."),
show_alert=True,
)
return
header = texts.t("FAQ_HEADER", "❓ <b>FAQ</b>")
prompt = texts.t("FAQ_PAGES_PROMPT", "Выберите вопрос:" )
caption = f"{header}\n\n{prompt}" if prompt else header
buttons: list[list[types.InlineKeyboardButton]] = []
for index, page in enumerate(pages, start=1):
raw_title = (page.title or "").strip()
if not raw_title:
raw_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия")
if len(raw_title) > 60:
raw_title = f"{raw_title[:57]}..."
buttons.append([
types.InlineKeyboardButton(
text=f"{index}. {raw_title}",
callback_data=f"menu_faq_page:{page.id}:1",
)
])
buttons.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")
])
await callback.message.edit_text(
caption,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
async def show_faq_page(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
raw_data = callback.data or ""
parts = raw_data.split(":")
page_id = None
requested_page = 1
if len(parts) >= 2:
try:
page_id = int(parts[1])
except ValueError:
page_id = None
if len(parts) >= 3:
try:
requested_page = int(parts[2])
except ValueError:
requested_page = 1
if not page_id:
await callback.answer()
return
page = await FaqService.get_page(db, page_id, db_user.language)
if not page or not page.is_active:
await callback.answer(
texts.t("FAQ_PAGE_NOT_AVAILABLE", "Эта страница FAQ недоступна."),
show_alert=True,
)
return
content_pages = FaqService.split_content_into_pages(page.content)
if not content_pages:
await callback.answer(
texts.t("FAQ_PAGE_EMPTY", "Текст для этой страницы ещё не добавлен."),
show_alert=True,
)
return
total_pages = len(content_pages)
current_page = max(1, min(requested_page, total_pages))
header = texts.t("FAQ_HEADER", "❓ <b>FAQ</b>")
title_template = texts.t("FAQ_PAGE_TITLE", "<b>{title}</b>")
page_title = (page.title or "").strip()
if not page_title:
page_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия")
title_block = title_template.format(title=html.escape(page_title))
body = content_pages[current_page - 1]
footer_template = texts.t(
"FAQ_PAGE_FOOTER",
"Страница {current} из {total}",
)
footer = ""
if total_pages > 1 and footer_template:
try:
footer = footer_template.format(current=current_page, total=total_pages)
except Exception:
footer = f"{current_page}/{total_pages}"
parts_to_join = [header, title_block]
if body:
parts_to_join.append(body)
if footer:
parts_to_join.append(f"<code>{footer}</code>")
message_text = "\n\n".join(segment for segment in parts_to_join if segment)
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
if total_pages > 1:
nav_row: list[types.InlineKeyboardButton] = []
if current_page > 1:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_PREV", "⬅️"),
callback_data=f"menu_faq_page:{page.id}:{current_page - 1}",
)
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{current_page}/{total_pages}",
callback_data="noop",
)
)
if current_page < total_pages:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_NEXT", "➡️"),
callback_data=f"menu_faq_page:{page.id}:{current_page + 1}",
)
)
keyboard_rows.append(nav_row)
keyboard_rows.append([
types.InlineKeyboardButton(
text=texts.t("FAQ_BACK_TO_LIST", "⬅️ К списку FAQ"),
callback_data="menu_faq",
)
])
keyboard_rows.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")
])
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
async def show_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
raw_page = 1
if callback.data and ":" in callback.data:
try:
raw_page = int(callback.data.split(":", 1)[1])
except ValueError:
raw_page = 1
if raw_page < 1:
raw_page = 1
policy = await PrivacyPolicyService.get_active_policy(db, db_user.language)
if not policy:
await callback.answer(
texts.t(
"PRIVACY_POLICY_NOT_AVAILABLE",
"Политика конфиденциальности временно недоступна.",
),
show_alert=True,
)
return
pages = PrivacyPolicyService.split_content_into_pages(policy.content)
if not pages:
await callback.answer(
texts.t(
"PRIVACY_POLICY_EMPTY_ALERT",
"Политика конфиденциальности ещё не заполнена.",
),
show_alert=True,
)
return
total_pages = len(pages)
current_page = raw_page if raw_page <= total_pages else total_pages
header = texts.t(
"PRIVACY_POLICY_HEADER",
"🛡️ <b>Политика конфиденциальности</b>",
)
body = pages[current_page - 1]
footer_template = texts.t(
"PRIVACY_POLICY_PAGE_INFO",
"Страница {current} из {total}",
)
footer = ""
if total_pages > 1 and footer_template:
try:
footer = footer_template.format(current=current_page, total=total_pages)
except Exception:
footer = f"{current_page}/{total_pages}"
message_text = header
if body:
message_text += f"\n\n{body}"
if footer:
message_text += f"\n\n<code>{footer}</code>"
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
if total_pages > 1:
nav_row: list[types.InlineKeyboardButton] = []
if current_page > 1:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_PREV", "⬅️"),
callback_data=f"menu_privacy_policy:{current_page - 1}",
)
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{current_page}/{total_pages}",
callback_data="noop",
)
)
if current_page < total_pages:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_NEXT", "➡️"),
callback_data=f"menu_privacy_policy:{current_page + 1}",
)
)
keyboard_rows.append(nav_row)
keyboard_rows.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]
)
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
async def show_public_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
raw_page = 1
if callback.data and ":" in callback.data:
try:
raw_page = int(callback.data.split(":", 1)[1])
except ValueError:
raw_page = 1
if raw_page < 1:
raw_page = 1
offer = await PublicOfferService.get_active_offer(db, db_user.language)
if not offer:
await callback.answer(
texts.t(
"PUBLIC_OFFER_NOT_AVAILABLE",
"Публичная оферта временно недоступна.",
),
show_alert=True,
)
return
pages = PublicOfferService.split_content_into_pages(offer.content)
if not pages:
await callback.answer(
texts.t(
"PUBLIC_OFFER_EMPTY_ALERT",
"Публичная оферта ещё не заполнена.",
),
show_alert=True,
)
return
total_pages = len(pages)
current_page = raw_page if raw_page <= total_pages else total_pages
header = texts.t(
"PUBLIC_OFFER_HEADER",
"📄 <b>Публичная оферта</b>",
)
body = pages[current_page - 1]
footer_template = texts.t(
"PUBLIC_OFFER_PAGE_INFO",
"Страница {current} из {total}",
)
footer = ""
if total_pages > 1 and footer_template:
try:
footer = footer_template.format(current=current_page, total=total_pages)
except Exception:
footer = f"{current_page}/{total_pages}"
message_text = header
if body:
message_text += f"\n\n{body}"
if footer:
message_text += f"\n\n<code>{footer}</code>"
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
if total_pages > 1:
nav_row: list[types.InlineKeyboardButton] = []
if current_page > 1:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_PREV", "⬅️"),
callback_data=f"menu_public_offer:{current_page - 1}",
)
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{current_page}/{total_pages}",
callback_data="noop",
)
)
if current_page < total_pages:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_NEXT", "➡️"),
callback_data=f"menu_public_offer:{current_page + 1}",
)
)
keyboard_rows.append(nav_row)
keyboard_rows.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]
)
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
async def show_language_menu(
callback: types.CallbackQuery,
db_user: User,
@@ -300,9 +722,38 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
user_name=user.full_name,
subscription_status=_get_subscription_status(user, texts)
)
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
info_sections: list[str] = []
try:
promo_hint = await build_promo_offer_hint(db, user, texts)
if promo_hint:
info_sections.append(promo_hint.strip())
except Exception as hint_error:
logger.debug(
"Не удалось построить подсказку промо-предложения для пользователя %s: %s",
getattr(user, "id", None),
hint_error,
)
try:
test_access_hint = await build_test_access_hint(db, user, texts)
if test_access_hint:
info_sections.append(test_access_hint.strip())
except Exception as test_error:
logger.debug(
"Не удалось построить подсказку тестового доступа для пользователя %s: %s",
getattr(user, "id", None),
test_error,
)
if info_sections:
extra_block = "\n\n".join(section for section in info_sections if section)
if extra_block:
base_text = _insert_random_message(base_text, extra_block, action_prompt)
try:
random_message = await get_random_active_message(db)
if random_message:
@@ -326,6 +777,41 @@ def register_handlers(dp: Dispatcher):
F.data == "menu_rules"
)
dp.callback_query.register(
show_info_menu,
F.data == "menu_info",
)
dp.callback_query.register(
show_faq_pages,
F.data == "menu_faq",
)
dp.callback_query.register(
show_faq_page,
F.data.startswith("menu_faq_page:"),
)
dp.callback_query.register(
show_privacy_policy,
F.data == "menu_privacy_policy",
)
dp.callback_query.register(
show_privacy_policy,
F.data.startswith("menu_privacy_policy:"),
)
dp.callback_query.register(
show_public_offer,
F.data == "menu_public_offer",
)
dp.callback_query.register(
show_public_offer,
F.data.startswith("menu_public_offer:"),
)
dp.callback_query.register(
show_language_menu,
F.data == "menu_language"
+33
View File
@@ -32,6 +32,10 @@ from app.services.admin_notification_service import AdminNotificationService
from app.services.subscription_service import SubscriptionService
from app.services.support_settings_service import SupportSettingsService
from app.utils.user_utils import generate_unique_referral_code
from app.utils.promo_offer import (
build_promo_offer_hint,
build_test_access_hint,
)
from app.database.crud.user_message import get_random_active_message
@@ -1279,6 +1283,35 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
info_sections: list[str] = []
try:
promo_hint = await build_promo_offer_hint(db, user, texts)
if promo_hint:
info_sections.append(promo_hint.strip())
except Exception as hint_error:
logger.debug(
"Не удалось построить подсказку промо-предложения для пользователя %s: %s",
getattr(user, "id", None),
hint_error,
)
try:
test_access_hint = await build_test_access_hint(db, user, texts)
if test_access_hint:
info_sections.append(test_access_hint.strip())
except Exception as test_error:
logger.debug(
"Не удалось построить подсказку тестового доступа для пользователя %s: %s",
getattr(user, "id", None),
test_error,
)
if info_sections:
extra_block = "\n\n".join(section for section in info_sections if section)
if extra_block:
base_text = _insert_random_message(base_text, extra_block, action_prompt)
try:
random_message = await get_random_active_message(db)
if random_message:
+432 -51
View File
@@ -9,14 +9,18 @@ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance, add_user_balance
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
@@ -52,6 +56,7 @@ from app.services.subscription_checkout_service import (
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
@@ -67,12 +72,35 @@ from app.utils.subscription_utils import (
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
logger = logging.getLogger(__name__)
TRAFFIC_PRICES = get_traffic_prices()
class _SafeFormatDict(dict):
def __missing__(self, key: str) -> str: # pragma: no cover - defensive fallback
return "{" + key + "}"
def _format_text_with_placeholders(template: str, values: Dict[str, Any]) -> str:
if not isinstance(template, str):
return template
safe_values = _SafeFormatDict()
safe_values.update(values)
try:
return template.format_map(safe_values)
except Exception: # pragma: no cover - defensive logging
logger.warning("Failed to format template '%s' with values %s", template, values)
return template
def _get_addon_discount_percent_for_user(
user: Optional[User],
category: str,
@@ -110,6 +138,29 @@ def _apply_addon_discount(
}
def _get_promo_offer_discount_percent(user: Optional[User]) -> int:
return get_user_active_promo_discount_percent(user)
def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Dict[str, int]:
percent = _get_promo_offer_discount_percent(user)
if amount <= 0 or percent <= 0:
return {"discounted": amount, "discount": 0, "percent": 0}
discounted, discount_value = apply_percentage_discount(amount, percent)
return {"discounted": discounted, "discount": discount_value, "percent": percent}
async def _get_promo_offer_hint(
db: AsyncSession,
db_user: User,
texts,
percent: Optional[int] = None,
) -> Optional[str]:
return await build_promo_offer_hint(db, db_user, texts, percent)
def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> Optional[int]:
if not subscription:
return None
@@ -245,7 +296,20 @@ async def _prepare_subscription_summary(
if not is_valid:
raise ValueError("Subscription price calculation validation failed")
original_total_price = total_price
promo_offer_component = _apply_promo_offer_discount(db_user, total_price)
if promo_offer_component["discount"] > 0:
total_price = promo_offer_component["discounted"]
summary_data['total_price'] = total_price
if promo_offer_component["discount"] > 0:
summary_data['promo_offer_discount_percent'] = promo_offer_component["percent"]
summary_data['promo_offer_discount_value'] = promo_offer_component["discount"]
summary_data['total_price_before_promo_offer'] = original_total_price
else:
summary_data.pop('promo_offer_discount_percent', None)
summary_data.pop('promo_offer_discount_value', None)
summary_data.pop('total_price_before_promo_offer', None)
summary_data['server_prices_for_period'] = selected_server_prices
summary_data['months_in_period'] = months_in_period
summary_data['base_price'] = base_price
@@ -329,6 +393,17 @@ async def _prepare_subscription_summary(
)
details_lines.append(devices_line)
if promo_offer_component["discount"] > 0:
details_lines.append(
texts.t(
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT",
"- Промо-предложение: -{amount} ({percent}% дополнительно)",
).format(
amount=texts.format_price(promo_offer_component["discount"]),
percent=promo_offer_component["percent"],
)
)
details_text = "\n".join(details_lines)
summary_text = (
@@ -426,19 +501,29 @@ def _build_promo_group_discount_text(
return "\n".join(lines)
def _build_subscription_period_prompt(db_user: User, texts) -> str:
async def _build_subscription_period_prompt(
db_user: User,
texts,
db: AsyncSession,
) -> str:
base_text = texts.BUY_SUBSCRIPTION_START.rstrip()
lines: List[str] = [base_text]
promo_offer_hint = await _get_promo_offer_hint(db, db_user, texts)
if promo_offer_hint:
lines.extend(["", promo_offer_hint])
promo_text = _build_promo_group_discount_text(
db_user,
settings.get_available_subscription_periods(),
texts=texts,
)
if not promo_text:
return f"{base_text}\n"
if promo_text:
lines.extend(["", promo_text])
return f"{base_text}\n\n{promo_text}\n"
return "\n".join(lines) + "\n"
async def show_subscription_info(
@@ -760,8 +845,6 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
except AttributeError:
period_discount_percent = 0
from app.utils.pricing_utils import apply_percentage_discount
base_cost, _ = apply_percentage_discount(
base_cost_original,
period_discount_percent,
@@ -1032,13 +1115,15 @@ async def activate_trial(
async def start_subscription_purchase(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
_build_subscription_period_prompt(db_user, texts),
reply_markup=get_subscription_period_keyboard(db_user.language)
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
subscription = getattr(db_user, 'subscription', None)
@@ -2373,14 +2458,13 @@ async def handle_extend_subscription(
available_periods = settings.get_available_renewal_periods()
renewal_prices = {}
promo_offer_percent = _get_promo_offer_discount_percent(db_user)
for days in available_periods:
try:
months_in_period = calculate_months_from_days(days)
from app.config import PERIOD_PRICES
from app.utils.pricing_utils import apply_percentage_discount
base_price_original = PERIOD_PRICES.get(days, 0)
period_discount_percent = db_user.get_promo_discount("period", days)
base_price, _ = apply_percentage_discount(
@@ -2418,7 +2502,8 @@ async def handle_extend_subscription(
total_traffic_price = (traffic_price_per_month - traffic_discount_per_month) * months_in_period
price = base_price + total_servers_price + total_devices_price + total_traffic_price
renewal_prices[days] = price
promo_component = _apply_promo_offer_discount(db_user, price)
renewal_prices[days] = promo_component["discounted"]
except Exception as e:
logger.error(f"Ошибка расчета цены для периода {days}: {e}")
@@ -2455,6 +2540,15 @@ async def handle_extend_subscription(
if promo_discounts_text:
message_text += f"{promo_discounts_text}\n\n"
promo_offer_hint = await _get_promo_offer_hint(
db,
db_user,
texts,
promo_offer_percent,
)
if promo_offer_hint:
message_text += f"{promo_offer_hint}\n\n"
message_text += "💡 <i>Цена включает все ваши текущие серверы и настройки</i>"
await callback.message.edit_text(
@@ -2666,7 +2760,6 @@ async def confirm_extend_subscription(
try:
from app.config import PERIOD_PRICES
from app.utils.pricing_utils import apply_percentage_discount
base_price_original = PERIOD_PRICES.get(days, 0)
period_discount_percent = db_user.get_promo_discount("period", days)
@@ -2719,13 +2812,17 @@ async def confirm_extend_subscription(
total_traffic_price = discounted_traffic_price_per_month * months_in_period
price = base_price + total_servers_price + total_devices_price + total_traffic_price
original_price = price
promo_component = _apply_promo_offer_discount(db_user, price)
if promo_component["discount"] > 0:
price = promo_component["discounted"]
monthly_additions = (
discounted_servers_price_per_month
+ discounted_devices_price_per_month
+ discounted_traffic_price_per_month
)
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, price)
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, original_price)
if not is_valid:
logger.error(f"Ошибка в расчете цены продления для пользователя {db_user.telegram_id}")
@@ -2773,6 +2870,12 @@ async def confirm_extend_subscription(
else ""
)
)
if promo_component["discount"] > 0:
logger.info(
" 🎯 Промо-предложение: -%s₽ (%s%%)",
promo_component["discount"] / 100,
promo_component["percent"],
)
logger.info(f" 💎 ИТОГО: {price / 100}")
except Exception as e:
@@ -2811,8 +2914,11 @@ async def confirm_extend_subscription(
try:
success = await subtract_user_balance(
db, db_user, price,
f"Продление подписки на {days} дней"
db,
db_user,
price,
f"Продление подписки на {days} дней",
consume_promo_offer=promo_component["discount"] > 0,
)
if not success:
@@ -2903,6 +3009,12 @@ async def confirm_extend_subscription(
f"💰 Списано: {texts.format_price(price)}"
)
if promo_component["discount"] > 0:
success_message += (
f" (включая доп. скидку {promo_component['percent']}%:"
f" -{texts.format_price(promo_component['discount'])})"
)
await callback.message.edit_text(
success_message,
reply_markup=get_back_keyboard(db_user.language)
@@ -3463,8 +3575,6 @@ async def confirm_purchase(
discounted_servers_price_per_month = 0
server_prices = []
from app.utils.pricing_utils import apply_percentage_discount
for server_price_per_month in per_month_prices:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price_per_month,
@@ -3504,8 +3614,6 @@ async def confirm_purchase(
"devices",
data['period_days'],
)
from app.utils.pricing_utils import apply_percentage_discount
discounted_devices_price_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
@@ -3538,8 +3646,6 @@ async def confirm_purchase(
"traffic",
data['period_days'],
)
from app.utils.pricing_utils import apply_percentage_discount
discounted_traffic_price_per_month, discount_per_month = apply_percentage_discount(
traffic_price_per_month,
traffic_discount_percent,
@@ -3549,7 +3655,26 @@ async def confirm_purchase(
total_servers_price = data.get('total_servers_price', total_countries_price)
final_price = data['total_price']
cached_total_price = data['total_price']
cached_promo_discount_value = data.get('promo_offer_discount_value', 0)
validation_total_price = data.get('total_price_before_promo_offer')
if validation_total_price is None and cached_promo_discount_value > 0:
validation_total_price = cached_total_price + cached_promo_discount_value
if validation_total_price is None:
validation_total_price = cached_total_price
current_promo_offer_percent = _get_promo_offer_discount_percent(db_user)
if current_promo_offer_percent > 0:
final_price, promo_offer_discount_value = apply_percentage_discount(
validation_total_price,
current_promo_offer_percent,
)
promo_offer_discount_percent = current_promo_offer_percent
else:
final_price = validation_total_price
promo_offer_discount_value = 0
promo_offer_discount_percent = 0
discounted_monthly_additions = data.get(
'discounted_monthly_additions',
@@ -3562,7 +3687,7 @@ async def confirm_purchase(
base_price,
discounted_monthly_additions,
months_in_period,
final_price,
validation_total_price,
)
if not is_valid:
@@ -3611,6 +3736,12 @@ async def confirm_purchase(
f" -{devices_discount_total / 100}₽)"
)
logger.info(message)
if promo_offer_discount_value > 0:
logger.info(
" 🎯 Промо-предложение: -%s₽ (%s%%)",
promo_offer_discount_value / 100,
promo_offer_discount_percent,
)
logger.info(f" ИТОГО: {final_price / 100}")
if db_user.balance_kopeks < final_price:
@@ -3646,8 +3777,11 @@ async def confirm_purchase(
try:
success = await subtract_user_balance(
db, db_user, final_price,
f"Покупка подписки на {data['period_days']} дней"
db,
db_user,
final_price,
f"Покупка подписки на {data['period_days']} дней",
consume_promo_offer=promo_offer_discount_value > 0,
)
if not success:
@@ -3810,6 +3944,16 @@ async def confirm_purchase(
subscription_link = get_display_subscription_link(subscription)
hide_subscription_link = settings.should_hide_subscription_link()
discount_note = ""
if promo_offer_discount_value > 0:
discount_note = texts.t(
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE",
"⚡ Доп. скидка {percent}%: -{amount}",
).format(
percent=promo_offer_discount_percent,
amount=texts.format_price(promo_offer_discount_value),
)
if remnawave_user and subscription_link:
if settings.is_happ_cryptolink_mode():
success_text = (
@@ -3849,6 +3993,9 @@ async def confirm_purchase(
f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}"
)
if discount_note:
success_text = f"{success_text}\n\n{discount_note}"
connect_mode = settings.CONNECT_BUTTON_MODE
if connect_mode == "miniapp_subscription":
@@ -3922,11 +4069,14 @@ async def confirm_purchase(
parse_mode="HTML"
)
else:
purchase_text = texts.SUBSCRIPTION_PURCHASED
if discount_note:
purchase_text = f"{purchase_text}\n\n{discount_note}"
await callback.message.edit_text(
texts.t(
"SUBSCRIPTION_LINK_GENERATING_NOTICE",
"{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.",
).format(purchase_text=texts.SUBSCRIPTION_PURCHASED),
).format(purchase_text=purchase_text),
reply_markup=get_back_keyboard(db_user.language)
)
@@ -4314,8 +4464,9 @@ async def handle_subscription_config_back(
if current_state == SubscriptionStates.selecting_traffic.state:
await callback.message.edit_text(
_build_subscription_period_prompt(db_user, texts),
reply_markup=get_subscription_period_keyboard(db_user.language)
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
@@ -4328,8 +4479,9 @@ async def handle_subscription_config_back(
await state.set_state(SubscriptionStates.selecting_traffic)
else:
await callback.message.edit_text(
_build_subscription_period_prompt(db_user, texts),
reply_markup=get_subscription_period_keyboard(db_user.language)
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
@@ -4352,8 +4504,9 @@ async def handle_subscription_config_back(
await state.set_state(SubscriptionStates.selecting_traffic)
else:
await callback.message.edit_text(
_build_subscription_period_prompt(db_user, texts),
reply_markup=get_subscription_period_keyboard(db_user.language)
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
@@ -5052,33 +5205,256 @@ async def claim_discount_offer(
)
return
bonus_amount = offer.bonus_amount_kopeks or 0
if bonus_amount > 0:
success = await add_user_balance(
effect_type = (offer.effect_type or "percent_discount").lower()
if effect_type == "balance_bonus":
effect_type = "percent_discount"
if effect_type == "test_access":
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
db,
db_user,
bonus_amount,
texts.get("DISCOUNT_BONUS_DESCRIPTION", "Скидка за продление подписки"),
offer,
)
if not success:
await callback.answer(
texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось начислить скидку. Попробуйте позже."),
show_alert=True,
)
if error_code == "subscription_missing":
error_message = texts.get(
"TEST_ACCESS_NO_SUBSCRIPTION",
"❌ Для активации предложения необходима действующая подписка.",
)
elif error_code == "squads_missing":
error_message = texts.get(
"TEST_ACCESS_NO_SQUADS",
"❌ Не удалось определить список серверов для теста. Обратитесь к администратору.",
)
elif error_code == "already_connected":
error_message = texts.get(
"TEST_ACCESS_ALREADY_CONNECTED",
"ℹ️ Этот сервер уже подключен к вашей подписке.",
)
elif error_code == "remnawave_sync_failed":
error_message = texts.get(
"TEST_ACCESS_REMNAWAVE_ERROR",
"❌ Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.",
)
else:
error_message = texts.get(
"TEST_ACCESS_UNKNOWN_ERROR",
"❌ Не удалось активировать предложение. Попробуйте позже.",
)
await callback.answer(error_message, show_alert=True)
return
await mark_offer_claimed(db, offer)
await mark_offer_claimed(
db,
offer,
details={
"context": "test_access_claim",
"new_squads": newly_added,
"expires_at": expires_at.isoformat() if expires_at else None,
},
)
success_message = texts.get(
expires_text = expires_at.strftime("%d.%m.%Y %H:%M") if expires_at else ""
success_message = texts.get(
"TEST_ACCESS_ACTIVATED_MESSAGE",
"🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.",
).format(expires_at=expires_text)
popup_text = texts.get("TEST_ACCESS_ACTIVATED_POPUP", "✅ Доступ выдан!")
await callback.answer(popup_text, show_alert=True)
back_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.get("BACK_TO_MENU", "🏠 В главное меню"),
callback_data="back_to_menu",
)
]
]
)
await callback.message.answer(success_message, reply_markup=back_keyboard)
return
discount_percent = int(offer.discount_percent or 0)
if discount_percent <= 0:
await callback.answer(
texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось активировать скидку. Попробуйте позже."),
show_alert=True,
)
return
db_user.promo_offer_discount_percent = discount_percent
db_user.promo_offer_discount_source = offer.notification_type
db_user.updated_at = now
extra_data = offer.extra_data or {}
raw_duration = extra_data.get("active_discount_hours")
template_id = extra_data.get("template_id")
if raw_duration in (None, "") and template_id:
try:
template = await get_promo_offer_template_by_id(db, int(template_id))
except (ValueError, TypeError):
template = None
if template and template.active_discount_hours:
raw_duration = template.active_discount_hours
try:
duration_hours = int(raw_duration) if raw_duration is not None else None
except (TypeError, ValueError):
duration_hours = None
if duration_hours and duration_hours > 0:
discount_expires_at = now + timedelta(hours=duration_hours)
else:
discount_expires_at = None
db_user.promo_offer_discount_expires_at = discount_expires_at
await mark_offer_claimed(
db,
offer,
details={
"context": "discount_claim",
"discount_percent": discount_percent,
"discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None,
},
)
await db.refresh(db_user)
success_template = texts.get(
"DISCOUNT_CLAIM_SUCCESS",
"🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
).format(
percent=offer.discount_percent,
amount=settings.format_price(bonus_amount),
"🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате.",
)
expires_text = (
discount_expires_at.strftime("%d.%m.%Y %H:%M") if discount_expires_at else ""
)
format_values: Dict[str, Any] = {"percent": discount_percent}
if duration_hours and duration_hours > 0:
format_values.setdefault("hours", duration_hours)
format_values.setdefault("duration_hours", duration_hours)
if discount_expires_at:
format_values.setdefault("expires_at", expires_text)
format_values.setdefault("expires_at_iso", discount_expires_at.isoformat())
try:
expires_timestamp = int(discount_expires_at.timestamp())
except (OverflowError, OSError, ValueError):
expires_timestamp = None
if expires_timestamp:
format_values.setdefault("expires_at_ts", expires_timestamp)
remaining_hours = int((discount_expires_at - now).total_seconds() // 3600)
if remaining_hours > 0:
format_values.setdefault("expires_in_hours", remaining_hours)
amount_text = ""
if isinstance(extra_data, dict):
raw_amount_text = (
extra_data.get("amount_text")
or extra_data.get("discount_amount_text")
or extra_data.get("formatted_amount")
)
if isinstance(raw_amount_text, str) and raw_amount_text.strip():
amount_text = raw_amount_text.strip()
else:
raw_amount = extra_data.get("amount") or extra_data.get("discount_amount")
if isinstance(raw_amount, (int, float)):
amount_text = settings.format_price(int(raw_amount))
elif isinstance(raw_amount, str) and raw_amount.strip():
amount_text = raw_amount.strip()
if not amount_text:
for key in ("discount_amount_kopeks", "amount_kopeks", "bonus_amount_kopeks"):
maybe_amount = extra_data.get(key)
try:
amount_value = int(maybe_amount)
except (TypeError, ValueError):
continue
if amount_value > 0:
amount_text = settings.format_price(amount_value)
break
for key, value in extra_data.items():
if (
isinstance(key, str)
and key.isidentifier()
and key not in format_values
and isinstance(value, (str, int, float))
):
format_values[key] = value
if not amount_text:
try:
bonus_amount = int(getattr(offer, "bonus_amount_kopeks", 0))
except (TypeError, ValueError):
bonus_amount = 0
if bonus_amount > 0:
amount_text = settings.format_price(bonus_amount)
if amount_text:
format_values.setdefault("amount", amount_text)
success_message = _format_text_with_placeholders(success_template, format_values)
await callback.answer("✅ Скидка активирована!", show_alert=True)
await callback.message.answer(success_message)
offer_type = None
if isinstance(extra_data, dict):
offer_type = extra_data.get("offer_type")
subscription = getattr(db_user, "subscription", None)
if offer_type == "purchase_discount":
button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку")
button_callback = "subscription_upgrade"
elif offer_type == "extend_discount":
button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку")
button_callback = "subscription_extend"
else:
has_active_paid_subscription = bool(
subscription
and getattr(subscription, "is_active", False)
and not getattr(subscription, "is_trial", False)
)
if has_active_paid_subscription:
button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку")
button_callback = "subscription_extend"
else:
button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку")
button_callback = "subscription_upgrade"
buy_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=button_text,
callback_data=button_callback,
)
]
]
)
await callback.message.answer(success_message, reply_markup=buy_keyboard)
async def handle_promo_offer_close(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
try:
await callback.message.delete()
except Exception:
try:
await callback.message.edit_reply_markup()
except Exception:
pass
await callback.answer()
async def handle_device_guide(
@@ -6124,6 +6500,11 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("claim_discount_")
)
dp.callback_query.register(
handle_promo_offer_close,
F.data == "promo_offer_close",
)
dp.callback_query.register(
handle_happ_download_request,
F.data == "subscription_happ_download"
+24
View File
@@ -101,6 +101,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
[
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"),
callback_data="admin_promo_offers"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_COMMUNICATIONS_WELCOME_TEXT", "👋 Приветственный текст"),
@@ -172,6 +178,24 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM
callback_data="maintenance_panel"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_SETTINGS_PRIVACY_POLICY", "🛡️ Политика конф."),
callback_data="admin_privacy_policy",
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_SETTINGS_PUBLIC_OFFER", "📄 Публичная оферта"),
callback_data="admin_public_offer",
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_SETTINGS_FAQ", "❓ FAQ"),
callback_data="admin_faq",
)
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")
]
+77 -28
View File
@@ -237,41 +237,23 @@ def get_main_menu_keyboard(
]
])
# Server status button
server_status_mode = settings.get_server_status_mode()
server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов")
if server_status_mode == "external_link":
status_url = settings.get_server_status_external_url()
if status_url:
keyboard.append([
InlineKeyboardButton(text=server_status_text, url=status_url)
])
elif server_status_mode == "external_link_miniapp":
status_url = settings.get_server_status_external_url()
if status_url:
keyboard.append([
InlineKeyboardButton(
text=server_status_text,
web_app=types.WebAppInfo(url=status_url),
)
])
elif server_status_mode == "xray":
keyboard.append([
InlineKeyboardButton(text=server_status_text, callback_data="menu_server_status")
])
# Support button is configurable (runtime via service)
try:
from app.services.support_settings_service import SupportSettingsService
support_enabled = SupportSettingsService.is_support_menu_enabled()
except Exception:
support_enabled = settings.SUPPORT_MENU_ENABLED
support_row = []
if support_enabled:
support_row.append(InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support"))
support_row.append(InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules"))
keyboard.append(support_row)
keyboard.append([
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("MENU_INFO", "️ Инфо"),
callback_data="menu_info",
)
])
if settings.is_language_selection_enabled():
keyboard.append([
@@ -298,6 +280,73 @@ def get_main_menu_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_info_menu_keyboard(
language: str = DEFAULT_LANGUAGE,
show_privacy_policy: bool = False,
show_public_offer: bool = False,
show_faq: bool = False,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
buttons: List[List[InlineKeyboardButton]] = []
if show_faq:
buttons.append([
InlineKeyboardButton(
text=texts.t("MENU_FAQ", "❓ FAQ"),
callback_data="menu_faq",
)
])
if show_privacy_policy:
buttons.append([
InlineKeyboardButton(
text=texts.t("MENU_PRIVACY_POLICY", "🛡️ Политика конф."),
callback_data="menu_privacy_policy",
)
])
if show_public_offer:
buttons.append([
InlineKeyboardButton(
text=texts.t("MENU_PUBLIC_OFFER", "📄 Оферта"),
callback_data="menu_public_offer",
)
])
buttons.append([
InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules")
])
server_status_mode = settings.get_server_status_mode()
server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов")
if server_status_mode == "external_link":
status_url = settings.get_server_status_external_url()
if status_url:
buttons.append([InlineKeyboardButton(text=server_status_text, url=status_url)])
elif server_status_mode == "external_link_miniapp":
status_url = settings.get_server_status_external_url()
if status_url:
buttons.append([
InlineKeyboardButton(
text=server_status_text,
web_app=types.WebAppInfo(url=status_url),
)
])
elif server_status_mode == "xray":
buttons.append([
InlineKeyboardButton(
text=server_status_text,
callback_data="menu_server_status",
)
])
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]:
if not settings.is_happ_download_button_enabled():
return None
+64 -5
View File
@@ -75,6 +75,8 @@
"LOADING": "⏳ Loading...",
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Test servers active: {servers}",
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Access active for {time_left}\n<code>{bar}</code>",
"MAIN_MENU_BUTTON": "🏠 Main menu",
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
"DEVICE_UUID_NOT_FOUND": "❌ User UUID not found",
@@ -275,6 +277,7 @@
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
"CONTACT_SUPPORT": "💬 Contact support",
"PRIVACY_RESTRICTED_BUTTON_HINT": "⚠️ <b>Telegram blocked the contact request button because of your privacy settings.</b>\n\nAllow sharing your contact details in Telegram settings or send the required information manually in the chat.",
"CREATE_INVITE": "📝 Create invite",
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
"DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})",
@@ -299,6 +302,9 @@
"MENU_ADMIN": "⚙️ Admin panel",
"MENU_BUY_SUBSCRIPTION": "💎 Buy subscription",
"MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription",
"MENU_INFO": "️ Info",
"MENU_INFO_HEADER": "️ <b>Info</b>",
"MENU_INFO_PROMPT": "Choose a section:",
"MENU_PROMOCODE": "🎫 Promo code",
"MENU_REFERRALS": "🤝 Referral program",
"MENU_RULES": "📋 Service rules",
@@ -522,6 +528,16 @@
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.",
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.",
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
"ADMIN_PROMO_OFFER_TEST_DURATION": "Access: {hours} h",
"ADMIN_PROMO_OFFER_TEST_SQUAD_NAME": "Server: {name}",
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Squads: {squads}",
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Squads: not specified",
"ADMIN_PROMO_OFFER_RESULT": "📬 Broadcast finished\nSent: {sent}\nFailed: {failed}",
"ADMIN_PROMO_OFFER_SKIPPED": "Skipped: {skipped} (already have access)",
"ADMIN_PROMO_OFFER_CTA_BUY": "Buy subscription",
"ADMIN_PROMO_OFFER_CTA_EXTEND": "Extend subscription",
"ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ Back to offer",
"ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ Back to promo offers",
"ADMIN_REPORTS": "📊 Reports",
"ADMIN_TICKETS_TITLE": "🎫 All support tickets:",
"ADMIN_TICKETS_TITLE_CLOSED": "🎫 Closed support tickets:",
@@ -548,11 +564,17 @@
"CREATE_TICKET_BUTTON": "🎫 Create ticket",
"DELETE_MESSAGE": "🗑 Delete",
"DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus",
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Promo offer: -{amount} ({percent}% extra)",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n<code>{bar}</code>",
"DISCOUNT_CLAIM_ALREADY": "️ This discount has already been activated.",
"DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.",
"DISCOUNT_CLAIM_ERROR": "❌ Failed to activate the discount. Please try again later.",
"PROMO_OFFER_CLOSE": "❌ Close",
"DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It will be applied automatically to your next payment.",
"TEST_ACCESS_ALREADY_CONNECTED": "️ This server is already connected to your subscription.",
"ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):",
"LANGUAGE_SELECTION_DISABLED": "⚙️ Language selection is temporarily unavailable. Using the default language.",
"MARK_AS_ANSWERED": "✅ Mark as answered",
@@ -592,8 +614,8 @@
"REPORT_CLOSE_ERROR": "❌ Failed to close the report.",
"SENDING_ATTACHMENTS": "📎 Sending attachments...",
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Your subscription expired</b>\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nActivate the offer to get an additional discount. It stacks with your promo group and is valid until {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\n\nIt's been {trigger_days} days without a subscription. Come back and activate the extra discount — it stacks with your promo group and is valid until {expires_at}.",
"SUBSCRIPTION_EXTEND": "💎 Extend subscription",
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.",
@@ -698,5 +720,42 @@
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Add to the description if needed.",
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Description updated.",
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Description sent below",
"ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted"
"ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted",
"MENU_PUBLIC_OFFER": "📄 Offer",
"PUBLIC_OFFER_NOT_AVAILABLE": "Public offer is temporarily unavailable.",
"PUBLIC_OFFER_EMPTY_ALERT": "Public offer content is not provided yet.",
"PUBLIC_OFFER_HEADER": "📄 <b>Public Offer</b>",
"PUBLIC_OFFER_PAGE_INFO": "Page {current} of {total}",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer",
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Public offer</b>",
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "The public offer is shown in the “Info” section.",
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Language: <code>{lang}</code>",
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Offer display is disabled or empty.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Offer is active and visible to users.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Offer is enabled but text is empty — users will not see it.",
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Last updated: {timestamp}",
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Text preview:</b>",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Text is not set yet.",
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Choose an action:",
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Edit text",
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 View current text",
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Enable display",
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Disable display",
"ADMIN_PUBLIC_OFFER_HTML_HELP": "️ HTML help",
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Current text (preview):",
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Send a new public offer text. HTML markup is allowed.",
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Use /html_help for the list of allowed tags.",
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Public offer editing",
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Cancel",
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Offer text is too long. Maximum 4000 characters.",
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ HTML error: {error}",
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Public offer updated.",
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ Back to offer settings",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Offer text is not set yet.",
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Current offer text</b>",
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Text shortened for display. Users will see the full version in the menu.",
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Offer enabled",
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Offer disabled",
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Back to editing",
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled."
}
+99 -4
View File
@@ -158,6 +158,7 @@
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку",
"CONTACT_SUPPORT": "💬 Написать в поддержку",
"CONTINUE": "➡️ Продолжить",
"PRIVACY_RESTRICTED_BUTTON_HINT": "⚠️ <b>Telegram не позволяет боту запросить контакт из-за ваших настроек приватности.</b>\n\nРазрешите отправку контакта в настройках Telegram или поделитесь данными вручную в ответ на сообщение.",
"CONTINUE_BUTTON": "✅ Продолжить",
"COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки",
"CREATE_INVITE": "📝 Создать приглашение",
@@ -202,6 +203,8 @@
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n",
"MAIN_MENU": "👤 <b>{user_name}</b>\n \n📱 <b>Подписка:</b> {subscription_status}\n\nВыберите действие:\n",
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Тестовые сервера активны: {servers}",
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Доступ действует ещё: {time_left}\n<code>{bar}</code>",
"MAIN_MENU_BUTTON": "🏠 Главное меню",
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
"DEVICE_UUID_NOT_FOUND": "❌ UUID пользователя не найден",
@@ -228,6 +231,19 @@
"MENU_BALANCE": "💰 Баланс",
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
"MENU_INFO": "️ Инфо",
"MENU_INFO_HEADER": "️ <b>Инфо</b>",
"MENU_INFO_PROMPT": "Выберите раздел:",
"MENU_PRIVACY_POLICY": "🛡️ Политика конф.",
"MENU_PUBLIC_OFFER": "📄 Оферта",
"PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
"PRIVACY_POLICY_NOT_AVAILABLE": "Политика конфиденциальности временно недоступна.",
"PRIVACY_POLICY_EMPTY_ALERT": "Политика конфиденциальности ещё не заполнена.",
"PRIVACY_POLICY_PAGE_INFO": "Страница {current} из {total}",
"PUBLIC_OFFER_NOT_AVAILABLE": "Публичная оферта временно недоступна.",
"PUBLIC_OFFER_EMPTY_ALERT": "Публичная оферта ещё не заполнена.",
"PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
"PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}",
"MENU_LANGUAGE": "🌐 Язык",
"MENU_PROMOCODE": "🎫 Промокод",
"MENU_REFERRALS": "🤝 Партнерка",
@@ -524,6 +540,16 @@
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.",
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:",
"ADMIN_PROMO_OFFER_TEST_DURATION": "Доступ: {hours} ч",
"ADMIN_PROMO_OFFER_TEST_SQUAD_NAME": "Сервер: {name}",
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Сквады: {squads}",
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Сквады: не указаны",
"ADMIN_PROMO_OFFER_RESULT": "📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}",
"ADMIN_PROMO_OFFER_SKIPPED": "Пропущено: {skipped} (уже есть доступ)",
"ADMIN_PROMO_OFFER_CTA_BUY": "Купить подписку",
"ADMIN_PROMO_OFFER_CTA_EXTEND": "Продлить подписку",
"ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ К предложению",
"ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ К промопредложениям",
"ADMIN_REPORTS": "📊 Отчеты",
"ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:",
"ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:",
@@ -548,11 +574,17 @@
"CREATE_TICKET_BUTTON": "🎫 Создать тикет",
"DELETE_MESSAGE": "🗑 Удалить",
"DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки",
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Промо-предложение: -{amount} ({percent}% дополнительно)",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n<code>{bar}</code>",
"DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.",
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.",
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось активировать скидку. Попробуйте позже.",
"PROMO_OFFER_CLOSE": "❌ Закрыть",
"DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 <b>Скидка {percent}% активирована!</b> \n\nОна суммируется с другими скидками и автоматически применится при следующей оплате.",
"TEST_ACCESS_ALREADY_CONNECTED": "ℹ️ Этот сервер уже подключен к вашей подписке.",
"ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):",
"LANGUAGE_SELECTION_DISABLED": "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
"MARK_AS_ANSWERED": "✅ Отметить как отвеченный",
@@ -592,8 +624,8 @@
"REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.",
"SENDING_ATTACHMENTS": "📎 Отправляю вложения...",
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Подписка закончилась</b>\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nАктивируйте предложение, чтобы получить дополнительную скидку. Она суммируется с вашей промогруппой и действует до {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.",
"SUBSCRIPTION_EXTEND": "💎 Продлить подписку",
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
@@ -661,6 +693,69 @@
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Политика конф.",
"ADMIN_PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
"ADMIN_PRIVACY_POLICY_DESCRIPTION": "Политика конфиденциальности отображается в разделе «Инфо».",
"ADMIN_PRIVACY_POLICY_LANGUAGE": "Язык: <code>{lang}</code>",
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Показ политики выключен или текст отсутствует.",
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Политика активна и показывается пользователям.",
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
"ADMIN_PRIVACY_POLICY_UPDATED_AT": "Последнее обновление: {timestamp}",
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "<b>Превью текста:</b>",
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "Текст ещё не задан.",
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Выберите действие:",
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Изменить текст",
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 Просмотреть текущий текст",
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Включить показ",
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Отключить показ",
"ADMIN_PRIVACY_POLICY_HTML_HELP": "️ HTML помощь",
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Текущий текст (превью):",
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
"ADMIN_PRIVACY_POLICY_EDIT_HINT": "Используйте /html_help для справки по тегам.",
"ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Редактирование политики",
"ADMIN_PRIVACY_POLICY_CANCEL": "❌ Отмена",
"ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ Текст политики слишком длинный. Максимум 4000 символов.",
"ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ Ошибка в HTML: {error}",
"ADMIN_PRIVACY_POLICY_SAVED": "✅ Политика конфиденциальности обновлена.",
"ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ К настройкам политики",
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "Текст политики пока не задан.",
"ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 <b>Текущий текст политики</b>",
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
"ADMIN_PRIVACY_POLICY_ENABLED": "✅ Политика включена",
"ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Политика отключена",
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "Публичная оферта отображается в разделе «Инфо».",
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Язык: <code>{lang}</code>",
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Показ оферты выключен или текст отсутствует.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Оферта активна и показывается пользователям.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Последнее обновление: {timestamp}",
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Превью текста:</b>",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Текст ещё не задан.",
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Выберите действие:",
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Изменить текст",
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 Просмотреть текущий текст",
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Включить показ",
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Отключить показ",
"ADMIN_PUBLIC_OFFER_HTML_HELP": "️ HTML помощь",
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Текущий текст (превью):",
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Используйте /html_help для справки по тегам.",
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Редактирование оферты",
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Отмена",
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Текст оферты слишком длинный. Максимум 4000 символов.",
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ Ошибка в HTML: {error}",
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Публичная оферта обновлена.",
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ К настройкам оферты",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Текст оферты пока не задан.",
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Текущий текст оферты</b>",
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Оферта включена",
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Оферта отключена",
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Редактирование оферты отменено.",
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
"ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены",
+273
View File
@@ -0,0 +1,273 @@
import logging
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.faq import (
bulk_update_order,
create_faq_page,
delete_faq_page,
get_faq_page_by_id,
get_faq_pages,
get_faq_setting,
set_faq_enabled,
update_faq_page,
)
from app.database.models import FaqPage, FaqSetting
logger = logging.getLogger(__name__)
class FaqService:
MAX_PAGE_LENGTH = 3500
@staticmethod
def _normalize_language(language: str) -> str:
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
return base_language.split("-")[0].lower()
@staticmethod
def normalize_language(language: str) -> str:
return FaqService._normalize_language(language)
@classmethod
async def get_setting(
cls,
db: AsyncSession,
language: str,
*,
fallback: bool = True,
) -> Optional[FaqSetting]:
lang = cls._normalize_language(language)
setting = await get_faq_setting(db, lang)
if setting or not fallback:
return setting
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang != default_lang:
return await get_faq_setting(db, default_lang)
return setting
@classmethod
async def is_enabled(cls, db: AsyncSession, language: str) -> bool:
pages = await cls.get_pages(db, language)
return bool(pages)
@classmethod
async def set_enabled(
cls,
db: AsyncSession,
language: str,
enabled: bool,
) -> FaqSetting:
lang = cls._normalize_language(language)
return await set_faq_enabled(db, lang, enabled)
@classmethod
async def toggle_enabled(
cls,
db: AsyncSession,
language: str,
) -> FaqSetting:
lang = cls._normalize_language(language)
setting = await get_faq_setting(db, lang)
new_status = True
if setting:
new_status = not setting.is_enabled
return await set_faq_enabled(db, lang, new_status)
@classmethod
async def get_pages(
cls,
db: AsyncSession,
language: str,
*,
include_inactive: bool = False,
fallback: bool = True,
) -> List[FaqPage]:
lang = cls._normalize_language(language)
pages = await get_faq_pages(db, lang, include_inactive=include_inactive)
if pages:
setting = await get_faq_setting(db, lang)
if setting and not setting.is_enabled and not include_inactive:
return []
return pages
if not fallback:
return []
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang == default_lang:
return []
fallback_pages = await get_faq_pages(
db,
default_lang,
include_inactive=include_inactive,
)
if not fallback_pages:
return []
setting = await get_faq_setting(db, default_lang)
if setting and not setting.is_enabled and not include_inactive:
return []
return fallback_pages
@classmethod
async def get_page(
cls,
db: AsyncSession,
page_id: int,
language: str,
*,
fallback: bool = True,
include_inactive: bool = False,
) -> Optional[FaqPage]:
page = await get_faq_page_by_id(db, page_id)
if not page:
return None
lang = cls._normalize_language(language)
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if not include_inactive and not page.is_active:
return None
if page.language == lang:
return page
if fallback and page.language == default_lang:
return page
return None
@classmethod
async def create_page(
cls,
db: AsyncSession,
*,
language: str,
title: str,
content: str,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
) -> FaqPage:
lang = cls._normalize_language(language)
is_active_value = True if is_active is None else bool(is_active)
page = await create_faq_page(
db,
language=lang,
title=title,
content=content,
display_order=display_order,
is_active=is_active_value,
)
setting = await get_faq_setting(db, lang)
if not setting:
await set_faq_enabled(db, lang, True)
return page
@classmethod
async def update_page(
cls,
db: AsyncSession,
page: FaqPage,
*,
title: Optional[str] = None,
content: Optional[str] = None,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
) -> FaqPage:
return await update_faq_page(
db,
page,
title=title,
content=content,
display_order=display_order,
is_active=is_active,
)
@classmethod
async def delete_page(cls, db: AsyncSession, page_id: int) -> None:
await delete_faq_page(db, page_id)
@classmethod
async def reorder_pages(
cls,
db: AsyncSession,
language: str,
pages: List[FaqPage],
) -> None:
lang = cls._normalize_language(language)
ordered = [page for page in pages if page.language == lang]
payload = [(page.id, index + 1) for index, page in enumerate(ordered)]
await bulk_update_order(db, payload)
@staticmethod
def split_content_into_pages(
content: str,
*,
max_length: Optional[int] = None,
) -> List[str]:
if not content:
return []
normalized = content.replace("\r\n", "\n").strip()
if not normalized:
return []
limit = max_length or FaqService.MAX_PAGE_LENGTH
if len(normalized) <= limit:
return [normalized]
paragraphs = [
paragraph.strip()
for paragraph in normalized.split("\n\n")
if paragraph.strip()
]
pages: List[str] = []
current = ""
def flush_current() -> None:
nonlocal current
if current:
pages.append(current.strip())
current = ""
for paragraph in paragraphs:
candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph
if len(candidate) <= limit:
current = candidate
continue
flush_current()
if len(paragraph) <= limit:
current = paragraph
continue
start = 0
while start < len(paragraph):
chunk = paragraph[start : start + limit]
pages.append(chunk.strip())
start += limit
current = ""
flush_current()
if not pages:
return [normalized[:limit]]
return pages
+131 -23
View File
@@ -15,8 +15,10 @@ from app.config import settings
from app.database.database import get_db
from app.database.crud.discount_offer import (
deactivate_expired_offers,
get_latest_claimed_offer_for_user,
upsert_discount_offer,
)
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.database.crud.notification import (
clear_notification_by_type,
notification_sent,
@@ -34,12 +36,15 @@ from app.database.crud.user import (
get_inactive_users,
get_user_by_id,
subtract_user_balance,
cleanup_expired_promo_offer_discounts,
)
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
from app.localization.texts import get_texts
from app.services.notification_settings_service import NotificationSettingsService
from app.services.payment_service import PaymentService
from app.services.subscription_service import SubscriptionService
from app.services.promo_offer_service import promo_offer_service
from app.utils.pricing_utils import apply_percentage_discount
from app.external.remnawave_api import (
RemnaWaveAPIError,
@@ -179,6 +184,17 @@ class MonitoringService:
if expired_offers:
logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений")
expired_active_discounts = await cleanup_expired_promo_offer_discounts(db)
if expired_active_discounts:
logger.info(
"🧹 Сброшено %s активных скидок промо-предложений с истекшим сроком",
expired_active_discounts,
)
cleaned_test_access = await promo_offer_service.cleanup_expired_test_access(db)
if cleaned_test_access:
logger.info(f"🧹 Отозвано {cleaned_test_access} истекших тестовых доступов к сквадам")
await self._check_expired_subscriptions(db)
await self._check_expiring_subscriptions(db)
await self._check_trial_expiring_soon(db)
@@ -676,15 +692,15 @@ class MonitoringService:
if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"):
percent = NotificationSettingsService.get_second_wave_discount_percent()
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
offer = await upsert_discount_offer(
db,
user_id=user.id,
subscription_id=subscription.id,
notification_type="expired_discount_wave2",
discount_percent=percent,
bonus_amount_kopeks=bonus_amount,
bonus_amount_kopeks=0,
valid_hours=valid_hours,
effect_type="percent_discount",
)
success = await self._send_expired_discount_notification(
user,
@@ -693,7 +709,6 @@ class MonitoringService:
offer.expires_at,
offer.id,
"second",
bonus_amount,
)
if success:
await record_notification(db, user.id, subscription.id, "expired_discount_wave2")
@@ -706,15 +721,15 @@ class MonitoringService:
if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"):
percent = NotificationSettingsService.get_third_wave_discount_percent()
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
offer = await upsert_discount_offer(
db,
user_id=user.id,
subscription_id=subscription.id,
notification_type="expired_discount_wave3",
discount_percent=percent,
bonus_amount_kopeks=bonus_amount,
bonus_amount_kopeks=0,
valid_hours=valid_hours,
effect_type="percent_discount",
)
success = await self._send_expired_discount_notification(
user,
@@ -723,7 +738,6 @@ class MonitoringService:
offer.expires_at,
offer.id,
"third",
bonus_amount,
trigger_days=trigger_days,
)
if success:
@@ -777,6 +791,85 @@ class MonitoringService:
return subscriptions
@staticmethod
def _get_user_promo_offer_discount_percent(user: Optional[User]) -> int:
if not user:
return 0
try:
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
return 0
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
if expires_at and expires_at <= datetime.utcnow():
return 0
return max(0, min(100, percent))
@staticmethod
async def _consume_user_promo_offer_discount(db: AsyncSession, user: User) -> None:
percent = MonitoringService._get_user_promo_offer_discount_percent(user)
if percent <= 0:
return
source = getattr(user, "promo_offer_discount_source", None)
log_payload = {
"offer_id": None,
"percent": percent,
"source": source,
"effect_type": None,
}
try:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception as lookup_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to resolve latest claimed promo offer for user %s: %s",
user.id,
lookup_error,
)
offer = None
if offer:
log_payload["offer_id"] = offer.id
log_payload["effect_type"] = offer.effect_type
if not log_payload["percent"] and offer.discount_percent:
log_payload["percent"] = offer.discount_percent
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
try:
await log_promo_offer_action(
db,
user_id=user.id,
offer_id=log_payload.get("offer_id"),
action="consumed",
source=log_payload.get("source"),
percent=log_payload.get("percent"),
effect_type=log_payload.get("effect_type"),
details={"reason": "autopay_consumed"},
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer autopay log for user %s: %s",
user.id,
log_error,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer autopay log failure: %s",
rollback_error,
)
async def _process_autopayments(self, db: AsyncSession):
try:
current_time = datetime.utcnow()
@@ -809,17 +902,26 @@ class MonitoringService:
continue
renewal_cost = settings.PRICE_30_DAYS
promo_discount_percent = self._get_user_promo_offer_discount_percent(user)
charge_amount = renewal_cost
promo_discount_value = 0
if renewal_cost > 0 and promo_discount_percent > 0:
charge_amount, promo_discount_value = apply_percentage_discount(
renewal_cost,
promo_discount_percent,
)
autopay_key = f"autopay_{user.telegram_id}_{subscription.id}"
if autopay_key in self._notified_users:
continue
if user.balance_kopeks >= renewal_cost:
if user.balance_kopeks >= charge_amount:
success = await subtract_user_balance(
db, user, renewal_cost,
db, user, charge_amount,
"Автопродление подписки"
)
if success:
await extend_subscription(db, subscription, 30)
await self.subscription_service.update_remnawave_user(
@@ -828,22 +930,30 @@ class MonitoringService:
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="автопродление подписки",
)
if promo_discount_value > 0:
await self._consume_user_promo_offer_discount(db, user)
if self.bot:
await self._send_autopay_success_notification(user, renewal_cost, 30)
await self._send_autopay_success_notification(user, charge_amount, 30)
processed_count += 1
self._notified_users.add(autopay_key)
logger.info(f"💳 Автопродление подписки пользователя {user.telegram_id} успешно")
logger.info(
"💳 Автопродление подписки пользователя %s успешно (списано %s, скидка %s%%)",
user.telegram_id,
charge_amount,
promo_discount_percent,
)
else:
failed_count += 1
if self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
logger.warning(f"💳 Ошибка списания средств для автопродления пользователя {user.telegram_id}")
else:
failed_count += 1
if self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
logger.warning(f"💳 Недостаточно средств для автопродления у пользователя {user.telegram_id}")
if processed_count > 0 or failed_count > 0:
@@ -1186,7 +1296,6 @@ class MonitoringService:
expires_at: datetime,
offer_id: int,
wave: str,
bonus_amount: int,
trigger_days: int = None,
) -> bool:
try:
@@ -1197,8 +1306,8 @@ class MonitoringService:
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
(
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
"Предложение действует до {expires_at}."
"Активируйте предложение, чтобы получить дополнительную скидку. "
"Она суммируется с вашей промогруппой и действует до {expires_at}."
),
)
else:
@@ -1206,14 +1315,13 @@ class MonitoringService:
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
(
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
"Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. "
"Скидка действует до {expires_at}."
"Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. "
"Она суммируется с промогруппой и действует до {expires_at}."
),
)
message = template.format(
percent=percent,
bonus=settings.format_price(bonus_amount),
expires_at=expires_at.strftime("%d.%m.%Y %H:%M"),
trigger_days=trigger_days or "",
)
+178
View File
@@ -0,0 +1,178 @@
import logging
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.privacy_policy import (
get_privacy_policy,
set_privacy_policy_enabled,
upsert_privacy_policy,
)
from app.database.models import PrivacyPolicy
logger = logging.getLogger(__name__)
class PrivacyPolicyService:
"""Utility helpers around privacy policy storage and presentation."""
MAX_PAGE_LENGTH = 3500
@staticmethod
def _normalize_language(language: str) -> str:
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
return base_language.split("-")[0].lower()
@staticmethod
def normalize_language(language: str) -> str:
return PrivacyPolicyService._normalize_language(language)
@classmethod
async def get_policy(
cls,
db: AsyncSession,
language: str,
*,
fallback: bool = False,
) -> Optional[PrivacyPolicy]:
lang = cls._normalize_language(language)
policy = await get_privacy_policy(db, lang)
if policy or not fallback:
return policy
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang != default_lang:
return await get_privacy_policy(db, default_lang)
return policy
@classmethod
async def get_active_policy(
cls,
db: AsyncSession,
language: str,
) -> Optional[PrivacyPolicy]:
lang = cls._normalize_language(language)
policy = await get_privacy_policy(db, lang)
if policy and policy.is_enabled and policy.content.strip():
return policy
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang != default_lang:
fallback_policy = await get_privacy_policy(db, default_lang)
if fallback_policy and fallback_policy.is_enabled and fallback_policy.content.strip():
return fallback_policy
return None
@classmethod
async def is_policy_enabled(cls, db: AsyncSession, language: str) -> bool:
policy = await cls.get_active_policy(db, language)
return policy is not None
@classmethod
async def save_policy(
cls,
db: AsyncSession,
language: str,
content: str,
) -> PrivacyPolicy:
lang = cls._normalize_language(language)
enable_if_new = True
policy = await upsert_privacy_policy(
db,
lang,
content,
enable_if_new=enable_if_new,
)
logger.info("✅ Политика конфиденциальности обновлена для языка %s", lang)
return policy
@classmethod
async def set_enabled(
cls,
db: AsyncSession,
language: str,
enabled: bool,
) -> PrivacyPolicy:
lang = cls._normalize_language(language)
return await set_privacy_policy_enabled(db, lang, enabled)
@classmethod
async def toggle_enabled(
cls,
db: AsyncSession,
language: str,
) -> PrivacyPolicy:
lang = cls._normalize_language(language)
policy = await get_privacy_policy(db, lang)
if policy:
new_status = not policy.is_enabled
else:
new_status = True
return await set_privacy_policy_enabled(db, lang, new_status)
@staticmethod
def split_content_into_pages(
content: str,
*,
max_length: int = None,
) -> List[str]:
if not content:
return []
normalized = content.replace("\r\n", "\n").strip()
if not normalized:
return []
max_len = max_length or PrivacyPolicyService.MAX_PAGE_LENGTH
if len(normalized) <= max_len:
return [normalized]
paragraphs = [
paragraph.strip()
for paragraph in normalized.split("\n\n")
if paragraph.strip()
]
pages: List[str] = []
current = ""
def flush_current() -> None:
nonlocal current
if current:
pages.append(current.strip())
current = ""
for paragraph in paragraphs:
candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph
if len(candidate) <= max_len:
current = candidate
continue
flush_current()
if len(paragraph) <= max_len:
current = paragraph
continue
start_index = 0
while start_index < len(paragraph):
chunk = paragraph[start_index:start_index + max_len]
pages.append(chunk.strip())
start_index += max_len
current = ""
flush_current()
if not pages:
return [normalized[:max_len]]
return pages
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Sequence, Tuple
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
DiscountOffer,
Subscription,
SubscriptionTemporaryAccess,
User,
)
from app.services.subscription_service import SubscriptionService
from app.database.crud.promo_offer_log import log_promo_offer_action
logger = logging.getLogger(__name__)
class PromoOfferService:
def __init__(self) -> None:
self.subscription_service = SubscriptionService()
async def grant_test_access(
self,
db: AsyncSession,
user: User,
offer: DiscountOffer,
) -> Tuple[bool, Optional[List[str]], Optional[datetime], str]:
subscription = getattr(user, "subscription", None)
if not subscription:
return False, None, None, "subscription_missing"
payload = offer.extra_data or {}
raw_squads = payload.get("test_squad_uuids") or payload.get("squads") or []
if isinstance(raw_squads, str):
candidates = [raw_squads]
else:
try:
candidates = list(raw_squads)
except TypeError:
candidates = []
squad_uuids: Sequence[str] = [str(item) for item in candidates if item]
if not squad_uuids:
return False, None, None, "squads_missing"
squad_uuids = list(dict.fromkeys(squad_uuids))
connected = {str(item) for item in subscription.connected_squads or []}
if squad_uuids and set(squad_uuids).issubset(connected):
return False, None, None, "already_connected"
try:
duration_hours = int(payload.get("test_duration_hours") or payload.get("duration_hours") or 24)
except (TypeError, ValueError):
duration_hours = 24
if duration_hours <= 0:
duration_hours = 24
now = datetime.utcnow()
expires_at = now + timedelta(hours=duration_hours)
original_connected = set(connected)
newly_added: List[str] = []
changes_made = False
for squad_uuid in squad_uuids:
normalized_uuid = str(squad_uuid)
existing_result = await db.execute(
select(SubscriptionTemporaryAccess)
.where(
SubscriptionTemporaryAccess.offer_id == offer.id,
SubscriptionTemporaryAccess.squad_uuid == normalized_uuid,
)
.order_by(SubscriptionTemporaryAccess.id.desc())
)
existing_access = existing_result.scalars().first()
if existing_access and existing_access.is_active:
if existing_access.expires_at < expires_at:
existing_access.expires_at = expires_at
changes_made = True
continue
was_already_connected = normalized_uuid in connected
if not was_already_connected:
connected.add(normalized_uuid)
newly_added.append(normalized_uuid)
changes_made = True
access_entry = SubscriptionTemporaryAccess(
subscription_id=subscription.id,
offer_id=offer.id,
squad_uuid=normalized_uuid,
expires_at=expires_at,
is_active=True,
was_already_connected=was_already_connected,
)
db.add(access_entry)
changes_made = True
connected_changed = connected != original_connected
if newly_added:
subscription.connected_squads = list(connected)
subscription.updated_at = now
changes_made = True
if connected_changed:
remnawave_user = await self.subscription_service.update_remnawave_user(
db,
subscription,
)
if remnawave_user is None:
await db.rollback()
await db.refresh(subscription)
logger.error(
"Не удалось синхронизировать временный доступ подписки %s с RemnaWave",
subscription.id,
)
return False, None, None, "remnawave_sync_failed"
await db.refresh(subscription)
elif changes_made:
await db.commit()
await db.refresh(subscription)
return True, newly_added, expires_at, "ok"
async def cleanup_expired_test_access(self, db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
select(SubscriptionTemporaryAccess)
.options(
selectinload(SubscriptionTemporaryAccess.subscription),
selectinload(SubscriptionTemporaryAccess.offer),
)
.where(
SubscriptionTemporaryAccess.is_active == True, # noqa: E712
SubscriptionTemporaryAccess.expires_at <= now,
)
)
entries = result.scalars().all()
if not entries:
return 0
subscriptions_updates: dict[int, Tuple[Subscription, set[str]]] = {}
log_payloads: List[Dict[str, object]] = []
for entry in entries:
entry.is_active = False
entry.deactivated_at = now
subscription = entry.subscription
if not subscription:
continue
bucket = subscriptions_updates.setdefault(subscription.id, (subscription, set()))
if not entry.was_already_connected:
bucket[1].add(entry.squad_uuid)
user_id = subscription.user_id
if user_id:
offer = entry.offer
log_payloads.append(
{
"user_id": user_id,
"offer_id": entry.offer_id,
"source": getattr(offer, "notification_type", None),
"percent": getattr(offer, "discount_percent", None),
"effect_type": getattr(offer, "effect_type", "test_access"),
"details": {
"reason": "test_access_expired",
"squad_uuid": entry.squad_uuid,
},
}
)
for subscription, squads_to_remove in subscriptions_updates.values():
if not squads_to_remove:
continue
current = set(subscription.connected_squads or [])
updated = current.difference(squads_to_remove)
if updated != current:
subscription.connected_squads = list(updated)
subscription.updated_at = now
try:
await self.subscription_service.update_remnawave_user(db, subscription)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Ошибка обновления Remnawave при отзыве тестового доступа подписки %s: %s",
subscription.id,
exc,
)
await db.commit()
for payload in log_payloads:
try:
await log_promo_offer_action(
db,
user_id=payload["user_id"],
offer_id=payload.get("offer_id"),
action="disabled",
source=payload.get("source"),
percent=payload.get("percent"),
effect_type=payload.get("effect_type"),
details=payload.get("details"),
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer test access disable log for user %s: %s",
payload.get("user_id"),
exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer test access log failure: %s",
rollback_error,
)
return len(entries)
promo_offer_service = PromoOfferService()
+359
View File
@@ -0,0 +1,359 @@
import logging
from html.parser import HTMLParser
from typing import List, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.public_offer import (
get_public_offer,
set_public_offer_enabled,
upsert_public_offer,
)
from app.database.models import PublicOffer
logger = logging.getLogger(__name__)
class PublicOfferService:
"""Helpers for managing the public offer text and visibility."""
MAX_PAGE_LENGTH = 3500
@staticmethod
def _normalize_language(language: str) -> str:
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
return base_language.split("-")[0].lower()
@staticmethod
def normalize_language(language: str) -> str:
return PublicOfferService._normalize_language(language)
@classmethod
async def get_offer(
cls,
db: AsyncSession,
language: str,
*,
fallback: bool = False,
) -> Optional[PublicOffer]:
lang = cls._normalize_language(language)
offer = await get_public_offer(db, lang)
if offer or not fallback:
return offer
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang != default_lang:
return await get_public_offer(db, default_lang)
return offer
@classmethod
async def get_active_offer(
cls,
db: AsyncSession,
language: str,
) -> Optional[PublicOffer]:
lang = cls._normalize_language(language)
offer = await get_public_offer(db, lang)
if offer:
if offer.is_enabled and offer.content.strip():
return offer
if not offer.is_enabled:
return None
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang != default_lang:
fallback_offer = await get_public_offer(db, default_lang)
if fallback_offer and fallback_offer.is_enabled and fallback_offer.content.strip():
return fallback_offer
return None
@classmethod
async def is_offer_enabled(cls, db: AsyncSession, language: str) -> bool:
offer = await cls.get_active_offer(db, language)
return offer is not None
@classmethod
async def save_offer(
cls,
db: AsyncSession,
language: str,
content: str,
) -> PublicOffer:
lang = cls._normalize_language(language)
enable_if_new = True
offer = await upsert_public_offer(
db,
lang,
content,
enable_if_new=enable_if_new,
)
logger.info("✅ Публичная оферта обновлена для языка %s", lang)
return offer
@classmethod
async def set_enabled(
cls,
db: AsyncSession,
language: str,
enabled: bool,
) -> PublicOffer:
lang = cls._normalize_language(language)
return await set_public_offer_enabled(db, lang, enabled)
@classmethod
async def toggle_enabled(
cls,
db: AsyncSession,
language: str,
) -> PublicOffer:
lang = cls._normalize_language(language)
offer = await get_public_offer(db, lang)
if offer:
new_status = not offer.is_enabled
else:
new_status = True
return await set_public_offer_enabled(db, lang, new_status)
class _RichTextPaginator(HTMLParser):
"""Split HTML-like text into Telegram-safe chunks."""
SELF_CLOSING_TAGS = {"br", "hr", "img", "input", "meta", "link"}
def __init__(self, max_len: int) -> None:
super().__init__(convert_charrefs=False)
self.max_len = max_len
self.pages: List[str] = []
self.current_parts: List[str] = []
self.current_length = 0
self.open_stack: List[Tuple[str, str, int]] = []
self.closing_length = 0
self.needs_prefix = False
self.prefix_length = 0
def _closing_sequence(self) -> str:
return "".join(f"</{name}>" for name, _, _ in reversed(self.open_stack))
def _ensure_prefix(self) -> None:
if not self.needs_prefix:
return
prefix = "".join(token for _, token, _ in self.open_stack)
if prefix:
self.current_parts.append(prefix)
self.current_length += len(prefix)
self.needs_prefix = False
def _flush(self) -> None:
if not self.current_parts and not self.closing_length:
return
content = "".join(self.current_parts)
closing_tags = self._closing_sequence()
page = (content + closing_tags).strip()
if page:
self.pages.append(page)
self.current_parts = []
self.current_length = 0
if self.prefix_length + self.closing_length > self.max_len:
self.open_stack = []
self.closing_length = 0
self.prefix_length = 0
self.needs_prefix = False
else:
self.needs_prefix = bool(self.open_stack)
@staticmethod
def _format_attrs(attrs: List[Tuple[str, Optional[str]]]) -> str:
parts = []
for name, value in attrs:
if value is None:
parts.append(f" {name}")
else:
parts.append(f" {name}=\"{value}\"")
return "".join(parts)
def _append_token(self, token: str) -> None:
while True:
self._ensure_prefix()
if self.current_length + len(token) + self.closing_length <= self.max_len:
self.current_parts.append(token)
self.current_length += len(token)
break
self._flush()
if len(token) > self.max_len and not self.current_parts:
self.current_parts.append(token)
self.current_length += len(token)
break
def handle_data(self, data: str) -> None:
if not data:
return
remaining = data
while remaining:
self._ensure_prefix()
available = self.max_len - (self.current_length + self.closing_length)
if available <= 0:
self._flush()
continue
piece = remaining[:available]
self.current_parts.append(piece)
self.current_length += len(piece)
remaining = remaining[available:]
if remaining:
self._flush()
def handle_entityref(self, name: str) -> None:
self.handle_data(f"&{name};")
def handle_charref(self, name: str) -> None:
self.handle_data(f"&#{name};")
def _handle_self_closing(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
token = f"<{tag}{self._format_attrs(attrs)}/>"
self._append_token(token)
def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
if tag in self.SELF_CLOSING_TAGS:
self._handle_self_closing(tag, attrs)
return
token = f"<{tag}{self._format_attrs(attrs)}>"
closing_token = f"</{tag}>"
closing_len = len(closing_token)
while True:
self._ensure_prefix()
projected_length = self.current_length + len(token) + self.closing_length + closing_len
if projected_length <= self.max_len:
self.current_parts.append(token)
self.current_length += len(token)
self.open_stack.append((tag, token, closing_len))
self.closing_length += closing_len
self.prefix_length += len(token)
break
self._flush()
if len(token) + closing_len > self.max_len and not self.current_parts:
self.current_parts.append(token)
self.current_length += len(token)
self.open_stack.append((tag, token, closing_len))
self.closing_length += closing_len
self.prefix_length += len(token)
break
def handle_endtag(self, tag: str) -> None:
token = f"</{tag}>"
closing_len_reduction = 0
index_to_remove = None
for index in range(len(self.open_stack) - 1, -1, -1):
if self.open_stack[index][0] == tag:
closing_len_reduction = self.open_stack[index][2]
index_to_remove = index
break
while True:
self._ensure_prefix()
projected_closing_length = self.closing_length - closing_len_reduction
if projected_closing_length < 0:
projected_closing_length = 0
projected_total = self.current_length + len(token) + projected_closing_length
if projected_total <= self.max_len or not self.current_parts:
self.current_parts.append(token)
self.current_length += len(token)
if index_to_remove is not None:
removed_tag = self.open_stack.pop(index_to_remove)
self.closing_length -= closing_len_reduction
self.prefix_length -= len(removed_tag[1])
break
self._flush()
def handle_startendtag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
self._handle_self_closing(tag, attrs)
def finalize(self) -> List[str]:
self._flush()
return self.pages
@classmethod
def _split_rich_paragraph(cls, paragraph: str, max_len: int) -> List[str]:
if len(paragraph) <= max_len:
return [paragraph]
paginator = cls._RichTextPaginator(max_len)
paginator.feed(paragraph)
paginator.close()
pages = paginator.finalize()
return pages or [paragraph]
@classmethod
def split_content_into_pages(
cls,
content: str,
*,
max_length: int = None,
) -> List[str]:
if not content:
return []
normalized = content.replace("\r\n", "\n").strip()
if not normalized:
return []
max_len = max_length or cls.MAX_PAGE_LENGTH
if len(normalized) <= max_len:
return [normalized]
paragraphs = [
paragraph.strip()
for paragraph in normalized.split("\n\n")
if paragraph.strip()
]
pages: List[str] = []
current = ""
def flush_current() -> None:
nonlocal current
if current:
pages.append(current.strip())
current = ""
for paragraph in paragraphs:
segments = cls._split_rich_paragraph(paragraph, max_len)
for segment in segments:
segment = segment.strip()
if not segment:
continue
candidate = f"{current}\n\n{segment}".strip() if current else segment
if len(candidate) <= max_len:
current = candidate
continue
flush_current()
current = segment
flush_current()
if not pages:
return [normalized[:max_len]]
return pages
+36 -10
View File
@@ -78,31 +78,56 @@ def get_traffic_reset_strategy():
class SubscriptionService:
def __init__(self):
self._config_error: Optional[str] = None
self.api: Optional[RemnaWaveAPI] = None
self._last_config_signature: Optional[Tuple[str, ...]] = None
self._refresh_configuration()
def _refresh_configuration(self) -> None:
auth_params = settings.get_remnawave_auth_params()
base_url = (auth_params.get("base_url") or "").strip()
api_key = (auth_params.get("api_key") or "").strip()
secret_key = (auth_params.get("secret_key") or "").strip() or None
username = (auth_params.get("username") or "").strip() or None
password = (auth_params.get("password") or "").strip() or None
auth_type = (auth_params.get("auth_type") or "").strip() or None
self._config_error: Optional[str] = None
config_signature = (
base_url,
api_key,
secret_key or "",
username or "",
password or "",
auth_type or "",
)
if config_signature == self._last_config_signature:
return
if not base_url:
self._config_error = "REMNAWAVE_API_URL не настроен"
self.api = None
elif not api_key:
self._config_error = "REMNAWAVE_API_KEY не настроен"
self.api = None
else:
self._config_error = None
self.api = RemnaWaveAPI(
base_url=base_url,
api_key=api_key,
secret_key=secret_key,
username=username,
password=password,
)
if self._config_error:
logger.warning(
"RemnaWave API недоступен: %s. Подписочный сервис будет работать в оффлайн-режиме.",
self._config_error
)
self.api = None
else:
self.api = RemnaWaveAPI(
base_url=base_url,
api_key=api_key,
secret_key=auth_params.get("secret_key"),
username=auth_params.get("username"),
password=auth_params.get("password")
)
self._last_config_signature = config_signature
@property
def is_configured(self) -> bool:
@@ -113,6 +138,7 @@ class SubscriptionService:
return self._config_error
def _ensure_configured(self) -> None:
self._refresh_configuration()
if not self.api or not self.is_configured:
raise RemnaWaveAPIError(
self._config_error or "RemnaWave API не настроен"
+18 -2
View File
@@ -87,6 +87,12 @@ class AdminStates(StatesGroup):
editing_user_traffic = State()
editing_rules_page = State()
editing_privacy_policy = State()
editing_public_offer = State()
creating_faq_title = State()
creating_faq_content = State()
editing_faq_title = State()
editing_faq_content = State()
editing_notification_value = State()
confirming_sync = State()
@@ -97,14 +103,24 @@ class AdminStates(StatesGroup):
editing_server_limit = State()
editing_server_description = State()
editing_server_promo_groups = State()
creating_server_uuid = State()
creating_server_name = State()
creating_server_price = State()
creating_server_country = State()
editing_welcome_text = State()
waiting_for_message_buttons = "waiting_for_message_buttons"
editing_promo_offer_message = State()
editing_promo_offer_button = State()
editing_promo_offer_valid_hours = State()
editing_promo_offer_active_duration = State()
editing_promo_offer_discount = State()
editing_promo_offer_test_duration = State()
editing_promo_offer_squads = State()
selecting_promo_offer_user = State()
searching_promo_offer_user = State()
# Состояния для отслеживания источника перехода
viewing_user_from_balance_list = State()
+87 -6
View File
@@ -1,10 +1,14 @@
from pathlib import Path
from aiogram.types import Message, FSInputFile, InputMediaPhoto
from typing import Any, Dict
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import FSInputFile, InputMediaPhoto, Message
from app.config import settings
from app.localization.texts import get_texts
LOGO_PATH = Path(settings.LOGO_FILE)
_PRIVACY_RESTRICTED_CODE = "BUTTON_USER_PRIVACY_RESTRICTED"
def is_qr_message(message: Message) -> bool:
@@ -15,6 +19,65 @@ _original_answer = Message.answer
_original_edit_text = Message.edit_text
def _get_language(message: Message) -> str | None:
try:
user = message.from_user
if user and getattr(user, "language_code", None):
return user.language_code
except AttributeError:
pass
return None
def _default_privacy_hint(language: str | None) -> str:
if language and language.lower().startswith("en"):
return (
"⚠️ Telegram blocked the contact request button because of your privacy settings. "
"Please allow sharing your contact information or send the required details manually."
)
return (
"⚠️ Telegram запретил кнопку запроса контакта из-за настроек приватности. "
"Разрешите отправку контакта в настройках Telegram или отправьте данные вручную."
)
def append_privacy_hint(text: str | None, language: str | None) -> str:
base_text = text or ""
try:
hint = get_texts(language).t(
"PRIVACY_RESTRICTED_BUTTON_HINT",
default=_default_privacy_hint(language),
)
except Exception:
hint = _default_privacy_hint(language)
hint = hint.strip()
if not hint:
return base_text
if hint in base_text:
return base_text
if base_text:
return f"{base_text}\n\n{hint}"
return hint
def prepare_privacy_safe_kwargs(kwargs: Dict[str, Any] | None = None) -> Dict[str, Any]:
safe_kwargs: Dict[str, Any] = dict(kwargs or {})
safe_kwargs.pop("reply_markup", None)
return safe_kwargs
def is_privacy_restricted_error(error: Exception) -> bool:
if not isinstance(error, TelegramBadRequest):
return False
message = getattr(error, "message", "") or ""
description = str(error)
return _PRIVACY_RESTRICTED_CODE in message or _PRIVACY_RESTRICTED_CODE in description
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
# Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ
if not settings.ENABLE_LOGO_MODE:
@@ -25,12 +88,20 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
return await _original_answer(self, text, **kwargs)
except Exception:
pass
language = _get_language(self)
if LOGO_PATH.exists():
try:
# Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
except TelegramBadRequest as error:
if is_privacy_restricted_error(error):
fallback_text = append_privacy_hint(text, language)
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
return await _original_answer(self, fallback_text, **safe_kwargs)
# Фоллбек, если Telegram ругается на caption или другое ограничение: отправим как текст
return await _original_answer(self, text, **kwargs)
except Exception:
# Фоллбек, если Telegram ругается на caption: отправим как текст
return await _original_answer(self, text, **kwargs)
return await _original_answer(self, text, **kwargs)
@@ -40,6 +111,7 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
if not settings.ENABLE_LOGO_MODE:
return await _original_edit_text(self, text, **kwargs)
if self.photo:
language = _get_language(self)
# Если caption потенциально слишком длинный — отправим как текст вместо caption
try:
if text is not None and len(text) > 900:
@@ -59,14 +131,23 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
else:
media = self.photo[-1].file_id
media_kwargs = {"media": media, "caption": text}
if "parse_mode" in kwargs:
_pm = kwargs.pop("parse_mode")
edit_kwargs = dict(kwargs)
if "parse_mode" in edit_kwargs:
_pm = edit_kwargs.pop("parse_mode")
media_kwargs["parse_mode"] = _pm if _pm is not None else "HTML"
else:
media_kwargs["parse_mode"] = "HTML"
try:
return await self.edit_media(InputMediaPhoto(**media_kwargs), **kwargs)
except TelegramBadRequest:
return await self.edit_media(InputMediaPhoto(**media_kwargs), **edit_kwargs)
except TelegramBadRequest as error:
if is_privacy_restricted_error(error):
fallback_text = append_privacy_hint(text, language)
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
try:
await self.delete()
except Exception:
pass
return await _original_answer(self, fallback_text, **safe_kwargs)
# Фоллбек: удалим и отправим обычный текст без фото
try:
await self.delete()
+68 -27
View File
@@ -3,7 +3,13 @@ from aiogram.exceptions import TelegramBadRequest
from aiogram.types import FSInputFile, InputMediaPhoto
from app.config import settings
from .message_patch import LOGO_PATH, is_qr_message
from .message_patch import (
LOGO_PATH,
append_privacy_hint,
is_privacy_restricted_error,
is_qr_message,
prepare_privacy_safe_kwargs,
)
def _resolve_media(message: types.Message):
@@ -17,35 +23,69 @@ def _resolve_media(message: types.Message):
return FSInputFile(LOGO_PATH)
def _get_language(callback: types.CallbackQuery) -> str | None:
try:
user = callback.from_user
if user and getattr(user, "language_code", None):
return user.language_code
except AttributeError:
pass
return None
def _build_base_kwargs(keyboard: types.InlineKeyboardMarkup | None, parse_mode: str | None):
kwargs: dict[str, object] = {}
if parse_mode is not None:
kwargs["parse_mode"] = parse_mode
if keyboard is not None:
kwargs["reply_markup"] = keyboard
return kwargs
async def _answer_text(
callback: types.CallbackQuery,
caption: str,
keyboard: types.InlineKeyboardMarkup | None,
parse_mode: str | None,
error: TelegramBadRequest | None = None,
) -> None:
language = _get_language(callback)
kwargs = _build_base_kwargs(keyboard, parse_mode)
if error and is_privacy_restricted_error(error):
caption = append_privacy_hint(caption, language)
kwargs = prepare_privacy_safe_kwargs(kwargs)
kwargs.setdefault("parse_mode", parse_mode or "HTML")
await callback.message.answer(
caption,
**kwargs,
)
async def edit_or_answer_photo(
callback: types.CallbackQuery,
caption: str,
keyboard: types.InlineKeyboardMarkup,
parse_mode: str | None = "HTML",
) -> None:
resolved_parse_mode = parse_mode or "HTML"
# Если режим логотипа выключен — работаем текстом
if not settings.ENABLE_LOGO_MODE:
try:
if callback.message.photo:
await callback.message.delete()
await callback.message.answer(
caption,
reply_markup=keyboard,
parse_mode=parse_mode,
)
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
else:
await callback.message.edit_text(
caption,
reply_markup=keyboard,
parse_mode=parse_mode,
parse_mode=resolved_parse_mode,
)
except TelegramBadRequest:
except TelegramBadRequest as error:
await callback.message.delete()
await callback.message.answer(
caption,
reply_markup=keyboard,
parse_mode=parse_mode,
)
await _answer_text(callback, caption, keyboard, resolved_parse_mode, error)
return
# Если текст слишком длинный для caption — отправим как текст
@@ -53,13 +93,9 @@ async def edit_or_answer_photo(
try:
if callback.message.photo:
await callback.message.delete()
await callback.message.answer(
caption,
reply_markup=keyboard,
parse_mode=parse_mode,
)
except TelegramBadRequest:
pass
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
except TelegramBadRequest as error:
await _answer_text(callback, caption, keyboard, resolved_parse_mode, error)
return
media = _resolve_media(callback.message)
@@ -68,7 +104,14 @@ async def edit_or_answer_photo(
InputMediaPhoto(media=media, caption=caption, parse_mode=(parse_mode or "HTML")),
reply_markup=keyboard,
)
except TelegramBadRequest:
except TelegramBadRequest as error:
if is_privacy_restricted_error(error):
try:
await callback.message.delete()
except Exception:
pass
await _answer_text(callback, caption, keyboard, resolved_parse_mode, error)
return
# Фоллбек: если не удалось обновить фото — отправим текст, чтобы не упасть на лимите caption
try:
await callback.message.delete()
@@ -80,12 +123,10 @@ async def edit_or_answer_photo(
photo=media if isinstance(media, FSInputFile) else FSInputFile(LOGO_PATH),
caption=caption,
reply_markup=keyboard,
parse_mode=(parse_mode or "HTML"),
parse_mode=resolved_parse_mode,
)
except TelegramBadRequest as photo_error:
await _answer_text(callback, caption, keyboard, resolved_parse_mode, photo_error)
except Exception:
# Последний фоллбек — обычный текст
await callback.message.answer(
caption,
reply_markup=keyboard,
parse_mode=(parse_mode or "HTML"),
)
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
+256
View File
@@ -0,0 +1,256 @@
from __future__ import annotations
import html
import math
from datetime import datetime
from typing import Optional, Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
from app.database.models import ServerSquad, SubscriptionTemporaryAccess, User
def _escape_format_braces(text: str) -> str:
"""Escape braces so str.format treats them as literals."""
return text.replace("{", "{{").replace("}", "}}")
def get_user_active_promo_discount_percent(user: Optional[User]) -> int:
if not user:
return 0
try:
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
return 0
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
if expires_at and expires_at <= datetime.utcnow():
return 0
return max(0, min(100, percent))
def _format_time_left(seconds_left: int, language: str) -> str:
total_minutes = max(1, math.ceil(seconds_left / 60))
days, remainder_minutes = divmod(total_minutes, 60 * 24)
hours, minutes = divmod(remainder_minutes, 60)
language_code = (language or "ru").split("-")[0].lower()
if language_code == "en":
day_label, hour_label, minute_label = "d", "h", "m"
else:
day_label, hour_label, minute_label = "д", "ч", "м"
parts: list[str] = []
if days:
parts.append(f"{days}{day_label}")
if hours or days:
parts.append(f"{hours}{hour_label}")
parts.append(f"{minutes}{minute_label}")
return " ".join(parts)
def _build_progress_bar(seconds_left: int, total_seconds: int) -> str:
if total_seconds <= 0:
total_seconds = seconds_left or 1
ratio = max(0.0, min(1.0, seconds_left / total_seconds))
bar_length = 10
filled_segments = int(round(ratio * bar_length))
filled_segments = max(0, min(bar_length, filled_segments))
if filled_segments == 0 and seconds_left > 0:
filled_segments = 1
return f"[{'' * filled_segments}{'' * (bar_length - filled_segments)}]"
async def build_promo_offer_timer_line(
db: AsyncSession,
user: User,
texts,
) -> Optional[str]:
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
if not expires_at:
return None
now = datetime.utcnow()
if expires_at <= now:
return None
seconds_left = int((expires_at - now).total_seconds())
if seconds_left <= 0:
return None
total_seconds: Optional[int] = None
source = getattr(user, "promo_offer_discount_source", None)
try:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception:
offer = None
if offer and getattr(offer, "claimed_at", None):
total_seconds = int((expires_at - offer.claimed_at).total_seconds())
if total_seconds <= 0:
total_seconds = None
if total_seconds is None and offer:
extra_data = getattr(offer, "extra_data", None)
if isinstance(extra_data, dict):
raw_duration = (
extra_data.get("active_discount_hours")
or extra_data.get("duration_hours")
)
else:
raw_duration = None
try:
if raw_duration:
total_seconds = int(float(raw_duration) * 3600)
except (TypeError, ValueError):
total_seconds = None
if total_seconds is None or total_seconds <= 0:
total_seconds = seconds_left
bar = _build_progress_bar(seconds_left, total_seconds)
time_left_text = _format_time_left(seconds_left, getattr(texts, "language", "ru"))
template = texts.t(
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER",
"⏳ Discount active for {time_left}\n<code>{bar}</code>",
)
return template.format(bar=bar, time_left=time_left_text)
async def build_promo_offer_hint(
db: AsyncSession,
user: User,
texts,
percent: Optional[int] = None,
) -> Optional[str]:
if percent is None:
percent = get_user_active_promo_discount_percent(user)
if percent <= 0:
return None
base_hint = texts.t(
"SUBSCRIPTION_PROMO_DISCOUNT_HINT",
"⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
).format(percent=percent)
timer_line = await build_promo_offer_timer_line(db, user, texts)
if timer_line:
return f"{base_hint}\n{timer_line}"
return base_hint
async def build_test_access_hint(
db: AsyncSession,
user: User,
texts,
) -> Optional[str]:
subscription = getattr(user, "subscription", None)
if not subscription:
return None
subscription_id = getattr(subscription, "id", None)
if not subscription_id:
return None
now = datetime.utcnow()
result = await db.execute(
select(SubscriptionTemporaryAccess)
.options(selectinload(SubscriptionTemporaryAccess.offer))
.where(
SubscriptionTemporaryAccess.subscription_id == subscription_id,
SubscriptionTemporaryAccess.is_active == True, # noqa: E712
SubscriptionTemporaryAccess.expires_at > now,
)
.order_by(SubscriptionTemporaryAccess.expires_at.desc())
)
entries: Sequence[SubscriptionTemporaryAccess] = result.scalars().all()
active_entries = [
entry for entry in entries if entry.expires_at and entry.expires_at > now
]
if not active_entries:
return None
latest_expiry = max(entry.expires_at for entry in active_entries)
seconds_left = int((latest_expiry - now).total_seconds())
if seconds_left <= 0:
return None
total_seconds: Optional[int] = None
for entry in active_entries:
offer = entry.offer
claimed_at = getattr(offer, "claimed_at", None) if offer else None
if claimed_at:
total = int((entry.expires_at - claimed_at).total_seconds())
if total > 0 and (total_seconds is None or total > total_seconds):
total_seconds = total
if total_seconds is None or total_seconds <= 0:
total_seconds = seconds_left
bar = _build_progress_bar(seconds_left, total_seconds)
time_left_text = _format_time_left(seconds_left, getattr(texts, "language", "ru"))
unique_squad_uuids: list[str] = []
seen_squads: set[str] = set()
for entry in active_entries:
squad_uuid = getattr(entry, "squad_uuid", None)
if squad_uuid and squad_uuid not in seen_squads:
seen_squads.add(squad_uuid)
unique_squad_uuids.append(squad_uuid)
squad_display_names: list[str] = []
if unique_squad_uuids:
squads_result = await db.execute(
select(ServerSquad.squad_uuid, ServerSquad.display_name).where(
ServerSquad.squad_uuid.in_(unique_squad_uuids)
)
)
names_map = {
squad_uuid: html.escape(display_name)
for squad_uuid, display_name in squads_result.all()
if display_name
}
for squad_uuid in unique_squad_uuids:
if squad_uuid in names_map:
squad_display_names.append(names_map[squad_uuid])
else:
squad_display_names.append(html.escape(squad_uuid))
if squad_display_names:
servers_display = ", ".join(squad_display_names)
elif unique_squad_uuids:
servers_display = ", ".join(
html.escape(squad_uuid) for squad_uuid in unique_squad_uuids
)
else:
servers_display = str(len(active_entries))
header_template = texts.t(
"MAIN_MENU_TEST_ACCESS_HEADER",
"🧪 Test servers active: {servers}",
)
timer_template = texts.t(
"MAIN_MENU_TEST_ACCESS_TIMER",
"⏳ Access active for {time_left}\n<code>{bar}</code>",
)
header = header_template.format(
servers=_escape_format_braces(servers_display)
)
timer_line = timer_template.format(time_left=time_left_text, bar=bar)
return f"{header}\n{timer_line}"
+12
View File
@@ -15,6 +15,8 @@ from .routes import (
promocodes,
miniapp,
promo_groups,
promo_offers,
pages,
remnawave,
stats,
subscriptions,
@@ -58,6 +60,10 @@ OPENAPI_TAGS = [
"name": "promo-groups",
"description": "Создание и управление промо-группами и их участниками.",
},
{
"name": "promo-offers",
"description": "Управление промо-предложениями, шаблонами и журналом событий.",
},
{
"name": "auth",
"description": "Управление токенами доступа к административному API.",
@@ -73,6 +79,10 @@ OPENAPI_TAGS = [
"name": "miniapp",
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
},
{
"name": "pages",
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
},
]
@@ -109,6 +119,8 @@ def create_web_api_app() -> FastAPI:
app.include_router(tickets.router, prefix="/tickets", tags=["support"])
app.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"])
app.include_router(promo_offers.router, prefix="/promo-offers", tags=["promo-offers"])
app.include_router(pages.router, prefix="/pages", tags=["pages"])
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])
app.include_router(backups.router, prefix="/backups", tags=["backups"])
+4
View File
@@ -2,6 +2,8 @@ from . import (
config,
health,
miniapp,
promo_offers,
pages,
promo_groups,
remnawave,
stats,
@@ -16,6 +18,8 @@ __all__ = [
"config",
"health",
"miniapp",
"promo_offers",
"pages",
"promo_groups",
"remnawave",
"stats",
+492
View File
@@ -0,0 +1,492 @@
from __future__ import annotations
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.faq import get_faq_page_by_id
from app.database.crud.rules import (
clear_all_rules,
create_or_update_rules,
get_all_rules_versions,
get_rules_by_language,
restore_rules_version,
)
from app.database.models import ServiceRule
from app.services.faq_service import FaqService
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService
from ..dependencies import get_db_session, require_api_token
from ..schemas.pages import (
FaqPageCreateRequest,
FaqPageListResponse,
FaqPageResponse,
FaqPageUpdateRequest,
FaqReorderRequest,
FaqStatusResponse,
FaqStatusUpdateRequest,
RichTextPageResponse,
RichTextPageUpdateRequest,
ServiceRulesHistoryResponse,
ServiceRulesResponse,
ServiceRulesUpdateRequest,
)
router = APIRouter()
def _serialize_rich_page(
*,
requested_language: str,
content: str,
language: str,
is_enabled: Optional[bool],
created_at,
updated_at,
splitter,
) -> RichTextPageResponse:
pages = splitter(content or "")
return RichTextPageResponse(
requested_language=requested_language,
language=language,
is_enabled=is_enabled,
content=content or "",
content_pages=pages,
created_at=created_at,
updated_at=updated_at,
)
def _serialize_faq_page(page) -> FaqPageResponse:
return FaqPageResponse(
id=page.id,
language=page.language,
title=page.title,
content=page.content,
content_pages=FaqService.split_content_into_pages(page.content),
display_order=page.display_order,
is_active=page.is_active,
created_at=page.created_at,
updated_at=page.updated_at,
)
def _serialize_rules(rule: ServiceRule) -> ServiceRulesResponse:
return ServiceRulesResponse(
id=rule.id,
title=rule.title,
content=rule.content,
language=rule.language,
is_active=rule.is_active,
created_at=rule.created_at,
updated_at=rule.updated_at,
)
@router.get("/public-offer", response_model=RichTextPageResponse)
async def get_public_offer(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
fallback: bool = Query(True, description="Использовать запасной язык, если контента нет"),
include_disabled: bool = Query(
True,
description="Возвращать контент даже если страница выключена",
),
) -> RichTextPageResponse:
requested_lang = PublicOfferService.normalize_language(language)
offer = await PublicOfferService.get_offer(db, requested_lang, fallback=fallback)
if not offer:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Public offer not found")
if not include_disabled and not offer.is_enabled:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Public offer disabled")
return _serialize_rich_page(
requested_language=requested_lang,
language=offer.language,
is_enabled=offer.is_enabled,
content=offer.content or "",
created_at=offer.created_at,
updated_at=offer.updated_at,
splitter=PublicOfferService.split_content_into_pages,
)
@router.put("/public-offer", response_model=RichTextPageResponse)
async def update_public_offer(
payload: RichTextPageUpdateRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> RichTextPageResponse:
lang = PublicOfferService.normalize_language(payload.language)
offer = await PublicOfferService.save_offer(db, lang, payload.content)
if payload.is_enabled is not None:
offer = await PublicOfferService.set_enabled(db, lang, payload.is_enabled)
refreshed = await PublicOfferService.get_offer(db, lang, fallback=False)
offer = refreshed or offer
return _serialize_rich_page(
requested_language=lang,
language=offer.language,
is_enabled=offer.is_enabled,
content=offer.content or "",
created_at=offer.created_at,
updated_at=offer.updated_at,
splitter=PublicOfferService.split_content_into_pages,
)
@router.get("/privacy-policy", response_model=RichTextPageResponse)
async def get_privacy_policy(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
fallback: bool = Query(True),
include_disabled: bool = Query(True),
) -> RichTextPageResponse:
requested_lang = PrivacyPolicyService.normalize_language(language)
policy = await PrivacyPolicyService.get_policy(db, requested_lang, fallback=fallback)
if not policy:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Privacy policy not found")
if not include_disabled and not policy.is_enabled:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Privacy policy disabled")
return _serialize_rich_page(
requested_language=requested_lang,
language=policy.language,
is_enabled=policy.is_enabled,
content=policy.content or "",
created_at=policy.created_at,
updated_at=policy.updated_at,
splitter=PrivacyPolicyService.split_content_into_pages,
)
@router.put("/privacy-policy", response_model=RichTextPageResponse)
async def update_privacy_policy(
payload: RichTextPageUpdateRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> RichTextPageResponse:
lang = PrivacyPolicyService.normalize_language(payload.language)
policy = await PrivacyPolicyService.save_policy(db, lang, payload.content)
if payload.is_enabled is not None:
policy = await PrivacyPolicyService.set_enabled(db, lang, payload.is_enabled)
refreshed = await PrivacyPolicyService.get_policy(db, lang, fallback=False)
policy = refreshed or policy
return _serialize_rich_page(
requested_language=lang,
language=policy.language,
is_enabled=policy.is_enabled,
content=policy.content or "",
created_at=policy.created_at,
updated_at=policy.updated_at,
splitter=PrivacyPolicyService.split_content_into_pages,
)
@router.get("/faq", response_model=FaqPageListResponse)
async def list_faq_pages(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
include_inactive: bool = Query(True),
fallback: bool = Query(True),
) -> FaqPageListResponse:
requested_lang = FaqService.normalize_language(language)
pages = await FaqService.get_pages(
db,
requested_lang,
include_inactive=include_inactive,
fallback=fallback,
)
resolved_language = requested_lang
if pages:
resolved_language = pages[0].language
setting = await FaqService.get_setting(db, requested_lang, fallback=fallback)
is_enabled = bool(setting.is_enabled) if setting else False
if setting:
resolved_language = setting.language
serialized = [_serialize_faq_page(page) for page in pages]
return FaqPageListResponse(
requested_language=requested_lang,
language=resolved_language,
is_enabled=is_enabled,
total=len(serialized),
items=serialized,
)
@router.post("/faq", response_model=FaqPageResponse, status_code=status.HTTP_201_CREATED)
async def create_faq_page(
payload: FaqPageCreateRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> FaqPageResponse:
lang = FaqService.normalize_language(payload.language)
is_active = True if payload.is_active is None else payload.is_active
page = await FaqService.create_page(
db,
language=lang,
title=payload.title,
content=payload.content,
display_order=payload.display_order,
is_active=is_active,
)
return _serialize_faq_page(page)
@router.get("/faq/{page_id}", response_model=FaqPageResponse)
async def get_faq_page(
page_id: int,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
include_inactive: bool = Query(True),
) -> FaqPageResponse:
requested_lang = FaqService.normalize_language(language)
page = await FaqService.get_page(
db,
page_id,
requested_lang,
include_inactive=include_inactive,
fallback=True,
)
if not page:
raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found")
return _serialize_faq_page(page)
@router.put("/faq/{page_id}", response_model=FaqPageResponse)
async def update_faq_page(
page_id: int,
payload: FaqPageUpdateRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> FaqPageResponse:
page = await get_faq_page_by_id(db, page_id)
if not page:
raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found")
updated = await FaqService.update_page(
db,
page,
title=payload.title,
content=payload.content,
display_order=payload.display_order,
is_active=payload.is_active,
)
return _serialize_faq_page(updated)
@router.delete("/faq/{page_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_faq_page(
page_id: int,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Response:
page = await get_faq_page_by_id(db, page_id)
if not page:
raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found")
await FaqService.delete_page(db, page_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/faq/reorder", response_model=FaqPageListResponse)
async def reorder_faq_pages(
payload: FaqReorderRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> FaqPageListResponse:
lang = FaqService.normalize_language(payload.language)
ordered_payload = sorted(payload.items, key=lambda item: item.display_order)
existing_pages = await FaqService.get_pages(
db,
lang,
include_inactive=True,
fallback=False,
)
pages_by_id = {page.id: page for page in existing_pages}
pages: List[Any] = []
for item in ordered_payload:
page = pages_by_id.get(item.id)
if not page:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
f"FAQ page {item.id} not found for language {lang}",
)
pages.append(page)
ordered_ids = {item.id for item in ordered_payload}
remaining = [page for page in existing_pages if page.id not in ordered_ids]
pages.extend(sorted(remaining, key=lambda page: (page.display_order, page.id)))
await FaqService.reorder_pages(db, lang, pages)
updated_pages = await FaqService.get_pages(
db,
lang,
include_inactive=True,
fallback=False,
)
setting = await FaqService.get_setting(db, lang, fallback=False)
serialized = [_serialize_faq_page(page) for page in updated_pages]
return FaqPageListResponse(
requested_language=lang,
language=lang,
is_enabled=bool(setting.is_enabled) if setting else False,
total=len(serialized),
items=serialized,
)
@router.get("/faq/status", response_model=FaqStatusResponse)
async def get_faq_status(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
fallback: bool = Query(True),
) -> FaqStatusResponse:
requested_lang = FaqService.normalize_language(language)
setting = await FaqService.get_setting(db, requested_lang, fallback=fallback)
if not setting:
return FaqStatusResponse(
requested_language=requested_lang,
language=requested_lang,
is_enabled=False,
)
return FaqStatusResponse(
requested_language=requested_lang,
language=setting.language,
is_enabled=bool(setting.is_enabled),
)
@router.put("/faq/status", response_model=FaqStatusResponse)
async def update_faq_status(
payload: FaqStatusUpdateRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> FaqStatusResponse:
lang = FaqService.normalize_language(payload.language)
setting = await FaqService.set_enabled(db, lang, payload.is_enabled)
return FaqStatusResponse(
requested_language=lang,
language=setting.language,
is_enabled=bool(setting.is_enabled),
)
@router.get("/service-rules", response_model=ServiceRulesResponse)
async def get_service_rules(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
fallback: bool = Query(True),
) -> ServiceRulesResponse:
requested_lang = language.split("-")[0].lower()
rules = await get_rules_by_language(db, requested_lang)
if not rules and fallback:
default_lang = (settings.DEFAULT_LANGUAGE or "ru").split("-")[0].lower()
if default_lang != requested_lang:
rules = await get_rules_by_language(db, default_lang)
if not rules:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Service rules not found")
return _serialize_rules(rules)
@router.put("/service-rules", response_model=ServiceRulesResponse)
async def update_service_rules(
payload: ServiceRulesUpdateRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ServiceRulesResponse:
lang = payload.language.split("-")[0].lower()
title = payload.title or "Правила сервиса"
rules = await create_or_update_rules(
db,
content=payload.content,
language=lang,
title=title,
)
return _serialize_rules(rules)
@router.delete("/service-rules", status_code=status.HTTP_204_NO_CONTENT)
async def clear_service_rules(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
) -> Response:
lang = language.split("-")[0].lower()
await clear_all_rules(db, lang)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/service-rules/history", response_model=ServiceRulesHistoryResponse)
async def get_service_rules_history(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
limit: int = Query(10, ge=1, le=100),
) -> ServiceRulesHistoryResponse:
lang = language.split("-")[0].lower()
history = await get_all_rules_versions(db, lang, limit=limit)
items = [_serialize_rules(item) for item in history]
return ServiceRulesHistoryResponse(
language=lang,
total=len(items),
items=items,
)
@router.post(
"/service-rules/history/{rule_id}/restore",
response_model=ServiceRulesResponse,
status_code=status.HTTP_201_CREATED,
)
async def restore_service_rules_version(
rule_id: int,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
) -> ServiceRulesResponse:
lang = language.split("-")[0].lower()
restored = await restore_rules_version(db, rule_id, language=lang)
if not restored:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Rules version not found")
return _serialize_rules(restored)
+323
View File
@@ -0,0 +1,323 @@
from __future__ import annotations
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.discount_offer import (
count_discount_offers,
get_offer_by_id,
list_discount_offers,
upsert_discount_offer,
)
from app.database.crud.promo_offer_log import list_promo_offer_logs
from app.database.crud.promo_offer_template import (
get_promo_offer_template_by_id,
list_promo_offer_templates,
update_promo_offer_template,
)
from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, Subscription, User
from ..dependencies import get_db_session, require_api_token
from ..schemas.promo_offers import (
PromoOfferCreateRequest,
PromoOfferListResponse,
PromoOfferLogListResponse,
PromoOfferLogOfferInfo,
PromoOfferLogResponse,
PromoOfferResponse,
PromoOfferSubscriptionInfo,
PromoOfferTemplateListResponse,
PromoOfferTemplateResponse,
PromoOfferTemplateUpdateRequest,
PromoOfferUserInfo,
)
router = APIRouter()
def _serialize_user(user: Optional[User]) -> Optional[PromoOfferUserInfo]:
if not user:
return None
return PromoOfferUserInfo(
id=user.id,
telegram_id=user.telegram_id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
full_name=getattr(user, "full_name", None),
)
def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[PromoOfferSubscriptionInfo]:
if not subscription:
return None
return PromoOfferSubscriptionInfo(
id=subscription.id,
status=subscription.status,
is_trial=subscription.is_trial,
start_date=subscription.start_date,
end_date=subscription.end_date,
autopay_enabled=subscription.autopay_enabled,
)
def _serialize_offer(offer: DiscountOffer) -> PromoOfferResponse:
return PromoOfferResponse(
id=offer.id,
user_id=offer.user_id,
subscription_id=offer.subscription_id,
notification_type=offer.notification_type,
discount_percent=offer.discount_percent,
bonus_amount_kopeks=offer.bonus_amount_kopeks,
expires_at=offer.expires_at,
claimed_at=offer.claimed_at,
is_active=offer.is_active,
effect_type=offer.effect_type,
extra_data=offer.extra_data or {},
created_at=offer.created_at,
updated_at=offer.updated_at,
user=_serialize_user(getattr(offer, "user", None)),
subscription=_serialize_subscription(getattr(offer, "subscription", None)),
)
def _serialize_template(template: PromoOfferTemplate) -> PromoOfferTemplateResponse:
return PromoOfferTemplateResponse(
id=template.id,
name=template.name,
offer_type=template.offer_type,
message_text=template.message_text,
button_text=template.button_text,
valid_hours=template.valid_hours,
discount_percent=template.discount_percent,
bonus_amount_kopeks=template.bonus_amount_kopeks,
active_discount_hours=template.active_discount_hours,
test_duration_hours=template.test_duration_hours,
test_squad_uuids=[str(uuid) for uuid in (template.test_squad_uuids or [])],
is_active=template.is_active,
created_by=template.created_by,
created_at=template.created_at,
updated_at=template.updated_at,
)
def _build_log_response(entry: PromoOfferLog) -> PromoOfferLogResponse:
user_info = _serialize_user(getattr(entry, "user", None))
offer = getattr(entry, "offer", None)
offer_info: Optional[PromoOfferLogOfferInfo] = None
if offer:
offer_info = PromoOfferLogOfferInfo(
id=offer.id,
notification_type=offer.notification_type,
discount_percent=offer.discount_percent,
bonus_amount_kopeks=offer.bonus_amount_kopeks,
effect_type=offer.effect_type,
expires_at=offer.expires_at,
claimed_at=offer.claimed_at,
is_active=offer.is_active,
)
return PromoOfferLogResponse(
id=entry.id,
user_id=entry.user_id,
offer_id=entry.offer_id,
action=entry.action,
source=entry.source,
percent=entry.percent,
effect_type=entry.effect_type,
details=entry.details or {},
created_at=entry.created_at,
user=user_info,
offer=offer_info,
)
@router.get("", response_model=PromoOfferListResponse)
async def list_promo_offers(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user_id: Optional[int] = Query(None, ge=1),
notification_type: Optional[str] = Query(None, min_length=1),
is_active: Optional[bool] = Query(None),
) -> PromoOfferListResponse:
offers = await list_discount_offers(
db,
offset=offset,
limit=limit,
user_id=user_id,
notification_type=notification_type,
is_active=is_active,
)
total = await count_discount_offers(
db,
user_id=user_id,
notification_type=notification_type,
is_active=is_active,
)
return PromoOfferListResponse(
items=[_serialize_offer(offer) for offer in offers],
total=total,
limit=limit,
offset=offset,
)
@router.post("", response_model=PromoOfferResponse, status_code=status.HTTP_201_CREATED)
async def create_promo_offer(
payload: PromoOfferCreateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PromoOfferResponse:
if payload.discount_percent < 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative")
if payload.bonus_amount_kopeks < 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative")
if payload.valid_hours <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive")
if not payload.notification_type.strip():
raise HTTPException(status.HTTP_400_BAD_REQUEST, "notification_type must not be empty")
if not payload.effect_type.strip():
raise HTTPException(status.HTTP_400_BAD_REQUEST, "effect_type must not be empty")
user = await db.get(User, payload.user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
if payload.subscription_id is not None:
subscription = await db.get(Subscription, payload.subscription_id)
if not subscription:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found")
if subscription.user_id != payload.user_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Subscription does not belong to the user")
offer = await upsert_discount_offer(
db,
user_id=payload.user_id,
subscription_id=payload.subscription_id,
notification_type=payload.notification_type.strip(),
discount_percent=payload.discount_percent,
bonus_amount_kopeks=payload.bonus_amount_kopeks,
valid_hours=payload.valid_hours,
effect_type=payload.effect_type,
extra_data=payload.extra_data,
)
await db.refresh(offer, attribute_names=["user", "subscription"])
return _serialize_offer(offer)
@router.get("/logs", response_model=PromoOfferLogListResponse)
async def get_promo_offer_logs(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user_id: Optional[int] = Query(None, ge=1),
offer_id: Optional[int] = Query(None, ge=1),
action: Optional[str] = Query(None, min_length=1),
source: Optional[str] = Query(None, min_length=1),
) -> PromoOfferLogListResponse:
logs, total = await list_promo_offer_logs(
db,
offset=offset,
limit=limit,
user_id=user_id,
offer_id=offer_id,
action=action,
source=source,
)
return PromoOfferLogListResponse(
items=[_build_log_response(entry) for entry in logs],
total=int(total),
limit=limit,
offset=offset,
)
@router.get("/templates", response_model=PromoOfferTemplateListResponse)
async def list_promo_offer_templates_endpoint(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PromoOfferTemplateListResponse:
templates = await list_promo_offer_templates(db)
return PromoOfferTemplateListResponse(items=[_serialize_template(template) for template in templates])
@router.get("/templates/{template_id}", response_model=PromoOfferTemplateResponse)
async def get_promo_offer_template_endpoint(
template_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PromoOfferTemplateResponse:
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer template not found")
return _serialize_template(template)
@router.patch("/templates/{template_id}", response_model=PromoOfferTemplateResponse)
async def update_promo_offer_template_endpoint(
template_id: int,
payload: PromoOfferTemplateUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PromoOfferTemplateResponse:
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer template not found")
if payload.valid_hours is not None and payload.valid_hours <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive")
if payload.active_discount_hours is not None and payload.active_discount_hours <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "active_discount_hours must be positive")
if payload.test_duration_hours is not None and payload.test_duration_hours <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "test_duration_hours must be positive")
if payload.discount_percent is not None and payload.discount_percent < 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative")
if payload.bonus_amount_kopeks is not None and payload.bonus_amount_kopeks < 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative")
if payload.test_squad_uuids is not None:
normalized_squads = [str(uuid).strip() for uuid in payload.test_squad_uuids if str(uuid).strip()]
else:
normalized_squads = None
updated_template = await update_promo_offer_template(
db,
template,
name=payload.name,
message_text=payload.message_text,
button_text=payload.button_text,
valid_hours=payload.valid_hours,
discount_percent=payload.discount_percent,
bonus_amount_kopeks=payload.bonus_amount_kopeks,
active_discount_hours=payload.active_discount_hours,
test_duration_hours=payload.test_duration_hours,
test_squad_uuids=normalized_squads,
is_active=payload.is_active,
)
return _serialize_template(updated_template)
@router.get("/{offer_id}", response_model=PromoOfferResponse)
async def get_promo_offer_endpoint(
offer_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PromoOfferResponse:
offer = await get_offer_by_id(db, offer_id)
if not offer:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer not found")
return _serialize_offer(offer)
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class RichTextPageResponse(BaseModel):
"""Generic representation for rich text informational pages."""
requested_language: str = Field(..., description="Язык, запрошенный клиентом")
language: str = Field(..., description="Фактический язык найденной записи")
is_enabled: Optional[bool] = Field(
default=None,
description="Текущий статус публикации страницы (если применимо)",
)
content: str = Field(..., description="Полное содержимое страницы")
content_pages: List[str] = Field(
default_factory=list,
description="Содержимое, разбитое на страницы фиксированной длины",
)
created_at: Optional[datetime] = Field(
default=None,
description="Дата создания записи",
)
updated_at: Optional[datetime] = Field(
default=None,
description="Дата последнего обновления записи",
)
class RichTextPageUpdateRequest(BaseModel):
language: str = Field(
default="ru",
min_length=2,
max_length=10,
description="Язык, для которого выполняется обновление",
)
content: str = Field(..., description="Новое содержимое страницы")
is_enabled: Optional[bool] = Field(
default=None,
description="Если указано — обновить статус публикации",
)
class FaqPageResponse(BaseModel):
id: int
language: str
title: str
content: str
content_pages: List[str] = Field(default_factory=list)
display_order: int
is_active: bool
created_at: datetime
updated_at: datetime
class FaqPageListResponse(BaseModel):
requested_language: str
language: str
is_enabled: bool
total: int
items: List[FaqPageResponse]
class FaqPageCreateRequest(BaseModel):
language: str = Field(
default="ru",
min_length=2,
max_length=10,
description="Язык создаваемой страницы",
)
title: str = Field(..., min_length=1, max_length=255)
content: str = Field(...)
display_order: Optional[int] = Field(
default=None,
ge=0,
description="Порядок отображения (если не указан — будет рассчитан автоматически)",
)
is_active: Optional[bool] = Field(
default=True,
description="Начальный статус активности страницы",
)
class FaqPageUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
content: Optional[str] = None
display_order: Optional[int] = Field(default=None, ge=0)
is_active: Optional[bool] = None
class FaqReorderItem(BaseModel):
id: int = Field(..., ge=1)
display_order: int = Field(..., ge=0)
class FaqReorderRequest(BaseModel):
language: str = Field(
default="ru",
min_length=2,
max_length=10,
description="Язык, для которого применяется сортировка",
)
items: List[FaqReorderItem]
class FaqStatusResponse(BaseModel):
requested_language: str
language: str
is_enabled: bool
class FaqStatusUpdateRequest(BaseModel):
language: str = Field(
default="ru",
min_length=2,
max_length=10,
)
is_enabled: bool
class ServiceRulesResponse(BaseModel):
id: int
title: str
content: str
language: str
is_active: bool
created_at: datetime
updated_at: datetime
class ServiceRulesUpdateRequest(BaseModel):
language: str = Field(
default="ru",
min_length=2,
max_length=10,
description="Язык, для которого обновляются правила",
)
title: Optional[str] = Field(
default="Правила сервиса",
min_length=1,
max_length=255,
)
content: str = Field(...)
class ServiceRulesHistoryResponse(BaseModel):
language: str
total: int
items: List[ServiceRulesResponse]
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class PromoOfferUserInfo(BaseModel):
id: int
telegram_id: int
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
full_name: Optional[str] = None
class PromoOfferSubscriptionInfo(BaseModel):
id: int
status: str
is_trial: bool
start_date: datetime
end_date: datetime
autopay_enabled: bool
class PromoOfferResponse(BaseModel):
id: int
user_id: int
subscription_id: Optional[int] = None
notification_type: str
discount_percent: int
bonus_amount_kopeks: int
expires_at: datetime
claimed_at: Optional[datetime] = None
is_active: bool
effect_type: str
extra_data: Dict[str, Any] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
user: Optional[PromoOfferUserInfo] = None
subscription: Optional[PromoOfferSubscriptionInfo] = None
class PromoOfferListResponse(BaseModel):
items: List[PromoOfferResponse]
total: int
limit: int
offset: int
class PromoOfferCreateRequest(BaseModel):
user_id: int
notification_type: str = Field(..., min_length=1)
valid_hours: int = Field(..., ge=1, description="Срок действия предложения в часах")
discount_percent: int = Field(0, ge=0)
bonus_amount_kopeks: int = Field(0, ge=0)
subscription_id: Optional[int] = None
effect_type: str = Field("percent_discount", min_length=1)
extra_data: Dict[str, Any] = Field(default_factory=dict)
class PromoOfferTemplateResponse(BaseModel):
id: int
name: str
offer_type: str
message_text: str
button_text: str
valid_hours: int
discount_percent: int
bonus_amount_kopeks: int
active_discount_hours: Optional[int] = None
test_duration_hours: Optional[int] = None
test_squad_uuids: List[str]
is_active: bool
created_by: Optional[int] = None
created_at: datetime
updated_at: datetime
class PromoOfferTemplateListResponse(BaseModel):
items: List[PromoOfferTemplateResponse]
class PromoOfferTemplateUpdateRequest(BaseModel):
name: Optional[str] = None
message_text: Optional[str] = None
button_text: Optional[str] = None
valid_hours: Optional[int] = Field(None, ge=1)
discount_percent: Optional[int] = Field(None, ge=0)
bonus_amount_kopeks: Optional[int] = Field(None, ge=0)
active_discount_hours: Optional[int] = Field(None, ge=1)
test_duration_hours: Optional[int] = Field(None, ge=1)
test_squad_uuids: Optional[List[str]] = None
is_active: Optional[bool] = None
class PromoOfferLogOfferInfo(BaseModel):
id: int
notification_type: Optional[str] = None
discount_percent: Optional[int] = None
bonus_amount_kopeks: Optional[int] = None
effect_type: Optional[str] = None
expires_at: Optional[datetime] = None
claimed_at: Optional[datetime] = None
is_active: Optional[bool] = None
class PromoOfferLogResponse(BaseModel):
id: int
user_id: Optional[int] = None
offer_id: Optional[int] = None
action: str
source: Optional[str] = None
percent: Optional[int] = None
effect_type: Optional[str] = None
details: Dict[str, Any] = Field(default_factory=dict)
created_at: datetime
user: Optional[PromoOfferUserInfo] = None
offer: Optional[PromoOfferLogOfferInfo] = None
class PromoOfferLogListResponse(BaseModel):
items: List[PromoOfferLogResponse]
total: int
limit: int
offset: int
+10
View File
@@ -127,8 +127,18 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
| `POST` | `/promo-groups` | Создать промо-группу.
| `PATCH` | `/promo-groups/{id}` | Обновить промо-группу.
| `DELETE` | `/promo-groups/{id}` | Удалить промо-группу.
| `GET` | `/promo-offers` | Список промо-предложений с фильтрами по пользователю, статусу и типу уведомления.
| `POST` | `/promo-offers` | Создать или обновить персональное промо-предложение пользователю.
| `GET` | `/promo-offers/{id}` | Детали конкретного промо-предложения.
| `GET` | `/promo-offers/templates` | Список шаблонов промо-предложений.
| `GET` | `/promo-offers/templates/{id}` | Получить данные шаблона промо-предложения.
| `PATCH` | `/promo-offers/templates/{id}` | Обновить текст, кнопки и параметры шаблона.
| `GET` | `/promo-offers/logs` | Журнал операций с промо-предложениями (активации, списания, выключения).
| `GET` | `/tokens` | Управление токенами доступа.
> Раздел **promo-offers** в Swagger объединяет работу с персональными предложениями: выдачу скидок/бонусов пользователям, настройку
> текстов шаблонов и просмотр журнала операций (активации, автосписания, отключения просроченных акций).
### RemnaWave интеграция
После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота:
+281 -5
View File
@@ -55,6 +55,8 @@
"LOADING": "⏳ Loading...",
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Test servers active: {servers}",
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Access active for {time_left}\n<code>{bar}</code>",
"MAIN_MENU_BUTTON": "🏠 Main menu",
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
"MENU_BALANCE": "💰 Balance",
@@ -328,6 +330,24 @@
"MENU_ADMIN": "⚙️ Admin panel",
"MENU_BUY_SUBSCRIPTION": "💎 Buy subscription",
"MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription",
"MENU_INFO": "️ Info",
"MENU_INFO_HEADER": "️ <b>Info</b>",
"MENU_INFO_PROMPT": "Choose a section:",
"MENU_FAQ": "❓ FAQ",
"FAQ_HEADER": "❓ <b>FAQ</b>",
"FAQ_PAGES_PROMPT": "Select a topic:",
"FAQ_NOT_AVAILABLE": "FAQ is temporarily unavailable.",
"FAQ_PAGE_UNTITLED": "Untitled",
"FAQ_PAGE_NOT_AVAILABLE": "This FAQ page is not available.",
"FAQ_PAGE_EMPTY": "Content for this page has not been added yet.",
"FAQ_PAGE_TITLE": "<b>{title}</b>",
"FAQ_PAGE_FOOTER": "Page {current} of {total}",
"FAQ_BACK_TO_LIST": "⬅️ Back to FAQ list",
"MENU_PRIVACY_POLICY": "🛡️ Privacy policy",
"PRIVACY_POLICY_HEADER": "🛡️ <b>Privacy policy</b>",
"PRIVACY_POLICY_NOT_AVAILABLE": "The privacy policy is temporarily unavailable.",
"PRIVACY_POLICY_EMPTY_ALERT": "The privacy policy text has not been provided yet.",
"PRIVACY_POLICY_PAGE_INFO": "Page {current} of {total}",
"MENU_PROMOCODE": "🎫 Promo code",
"MENU_REFERRALS": "🤝 Referral program",
"MENU_RULES": "📋 Service rules",
@@ -527,14 +547,26 @@
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
"TRIAL_INACTIVE_24H": "⏳ <b>A full day passed without activity</b>\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!",
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Your subscription expired</b>\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nActivate the offer to get an additional discount. It stacks with your promo group and is valid until {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\n\nIt's been {trigger_days} days without a subscription. Come back and activate the extra discount — it stacks with your promo group and is valid until {expires_at}.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It stacks with other discounts and will apply automatically to your next payment.",
"DISCOUNT_CLAIM_ALREADY": "️ This discount has already been activated.",
"DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
"DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.",
"DISCOUNT_CLAIM_ERROR": "❌ Failed to activate the discount. Please try again later.",
"PROMO_OFFER_CLOSE": "❌ Close",
"TEST_ACCESS_NO_SUBSCRIPTION": "❌ You need an active subscription to use this offer.",
"TEST_ACCESS_NO_SQUADS": "❌ Unable to determine servers for the test access. Please contact support.",
"TEST_ACCESS_REMNAWAVE_ERROR": "❌ Failed to connect the servers. Please try again later or contact support.",
"TEST_ACCESS_UNKNOWN_ERROR": "❌ Failed to activate the offer. Please try again later.",
"TEST_ACCESS_ALREADY_CONNECTED": "️ This server is already connected to your subscription.",
"TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Test servers are connected! Access is active until {expires_at}.",
"TEST_ACCESS_ACTIVATED_POPUP": "✅ Access granted!",
"DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus",
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Promo offer: -{amount} ({percent}% extra)",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n<code>{bar}</code>",
"NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.",
"NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.",
"NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):",
@@ -557,6 +589,131 @@
"ADMIN_SUBMENU_SELECT_SECTION": "Choose a section:",
"ADMIN_COMMUNICATIONS_WELCOME_TEXT": "👋 Welcome message",
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Menu messages",
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Promo offers",
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Promo offers</b>\n\nSelect a template to configure:",
"ADMIN_PROMO_OFFER_TEST_ACCESS": "Test servers",
"ADMIN_PROMO_OFFER_EXTEND": "Renewal discount",
"ADMIN_PROMO_OFFER_PURCHASE": "Purchase discount",
"ADMIN_PROMO_OFFER_TYPE": "Type: {label}",
"ADMIN_PROMO_OFFER_VALID": "Validity: {hours} h",
"ADMIN_PROMO_OFFER_DISCOUNT": "Extra discount: {percent}% (stacks with other discounts)",
"ADMIN_PROMO_OFFER_STACKABLE_NOTE": "The discount applies once and stacks with the promo group.",
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "After activation the discount lasts for {hours} h.",
"ADMIN_PROMO_OFFER_TEST_DURATION": "Access: {hours} h",
"ADMIN_PROMO_OFFER_TEST_SQUAD_NAME": "Server: {name}",
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Squads: {squads}",
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Squads: not specified",
"ADMIN_PROMO_OFFER_ALLOWED": "Available segments:",
"ADMIN_PROMO_OFFER_PREVIEW": "Preview:",
"ADMIN_PROMO_OFFER_PROMPT_MESSAGE": "Enter the new offer text:",
"ADMIN_PROMO_OFFER_PROMPT_BUTTON": "Enter the button label:",
"ADMIN_PROMO_OFFER_PROMPT_VALID": "Enter validity (hours):",
"ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Enter discount percentage:",
"ADMIN_PROMO_OFFER_PROMPT_ACTIVE_DURATION": "Enter how long the activated discount should remain valid (hours):",
"ADMIN_PROMO_OFFER_PROMPT_DURATION": "Enter test access duration (hours):",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE": "🌍 <b>Select a squad</b>",
"ADMIN_PROMO_OFFER_SELECTED_SQUAD": "Current squad: {name}",
"ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID": "Current squad: {uuid}",
"ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY": "Current squad: not selected",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT": "Pick a server to grant temporary access from the list below.",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE": "Page {page}/{total}",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR": "🗑 Clear",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK": "↩️ Back",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED": "✅ Squad updated",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED": "✅ Squad cleared",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND": "❌ Server not found",
"ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE": "❌ No servers available.",
"ADMIN_PROMO_OFFER_SENDING": "Starting broadcast...",
"ADMIN_PROMO_OFFER_NO_USERS": "No matching users found.",
"ADMIN_PROMO_OFFER_RESULT": "📬 Broadcast finished\nSent: {sent}\nFailed: {failed}",
"ADMIN_PROMO_OFFER_SKIPPED": "Skipped: {skipped} (already have access)",
"ADMIN_PROMO_OFFER_CTA_BUY": "Buy subscription",
"ADMIN_PROMO_OFFER_CTA_EXTEND": "Extend subscription",
"ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ Back to offer",
"ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ Back to promo offers",
"ADMIN_PROMO_OFFER_LOGS": "📜 Activity log",
"ADMIN_PROMO_OFFER_LOGS_TITLE": "📜 <b>Promo offer activity log</b>",
"ADMIN_PROMO_OFFER_LOGS_PAGINATION": "Page {page}/{total}",
"ADMIN_PROMO_OFFER_LOGS_EMPTY_BODY": "No activity yet.",
"ADMIN_PROMO_OFFER_LOGS_UNKNOWN_USER": "Unknown user",
"ADMIN_PROMO_OFFER_LOGS_USER": "👤 {user}",
"ADMIN_PROMO_OFFER_LOGS_PERCENT": "📉 Discount: {percent}%",
"ADMIN_PROMO_OFFER_LOGS_EFFECT_TEST": "🧪 Test access",
"ADMIN_PROMO_OFFER_LOGS_EFFECT_DISCOUNT": "💸 Discount",
"ADMIN_PROMO_OFFER_LOGS_SOURCE": "🏷 Source: {source}",
"ADMIN_PROMO_OFFER_LOGS_REASON_GENERIC": "️ Action: {reason}",
"ADMIN_PROMO_OFFER_LOGS_REASON_MANUAL": "💳 Applied during manual payment",
"ADMIN_PROMO_OFFER_LOGS_REASON_AUTOPAY": "🤖 Used in autopay renewal",
"ADMIN_PROMO_OFFER_LOGS_REASON_EXPIRED": "⏳ Offer expired",
"ADMIN_PROMO_OFFER_LOGS_REASON_TEST_EXPIRED": "⏳ Test access removed",
"ADMIN_PROMO_OFFER_LOGS_DESCRIPTION": "📝 {description}",
"ADMIN_PROMO_OFFER_LOGS_AMOUNT": "💰 Amount: {amount}",
"ADMIN_PROMO_OFFER_LOGS_SQUAD": "🌍 Squad: {squad}",
"ADMIN_PROMO_OFFER_LOGS_NEW_SQUADS": "🌍 New squads: {squads}",
"ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED": "Claimed",
"ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED": "Used",
"ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED": "Disabled",
"ADMIN_PROMO_OFFER_SEND_USER": "👤 Send to user",
"ADMIN_PROMO_OFFER_SEND_USER_TITLE": "👤 <b>Send to a user</b>",
"ADMIN_PROMO_OFFER_SEND_USER_HINT": "Select a user to deliver the promo offer.",
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH": "🔍 Search",
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_PROMPT": "Enter name, username or user ID to search:",
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_QUERY": "🔍 Search: <b>{query}</b>",
"ADMIN_PROMO_OFFER_SEND_USER_RESET": "❌ Clear search",
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_SEGMENTS": "↩️ Back to segment selection",
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY": "No matching users found. Adjust your query.",
"ADMIN_PROMO_OFFER_SEND_USER_PROFILE": "👤 <b>{name}</b>",
"ADMIN_PROMO_OFFER_SEND_USER_TELEGRAM": "🆔 <code>{telegram_id}</code>",
"ADMIN_PROMO_OFFER_SEND_USER_USERNAME": "🔗 @{username}",
"ADMIN_PROMO_OFFER_SEND_USER_STATUS": "Status: {status}",
"ADMIN_PROMO_OFFER_SEND_USER_BALANCE": "Balance: {amount}",
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION": "💳 <b>Subscription</b>",
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS": "Status: {status}",
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END": "Expires: {date}",
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END_UNKNOWN": "not specified",
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC": "Traffic: {used}/{limit} GB",
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS": "Connected squads: {count}",
"ADMIN_PROMO_OFFER_SEND_USER_NO_SUBSCRIPTION": "💳 No active subscription",
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT": "💸 Active discount: {percent}%",
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_UNTIL": " (until {date})",
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_LEFT": " (until {date}, {time} left)",
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_SOURCE": " — source: {source}",
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_NONE": "💸 No active discount",
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_HEADER": "📨 <b>Selected offer</b>",
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TYPE": "Type: {label}",
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_VALID": "Valid for: {hours} h",
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TEST_DURATION": "Test access: {hours} h",
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_DISCOUNT": "Discount: {percent}%",
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACTIVE_DURATION": "Active after claim for {hours} h",
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_OFFERS": "📨 Active offers:",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TEST": "Test access",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_PERCENT": "{percent}%",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_BONUS": "+{bonus}%",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY": "no expiry",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM": "• {description} (until {expires})",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM_HEADER": "• {description}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_EXPIRES": " Expires: {expires}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS": " Status: {status}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_ACCEPTED": "✅ Claimed",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_PENDING": "⏳ Not claimed",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT": " Time left: {time}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT_EXPIRED": "expired",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ACTIVE_DURATION": " After claim: {duration}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TOTAL_DURATION": " Total validity: {duration}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_HEADER": "📊 Offer stats",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_TOTAL": "Total sent: {count}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACCEPTED": "Claimed: {count}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_PENDING": "Not claimed: {count}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACTIVE": "Active now: {count}",
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS": "🧪 Active test accesses:",
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS_ITEM": "• {squad} (until {expires})",
"ADMIN_PROMO_OFFER_SEND_USER_NO_ACTIVE_OFFERS": "📨 No active offers",
"ADMIN_PROMO_OFFER_SEND_USER_SEND_BUTTON": "📬 Send offer",
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST": "⬅️ Back to users",
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_PROFILE": "👤 Back to profile",
"ADMIN_PROMO_OFFER_SEND_USER_SUMMARY_TITLE": "📬 Sent to {name}",
"ADMIN_PROMO_OFFER_SEND_USER_SKIPPED": "Skipped: {skipped} (already has access)",
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY_RESULT": "Delivery not performed",
"ADMIN_SUPPORT_TICKETS": "🎫 Support tickets",
"ADMIN_SUPPORT_AUDIT": "🧾 Moderator audit",
"ADMIN_SUPPORT_SETTINGS": "🛟 Support settings",
@@ -611,6 +768,37 @@
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Unblock",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **System settings**\n\n",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Manage Remnawave, monitoring and other settings:",
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Privacy policy",
"ADMIN_PRIVACY_POLICY_HEADER": "🛡️ <b>Privacy policy</b>",
"ADMIN_PRIVACY_POLICY_DESCRIPTION": "The privacy policy is shown in the Info section.",
"ADMIN_PRIVACY_POLICY_LANGUAGE": "Language: <code>{lang}</code>",
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Policy display is disabled or empty.",
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Policy is active and visible to users.",
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Policy is enabled but the text is empty — users won't see it.",
"ADMIN_PRIVACY_POLICY_UPDATED_AT": "Last updated: {timestamp}",
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "<b>Text preview:</b>",
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "No text provided yet.",
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Choose an action:",
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Edit text",
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 View current text",
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Enable display",
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Disable display",
"ADMIN_PRIVACY_POLICY_HTML_HELP": "️ HTML help",
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Current text (preview):",
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Send the new privacy policy text. HTML markup is allowed.",
"ADMIN_PRIVACY_POLICY_EDIT_HINT": "Use /html_help for supported tags.",
"ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Privacy policy editing",
"ADMIN_PRIVACY_POLICY_CANCEL": "❌ Cancel",
"ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ The policy text is too long. Maximum 4000 characters.",
"ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ HTML error: {error}",
"ADMIN_PRIVACY_POLICY_SAVED": "✅ Privacy policy updated.",
"ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ Back to policy settings",
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "The privacy policy text is not set yet.",
"ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 <b>Current policy text</b>",
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ The text is shortened for display. Users will see the full version in the menu.",
"ADMIN_PRIVACY_POLICY_ENABLED": "✅ Policy enabled",
"ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Policy disabled",
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Back to editing",
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **System tools**\n\n",
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Reports, updates, logs, backups and system operations:",
"ADMIN_SETTINGS_BOT_CONFIG": "🧩 Bot configuration",
@@ -874,5 +1062,93 @@
"ADMIN_PRICING_SETTING_PROMPT": "Send a new value or type \"Cancel\". Use none to clear.",
"ADMIN_PRICING_SETTING_SUCCESS": "Parameter {label} updated: {value}",
"ADMIN_PRICING_SETTING_TOGGLE_STATEFUL": "{icon} {label}",
"ADMIN_PRICING_SETTING_WARNING": "Important"
"ADMIN_PRICING_SETTING_WARNING": "Important",
"MENU_PUBLIC_OFFER": "📄 Offer",
"PUBLIC_OFFER_NOT_AVAILABLE": "Public offer is temporarily unavailable.",
"PUBLIC_OFFER_EMPTY_ALERT": "Public offer content is not provided yet.",
"PUBLIC_OFFER_HEADER": "📄 <b>Public Offer</b>",
"PUBLIC_OFFER_PAGE_INFO": "Page {current} of {total}",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer",
"ADMIN_SETTINGS_FAQ": "❓ FAQ",
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Public offer</b>",
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "The public offer is shown in the “Info” section.",
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Language: <code>{lang}</code>",
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Offer display is disabled or empty.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Offer is active and visible to users.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Offer is enabled but text is empty — users will not see it.",
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Last updated: {timestamp}",
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Text preview:</b>",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Text is not set yet.",
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Choose an action:",
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Edit text",
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 View current text",
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Enable display",
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Disable display",
"ADMIN_PUBLIC_OFFER_HTML_HELP": "️ HTML help",
"ADMIN_FAQ_HEADER": "❓ <b>FAQ</b>",
"ADMIN_FAQ_DESCRIPTION": "FAQ is shown in the “Info” section.",
"ADMIN_FAQ_LANGUAGE": "Language: <code>{lang}</code>",
"ADMIN_FAQ_PAGE_STATS": "Total pages: {total}",
"ADMIN_FAQ_STATUS_DISABLED": "⚠️ FAQ display is turned off.",
"ADMIN_FAQ_STATUS_ENABLED": "✅ FAQ is enabled. Active pages: {count}.",
"ADMIN_FAQ_STATUS_ENABLED_EMPTY": "⚠️ FAQ is enabled but there are no active pages.",
"ADMIN_FAQ_STATUS_EMPTY": "⚠️ FAQ has not been configured yet.",
"ADMIN_FAQ_PAGES_EMPTY": "No pages have been created yet.",
"ADMIN_FAQ_PAGES_OVERVIEW": "<b>Page list:</b>\n{items}",
"ADMIN_FAQ_ACTION_PROMPT": "Choose an action:",
"ADMIN_FAQ_ADD_PAGE_BUTTON": " Add page",
"ADMIN_FAQ_ENABLE_BUTTON": "✅ Enable display",
"ADMIN_FAQ_DISABLE_BUTTON": "🚫 Disable display",
"ADMIN_FAQ_HTML_HELP": "️ HTML help",
"ADMIN_FAQ_ENTER_TITLE": "Enter a title for the new FAQ page:",
"ADMIN_FAQ_CANCEL_BUTTON": "⬅️ Cancel",
"ADMIN_FAQ_TITLE_EMPTY": "❌ Title cannot be empty.",
"ADMIN_FAQ_TITLE_TOO_LONG": "❌ Title is too long. Maximum 255 characters.",
"ADMIN_FAQ_ENTER_CONTENT": "Send the content of the FAQ page. HTML is allowed.",
"ADMIN_FAQ_CONTENT_TOO_LONG": "❌ Text is too long. Maximum 6000 characters.",
"ADMIN_FAQ_CONTENT_EMPTY": "❌ Text cannot be empty.",
"ADMIN_FAQ_HTML_ERROR": "❌ HTML error: {error}",
"ADMIN_FAQ_PAGE_CREATED": "✅ FAQ page created.",
"ADMIN_FAQ_BACK_TO_LIST": "⬅️ Back to FAQ settings",
"ADMIN_FAQ_ENABLED_ALERT": "✅ FAQ enabled.",
"ADMIN_FAQ_DISABLED_ALERT": "🚫 FAQ disabled.",
"ADMIN_FAQ_PAGE_NOT_FOUND": "⚠️ Page not found.",
"ADMIN_FAQ_PAGE_HEADER": "📄 <b>FAQ page</b>",
"ADMIN_FAQ_PAGE_STATUS_ACTIVE": "✅ Active",
"ADMIN_FAQ_PAGE_STATUS_INACTIVE": "🚫 Disabled",
"ADMIN_FAQ_PAGE_UPDATED": "Updated: {timestamp}",
"ADMIN_FAQ_PAGE_PREVIEW_EMPTY": "Content has not been provided yet.",
"ADMIN_FAQ_PAGE_PREVIEW": "<b>Preview:</b>\n{content}",
"ADMIN_FAQ_PAGE_TITLE": "<b>Title:</b> {title}",
"ADMIN_FAQ_PAGE_STATUS": "Status: {status}",
"ADMIN_FAQ_EDIT_TITLE_BUTTON": "✏️ Edit title",
"ADMIN_FAQ_EDIT_CONTENT_BUTTON": "📝 Edit text",
"ADMIN_FAQ_PAGE_ENABLE_BUTTON": "✅ Enable page",
"ADMIN_FAQ_PAGE_DISABLE_BUTTON": "🚫 Disable page",
"ADMIN_FAQ_PAGE_MOVE_UP": "⬆️ Up",
"ADMIN_FAQ_PAGE_MOVE_DOWN": "⬇️ Down",
"ADMIN_FAQ_PAGE_DELETE_BUTTON": "🗑️ Delete",
"ADMIN_FAQ_TITLE_UPDATED": "✅ Title updated.",
"ADMIN_FAQ_UNEXPECTED_STATE": "⚠️ State was reset.",
"ADMIN_FAQ_CONTENT_UPDATED": "✅ Page text updated.",
"ADMIN_FAQ_PAGE_ENABLED_ALERT": "✅ Page enabled.",
"ADMIN_FAQ_PAGE_DISABLED_ALERT": "🚫 Page disabled.",
"ADMIN_FAQ_PAGE_DELETED": "🗑️ Page deleted.",
"ADMIN_FAQ_PAGE_REORDERED": "✅ Order updated.",
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Current text (preview):",
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Send a new public offer text. HTML markup is allowed.",
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Use /html_help for the list of allowed tags.",
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Public offer editing",
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Cancel",
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Offer text is too long. Maximum 4000 characters.",
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ HTML error: {error}",
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Public offer updated.",
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ Back to offer settings",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Offer text is not set yet.",
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Current offer text</b>",
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Text shortened for display. Users will see the full version in the menu.",
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Offer enabled",
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Offer disabled",
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Back to editing",
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled."
}
+245 -5
View File
@@ -235,12 +235,27 @@
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n",
"MAIN_MENU": "👤 <b>{user_name}</b>\n \n📱 <b>Подписка:</b> {subscription_status}\n\nВыберите действие:\n",
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Тестовые сервера активны: {servers}",
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Доступ действует ещё: {time_left}\n<code>{bar}</code>",
"MAIN_MENU_BUTTON": "🏠 Главное меню",
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
"MENU_ADMIN": "⚙️ Админ-панель",
"MENU_BALANCE": "💰 Баланс",
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
"MENU_INFO": "️ Инфо",
"MENU_INFO_HEADER": "️ <b>Инфо</b>",
"MENU_INFO_PROMPT": "Выберите раздел:",
"MENU_FAQ": "❓ FAQ",
"FAQ_HEADER": "❓ <b>FAQ</b>",
"FAQ_PAGES_PROMPT": "Выберите вопрос:",
"FAQ_NOT_AVAILABLE": "FAQ временно недоступен.",
"FAQ_PAGE_UNTITLED": "Без названия",
"FAQ_PAGE_NOT_AVAILABLE": "Эта страница FAQ недоступна.",
"FAQ_PAGE_EMPTY": "Текст для этой страницы ещё не добавлен.",
"FAQ_PAGE_TITLE": "<b>{title}</b>",
"FAQ_PAGE_FOOTER": "Страница {current} из {total}",
"FAQ_BACK_TO_LIST": "⬅️ К списку FAQ",
"MENU_LANGUAGE": "🌐 Язык",
"MENU_PROMOCODE": "🎫 Промокод",
"MENU_REFERRALS": "🤝 Партнерка",
@@ -527,14 +542,26 @@
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
"TRIAL_INACTIVE_24H": "⏳ <b>Прошли сутки с начала теста</b>\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!",
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Подписка закончилась</b>\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nАктивируйте предложение, чтобы получить дополнительную скидку. Она суммируется с вашей промогруппой и действует до {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 <b>Скидка {percent}% активирована!</b> \n\nОна суммируется с другими скидками и автоматически применится при следующей оплате.",
"DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.",
"DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.",
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось активировать скидку. Попробуйте позже.",
"PROMO_OFFER_CLOSE": "❌ Закрыть",
"TEST_ACCESS_NO_SUBSCRIPTION": "❌ Для активации предложения необходима действующая подписка.",
"TEST_ACCESS_NO_SQUADS": "❌ Не удалось определить список серверов для теста. Обратитесь к администратору.",
"TEST_ACCESS_REMNAWAVE_ERROR": "❌ Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.",
"TEST_ACCESS_UNKNOWN_ERROR": "❌ Не удалось активировать предложение. Попробуйте позже.",
"TEST_ACCESS_ALREADY_CONNECTED": "ℹ️ Этот сервер уже подключен к вашей подписке.",
"TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.",
"TEST_ACCESS_ACTIVATED_POPUP": "✅ Доступ выдан!",
"DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки",
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Промо-предложение: -{amount} ({percent}% дополнительно)",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n<code>{bar}</code>",
"NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.",
"NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.",
"NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
@@ -557,6 +584,131 @@
"ADMIN_SUBMENU_SELECT_SECTION": "Выберите нужный раздел:",
"ADMIN_COMMUNICATIONS_WELCOME_TEXT": "👋 Приветственный текст",
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Сообщения в меню",
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Промо-предложения",
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Промо-предложения</b>\n\nВыберите предложение для настройки:",
"ADMIN_PROMO_OFFER_TEST_ACCESS": "Тестовые сервера",
"ADMIN_PROMO_OFFER_EXTEND": "Скидка на продление",
"ADMIN_PROMO_OFFER_PURCHASE": "Скидка на покупку",
"ADMIN_PROMO_OFFER_TYPE": "Тип: {label}",
"ADMIN_PROMO_OFFER_VALID": "Срок действия: {hours} ч",
"ADMIN_PROMO_OFFER_DISCOUNT": "Доп. скидка: {percent}% (суммируется с другими скидками)",
"ADMIN_PROMO_OFFER_STACKABLE_NOTE": "Скидка применяется один раз и добавляется к промогруппе.",
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "Скидка после активации действует {hours} ч.",
"ADMIN_PROMO_OFFER_TEST_DURATION": "Доступ: {hours} ч",
"ADMIN_PROMO_OFFER_TEST_SQUAD_NAME": "Сервер: {name}",
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Сквады: {squads}",
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Сквады: не указаны",
"ADMIN_PROMO_OFFER_ALLOWED": "Доступные категории:",
"ADMIN_PROMO_OFFER_PREVIEW": "Предпросмотр:",
"ADMIN_PROMO_OFFER_PROMPT_MESSAGE": "Введите новый текст предложения:",
"ADMIN_PROMO_OFFER_PROMPT_BUTTON": "Введите новый текст кнопки:",
"ADMIN_PROMO_OFFER_PROMPT_VALID": "Укажите срок действия (в часах):",
"ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Введите размер скидки в процентах:",
"ADMIN_PROMO_OFFER_PROMPT_ACTIVE_DURATION": "Укажите срок действия активированной скидки (в часах):",
"ADMIN_PROMO_OFFER_PROMPT_DURATION": "Введите длительность тестового доступа (в часах):",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE": "🌍 <b>Выберите сквад</b>",
"ADMIN_PROMO_OFFER_SELECTED_SQUAD": "Текущий сквад: {name}",
"ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID": "Текущий сквад: {uuid}",
"ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY": "Текущий сквад: не выбран",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT": "Выберите сервер для тестового доступа из списка ниже.",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE": "Страница {page}/{total}",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR": "🗑 Очистить",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK": "↩️ Назад",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED": "✅ Сквад обновлён",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED": "✅ Сквад очищен",
"ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND": "❌ Сервер не найден",
"ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE": "❌ Доступные серверы не найдены.",
"ADMIN_PROMO_OFFER_SENDING": "Начинаем рассылку...",
"ADMIN_PROMO_OFFER_NO_USERS": "Подходящих пользователей не найдено.",
"ADMIN_PROMO_OFFER_RESULT": "📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}",
"ADMIN_PROMO_OFFER_SKIPPED": "Пропущено: {skipped} (уже есть доступ)",
"ADMIN_PROMO_OFFER_CTA_BUY": "Купить подписку",
"ADMIN_PROMO_OFFER_CTA_EXTEND": "Продлить подписку",
"ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ К предложению",
"ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ К промопредложениям",
"ADMIN_PROMO_OFFER_LOGS": "📜 Лог операций",
"ADMIN_PROMO_OFFER_LOGS_TITLE": "📜 <b>Лог операций промо-предложений</b>",
"ADMIN_PROMO_OFFER_LOGS_PAGINATION": "Страница {page}/{total}",
"ADMIN_PROMO_OFFER_LOGS_EMPTY_BODY": "Записей пока нет.",
"ADMIN_PROMO_OFFER_LOGS_UNKNOWN_USER": "Неизвестный пользователь",
"ADMIN_PROMO_OFFER_LOGS_USER": "👤 {user}",
"ADMIN_PROMO_OFFER_LOGS_PERCENT": "📉 Скидка: {percent}%",
"ADMIN_PROMO_OFFER_LOGS_EFFECT_TEST": "🧪 Тестовый доступ",
"ADMIN_PROMO_OFFER_LOGS_EFFECT_DISCOUNT": "💸 Скидка",
"ADMIN_PROMO_OFFER_LOGS_SOURCE": "🏷 Источник: {source}",
"ADMIN_PROMO_OFFER_LOGS_REASON_GENERIC": "️ Действие: {reason}",
"ADMIN_PROMO_OFFER_LOGS_REASON_MANUAL": "💳 Списано при ручной оплате",
"ADMIN_PROMO_OFFER_LOGS_REASON_AUTOPAY": "🤖 Применено при автопродлении",
"ADMIN_PROMO_OFFER_LOGS_REASON_EXPIRED": "⏳ Предложение истекло",
"ADMIN_PROMO_OFFER_LOGS_REASON_TEST_EXPIRED": "⏳ Тестовый доступ отключён",
"ADMIN_PROMO_OFFER_LOGS_DESCRIPTION": "📝 {description}",
"ADMIN_PROMO_OFFER_LOGS_AMOUNT": "💰 Сумма: {amount}",
"ADMIN_PROMO_OFFER_LOGS_SQUAD": "🌍 Сквад: {squad}",
"ADMIN_PROMO_OFFER_LOGS_NEW_SQUADS": "🌍 Новые сквады: {squads}",
"ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED": "Принято",
"ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED": "Использовано",
"ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED": "Отключено",
"ADMIN_PROMO_OFFER_SEND_USER": "👤 Отправка пользователю",
"ADMIN_PROMO_OFFER_SEND_USER_TITLE": "👤 <b>Отправка пользователю</b>",
"ADMIN_PROMO_OFFER_SEND_USER_HINT": "Выберите пользователя для отправки промопредложения.",
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH": "🔍 Поиск",
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_PROMPT": "Введите имя, username или ID пользователя для поиска:",
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_QUERY": "🔍 Поиск: <b>{query}</b>",
"ADMIN_PROMO_OFFER_SEND_USER_RESET": "❌ Сбросить поиск",
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_SEGMENTS": "↩️ К выбору категории",
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY": "Подходящие пользователи не найдены. Измените запрос поиска.",
"ADMIN_PROMO_OFFER_SEND_USER_PROFILE": "👤 <b>{name}</b>",
"ADMIN_PROMO_OFFER_SEND_USER_TELEGRAM": "🆔 <code>{telegram_id}</code>",
"ADMIN_PROMO_OFFER_SEND_USER_USERNAME": "🔗 @{username}",
"ADMIN_PROMO_OFFER_SEND_USER_STATUS": "Статус: {status}",
"ADMIN_PROMO_OFFER_SEND_USER_BALANCE": "Баланс: {amount}",
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION": "💳 <b>Подписка</b>",
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS": "Статус: {status}",
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END": "Истекает: {date}",
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END_UNKNOWN": "не указано",
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC": "Трафик: {used}/{limit} ГБ",
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS": "Подключено сквадов: {count}",
"ADMIN_PROMO_OFFER_SEND_USER_NO_SUBSCRIPTION": "💳 Подписка отсутствует",
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT": "💸 Активная скидка: {percent}%",
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_UNTIL": " (до {date})",
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_LEFT": " (до {date}, осталось {time})",
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_SOURCE": " — источник: {source}",
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_NONE": "💸 Активная скидка отсутствует",
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_HEADER": "📨 <b>Выбранное предложение</b>",
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TYPE": "Тип: {label}",
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_VALID": "Действует: {hours} ч.",
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TEST_DURATION": "Тестовый доступ: {hours} ч.",
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_DISCOUNT": "Скидка: {percent}%",
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACTIVE_DURATION": "После активации действует {hours} ч.",
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_OFFERS": "📨 Активные предложения:",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TEST": "Тестовый доступ",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_PERCENT": "{percent}%",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_BONUS": "+{bonus}%",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY": "без срока",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM": "• {description} (до {expires})",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM_HEADER": "• {description}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_EXPIRES": " Истекает: {expires}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS": " Статус: {status}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_ACCEPTED": "✅ Принято",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_PENDING": "⏳ Не принято",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT": " Осталось: {time}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT_EXPIRED": "истекло",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ACTIVE_DURATION": " После активации: {duration}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TOTAL_DURATION": " Всего действует: {duration}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_HEADER": "📊 Статистика предложений",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_TOTAL": "Всего отправлено: {count}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACCEPTED": "Принято: {count}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_PENDING": "Не принято: {count}",
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACTIVE": "Активно сейчас: {count}",
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS": "🧪 Активные тестовые доступы:",
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS_ITEM": "• {squad} (до {expires})",
"ADMIN_PROMO_OFFER_SEND_USER_NO_ACTIVE_OFFERS": "📨 Активных предложений нет",
"ADMIN_PROMO_OFFER_SEND_USER_SEND_BUTTON": "📬 Отправить предложение",
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST": "⬅️ К списку пользователей",
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_PROFILE": "👤 К профилю пользователя",
"ADMIN_PROMO_OFFER_SEND_USER_SUMMARY_TITLE": "📬 Отправка пользователю {name}",
"ADMIN_PROMO_OFFER_SEND_USER_SKIPPED": "Пропущено: {skipped} (уже есть доступ)",
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY_RESULT": "Отправка не выполнена",
"ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки",
"ADMIN_SUPPORT_AUDIT": "🧾 Аудит модераторов",
"ADMIN_SUPPORT_SETTINGS": "🛟 Настройки поддержки",
@@ -874,5 +1026,93 @@
"ADMIN_PRICING_SETTING_PROMPT": "Отправьте новое значение или напишите «Отмена». Для очистки используйте none.",
"ADMIN_PRICING_SETTING_SUCCESS": "Параметр {label} обновлен: {value}",
"ADMIN_PRICING_SETTING_TOGGLE_STATEFUL": "{icon} {label}",
"ADMIN_PRICING_SETTING_WARNING": "Важно"
"ADMIN_PRICING_SETTING_WARNING": "Важно",
"MENU_PUBLIC_OFFER": "📄 Оферта",
"PUBLIC_OFFER_NOT_AVAILABLE": "Публичная оферта временно недоступна.",
"PUBLIC_OFFER_EMPTY_ALERT": "Публичная оферта ещё не заполнена.",
"PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
"PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
"ADMIN_SETTINGS_FAQ": "❓ FAQ",
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "Публичная оферта отображается в разделе «Инфо».",
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Язык: <code>{lang}</code>",
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Показ оферты выключен или текст отсутствует.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Оферта активна и показывается пользователям.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Последнее обновление: {timestamp}",
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Превью текста:</b>",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Текст ещё не задан.",
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Выберите действие:",
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Изменить текст",
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 Просмотреть текущий текст",
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Включить показ",
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Отключить показ",
"ADMIN_PUBLIC_OFFER_HTML_HELP": "️ HTML помощь",
"ADMIN_FAQ_HEADER": "❓ <b>FAQ</b>",
"ADMIN_FAQ_DESCRIPTION": "FAQ отображается в разделе «Инфо».",
"ADMIN_FAQ_LANGUAGE": "Язык: <code>{lang}</code>",
"ADMIN_FAQ_PAGE_STATS": "Всего страниц: {total}",
"ADMIN_FAQ_STATUS_DISABLED": "⚠️ Показ FAQ выключен.",
"ADMIN_FAQ_STATUS_ENABLED": "✅ FAQ включён. Активных страниц: {count}.",
"ADMIN_FAQ_STATUS_ENABLED_EMPTY": "⚠️ FAQ включён, но нет активных страниц.",
"ADMIN_FAQ_STATUS_EMPTY": "⚠️ FAQ ещё не настроен.",
"ADMIN_FAQ_PAGES_EMPTY": "Страницы ещё не созданы.",
"ADMIN_FAQ_PAGES_OVERVIEW": "<b>Список страниц:</b>\n{items}",
"ADMIN_FAQ_ACTION_PROMPT": "Выберите действие:",
"ADMIN_FAQ_ADD_PAGE_BUTTON": "➕ Добавить страницу",
"ADMIN_FAQ_ENABLE_BUTTON": "✅ Включить показ",
"ADMIN_FAQ_DISABLE_BUTTON": "🚫 Отключить показ",
"ADMIN_FAQ_HTML_HELP": "️ HTML помощь",
"ADMIN_FAQ_ENTER_TITLE": "Введите заголовок для новой страницы FAQ:",
"ADMIN_FAQ_CANCEL_BUTTON": "⬅️ Отмена",
"ADMIN_FAQ_TITLE_EMPTY": "❌ Заголовок не может быть пустым.",
"ADMIN_FAQ_TITLE_TOO_LONG": "❌ Заголовок слишком длинный. Максимум 255 символов.",
"ADMIN_FAQ_ENTER_CONTENT": "Отправьте содержимое страницы FAQ. Допускается HTML.",
"ADMIN_FAQ_CONTENT_TOO_LONG": "❌ Текст слишком длинный. Максимум 6000 символов.",
"ADMIN_FAQ_CONTENT_EMPTY": "❌ Текст не может быть пустым.",
"ADMIN_FAQ_HTML_ERROR": "❌ Ошибка в HTML: {error}",
"ADMIN_FAQ_PAGE_CREATED": "✅ Страница FAQ создана.",
"ADMIN_FAQ_BACK_TO_LIST": "⬅️ К настройкам FAQ",
"ADMIN_FAQ_ENABLED_ALERT": "✅ FAQ включён.",
"ADMIN_FAQ_DISABLED_ALERT": "🚫 FAQ отключён.",
"ADMIN_FAQ_PAGE_NOT_FOUND": "⚠️ Страница не найдена.",
"ADMIN_FAQ_PAGE_HEADER": "📄 <b>Страница FAQ</b>",
"ADMIN_FAQ_PAGE_STATUS_ACTIVE": "✅ Активна",
"ADMIN_FAQ_PAGE_STATUS_INACTIVE": "🚫 Выключена",
"ADMIN_FAQ_PAGE_UPDATED": "Обновлено: {timestamp}",
"ADMIN_FAQ_PAGE_PREVIEW_EMPTY": "Текст ещё не задан.",
"ADMIN_FAQ_PAGE_PREVIEW": "<b>Превью:</b>\n{content}",
"ADMIN_FAQ_PAGE_TITLE": "<b>Заголовок:</b> {title}",
"ADMIN_FAQ_PAGE_STATUS": "Статус: {status}",
"ADMIN_FAQ_EDIT_TITLE_BUTTON": "✏️ Изменить заголовок",
"ADMIN_FAQ_EDIT_CONTENT_BUTTON": "📝 Изменить текст",
"ADMIN_FAQ_PAGE_ENABLE_BUTTON": "✅ Включить страницу",
"ADMIN_FAQ_PAGE_DISABLE_BUTTON": "🚫 Выключить страницу",
"ADMIN_FAQ_PAGE_MOVE_UP": "⬆️ Выше",
"ADMIN_FAQ_PAGE_MOVE_DOWN": "⬇️ Ниже",
"ADMIN_FAQ_PAGE_DELETE_BUTTON": "🗑️ Удалить",
"ADMIN_FAQ_TITLE_UPDATED": "✅ Заголовок обновлён.",
"ADMIN_FAQ_UNEXPECTED_STATE": "⚠️ Состояние сброшено.",
"ADMIN_FAQ_CONTENT_UPDATED": "✅ Текст страницы обновлён.",
"ADMIN_FAQ_PAGE_ENABLED_ALERT": "✅ Страница включена.",
"ADMIN_FAQ_PAGE_DISABLED_ALERT": "🚫 Страница выключена.",
"ADMIN_FAQ_PAGE_DELETED": "🗑️ Страница удалена.",
"ADMIN_FAQ_PAGE_REORDERED": "✅ Порядок обновлён.",
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Текущий текст (превью):",
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Используйте /html_help для справки по тегам.",
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Редактирование оферты",
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Отмена",
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Текст оферты слишком длинный. Максимум 4000 символов.",
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ Ошибка в HTML: {error}",
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Публичная оферта обновлена.",
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ К настройкам оферты",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Текст оферты пока не задан.",
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Текущий текст оферты</b>",
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Оферта включена",
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Оферта отключена",
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Редактирование оферты отменено."
}