├── 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 |
--------------------------------------------------------------------------------