├── users ├── __init__.py ├── jwt │ ├── __init__.py │ ├── exceptions.py │ └── payloads.py ├── utils │ ├── __init__.py │ ├── tests.py │ └── auth.py ├── migrations │ ├── __init__.py │ └── 0002_user_accepted_terms_user_terms_accepted_date.py ├── enums.py ├── apps.py ├── urls.py ├── permissions.py ├── authentication.py ├── tasks.py ├── routers.py ├── serializers.py ├── admin.py ├── models.py └── backends.py ├── donation ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0004_convert_html_to_markdown.py │ ├── 0005_alter_donation_date.py │ ├── 0003_alter_method_link.py │ ├── 0002_alter_method_position_alter_section_position.py │ └── 0001_initial.py ├── translation.py ├── apps.py ├── urls.py ├── serializers.py ├── tests.py ├── admin.py ├── views.py └── models.py ├── instruction ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0003_convert_html_to_markdown.py │ ├── 0002_alter_section_position.py │ └── 0001_initial.py ├── translation.py ├── serializers.py ├── apps.py ├── urls.py ├── admin.py ├── tests.py ├── models.py └── views.py ├── languages ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── makemessages.py ├── migrations │ └── __init__.py ├── apps.py ├── urls.py ├── serializers.py ├── tests.py └── views.py ├── privacy_policy ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0003_convert_html_to_markdown.py │ ├── 0002_alter_section_position.py │ └── 0001_initial.py ├── translation.py ├── serializers.py ├── urls.py ├── apps.py ├── admin.py ├── tests.py ├── models.py └── views.py ├── telegram_bots ├── __init__.py ├── hub │ ├── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── create_hub.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_alter_telegrambotshub_microservice_token_and_more.py │ │ └── 0001_initial.py │ ├── service │ │ ├── __init__.py │ │ ├── schemas.py │ │ ├── adapters.py │ │ └── api.py │ ├── serializers │ │ ├── variable.py │ │ ├── telegram_bot.py │ │ ├── background_task.py │ │ ├── api_request.py │ │ ├── condition.py │ │ ├── trigger.py │ │ ├── user.py │ │ ├── database_operation.py │ │ ├── __init__.py │ │ ├── message.py │ │ ├── connection.py │ │ └── database_record.py │ ├── tests │ │ ├── mixins.py │ │ ├── __init__.py │ │ ├── utils.py │ │ └── telegram_bot.py │ ├── admin.py │ ├── apps.py │ ├── utils.py │ ├── authentication.py │ ├── views │ │ ├── mixins.py │ │ ├── telegram_bot.py │ │ ├── __init__.py │ │ ├── user.py │ │ ├── variable.py │ │ ├── api_request.py │ │ ├── condition.py │ │ ├── background_task.py │ │ ├── database_operation.py │ │ ├── message.py │ │ ├── database_record.py │ │ └── trigger.py │ ├── urls.py │ └── models.py ├── migrations │ ├── __init__.py │ ├── 0011_alter_triggermessage_text.py │ ├── 0013_alter_message_text.py │ ├── 0007_rename_is_delete_user_message_commandsettings_delete_user_message_and_more.py │ ├── 0002_alter_backgroundtaskapirequest_url_and_more.py │ ├── 0009_alter_apirequest_body_alter_apirequest_headers_and_more.py │ ├── 0003_rename_file_command_and_more.py │ └── 0008_remove_connection_telegram_bo_source__bb2efb_idx_and_more.py ├── apps.py ├── utils.py ├── tests │ ├── stats.py │ └── __init__.py ├── serializers │ ├── mixins.py │ ├── user.py │ ├── database_record.py │ ├── variable.py │ ├── base.py │ ├── background_task.py │ ├── telegram_bot.py │ ├── __init__.py │ └── api_request.py ├── models │ ├── variable.py │ ├── background_task.py │ ├── database_record.py │ ├── user.py │ ├── api_request.py │ ├── __init__.py │ ├── connection.py │ ├── condition.py │ ├── base.py │ ├── trigger.py │ └── database_operation.py ├── views │ ├── stats.py │ ├── mixins.py │ ├── connection.py │ ├── variable.py │ ├── database_record.py │ ├── __init__.py │ ├── telegram_bot.py │ ├── trigger.py │ ├── background_task.py │ ├── condition.py │ ├── api_request.py │ ├── database_operation.py │ └── message.py ├── tasks.py └── enums.py ├── terms_of_service ├── __init__.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── translation.py ├── serializers.py ├── urls.py ├── apps.py ├── admin.py ├── tests.py ├── models.py └── views.py ├── .github ├── FUNDING.yml ├── pull_request_template.md ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── main.yml ├── constructor_telegram_bots ├── mixins.py ├── __init__.py ├── asgi.py ├── wsgi.py ├── utils.py ├── permissions.py ├── pagination.py ├── gunicorn.py ├── celery.py ├── fields.py ├── migrations.py ├── urls.py ├── validators.py └── parsers.py ├── .gitignore ├── SECURITY.md ├── manage.py ├── install.sh ├── CONTRIBUTING.md ├── README.md └── pyproject.toml /users/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /donation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instruction/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /languages/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /users/jwt/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /users/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /privacy_policy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /telegram_bots/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /terms_of_service/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /users/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /donation/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /languages/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /languages/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /telegram_bots/hub/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instruction/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /privacy_policy/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /telegram_bots/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /languages/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /telegram_bots/hub/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /telegram_bots/hub/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /terms_of_service/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /telegram_bots/hub/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: EXG1O 2 | custom: https://constructor.exg1o.org/donation 3 | -------------------------------------------------------------------------------- /telegram_bots/hub/service/__init__.py: -------------------------------------------------------------------------------- 1 | from .api import API 2 | 3 | __all__ = ['API'] 4 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | Issue #... 6 | -------------------------------------------------------------------------------- /constructor_telegram_bots/mixins.py: -------------------------------------------------------------------------------- 1 | class IDLookupMixin: 2 | lookup_value_converter = 'int' 3 | lookup_field = 'id' 4 | -------------------------------------------------------------------------------- /constructor_telegram_bots/__init__.py: -------------------------------------------------------------------------------- 1 | from constructor_telegram_bots.celery import celery_app 2 | 3 | __all__ = ['celery_app'] 4 | -------------------------------------------------------------------------------- /telegram_bots/hub/service/schemas.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | 3 | 4 | class StartTelegramBot(TypedDict): 5 | bot_token: str 6 | -------------------------------------------------------------------------------- /languages/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class LanguagesConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'languages' 7 | -------------------------------------------------------------------------------- /languages/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import LanguagesAPIView 4 | 5 | app_name = 'languages' 6 | urlpatterns = [path('', LanguagesAPIView.as_view(), name='index')] 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Папки 2 | __pycache__/ 3 | node_modules/ 4 | .vscode/ 5 | /static/ 6 | media/ 7 | logs/ 8 | env/ 9 | dist/ 10 | 11 | # Файлы 12 | celerybeat-schedule 13 | .coverage 14 | .env 15 | *.db 16 | *.mo -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Discussions 4 | url: https://github.com/EXG1O/Constructor-Telegram-Bots/discussions/categories/ideas 5 | about: Please ask questions here 6 | -------------------------------------------------------------------------------- /constructor_telegram_bots/asgi.py: -------------------------------------------------------------------------------- 1 | from django.core.asgi import get_asgi_application 2 | 3 | import os 4 | 5 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'constructor_telegram_bots.settings') 6 | 7 | application = get_asgi_application() 8 | -------------------------------------------------------------------------------- /constructor_telegram_bots/wsgi.py: -------------------------------------------------------------------------------- 1 | from django.core.wsgi import get_wsgi_application 2 | 3 | import os 4 | 5 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'constructor_telegram_bots.settings') 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /donation/translation.py: -------------------------------------------------------------------------------- 1 | from modeltranslation.translator import TranslationOptions, register 2 | 3 | from .models import Section 4 | 5 | 6 | @register(Section) 7 | class SectionTranslationOptions(TranslationOptions): 8 | fields = ['title', 'text'] 9 | -------------------------------------------------------------------------------- /instruction/translation.py: -------------------------------------------------------------------------------- 1 | from modeltranslation.translator import TranslationOptions, register 2 | 3 | from .models import Section 4 | 5 | 6 | @register(Section) 7 | class SectionTranslationOptions(TranslationOptions): 8 | fields = ['title', 'text'] 9 | -------------------------------------------------------------------------------- /users/enums.py: -------------------------------------------------------------------------------- 1 | from django.db.models import TextChoices 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class TokenType(TextChoices): 6 | REFRESH = 'refresh', _('Токен обновления') 7 | ACCESS = 'access', _('Токен разрешения') 8 | -------------------------------------------------------------------------------- /privacy_policy/translation.py: -------------------------------------------------------------------------------- 1 | from modeltranslation.translator import TranslationOptions, register 2 | 3 | from .models import Section 4 | 5 | 6 | @register(Section) 7 | class SectionTranslationOptions(TranslationOptions): 8 | fields = ['title', 'text'] 9 | -------------------------------------------------------------------------------- /terms_of_service/translation.py: -------------------------------------------------------------------------------- 1 | from modeltranslation.translator import TranslationOptions, register 2 | 3 | from .models import Section 4 | 5 | 6 | @register(Section) 7 | class SectionTranslationOptions(TranslationOptions): 8 | fields = ['title', 'text'] 9 | -------------------------------------------------------------------------------- /users/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class UsersConfig(AppConfig): 6 | default_auto_field = 'django.db.models.BigAutoField' 7 | name = 'users' 8 | verbose_name = _('Пользователи') 9 | -------------------------------------------------------------------------------- /constructor_telegram_bots/utils.py: -------------------------------------------------------------------------------- 1 | from django.utils.html import format_html 2 | 3 | 4 | def format_html_link(url: str, text: str | None = None) -> str: 5 | return format_html( 6 | f'{text or url}' 7 | ) 8 | -------------------------------------------------------------------------------- /instruction/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Section 4 | 5 | 6 | class SectionSerializer(serializers.ModelSerializer[Section]): 7 | class Meta: 8 | model = Section 9 | fields = ['id', 'title', 'text'] 10 | -------------------------------------------------------------------------------- /donation/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class DonationConfig(AppConfig): 6 | default_auto_field = 'django.db.models.BigAutoField' 7 | name = 'donation' 8 | verbose_name = _('Пожертвования') 9 | -------------------------------------------------------------------------------- /privacy_policy/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Section 4 | 5 | 6 | class SectionSerializer(serializers.ModelSerializer[Section]): 7 | class Meta: 8 | model = Section 9 | fields = ['id', 'title', 'text'] 10 | -------------------------------------------------------------------------------- /terms_of_service/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Section 4 | 5 | 6 | class SectionSerializer(serializers.ModelSerializer[Section]): 7 | class Meta: 8 | model = Section 9 | fields = ['id', 'title', 'text'] 10 | -------------------------------------------------------------------------------- /instruction/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class InstructionConfig(AppConfig): 6 | default_auto_field = 'django.db.models.BigAutoField' 7 | name = 'instruction' 8 | verbose_name = _('Инструкция') 9 | -------------------------------------------------------------------------------- /telegram_bots/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class TelegramBotsConfig(AppConfig): 6 | default_auto_field = 'django.db.models.BigAutoField' 7 | name = 'telegram_bots' 8 | verbose_name = _('Telegram боты') 9 | -------------------------------------------------------------------------------- /telegram_bots/hub/serializers/variable.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from ...models import Variable 4 | 5 | 6 | class VariableSerializer(serializers.ModelSerializer[Variable]): 7 | class Meta: 8 | model = Variable 9 | fields = ['id', 'name', 'value'] 10 | -------------------------------------------------------------------------------- /instruction/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import SimpleRouter 2 | 3 | from .views import SectionViewSet 4 | 5 | router = SimpleRouter(use_regex_path=False) 6 | router.register('sections', SectionViewSet, basename='section') 7 | 8 | app_name = 'instruction' 9 | urlpatterns = router.urls 10 | -------------------------------------------------------------------------------- /users/jwt/exceptions.py: -------------------------------------------------------------------------------- 1 | from jwt import InvalidTokenError 2 | 3 | 4 | class InvalidTokenTypeError(InvalidTokenError): 5 | pass 6 | 7 | 8 | class InvalidTokenSubjectError(InvalidTokenError): 9 | pass 10 | 11 | 12 | class InvalidTokenRefreshJTIError(InvalidTokenError): 13 | pass 14 | -------------------------------------------------------------------------------- /languages/serializers.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from rest_framework import serializers 4 | 5 | 6 | class SetLanguageSerializer(serializers.Serializer[None]): 7 | lang_code = serializers.ChoiceField(settings.LANGUAGES) 8 | 9 | class Meta: 10 | fields = ['lang_code'] 11 | -------------------------------------------------------------------------------- /privacy_policy/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import SimpleRouter 2 | 3 | from .views import SectionsViewSet 4 | 5 | router = SimpleRouter(use_regex_path=False) 6 | router.register('sections', SectionsViewSet, basename='section') 7 | 8 | app_name = 'privacy-policy' 9 | urlpatterns = router.urls 10 | -------------------------------------------------------------------------------- /privacy_policy/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class PrivacyPolicyConfig(AppConfig): 6 | default_auto_field = 'django.db.models.BigAutoField' 7 | name = 'privacy_policy' 8 | verbose_name = _('Политика конфиденциальности') 9 | -------------------------------------------------------------------------------- /telegram_bots/hub/serializers/telegram_bot.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from ...models import TelegramBot 4 | 5 | 6 | class TelegramBotSerializer(serializers.ModelSerializer[TelegramBot]): 7 | class Meta: 8 | model = TelegramBot 9 | fields = ['id', 'is_private'] 10 | -------------------------------------------------------------------------------- /terms_of_service/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import SimpleRouter 2 | 3 | from .views import SectionsViewSet 4 | 5 | router = SimpleRouter(use_regex_path=False) 6 | router.register('sections', SectionsViewSet, basename='section') 7 | 8 | app_name = 'terms-of-service' 9 | urlpatterns = router.urls 10 | -------------------------------------------------------------------------------- /terms_of_service/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class TermsOfServiceConfig(AppConfig): 6 | default_auto_field = 'django.db.models.BigAutoField' 7 | name = 'terms_of_service' 8 | verbose_name = _('Условия использования сервиса') 9 | -------------------------------------------------------------------------------- /telegram_bots/hub/tests/mixins.py: -------------------------------------------------------------------------------- 1 | from ..models import TelegramBotsHub 2 | 3 | 4 | class HubMixin: 5 | def setUp(self) -> None: 6 | super().setUp() # type: ignore [misc] 7 | self.hub: TelegramBotsHub = TelegramBotsHub.objects.create( 8 | url='http://127.0.0.1', microservice_token='Token :-)' 9 | ) 10 | -------------------------------------------------------------------------------- /telegram_bots/hub/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import TelegramBotsHub 4 | 5 | 6 | @admin.register(TelegramBotsHub) 7 | class TelegramBotsHubAdmin(admin.ModelAdmin[TelegramBotsHub]): 8 | search_fields = ['url'] 9 | list_display = ['url'] 10 | fields = ['url', 'service_token', 'microservice_token'] 11 | -------------------------------------------------------------------------------- /telegram_bots/hub/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class TelegramBotsHubConfig(AppConfig): 6 | default_auto_field = 'django.db.models.BigAutoField' 7 | name = 'telegram_bots.hub' 8 | label = 'telegram_bots_hub' 9 | verbose_name = _('Центр Telegram ботов') 10 | -------------------------------------------------------------------------------- /constructor_telegram_bots/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import SAFE_METHODS, BasePermission 2 | from rest_framework.request import Request 3 | from rest_framework.views import APIView 4 | 5 | 6 | class ReadOnly(BasePermission): 7 | def has_permission(self, request: Request, view: APIView) -> bool: 8 | return request.method in SAFE_METHODS 9 | -------------------------------------------------------------------------------- /telegram_bots/utils.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | 3 | from functools import cache 4 | from typing import TYPE_CHECKING, Any 5 | 6 | if TYPE_CHECKING: 7 | from .models import TelegramBot 8 | else: 9 | TelegramBot = Any 10 | 11 | 12 | @cache 13 | def get_telegram_bot_modal() -> type[TelegramBot]: 14 | return apps.get_model('telegram_bots.TelegramBot') 15 | -------------------------------------------------------------------------------- /users/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .routers import UserRouter 4 | from .views import StatsAPIView, UserViewSet 5 | 6 | user_router = UserRouter(use_regex_path=False) 7 | user_router.register('', UserViewSet, basename='user') 8 | 9 | 10 | app_name = 'users' 11 | urlpatterns = [path('stats/', StatsAPIView.as_view(), name='stats')] + user_router.urls 12 | -------------------------------------------------------------------------------- /telegram_bots/hub/utils.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | 3 | from functools import cache 4 | from typing import TYPE_CHECKING, Any 5 | 6 | if TYPE_CHECKING: 7 | from .models import TelegramBotsHub 8 | else: 9 | TelegramBotsHub = Any 10 | 11 | 12 | @cache 13 | def get_telegram_bots_hub_modal() -> type[TelegramBotsHub]: 14 | return apps.get_model('telegram_bots_hub.TelegramBotsHub') 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea 4 | title: '' 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | 13 | 14 | Idea #... 15 | -------------------------------------------------------------------------------- /constructor_telegram_bots/pagination.py: -------------------------------------------------------------------------------- 1 | from rest_framework.pagination import LimitOffsetPagination as BaseLimitOffsetPagination 2 | from rest_framework.response import Response 3 | 4 | from typing import Any 5 | 6 | 7 | class LimitOffsetPagination(BaseLimitOffsetPagination): 8 | def get_paginated_response(self, data: list[dict[str, Any]]) -> Response: 9 | return Response({'count': self.count, 'results': data}) 10 | -------------------------------------------------------------------------------- /donation/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import SimpleRouter 2 | 3 | from .views import DonationViewSet, MethodViewSet, SectionViewSet 4 | 5 | router = SimpleRouter(use_regex_path=False) 6 | router.register('donations', DonationViewSet, basename='donation') 7 | router.register('sections', SectionViewSet, basename='section') 8 | router.register('methods', MethodViewSet, basename='method') 9 | 10 | app_name = 'donation' 11 | urlpatterns = router.urls 12 | -------------------------------------------------------------------------------- /instruction/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from adminsortable2.admin import SortableAdminMixin 4 | from modeltranslation.admin import TranslationAdmin 5 | 6 | from .models import Section 7 | 8 | 9 | @admin.register(Section) 10 | class SectionAdmin( 11 | SortableAdminMixin, 12 | TranslationAdmin, # FIXME: Need to add generics support 13 | ): 14 | list_display = ['title', 'position'] 15 | fields = ['title', 'text'] 16 | -------------------------------------------------------------------------------- /privacy_policy/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from adminsortable2.admin import SortableAdminMixin 4 | from modeltranslation.admin import TranslationAdmin 5 | 6 | from .models import Section 7 | 8 | 9 | @admin.register(Section) 10 | class SectionAdmin( 11 | SortableAdminMixin, 12 | TranslationAdmin, # FIXME: Need to add generics support 13 | ): 14 | list_display = ['title', 'position'] 15 | fields = ['title', 'text'] 16 | -------------------------------------------------------------------------------- /telegram_bots/hub/serializers/background_task.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from ...models import BackgroundTask 4 | from .connection import ConnectionSerializer 5 | 6 | 7 | class BackgroundTaskSerializer(serializers.ModelSerializer[BackgroundTask]): 8 | source_connections = ConnectionSerializer(many=True) 9 | 10 | class Meta: 11 | model = BackgroundTask 12 | fields = ['id', 'interval', 'source_connections'] 13 | -------------------------------------------------------------------------------- /users/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import BasePermission 2 | from rest_framework.request import Request 3 | from rest_framework.views import APIView 4 | 5 | from .models import User 6 | 7 | 8 | class IsTermsAccepted(BasePermission): 9 | def has_permission(self, request: Request, view: APIView) -> bool: 10 | user: User | None = request.user # type: ignore [assignment] 11 | return bool(user and user.accepted_terms) 12 | -------------------------------------------------------------------------------- /telegram_bots/hub/serializers/api_request.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from ...models import APIRequest 4 | from .connection import ConnectionSerializer 5 | 6 | 7 | class APIRequestSerializer(serializers.ModelSerializer[APIRequest]): 8 | source_connections = ConnectionSerializer(many=True) 9 | 10 | class Meta: 11 | model = APIRequest 12 | fields = ['id', 'url', 'method', 'headers', 'body', 'source_connections'] 13 | -------------------------------------------------------------------------------- /terms_of_service/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from adminsortable2.admin import SortableAdminMixin 4 | from modeltranslation.admin import TranslationAdmin 5 | 6 | from .models import Section 7 | 8 | 9 | @admin.register(Section) 10 | class SectionAdmin( 11 | SortableAdminMixin, 12 | TranslationAdmin, # FIXME: Need to add generics support 13 | ): 14 | list_display = ['title', 'position'] 15 | fields = ['title', 'text'] 16 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | If you discover a security vulnerability in this project, please report it privately. **Do not disclose it as a public issue.** 4 | 5 | You may submit a report via telegram to project founder [@exg1o](https://t.me/exg1o). 6 | 7 | This project is maintained by volunteers on a reasonable-effort basis. Please give 30 days to work on a fix before public exposure, reducing the chance that an exploit will be used before a patch is released. 8 | -------------------------------------------------------------------------------- /constructor_telegram_bots/gunicorn.py: -------------------------------------------------------------------------------- 1 | from constructor_telegram_bots.settings import LOGS_DIR 2 | 3 | from multiprocessing import cpu_count 4 | from typing import Final 5 | 6 | workers: Final[int] = cpu_count() * 2 + 1 7 | max_requests: Final[int] = 1000 8 | max_requests_jitter: Final[int] = 100 9 | 10 | capture_output: Final[bool] = True 11 | accesslog: Final[str] = str(LOGS_DIR / 'gunicorn_info.log') 12 | errorlog: Final[str] = str(LOGS_DIR / 'gunicorn_info.log') 13 | -------------------------------------------------------------------------------- /donation/migrations/0004_convert_html_to_markdown.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | from constructor_telegram_bots.migrations import convert_html_to_markdown 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [('donation', '0003_alter_method_link')] 8 | operations = [ 9 | migrations.RunPython( 10 | convert_html_to_markdown( 11 | app_label='donation', model_name='Section', fields=['text'] 12 | ) 13 | ) 14 | ] 15 | -------------------------------------------------------------------------------- /donation/migrations/0005_alter_donation_date.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2025-03-26 20:47 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('donation', '0004_convert_html_to_markdown'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='donation', 15 | name='date', 16 | field=models.DateField(verbose_name='Дата'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /instruction/migrations/0003_convert_html_to_markdown.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | from constructor_telegram_bots.migrations import convert_html_to_markdown 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [('instruction', '0002_alter_section_position')] 8 | operations = [ 9 | migrations.RunPython( 10 | convert_html_to_markdown( 11 | app_label='instruction', model_name='Section', fields=['text'] 12 | ) 13 | ) 14 | ] 15 | -------------------------------------------------------------------------------- /privacy_policy/migrations/0003_convert_html_to_markdown.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | from constructor_telegram_bots.migrations import convert_html_to_markdown 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [('privacy_policy', '0002_alter_section_position')] 8 | operations = [ 9 | migrations.RunPython( 10 | convert_html_to_markdown( 11 | app_label='privacy_policy', model_name='Section', fields=['text'] 12 | ) 13 | ) 14 | ] 15 | -------------------------------------------------------------------------------- /instruction/migrations/0002_alter_section_position.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2025-02-04 12:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('instruction', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='section', 15 | name='position', 16 | field=models.PositiveSmallIntegerField(blank=True, verbose_name='Позиция'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /telegram_bots/tests/stats.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import reverse 3 | 4 | from rest_framework import status 5 | from rest_framework.test import APIClient 6 | 7 | 8 | class StatsAPIViewTests(TestCase): 9 | url: str = reverse('api:telegram-bots:stats') 10 | 11 | def setUp(self) -> None: 12 | self.client: APIClient = APIClient() 13 | 14 | def test_get_method(self) -> None: 15 | response = self.client.get(self.url) 16 | self.assertEqual(response.status_code, status.HTTP_200_OK) 17 | -------------------------------------------------------------------------------- /privacy_policy/migrations/0002_alter_section_position.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2025-02-04 12:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('privacy_policy', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='section', 15 | name='position', 16 | field=models.PositiveSmallIntegerField(blank=True, verbose_name='Позиция'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /users/authentication.py: -------------------------------------------------------------------------------- 1 | from rest_framework.authentication import TokenAuthentication 2 | from rest_framework.exceptions import AuthenticationFailed 3 | 4 | from .jwt.tokens import AccessToken 5 | from .models import User 6 | from .utils.auth import authenticate_token 7 | 8 | 9 | class JWTAuthentication(TokenAuthentication): 10 | def authenticate_credentials(self, raw_token: str) -> tuple[User, AccessToken]: 11 | return authenticate_token( 12 | raw_token, token_cls=AccessToken, exception_cls=AuthenticationFailed 13 | ) 14 | -------------------------------------------------------------------------------- /telegram_bots/migrations/0011_alter_triggermessage_text.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2025-11-28 14:48 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('telegram_bots', '0010_update_text_variables'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='triggermessage', 15 | name='text', 16 | field=models.TextField(max_length=4096, null=True, verbose_name='Текст'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /telegram_bots/migrations/0013_alter_message_text.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2025-12-07 16:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('telegram_bots', '0012_rename_commands_models'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='message', 15 | name='text', 16 | field=models.TextField(default=None, max_length=4096, null=True, verbose_name='Текст'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /instruction/tests.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.test import TestCase 3 | from django.urls import reverse 4 | 5 | from rest_framework import status 6 | from rest_framework.test import APIClient 7 | 8 | 9 | class SectionViewSetTests(TestCase): 10 | url: str = reverse('api:instruction:section-list') 11 | 12 | def setUp(self) -> None: 13 | self.client: APIClient = APIClient() 14 | 15 | def test_list(self) -> None: 16 | response: HttpResponse = self.client.get(self.url) 17 | self.assertEqual(response.status_code, status.HTTP_200_OK) 18 | -------------------------------------------------------------------------------- /constructor_telegram_bots/celery.py: -------------------------------------------------------------------------------- 1 | from celery import Celery, signals 2 | 3 | from typing import Any 4 | import os 5 | 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'constructor_telegram_bots.settings') 7 | 8 | celery_app = Celery('constructor_telegram_bots') 9 | celery_app.config_from_object('django.conf:settings', namespace='CELERY') 10 | celery_app.autodiscover_tasks() 11 | 12 | 13 | @signals.celeryd_after_setup.connect 14 | def celery_after_setup(*args: Any, **kwargs: Any) -> None: 15 | from telegram_bots.tasks import start_telegram_bots 16 | 17 | start_telegram_bots.delay() 18 | -------------------------------------------------------------------------------- /privacy_policy/tests.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.test import TestCase 3 | from django.urls import reverse 4 | 5 | from rest_framework import status 6 | from rest_framework.test import APIClient 7 | 8 | 9 | class SectionViewSetTests(TestCase): 10 | url: str = reverse('api:privacy-policy:section-list') 11 | 12 | def setUp(self) -> None: 13 | self.client: APIClient = APIClient() 14 | 15 | def test_list(self) -> None: 16 | response: HttpResponse = self.client.get(self.url) 17 | self.assertEqual(response.status_code, status.HTTP_200_OK) 18 | -------------------------------------------------------------------------------- /terms_of_service/tests.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.test import TestCase 3 | from django.urls import reverse 4 | 5 | from rest_framework import status 6 | from rest_framework.test import APIClient 7 | 8 | 9 | class SectionViewSetTests(TestCase): 10 | url: str = reverse('api:terms-of-service:section-list') 11 | 12 | def setUp(self) -> None: 13 | self.client: APIClient = APIClient() 14 | 15 | def test_list(self) -> None: 16 | response: HttpResponse = self.client.get(self.url) 17 | self.assertEqual(response.status_code, status.HTTP_200_OK) 18 | -------------------------------------------------------------------------------- /donation/migrations/0003_alter_method_link.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2025-02-04 21:15 2 | 3 | import constructor_telegram_bots.fields 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('donation', '0002_alter_method_position_alter_section_position'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='method', 16 | name='link', 17 | field=constructor_telegram_bots.fields.PublicURLField(blank=True, null=True, verbose_name='Ссылка'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /donation/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Donation, Method, Section 4 | 5 | 6 | class DonationSerializer(serializers.ModelSerializer[Donation]): 7 | class Meta: 8 | model = Donation 9 | fields = ['id', 'sum', 'sender', 'date'] 10 | 11 | 12 | class SectionSerializer(serializers.ModelSerializer[Section]): 13 | class Meta: 14 | model = Section 15 | fields = ['id', 'title', 'text'] 16 | 17 | 18 | class MethodSerializer(serializers.ModelSerializer[Method]): 19 | class Meta: 20 | model = Method 21 | fields = ['id', 'text', 'link', 'value'] 22 | -------------------------------------------------------------------------------- /instruction/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from django_stubs_ext.db.models import TypedModelMeta 5 | 6 | 7 | class Section(models.Model): # type: ignore [django-manager-missing] 8 | title = models.CharField(_('Заголовок'), max_length=255) 9 | text = models.TextField(_('Текст')) 10 | position = models.PositiveSmallIntegerField(_('Позиция'), blank=True) 11 | 12 | class Meta(TypedModelMeta): 13 | ordering = ['position'] 14 | verbose_name = _('Раздел') 15 | verbose_name_plural = _('Разделы') 16 | 17 | def __str__(self) -> str: 18 | return self.title 19 | -------------------------------------------------------------------------------- /privacy_policy/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from django_stubs_ext.db.models import TypedModelMeta 5 | 6 | 7 | class Section(models.Model): # type: ignore [django-manager-missing] 8 | title = models.CharField(_('Заголовок'), max_length=255) 9 | text = models.TextField(_('Текст')) 10 | position = models.PositiveSmallIntegerField(_('Позиция'), blank=True) 11 | 12 | class Meta(TypedModelMeta): 13 | ordering = ['position'] 14 | verbose_name = _('Раздел') 15 | verbose_name_plural = _('Разделы') 16 | 17 | def __str__(self) -> str: 18 | return self.title 19 | -------------------------------------------------------------------------------- /terms_of_service/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from django_stubs_ext.db.models import TypedModelMeta 5 | 6 | 7 | class Section(models.Model): # type: ignore [django-manager-missing] 8 | title = models.CharField(_('Заголовок'), max_length=255) 9 | text = models.TextField(_('Текст')) 10 | position = models.PositiveSmallIntegerField(_('Позиция'), blank=True) 11 | 12 | class Meta(TypedModelMeta): 13 | ordering = ['position'] 14 | verbose_name = _('Раздел') 15 | verbose_name_plural = _('Разделы') 16 | 17 | def __str__(self) -> str: 18 | return self.title 19 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | 5 | def main() -> None: 6 | os.environ.setdefault( 7 | 'DJANGO_SETTINGS_MODULE', 'constructor_telegram_bots.settings' 8 | ) 9 | 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | 'available on your PYTHONPATH environment variable? Did you ' 16 | 'forget to activate a virtual environment?' 17 | ) from exc 18 | 19 | execute_from_command_line(sys.argv) 20 | 21 | 22 | if __name__ == '__main__': 23 | main() 24 | -------------------------------------------------------------------------------- /telegram_bots/serializers/mixins.py: -------------------------------------------------------------------------------- 1 | from django.utils.functional import cached_property 2 | 3 | from ..models import TelegramBot 4 | 5 | from typing import TYPE_CHECKING, Any 6 | 7 | 8 | class TelegramBotMixin: 9 | if TYPE_CHECKING: 10 | context: dict[str, Any] 11 | 12 | @cached_property 13 | def telegram_bot(self) -> TelegramBot: 14 | telegram_bot: Any = self.context.get('telegram_bot') 15 | 16 | if not isinstance(telegram_bot, TelegramBot): 17 | raise TypeError( 18 | 'You not passed a TelegramBot instance as telegram_bot to the ' 19 | 'serializer context.' 20 | ) 21 | 22 | return telegram_bot 23 | -------------------------------------------------------------------------------- /telegram_bots/hub/authentication.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext as _ 2 | 3 | from rest_framework.authentication import TokenAuthentication as BaseTokenAuthentication 4 | from rest_framework.exceptions import AuthenticationFailed 5 | 6 | from .models import TelegramBotsHub 7 | 8 | 9 | class TokenAuthentication(BaseTokenAuthentication): 10 | def authenticate_credentials(self, token: str) -> tuple[TelegramBotsHub, str]: 11 | try: 12 | hub: TelegramBotsHub = TelegramBotsHub.objects.get(service_token=token) 13 | except TelegramBotsHub.DoesNotExist as error: 14 | raise AuthenticationFailed(_('Недействительный токен.')) from error 15 | 16 | return hub, token 17 | -------------------------------------------------------------------------------- /instruction/views.py: -------------------------------------------------------------------------------- 1 | from django.utils.decorators import method_decorator 2 | from django.views.decorators.cache import cache_page 3 | from django.views.decorators.vary import vary_on_cookie 4 | 5 | from rest_framework.mixins import ListModelMixin 6 | from rest_framework.viewsets import GenericViewSet 7 | 8 | from .models import Section 9 | from .serializers import SectionSerializer 10 | 11 | 12 | @method_decorator(cache_page(3600), name='dispatch') 13 | @method_decorator(vary_on_cookie, name='dispatch') 14 | class SectionViewSet(ListModelMixin, GenericViewSet[Section]): 15 | authentication_classes = [] 16 | permission_classes = [] 17 | queryset = Section.objects.all() 18 | serializer_class = SectionSerializer 19 | -------------------------------------------------------------------------------- /privacy_policy/views.py: -------------------------------------------------------------------------------- 1 | from django.utils.decorators import method_decorator 2 | from django.views.decorators.cache import cache_page 3 | from django.views.decorators.vary import vary_on_cookie 4 | 5 | from rest_framework.mixins import ListModelMixin 6 | from rest_framework.viewsets import GenericViewSet 7 | 8 | from .models import Section 9 | from .serializers import SectionSerializer 10 | 11 | 12 | @method_decorator(cache_page(3600), name='dispatch') 13 | @method_decorator(vary_on_cookie, name='dispatch') 14 | class SectionsViewSet(ListModelMixin, GenericViewSet[Section]): 15 | authentication_classes = [] 16 | permission_classes = [] 17 | queryset = Section.objects.all() 18 | serializer_class = SectionSerializer 19 | -------------------------------------------------------------------------------- /terms_of_service/views.py: -------------------------------------------------------------------------------- 1 | from django.utils.decorators import method_decorator 2 | from django.views.decorators.cache import cache_page 3 | from django.views.decorators.vary import vary_on_cookie 4 | 5 | from rest_framework.mixins import ListModelMixin 6 | from rest_framework.viewsets import GenericViewSet 7 | 8 | from .models import Section 9 | from .serializers import SectionSerializer 10 | 11 | 12 | @method_decorator(cache_page(3600), name='dispatch') 13 | @method_decorator(vary_on_cookie, name='dispatch') 14 | class SectionsViewSet(ListModelMixin, GenericViewSet[Section]): 15 | authentication_classes = [] 16 | permission_classes = [] 17 | queryset = Section.objects.all() 18 | serializer_class = SectionSerializer 19 | -------------------------------------------------------------------------------- /telegram_bots/hub/views/mixins.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import get_object_or_404 2 | from django.utils.functional import cached_property 3 | 4 | from ...models import TelegramBot 5 | 6 | from typing import TYPE_CHECKING, Any 7 | 8 | 9 | class TelegramBotMixin: 10 | if TYPE_CHECKING: 11 | kwargs: dict[str, Any] 12 | 13 | @cached_property 14 | def telegram_bot(self) -> TelegramBot: 15 | return get_object_or_404(TelegramBot, id=self.kwargs['telegram_bot_id']) 16 | 17 | def get_serializer_context(self) -> dict[str, Any]: 18 | context: dict[str, Any] = super().get_serializer_context() # type: ignore [misc] 19 | context.update({'telegram_bot': self.telegram_bot}) 20 | 21 | return context 22 | -------------------------------------------------------------------------------- /telegram_bots/hub/views/telegram_bot.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.viewsets import ReadOnlyModelViewSet 5 | 6 | from constructor_telegram_bots.mixins import IDLookupMixin 7 | 8 | from ...models import TelegramBot 9 | from ..authentication import TokenAuthentication 10 | from ..serializers import TelegramBotSerializer 11 | 12 | 13 | class TelegramBotViewSet(IDLookupMixin, ReadOnlyModelViewSet[TelegramBot]): 14 | authentication_classes = [TokenAuthentication] 15 | permission_classes = [IsAuthenticated] 16 | serializer_class = TelegramBotSerializer 17 | 18 | def get_queryset(self) -> QuerySet[TelegramBot]: 19 | return TelegramBot.objects.all() 20 | -------------------------------------------------------------------------------- /users/tasks.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | 3 | from celery import shared_task 4 | 5 | from .models import Token 6 | 7 | # FIXME: In 30 days after the release of v3.1.0, the code for automating the removal ... 8 | # of users who have not accepted the Terms of Service within 30 days needs to be uncommented. 9 | 10 | # from .models import User 11 | 12 | # from datetime import timedelta 13 | 14 | 15 | # @shared_task 16 | # def delete_users_not_accepted_terms() -> None: 17 | # User.objects.filter( 18 | # accepted_terms=False, joined_date__lt=timezone.now() - timedelta(days=30) 19 | # ).delete() 20 | 21 | 22 | @shared_task 23 | def check_tokens_expiration_date() -> None: 24 | Token.objects.filter(expiry_date__lt=timezone.now()).delete() 25 | -------------------------------------------------------------------------------- /donation/migrations/0002_alter_method_position_alter_section_position.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2025-02-04 12:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('donation', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='method', 15 | name='position', 16 | field=models.PositiveSmallIntegerField(blank=True, verbose_name='Позиция'), 17 | ), 18 | migrations.AlterField( 19 | model_name='section', 20 | name='position', 21 | field=models.PositiveSmallIntegerField(blank=True, verbose_name='Позиция'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /telegram_bots/hub/migrations/0002_alter_telegrambotshub_microservice_token_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2025-08-15 03:24 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('telegram_bots_hub', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='telegrambotshub', 15 | name='microservice_token', 16 | field=models.CharField(max_length=64, verbose_name='Токен микросервиса'), 17 | ), 18 | migrations.AlterField( 19 | model_name='telegrambotshub', 20 | name='service_token', 21 | field=models.CharField(max_length=64, primary_key=True, serialize=False, verbose_name='Токен сервиса'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /telegram_bots/hub/serializers/condition.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from ...models import Condition, ConditionPart 4 | from .connection import ConnectionSerializer 5 | 6 | 7 | class ConditionPartSerializer(serializers.ModelSerializer[ConditionPart]): 8 | class Meta: 9 | model = ConditionPart 10 | fields = [ 11 | 'id', 12 | 'type', 13 | 'first_value', 14 | 'operator', 15 | 'second_value', 16 | 'next_part_operator', 17 | ] 18 | 19 | 20 | class ConditionSerializer(serializers.ModelSerializer[Condition]): 21 | parts = ConditionPartSerializer(many=True) 22 | source_connections = ConnectionSerializer(many=True) 23 | 24 | class Meta: 25 | model = Condition 26 | fields = ['id', 'parts', 'source_connections'] 27 | -------------------------------------------------------------------------------- /languages/management/commands/makemessages.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.management.commands import makemessages 3 | 4 | from pathlib import Path 5 | 6 | 7 | class Command(makemessages.Command): 8 | def find_files(self, root: str) -> list[makemessages.TranslatableFile]: 9 | result: list[makemessages.TranslatableFile] = super().find_files(root) 10 | 11 | frontend_html_file: Path = settings.FRONTEND_PATH / 'src/prod.html' 12 | 13 | if frontend_html_file.exists(): 14 | result.append( 15 | self.translatable_file_class( 16 | dirpath=str(frontend_html_file.parent), 17 | file_name=frontend_html_file.name, 18 | locale_dir=self.default_locale_path, 19 | ) 20 | ) 21 | 22 | return result 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Description 11 | Describe the issue you are encountering. The more detailed, the better. 12 | 13 | ## Steps to Reproduce 14 | 1. Step one to reproduce the issue. 15 | 2. Step two to reproduce the issue. 16 | 3. Step three to reproduce the issue. 17 | 4. (and so on, if needed) 18 | 19 | ## Expected behavior 20 | What should have happened? 21 | 22 | ## Actual behavior 23 | What actually happened? 24 | 25 | ## Environment 26 | - **Python version**: 3.11.x 27 | - **PostgreSQL version**: x.x.x 28 | - **Redis version**: x.x.x 29 | - **Celery version**: x.x.x 30 | - **Operating system**: Linux (mention specific distribution/version) 31 | 32 | ## Logs 33 | Please attach the log files from the project directory `./logs`. 34 | -------------------------------------------------------------------------------- /telegram_bots/hub/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .api_request import APIRequestViewSet 2 | from .background_task import BackgroundTaskViewSet 3 | from .condition import ConditionViewSet 4 | from .database_operation import DatabaseOperationViewSet 5 | from .database_record import DatabaseRecordViewSet 6 | from .message import MessageKeyboardButtonViewSet, MessageViewSet 7 | from .telegram_bot import TelegramBotViewSet 8 | from .trigger import TriggerViewSet 9 | from .user import UserViewSet 10 | from .variable import VariableViewSet 11 | 12 | __all__ = [ 13 | 'TelegramBotViewSet', 14 | 'TriggerViewSet', 15 | 'MessageViewSet', 16 | 'MessageKeyboardButtonViewSet', 17 | 'ConditionViewSet', 18 | 'BackgroundTaskViewSet', 19 | 'APIRequestViewSet', 20 | 'DatabaseOperationViewSet', 21 | 'VariableViewSet', 22 | 'UserViewSet', 23 | 'DatabaseRecordViewSet', 24 | ] 25 | -------------------------------------------------------------------------------- /telegram_bots/serializers/user.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from ..models import User 4 | 5 | from typing import Any 6 | 7 | 8 | class UserSerializer(serializers.ModelSerializer[User]): 9 | class Meta: 10 | model = User 11 | fields = [ 12 | 'id', 13 | 'telegram_id', 14 | 'full_name', 15 | 'is_allowed', 16 | 'is_blocked', 17 | 'activated_date', 18 | ] 19 | read_only_fields = ['telegram_id', 'full_name', 'activated_date'] 20 | 21 | def update(self, user: User, validated_data: dict[str, Any]) -> User: 22 | user.is_allowed = validated_data.get('is_allowed', user.is_allowed) 23 | user.is_blocked = validated_data.get('is_blocked', user.is_blocked) 24 | user.save(update_fields=['is_allowed', 'is_blocked']) 25 | 26 | return user 27 | -------------------------------------------------------------------------------- /telegram_bots/hub/management/commands/create_hub.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from ...models import TelegramBotsHub 4 | 5 | from typing import Any 6 | import secrets 7 | 8 | 9 | class Command(BaseCommand): 10 | def handle(self, *args: Any, **options: Any) -> None: 11 | url: str = input('Enter a microservice URL: ') 12 | 13 | service_token: str = secrets.token_hex(32) 14 | microservice_token: str = secrets.token_hex(32) 15 | 16 | hub = TelegramBotsHub( 17 | url=url, 18 | service_token=service_token, 19 | microservice_token=microservice_token, 20 | ) 21 | hub.clean_fields() 22 | hub.save() 23 | 24 | self.stdout.write(f'Token for microservice authorization: {service_token}') 25 | self.stdout.write(f"{'Microservice token:':37} {microservice_token}") 26 | -------------------------------------------------------------------------------- /telegram_bots/hub/views/user.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.mixins import CreateModelMixin 4 | from rest_framework.permissions import IsAuthenticated 5 | from rest_framework.viewsets import ReadOnlyModelViewSet 6 | 7 | from constructor_telegram_bots.mixins import IDLookupMixin 8 | 9 | from ...models import User 10 | from ..authentication import TokenAuthentication 11 | from ..serializers import UserSerializer 12 | from .mixins import TelegramBotMixin 13 | 14 | 15 | class UserViewSet( 16 | IDLookupMixin, 17 | TelegramBotMixin, 18 | CreateModelMixin, 19 | ReadOnlyModelViewSet[User], 20 | ): 21 | authentication_classes = [TokenAuthentication] 22 | permission_classes = [IsAuthenticated] 23 | serializer_class = UserSerializer 24 | 25 | def get_queryset(self) -> QuerySet[User]: 26 | return self.telegram_bot.users.all() 27 | -------------------------------------------------------------------------------- /languages/tests.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.test import TestCase 3 | from django.urls import reverse 4 | 5 | from rest_framework import status 6 | from rest_framework.test import APIClient 7 | 8 | 9 | class LanguagesAPIViewTests(TestCase): 10 | url: str = reverse('api:languages:index') 11 | 12 | def setUp(self) -> None: 13 | self.client: APIClient = APIClient() 14 | 15 | def test_get_method(self) -> None: 16 | response: HttpResponse = self.client.get(self.url) 17 | self.assertEqual(response.status_code, status.HTTP_200_OK) 18 | 19 | def test_post_method(self) -> None: 20 | response: HttpResponse = self.client.post(self.url) 21 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 22 | 23 | response = self.client.post(self.url, {'lang_code': 'ru'}) 24 | self.assertEqual(response.status_code, status.HTTP_200_OK) 25 | -------------------------------------------------------------------------------- /telegram_bots/models/variable.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from django_stubs_ext.db.models import TypedModelMeta 5 | 6 | 7 | class Variable(models.Model): 8 | telegram_bot = models.ForeignKey( 9 | 'TelegramBot', 10 | on_delete=models.CASCADE, 11 | related_name='variables', 12 | verbose_name=_('Telegram бот'), 13 | ) 14 | name = models.CharField(_('Название'), max_length=64) 15 | value = models.TextField(_('Значение'), max_length=2048) 16 | description = models.CharField(_('Описание'), max_length=255) 17 | 18 | class Meta(TypedModelMeta): 19 | db_table = 'telegram_bot_variable' 20 | indexes = [models.Index(fields=['name'])] 21 | verbose_name = _('Переменная') 22 | verbose_name_plural = _('Переменные') 23 | 24 | def __str__(self) -> str: 25 | return self.name 26 | -------------------------------------------------------------------------------- /telegram_bots/hub/serializers/trigger.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from ...models import Trigger, TriggerCommand, TriggerMessage 4 | from .connection import ConnectionSerializer 5 | 6 | 7 | class TriggerCommandSerializer(serializers.ModelSerializer[TriggerCommand]): 8 | class Meta: 9 | model = TriggerCommand 10 | fields = ['command', 'payload', 'description'] 11 | 12 | 13 | class TriggerMessageSerializer(serializers.ModelSerializer[TriggerMessage]): 14 | class Meta: 15 | model = TriggerMessage 16 | fields = ['text'] 17 | 18 | 19 | class TriggerSerializer(serializers.ModelSerializer[Trigger]): 20 | command = TriggerCommandSerializer() 21 | message = TriggerMessageSerializer() 22 | source_connections = ConnectionSerializer(many=True) 23 | 24 | class Meta: 25 | model = Trigger 26 | fields = ['id', 'command', 'message', 'source_connections'] 27 | -------------------------------------------------------------------------------- /telegram_bots/hub/views/variable.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.viewsets import ModelViewSet 5 | 6 | from django_filters.rest_framework import DjangoFilterBackend 7 | 8 | from constructor_telegram_bots.mixins import IDLookupMixin 9 | 10 | from ...models import Variable 11 | from ..authentication import TokenAuthentication 12 | from ..serializers import VariableSerializer 13 | from .mixins import TelegramBotMixin 14 | 15 | 16 | class VariableViewSet(IDLookupMixin, TelegramBotMixin, ModelViewSet[Variable]): 17 | authentication_classes = [TokenAuthentication] 18 | permission_classes = [IsAuthenticated] 19 | serializer_class = VariableSerializer 20 | filter_backends = [DjangoFilterBackend] 21 | filterset_fields = ['name'] 22 | 23 | def get_queryset(self) -> QuerySet[Variable]: 24 | return self.telegram_bot.variables.all() 25 | -------------------------------------------------------------------------------- /telegram_bots/models/background_task.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from django_stubs_ext.db.models import TypedModelMeta 5 | 6 | from ..enums import BackgroundTaskInterval 7 | from .base import AbstractBlock 8 | 9 | 10 | class BackgroundTask(AbstractBlock): 11 | telegram_bot = models.ForeignKey( 12 | 'TelegramBot', 13 | on_delete=models.CASCADE, 14 | related_name='background_tasks', 15 | verbose_name=_('Telegram бот'), 16 | ) 17 | interval = models.PositiveSmallIntegerField( 18 | _('Интервал'), choices=BackgroundTaskInterval 19 | ) 20 | target_connections = None 21 | 22 | class Meta(TypedModelMeta): 23 | db_table = 'telegram_bot_background_task' 24 | verbose_name = _('Фоновая задача') 25 | verbose_name_plural = _('Фоновые задачи') 26 | 27 | def __str__(self) -> str: 28 | return self.name 29 | -------------------------------------------------------------------------------- /telegram_bots/models/database_record.py: -------------------------------------------------------------------------------- 1 | from django.contrib.postgres.indexes import GinIndex 2 | from django.db import models 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from django_stubs_ext.db.models import TypedModelMeta 6 | 7 | from constructor_telegram_bots.fields import StrictJSONField 8 | 9 | 10 | class DatabaseRecord(models.Model): 11 | telegram_bot = models.ForeignKey( 12 | 'TelegramBot', 13 | on_delete=models.CASCADE, 14 | related_name='database_records', 15 | verbose_name=_('Telegram бот'), 16 | ) 17 | data = StrictJSONField(_('Данные')) 18 | 19 | class Meta(TypedModelMeta): 20 | db_table = 'telegram_bot_database_record' 21 | indexes = [GinIndex(fields=['data'])] 22 | verbose_name = _('Запись в БД') 23 | verbose_name_plural = _('Записи в БД') 24 | 25 | def __str__(self) -> str: 26 | return f"{self.telegram_bot.username} | {getattr(self, 'id', 'NULL')}" 27 | -------------------------------------------------------------------------------- /telegram_bots/views/stats.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Count, Q 2 | from django.utils.decorators import method_decorator 3 | from django.views.decorators.cache import cache_page 4 | 5 | from rest_framework.request import Request 6 | from rest_framework.response import Response 7 | from rest_framework.views import APIView 8 | 9 | from ..models import TelegramBot, User 10 | 11 | 12 | class StatsAPIView(APIView): 13 | authentication_classes = [] 14 | permission_classes = [] 15 | 16 | @method_decorator(cache_page(3600)) 17 | def get(self, request: Request) -> Response: 18 | return Response( 19 | { 20 | 'telegram_bots': TelegramBot.objects.aggregate( 21 | total=Count('id'), 22 | enabled=Count('id', filter=Q(must_be_enabled=True)), 23 | ), 24 | 'users': { 25 | 'total': User.objects.count(), 26 | }, 27 | } 28 | ) 29 | -------------------------------------------------------------------------------- /telegram_bots/hub/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .api_request import APIRequestViewSetTests 2 | from .background_task import BackgroundTaskViewSetTests 3 | from .condition import ConditionViewSetTests 4 | from .database_operation import DatabaseOperationViewSetTests 5 | from .database_record import DatabaseRecordViewSetTests 6 | from .message import MessageKeyboardButtonViewSetTests, MessageViewSetTests 7 | from .telegram_bot import TelegramBotViewSetTests 8 | from .trigger import TriggerViewSetTests 9 | from .user import UserViewSetTests 10 | from .variable import VariableViewSetTests 11 | 12 | __all__ = [ 13 | 'TelegramBotViewSetTests', 14 | 'MessageViewSetTests', 15 | 'MessageKeyboardButtonViewSetTests', 16 | 'ConditionViewSetTests', 17 | 'BackgroundTaskViewSetTests', 18 | 'APIRequestViewSetTests', 19 | 'DatabaseOperationViewSetTests', 20 | 'TriggerViewSetTests', 21 | 'VariableViewSetTests', 22 | 'UserViewSetTests', 23 | 'DatabaseRecordViewSetTests', 24 | ] 25 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pip install -U pip 4 | pip install poetry 5 | poetry install 6 | 7 | SECRET_KEY=$(python -c "import secrets; print(secrets.token_hex(25))") 8 | 9 | read -p "Enter Telegram bot API-token: " TELEGRAM_BOT_TOKEN 10 | read -p "Enter path to the 'dist' directory of the frontend: " FRONTEND_PATH 11 | read -p "Enter PostgreSQL database name: " POSTGRESQL_DATABASE_NAME 12 | read -p "Enter PostgreSQL database user: " POSTGRESQL_DATABASE_USER 13 | read -p "Enter PostgreSQL database password: " POSTGRESQL_DATABASE_PASSWORD 14 | 15 | cat << EOF > .env 16 | SECRET_KEY=$SECRET_KEY 17 | 18 | DEBUG=True 19 | ENABLE_TELEGRAM_AUTH=False 20 | 21 | TELEGRAM_BOT_TOKEN=$TELEGRAM_BOT_TOKEN 22 | 23 | FRONTEND_PATH=$FRONTEND_PATH 24 | 25 | POSTGRESQL_DATABASE_NAME=$POSTGRESQL_DATABASE_NAME 26 | POSTGRESQL_DATABASE_USER=$POSTGRESQL_DATABASE_USER 27 | POSTGRESQL_DATABASE_PASSWORD=$POSTGRESQL_DATABASE_PASSWORD 28 | EOF 29 | 30 | python manage.py compilemessages -i env 31 | python manage.py migrate 32 | -------------------------------------------------------------------------------- /telegram_bots/hub/tests/utils.py: -------------------------------------------------------------------------------- 1 | from rest_framework import status 2 | from rest_framework.request import Request 3 | from rest_framework.response import Response 4 | from rest_framework.test import force_authenticate 5 | 6 | from typing import TYPE_CHECKING, Any 7 | 8 | if TYPE_CHECKING: 9 | from rest_framework.views import AsView 10 | else: 11 | from typing import Generic, TypeVar 12 | 13 | T = TypeVar('T') 14 | 15 | class AsView(Generic[T]): ... 16 | 17 | 18 | def assert_view_basic_protected( 19 | view: AsView[Any], request: Request, token: Any, **view_kwargs: Any 20 | ) -> None: 21 | if TYPE_CHECKING: 22 | response: Response 23 | 24 | force_authenticate(request, None, None) 25 | 26 | response = view(request, **view_kwargs) 27 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 28 | 29 | force_authenticate(request, None, token) 30 | 31 | response = view(request, **view_kwargs) 32 | assert response.status_code == status.HTTP_403_FORBIDDEN 33 | -------------------------------------------------------------------------------- /telegram_bots/views/mixins.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import get_object_or_404 2 | from django.utils.functional import cached_property 3 | 4 | from rest_framework.exceptions import PermissionDenied 5 | 6 | from users.models import User 7 | 8 | from ..models import TelegramBot 9 | 10 | from typing import TYPE_CHECKING, Any 11 | 12 | 13 | class TelegramBotMixin: 14 | if TYPE_CHECKING: 15 | request: Any 16 | kwargs: dict[str, Any] 17 | 18 | @cached_property 19 | def telegram_bot(self) -> TelegramBot: 20 | if not isinstance(self.request.user, User): 21 | raise PermissionDenied() 22 | 23 | return get_object_or_404( 24 | self.request.user.telegram_bots, 25 | id=self.kwargs['telegram_bot_id'], 26 | ) 27 | 28 | def get_serializer_context(self) -> dict[str, Any]: 29 | context: dict[str, Any] = super().get_serializer_context() # type: ignore [misc] 30 | context.update({'telegram_bot': self.telegram_bot}) 31 | 32 | return context 33 | -------------------------------------------------------------------------------- /telegram_bots/hub/serializers/user.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | 3 | from rest_framework import serializers 4 | 5 | from ...models import User 6 | from ...serializers.mixins import TelegramBotMixin 7 | 8 | from typing import Any 9 | 10 | 11 | class UserSerializer(TelegramBotMixin, serializers.ModelSerializer[User]): 12 | class Meta: 13 | model = User 14 | fields = ['id', 'telegram_id', 'full_name', 'is_allowed', 'is_blocked'] 15 | read_only_fields = ['is_allowed', 'is_blocked'] 16 | 17 | def create(self, validated_data: dict[str, Any]) -> User: 18 | telegram_id: int = validated_data.pop('telegram_id') 19 | 20 | user, created = self.telegram_bot.users.get_or_create( 21 | telegram_id=telegram_id, defaults=validated_data 22 | ) 23 | 24 | if not created: 25 | user.full_name = validated_data.get('full_name', user.full_name) 26 | user.last_activity_date = timezone.now() 27 | user.save(update_fields=['full_name', 'last_activity_date']) 28 | 29 | return user 30 | -------------------------------------------------------------------------------- /telegram_bots/hub/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-07-23 14:25 2 | 3 | from django.db import migrations, models 4 | import secrets 5 | 6 | 7 | def generate_token() -> str: 8 | return secrets.token_hex(25) 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='TelegramBotsHub', 21 | fields=[ 22 | ('url', models.URLField(unique=True, verbose_name='URL-адрес')), 23 | ('service_token', models.CharField(default=generate_token, max_length=50, primary_key=True, serialize=False, verbose_name='Токен сервиса')), 24 | ('microservice_token', models.CharField(max_length=50, verbose_name='Токен микросервиса')), 25 | ], 26 | options={ 27 | 'verbose_name': 'Центр', 28 | 'verbose_name_plural': 'Центра', 29 | 'db_table': 'telegram_bots_hub', 30 | }, 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /telegram_bots/views/connection.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.mixins import CreateModelMixin, DestroyModelMixin 4 | from rest_framework.permissions import IsAuthenticated 5 | from rest_framework.viewsets import GenericViewSet 6 | 7 | from constructor_telegram_bots.mixins import IDLookupMixin 8 | from constructor_telegram_bots.permissions import ReadOnly 9 | from users.authentication import JWTAuthentication 10 | from users.permissions import IsTermsAccepted 11 | 12 | from ..models import Connection 13 | from ..serializers import ConnectionSerializer 14 | from .mixins import TelegramBotMixin 15 | 16 | 17 | class ConnectionViewSet( 18 | IDLookupMixin, 19 | TelegramBotMixin, 20 | CreateModelMixin, 21 | DestroyModelMixin, 22 | GenericViewSet[Connection], 23 | ): 24 | authentication_classes = [JWTAuthentication] 25 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 26 | serializer_class = ConnectionSerializer 27 | 28 | def get_queryset(self) -> QuerySet[Connection]: 29 | return self.telegram_bot.connections.all() 30 | -------------------------------------------------------------------------------- /telegram_bots/hub/views/api_request.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.viewsets import ReadOnlyModelViewSet 5 | 6 | from constructor_telegram_bots.mixins import IDLookupMixin 7 | 8 | from ...models import APIRequest 9 | from ..authentication import TokenAuthentication 10 | from ..serializers import APIRequestSerializer 11 | from .mixins import TelegramBotMixin 12 | 13 | 14 | class APIRequestViewSet( 15 | IDLookupMixin, TelegramBotMixin, ReadOnlyModelViewSet[APIRequest] 16 | ): 17 | authentication_classes = [TokenAuthentication] 18 | permission_classes = [IsAuthenticated] 19 | serializer_class = APIRequestSerializer 20 | 21 | def get_queryset(self) -> QuerySet[APIRequest]: 22 | api_requests: QuerySet[APIRequest] = self.telegram_bot.api_requests.all() 23 | 24 | if self.action in ['list', 'retrieve']: 25 | return api_requests.prefetch_related( 26 | 'source_connections__source_object', 27 | 'source_connections__target_object', 28 | ) 29 | 30 | return api_requests 31 | -------------------------------------------------------------------------------- /telegram_bots/models/user.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from django_stubs_ext.db.models import TypedModelMeta 5 | 6 | 7 | class User(models.Model): 8 | telegram_bot = models.ForeignKey( 9 | 'TelegramBot', 10 | on_delete=models.CASCADE, 11 | related_name='users', 12 | verbose_name=_('Telegram бот'), 13 | ) 14 | telegram_id = models.PositiveBigIntegerField('Telegram ID') 15 | full_name = models.CharField(_('Имя и фамилия'), max_length=129) 16 | is_allowed = models.BooleanField(_('Разрешён'), default=False) 17 | is_blocked = models.BooleanField(_('Заблокирован'), default=False) 18 | last_activity_date = models.DateTimeField( 19 | _('Дата последней активности'), auto_now_add=True 20 | ) 21 | activated_date = models.DateTimeField(_('Дата активации'), auto_now_add=True) 22 | 23 | class Meta(TypedModelMeta): 24 | db_table = 'telegram_bot_user' 25 | verbose_name = _('Пользователя') 26 | verbose_name_plural = _('Пользователи') 27 | 28 | def __str__(self) -> str: 29 | return self.full_name 30 | -------------------------------------------------------------------------------- /telegram_bots/hub/views/condition.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.viewsets import ReadOnlyModelViewSet 5 | 6 | from constructor_telegram_bots.mixins import IDLookupMixin 7 | 8 | from ...models import Condition 9 | from ..authentication import TokenAuthentication 10 | from ..serializers import ConditionSerializer 11 | from .mixins import TelegramBotMixin 12 | 13 | 14 | class ConditionViewSet( 15 | IDLookupMixin, TelegramBotMixin, ReadOnlyModelViewSet[Condition] 16 | ): 17 | authentication_classes = [TokenAuthentication] 18 | permission_classes = [IsAuthenticated] 19 | serializer_class = ConditionSerializer 20 | 21 | def get_queryset(self) -> QuerySet[Condition]: 22 | conditions: QuerySet[Condition] = self.telegram_bot.conditions.all() 23 | 24 | if self.action in ['list', 'retrieve']: 25 | return conditions.prefetch_related( 26 | 'parts', 27 | 'source_connections__source_object', 28 | 'source_connections__target_object', 29 | ) 30 | 31 | return conditions 32 | -------------------------------------------------------------------------------- /telegram_bots/migrations/0007_rename_is_delete_user_message_commandsettings_delete_user_message_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2025-08-18 00:30 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('telegram_bots', '0006_databaseoperation_databasecreateoperation_and_more'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='commandsettings', 15 | old_name='is_delete_user_message', 16 | new_name='delete_user_message', 17 | ), 18 | migrations.RenameField( 19 | model_name='commandsettings', 20 | old_name='is_reply_to_user_message', 21 | new_name='reply_to_user_message', 22 | ), 23 | migrations.RemoveField( 24 | model_name='commandsettings', 25 | name='is_send_as_new_message', 26 | ), 27 | migrations.AddField( 28 | model_name='commandsettings', 29 | name='send_as_new_message', 30 | field=models.BooleanField(default=True, verbose_name='Отправить сообщение как новое'), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /donation/tests.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.test import TestCase 3 | from django.urls import reverse 4 | 5 | from rest_framework import status 6 | from rest_framework.test import APIClient 7 | 8 | 9 | class CustomTestCase(TestCase): 10 | def setUp(self) -> None: 11 | self.client: APIClient = APIClient() 12 | 13 | 14 | class DonationViewSetTests(CustomTestCase): 15 | url: str = reverse('api:donation:donation-list') 16 | 17 | def test_list(self) -> None: 18 | response: HttpResponse = self.client.get(self.url) 19 | self.assertEqual(response.status_code, status.HTTP_200_OK) 20 | 21 | 22 | class SectionViewSetTests(CustomTestCase): 23 | url: str = reverse('api:donation:section-list') 24 | 25 | def test_list(self) -> None: 26 | response: HttpResponse = self.client.get(self.url) 27 | self.assertEqual(response.status_code, status.HTTP_200_OK) 28 | 29 | 30 | class MethodViewSetTests(CustomTestCase): 31 | url: str = reverse('api:donation:method-list') 32 | 33 | def test_list(self) -> None: 34 | response: HttpResponse = self.client.get(self.url) 35 | self.assertEqual(response.status_code, status.HTTP_200_OK) 36 | -------------------------------------------------------------------------------- /telegram_bots/hub/views/background_task.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.viewsets import ReadOnlyModelViewSet 5 | 6 | from constructor_telegram_bots.mixins import IDLookupMixin 7 | 8 | from ...models import BackgroundTask 9 | from ..authentication import TokenAuthentication 10 | from ..serializers import BackgroundTaskSerializer 11 | from .mixins import TelegramBotMixin 12 | 13 | 14 | class BackgroundTaskViewSet( 15 | IDLookupMixin, TelegramBotMixin, ReadOnlyModelViewSet[BackgroundTask] 16 | ): 17 | authentication_classes = [TokenAuthentication] 18 | permission_classes = [IsAuthenticated] 19 | serializer_class = BackgroundTaskSerializer 20 | 21 | def get_queryset(self) -> QuerySet[BackgroundTask]: 22 | background_tasks: QuerySet[BackgroundTask] = ( 23 | self.telegram_bot.background_tasks.all() 24 | ) 25 | 26 | if self.action in ['list', 'retrieve']: 27 | return background_tasks.prefetch_related( 28 | 'source_connections__source_object', 29 | 'source_connections__target_object', 30 | ) 31 | 32 | return background_tasks 33 | -------------------------------------------------------------------------------- /telegram_bots/views/variable.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.filters import OrderingFilter, SearchFilter 4 | from rest_framework.permissions import IsAuthenticated 5 | from rest_framework.viewsets import ModelViewSet 6 | 7 | from constructor_telegram_bots.mixins import IDLookupMixin 8 | from constructor_telegram_bots.pagination import LimitOffsetPagination 9 | from constructor_telegram_bots.permissions import ReadOnly 10 | from users.authentication import JWTAuthentication 11 | from users.permissions import IsTermsAccepted 12 | 13 | from ..models import Variable 14 | from ..serializers import VariableSerializer 15 | from .mixins import TelegramBotMixin 16 | 17 | 18 | class VariableViewSet(IDLookupMixin, TelegramBotMixin, ModelViewSet[Variable]): 19 | authentication_classes = [JWTAuthentication] 20 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 21 | serializer_class = VariableSerializer 22 | pagination_class = LimitOffsetPagination 23 | filter_backends = [SearchFilter, OrderingFilter] 24 | search_fields = ['id', 'name'] 25 | ordering = ['-id'] 26 | 27 | def get_queryset(self) -> QuerySet[Variable]: 28 | return self.telegram_bot.variables.all() 29 | -------------------------------------------------------------------------------- /telegram_bots/models/api_request.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from django_stubs_ext.db.models import TypedModelMeta 5 | 6 | from constructor_telegram_bots.fields import PublicURLField, StrictJSONField 7 | 8 | from ..enums import APIRequestMethod 9 | from .base import AbstractBlock 10 | 11 | 12 | class APIRequest(AbstractBlock): 13 | telegram_bot = models.ForeignKey( 14 | 'TelegramBot', 15 | on_delete=models.CASCADE, 16 | related_name='api_requests', 17 | verbose_name=_('Telegram бот'), 18 | ) 19 | url = PublicURLField(_('URL-адрес')) 20 | method = models.CharField( 21 | _('Метод'), max_length=6, choices=APIRequestMethod, default=APIRequestMethod.GET 22 | ) 23 | headers = StrictJSONField( 24 | _('Заголовки'), max_length=2048, allowed_types=(dict,), blank=True, null=True 25 | ) 26 | body = StrictJSONField(_('Данные'), max_length=4096, blank=True, null=True) 27 | 28 | class Meta(TypedModelMeta): 29 | db_table = 'telegram_bot_api_request' 30 | verbose_name = _('API-запрос') 31 | verbose_name_plural = _('API-запрос') 32 | 33 | def __str__(self) -> str: 34 | return self.name 35 | -------------------------------------------------------------------------------- /users/routers.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import DynamicRoute, Route, SimpleRouter 2 | 3 | 4 | class UserRouter(SimpleRouter): 5 | routes = [ 6 | Route( 7 | url=r'^{prefix}{trailing_slash}$', 8 | mapping={'get': 'list', 'post': 'create'}, 9 | name='{basename}-list', 10 | detail=False, 11 | initkwargs={'suffix': 'List'}, 12 | ), 13 | DynamicRoute( 14 | url=r'^{prefix}/{url_path}{trailing_slash}$', 15 | name='{basename}-{url_name}', 16 | detail=False, 17 | initkwargs={}, 18 | ), 19 | Route( 20 | url=r'^{prefix}/me{trailing_slash}$', 21 | mapping={ 22 | 'get': 'retrieve', 23 | 'put': 'update', 24 | 'patch': 'partial_update', 25 | 'delete': 'destroy', 26 | }, 27 | name='{basename}-detail', 28 | detail=True, 29 | initkwargs={'suffix': 'Instance'}, 30 | ), 31 | DynamicRoute( 32 | url=r'^{prefix}/me/{url_path}{trailing_slash}$', 33 | name='{basename}-{url_name}', 34 | detail=True, 35 | initkwargs={}, 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /telegram_bots/views/database_record.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.filters import OrderingFilter, SearchFilter 4 | from rest_framework.permissions import IsAuthenticated 5 | from rest_framework.viewsets import ModelViewSet 6 | 7 | from constructor_telegram_bots.mixins import IDLookupMixin 8 | from constructor_telegram_bots.pagination import LimitOffsetPagination 9 | from constructor_telegram_bots.permissions import ReadOnly 10 | from users.authentication import JWTAuthentication 11 | from users.permissions import IsTermsAccepted 12 | 13 | from ..models import DatabaseRecord 14 | from ..serializers import DatabaseRecordSerializer 15 | from .mixins import TelegramBotMixin 16 | 17 | 18 | class DatabaseRecordViewSet( 19 | IDLookupMixin, TelegramBotMixin, ModelViewSet[DatabaseRecord] 20 | ): 21 | authentication_classes = [JWTAuthentication] 22 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 23 | serializer_class = DatabaseRecordSerializer 24 | pagination_class = LimitOffsetPagination 25 | filter_backends = [SearchFilter, OrderingFilter] 26 | search_fields = ['data'] 27 | ordering = ['-id'] 28 | 29 | def get_queryset(self) -> QuerySet[DatabaseRecord]: 30 | return self.telegram_bot.database_records.all() 31 | -------------------------------------------------------------------------------- /telegram_bots/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .api_request import APIRequest 2 | from .background_task import BackgroundTask 3 | from .condition import Condition, ConditionPart 4 | from .connection import Connection 5 | from .database_operation import ( 6 | DatabaseCreateOperation, 7 | DatabaseOperation, 8 | DatabaseUpdateOperation, 9 | ) 10 | from .database_record import DatabaseRecord 11 | from .message import ( 12 | Message, 13 | MessageDocument, 14 | MessageImage, 15 | MessageKeyboard, 16 | MessageKeyboardButton, 17 | MessageSettings, 18 | ) 19 | from .telegram_bot import TelegramBot 20 | from .trigger import Trigger, TriggerCommand, TriggerMessage 21 | from .user import User 22 | from .variable import Variable 23 | 24 | __all__ = [ 25 | 'TelegramBot', 26 | 'Connection', 27 | 'Trigger', 28 | 'TriggerCommand', 29 | 'TriggerMessage', 30 | 'Message', 31 | 'MessageSettings', 32 | 'MessageImage', 33 | 'MessageDocument', 34 | 'MessageKeyboard', 35 | 'MessageKeyboardButton', 36 | 'Condition', 37 | 'ConditionPart', 38 | 'BackgroundTask', 39 | 'APIRequest', 40 | 'DatabaseOperation', 41 | 'DatabaseCreateOperation', 42 | 'DatabaseUpdateOperation', 43 | 'Variable', 44 | 'User', 45 | 'DatabaseRecord', 46 | ] 47 | -------------------------------------------------------------------------------- /telegram_bots/hub/serializers/database_operation.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from ...models import ( 4 | DatabaseCreateOperation, 5 | DatabaseOperation, 6 | DatabaseUpdateOperation, 7 | ) 8 | from .connection import ConnectionSerializer 9 | 10 | 11 | class DatabaseCreateOperationSerializer( 12 | serializers.ModelSerializer[DatabaseCreateOperation] 13 | ): 14 | class Meta: 15 | model = DatabaseCreateOperation 16 | fields = ['data'] 17 | 18 | 19 | class DatabaseUpdateOperationSerializer( 20 | serializers.ModelSerializer[DatabaseUpdateOperation] 21 | ): 22 | class Meta: 23 | model = DatabaseUpdateOperation 24 | fields = [ 25 | 'overwrite', 26 | 'lookup_field_name', 27 | 'lookup_field_value', 28 | 'create_if_not_found', 29 | 'new_data', 30 | ] 31 | 32 | 33 | class DatabaseOperationSerializer(serializers.ModelSerializer[DatabaseOperation]): 34 | create_operation = DatabaseCreateOperationSerializer() 35 | update_operation = DatabaseUpdateOperationSerializer() 36 | source_connections = ConnectionSerializer(many=True) 37 | 38 | class Meta: 39 | model = DatabaseOperation 40 | fields = ['id', 'create_operation', 'update_operation', 'source_connections'] 41 | -------------------------------------------------------------------------------- /telegram_bots/hub/views/database_operation.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.viewsets import ReadOnlyModelViewSet 5 | 6 | from constructor_telegram_bots.mixins import IDLookupMixin 7 | 8 | from ...models import DatabaseOperation 9 | from ..authentication import TokenAuthentication 10 | from ..serializers import DatabaseOperationSerializer 11 | from .mixins import TelegramBotMixin 12 | 13 | 14 | class DatabaseOperationViewSet( 15 | IDLookupMixin, TelegramBotMixin, ReadOnlyModelViewSet[DatabaseOperation] 16 | ): 17 | authentication_classes = [TokenAuthentication] 18 | permission_classes = [IsAuthenticated] 19 | serializer_class = DatabaseOperationSerializer 20 | 21 | def get_queryset(self) -> QuerySet[DatabaseOperation]: 22 | operations: QuerySet[DatabaseOperation] = ( 23 | self.telegram_bot.database_operations.all() 24 | ) 25 | 26 | if self.action in ['list', 'retrieve']: 27 | return operations.select_related( 28 | 'create_operation', 'update_operation' 29 | ).prefetch_related( 30 | 'source_connections__source_object', 31 | 'source_connections__target_object', 32 | ) 33 | 34 | return operations 35 | -------------------------------------------------------------------------------- /telegram_bots/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .api_request import APIRequestViewSet, DiagramAPIRequestViewSet 2 | from .background_task import BackgroundTaskViewSet, DiagramBackgroundTaskViewSet 3 | from .condition import ConditionViewSet, DiagramConditionViewSet 4 | from .connection import ConnectionViewSet 5 | from .database_operation import ( 6 | DatabaseOperationViewSet, 7 | DiagramDatabaseOperationViewSet, 8 | ) 9 | from .database_record import DatabaseRecordViewSet 10 | from .message import DiagramMessageViewSet, MessageViewSet 11 | from .stats import StatsAPIView 12 | from .telegram_bot import TelegramBotViewSet 13 | from .trigger import DiagramTriggerViewSet, TriggerViewSet 14 | from .user import UserViewSet 15 | from .variable import VariableViewSet 16 | 17 | __all__ = [ 18 | 'StatsAPIView', 19 | 'TelegramBotViewSet', 20 | 'ConnectionViewSet', 21 | 'TriggerViewSet', 22 | 'DiagramTriggerViewSet', 23 | 'MessageViewSet', 24 | 'DiagramMessageViewSet', 25 | 'ConditionViewSet', 26 | 'DiagramConditionViewSet', 27 | 'BackgroundTaskViewSet', 28 | 'DiagramBackgroundTaskViewSet', 29 | 'APIRequestViewSet', 30 | 'DiagramAPIRequestViewSet', 31 | 'DatabaseOperationViewSet', 32 | 'DiagramDatabaseOperationViewSet', 33 | 'VariableViewSet', 34 | 'UserViewSet', 35 | 'DatabaseRecordViewSet', 36 | ] 37 | -------------------------------------------------------------------------------- /users/utils/tests.py: -------------------------------------------------------------------------------- 1 | from rest_framework import status 2 | from rest_framework.request import Request 3 | from rest_framework.response import Response 4 | from rest_framework.test import force_authenticate 5 | 6 | from ..models import User 7 | 8 | from typing import TYPE_CHECKING, Any 9 | 10 | if TYPE_CHECKING: 11 | from rest_framework.views import AsView 12 | else: 13 | from typing import Generic, TypeVar 14 | 15 | T = TypeVar('T') 16 | 17 | class AsView(Generic[T]): ... 18 | 19 | 20 | def assert_view_basic_protected( 21 | view: AsView[Any], request: Request, token: Any, **view_kwargs: Any 22 | ) -> None: 23 | if TYPE_CHECKING: 24 | response: Response 25 | 26 | force_authenticate(request, None, None) 27 | 28 | response = view(request, **view_kwargs) 29 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 30 | 31 | force_authenticate(request, None, token) 32 | 33 | response = view(request, **view_kwargs) 34 | assert response.status_code == status.HTTP_403_FORBIDDEN 35 | 36 | 37 | def assert_view_requires_terms_acceptance( 38 | view: AsView[Any], request: Request, user: User, **view_kwargs: Any 39 | ) -> None: 40 | user.accepted_terms = False 41 | 42 | response: Response = view(request, **view_kwargs) 43 | assert response.status_code == status.HTTP_403_FORBIDDEN 44 | 45 | user.accepted_terms = True 46 | -------------------------------------------------------------------------------- /constructor_telegram_bots/fields.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from .validators import PublicURLValidator, StrictJSONValidator 4 | 5 | from collections.abc import Sequence 6 | from typing import Any, TypeVar 7 | 8 | _ST = TypeVar('_ST', contravariant=True) 9 | _GT = TypeVar('_GT', covariant=True) 10 | 11 | 12 | class PublicURLField(models.URLField[_ST, _GT]): 13 | default_validators = [PublicURLValidator()] 14 | 15 | 16 | class StrictJSONField(models.JSONField[_ST, _GT]): 17 | def __init__( 18 | self, 19 | *args: Any, 20 | max_light: int = 4096, 21 | allowed_types: tuple[type[dict[Any, Any]] | type[list[Any]], ...] = ( 22 | dict, 23 | list, 24 | ), 25 | **kwargs: Any, 26 | ) -> None: 27 | super().__init__(*args, **kwargs) 28 | self.max_length = max_light 29 | self.allowed_types = allowed_types 30 | self.validators.append(StrictJSONValidator(max_light, allowed_types)) 31 | 32 | def deconstruct(self) -> tuple[str, str, Sequence[Any], dict[str, Any]]: 33 | name, path, args, kwargs = super().deconstruct() 34 | 35 | if self.max_length == 4096: 36 | del kwargs['max_length'] 37 | 38 | if self.allowed_types != (dict, list): 39 | kwargs['allowed_types'] = self.allowed_types 40 | 41 | return name, path, args, kwargs 42 | -------------------------------------------------------------------------------- /donation/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from adminsortable2.admin import SortableAdminMixin 5 | from modeltranslation.admin import TranslationAdmin 6 | 7 | from constructor_telegram_bots.utils import format_html_link 8 | 9 | from .models import Donation, Method, Section 10 | 11 | 12 | @admin.register(Donation) 13 | class DonationAdmin(admin.ModelAdmin[Donation]): 14 | date_hierarchy = 'date' 15 | list_display = ['sum_display', 'sender', 'date'] 16 | fields = ['sum', 'sender', 'date'] 17 | 18 | @admin.display(description=_('Сумма'), ordering='sum') 19 | def sum_display(self, donation: Donation) -> str: 20 | return f'{donation.sum}€' 21 | 22 | 23 | @admin.register(Section) 24 | class SectionAdmin( 25 | SortableAdminMixin, 26 | TranslationAdmin, # FIXME: Need to add generics support 27 | ): 28 | list_display = ['title', 'position'] 29 | fields = ['title', 'text'] 30 | 31 | 32 | @admin.register(Method) 33 | class MethodAdmin(SortableAdminMixin, admin.ModelAdmin[Method]): 34 | list_display = ['text', 'link_display', 'value', 'position'] 35 | fields = ['text', 'link', 'value'] 36 | 37 | @admin.display(description=_('Ссылка'), ordering='link') 38 | def link_display(self, method: Method) -> str | None: 39 | return format_html_link(method.link) if method.link else None 40 | -------------------------------------------------------------------------------- /languages/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.decorators import method_decorator 3 | from django.views.decorators.cache import cache_page 4 | from django.views.decorators.vary import vary_on_cookie 5 | 6 | from rest_framework.request import Request 7 | from rest_framework.response import Response 8 | from rest_framework.views import APIView 9 | 10 | from .serializers import SetLanguageSerializer 11 | 12 | 13 | class LanguagesAPIView(APIView): 14 | authentication_classes = [] 15 | permission_classes = [] 16 | 17 | @method_decorator(cache_page(3600)) 18 | @method_decorator(vary_on_cookie) 19 | def get(self, request: Request) -> Response: 20 | return Response(dict(settings.LANGUAGES)) 21 | 22 | def post(self, request: Request) -> Response: 23 | serializer = SetLanguageSerializer(data=request.data) 24 | serializer.is_valid(raise_exception=True) 25 | 26 | response = Response() 27 | response.set_cookie( 28 | settings.LANGUAGE_COOKIE_NAME, 29 | serializer.validated_data['lang_code'], 30 | max_age=settings.LANGUAGE_COOKIE_AGE, 31 | path=settings.LANGUAGE_COOKIE_PATH, 32 | domain=settings.LANGUAGE_COOKIE_DOMAIN, 33 | secure=settings.LANGUAGE_COOKIE_SECURE, 34 | httponly=settings.LANGUAGE_COOKIE_HTTPONLY, 35 | samesite=settings.LANGUAGE_COOKIE_SAMESITE, 36 | ) 37 | 38 | return response 39 | -------------------------------------------------------------------------------- /donation/views.py: -------------------------------------------------------------------------------- 1 | from django.utils.decorators import method_decorator 2 | from django.views.decorators.cache import cache_page 3 | from django.views.decorators.vary import vary_on_cookie 4 | 5 | from rest_framework.mixins import ListModelMixin 6 | from rest_framework.viewsets import GenericViewSet 7 | 8 | from constructor_telegram_bots.pagination import LimitOffsetPagination 9 | 10 | from .models import Donation, Method, Section 11 | from .serializers import DonationSerializer, MethodSerializer, SectionSerializer 12 | 13 | 14 | @method_decorator(cache_page(3600), name='dispatch') 15 | class DonationViewSet(ListModelMixin, GenericViewSet[Donation]): 16 | authentication_classes = [] 17 | permission_classes = [] 18 | queryset = Donation.objects.all() 19 | serializer_class = DonationSerializer 20 | pagination_class = LimitOffsetPagination 21 | 22 | 23 | @method_decorator(cache_page(3600), name='dispatch') 24 | @method_decorator(vary_on_cookie, name='dispatch') 25 | class SectionViewSet(ListModelMixin, GenericViewSet[Section]): 26 | authentication_classes = [] 27 | permission_classes = [] 28 | queryset = Section.objects.all() 29 | serializer_class = SectionSerializer 30 | 31 | 32 | @method_decorator(cache_page(3600), name='dispatch') 33 | class MethodViewSet(ListModelMixin, GenericViewSet[Method]): 34 | authentication_classes = [] 35 | permission_classes = [] 36 | queryset = Method.objects.all() 37 | serializer_class = MethodSerializer 38 | -------------------------------------------------------------------------------- /users/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .jwt.tokens import RefreshToken 4 | from .models import User 5 | from .utils.auth import authenticate_token 6 | 7 | 8 | class UserSerializer(serializers.ModelSerializer[User]): 9 | class Meta: 10 | model = User 11 | fields = [ 12 | 'id', 13 | 'telegram_id', 14 | 'first_name', 15 | 'last_name', 16 | 'full_name', 17 | 'accepted_terms', 18 | 'is_staff', 19 | 'joined_date', 20 | ] 21 | 22 | 23 | class UserLoginSerializer(serializers.Serializer[User]): 24 | id = serializers.IntegerField() 25 | first_name = serializers.CharField(min_length=1, max_length=64) 26 | last_name = serializers.CharField(max_length=64, required=False) 27 | username = serializers.CharField(min_length=4, max_length=32, required=False) 28 | photo_url = serializers.URLField(required=False) 29 | auth_date = serializers.IntegerField() 30 | hash = serializers.CharField(min_length=64, max_length=64) 31 | 32 | 33 | class UserTokenRefreshSerializer(serializers.Serializer[User]): 34 | refresh_token = serializers.CharField() 35 | 36 | def validate_refresh_token(self, raw_refresh_token: str) -> RefreshToken: 37 | _, refresh_token = authenticate_token( 38 | raw_refresh_token, 39 | token_cls=RefreshToken, 40 | exception_cls=serializers.ValidationError, 41 | ) 42 | return refresh_token 43 | -------------------------------------------------------------------------------- /telegram_bots/serializers/database_record.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.translation import gettext as _ 3 | 4 | from rest_framework import serializers 5 | 6 | from ..models import DatabaseRecord 7 | from .mixins import TelegramBotMixin 8 | 9 | from typing import Any 10 | 11 | 12 | class DatabaseRecordSerializer( 13 | TelegramBotMixin, serializers.ModelSerializer[DatabaseRecord] 14 | ): 15 | class Meta: 16 | model = DatabaseRecord 17 | fields = ['id', 'data'] 18 | 19 | def validate(self, data: dict[str, Any]) -> dict[str, Any]: 20 | if ( 21 | not self.instance 22 | and self.telegram_bot.conditions.count() + 1 23 | > settings.TELEGRAM_BOT_MAX_DATABASE_RECORDS 24 | ): 25 | raise serializers.ValidationError( 26 | _('Нельзя добавлять больше %(max)s записей в базу данных.') 27 | % {'max': settings.TELEGRAM_BOT_MAX_DATABASE_RECORDS}, 28 | code='max_limit', 29 | ) 30 | 31 | return data 32 | 33 | def create(self, validated_data: dict[str, Any]) -> DatabaseRecord: 34 | return self.telegram_bot.database_records.create(**validated_data) 35 | 36 | def update( 37 | self, database_record: DatabaseRecord, validated_data: dict[str, Any] 38 | ) -> DatabaseRecord: 39 | database_record.data = validated_data.get('data', database_record.data) 40 | database_record.save(update_fields=['data']) 41 | 42 | return database_record 43 | -------------------------------------------------------------------------------- /telegram_bots/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .api_request import APIRequestViewSetTests, DiagramAPIRequestViewSetTests 2 | from .background_task import ( 3 | BackgroundTaskViewSetTests, 4 | DiagramBackgroundTaskViewSetTests, 5 | ) 6 | from .condition import ConditionViewSetTests, DiagramConditionViewSetTests 7 | from .connection import ConnectionViewSetTests 8 | from .database_operation import ( 9 | DatabaseOperationViewSetTests, 10 | DiagramDatabaseOperationViewSetTests, 11 | ) 12 | from .database_record import DatabaseRecordViewSetTests 13 | from .message import DiagramMessageViewSetTests, MessageViewSetTests 14 | from .stats import StatsAPIViewTests 15 | from .telegram_bot import TelegramBotViewSetTests 16 | from .trigger import DiagramTriggerViewSetTests, TriggerViewSetTests 17 | from .user import UserViewSetTests 18 | from .variable import VariableViewSetTests 19 | 20 | __all__ = [ 21 | 'StatsAPIViewTests', 22 | 'TelegramBotViewSetTests', 23 | 'ConnectionViewSetTests', 24 | 'MessageViewSetTests', 25 | 'DiagramMessageViewSetTests', 26 | 'ConditionViewSetTests', 27 | 'DiagramConditionViewSetTests', 28 | 'BackgroundTaskViewSetTests', 29 | 'DiagramBackgroundTaskViewSetTests', 30 | 'APIRequestViewSetTests', 31 | 'DiagramAPIRequestViewSetTests', 32 | 'DatabaseOperationViewSetTests', 33 | 'DiagramDatabaseOperationViewSetTests', 34 | 'TriggerViewSetTests', 35 | 'DiagramTriggerViewSetTests', 36 | 'VariableViewSetTests', 37 | 'UserViewSetTests', 38 | 'DatabaseRecordViewSetTests', 39 | ] 40 | -------------------------------------------------------------------------------- /users/migrations/0002_user_accepted_terms_user_terms_accepted_date.py: -------------------------------------------------------------------------------- 1 | # Initially generated by Django 5.0.6 on 2025-12-09 07:07 2 | 3 | from django.apps.registry import Apps 4 | from django.db import migrations, models 5 | from django.db.backends.base.schema import BaseDatabaseSchemaEditor 6 | 7 | from typing import TYPE_CHECKING, Any 8 | 9 | if TYPE_CHECKING: 10 | from telegram_bots.models import TelegramBot 11 | else: 12 | TelegramBot = Any 13 | 14 | 15 | def disable_telegram_bots(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: 16 | telegram_bot_model: type[TelegramBot] = apps.get_model( 17 | 'telegram_bots', 'TelegramBot' 18 | ) 19 | telegram_bot_model.objects.update(must_be_enabled=False) 20 | 21 | 22 | class Migration(migrations.Migration): 23 | dependencies = [('users', '0001_initial')] 24 | operations = [ 25 | migrations.AddField( 26 | model_name='user', 27 | name='accepted_terms', 28 | field=models.BooleanField( 29 | default=False, verbose_name='Принятие условий сервиса' 30 | ), 31 | ), 32 | migrations.AddField( 33 | model_name='user', 34 | name='terms_accepted_date', 35 | field=models.DateTimeField( 36 | blank=True, null=True, verbose_name='Дата принятия условий сервиса' 37 | ), 38 | ), 39 | migrations.RunPython( 40 | disable_telegram_bots, 41 | reverse_code=migrations.RunPython.noop, 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /constructor_telegram_bots/migrations.py: -------------------------------------------------------------------------------- 1 | from django.apps.registry import Apps 2 | from django.conf import settings 3 | from django.db import models 4 | from django.db.backends.base.schema import BaseDatabaseSchemaEditor 5 | 6 | from html2text import HTML2Text 7 | 8 | from collections.abc import Callable 9 | 10 | html2text = HTML2Text(bodywidth=0) 11 | 12 | 13 | def convert_html_to_markdown( 14 | app_label: str, model_name: str, fields: list[str] 15 | ) -> Callable[[Apps, BaseDatabaseSchemaEditor], None]: 16 | def wrapper(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: 17 | Model: type[models.Model] = apps.get_model(app_label, model_name) 18 | 19 | for instance in Model.objects.iterator(): # type: ignore [attr-defined] 20 | update_fields: list[str] = [] 21 | 22 | for field in fields: 23 | setattr(instance, field, html2text.handle(getattr(instance, field))) 24 | update_fields.append(field) 25 | 26 | for lang_code, _ in settings.LANGUAGES: 27 | lang_field: str = f'{field}_{lang_code}' 28 | 29 | if hasattr(instance, lang_field): 30 | setattr( 31 | instance, 32 | lang_field, 33 | html2text.handle(getattr(instance, lang_field)), 34 | ) 35 | update_fields.append(lang_field) 36 | 37 | instance.save(update_fields=update_fields) 38 | 39 | return wrapper 40 | -------------------------------------------------------------------------------- /terms_of_service/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2025-12-09 08:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Section', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('title', models.CharField(max_length=255, verbose_name='Заголовок')), 19 | ('title_en', models.CharField(max_length=255, null=True, verbose_name='Заголовок')), 20 | ('title_uk', models.CharField(max_length=255, null=True, verbose_name='Заголовок')), 21 | ('title_ru', models.CharField(max_length=255, null=True, verbose_name='Заголовок')), 22 | ('text', models.TextField(verbose_name='Текст')), 23 | ('text_en', models.TextField(null=True, verbose_name='Текст')), 24 | ('text_uk', models.TextField(null=True, verbose_name='Текст')), 25 | ('text_ru', models.TextField(null=True, verbose_name='Текст')), 26 | ('position', models.PositiveSmallIntegerField(blank=True, verbose_name='Позиция')), 27 | ], 28 | options={ 29 | 'verbose_name': 'Раздел', 30 | 'verbose_name_plural': 'Разделы', 31 | 'ordering': ['position'], 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /instruction/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.4 on 2024-04-29 10:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Section', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('title', models.CharField(max_length=255, verbose_name='Заголовок')), 19 | ('title_en', models.CharField(max_length=255, null=True, verbose_name='Заголовок')), 20 | ('title_uk', models.CharField(max_length=255, null=True, verbose_name='Заголовок')), 21 | ('title_ru', models.CharField(max_length=255, null=True, verbose_name='Заголовок')), 22 | ('text', models.TextField(verbose_name='Текст')), 23 | ('text_en', models.TextField(null=True, verbose_name='Текст')), 24 | ('text_uk', models.TextField(null=True, verbose_name='Текст')), 25 | ('text_ru', models.TextField(null=True, verbose_name='Текст')), 26 | ('position', models.PositiveSmallIntegerField(blank=True, default=0, verbose_name='Позиция')), 27 | ], 28 | options={ 29 | 'verbose_name': 'Раздел', 30 | 'verbose_name_plural': 'Разделы', 31 | 'ordering': ['position'], 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /privacy_policy/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.4 on 2024-04-29 10:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Section', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('title', models.CharField(max_length=255, verbose_name='Заголовок')), 19 | ('title_en', models.CharField(max_length=255, null=True, verbose_name='Заголовок')), 20 | ('title_uk', models.CharField(max_length=255, null=True, verbose_name='Заголовок')), 21 | ('title_ru', models.CharField(max_length=255, null=True, verbose_name='Заголовок')), 22 | ('text', models.TextField(verbose_name='Текст')), 23 | ('text_en', models.TextField(null=True, verbose_name='Текст')), 24 | ('text_uk', models.TextField(null=True, verbose_name='Текст')), 25 | ('text_ru', models.TextField(null=True, verbose_name='Текст')), 26 | ('position', models.PositiveSmallIntegerField(blank=True, default=0, verbose_name='Позиция')), 27 | ], 28 | options={ 29 | 'verbose_name': 'Раздел', 30 | 'verbose_name_plural': 'Разделы', 31 | 'ordering': ['position'], 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /telegram_bots/hub/service/adapters.py: -------------------------------------------------------------------------------- 1 | from urllib3.connection import HTTPConnection 2 | from urllib3.connectionpool import HTTPConnectionPool 3 | 4 | from requests import PreparedRequest 5 | from requests.adapters import HTTPAdapter 6 | from requests.compat import unquote, urlparse 7 | 8 | from collections.abc import Mapping 9 | from socket import AF_UNIX, SOCK_STREAM, socket 10 | 11 | 12 | class UnixHTTPConnection(HTTPConnection): 13 | def __init__(self, socket_path: str) -> None: 14 | super().__init__('localhost') 15 | self.socket_path = socket_path 16 | 17 | def connect(self) -> None: 18 | self.sock = socket(AF_UNIX, SOCK_STREAM) 19 | self.sock.connect(self.socket_path) 20 | 21 | 22 | class UnixHTTPConnectionPool(HTTPConnectionPool): 23 | def __init__(self, socket_path: str) -> None: 24 | super().__init__('localhost') 25 | self.socket_path = socket_path 26 | 27 | def _new_conn(self) -> UnixHTTPConnection: 28 | return UnixHTTPConnection(self.socket_path) 29 | 30 | 31 | class UnixHTTPAdapter(HTTPAdapter): 32 | def get_connection_with_tls_context( 33 | self, 34 | request: PreparedRequest, 35 | verify: bool | str | None, 36 | proxies: Mapping[str, str] | None = None, 37 | cert: tuple[str, str] | str | None = None, 38 | ) -> UnixHTTPConnectionPool: 39 | return UnixHTTPConnectionPool(unquote(urlparse(request.url).netloc)) 40 | 41 | def request_url(self, request: PreparedRequest, proxies: Mapping[str, str]) -> str: 42 | return request.path_url 43 | -------------------------------------------------------------------------------- /telegram_bots/migrations/0002_alter_backgroundtaskapirequest_url_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2025-02-04 21:15 2 | 3 | import constructor_telegram_bots.fields 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('telegram_bots', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='backgroundtaskapirequest', 16 | name='url', 17 | field=constructor_telegram_bots.fields.PublicURLField(verbose_name='URL-адрес'), 18 | ), 19 | migrations.AlterField( 20 | model_name='commandapirequest', 21 | name='url', 22 | field=constructor_telegram_bots.fields.PublicURLField(verbose_name='URL-адрес'), 23 | ), 24 | migrations.AlterField( 25 | model_name='commandfile', 26 | name='from_url', 27 | field=constructor_telegram_bots.fields.PublicURLField(blank=True, null=True, verbose_name='Из URL-адреса'), 28 | ), 29 | migrations.AlterField( 30 | model_name='commandimage', 31 | name='from_url', 32 | field=constructor_telegram_bots.fields.PublicURLField(blank=True, null=True, verbose_name='Из URL-адреса'), 33 | ), 34 | migrations.AlterField( 35 | model_name='commandkeyboardbutton', 36 | name='url', 37 | field=constructor_telegram_bots.fields.PublicURLField(blank=True, null=True, verbose_name='URL-адрес'), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /users/jwt/payloads.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | 3 | from ..enums import TokenType 4 | 5 | from calendar import timegm 6 | from dataclasses import asdict, dataclass 7 | from datetime import datetime 8 | from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar 9 | from uuid import uuid4 10 | 11 | if TYPE_CHECKING: 12 | from .tokens import BaseToken, RefreshToken 13 | else: 14 | T = TypeVar('T') 15 | 16 | class BaseToken(Generic[T]): ... 17 | 18 | RefreshToken = Any 19 | 20 | 21 | @dataclass 22 | class TokenPayload: 23 | jti: str 24 | typ: TokenType 25 | sub: str 26 | exp: int 27 | iat: int 28 | 29 | @classmethod 30 | def create(cls, token_cls: BaseToken[Any], sub: str, **kwargs: Any) -> Self: 31 | current_date: datetime = timezone.now() 32 | 33 | return cls( 34 | jti=uuid4().hex, 35 | typ=token_cls._type, 36 | sub=sub, 37 | exp=timegm((current_date + token_cls.lifetime).utctimetuple()), 38 | iat=timegm(current_date.utctimetuple()), 39 | **kwargs, 40 | ) 41 | 42 | def to_dict(self) -> dict[str, Any]: 43 | return asdict(self) 44 | 45 | 46 | @dataclass 47 | class AccessTokenPayload(TokenPayload): 48 | refresh_jti: str 49 | 50 | @classmethod 51 | def create( # type: ignore [override] 52 | cls, token_cls: BaseToken[Any], refresh_token: RefreshToken 53 | ) -> Self: 54 | return super().create( 55 | token_cls, refresh_token.payload.sub, refresh_jti=refresh_token.payload.jti 56 | ) 57 | -------------------------------------------------------------------------------- /telegram_bots/serializers/variable.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.translation import gettext as _ 3 | 4 | from rest_framework import serializers 5 | 6 | from ..models import Variable 7 | from .mixins import TelegramBotMixin 8 | 9 | from typing import Any 10 | 11 | 12 | class VariableSerializer(TelegramBotMixin, serializers.ModelSerializer[Variable]): 13 | class Meta: 14 | model = Variable 15 | fields = ['id', 'name', 'value', 'description'] 16 | 17 | def validate(self, data: dict[str, Any]) -> dict[str, Any]: 18 | if ( 19 | not self.instance 20 | and self.telegram_bot.variables.count() + 1 21 | > settings.TELEGRAM_BOT_MAX_VARIABLES 22 | ): 23 | raise serializers.ValidationError( 24 | _('Нельзя добавлять больше %(max)s переменных.') 25 | % {'max': settings.TELEGRAM_BOT_MAX_VARIABLES}, 26 | code='max_limit', 27 | ) 28 | 29 | return data 30 | 31 | def create(self, validated_data: dict[str, Any]) -> Variable: 32 | return self.telegram_bot.variables.create(**validated_data) 33 | 34 | def update(self, variable: Variable, validated_data: dict[str, Any]) -> Variable: 35 | variable.name = validated_data.get('name', variable.name) 36 | variable.value = validated_data.get('value', variable.value) 37 | variable.description = validated_data.get('description', variable.description) 38 | variable.save(update_fields=['name', 'value', 'description']) 39 | 40 | return variable 41 | -------------------------------------------------------------------------------- /telegram_bots/migrations/0009_alter_apirequest_body_alter_apirequest_headers_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2025-09-03 21:26 2 | 3 | import constructor_telegram_bots.fields 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('telegram_bots', '0008_remove_connection_telegram_bo_source__bb2efb_idx_and_more'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='apirequest', 16 | name='body', 17 | field=constructor_telegram_bots.fields.StrictJSONField(blank=True, null=True, verbose_name='Данные'), 18 | ), 19 | migrations.AlterField( 20 | model_name='apirequest', 21 | name='headers', 22 | field=constructor_telegram_bots.fields.StrictJSONField(allowed_types=(dict,), blank=True, null=True, verbose_name='Заголовки'), 23 | ), 24 | migrations.AlterField( 25 | model_name='databasecreateoperation', 26 | name='data', 27 | field=constructor_telegram_bots.fields.StrictJSONField(verbose_name='Данные'), 28 | ), 29 | migrations.AlterField( 30 | model_name='databaserecord', 31 | name='data', 32 | field=constructor_telegram_bots.fields.StrictJSONField(verbose_name='Данные'), 33 | ), 34 | migrations.AlterField( 35 | model_name='databaseupdateoperation', 36 | name='new_data', 37 | field=constructor_telegram_bots.fields.StrictJSONField(verbose_name='Новые данные'), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /users/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.db.models import Count 3 | from django.db.models.query import QuerySet 4 | from django.http import HttpRequest 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from .models import User 8 | 9 | 10 | @admin.register(User) 11 | class UserAdmin(admin.ModelAdmin[User]): 12 | date_hierarchy = 'joined_date' 13 | search_fields = ['telegram_id', 'first_name', 'last_name'] 14 | list_filter = ['is_staff', 'last_login', 'joined_date'] 15 | list_display = [ 16 | 'id', 17 | 'telegram_id', 18 | 'first_name', 19 | 'last_name', 20 | 'telegram_bot_count', 21 | 'is_staff', 22 | 'last_login', 23 | 'joined_date', 24 | ] 25 | fields = [ 26 | 'id', 27 | 'telegram_id', 28 | 'first_name', 29 | 'last_name', 30 | 'telegram_bot_count', 31 | 'groups', 32 | 'is_staff', 33 | 'last_login', 34 | 'joined_date', 35 | ] 36 | readonly_fields = [ 37 | 'id', 38 | 'telegram_id', 39 | 'first_name', 40 | 'last_name', 41 | 'telegram_bot_count', 42 | 'last_login', 43 | 'joined_date', 44 | ] 45 | 46 | def get_queryset(self, request: HttpRequest) -> QuerySet[User]: 47 | return ( 48 | super() 49 | .get_queryset(request) 50 | .annotate(telegram_bot_count=Count('telegram_bots')) 51 | ) 52 | 53 | @admin.display(description=_('Telegram ботов'), ordering='telegram_bot_count') 54 | def telegram_bot_count(self, user: User) -> int: 55 | return user.telegram_bots.count() 56 | -------------------------------------------------------------------------------- /telegram_bots/hub/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | from .api_request import APIRequestSerializer 2 | from .background_task import BackgroundTaskSerializer 3 | from .condition import ( 4 | ConditionPartSerializer, 5 | ConditionSerializer, 6 | ) 7 | from .connection import ConnectionSerializer 8 | from .database_operation import ( 9 | DatabaseCreateOperationSerializer, 10 | DatabaseOperationSerializer, 11 | DatabaseUpdateOperationSerializer, 12 | ) 13 | from .database_record import DatabaseRecordSerializer 14 | from .message import ( 15 | MessageDocumentSerializer, 16 | MessageImageSerializer, 17 | MessageKeyboardButtonSerializer, 18 | MessageKeyboardSerializer, 19 | MessageSerializer, 20 | MessageSettingsSerializer, 21 | ) 22 | from .telegram_bot import TelegramBotSerializer 23 | from .trigger import ( 24 | TriggerCommandSerializer, 25 | TriggerMessageSerializer, 26 | TriggerSerializer, 27 | ) 28 | from .user import UserSerializer 29 | from .variable import VariableSerializer 30 | 31 | __all__ = [ 32 | 'TelegramBotSerializer', 33 | 'ConnectionSerializer', 34 | 'TriggerSerializer', 35 | 'TriggerCommandSerializer', 36 | 'TriggerMessageSerializer', 37 | 'MessageSerializer', 38 | 'MessageSettingsSerializer', 39 | 'MessageImageSerializer', 40 | 'MessageDocumentSerializer', 41 | 'MessageKeyboardSerializer', 42 | 'MessageKeyboardButtonSerializer', 43 | 'ConditionSerializer', 44 | 'ConditionPartSerializer', 45 | 'BackgroundTaskSerializer', 46 | 'APIRequestSerializer', 47 | 'DatabaseOperationSerializer', 48 | 'DatabaseCreateOperationSerializer', 49 | 'DatabaseUpdateOperationSerializer', 50 | 'VariableSerializer', 51 | 'UserSerializer', 52 | 'DatabaseRecordSerializer', 53 | ] 54 | -------------------------------------------------------------------------------- /telegram_bots/migrations/0003_rename_file_command_and_more.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | import django.db.models.deletion 3 | 4 | import telegram_bots.models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ('telegram_bots', '0002_alter_backgroundtaskapirequest_url_and_more') 10 | ] 11 | operations = [ 12 | migrations.RenameField( 13 | model_name='commandimage', old_name='image', new_name='file' 14 | ), 15 | migrations.RenameModel(old_name='CommandFile', new_name='CommandDocument'), 16 | migrations.AlterModelTable( 17 | name='commanddocument', table='telegram_bot_command_document' 18 | ), 19 | migrations.AlterField( 20 | model_name='commanddocument', 21 | name='command', 22 | field=models.ForeignKey( 23 | on_delete=django.db.models.deletion.CASCADE, 24 | related_name='documents', 25 | to='telegram_bots.command', 26 | verbose_name='Команда', 27 | ), 28 | ), 29 | migrations.AlterField( 30 | model_name='commanddocument', 31 | name='file', 32 | field=models.FileField( 33 | blank=True, 34 | max_length=500, 35 | null=True, 36 | upload_to=telegram_bots.models.message.upload_message_media_path, 37 | verbose_name='Документ', 38 | ), 39 | ), 40 | migrations.AlterModelOptions( 41 | name='commanddocument', 42 | options={ 43 | 'verbose_name': 'Документ команды', 44 | 'verbose_name_plural': 'Документы команд', 45 | }, 46 | ), 47 | ] 48 | -------------------------------------------------------------------------------- /donation/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from django_stubs_ext.db.models import TypedModelMeta 5 | 6 | from constructor_telegram_bots.fields import PublicURLField 7 | 8 | 9 | class Donation(models.Model): 10 | sum = models.FloatField(_('Сумма')) 11 | sender = models.CharField(_('Отправитель'), max_length=64) 12 | date = models.DateField(_('Дата')) 13 | 14 | class Meta(TypedModelMeta): 15 | db_table = 'donation' 16 | ordering = ['-sum'] 17 | verbose_name = _('Пожертвование') 18 | verbose_name_plural = _('Пожертвования') 19 | 20 | def __str__(self) -> str: 21 | return self.sender 22 | 23 | 24 | class Section(models.Model): # type: ignore [django-manager-missing] 25 | title = models.CharField(_('Заголовок'), max_length=255) 26 | text = models.TextField(_('Текст')) 27 | position = models.PositiveSmallIntegerField(_('Позиция'), blank=True) 28 | 29 | class Meta(TypedModelMeta): 30 | ordering = ['position'] 31 | verbose_name = _('Раздел') 32 | verbose_name_plural = _('Разделы') 33 | 34 | def __str__(self) -> str: 35 | return self.title 36 | 37 | 38 | class Method(models.Model): 39 | text = models.CharField(_('Текст'), max_length=128) 40 | link = PublicURLField(_('Ссылка'), blank=True, null=True) 41 | value = models.CharField(_('Значение'), max_length=255, blank=True, null=True) 42 | position = models.PositiveSmallIntegerField(_('Позиция'), blank=True) 43 | 44 | class Meta(TypedModelMeta): 45 | ordering = ['position'] 46 | verbose_name = _('Метод поддержки') 47 | verbose_name_plural = _('Методы поддержки') 48 | 49 | def __str__(self) -> str: 50 | return self.text 51 | -------------------------------------------------------------------------------- /telegram_bots/hub/service/api.py: -------------------------------------------------------------------------------- 1 | from yarl import URL 2 | 3 | from .adapters import UnixHTTPAdapter 4 | from .schemas import StartTelegramBot 5 | 6 | from requests import RequestException, Response, Session 7 | import requests 8 | 9 | from typing import Any, Literal 10 | 11 | 12 | class API: 13 | def __init__(self, url: str, access_token: str) -> None: 14 | self.url = URL(url) 15 | self.headers = {'X-API-KEY': access_token} 16 | 17 | self.session = Session() 18 | self.session.mount('http+unix://', UnixHTTPAdapter()) 19 | 20 | def _request( 21 | self, 22 | method: Literal['get', 'post', 'patch', 'put', 'delete'], 23 | endpoint: str, 24 | data: Any | None = None, 25 | ) -> Response | None: 26 | try: 27 | response: Response = requests.request( 28 | method, 29 | str(self.url / endpoint), 30 | headers=self.headers, 31 | json=data, 32 | ) 33 | response.raise_for_status() 34 | 35 | return response 36 | except RequestException: 37 | return None 38 | 39 | def get_telegram_bot_ids(self) -> list[int]: 40 | response: Response | None = self._request('get', 'bots/') 41 | return response.json() if response else [] 42 | 43 | def start_telegram_bot(self, telegram_bot_id: int, data: StartTelegramBot) -> bool: 44 | return ( 45 | self._request('post', f'bots/{telegram_bot_id}/start/', data=data) 46 | is not None 47 | ) 48 | 49 | def restart_telegram_bot(self, telegram_bot_id: int) -> bool: 50 | return self._request('post', f'bots/{telegram_bot_id}/restart/') is not None 51 | 52 | def stop_telegram_bot(self, telegram_bot_id: int) -> bool: 53 | return self._request('post', f'bots/{telegram_bot_id}/stop/') is not None 54 | -------------------------------------------------------------------------------- /telegram_bots/views/telegram_bot.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.decorators import action 4 | from rest_framework.permissions import IsAuthenticated 5 | from rest_framework.request import Request 6 | from rest_framework.response import Response 7 | from rest_framework.viewsets import ModelViewSet 8 | 9 | from constructor_telegram_bots.mixins import IDLookupMixin 10 | from constructor_telegram_bots.permissions import ReadOnly 11 | from users.authentication import JWTAuthentication 12 | from users.permissions import IsTermsAccepted 13 | 14 | from ..models import TelegramBot 15 | from ..serializers import TelegramBotSerializer 16 | 17 | 18 | class TelegramBotViewSet(IDLookupMixin, ModelViewSet[TelegramBot]): 19 | authentication_classes = [JWTAuthentication] 20 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 21 | serializer_class = TelegramBotSerializer 22 | 23 | def get_queryset(self) -> QuerySet[TelegramBot]: 24 | return self.request.user.telegram_bots.all() # type: ignore [union-attr] 25 | 26 | @action(detail=True, methods=['POST']) 27 | def start(self, request: Request, id: int) -> Response: 28 | telegram_bot: TelegramBot = self.get_object() 29 | telegram_bot.start() 30 | 31 | return Response(self.get_serializer(telegram_bot).data) 32 | 33 | @action(detail=True, methods=['POST']) 34 | def restart(self, request: Request, id: int) -> Response: 35 | telegram_bot: TelegramBot = self.get_object() 36 | telegram_bot.restart() 37 | 38 | return Response(self.get_serializer(telegram_bot).data) 39 | 40 | @action(detail=True, methods=['POST']) 41 | def stop(self, request: Request, id: int) -> Response: 42 | telegram_bot: TelegramBot = self.get_object() 43 | telegram_bot.stop() 44 | 45 | return Response(self.get_serializer(telegram_bot).data) 46 | -------------------------------------------------------------------------------- /telegram_bots/views/trigger.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.viewsets import ModelViewSet 5 | 6 | from constructor_telegram_bots.mixins import IDLookupMixin 7 | from constructor_telegram_bots.permissions import ReadOnly 8 | from users.authentication import JWTAuthentication 9 | from users.permissions import IsTermsAccepted 10 | 11 | from ..models import Trigger 12 | from ..serializers import DiagramTriggerSerializer, TriggerSerializer 13 | from .mixins import TelegramBotMixin 14 | 15 | 16 | class TriggerViewSet(IDLookupMixin, TelegramBotMixin, ModelViewSet[Trigger]): 17 | authentication_classes = [JWTAuthentication] 18 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 19 | serializer_class = TriggerSerializer 20 | 21 | def get_queryset(self) -> QuerySet[Trigger]: 22 | triggers: QuerySet[Trigger] = self.telegram_bot.triggers.all() 23 | 24 | if self.action in ['list', 'retrieve']: 25 | return triggers.select_related('command', 'message') 26 | 27 | return triggers 28 | 29 | 30 | class DiagramTriggerViewSet(IDLookupMixin, TelegramBotMixin, ModelViewSet[Trigger]): 31 | authentication_classes = [JWTAuthentication] 32 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 33 | serializer_class = DiagramTriggerSerializer 34 | 35 | def get_queryset(self) -> QuerySet[Trigger]: 36 | triggers: QuerySet[Trigger] = self.telegram_bot.triggers.all() 37 | 38 | if self.action in ['list', 'retrieve']: 39 | return triggers.prefetch_related( 40 | 'source_connections__source_object', 41 | 'source_connections__target_object', 42 | 'target_connections__source_object', 43 | 'target_connections__target_object', 44 | ) 45 | 46 | return triggers 47 | -------------------------------------------------------------------------------- /constructor_telegram_bots/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.urls import URLPattern, URLResolver, include, path, re_path 4 | from django.views.generic import RedirectView, TemplateView 5 | 6 | import django_stubs_ext 7 | 8 | from rest_framework.generics import GenericAPIView 9 | 10 | django_stubs_ext.monkeypatch(extra_classes=[GenericAPIView]) 11 | 12 | 13 | urlpatterns: list[URLPattern | URLResolver] = [ 14 | path('admin/login/', RedirectView.as_view(url='/')), 15 | path('admin/', admin.site.urls), 16 | path( 17 | 'api/', 18 | include( 19 | ( 20 | [ 21 | path('languages/', include('languages.urls')), 22 | path('users/', include('users.urls')), 23 | path('telegram-bots/', include('telegram_bots.urls')), 24 | path( 25 | 'telegram-bots-hub/telegram-bots/', 26 | include('telegram_bots.hub.urls'), 27 | ), 28 | path('donation/', include('donation.urls')), 29 | path('instruction/', include('instruction.urls')), 30 | path('privacy-policy/', include('privacy_policy.urls')), 31 | path('terms-of-service/', include('terms_of_service.urls')), 32 | ], 33 | 'api', 34 | ) 35 | ), 36 | ), 37 | ] 38 | 39 | if not settings.TEST and settings.DEBUG: 40 | from django.conf.urls.static import static 41 | 42 | urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 43 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 44 | 45 | urlpatterns.append(path('silk/', include('silk.urls', namespace='silk'))) 46 | 47 | urlpatterns.append( 48 | re_path(r'^.*', TemplateView.as_view(template_name='frontend/index.html')) 49 | ) 50 | -------------------------------------------------------------------------------- /telegram_bots/models/connection.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.fields import GenericForeignKey 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.db import models 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from django_stubs_ext.db.models import TypedModelMeta 7 | 8 | from ..enums import ConnectionHandlePosition 9 | 10 | 11 | class Connection(models.Model): 12 | telegram_bot = models.ForeignKey( 13 | 'TelegramBot', 14 | on_delete=models.CASCADE, 15 | related_name='connections', 16 | verbose_name=_('Telegram бот'), 17 | ) 18 | 19 | source_content_type = models.ForeignKey( 20 | ContentType, on_delete=models.CASCADE, related_name='source_connections' 21 | ) 22 | source_object_id = models.PositiveBigIntegerField() 23 | source_object = GenericForeignKey('source_content_type', 'source_object_id') 24 | source_handle_position = models.CharField( 25 | _('Стартовая позиция коннектора'), 26 | max_length=5, 27 | choices=ConnectionHandlePosition, 28 | ) 29 | 30 | target_content_type = models.ForeignKey( 31 | ContentType, on_delete=models.CASCADE, related_name='target_connections' 32 | ) 33 | target_object_id = models.PositiveBigIntegerField() 34 | target_object = GenericForeignKey('target_content_type', 'target_object_id') 35 | target_handle_position = models.CharField( 36 | _('Окончательная позиция коннектора'), 37 | max_length=5, 38 | choices=ConnectionHandlePosition, 39 | ) 40 | 41 | class Meta(TypedModelMeta): 42 | db_table = 'telegram_bot_block_connection' 43 | indexes = [ 44 | models.Index(fields=['source_content_type', 'source_object_id']), 45 | models.Index(fields=['target_content_type', 'target_object_id']), 46 | ] 47 | verbose_name = _('Подключение') 48 | verbose_name_plural = _('Подключения') 49 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | env: 12 | SECRET_KEY: test_secret_key 13 | TELEGRAM_BOT_TOKEN: 12345:test 14 | FRONTEND_PATH: '' 15 | POSTGRESQL_DATABASE_NAME: postgres 16 | POSTGRESQL_DATABASE_USER: postgres 17 | POSTGRESQL_DATABASE_PASSWORD: postgres 18 | 19 | jobs: 20 | code-quality: 21 | name: Code Quality Checks 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Check out repository 25 | uses: actions/checkout@v4 26 | 27 | - name: Install poetry 28 | run: pipx install poetry 29 | 30 | - name: Set up Python 31 | uses: actions/setup-python@v5 32 | with: 33 | python-version: 3.11 34 | cache: poetry 35 | 36 | - name: Install dependencies 37 | run: poetry install --no-interaction 38 | 39 | - name: Run ruff 40 | run: poetry run ruff check . && poetry run ruff format --check . 41 | 42 | - name: Run mypy 43 | run: poetry run mypy . 44 | tests: 45 | name: Run Tests 46 | needs: code-quality 47 | runs-on: ubuntu-latest 48 | services: 49 | postgres: 50 | image: postgres 51 | env: 52 | POSTGRES_PASSWORD: postgres 53 | ports: 54 | - 5432:5432 55 | redis: 56 | image: redis 57 | ports: 58 | - 6379:6379 59 | steps: 60 | - name: Check out repository 61 | uses: actions/checkout@v4 62 | 63 | - name: Install poetry 64 | run: pipx install poetry 65 | 66 | - name: Set up Python 67 | uses: actions/setup-python@v5 68 | with: 69 | python-version: 3.11 70 | cache: poetry 71 | 72 | - name: Install dependencies 73 | run: poetry install --no-interaction 74 | 75 | - name: Run tests 76 | run: poetry run python manage.py test --verbosity 3 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | Thank you for considering contributing!
3 | We appreciate your help in making this project better. 4 | 5 | ## Installing 6 | Deploy the project by following the "[Installing](https://github.com/EXG1O/Constructor-Telegram-Bots#installing)" section.
7 | However, depending on your needs, you can skip certain steps: 8 | - **Frontend Deployment**. If you're working only on the backend and don't need the UI, you can skip steps 2 – 4 (inclusive). 9 | - **Microservice Deployment**. If you are going to work on the backend part that is not related to the microservice for Telegram bots, you can skip steps 5 – 8 (inclusive). 10 | 11 | ## Code Formatting and Linting 12 | To maintain a consistent code style, we use **ruff** as code formatter and linter, and **mypy** for type checking. 13 | 14 | ### ruff 15 | To format your code, run the following command: 16 | ```bash 17 | ruff format . 18 | ``` 19 | 20 | To check your code for linting issues, run the following command: 21 | ```bash 22 | ruff check . 23 | ``` 24 | This will list any issues that need to be addressed. 25 | 26 | To auto-fix these issues, run the following command: 27 | ```bash 28 | ruff check --fix . 29 | ``` 30 | 31 | ### mypy 32 | To ensure that your code passes type checking, run the following command: 33 | ```bash 34 | mypy . 35 | ``` 36 | 37 | ## Testing 38 | We prioritize code quality and early bug detection through tests. To run the tests, use the following command: 39 | ```bash 40 | python manage.py test 41 | ``` 42 | If your changes require new tests, please add them to ensure complete coverage. 43 | 44 | ## Logs 45 | All log files can be found in the `./logs` directory. 46 | 47 | ## Translations 48 | If you'd like to contribute by improving translations, you can find all locale files in the `./locale` directory. 49 | 50 | ## Pull Requests 51 | When submitting a PR, please ensure that: 52 | 1. Your code follows the project's coding standards. 53 | 2. All tests pass successfully. 54 | 3. Your changes are well-documented. 55 | -------------------------------------------------------------------------------- /users/utils/auth.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import login as auth_login 2 | from django.contrib.auth import logout as auth_logout 3 | from django.http import HttpRequest 4 | from django.utils.translation import gettext as _ 5 | 6 | from rest_framework.exceptions import APIException 7 | 8 | from jwt import PyJWTError 9 | 10 | from ..enums import TokenType 11 | from ..jwt.tokens import AccessToken, RefreshToken 12 | from ..models import BlacklistedToken, Token, User 13 | 14 | from typing import TypeVar 15 | 16 | JWT = TypeVar('JWT', RefreshToken, AccessToken) 17 | 18 | 19 | def authenticate_token( 20 | raw_token: str, token_cls: type[JWT], exception_cls: type[APIException] 21 | ) -> tuple[User, JWT]: 22 | try: 23 | token = token_cls(token=raw_token) 24 | except PyJWTError as error: 25 | raise exception_cls(_('Недействительный токен.')) from error 26 | 27 | if token.is_blacklisted: 28 | raise exception_cls(_('Токен в чёрном списке.')) 29 | 30 | user: User | None = token.user 31 | 32 | if not user or not user.is_active: 33 | raise exception_cls(_('Пользователь неактивен или удалён.')) 34 | 35 | return user, token 36 | 37 | 38 | def login(request: HttpRequest, user: User) -> RefreshToken: 39 | auth_login(request, user) 40 | return RefreshToken.for_user(user) 41 | 42 | 43 | def logout(request: HttpRequest, jwt_token: RefreshToken | AccessToken) -> None: 44 | auth_logout(request) 45 | 46 | if isinstance(jwt_token, RefreshToken): 47 | jwt_token.to_blacklist() 48 | return 49 | 50 | token: Token = Token.objects.get( 51 | jti=jwt_token.payload.refresh_jti, type=TokenType.REFRESH 52 | ) 53 | BlacklistedToken.objects.create(token=token) 54 | 55 | 56 | def logout_all(request: HttpRequest, user: User) -> None: 57 | auth_logout(request) 58 | BlacklistedToken.objects.bulk_create( 59 | BlacklistedToken(token=token) 60 | for token in Token.objects.filter(user=user).exclude(blacklisted__isnull=False) 61 | ) 62 | -------------------------------------------------------------------------------- /telegram_bots/models/condition.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from django_stubs_ext.db.models import TypedModelMeta 5 | 6 | from ..enums import ( 7 | ConditionPartNextPartOperator, 8 | ConditionPartOperatorType, 9 | ConditionPartType, 10 | ) 11 | from .base import AbstractBlock 12 | 13 | from typing import TYPE_CHECKING 14 | 15 | 16 | class ConditionPart(models.Model): 17 | condition = models.ForeignKey( 18 | 'Condition', 19 | on_delete=models.CASCADE, 20 | related_name='parts', 21 | verbose_name=_('Условие'), 22 | ) 23 | type = models.CharField(_('Тип'), max_length=1, choices=ConditionPartType) 24 | first_value = models.CharField(_('Первое значение'), max_length=255) 25 | operator = models.CharField( 26 | _('Оператор'), max_length=2, choices=ConditionPartOperatorType 27 | ) 28 | second_value = models.CharField(_('Второе значение'), max_length=255) 29 | next_part_operator = models.CharField( 30 | _('Оператор для следующей части'), 31 | max_length=2, 32 | choices=ConditionPartNextPartOperator, 33 | blank=True, 34 | null=True, 35 | ) 36 | 37 | class Meta(TypedModelMeta): 38 | db_table = 'telegram_bot_condition_part' 39 | verbose_name = _('Часть условия') 40 | verbose_name_plural = _('Части условий') 41 | 42 | def __str__(self) -> str: 43 | return self.condition.name 44 | 45 | 46 | class Condition(AbstractBlock): 47 | telegram_bot = models.ForeignKey( 48 | 'TelegramBot', 49 | on_delete=models.CASCADE, 50 | related_name='conditions', 51 | verbose_name=_('Telegram бот'), 52 | ) 53 | 54 | if TYPE_CHECKING: 55 | parts: models.Manager[ConditionPart] 56 | 57 | class Meta(TypedModelMeta): 58 | db_table = 'telegram_bot_condition' 59 | verbose_name = _('Условие') 60 | verbose_name_plural = _('Условия') 61 | 62 | def __str__(self) -> str: 63 | return self.name 64 | -------------------------------------------------------------------------------- /telegram_bots/hub/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import SimpleRouter 2 | 3 | from .views import ( 4 | APIRequestViewSet, 5 | BackgroundTaskViewSet, 6 | ConditionViewSet, 7 | DatabaseOperationViewSet, 8 | DatabaseRecordViewSet, 9 | MessageKeyboardButtonViewSet, 10 | MessageViewSet, 11 | TelegramBotViewSet, 12 | TriggerViewSet, 13 | UserViewSet, 14 | VariableViewSet, 15 | ) 16 | 17 | base_path: str = '' 18 | base_name: str = 'telegram-bot' 19 | 20 | router = SimpleRouter(use_regex_path=False) 21 | router.register('', TelegramBotViewSet, basename=base_name) 22 | router.register( 23 | f'{base_path}/triggers', 24 | TriggerViewSet, 25 | basename=f'{base_name}-trigger', 26 | ) 27 | router.register( 28 | f'{base_path}/messages', 29 | MessageViewSet, 30 | basename=f'{base_name}-message', 31 | ) 32 | router.register( 33 | f'{base_path}/messages-keyboard-buttons', 34 | MessageKeyboardButtonViewSet, 35 | basename=f'{base_name}-messages-keyboard-button', 36 | ) 37 | router.register( 38 | f'{base_path}/conditions', 39 | ConditionViewSet, 40 | basename=f'{base_name}-condition', 41 | ) 42 | router.register( 43 | f'{base_path}/background-tasks', 44 | BackgroundTaskViewSet, 45 | basename=f'{base_name}-background-task', 46 | ) 47 | router.register( 48 | f'{base_path}/api-requests', 49 | APIRequestViewSet, 50 | basename=f'{base_name}-api-request', 51 | ) 52 | router.register( 53 | f'{base_path}/database-operations', 54 | DatabaseOperationViewSet, 55 | basename=f'{base_name}-database-operation', 56 | ) 57 | router.register( 58 | f'{base_path}/variables', 59 | VariableViewSet, 60 | basename=f'{base_name}-variable', 61 | ) 62 | router.register( 63 | f'{base_path}/users', 64 | UserViewSet, 65 | basename=f'{base_name}-user', 66 | ) 67 | router.register( 68 | f'{base_path}/database-records', 69 | DatabaseRecordViewSet, 70 | basename=f'{base_name}-database-record', 71 | ) 72 | 73 | app_name = 'telegram-bots-hub' 74 | urlpatterns = router.urls 75 | -------------------------------------------------------------------------------- /telegram_bots/serializers/base.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from ..models.base import AbstractBlock, AbstractMessageMedia 4 | 5 | from typing import Any, TypeVar 6 | import os 7 | 8 | ABT = TypeVar('ABT', bound=AbstractBlock) 9 | AMMT = TypeVar('AMMT', bound=AbstractMessageMedia) 10 | 11 | 12 | class DiagramSerializer(serializers.ModelSerializer[ABT]): 13 | class Meta: 14 | fields = ['x', 'y'] 15 | 16 | def update( 17 | self, 18 | instance: ABT, 19 | validated_data: dict[str, Any], 20 | update_fields: list[str] = [], # noqa: B006 21 | ) -> ABT: 22 | instance.x = validated_data.get('x', instance.x) 23 | instance.y = validated_data.get('y', instance.y) 24 | instance.save(update_fields=update_fields + ['x', 'y']) 25 | 26 | return instance 27 | 28 | 29 | class MessageMediaSerializer(serializers.ModelSerializer[AMMT]): 30 | name = serializers.CharField(source='file.name', read_only=True, allow_null=True) 31 | size = serializers.IntegerField(source='file.size', read_only=True, allow_null=True) 32 | url = serializers.URLField(source='file.url', read_only=True, allow_null=True) 33 | 34 | class Meta: 35 | fields = ['id', 'file', 'name', 'size', 'url', 'from_url', 'position'] 36 | extra_kwargs = { 37 | 'id': {'read_only': False, 'required': False}, 38 | 'file': { 39 | 'write_only': True, 40 | 'required': False, 41 | 'allow_null': True, 42 | }, 43 | } 44 | 45 | def process_name(self, base_name: str) -> str: 46 | name, ext = os.path.splitext(os.path.basename(base_name)) 47 | return '_'.join(name.split('_')[:-1]) + ext 48 | 49 | def to_representation(self, instance: AMMT) -> dict[str, Any]: 50 | representation: dict[str, Any] = super().to_representation(instance) 51 | 52 | name: str | None = representation.get('name') 53 | 54 | if name: 55 | representation['name'] = self.process_name(name) 56 | 57 | return representation 58 | -------------------------------------------------------------------------------- /telegram_bots/hub/serializers/message.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from ...models import ( 4 | Message, 5 | MessageDocument, 6 | MessageImage, 7 | MessageKeyboard, 8 | MessageKeyboardButton, 9 | MessageSettings, 10 | ) 11 | from ...serializers.base import MessageMediaSerializer 12 | from .connection import ConnectionSerializer 13 | 14 | 15 | class MessageSettingsSerializer(serializers.ModelSerializer[MessageSettings]): 16 | class Meta: 17 | model = MessageSettings 18 | fields = ['reply_to_user_message', 'delete_user_message', 'send_as_new_message'] 19 | 20 | 21 | class MessageImageSerializer(MessageMediaSerializer[MessageImage]): 22 | class Meta(MessageMediaSerializer.Meta): 23 | model = MessageImage 24 | 25 | 26 | class MessageDocumentSerializer(MessageMediaSerializer[MessageDocument]): 27 | class Meta(MessageMediaSerializer.Meta): 28 | model = MessageDocument 29 | 30 | 31 | class MessageKeyboardButtonSerializer( 32 | serializers.ModelSerializer[MessageKeyboardButton] 33 | ): 34 | source_connections = ConnectionSerializer(many=True) 35 | 36 | class Meta: 37 | model = MessageKeyboardButton 38 | fields = ['id', 'row', 'position', 'text', 'url', 'source_connections'] 39 | 40 | 41 | class MessageKeyboardSerializer(serializers.ModelSerializer[MessageKeyboard]): 42 | buttons = MessageKeyboardButtonSerializer(many=True) 43 | 44 | class Meta: 45 | model = MessageKeyboard 46 | fields = ['type', 'buttons'] 47 | 48 | 49 | class MessageSerializer(serializers.ModelSerializer[Message]): 50 | settings = MessageSettingsSerializer() 51 | images = MessageImageSerializer(many=True) 52 | documents = MessageDocumentSerializer(many=True) 53 | keyboard = MessageKeyboardSerializer() 54 | source_connections = ConnectionSerializer(many=True) 55 | 56 | class Meta: 57 | model = Message 58 | fields = [ 59 | 'id', 60 | 'text', 61 | 'settings', 62 | 'images', 63 | 'documents', 64 | 'keyboard', 65 | 'source_connections', 66 | ] 67 | -------------------------------------------------------------------------------- /telegram_bots/hub/views/message.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.viewsets import ReadOnlyModelViewSet 5 | 6 | from django_filters.rest_framework import DjangoFilterBackend 7 | 8 | from constructor_telegram_bots.mixins import IDLookupMixin 9 | 10 | from ...models import Message, MessageKeyboardButton 11 | from ..authentication import TokenAuthentication 12 | from ..serializers import MessageKeyboardButtonSerializer, MessageSerializer 13 | from .mixins import TelegramBotMixin 14 | 15 | 16 | class MessageViewSet(IDLookupMixin, TelegramBotMixin, ReadOnlyModelViewSet[Message]): 17 | authentication_classes = [TokenAuthentication] 18 | permission_classes = [IsAuthenticated] 19 | serializer_class = MessageSerializer 20 | 21 | def get_queryset(self) -> QuerySet[Message]: 22 | messages: QuerySet[Message] = self.telegram_bot.messages.all() 23 | 24 | if self.action in ['list', 'retrieve']: 25 | return messages.select_related('settings', 'keyboard').prefetch_related( 26 | 'images', 27 | 'documents', 28 | 'keyboard__buttons__source_connections__source_object', 29 | 'keyboard__buttons__source_connections__target_object', 30 | 'source_connections__source_object', 31 | 'source_connections__target_object', 32 | ) 33 | 34 | return messages 35 | 36 | 37 | class MessageKeyboardButtonViewSet( 38 | IDLookupMixin, TelegramBotMixin, ReadOnlyModelViewSet[MessageKeyboardButton] 39 | ): 40 | authentication_classes = [TokenAuthentication] 41 | permission_classes = [IsAuthenticated] 42 | serializer_class = MessageKeyboardButtonSerializer 43 | filter_backends = [DjangoFilterBackend] 44 | filterset_fields = ['id', 'text'] 45 | 46 | def get_queryset(self) -> QuerySet[MessageKeyboardButton]: 47 | return MessageKeyboardButton.objects.filter( 48 | keyboard__message__telegram_bot=self.telegram_bot 49 | ).prefetch_related( 50 | 'source_connections__source_object', 51 | 'source_connections__target_object', 52 | ) 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Constructor Telegram Bots 2 | [**Constructor Telegram Bots**](https://constructor.exg1o.org/) is a website with which you can easily, free and without any programming knowledge, create your own multifunctional Telegram bot. 3 | 4 | The website is non-commercial and does not aim to make money from its users. 5 | 6 | The website was created because, unfortunately, all similar websites are commercial and aim to profit from their users, while the free plans on such sites severely restrict their users. 7 | 8 | If you would like to support the project, you can make a [**donation**](https://constructor.exg1o.org/donation).
9 | Your donation will greatly help the development and improvement of the website. 10 | 11 | ## Requirements 12 | - Linux 13 | - Python 3.11 14 | - PostgreSQL 15 | - Redis 16 | 17 | ## Requirements from related projects. 18 | - [Constructor Telegram Bots Frontend](https://github.com/EXG1O/Constructor-Telegram-Bots-Frontend#requirements) 19 | - [Telegram Bots Hub](https://github.com/EXG1O/Telegram-Bots-Hub#requirements) 20 | 21 | ## Installing 22 | 1. First, installing the backend using the following commands: 23 | ```bash 24 | git clone https://github.com/EXG1O/Constructor-Telegram-Bots.git 25 | cd Constructor-Telegram-Bots 26 | git checkout tags/v3.1.0 27 | python -m venv env 28 | source env/bin/activate 29 | source install.sh 30 | ``` 31 | 2. Now, we need to run the command to get tokens for a microservice: 32 | ```bash 33 | python manage.py create_hub 34 | ``` 35 | 3. Deploy the [Telegram Bots Hub](https://github.com/EXG1O/Telegram-Bots-Hub) project and run it **(working in the global network)**. 36 | 4. Finally, build the [frontend](https://github.com/EXG1O/Constructor-Telegram-Bots-Frontend#installing). 37 | 38 | ## Usage 39 | 1. To start we need two terminals and the following commands for each: 40 | ```bash 41 | python manage.py runserver 42 | ``` 43 | ```bash 44 | celery -A constructor_telegram_bots worker --loglevel=INFO -f logs/celery.log 45 | ``` 46 | 2. Open the home page `http://127.0.0.1:8000` and enjoy :) 47 | 48 | ## Contributing 49 | Read [CONTRIBUTING.md](CONTRIBUTING.md) for more information on this. 50 | 51 | ## License 52 | This repository is licensed under the [AGPL-3.0 License](LICENSE). 53 | -------------------------------------------------------------------------------- /telegram_bots/serializers/background_task.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.translation import gettext as _ 3 | 4 | from rest_framework import serializers 5 | 6 | from ..models import BackgroundTask 7 | from .base import DiagramSerializer 8 | from .connection import ConnectionSerializer 9 | from .mixins import TelegramBotMixin 10 | 11 | from typing import Any 12 | 13 | 14 | class BackgroundTaskSerializer( 15 | TelegramBotMixin, serializers.ModelSerializer[BackgroundTask] 16 | ): 17 | class Meta: 18 | model = BackgroundTask 19 | fields = ['id', 'name', 'interval'] 20 | 21 | def validate(self, data: dict[str, Any]) -> dict[str, Any]: 22 | if ( 23 | not self.instance 24 | and self.telegram_bot.background_tasks.count() + 1 25 | > settings.TELEGRAM_BOT_MAX_BACKGROUND_TASKS 26 | ): 27 | raise serializers.ValidationError( 28 | _('Нельзя добавлять больше %(max)s фоновых задач.') 29 | % {'max': settings.TELEGRAM_BOT_MAX_BACKGROUND_TASKS}, 30 | code='max_limit', 31 | ) 32 | 33 | return data 34 | 35 | def create(self, validated_data: dict[str, Any]) -> BackgroundTask: 36 | return self.telegram_bot.background_tasks.create(**validated_data) 37 | 38 | def update( 39 | self, background_task: BackgroundTask, validated_data: dict[str, Any] 40 | ) -> BackgroundTask: 41 | background_task.name = validated_data.get('name', background_task.name) 42 | background_task.interval = validated_data.get( 43 | 'interval', background_task.interval 44 | ) 45 | background_task.save(update_fields=['name', 'interval']) 46 | 47 | return background_task 48 | 49 | 50 | class DiagramBackgroundTaskSerializer(DiagramSerializer[BackgroundTask]): 51 | source_connections = ConnectionSerializer(many=True, read_only=True) 52 | 53 | class Meta: 54 | model = BackgroundTask 55 | fields = [ 56 | 'id', 57 | 'name', 58 | 'interval', 59 | 'source_connections', 60 | ] + DiagramSerializer.Meta.fields 61 | read_only_fields = ['name', 'interval'] 62 | -------------------------------------------------------------------------------- /telegram_bots/serializers/telegram_bot.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.request import Request 3 | 4 | from users.models import User as SiteUser 5 | 6 | from ..models import TelegramBot 7 | 8 | from typing import Any 9 | 10 | 11 | class TelegramBotSerializer(serializers.ModelSerializer[TelegramBot]): 12 | class Meta: 13 | model = TelegramBot 14 | fields = [ 15 | 'id', 16 | 'username', 17 | 'api_token', 18 | 'storage_size', 19 | 'used_storage_size', 20 | 'remaining_storage_size', 21 | 'is_private', 22 | 'is_enabled', 23 | 'is_loading', 24 | 'added_date', 25 | ] 26 | read_only_fields = [ 27 | 'id', 28 | 'username', 29 | 'storage_size', 30 | 'used_storage_size', 31 | 'remaining_storage_size', 32 | 'is_enabled', 33 | 'is_loading', 34 | 'added_date', 35 | ] 36 | 37 | @property 38 | def site_user(self) -> SiteUser: 39 | request: Any = self.context.get('request') 40 | 41 | if not isinstance(request, Request): 42 | raise TypeError( 43 | 'You not passed a rest_framework.request.Request instance ' 44 | 'as request to the serializer context.' 45 | ) 46 | elif not isinstance(request.user, SiteUser): 47 | raise TypeError( 48 | 'The request.user instance is not an users.models.User instance.' 49 | ) 50 | 51 | return request.user 52 | 53 | def create(self, validated_data: dict[str, Any]) -> TelegramBot: 54 | return self.site_user.telegram_bots.create(**validated_data) 55 | 56 | def update( 57 | self, telegram_bot: TelegramBot, validated_data: dict[str, Any] 58 | ) -> TelegramBot: 59 | telegram_bot.api_token = validated_data.get('api_token', telegram_bot.api_token) 60 | telegram_bot.is_private = validated_data.get( 61 | 'is_private', telegram_bot.is_private 62 | ) 63 | telegram_bot.save(update_fields=['api_token', 'is_private']) 64 | 65 | return telegram_bot 66 | -------------------------------------------------------------------------------- /telegram_bots/migrations/0008_remove_connection_telegram_bo_source__bb2efb_idx_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2025-08-28 08:42 2 | 3 | import django.contrib.postgres.indexes 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('contenttypes', '0002_remove_content_type_name'), 11 | ('telegram_bots', '0007_rename_is_delete_user_message_commandsettings_delete_user_message_and_more'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveIndex( 16 | model_name='connection', 17 | name='telegram_bo_source__bb2efb_idx', 18 | ), 19 | migrations.AddIndex( 20 | model_name='commandkeyboardbutton', 21 | index=models.Index(fields=['text'], name='telegram_bo_text_f748fe_idx'), 22 | ), 23 | migrations.AddIndex( 24 | model_name='connection', 25 | index=models.Index(fields=['source_content_type', 'source_object_id'], name='telegram_bo_source__950e28_idx'), 26 | ), 27 | migrations.AddIndex( 28 | model_name='connection', 29 | index=models.Index(fields=['target_content_type', 'target_object_id'], name='telegram_bo_target__b66ce3_idx'), 30 | ), 31 | migrations.AddIndex( 32 | model_name='databaserecord', 33 | index=django.contrib.postgres.indexes.GinIndex(fields=['data'], name='telegram_bo_data_92dfd0_gin'), 34 | ), 35 | migrations.AddIndex( 36 | model_name='triggercommand', 37 | index=models.Index(fields=['command'], name='telegram_bo_command_c9d0cd_idx'), 38 | ), 39 | migrations.AddIndex( 40 | model_name='triggercommand', 41 | index=models.Index(fields=['payload'], name='telegram_bo_payload_ab2f44_idx'), 42 | ), 43 | migrations.AddIndex( 44 | model_name='triggercommand', 45 | index=models.Index(fields=['description'], name='telegram_bo_descrip_1c3d19_idx'), 46 | ), 47 | migrations.AddIndex( 48 | model_name='variable', 49 | index=models.Index(fields=['name'], name='telegram_bo_name_7e7a7d_idx'), 50 | ), 51 | ] 52 | -------------------------------------------------------------------------------- /telegram_bots/hub/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.db.models import QuerySet 4 | from django.utils.functional import cached_property 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from django_stubs_ext.db.models import TypedModelMeta 8 | 9 | from ..utils import get_telegram_bot_modal 10 | from .service import API 11 | 12 | from typing import TYPE_CHECKING, Any, Optional 13 | 14 | if TYPE_CHECKING: 15 | from ..models import TelegramBot 16 | else: 17 | TelegramBot = Any 18 | 19 | 20 | class TelegramBotsHubManager(models.Manager['TelegramBotsHub']): 21 | def get_freest(self) -> Optional['TelegramBotsHub']: 22 | return ( 23 | sorted(hubs, key=lambda hub: hub.api.get_telegram_bot_ids())[0] 24 | if (hubs := self.all()) 25 | else None 26 | ) 27 | 28 | def get_telegram_bot_hub(self, telegram_bot_id: int) -> Optional['TelegramBotsHub']: 29 | for hub in self.all(): 30 | if telegram_bot_id in hub.api.get_telegram_bot_ids(): 31 | return hub 32 | 33 | return None 34 | 35 | 36 | class TelegramBotsHub(models.Model): 37 | url = models.URLField(_('URL-адрес'), unique=True) 38 | service_token = models.CharField( 39 | _('Токен сервиса'), max_length=64, primary_key=True 40 | ) 41 | microservice_token = models.CharField(_('Токен микросервиса'), max_length=64) 42 | 43 | is_authenticated = True # Stub for IsAuthenticated permission 44 | 45 | objects = TelegramBotsHubManager() 46 | 47 | class Meta(TypedModelMeta): 48 | db_table = 'telegram_bots_hub' 49 | verbose_name = _('Центр') 50 | verbose_name_plural = _('Центра') 51 | 52 | @cached_property 53 | def api(self) -> API: 54 | return API(self.url, self.microservice_token) 55 | 56 | @property 57 | def telegram_bots(self) -> QuerySet[TelegramBot]: 58 | telegram_bot_modal = get_telegram_bot_modal() 59 | 60 | if settings.TEST: 61 | return telegram_bot_modal.objects.all() 62 | 63 | return telegram_bot_modal.objects.filter(id__in=self.api.get_telegram_bot_ids()) 64 | 65 | def __str__(self) -> str: 66 | return self.url 67 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | package-mode = false 3 | 4 | [tool.poetry.dependencies] 5 | python = "^3.11" 6 | celery = "^5.3.6" 7 | django = "^5.0.4" 8 | django-filter = "^24.2" 9 | django-modeltranslation = "^0.18.11" 10 | djangorestframework = "^3.15.1" 11 | drf-standardized-errors = "^0.13.0" 12 | gunicorn = "^21.2.0" 13 | python-dotenv = "^1.0.1" 14 | redis = "^5.0.3" 15 | psycopg = "^3.1.18" 16 | django-stubs-ext = "^5.0.0" 17 | yarl = "^1.9.4" 18 | pyjwt = "^2.9.0" 19 | pillow = "^11.0.0" 20 | django-admin-sortable2 = "^2.2.4" 21 | 22 | [tool.poetry.group.dev.dependencies] 23 | django-silk = "^5.1.0" 24 | django-stubs = "^5.0.0" 25 | djangorestframework-stubs = "^3.15.0" 26 | mypy = "^1.10.0" 27 | ruff = "^0.4.4" 28 | 29 | [tool.poetry.group.migrations.dependencies] 30 | html2text = "^2024.2.26" 31 | 32 | [tool.mypy] 33 | strict = true 34 | ignore_missing_imports = true 35 | disallow_untyped_decorators = false 36 | disallow_subclassing_any = false 37 | warn_unreachable = true 38 | warn_no_return = true 39 | warn_return_any = false 40 | 41 | plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"] 42 | 43 | [tool.django-stubs] 44 | django_settings_module = "constructor_telegram_bots.settings" 45 | 46 | [tool.ruff] 47 | target-version = "py311" 48 | extend-exclude = ["migrations"] 49 | 50 | [tool.ruff.lint] 51 | select = [ 52 | "F", # pyflakes 53 | "E", # pycodestyle errors 54 | "W", # pycodestyle warnings 55 | "I", # isort 56 | "N", # pep8-naming 57 | "UP", # pyupgrade 58 | "B", # flake8-bugbear 59 | "C4", # flake8-comprehensions 60 | "T20", # flake8-print 61 | "INT", # flake8-gettext 62 | ] 63 | ignore = [ 64 | "E501", # line too long, handled by black 65 | "W191", # indentation contains tabs 66 | ] 67 | 68 | [tool.ruff.lint.isort] 69 | section-order = [ 70 | "future", 71 | "django", 72 | "django_stubs_ext", 73 | "rest_framework", 74 | "third-party", 75 | "first-party", 76 | "local-folder", 77 | "requests", 78 | "standard-library", 79 | ] 80 | from-first = true 81 | 82 | [tool.ruff.lint.isort.sections] 83 | django = ["django"] 84 | django_stubs_ext = ["django_stubs_ext"] 85 | rest_framework = ["rest_framework"] 86 | requests = ["requests"] 87 | 88 | [tool.ruff.format] 89 | quote-style = "single" 90 | -------------------------------------------------------------------------------- /constructor_telegram_bots/validators.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.core.validators import MaxLengthValidator, URLValidator 3 | from django.db.models import JSONField 4 | from django.utils.deconstruct import deconstructible 5 | 6 | from collections.abc import Callable 7 | from ipaddress import ( 8 | IPv4Address, 9 | IPv4Network, 10 | IPv6Address, 11 | IPv6Network, 12 | ip_address, 13 | ip_network, 14 | ) 15 | from typing import Any 16 | from urllib.parse import urlparse 17 | import json 18 | 19 | 20 | @deconstructible 21 | class PublicURLValidator: 22 | validate_url: Callable[[str | None], None] = URLValidator() 23 | private_networks: list[IPv4Network | IPv6Network] = [ 24 | ip_network('127.0.0.0/8'), 25 | ip_network('10.0.0.0/8'), 26 | ip_network('172.16.0.0/12'), 27 | ip_network('192.168.0.0/16'), 28 | ip_network('169.254.0.0/16'), 29 | ip_network('::1/128'), 30 | ip_network('fc00::/7'), 31 | ip_network('fe80::/10'), 32 | ] 33 | 34 | def __call__(self, url: str | None) -> None: 35 | self.validate_url(url) 36 | assert url 37 | 38 | hostname: str | None = urlparse(url).hostname 39 | assert hostname 40 | 41 | try: 42 | ip: IPv4Address | IPv6Address = ip_address(hostname) 43 | except ValueError: 44 | return 45 | 46 | for network in self.private_networks: 47 | if ip in network: 48 | raise ValidationError( 49 | URLValidator.message, code=URLValidator.code, params={'value': url} 50 | ) 51 | 52 | 53 | @deconstructible 54 | class StrictJSONValidator: 55 | code = 'invalid' 56 | message = JSONField.default_error_messages[code] 57 | 58 | def __init__( 59 | self, 60 | max_light: int, 61 | allowed_types: tuple[type[dict[Any, Any]] | type[list[Any]], ...], 62 | ): 63 | self.allowed_types = allowed_types 64 | self.validate_max_length = MaxLengthValidator(max_light) 65 | 66 | def __call__(self, value: Any) -> None: 67 | if not isinstance(value, self.allowed_types): 68 | raise ValidationError(self.message, code=self.code) 69 | 70 | self.validate_max_length(json.dumps(value)) 71 | -------------------------------------------------------------------------------- /telegram_bots/tasks.py: -------------------------------------------------------------------------------- 1 | from celery import shared_task 2 | 3 | from .hub.utils import get_telegram_bots_hub_modal 4 | from .utils import get_telegram_bot_modal 5 | 6 | from collections.abc import Callable 7 | from functools import wraps 8 | from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar 9 | 10 | if TYPE_CHECKING: 11 | from .hub.models import TelegramBotsHub 12 | from .models import TelegramBot 13 | else: 14 | TelegramBot = Any 15 | TelegramBotsHub = Any 16 | 17 | 18 | P = ParamSpec('P') 19 | R = TypeVar('R') 20 | 21 | 22 | def execute_task(func: Callable[Concatenate[TelegramBot, P], R]) -> Callable[P, R]: 23 | @wraps(func) 24 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 25 | telegram_bot_id = kwargs['telegram_bot_id'] 26 | assert isinstance(telegram_bot_id, int) 27 | 28 | telegram_bot: TelegramBot = get_telegram_bot_modal().objects.get( 29 | id=telegram_bot_id 30 | ) 31 | 32 | try: 33 | return func(telegram_bot, *args, **kwargs) 34 | finally: 35 | telegram_bot.is_loading = False 36 | telegram_bot.save(update_fields=['is_loading']) 37 | 38 | return wrapper 39 | 40 | 41 | @shared_task 42 | @execute_task 43 | def start_telegram_bot(telegram_bot: TelegramBot, telegram_bot_id: int) -> None: 44 | hub: TelegramBotsHub | None = get_telegram_bots_hub_modal().objects.get_freest() 45 | 46 | if not hub: 47 | return 48 | 49 | hub.api.start_telegram_bot(telegram_bot.id, {'bot_token': telegram_bot.api_token}) 50 | 51 | 52 | @shared_task 53 | @execute_task 54 | def restart_telegram_bot(telegram_bot: TelegramBot, telegram_bot_id: int) -> None: 55 | if not telegram_bot.hub: 56 | return None 57 | 58 | telegram_bot.hub.api.restart_telegram_bot(telegram_bot.id) 59 | 60 | 61 | @shared_task 62 | @execute_task 63 | def stop_telegram_bot(telegram_bot: TelegramBot, telegram_bot_id: int) -> None: 64 | if not telegram_bot.hub: 65 | return None 66 | 67 | telegram_bot.hub.api.stop_telegram_bot(telegram_bot.id) 68 | 69 | 70 | @shared_task 71 | def start_telegram_bots() -> None: 72 | for telegram_bot in get_telegram_bot_modal().objects.filter(must_be_enabled=True): 73 | if not telegram_bot.is_enabled: 74 | telegram_bot.start() 75 | -------------------------------------------------------------------------------- /telegram_bots/hub/serializers/connection.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Model 2 | 3 | from rest_framework import serializers 4 | 5 | from ...enums import ConnectionObjectType 6 | from ...models import ( 7 | APIRequest, 8 | BackgroundTask, 9 | Condition, 10 | Connection, 11 | DatabaseOperation, 12 | Message, 13 | MessageKeyboardButton, 14 | Trigger, 15 | ) 16 | 17 | from typing import Any 18 | 19 | 20 | class ConnectionSerializer(serializers.ModelSerializer[Connection]): 21 | source_object_type = serializers.ChoiceField( 22 | choices=ConnectionObjectType.source_choices(), write_only=True 23 | ) 24 | target_object_type = serializers.ChoiceField( 25 | choices=ConnectionObjectType.target_choices(), write_only=True 26 | ) 27 | 28 | class Meta: 29 | model = Connection 30 | fields = [ 31 | 'id', 32 | 'source_object_type', 33 | 'source_object_id', 34 | 'target_object_type', 35 | 'target_object_id', 36 | ] 37 | 38 | _object_type_map: dict[ConnectionObjectType, type[Model]] = { 39 | ConnectionObjectType.TRIGGER: Trigger, 40 | ConnectionObjectType.MESSAGE: Message, 41 | ConnectionObjectType.MESSAGE_KEYBOARD_BUTTON: MessageKeyboardButton, 42 | ConnectionObjectType.CONDITION: Condition, 43 | ConnectionObjectType.BACKGROUND_TASK: BackgroundTask, 44 | ConnectionObjectType.API_REQUEST: APIRequest, 45 | ConnectionObjectType.DATABASE_OPERATION: DatabaseOperation, 46 | } 47 | 48 | def get_object_type(self, object: Model) -> str: 49 | for object_type, model_class in self._object_type_map.items(): 50 | if isinstance(object, model_class): 51 | return object_type 52 | 53 | raise ValueError('Unknown object.') 54 | 55 | def to_representation(self, instance: Connection) -> dict[str, Any]: 56 | representation: dict[str, Any] = super().to_representation(instance) 57 | representation['source_object_type'] = self.get_object_type( 58 | instance.source_object # type: ignore [arg-type] 59 | ) 60 | representation['target_object_type'] = self.get_object_type( 61 | instance.target_object # type: ignore [arg-type] 62 | ) 63 | 64 | return representation 65 | -------------------------------------------------------------------------------- /telegram_bots/models/base.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.fields import GenericRelation 2 | from django.core.exceptions import FieldError 3 | from django.db import models 4 | from django.db.models.base import ModelBase 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from django_stubs_ext.db.models import TypedModelMeta 8 | 9 | from constructor_telegram_bots.fields import PublicURLField 10 | 11 | from collections.abc import Iterable 12 | from typing import TYPE_CHECKING 13 | import random 14 | 15 | 16 | def generate_random_coordinate() -> int: 17 | return random.randint(-150, 150) 18 | 19 | 20 | class AbstractBlock(models.Model): 21 | name = models.CharField(_('Название'), max_length=128) 22 | x = models.FloatField(_('Координата X'), default=generate_random_coordinate) 23 | y = models.FloatField(_('Координата Y'), default=generate_random_coordinate) 24 | source_connections = GenericRelation( 25 | 'Connection', 'source_object_id', 'source_content_type' 26 | ) 27 | target_connections = GenericRelation( 28 | 'Connection', 'target_object_id', 'target_content_type' 29 | ) 30 | 31 | class Meta(TypedModelMeta): 32 | abstract = True 33 | 34 | 35 | class AbstractMessageMedia(models.Model): 36 | if TYPE_CHECKING: 37 | related_name: str 38 | file: models.FileField 39 | 40 | from_url = PublicURLField(_('Из URL-адреса'), blank=True, null=True) 41 | position = models.PositiveSmallIntegerField(_('Позиция')) 42 | 43 | class Meta(TypedModelMeta): 44 | abstract = True 45 | 46 | def save( 47 | self, 48 | force_insert: bool | tuple[ModelBase, ...] = False, 49 | force_update: bool = False, 50 | using: str | None = None, 51 | update_fields: Iterable[str] | None = None, 52 | ) -> None: 53 | if bool(self.file) is bool(self.from_url): 54 | raise FieldError( 55 | "Only one of the fields 'file' or 'from_url' should be specified." 56 | ) 57 | 58 | super().save(force_insert, force_update, using, update_fields) 59 | 60 | def delete( 61 | self, using: str | None = None, keep_parents: bool = False 62 | ) -> tuple[int, dict[str, int]]: 63 | if self.file: 64 | self.file.delete(save=False) 65 | 66 | return super().delete(using, keep_parents) 67 | -------------------------------------------------------------------------------- /telegram_bots/views/background_task.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin 4 | from rest_framework.permissions import IsAuthenticated 5 | from rest_framework.viewsets import GenericViewSet, ModelViewSet 6 | 7 | from constructor_telegram_bots.mixins import IDLookupMixin 8 | from constructor_telegram_bots.permissions import ReadOnly 9 | from users.authentication import JWTAuthentication 10 | from users.permissions import IsTermsAccepted 11 | 12 | from ..models import BackgroundTask 13 | from ..serializers import BackgroundTaskSerializer, DiagramBackgroundTaskSerializer 14 | from .mixins import TelegramBotMixin 15 | 16 | 17 | class BackgroundTaskViewSet( 18 | IDLookupMixin, TelegramBotMixin, ModelViewSet[BackgroundTask] 19 | ): 20 | authentication_classes = [JWTAuthentication] 21 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 22 | serializer_class = BackgroundTaskSerializer 23 | 24 | def get_queryset(self) -> QuerySet[BackgroundTask]: 25 | background_tasks: QuerySet[BackgroundTask] = ( 26 | self.telegram_bot.background_tasks.all() 27 | ) 28 | 29 | if self.action in ['list', 'retrieve']: 30 | return background_tasks.prefetch_related( 31 | 'source_connections__source_object', 32 | 'source_connections__target_object', 33 | ) 34 | 35 | return background_tasks 36 | 37 | 38 | class DiagramBackgroundTaskViewSet( 39 | IDLookupMixin, 40 | TelegramBotMixin, 41 | ListModelMixin, 42 | RetrieveModelMixin, 43 | UpdateModelMixin, 44 | GenericViewSet[BackgroundTask], 45 | ): 46 | authentication_classes = [JWTAuthentication] 47 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 48 | serializer_class = DiagramBackgroundTaskSerializer 49 | 50 | def get_queryset(self) -> QuerySet[BackgroundTask]: 51 | background_tasks: QuerySet[BackgroundTask] = ( 52 | self.telegram_bot.background_tasks.all() 53 | ) 54 | 55 | if self.action in ['list', 'retrieve']: 56 | return background_tasks.prefetch_related( 57 | 'source_connections__source_object', 58 | 'source_connections__target_object', 59 | ) 60 | 61 | return background_tasks 62 | -------------------------------------------------------------------------------- /telegram_bots/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | from .api_request import APIRequestSerializer, DiagramAPIRequestSerializer 2 | from .background_task import BackgroundTaskSerializer, DiagramBackgroundTaskSerializer 3 | from .condition import ( 4 | ConditionPartSerializer, 5 | ConditionSerializer, 6 | DiagramConditionSerializer, 7 | ) 8 | from .connection import ConnectionSerializer 9 | from .database_operation import ( 10 | DatabaseCreateOperationSerializer, 11 | DatabaseOperationSerializer, 12 | DatabaseUpdateOperationSerializer, 13 | DiagramDatabaseOperationSerializer, 14 | ) 15 | from .database_record import DatabaseRecordSerializer 16 | from .message import ( 17 | DiagramMessageKeyboardButtonSerializer, 18 | DiagramMessageKeyboardSerializer, 19 | DiagramMessageSerializer, 20 | MessageDocumentSerializer, 21 | MessageImageSerializer, 22 | MessageKeyboardButtonSerializer, 23 | MessageKeyboardSerializer, 24 | MessageSerializer, 25 | MessageSettingsSerializer, 26 | ) 27 | from .telegram_bot import TelegramBotSerializer 28 | from .trigger import ( 29 | DiagramTriggerSerializer, 30 | TriggerCommandSerializer, 31 | TriggerMessageSerializer, 32 | TriggerSerializer, 33 | ) 34 | from .user import UserSerializer 35 | from .variable import VariableSerializer 36 | 37 | __all__ = [ 38 | 'TelegramBotSerializer', 39 | 'ConnectionSerializer', 40 | 'TriggerSerializer', 41 | 'TriggerCommandSerializer', 42 | 'TriggerMessageSerializer', 43 | 'DiagramTriggerSerializer', 44 | 'MessageSerializer', 45 | 'MessageSettingsSerializer', 46 | 'MessageImageSerializer', 47 | 'MessageDocumentSerializer', 48 | 'MessageKeyboardSerializer', 49 | 'MessageKeyboardButtonSerializer', 50 | 'DiagramMessageSerializer', 51 | 'DiagramMessageKeyboardSerializer', 52 | 'DiagramMessageKeyboardButtonSerializer', 53 | 'ConditionSerializer', 54 | 'ConditionPartSerializer', 55 | 'DiagramConditionSerializer', 56 | 'BackgroundTaskSerializer', 57 | 'DiagramBackgroundTaskSerializer', 58 | 'APIRequestSerializer', 59 | 'DiagramAPIRequestSerializer', 60 | 'DatabaseOperationSerializer', 61 | 'DatabaseCreateOperationSerializer', 62 | 'DatabaseUpdateOperationSerializer', 63 | 'DiagramDatabaseOperationSerializer', 64 | 'VariableSerializer', 65 | 'UserSerializer', 66 | 'DatabaseRecordSerializer', 67 | ] 68 | -------------------------------------------------------------------------------- /telegram_bots/models/trigger.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from django_stubs_ext.db.models import TypedModelMeta 5 | 6 | from .base import AbstractBlock 7 | 8 | from typing import TYPE_CHECKING 9 | 10 | 11 | class TriggerCommand(models.Model): 12 | trigger = models.OneToOneField( 13 | 'Trigger', 14 | on_delete=models.CASCADE, 15 | related_name='command', 16 | verbose_name=_('Триггер'), 17 | ) 18 | command = models.CharField(_('Команда'), max_length=32) 19 | payload = models.CharField( 20 | _('Полезная нагрузка'), max_length=64, blank=True, null=True 21 | ) 22 | description = models.CharField(_('Описание'), max_length=255, blank=True, null=True) 23 | 24 | class Meta(TypedModelMeta): 25 | db_table = 'telegram_bot_trigger_command' 26 | indexes = [ 27 | models.Index(fields=['command']), 28 | models.Index(fields=['payload']), 29 | models.Index(fields=['description']), 30 | ] 31 | verbose_name = _('Команда триггер') 32 | verbose_name_plural = _('Команды триггеры') 33 | 34 | def __str__(self) -> str: 35 | return self.command 36 | 37 | 38 | class TriggerMessage(models.Model): 39 | trigger = models.OneToOneField( 40 | 'Trigger', 41 | on_delete=models.CASCADE, 42 | related_name='message', 43 | verbose_name=_('Триггер'), 44 | ) 45 | text = models.TextField(_('Текст'), max_length=4096, null=True) 46 | 47 | class Meta(TypedModelMeta): 48 | db_table = 'telegram_bot_trigger_message' 49 | verbose_name = _('Сообщение триггер') 50 | verbose_name_plural = _('Сообщения триггеры') 51 | 52 | def __str__(self) -> str: 53 | return (self.text or 'NULL')[:128] 54 | 55 | 56 | class Trigger(AbstractBlock): 57 | telegram_bot = models.ForeignKey( 58 | 'TelegramBot', 59 | on_delete=models.CASCADE, 60 | related_name='triggers', 61 | verbose_name=_('Telegram бот'), 62 | ) 63 | 64 | if TYPE_CHECKING: 65 | command: TriggerCommand 66 | message: TriggerMessage 67 | 68 | class Meta(TypedModelMeta): 69 | db_table = 'telegram_bot_trigger' 70 | verbose_name = _('Триггер') 71 | verbose_name_plural = _('Триггеры') 72 | 73 | def __str__(self) -> str: 74 | return self.name 75 | -------------------------------------------------------------------------------- /telegram_bots/views/condition.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin 4 | from rest_framework.permissions import IsAuthenticated 5 | from rest_framework.viewsets import GenericViewSet, ModelViewSet 6 | 7 | from constructor_telegram_bots.mixins import IDLookupMixin 8 | from constructor_telegram_bots.permissions import ReadOnly 9 | from users.authentication import JWTAuthentication 10 | from users.permissions import IsTermsAccepted 11 | 12 | from ..models import Condition 13 | from ..serializers import ConditionSerializer, DiagramConditionSerializer 14 | from .mixins import TelegramBotMixin 15 | 16 | 17 | class ConditionViewSet(IDLookupMixin, TelegramBotMixin, ModelViewSet[Condition]): 18 | authentication_classes = [JWTAuthentication] 19 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 20 | serializer_class = ConditionSerializer 21 | 22 | def get_queryset(self) -> QuerySet[Condition]: 23 | conditions: QuerySet[Condition] = self.telegram_bot.conditions.all() 24 | 25 | if self.action in ['list', 'retrieve']: 26 | return conditions.prefetch_related( 27 | 'parts', 28 | 'source_connections__source_object', 29 | 'source_connections__target_object', 30 | 'target_connections__source_object', 31 | 'target_connections__target_object', 32 | ) 33 | 34 | return conditions 35 | 36 | 37 | class DiagramConditionViewSet( 38 | IDLookupMixin, 39 | TelegramBotMixin, 40 | ListModelMixin, 41 | RetrieveModelMixin, 42 | UpdateModelMixin, 43 | GenericViewSet[Condition], 44 | ): 45 | authentication_classes = [JWTAuthentication] 46 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 47 | serializer_class = DiagramConditionSerializer 48 | 49 | def get_queryset(self) -> QuerySet[Condition]: 50 | conditions: QuerySet[Condition] = self.telegram_bot.conditions.all() 51 | 52 | if self.action in ['list', 'retrieve']: 53 | return conditions.prefetch_related( 54 | 'source_connections__source_object', 55 | 'source_connections__target_object', 56 | 'target_connections__source_object', 57 | 'target_connections__target_object', 58 | ) 59 | 60 | return conditions 61 | -------------------------------------------------------------------------------- /telegram_bots/views/api_request.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin 4 | from rest_framework.permissions import IsAuthenticated 5 | from rest_framework.viewsets import GenericViewSet, ModelViewSet 6 | 7 | from constructor_telegram_bots.mixins import IDLookupMixin 8 | from constructor_telegram_bots.permissions import ReadOnly 9 | from users.authentication import JWTAuthentication 10 | from users.permissions import IsTermsAccepted 11 | 12 | from ..models import APIRequest 13 | from ..serializers import APIRequestSerializer, DiagramAPIRequestSerializer 14 | from .mixins import TelegramBotMixin 15 | 16 | 17 | class APIRequestViewSet(IDLookupMixin, TelegramBotMixin, ModelViewSet[APIRequest]): 18 | authentication_classes = [JWTAuthentication] 19 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 20 | serializer_class = APIRequestSerializer 21 | 22 | def get_queryset(self) -> QuerySet[APIRequest]: 23 | api_requests: QuerySet[APIRequest] = self.telegram_bot.api_requests.all() 24 | 25 | if self.action in ['list', 'retrieve']: 26 | return api_requests.prefetch_related( 27 | 'source_connections__source_object', 28 | 'source_connections__target_object', 29 | 'target_connections__source_object', 30 | 'target_connections__target_object', 31 | ) 32 | 33 | return api_requests 34 | 35 | 36 | class DiagramAPIRequestViewSet( 37 | IDLookupMixin, 38 | TelegramBotMixin, 39 | ListModelMixin, 40 | RetrieveModelMixin, 41 | UpdateModelMixin, 42 | GenericViewSet[APIRequest], 43 | ): 44 | authentication_classes = [JWTAuthentication] 45 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 46 | serializer_class = DiagramAPIRequestSerializer 47 | 48 | def get_queryset(self) -> QuerySet[APIRequest]: 49 | api_requests: QuerySet[APIRequest] = self.telegram_bot.api_requests.all() 50 | 51 | if self.action in ['list', 'retrieve']: 52 | return api_requests.prefetch_related( 53 | 'source_connections__source_object', 54 | 'source_connections__target_object', 55 | 'target_connections__source_object', 56 | 'target_connections__target_object', 57 | ) 58 | 59 | return api_requests 60 | -------------------------------------------------------------------------------- /telegram_bots/hub/views/database_record.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.decorators import action 4 | from rest_framework.filters import SearchFilter 5 | from rest_framework.mixins import CreateModelMixin, UpdateModelMixin 6 | from rest_framework.permissions import IsAuthenticated 7 | from rest_framework.request import Request 8 | from rest_framework.response import Response 9 | from rest_framework.viewsets import ReadOnlyModelViewSet 10 | 11 | from django_filters.rest_framework import CharFilter, DjangoFilterBackend, FilterSet 12 | 13 | from constructor_telegram_bots.mixins import IDLookupMixin 14 | 15 | from ...models import DatabaseRecord 16 | from ..authentication import TokenAuthentication 17 | from ..serializers import DatabaseRecordSerializer 18 | from .mixins import TelegramBotMixin 19 | 20 | 21 | class DatabaseRecordFilter(FilterSet): 22 | has_data_path = CharFilter(field_name='data', method='filter_has_data_path') 23 | 24 | def filter_has_data_path( 25 | self, queryset: QuerySet[DatabaseRecord], name: str, value: str 26 | ) -> QuerySet[DatabaseRecord]: 27 | return queryset.filter(**{f"{name}__{value.replace('.', '__')}__isnull": False}) 28 | 29 | class Meta: 30 | model = DatabaseRecord 31 | fields = ['has_data_path'] 32 | 33 | 34 | class DatabaseRecordViewSet( 35 | IDLookupMixin, 36 | TelegramBotMixin, 37 | CreateModelMixin, 38 | UpdateModelMixin, 39 | ReadOnlyModelViewSet[DatabaseRecord], 40 | ): 41 | authentication_classes = [TokenAuthentication] 42 | permission_classes = [IsAuthenticated] 43 | serializer_class = DatabaseRecordSerializer 44 | filter_backends = [DjangoFilterBackend, SearchFilter] 45 | filterset_class = DatabaseRecordFilter 46 | search_fields = ['data'] 47 | 48 | def get_queryset(self) -> QuerySet[DatabaseRecord]: 49 | return self.telegram_bot.database_records.all() 50 | 51 | @action(detail=False, url_path='update-many', methods=['PUT', 'PATCH']) 52 | def update_many(self, request: Request, telegram_bot_id: int) -> Response: 53 | serializer = self.get_serializer( 54 | list(self.filter_queryset(self.get_queryset())), 55 | partial=request.method == 'PATCH', 56 | data=request.data, 57 | many=True, 58 | ) 59 | serializer.is_valid(raise_exception=True) 60 | serializer.save() 61 | 62 | return Response(serializer.data) 63 | -------------------------------------------------------------------------------- /telegram_bots/hub/serializers/database_record.py: -------------------------------------------------------------------------------- 1 | from rest_framework import fields, serializers 2 | 3 | from ...models import DatabaseRecord 4 | from ...serializers.mixins import TelegramBotMixin 5 | 6 | from typing import Any 7 | 8 | 9 | class DatabaseRecordListSerializer(serializers.ListSerializer[list[DatabaseRecord]]): 10 | def run_validation(self, data: Any = fields.empty) -> dict[str, Any]: 11 | assert self.child 12 | return self.child.run_validation(data) 13 | 14 | def update( 15 | self, records: list[DatabaseRecord], validated_data: dict[str, Any] 16 | ) -> list[DatabaseRecord]: 17 | new_data: Any = validated_data['data'] 18 | 19 | for record in records: 20 | if self.partial: 21 | data: dict[str, Any] | list[Any] = record.data.copy() 22 | 23 | if isinstance(data, dict): 24 | data.update( 25 | new_data 26 | if isinstance(new_data, dict) 27 | else {'new_data': new_data} 28 | ) 29 | elif isinstance(data, list): 30 | if isinstance(new_data, list): 31 | data.extend(new_data) 32 | else: 33 | data.append(new_data) 34 | 35 | record.data = data 36 | else: 37 | record.data = new_data 38 | 39 | DatabaseRecord.objects.bulk_update(records, fields=['data']) 40 | 41 | return records 42 | 43 | def save(self, **kwargs: Any) -> list[DatabaseRecord]: 44 | if self.instance is None: 45 | raise NotImplementedError("Bulk creation isn't implemented.") 46 | 47 | self.instance = self.update(self.instance, self.validated_data) 48 | return self.instance 49 | 50 | 51 | class DatabaseRecordSerializer( 52 | TelegramBotMixin, serializers.ModelSerializer[DatabaseRecord] 53 | ): 54 | class Meta: 55 | model = DatabaseRecord 56 | fields = ['id', 'data'] 57 | list_serializer_class = DatabaseRecordListSerializer 58 | 59 | def create(self, validated_data: dict[str, Any]) -> DatabaseRecord: 60 | return self.telegram_bot.database_records.create(**validated_data) 61 | 62 | def update( 63 | self, record: DatabaseRecord, validated_data: dict[str, Any] 64 | ) -> DatabaseRecord: 65 | record.data = validated_data.get('data', record.data) 66 | record.save(update_fields=['data']) 67 | 68 | return record 69 | -------------------------------------------------------------------------------- /telegram_bots/models/database_operation.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from django_stubs_ext.db.models import TypedModelMeta 5 | 6 | from constructor_telegram_bots.fields import StrictJSONField 7 | 8 | from .base import AbstractBlock 9 | 10 | from typing import TYPE_CHECKING 11 | 12 | 13 | class DatabaseCreateOperation(models.Model): 14 | operation = models.OneToOneField( 15 | 'DatabaseOperation', 16 | on_delete=models.CASCADE, 17 | related_name='create_operation', 18 | verbose_name=_('Операция'), 19 | ) 20 | data = StrictJSONField(_('Данные')) 21 | 22 | class Meta(TypedModelMeta): 23 | db_table = 'telegram_bot_database_create_operation' 24 | verbose_name = _('Операция создания записи') 25 | verbose_name_plural = _('Операции создания записей') 26 | 27 | def __str__(self) -> str: 28 | return self.operation.name 29 | 30 | 31 | class DatabaseUpdateOperation(models.Model): 32 | operation = models.OneToOneField( 33 | 'DatabaseOperation', 34 | on_delete=models.CASCADE, 35 | related_name='update_operation', 36 | verbose_name=_('Операция'), 37 | ) 38 | overwrite = models.BooleanField(_('Перезаписать'), default=True) 39 | lookup_field_name = models.CharField(_('Название поля для поиска'), max_length=255) 40 | lookup_field_value = models.CharField(_('Значение поля для поиска'), max_length=255) 41 | create_if_not_found = models.BooleanField( 42 | _('Создать, если не найдена'), default=True 43 | ) 44 | new_data = StrictJSONField(_('Новые данные')) 45 | 46 | class Meta(TypedModelMeta): 47 | db_table = 'telegram_bot_database_update_operation' 48 | verbose_name = _('Операция обновления записи') 49 | verbose_name_plural = _('Операции обновления записей') 50 | 51 | def __str__(self) -> str: 52 | return self.operation.name 53 | 54 | 55 | class DatabaseOperation(AbstractBlock): 56 | telegram_bot = models.ForeignKey( 57 | 'TelegramBot', 58 | on_delete=models.CASCADE, 59 | related_name='database_operations', 60 | verbose_name=_('Telegram бот'), 61 | ) 62 | 63 | if TYPE_CHECKING: 64 | create_operation: DatabaseCreateOperation 65 | update_operation: DatabaseUpdateOperation 66 | 67 | class Meta(TypedModelMeta): 68 | db_table = 'telegram_bot_database_operation' 69 | verbose_name = _('Операция базы данных') 70 | verbose_name_plural = _('Операции баз данных') 71 | 72 | def __str__(self) -> str: 73 | return self.name 74 | -------------------------------------------------------------------------------- /telegram_bots/hub/tests/telegram_bot.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import reverse 3 | 4 | from rest_framework import status 5 | from rest_framework.request import Request 6 | from rest_framework.response import Response 7 | from rest_framework.test import APIRequestFactory, force_authenticate 8 | 9 | from ...tests.mixins import TelegramBotMixin, UserMixin 10 | from ..views import TelegramBotViewSet 11 | from .mixins import HubMixin 12 | from .utils import assert_view_basic_protected 13 | 14 | from typing import TYPE_CHECKING 15 | 16 | 17 | class TelegramBotViewSetTests(TelegramBotMixin, UserMixin, HubMixin, TestCase): 18 | list_url: str = reverse('api:telegram-bots-hub:telegram-bot-list') 19 | 20 | def setUp(self) -> None: 21 | super().setUp() 22 | 23 | self.factory = APIRequestFactory() 24 | 25 | self.detail_true_url: str = reverse( 26 | 'api:telegram-bots-hub:telegram-bot-detail', 27 | kwargs={'id': self.telegram_bot.id}, 28 | ) 29 | self.detail_false_url: str = reverse( 30 | 'api:telegram-bots-hub:telegram-bot-detail', kwargs={'id': 0} 31 | ) 32 | 33 | def test_list(self) -> None: 34 | view = TelegramBotViewSet.as_view({'get': 'list'}) 35 | 36 | request: Request = self.factory.get(self.list_url) 37 | assert_view_basic_protected(view, request, self.hub.service_token) 38 | 39 | force_authenticate(request, self.hub, self.hub.service_token) # type: ignore [arg-type] 40 | 41 | response: Response = view(request) 42 | self.assertEqual(response.status_code, status.HTTP_200_OK) 43 | 44 | def test_retrieve(self) -> None: 45 | view = TelegramBotViewSet.as_view({'get': 'retrieve'}) 46 | 47 | if TYPE_CHECKING: 48 | request: Request 49 | response: Response 50 | 51 | request = self.factory.get(self.detail_true_url) 52 | assert_view_basic_protected( 53 | view, request, self.hub.service_token, id=self.telegram_bot.id 54 | ) 55 | 56 | request = self.factory.get(self.detail_false_url) 57 | force_authenticate(request, self.hub, self.hub.service_token) # type: ignore [arg-type] 58 | 59 | response = view(request, id=0) 60 | self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) 61 | 62 | request = self.factory.get(self.detail_true_url) 63 | force_authenticate(request, self.hub, self.hub.service_token) # type: ignore [arg-type] 64 | 65 | response = view(request, id=self.telegram_bot.id) 66 | self.assertEqual(response.status_code, status.HTTP_200_OK) 67 | -------------------------------------------------------------------------------- /constructor_telegram_bots/parsers.py: -------------------------------------------------------------------------------- 1 | from django.core.files.uploadedfile import UploadedFile 2 | from django.utils.datastructures import MultiValueDict 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from rest_framework.exceptions import ParseError 6 | from rest_framework.parsers import DataAndFiles, MultiPartParser 7 | 8 | from itertools import chain 9 | from json import JSONDecodeError 10 | from typing import Any 11 | import json 12 | import re 13 | 14 | 15 | class MultiPartJSONParser(MultiPartParser): 16 | """Parser for JSON data in multipart form data with support for files and their extra data.""" 17 | 18 | error_detail = _("Не удалось проанализировать JSON данные для ключа '%(key)s'.") 19 | 20 | def parse_json(self, key: str, data: str) -> dict[str, Any]: 21 | try: 22 | return json.loads(data) 23 | except JSONDecodeError as error: 24 | raise ParseError(self.error_detail % {'key': key}) from error 25 | 26 | def parse(self, *args: Any, **kwargs: Any) -> dict[str, Any]: # type: ignore [override] 27 | parsed: DataAndFiles[dict[str, Any], MultiValueDict[str, UploadedFile]] = ( 28 | super().parse(*args, **kwargs) # type: ignore [assignment] 29 | ) 30 | 31 | data: dict[str, Any] = ( 32 | self.parse_json('data', raw_data) 33 | if (raw_data := parsed.data.get('data')) 34 | else {} 35 | ) 36 | 37 | for key, value in chain(parsed.data.items(), parsed.files.items()): 38 | result: re.Match[str] | None = re.fullmatch( 39 | r'^(?P\w+):(?P\d+)$', key, re.IGNORECASE 40 | ) 41 | 42 | if not result: 43 | continue 44 | 45 | name, index = result.group('name', 'index') 46 | name_plural: str = f'{name}s' 47 | 48 | extra_data_key: str = f'{name}:{index}:extra_data' 49 | extra_data: dict[str, Any] = ( 50 | self.parse_json(extra_data_key, raw_extra_data) 51 | if (raw_extra_data := parsed.data.get(extra_data_key)) 52 | else {} 53 | ) 54 | 55 | items: list[dict[str, Any]] = data.setdefault(name_plural, []) 56 | 57 | if isinstance(value, UploadedFile): 58 | items.append({'file': value, **extra_data}) 59 | elif isinstance(value, str): 60 | if value.isdigit(): 61 | items.append({'id': int(value), **extra_data}) 62 | else: 63 | items.append({'url': value, **extra_data}) 64 | elif len(items) < 1: 65 | del data[name_plural] 66 | 67 | return data 68 | -------------------------------------------------------------------------------- /telegram_bots/views/database_operation.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin 4 | from rest_framework.permissions import IsAuthenticated 5 | from rest_framework.viewsets import GenericViewSet, ModelViewSet 6 | 7 | from constructor_telegram_bots.mixins import IDLookupMixin 8 | from constructor_telegram_bots.permissions import ReadOnly 9 | from users.authentication import JWTAuthentication 10 | from users.permissions import IsTermsAccepted 11 | 12 | from ..models import DatabaseOperation 13 | from ..serializers import ( 14 | DatabaseOperationSerializer, 15 | DiagramDatabaseOperationSerializer, 16 | ) 17 | from .mixins import TelegramBotMixin 18 | 19 | 20 | class DatabaseOperationViewSet( 21 | IDLookupMixin, TelegramBotMixin, ModelViewSet[DatabaseOperation] 22 | ): 23 | authentication_classes = [JWTAuthentication] 24 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 25 | serializer_class = DatabaseOperationSerializer 26 | 27 | def get_queryset(self) -> QuerySet[DatabaseOperation]: 28 | operations: QuerySet[DatabaseOperation] = ( 29 | self.telegram_bot.database_operations.all() 30 | ) 31 | 32 | if self.action in ['list', 'retrieve']: 33 | return operations.select_related( 34 | 'create_operation', 'update_operation' 35 | ).prefetch_related( 36 | 'source_connections__source_object', 37 | 'source_connections__target_object', 38 | 'target_connections__source_object', 39 | 'target_connections__target_object', 40 | ) 41 | 42 | return operations 43 | 44 | 45 | class DiagramDatabaseOperationViewSet( 46 | IDLookupMixin, 47 | TelegramBotMixin, 48 | ListModelMixin, 49 | RetrieveModelMixin, 50 | UpdateModelMixin, 51 | GenericViewSet[DatabaseOperation], 52 | ): 53 | authentication_classes = [JWTAuthentication] 54 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 55 | serializer_class = DiagramDatabaseOperationSerializer 56 | 57 | def get_queryset(self) -> QuerySet[DatabaseOperation]: 58 | operations: QuerySet[DatabaseOperation] = ( 59 | self.telegram_bot.database_operations.all() 60 | ) 61 | 62 | if self.action in ['list', 'retrieve']: 63 | return operations.prefetch_related( 64 | 'source_connections__source_object', 65 | 'source_connections__target_object', 66 | 'target_connections__source_object', 67 | 'target_connections__target_object', 68 | ) 69 | 70 | return operations 71 | -------------------------------------------------------------------------------- /telegram_bots/hub/views/trigger.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.viewsets import ReadOnlyModelViewSet 5 | 6 | from django_filters.rest_framework import ( 7 | BooleanFilter, 8 | CharFilter, 9 | DjangoFilterBackend, 10 | FilterSet, 11 | ) 12 | 13 | from constructor_telegram_bots.mixins import IDLookupMixin 14 | 15 | from ...models import Trigger 16 | from ..authentication import TokenAuthentication 17 | from ..serializers import TriggerSerializer 18 | from .mixins import TelegramBotMixin 19 | 20 | 21 | class TriggerFilter(FilterSet): 22 | command = CharFilter(field_name='command__command', lookup_expr='exact') 23 | command_payload = CharFilter(field_name='command__payload', lookup_expr='exact') 24 | has_command = BooleanFilter( 25 | field_name='command__command', method='filter_has_field' 26 | ) 27 | has_command_payload = BooleanFilter( 28 | field_name='command__payload', method='filter_has_field' 29 | ) 30 | has_command_description = BooleanFilter( 31 | field_name='command__description', method='filter_has_field' 32 | ) 33 | has_message = BooleanFilter(field_name='message', method='filter_has_field') 34 | has_message_text = BooleanFilter( 35 | field_name='message__text', method='filter_has_field' 36 | ) 37 | has_target_connections = BooleanFilter( 38 | field_name='target_connections', method='filter_has_field' 39 | ) 40 | 41 | def filter_has_field( 42 | self, queryset: QuerySet[Trigger], name: str, value: bool 43 | ) -> QuerySet[Trigger]: 44 | return queryset.filter(**{f'{name}__isnull': not value}) 45 | 46 | class Meta: 47 | model = Trigger 48 | fields = [ 49 | 'command', 50 | 'command_payload', 51 | 'has_command_payload', 52 | 'has_message', 53 | 'has_message_text', 54 | 'has_target_connections', 55 | ] 56 | 57 | 58 | class TriggerViewSet(IDLookupMixin, TelegramBotMixin, ReadOnlyModelViewSet[Trigger]): 59 | authentication_classes = [TokenAuthentication] 60 | permission_classes = [IsAuthenticated] 61 | serializer_class = TriggerSerializer 62 | filter_backends = [DjangoFilterBackend] 63 | filterset_class = TriggerFilter 64 | 65 | def get_queryset(self) -> QuerySet[Trigger]: 66 | triggers: QuerySet[Trigger] = self.telegram_bot.triggers.all() 67 | 68 | if self.action in ['list', 'retrieve']: 69 | return triggers.select_related('command', 'message').prefetch_related( 70 | 'source_connections__source_object', 71 | 'source_connections__target_object', 72 | ) 73 | 74 | return triggers 75 | -------------------------------------------------------------------------------- /telegram_bots/views/message.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin 4 | from rest_framework.permissions import IsAuthenticated 5 | from rest_framework.viewsets import GenericViewSet, ModelViewSet 6 | 7 | from constructor_telegram_bots.mixins import IDLookupMixin 8 | from constructor_telegram_bots.parsers import MultiPartJSONParser 9 | from constructor_telegram_bots.permissions import ReadOnly 10 | from users.authentication import JWTAuthentication 11 | from users.permissions import IsTermsAccepted 12 | 13 | from ..models import Message 14 | from ..serializers import DiagramMessageSerializer, MessageSerializer 15 | from .mixins import TelegramBotMixin 16 | 17 | 18 | class MessageViewSet(IDLookupMixin, TelegramBotMixin, ModelViewSet[Message]): 19 | authentication_classes = [JWTAuthentication] 20 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 21 | parser_classes = [MultiPartJSONParser] 22 | serializer_class = MessageSerializer 23 | 24 | def get_queryset(self) -> QuerySet[Message]: 25 | messages: QuerySet[Message] = self.telegram_bot.messages.all() 26 | 27 | if self.action in ['list', 'retrieve']: 28 | return messages.select_related('settings', 'keyboard').prefetch_related( 29 | 'images', 30 | 'documents', 31 | 'keyboard__buttons__source_connections__source_object', 32 | 'keyboard__buttons__source_connections__target_object', 33 | 'target_connections__source_object', 34 | 'target_connections__target_object', 35 | ) 36 | 37 | return messages 38 | 39 | 40 | class DiagramMessageViewSet( 41 | IDLookupMixin, 42 | TelegramBotMixin, 43 | ListModelMixin, 44 | RetrieveModelMixin, 45 | UpdateModelMixin, 46 | GenericViewSet[Message], 47 | ): 48 | authentication_classes = [JWTAuthentication] 49 | permission_classes = [IsAuthenticated & (IsTermsAccepted | ReadOnly)] 50 | serializer_class = DiagramMessageSerializer 51 | 52 | def get_queryset(self) -> QuerySet[Message]: 53 | messages: QuerySet[Message] = self.telegram_bot.messages.all() 54 | 55 | if self.action in ['list', 'retrieve']: 56 | return messages.select_related('keyboard').prefetch_related( 57 | 'keyboard__buttons__source_connections__source_object', 58 | 'keyboard__buttons__source_connections__target_object', 59 | 'source_connections__source_object', 60 | 'source_connections__target_object', 61 | 'target_connections__source_object', 62 | 'target_connections__target_object', 63 | ) 64 | 65 | return messages 66 | -------------------------------------------------------------------------------- /telegram_bots/enums.py: -------------------------------------------------------------------------------- 1 | from django.db.models import IntegerChoices, TextChoices 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class APIRequestMethod(TextChoices): 6 | GET = 'get', 'GET' 7 | POST = 'post', 'POST' 8 | PUT = 'put', 'PUT' 9 | PATCH = 'patch', 'PATCH' 10 | DELETE = 'delete', 'DELETE' 11 | 12 | 13 | class ConnectionHandlePosition(TextChoices): 14 | LEFT = 'left', _('Слева') 15 | RIGHT = 'right', _('Справа') 16 | 17 | 18 | class ConnectionObjectType(TextChoices): 19 | TRIGGER = 'trigger', _('Триггер') 20 | MESSAGE = 'message', _('Сообщение') 21 | MESSAGE_KEYBOARD_BUTTON = ( 22 | 'message_keyboard_button', 23 | _('Кнопка клавиатуры сообщения'), 24 | ) 25 | CONDITION = 'condition', _('Условие') 26 | BACKGROUND_TASK = 'background_task', _('Фоновая задача') 27 | API_REQUEST = 'api_request', _('API-запрос') 28 | DATABASE_OPERATION = 'database_operation', _('Операция базы данных') 29 | 30 | @staticmethod 31 | def source_choices() -> list[tuple[str, str]]: 32 | return [ 33 | (item.value, item.label) 34 | for item in [ 35 | ConnectionObjectType.TRIGGER, 36 | ConnectionObjectType.MESSAGE, 37 | ConnectionObjectType.MESSAGE_KEYBOARD_BUTTON, 38 | ConnectionObjectType.CONDITION, 39 | ConnectionObjectType.BACKGROUND_TASK, 40 | ConnectionObjectType.API_REQUEST, 41 | ConnectionObjectType.DATABASE_OPERATION, 42 | ] 43 | ] 44 | 45 | @staticmethod 46 | def target_choices() -> list[tuple[str, str]]: 47 | return [ 48 | (item.value, item.label) 49 | for item in [ 50 | ConnectionObjectType.TRIGGER, 51 | ConnectionObjectType.MESSAGE, 52 | ConnectionObjectType.CONDITION, 53 | ConnectionObjectType.API_REQUEST, 54 | ConnectionObjectType.DATABASE_OPERATION, 55 | ] 56 | ] 57 | 58 | 59 | class KeyboardType(TextChoices): 60 | DEFAULT = 'default', _('Обычный') 61 | INLINE = 'inline', _('Встроенный') 62 | PAYMENT = 'payment', _('Платёжный') 63 | 64 | 65 | class ConditionPartType(TextChoices): 66 | POSITIVE = '+', _('Положительный') 67 | NEGATIVE = '-', _('Отрицательный') 68 | 69 | 70 | class ConditionPartOperatorType(TextChoices): 71 | EQUAL = '==', _('Равно') 72 | NOT_EQUAL = '!=', _('Не равно') 73 | GREATER = '>', _('Больше') 74 | GREATER_OR_EQUAL = '>=', _('Больше или равно') 75 | LESS = '<', _('Меньше') 76 | LESS_OR_EQUAL = '<=', _('Меньше или равно') 77 | 78 | 79 | class ConditionPartNextPartOperator(TextChoices): 80 | AND = '&&', _('И') 81 | OR = '||', _('ИЛИ') 82 | 83 | 84 | class BackgroundTaskInterval(IntegerChoices): 85 | DAY_1 = 1, _('1 день') 86 | DAYS_3 = 3, _('3 дня') 87 | DAYS_7 = 7, _('7 дней') 88 | DAYS_14 = 14, _('14 дней') 89 | DAYS_28 = 28, _('28 дней') 90 | -------------------------------------------------------------------------------- /telegram_bots/serializers/api_request.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.translation import gettext as _ 3 | 4 | from rest_framework import serializers 5 | 6 | from ..models import APIRequest 7 | from .base import DiagramSerializer 8 | from .connection import ConnectionSerializer 9 | from .mixins import TelegramBotMixin 10 | 11 | from typing import Any 12 | 13 | 14 | class APIRequestSerializer(TelegramBotMixin, serializers.ModelSerializer[APIRequest]): 15 | class Meta: 16 | model = APIRequest 17 | fields = ['id', 'name', 'url', 'method', 'headers', 'body'] 18 | 19 | def validate_headers(self, headers: list[Any] | dict[str, Any]) -> dict[str, str]: 20 | if not isinstance(headers, dict): 21 | raise serializers.ValidationError(_('Заголовки должны быть словарем.')) 22 | 23 | for key, value in headers.items(): 24 | if not isinstance(value, str): 25 | raise serializers.ValidationError( 26 | _("Значение для заголовка '%(key)s' должно быть строкой.") 27 | % {'key': key} 28 | ) 29 | 30 | return headers 31 | 32 | def validate(self, data: dict[str, Any]) -> dict[str, Any]: 33 | if ( 34 | not self.instance 35 | and self.telegram_bot.api_requests.count() + 1 36 | > settings.TELEGRAM_BOT_MAX_API_REQUESTS 37 | ): 38 | raise serializers.ValidationError( 39 | _('Нельзя добавлять больше %(max)s API-запросов.') 40 | % {'max': settings.TELEGRAM_BOT_MAX_API_REQUESTS}, 41 | code='max_limit', 42 | ) 43 | 44 | return data 45 | 46 | def create(self, validated_data: dict[str, Any]) -> APIRequest: 47 | return self.telegram_bot.api_requests.create(**validated_data) 48 | 49 | def update( 50 | self, api_request: APIRequest, validated_data: dict[str, Any] 51 | ) -> APIRequest: 52 | api_request.name = validated_data.get('name', api_request.name) 53 | api_request.url = validated_data.get('url', api_request.url) 54 | api_request.method = validated_data.get('method', api_request.method) 55 | api_request.headers = validated_data.get('headers', api_request.headers) 56 | api_request.body = validated_data.get('body', api_request.body) 57 | api_request.save(update_fields=['name', 'url', 'method', 'headers', 'body']) 58 | 59 | return api_request 60 | 61 | 62 | class DiagramAPIRequestSerializer(DiagramSerializer[APIRequest]): 63 | source_connections = ConnectionSerializer(many=True, read_only=True) 64 | target_connections = ConnectionSerializer(many=True, read_only=True) 65 | 66 | class Meta: 67 | model = APIRequest 68 | fields = [ 69 | 'id', 70 | 'name', 71 | 'url', 72 | 'method', 73 | 'source_connections', 74 | 'target_connections', 75 | ] + DiagramSerializer.Meta.fields 76 | read_only_fields = ['name', 'url', 'method'] 77 | -------------------------------------------------------------------------------- /donation/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-12-10 12:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Donation', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('sum', models.FloatField(verbose_name='Сумма')), 19 | ('sender', models.CharField(max_length=64, verbose_name='Отправитель')), 20 | ('date', models.DateTimeField(verbose_name='Дата')), 21 | ], 22 | options={ 23 | 'verbose_name': 'Пожертвование', 24 | 'verbose_name_plural': 'Пожертвования', 25 | 'db_table': 'donation', 26 | 'ordering': ['-sum'], 27 | }, 28 | ), 29 | migrations.CreateModel( 30 | name='Method', 31 | fields=[ 32 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 33 | ('text', models.CharField(max_length=128, verbose_name='Текст')), 34 | ('link', models.URLField(blank=True, null=True, verbose_name='Ссылка')), 35 | ('value', models.CharField(blank=True, max_length=255, null=True, verbose_name='Значение')), 36 | ('position', models.PositiveSmallIntegerField(blank=True, default=0, verbose_name='Позиция')), 37 | ], 38 | options={ 39 | 'verbose_name': 'Метод поддержки', 40 | 'verbose_name_plural': 'Методы поддержки', 41 | 'ordering': ['position'], 42 | }, 43 | ), 44 | migrations.CreateModel( 45 | name='Section', 46 | fields=[ 47 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 48 | ('title', models.CharField(max_length=255, verbose_name='Заголовок')), 49 | ('title_en', models.CharField(max_length=255, null=True, verbose_name='Заголовок')), 50 | ('title_uk', models.CharField(max_length=255, null=True, verbose_name='Заголовок')), 51 | ('title_ru', models.CharField(max_length=255, null=True, verbose_name='Заголовок')), 52 | ('text', models.TextField(verbose_name='Текст')), 53 | ('text_en', models.TextField(null=True, verbose_name='Текст')), 54 | ('text_uk', models.TextField(null=True, verbose_name='Текст')), 55 | ('text_ru', models.TextField(null=True, verbose_name='Текст')), 56 | ('position', models.PositiveSmallIntegerField(blank=True, default=0, verbose_name='Позиция')), 57 | ], 58 | options={ 59 | 'verbose_name': 'Раздел', 60 | 'verbose_name_plural': 'Разделы', 61 | 'ordering': ['position'], 62 | }, 63 | ), 64 | ] 65 | -------------------------------------------------------------------------------- /users/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.base_user import BaseUserManager 2 | from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin 3 | from django.db import models 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from django_stubs_ext.db.models import TypedModelMeta 7 | 8 | from telegram_bots.models import TelegramBot 9 | 10 | from .enums import TokenType 11 | 12 | from typing import TYPE_CHECKING, Any 13 | 14 | 15 | class UserManager(BaseUserManager['User']): 16 | def create_superuser(self, **fields: Any) -> 'User': 17 | return self.create(is_staff=True, is_superuser=True, **fields) 18 | 19 | 20 | class User(AbstractBaseUser, PermissionsMixin): 21 | password = None # type: ignore [assignment] 22 | 23 | telegram_id = models.PositiveBigIntegerField('Telegram ID', unique=True) 24 | first_name = models.CharField(_('Имя'), max_length=64) 25 | last_name = models.CharField(_('Фамилия'), max_length=64, null=True) 26 | accepted_terms = models.BooleanField(_('Принятие условий сервиса'), default=False) 27 | terms_accepted_date = models.DateTimeField( 28 | _('Дата принятия условий сервиса'), null=True, blank=True 29 | ) 30 | is_staff = models.BooleanField(_('Сотрудник'), default=False) 31 | joined_date = models.DateTimeField(_('Присоединился'), auto_now_add=True) 32 | 33 | if TYPE_CHECKING: 34 | tokens: models.Manager['Token'] 35 | telegram_bots: models.Manager[TelegramBot] 36 | 37 | USERNAME_FIELD = 'telegram_id' 38 | 39 | objects = UserManager() 40 | 41 | class Meta(TypedModelMeta): 42 | db_table = 'user' 43 | verbose_name = _('Пользователя') 44 | verbose_name_plural = _('Пользователи') 45 | 46 | @property 47 | def full_name(self) -> str: 48 | return f"{self.first_name} {self.last_name or ''}".strip() 49 | 50 | def __str__(self) -> str: 51 | return f'Telegram ID: {self.telegram_id}' 52 | 53 | 54 | class Token(models.Model): 55 | user = models.ForeignKey( 56 | User, 57 | on_delete=models.SET_NULL, 58 | related_name='tokens', 59 | verbose_name=_('Пользователь'), 60 | blank=True, 61 | null=True, 62 | ) 63 | jti = models.CharField('JWT ID', primary_key=True, max_length=32) 64 | type = models.CharField(_('Тип'), max_length=7, choices=TokenType) 65 | expiry_date = models.DateTimeField(_('Срок действия')) 66 | created_date = models.DateTimeField(_('Создан'), auto_now_add=True) 67 | 68 | if TYPE_CHECKING: 69 | blacklisted: 'BlacklistedToken' 70 | 71 | class Meta(TypedModelMeta): 72 | db_table = 'user_token' 73 | verbose_name = _('Токен') 74 | verbose_name_plural = _('Токены') 75 | 76 | 77 | class BlacklistedToken(models.Model): 78 | token = models.OneToOneField( 79 | Token, 80 | on_delete=models.CASCADE, 81 | related_name='blacklisted', 82 | verbose_name=_('Токен'), 83 | ) 84 | blacklisted_date = models.DateTimeField(_('Внесён'), auto_now_add=True) 85 | 86 | class Meta(TypedModelMeta): 87 | db_table = 'user_blacklisted_token' 88 | verbose_name = _('Токен в чёрном списке') 89 | verbose_name_plural = _('Токены в чёрном списке') 90 | -------------------------------------------------------------------------------- /users/backends.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.backends import ModelBackend 3 | from django.http import HttpRequest 4 | from django.utils.translation import gettext as _ 5 | 6 | from rest_framework.exceptions import APIException, PermissionDenied, ValidationError 7 | 8 | from .models import User 9 | 10 | from typing import Any, Literal, overload 11 | import hashlib 12 | import hmac 13 | import time 14 | 15 | 16 | class TelegramBackend(ModelBackend): 17 | @overload # type: ignore [override] 18 | def authenticate( 19 | self, 20 | request: HttpRequest, 21 | hash: str, 22 | raise_exception: Literal[False], 23 | **data: Any, 24 | ) -> User | None: ... 25 | 26 | @overload 27 | def authenticate( 28 | self, 29 | request: HttpRequest, 30 | hash: str, 31 | raise_exception: Literal[True], 32 | **data: Any, 33 | ) -> User: ... 34 | 35 | def authenticate( 36 | self, 37 | request: HttpRequest, 38 | hash: str, 39 | raise_exception: bool = False, 40 | **data: Any, 41 | ) -> User | None: 42 | telegram_id: int = data['id'] 43 | first_name: str = data['first_name'] 44 | 45 | if settings.ENABLE_TELEGRAM_AUTH: 46 | try: 47 | self._validate_auth_date(int(data['auth_date'])) 48 | self._validate_auth_data(data, hash) 49 | except APIException as error: 50 | if raise_exception: 51 | raise error 52 | 53 | return None 54 | 55 | last_name: str | None = data.get('last_name') 56 | 57 | user, created = User.objects.get_or_create( 58 | telegram_id=telegram_id, 59 | defaults={'first_name': first_name, 'last_name': last_name}, 60 | ) 61 | 62 | if not self.user_can_authenticate(user): 63 | if raise_exception: 64 | raise PermissionDenied( 65 | _('Пользователь не может быть аутентифицирован.') 66 | ) 67 | 68 | return None 69 | 70 | if not created: 71 | user.first_name = first_name 72 | user.last_name = last_name 73 | user.save(update_fields=['first_name', 'last_name']) 74 | 75 | return user 76 | 77 | def _validate_auth_date(self, auth_unix_date: int) -> None: 78 | if int(time.time()) - auth_unix_date > 86400: 79 | raise ValidationError( 80 | _('Срок действия данных аутентификации от Telegram истёк.') 81 | ) 82 | 83 | def _validate_auth_data(self, data: dict[str, Any], hash: str) -> None: 84 | secret_key: bytes = hashlib.sha256( 85 | settings.TELEGRAM_BOT_TOKEN.encode() 86 | ).digest() 87 | data_check_string: str = '\n'.join( 88 | [f'{key}={data[key]}' for key in sorted(data.keys())] 89 | ) 90 | result_hash: str = hmac.new( 91 | secret_key, data_check_string.encode(), hashlib.sha256 92 | ).hexdigest() 93 | 94 | if not hmac.compare_digest(result_hash, hash): 95 | raise ValidationError( 96 | _('Данные аутентификации Telegram являются подделкой.') 97 | ) 98 | --------------------------------------------------------------------------------