diff --git a/.gitignore b/.gitignore index a637850..f9979e8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ delme*.py /venv/ oidc.key /log/ +/logs/*.log diff --git a/apps/core/admin.py b/apps/core/admin.py index c6fe108..69b3eff 100644 --- a/apps/core/admin.py +++ b/apps/core/admin.py @@ -1,2 +1,17 @@ from django.contrib import admin +from .models import SMSPolicy, Config + +class SMSPolicyAdmin(admin.ModelAdmin): + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + + +class ConfigAdmin(admin.ModelAdmin): + list_display = ['key', 'value', 'value_type', 'get_value', 'description', 'comment'] + +admin.site.register(Config, ConfigAdmin) +admin.site.register(SMSPolicy, SMSPolicyAdmin) diff --git a/apps/core/decorators.py b/apps/core/decorators.py new file mode 100644 index 0000000..3c547bc --- /dev/null +++ b/apps/core/decorators.py @@ -0,0 +1,31 @@ +from functools import wraps + +from django.utils import timezone + +from apps.core.models import Config, ConfigValueChoices +from utils.exceptions import ServiceUnavailable + + +def service_availability(key:str): + def with_params(view_func): + @wraps(view_func) + def wrapper(self, request, *args, **kwargs): + print(key) + print(f"Executing {view_func.__name__} action") + + service_is_available = Config.get_value_of(f"SERVICE_IS_AVAILABLE_{key.upper()}", "True", ConfigValueChoices.BOOLEAN) + config = Config.objects.get(key=f"SERVICE_IS_AVAILABLE_{key.upper()}") + if not service_is_available: + raise ServiceUnavailable( + detail={ + # "code": 'service_unavailable_at_now', + # "timestamp": timezone.now().isoformat(), + "message": config.description or "این سرویس در حال حاضر در دسترس نیست" + }, + ) + + return view_func(self, request, *args, **kwargs) + + return wrapper + + return with_params diff --git a/apps/core/migrations/0001_initial.py b/apps/core/migrations/0001_initial.py new file mode 100644 index 0000000..30c7f22 --- /dev/null +++ b/apps/core/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 6.0.2 on 2026-03-16 12:23 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SMSPolicy', + fields=[ + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True)), + ('preferred', models.CharField(choices=[('fake', 'fake'), ('payam_sms', 'payam_sms'), ('mobin_sms', 'mobin_sms')], default='fake', max_length=16)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, unique=True)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/core/migrations/0002_config.py b/apps/core/migrations/0002_config.py new file mode 100644 index 0000000..5ffcf17 --- /dev/null +++ b/apps/core/migrations/0002_config.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.2 on 2026-04-06 05:59 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Config', + fields=[ + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True)), + ('key', models.CharField(db_index=True, max_length=255, unique=True, verbose_name='key')), + ('value', models.TextField(blank=True, null=True, verbose_name='value')), + ('value_type', models.CharField(choices=[('INT', 'Integer'), ('FLOAT', 'Float'), ('BOOLEAN', 'Boolean'), ('STRING', 'String'), ('DATE', 'Date'), ('DATETIME', 'DateTime'), ('JSON', 'JSON')], max_length=64)), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('comment', models.TextField(blank=True, null=True, verbose_name='comment')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/core/models.py b/apps/core/models.py index 56987d4..f4ade93 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -1,2 +1,106 @@ +import logging + +from django.db.models import TextChoices + +from apps.gooyal_oauth2.settings import oauth2_settings +from utils.clients.sms.fake.client import FakeClient +from utils.clients.sms.mobin_sms.client import MobinSMSClient +from utils.clients.sms.payam_sms.client import PayamSMSClient +from utils.clients.sms.sms import BaseSMSClient +from utils.models import BaseModel + +logger = logging.getLogger(__name__) + +import json +from datetime import datetime + from django.db import models from django.utils.translation import gettext_lazy as _ + + +class SMSClienChoises(TextChoices): + FAKE = "fake", _("fake") + PAYAM_SMS = 'payam_sms', _('payam_sms') + MOBIN_SMS = 'mobin_sms', _('mobin_sms') + + +class SMSPolicy(BaseModel): + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT, + related_name='+', null=True, blank=True, unique=True) + preferred = models.CharField(max_length=16, choices=SMSClienChoises.choices, default=SMSClienChoises.FAKE) + + def __str__(self): + return self.get_preferred_display() + + @staticmethod + def get_client() -> BaseSMSClient: + policy = SMSPolicy.objects.get_or_create(application=None)[0] + if policy.preferred == SMSClienChoises.MOBIN_SMS: + return MobinSMSClient() + elif policy.preferred == SMSClienChoises.PAYAM_SMS: + return PayamSMSClient() + else: + return FakeClient() + + +class ConfigValueChoices(models.TextChoices): + INT = 'INT', _('Integer') + FLOAT = 'FLOAT', _('Float') + BOOLEAN = 'BOOLEAN', _('Boolean') + STRING = 'STRING', _('String') + DATE = 'DATE', _('Date') + DATETIME = 'DATETIME', _('DateTime') + JSON = 'JSON', _('JSON') + + +class Config(BaseModel): + key = models.CharField(_('key'), max_length=255, unique=True, db_index=True) + value = models.TextField(_('value'), null=True, blank=True) + value_type = models.CharField(choices=ConfigValueChoices.choices, max_length=64) + description = models.TextField(_('description'), null=True, blank=True) + comment = models.TextField(_('comment'), null=True, blank=True) + + def get_value(self): + return Config.get_value_of(self.key) + + @staticmethod + def type_cast(value, value_type): + + try: + if value_type == ConfigValueChoices.STRING.value: + value = str(value) + + elif value_type == ConfigValueChoices.BOOLEAN.value: + value = value.lower() == 'true' + + elif value_type == ConfigValueChoices.DATE.value: + value = datetime.fromisoformat(value) + + elif value_type == ConfigValueChoices.DATETIME.value: + value = datetime.fromisoformat(value) + + elif value_type == ConfigValueChoices.JSON.value: + value = json.loads(value) + + elif value_type == ConfigValueChoices.INT.value: + value = int(value) + + elif value_type == ConfigValueChoices.FLOAT.value: + value = float(value) + + except Exception as e: + value = None + + return value + + @staticmethod + def get_value_of(key, default=None, casting_type=None): + # TODO: use cache + config, created = Config.objects.get_or_create(key=key, defaults={'value': default}) + + if config.value is None: + return default + + value = Config.type_cast(config.value, casting_type or config.value_type) + + return value diff --git a/apps/core/serializers.py b/apps/core/serializers.py new file mode 100644 index 0000000..16a58a1 --- /dev/null +++ b/apps/core/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from .models import Config + + +class ConfigSerializer(serializers.ModelSerializer): + class Meta: + model = Config + fields = ['key', 'value', 'value_type'] diff --git a/apps/core/urls.py b/apps/core/urls.py index 7f82b8a..7aec53e 100644 --- a/apps/core/urls.py +++ b/apps/core/urls.py @@ -1,7 +1,17 @@ from django.urls import path, include -from .views import HomeView +from rest_framework.routers import DefaultRouter + +from .views import HomeView, TestIpView, HealthcheckView, StatusView + app_name = "core" +router = DefaultRouter() + +# router.register('api/config', ConfigViewSet, basename='configs') + urlpatterns = [ path('', HomeView.as_view(), name='home'), + path('test-ip', TestIpView.as_view(), name='test-ip'), + path('status', StatusView.as_view(), name='status'), + path('healthcheck', HealthcheckView.as_view(), name='healthcheck'), ] diff --git a/apps/core/views.py b/apps/core/views.py index a3b2197..a052e5e 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -2,9 +2,82 @@ from django.contrib.auth.decorators import login_required from django.shortcuts import render from django.utils.decorators import method_decorator from django.views.generic import TemplateView +from django.contrib.sessions.backends.db import SessionStore +from django.contrib.sessions.models import Session +from rest_framework import mixins +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.viewsets import GenericViewSet + +from apps.core.decorators import service_availability +from apps.core.models import Config +from apps.core.serializers import ConfigSerializer +from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements + -# Create your views here. # @method_decorator(login_required, name='dispatch') class HomeView(TemplateView): template_name = 'core/home.html' + # def get(self, request, *args, **kwargs): + # session = request.session + # print(type(session)) + # print(session.session_key) + # return super(HomeView, self).get(request, *args, **kwargs) + + +class TestIpView(TemplateView): + template_name = 'core/home.html' + + def get(self, request, *args, **kwargs): + print(request.headers) + headers_data = {'Host': 'accounts.gooyal.ir', + 'X-Real-Ip': '212.23.216.131', + 'X-Forwarded-For': '5.216.121.72, 212.23.216.131', + 'X-Forwarded-Proto': 'http', + 'Connection': 'close', + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en,en-US;q=0.7,fa;q=0.3', + 'Accept-Encoding': 'gzip, deflate, br, zstd', + 'Cookie': 'csrftoken=xor4RVhDDuhQ5l4hf44iI3o2I0FJU8kJ; sessionid=3c0kju13bswh6ufu3mdnl1u38wi7d4b3', + 'Upgrade-Insecure-Requests': '1', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-User': '?1', + 'Priority': 'u=0, i'} + + return super(TestIpView, self).get(request, *args, **kwargs) + + +class ConfigViewSet(mixins.ListModelMixin, GenericViewSet): + queryset = Config.objects.all() + serializer_class = ConfigSerializer + permission_classes = [AllowAny] + + +class StatusView(APIView): + permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] + required_alternate_scopes = { + "GET": [["accounts.status:get"]], + } + + @service_availability('status') + def get(self, request, format=None): + + data = { + "status": "ok", + } + return Response(data) + + +class HealthcheckView(APIView): + permission_classes = [AllowAny] + + def get(self, request, format=None): + data = { + "is_healthy": True + } + return Response(data) diff --git a/apps/gooyal_oauth2/admin.py b/apps/gooyal_oauth2/admin.py index 60d341d..2624558 100755 --- a/apps/gooyal_oauth2/admin.py +++ b/apps/gooyal_oauth2/admin.py @@ -7,7 +7,7 @@ from django.contrib.admin.sites import NotRegistered from oauth2_provider.admin import ApplicationAdmin -from .models import Application, Resource, Scope +from .models import Application, Scope from .forms import ApplicationForm @@ -23,14 +23,9 @@ class ApplicationAdmin(ApplicationAdmin): form = ApplicationForm -@admin.register(Resource) -class ResourceAdmin(admin.ModelAdmin): - list_display = ("name", "user", "expires") - - @admin.register(Scope) class ScopeAdmin(admin.ModelAdmin): - list_display = ('name', "resource", 'description', 'is_default') + list_display = ('name', 'description', 'is_default') # admin.site.register(RestrictedApplication, RestrictedApplicationAdmin) diff --git a/apps/gooyal_oauth2/decorators.py b/apps/gooyal_oauth2/decorators.py new file mode 100644 index 0000000..717c3a8 --- /dev/null +++ b/apps/gooyal_oauth2/decorators.py @@ -0,0 +1,2 @@ +def session_limit_count(count=0): + pass diff --git a/apps/gooyal_oauth2/migrations/0006_remove_scope_resource_remove_application_resource_and_more.py b/apps/gooyal_oauth2/migrations/0006_remove_scope_resource_remove_application_resource_and_more.py new file mode 100644 index 0000000..ee76906 --- /dev/null +++ b/apps/gooyal_oauth2/migrations/0006_remove_scope_resource_remove_application_resource_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.2 on 2026-04-05 13:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gooyal_oauth2', '0005_alter_refreshtoken_unique_together_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='scope', + name='resource', + ), + migrations.RemoveField( + model_name='application', + name='resource', + ), + migrations.AddField( + model_name='application', + name='max_allowed_session', + field=models.PositiveIntegerField(default=0), + ), + migrations.AlterField( + model_name='application', + name='authorization_grant_type', + field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('urn:ietf:params:oauth:grant-type:device_code', 'Device Code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=44), + ), + migrations.DeleteModel( + name='Resource', + ), + ] diff --git a/apps/gooyal_oauth2/models.py b/apps/gooyal_oauth2/models.py index cafa003..89bcab1 100644 --- a/apps/gooyal_oauth2/models.py +++ b/apps/gooyal_oauth2/models.py @@ -23,19 +23,6 @@ from .settings import oauth2_settings logger = logging.getLogger(__name__) -class Resource(BaseModel): - name = models.CharField(max_length=255) - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, - related_name="resources" - ) - expires = models.DateTimeField() - - def __str__(self): - return self.name - - class Application(AbstractApplication, BaseModel): """ Application model for use with Django OAuth Toolkit that allows the scopes @@ -49,15 +36,10 @@ class Application(AbstractApplication, BaseModel): on_delete=models.PROTECT ) allowed_scope = models.TextField(blank=True) - resource = models.OneToOneField( - Resource, - models.PROTECT, - blank=True, null=True, - help_text='The resource of application.', - related_name='application' - ) avatar = models.ImageField(upload_to='avatars', null=True, blank=True) + max_allowed_session = models.PositiveIntegerField(default=0) + @property def allowed_scopes(self): @@ -96,13 +78,6 @@ class Scope(BaseModel): help_text='The application to which the scope belongs.', related_name='scopes' ) - resource = models.ForeignKey( - Resource, - models.PROTECT, - blank=True, null=True, - help_text='The resource of scope.', - related_name='scopes' - ) #: The name of the scope name = models.CharField( max_length=255, @@ -122,19 +97,10 @@ class Scope(BaseModel): @property def final_name(self): - args = [] - if self.resource: - args.append(self.resource.name) - - args.append(self.name) - return '.'.join(args) + return self.name @property def final_description(self): - resource_name = self.resource and self.resource.name - - if resource_name: - return f"{resource_name} -> {self.description}" return self.description @classmethod diff --git a/apps/gooyal_oauth2/scopes.py b/apps/gooyal_oauth2/scopes.py index 64d979f..3cce1d2 100644 --- a/apps/gooyal_oauth2/scopes.py +++ b/apps/gooyal_oauth2/scopes.py @@ -18,7 +18,7 @@ class Scopes(BaseScopes): def get_queryset(self, application=None): queryset = Scope.objects.all() if application: - queryset = queryset.filter(name__in=application.allowed_scopes).order_by('resource__uuid') + queryset = queryset.filter(name__in=application.allowed_scopes) return queryset diff --git a/apps/gooyal_oauth2/tests.py b/apps/gooyal_oauth2/tests.py new file mode 100644 index 0000000..f2433c8 --- /dev/null +++ b/apps/gooyal_oauth2/tests.py @@ -0,0 +1,212 @@ +import base64 +import json +import uuid +from datetime import timedelta +from unicodedata import category +from unittest.mock import patch + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from oauth2_provider.models import get_access_token_model, get_application_model +from rest_framework import status +from rest_framework.test import APIClient, APITestCase + + +from apps.core.models import Config +from apps.gooyal_oauth2.models import Scope +from apps.users.models import User + +AccessToken = get_access_token_model() +Application = get_application_model() + + + +def mock_notifications_push_user_success(user_uuid, title, message, priority=5, extras=None): + import uuid as sys_uuid + class Tmp(): + uuid = sys_uuid.uuid4() + + data = Tmp() + return data + + +class GooyalOAuth2Tests(APITestCase): + user_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb47005f') + access_token_1 = 'au4naVsdKCbKNOhnElPyXcrwSnqqFbm' + access_token_2 = 'vu4naVsdKCbKNOhnElPyXcrwSnqqFbm' + application_uuid = uuid.UUID('a14e8b86-8f4a-44d9-b29d-badceb47005f') + client_id = '4INGOCMoulE0fNY1SQlTbPtsWqqxGj2DdqjADq6u' + client_secret = '4INGOCMoulE0fNY1SQlTbPtsWqqxGj2DdqjADq6u' + + visitor_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb47005a') + + expire_datetime = timezone.now() + timedelta(seconds=3600) + expire_datetime.isoformat() + client = APIClient() + + def setUp(self): + from django.conf import settings + settings.SMS_SEND = False + + self.notifications_push_user_success_patcher = patch('apps.users.models.User.notify', + mock_notifications_push_user_success) + + self.notifications_push_user_success_patcher.start() + self.user_phone_number = '+989100000000' + self.user = User.objects.create(pk=self.user_uuid, phone_number=self.user_phone_number) + + scope = Scope.objects.create(name='accounts.status:get', description='accounts.status:get') + + self.application = Application.objects.create( + + client_id=self.client_id, + client_secret=self.client_secret, + authorization_grant_type='password', + hash_client_secret=False, + uuid=self.application_uuid, + user_id=self.user_uuid, + max_allowed_session=1, + allowed_scope='accounts.status:get', + ) + + # self.sys_date_patcher = patch('simata_safte.models.get_sys_date', mock_get_sys_date) + # self.set_id_patcher = patch('simata_safte.models.set_id', mock_set_id) + # self.sign_pdf_patcher = patch('simata_safte.models.sign_pdf', mock_sign_pdf) + # self.check_pdf_signature_patcher = patch('simata_safte.models.check_pdf_signature', mock_check_pdf_signature) + + def tearDown(self): + super().tearDown() + + + + def _create_authorization_header(self, token): + return "Bearer {0}".format(token) + + def test_authentication_allow(self): + access_token_1 = AccessToken.objects.create( + **{ + "token": self.access_token_1, + "user": self.user, + # "client_id": self.client_id, + # "client_owner": owner, + "application_id": self.application_uuid, + "scope": 'accounts.status:get', + "expires": self.expire_datetime.isoformat(), + }, + ) + + auth_1 = self._create_authorization_header(access_token_1.token) + + response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_1) + + self.assertContains(response, 'status') + + access_token_2 = AccessToken.objects.create( + **{ + "token": self.access_token_2, + "user": self.user, + + # "client_id": self.client_id, + # "client_owner": owner, + "application_id": self.application_uuid, + "scope": 'accounts.status:get', + "expires": self.expire_datetime.isoformat(), + }, + ) + + auth_2 = self._create_authorization_header(access_token_2.token) + + response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_1) + self.assertEqual(response.status_code, 200) + + response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_2) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + + self.application.max_allowed_session = 0 + self.application.save() + self.application.refresh_from_db() + + response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_2) + self.assertEqual(response.status_code, 200) + + def basic_auth_string(self, username, password): + """ساخت Basic Auth string""" + import base64 + user_pass = f"{username}:{password}" + basic_credentials = base64.b64encode(user_pass.encode('utf-8')).decode('utf-8') + + return basic_credentials + + def login(self): + self.user.set_otp() + + data = { + "grant_type": "password", + "username": self.user_phone_number, + "password": '77501', + "scope": 'accounts.status:get', + "auth_fields": 'phone_number:otp' + } + self.client.credentials( + HTTP_AUTHORIZATION='Basic ' + self.basic_auth_string(self.client_id, self.client_secret) + ) + + result = self.client.post(reverse("gooyal_oauth2:token"), data=data) + access_token = result.json()['access_token'] + self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + access_token) + + self.assertEqual(result.status_code, 200) + return result + + + def test_loginByOTP_allOK_success(self): + print(self.login()) + response = self.client.get(reverse("core:status")) + print(response.status_code) + + def test_revoke_token(self): + self.user.set_otp() + + data = { + "grant_type": "password", + "username": self.user_phone_number, + "password": '77501', + "scope": 'accounts.status:get', + "auth_fields": 'phone_number:otp' + } + self.client.credentials( + HTTP_AUTHORIZATION='Basic ' + self.basic_auth_string(self.client_id, self.client_secret) + ) + result = self.client.post(reverse("gooyal_oauth2:token"), data=data) + access_token = result.json()['access_token'] + + revoke_data = { + "token": access_token, + } + + result = self.client.post(reverse("gooyal_oauth2:revoke-token"), data=revoke_data) + self.assertEqual(result.status_code, 200) + self.assertEqual(AccessToken.objects.count(), 0) + + self.user.refresh_from_db() + self.user.set_otp() + self.client.credentials( + HTTP_AUTHORIZATION='Basic ' + self.basic_auth_string(self.client_id, self.client_secret) + ) + result = self.client.post(reverse("gooyal_oauth2:token"), data=data) + access_token = result.json()['access_token'] + access_token_object = AccessToken.objects.first() + revoke_data = { + "token": access_token_object.pk, + } + result = self.client.post(reverse("gooyal_oauth2:revoke-token"), data=revoke_data) + self.assertEqual(result.status_code, 200) + self.assertEqual(AccessToken.objects.count(), 0) + + + + + + + diff --git a/apps/gooyal_oauth2/throttling.py b/apps/gooyal_oauth2/throttling.py new file mode 100644 index 0000000..392cabf --- /dev/null +++ b/apps/gooyal_oauth2/throttling.py @@ -0,0 +1,47 @@ +from rest_framework.throttling import UserRateThrottle + +from apps.gooyal_oauth2.models import AccessToken + + +def get_application(request): + try: + application = request.auth.application + except: + application = None + return application + + +class TokenLimitThrottle: + def allow_request(self, request, view): + application = get_application(request) + + if application: + if hasattr(view, "max_allowed_session"): + max_allowed_session = view.max_allowed_session + else: + max_allowed_session = application.max_allowed_session + + if max_allowed_session: + session_count = AccessToken.objects.filter(application=application, user=request.user).count() + if max_allowed_session >= session_count: + return True + else: + from utils.exceptions import Conflict + raise Conflict(code='max_allowed_session_reached') + + active_tokens = AccessToken.objects.filter( + application=application, user=request.user + ).order_by("created")[:max_allowed_session].values_list("token", flat=True) + if request.auth.token in active_tokens: + return True + else: + from utils.exceptions import Conflict + raise Conflict(code='max_allowed_session_reached') + + else: + return True + + return True + + def wait(self): + pass diff --git a/apps/gooyal_oauth2/urls.py b/apps/gooyal_oauth2/urls.py index 62e9055..78f2c19 100644 --- a/apps/gooyal_oauth2/urls.py +++ b/apps/gooyal_oauth2/urls.py @@ -3,7 +3,7 @@ from django.urls import re_path, path from oauth2_provider import views from rest_framework import routers -from .views.introspect import IntrospectTokenView, IntrospectApplicationView, TokenView +from .views.oauth_views import IntrospectTokenView, IntrospectApplicationView, TokenView, GooyalRevokeTokenView from .views import apis as api_views from .views import pages app_name = "gooyal_oauth2" diff --git a/apps/gooyal_oauth2/validators.py b/apps/gooyal_oauth2/validators.py index 797cddf..0928ccc 100755 --- a/apps/gooyal_oauth2/validators.py +++ b/apps/gooyal_oauth2/validators.py @@ -1,22 +1,34 @@ import base64 import binascii import logging +from copy import deepcopy from datetime import datetime, timedelta from urllib.parse import unquote_plus import requests # import service_clients from django.contrib.auth import get_user_model +from django.utils import timezone from django.utils.timezone import make_aware -from oauth2_provider.models import get_access_token_model +from oauth2_provider.exceptions import FatalClientError +from oauth2_provider.models import get_access_token_model, get_application_model, get_id_token_model, get_grant_model, \ + get_refresh_token_model from oauth2_provider.oauth2_validators import OAuth2Validator as BaseOAuth2Validator +from requests import Session + +from utils.exceptions import ServiceUnavailable from .settings import oauth2_settings from django.conf import settings +from django.db import router, transaction log = logging.getLogger("oauth2_provider") -AccessTokenModel = get_access_token_model() UserModel = get_user_model() +Application = get_application_model() +AccessToken = get_access_token_model() +IDToken = get_id_token_model() +Grant = get_grant_model() +RefreshToken = get_refresh_token_model() class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223 @@ -77,3 +89,199 @@ class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223 def get_oidc_issuer_endpoint(self, request): return oauth2_settings.oidc_issuer(request) + + def save_token(self, token, request, *args, **kwargs): + """Persist the token with a token type specific method. + + Currently, only save_bearer_token is supported. + + :param token: A (Bearer) token dict. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + """ + return self.save_bearer_token(token, request, *args, **kwargs) + + def save_bearer_token(self, token, request, *args, **kwargs): + """ + Save access and refresh token. + + Override _save_bearer_token and not this function when adding custom logic + for the storing of these token. This allows the transaction logic to be + separate from the token handling. + """ + # Use the AccessToken's database instead of making the assumption it is in 'default'. + with transaction.atomic(using=router.db_for_write(AccessToken)): + return self._save_bearer_token(token, request, *args, **kwargs) + + def _save_bearer_token(self, token, request, *args, **kwargs): + """ + Save access and refresh token. + + If refresh token is issued, remove or reuse old refresh token as in rfc:`6`. + + @see: https://rfc-editor.org/rfc/rfc6749.html#section-6 + """ + + if "scope" not in token: + raise FatalClientError("Failed to renew access token: missing scope") + + # expires_in is passed to Server on initialization + # custom server class can have logic to override this + expires = timezone.now() + timedelta( + seconds=token.get( + "expires_in", + oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS, + ) + ) + + if request.grant_type == "client_credentials": + request.user = None + + # This comes from OAuthLib: + # https://github.com/idan/oauthlib/blob/1.0.3/oauthlib/oauth2/rfc6749/tokens.py#L267 + # Its value is either a new random code; or if we are reusing + # refresh tokens, then it is the same value that the request passed in + # (stored in `request.refresh_token`) + refresh_token_code = token.get("refresh_token", None) + + if refresh_token_code: + # an instance of `RefreshToken` that matches the old refresh code. + # Set on the request in `validate_refresh_token` + refresh_token_instance = getattr(request, "refresh_token_instance", None) + + # If we are to reuse tokens, and we can: do so + if ( + not self.rotate_refresh_token(request) + and isinstance(refresh_token_instance, RefreshToken) + and refresh_token_instance.access_token + ): + access_token = AccessToken.objects.select_for_update().get( + pk=refresh_token_instance.access_token.pk + ) + access_token.user = request.user + access_token.scope = token["scope"] + access_token.expires = expires + access_token.token = token["access_token"] + access_token.application = request.client + access_token.save() + + # else create fresh with access & refresh tokens + else: + # revoke existing tokens if possible to allow reuse of grant + if isinstance(refresh_token_instance, RefreshToken): + # First, to ensure we don't have concurrency issues, we refresh the refresh token + # from the db while acquiring a lock on it + # We also put it in the "request cache" + refresh_token_instance = RefreshToken.objects.select_for_update().get( + pk=refresh_token_instance.pk + ) + request.refresh_token_instance = refresh_token_instance + + previous_access_token = AccessToken.objects.filter( + source_refresh_token=refresh_token_instance + ).first() + try: + refresh_token_instance.revoke() + except (AccessToken.DoesNotExist, RefreshToken.DoesNotExist): + pass + else: + setattr(request, "refresh_token_instance", None) + else: + previous_access_token = None + + # If the refresh token has already been used to create an + # access token (ie it's within the grace period), return that + # access token + if not previous_access_token: + access_token = self._create_access_token( + expires, + request, + token, + source_refresh_token=refresh_token_instance, + ) + + self._create_refresh_token( + request, refresh_token_code, access_token, refresh_token_instance + ) + else: + # make sure that the token data we're returning matches + # the existing token + token["access_token"] = previous_access_token.token + token["refresh_token"] = ( + RefreshToken.objects.filter(access_token=previous_access_token).first().token + ) + token["scope"] = previous_access_token.scope + + # No refresh token should be created, just access token + else: + self._create_access_token(expires, request, token) + + def _create_access_token(self, expires, request, token, source_refresh_token=None): + id_token = token.get("id_token", None) + if id_token: + id_token = self._load_id_token(id_token) + headers = {} + for header, value in dict(request.headers).items(): + if type(value) in [str, bool, int, float, tuple, list]: + print(f'unserializable header: {header} -> {value}') + headers[header] = value + + return AccessToken.objects.create( + user=request.user, + scope=token["scope"], + expires=expires, + token=token["access_token"], + id_token=id_token, + application=request.client, + source_refresh_token=source_refresh_token, + detail={'headers': headers}, + ) + + # def _get_token_from_authentication_server + # def validate_bearer_token(self, token, scopes, request): + # result = super().validate_bearer_token(token, scopes, request) + # if result: + # application = request.client + # if hasattr(request, "max_allowed_session"): + # max_allowed_session = request.max_allowed_session + # else: + # max_allowed_session = application.max_allowed_session + # + # if max_allowed_session: + # session_count = AccessToken.objects.filter(application=application, user=request.user).count() + # if max_allowed_session >= session_count: + # return result + # else: + # active_tokens = AccessToken.objects.filter( + # application=application, user=request.user + # ).order_by("created")[:max_allowed_session].values_list("token", flat=True) + # if token in active_tokens: + # return result + # else: + # raise ServiceUnavailable(code='max_allowed_session_reached') + # + # else: + # return result + + def revoke_token(self, token, token_type_hint, request, *args, **kwargs): + """ + Revoke an access or refresh token. + + :param token: The token string. + :param token_type_hint: access_token or refresh_token. + :param request: The HTTP Request (oauthlib.common.Request) + """ + if token_type_hint not in ["access_token", "refresh_token"]: + token_type_hint = None + + token_types = { + "access_token": AccessToken, + "refresh_token": RefreshToken, + } + + token_type = token_types.get(token_type_hint, AccessToken) + + try: + token_type.objects.get(pk=token).revoke() + except: + token_type.objects.get(token=token).revoke() diff --git a/apps/gooyal_oauth2/views/introspect.py b/apps/gooyal_oauth2/views/oauth_views.py similarity index 91% rename from apps/gooyal_oauth2/views/introspect.py rename to apps/gooyal_oauth2/views/oauth_views.py index fca06b1..561d1bf 100644 --- a/apps/gooyal_oauth2/views/introspect.py +++ b/apps/gooyal_oauth2/views/oauth_views.py @@ -167,3 +167,17 @@ class TokenView(OAuthLibMixin, View): response[k] = v return response +@method_decorator(csrf_exempt, name="dispatch") +@method_decorator(login_not_required, name="dispatch") +class GooyalRevokeTokenView(OAuthLibMixin, View): + """ + Implements an endpoint to revoke access or refresh tokens + """ + + def post(self, request, *args, **kwargs): + url, headers, body, status = self.create_revocation_response(request) + response = HttpResponse(content=body or "", status=status) + + for k, v in headers.items(): + response[k] = v + return response diff --git a/apps/users/admin.py b/apps/users/admin.py index f0da7b2..05a6999 100644 --- a/apps/users/admin.py +++ b/apps/users/admin.py @@ -11,10 +11,7 @@ class UserAdmin(admin.ModelAdmin): 'first_name', 'last_name', 'username', - 'email', 'phone_number', - 'password', - 'otp', 'is_staff', 'is_superuser', 'groups', @@ -24,10 +21,9 @@ class UserAdmin(admin.ModelAdmin): 'last_update', 'otp_expire', 'otp_try', - 'balance', ] readonly_fields = ['last_update', 'uuid'] - list_display = ['pk', 'first_name', 'last_name', 'phone_number', 'date_joined', 'last_update'] + list_display = ['pk', 'date_joined', 'last_update'] search_fields = ['pk', 'first_name', 'last_name', 'phone_number'] diff --git a/apps/users/migrations/0008_alter_user_options.py b/apps/users/migrations/0008_alter_user_options.py new file mode 100644 index 0000000..f62e225 --- /dev/null +++ b/apps/users/migrations/0008_alter_user_options.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.2 on 2026-03-16 12:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0007_alter_user_otp'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={'ordering': ['-date_joined'], 'verbose_name': 'user', 'verbose_name_plural': 'users'}, + ), + ] diff --git a/apps/users/models.py b/apps/users/models.py index 16fadab..cc1ac4d 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -15,8 +15,7 @@ from django.utils.translation import gettext_lazy as _ import uuid from django_minio_backend import MinioBackend, iso_date_prefix - -from utils.clients.payam_sms import send_sms +from apps.core.models import SMSPolicy from .provinces_and_cities import state # from .tasks import send_notification @@ -193,6 +192,8 @@ class User(AbstractUser): if result: self.otp = None + self.otp_expire = None + if not self.date_joined: self.date_joined = timezone.now() else: @@ -200,7 +201,9 @@ class User(AbstractUser): self.save() else: result = False - + self.otp = None + self.otp_expire = None + self.save() return result @@ -242,15 +245,16 @@ class User(AbstractUser): def notify(self, body, title=None, notification_type='sms'): message = ("وینسو" "\n" - f"رمزیکبارمصرف: {body}" + f"رمز یکبار مصرف: {body}" # "\n" # # f"code is: {body}\n" # f"{settings.SMS_OTP_SIGNITURE}" ) - if settings.SMS_SEND: - send_sms(self.phone_number.strip('+'), message) - else: - logger.info(f'otp for: {self.phone_number} is {message}') + + sms_client = SMSPolicy.get_client() + sms_client.send_sms(self.phone_number.strip('+'), message) + + logger.info(f'otp for: {self.phone_number} is {message}') return # TODO: enable celery send_notification( diff --git a/apps/users/serializers.py b/apps/users/serializers.py index c4b5e72..07e5211 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -1,4 +1,5 @@ from django.core.validators import RegexValidator +from oauth2_provider.models import get_access_token_model from rest_framework import serializers from apps.users.models import User @@ -126,3 +127,25 @@ class ChangePasswordSerializer(serializers.Serializer): old_password = serializers.CharField(required=True) old_password_field = serializers.CharField(default='password') new_password = serializers.CharField(required=True) + + +AccessToken = get_access_token_model() +class SessionSerializer(serializers.ModelSerializer): + is_current = serializers.SerializerMethodField() + + def get_is_current(self, obj): + request = self.context.get('request') + token = request and request.auth and request.auth.token + if token == obj.token: + result = True + + else: + result = False + + return result + + + class Meta: + model = AccessToken + fields = ('uuid', 'created', "detail", "is_current") + read_only_fields = ['uuid', 'created', "detail", "is_current" ] diff --git a/apps/users/tests.py b/apps/users/tests.py index 7ce503c..8fa80f6 100644 --- a/apps/users/tests.py +++ b/apps/users/tests.py @@ -1,3 +1,154 @@ -from django.test import TestCase +import base64 +import json +import uuid +from datetime import timedelta +from unicodedata import category +from unittest.mock import patch + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from oauth2_provider.models import get_access_token_model, get_application_model +from rest_framework.test import APIClient, APITestCase + + +from apps.core.models import Config +from apps.gooyal_oauth2.models import Scope +from apps.users.models import User + +AccessToken = get_access_token_model() +Application = get_application_model() + + +class UserTests(APITestCase): + user_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb47005f') + access_token_1 = 'au4naVsdKCbKNOhnElPyXcrwSnqqFbm' + access_token_2 = 'vu4naVsdKCbKNOhnElPyXcrwSnqqFbm' + application_uuid = uuid.UUID('a14e8b86-8f4a-44d9-b29d-badceb47005f') + client_id = '4INGOCMoulE0fNY1SQlTbPtsWqqxGj2DdqjADq6u' + client_secret = '4INGOCMoulE0fNY1SQlTbPtsWqqxGj2DdqjADq6u' + + visitor_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb47005a') + + expire_datetime = timezone.now() + timedelta(seconds=3600) + expire_datetime.isoformat() + client = APIClient() + + def setUp(self): + from django.conf import settings + settings.SMS_SEND = False + self.user_phone_number = '+989100000000' + self.user = User.objects.create(pk=self.user_uuid, phone_number=self.user_phone_number) + + Scope.objects.create(name='accounts.status:get', description='accounts.status:get') + Scope.objects.create(name='accounts.account:retrieve', description='accounts.account:retrieve') + + self.application = Application.objects.create( + client_id=self.client_id, + client_secret=self.client_secret, + authorization_grant_type='password', + hash_client_secret=False, + uuid=self.application_uuid, + user_id=self.user_uuid, + max_allowed_session=1, + allowed_scope='accounts.status:get accounts.account:retrieve', + ) + + # self.sys_date_patcher = patch('simata_safte.models.get_sys_date', mock_get_sys_date) + # self.set_id_patcher = patch('simata_safte.models.set_id', mock_set_id) + # self.sign_pdf_patcher = patch('simata_safte.models.sign_pdf', mock_sign_pdf) + # self.check_pdf_signature_patcher = patch('simata_safte.models.check_pdf_signature', mock_check_pdf_signature) + + def tearDown(self): + super().tearDown() + + + def _create_authorization_header(self, token): + return "Bearer {0}".format(token) + + def test_authentication_allow(self): + access_token_1 = AccessToken.objects.create( + **{ + "token": self.access_token_1, + "user": self.user, + # "client_id": self.client_id, + # "client_owner": owner, + "application_id": self.application_uuid, + "scope": 'accounts.status:get accounts.account:retrieve', + "expires": self.expire_datetime.isoformat(), + }, + ) + + auth_1 = self._create_authorization_header(access_token_1.token) + + response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_1) + + self.assertContains(response, 'status') + + access_token_2 = AccessToken.objects.create( + **{ + "token": self.access_token_2, + "user": self.user, + + # "client_id": self.client_id, + # "client_owner": owner, + "application_id": self.application_uuid, + "scope": 'accounts.status:get', + "expires": self.expire_datetime.isoformat(), + }, + ) + + auth_2 = self._create_authorization_header(access_token_2.token) + + response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_1) + self.assertEqual(response.status_code, 200) + + response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_2) + self.assertEqual(response.status_code, 503) + + self.application.max_allowed_session = 0 + self.application.save() + self.application.refresh_from_db() + + response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_2) + self.assertEqual(response.status_code, 200) + + def basic_auth_string(self, username, password): + """ساخت Basic Auth string""" + import base64 + user_pass = f"{username}:{password}" + basic_credentials = base64.b64encode(user_pass.encode('utf-8')).decode('utf-8') + + return basic_credentials + + def login(self): + self.user.set_otp() + + data = { + "grant_type": "password", + "username": self.user_phone_number, + "password": '77501', + "scope": 'accounts.status:get accounts.account:retrieve', + "auth_fields": 'phone_number:otp' + } + self.client.credentials( + HTTP_AUTHORIZATION='Basic ' + self.basic_auth_string(self.client_id, self.client_secret) + ) + + result = self.client.post(reverse("gooyal_oauth2:token"), data=data) + access_token = result.json()['access_token'] + self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + access_token) + + self.assertEqual(result.status_code, 200) + return result + + + def test_getSessions_allOK_success(self): + self.login() + response = self.client.get(reverse("users:user_sessions_api")) + self.assertEqual(response.json()['results'][0]['is_current'] , True) + + + + -# Create your tests here. diff --git a/apps/users/urls.py b/apps/users/urls.py index c8f4d6e..789744e 100644 --- a/apps/users/urls.py +++ b/apps/users/urls.py @@ -2,7 +2,7 @@ from django.urls import path from django.contrib.auth.views import LogoutView from .views import UserListView, UserPublicRetrieveView, AccountView, RequestOTPView, ChangePasswordView, \ OTPLoginView, ProfileDetailView, ProfileUpdateView, RequestOTTView, UserCurrentAvatarUrlView, UserInquiryView, \ - UserDetailedRetrieveView + UserDetailedRetrieveView, UserSessionListView app_name = "users" @@ -13,6 +13,7 @@ urlpatterns = [ path('account/update/', ProfileUpdateView.as_view(), name='account_update'), path('api/account/', AccountView.as_view(), name='account_api'), path('api/users/', UserListView.as_view(), name='user_list_api'), + path('api/sessions/', UserSessionListView.as_view(), name='user_sessions_api'), path('api/users//', UserPublicRetrieveView.as_view(), name='user_public_retrieve_api'), path('api/users//avatar', UserCurrentAvatarUrlView.as_view(), name='user_avatar_api'), path('api/users//details', UserDetailedRetrieveView.as_view(), name='user_detailed_retrieve_api'), diff --git a/apps/users/views.py b/apps/users/views.py index 94dafbe..ef6b6f0 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -20,7 +20,7 @@ from apps.users.forms import OTPAuthenticationForm, ProfileUpdateForm from apps.users.models import User from apps.users.provinces_and_cities import State from apps.users.serializers import PublicUserSerializer, AccountSerializer, RequestOTPSerializer, RequestOTTSerializer, \ - ChangePasswordSerializer, UserInquirySerializer + ChangePasswordSerializer, UserInquirySerializer, SessionSerializer from utils.throttles import RequestOTPDayRateThrottle, RequestOTPMinRateThrottle, NumberedRequestOTPDayRateThrottle, NumberedRequestOTPMinRateThrottle UserModel = get_user_model() @@ -183,3 +183,20 @@ class ProfileUpdateView(UpdateView): def form_valid(self, form): return super().form_valid(form) + + +class UserSessionListView(generics.ListAPIView): + permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] + serializer_class = SessionSerializer + filter_backends = (DjangoFilterBackend,) + + max_allowed_session = 0 + + required_alternate_scopes = { + "GET": [["accounts.account:retrieve"]], + } + + def get_queryset(self): + from apps.gooyal_oauth2.models import AccessToken + return AccessToken.objects.filter(user=self.request.user).all() + diff --git a/client.py b/client.py index 57e85d2..29a1577 100644 --- a/client.py +++ b/client.py @@ -3,10 +3,10 @@ import time import requests -OAUTH_CLIENT_ID = 'xrFXKf53jrxVOygbLEoqrtOlUwBP00jIQ4zVpzc6' -OAUTH_CLIENT_SECRET = 'qelUdRCdEEalVdko5aHRojxsC3CaL29yjwxmc6FeWs39SeVSb6aM9mNrYQixMJNTYQK7s2wffwPW94JPPbP6jTdd9dSf1mlqYcr6hW2COuayFUk4jP33OWu4taUYP2F5' +OAUTH_CLIENT_ID = '4INGOCMoulE0fNY1SQlTbPtsWqqxGj2DdqjADq6u' +OAUTH_CLIENT_SECRET = 'KPc4dMSztNwAIB3vii3geXrzC1mKUIsAztz3t2ylC3HlrgJudJWhdrtoY6XeRJIuadTAREYYsXk9XtFHUDfPVcJvyfHMpNkz2VvghwrijhVprok0VYV7XrOirJ5nFUxD' -API_URI = 'https://accounts.gooyal.com' +API_URI = 'https://accounts.gooyal.ir' # API_URI = 'http://127.0.0.1:8000' @@ -27,7 +27,7 @@ class ApiClient(): "grant_type": "password", "username": phone_number, "password": password, - "scope": 'accounts.account:request_ott accounts.account:change_password accounts.profile:inquiry accounts.account:update accounts.account:retrieve accounts.profile:retrieve accounts.profile:list introspection wallet.wallet:get_balance', + "scope": 'introspection', "auth_fields": 'phone_number:otp' } auth = (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET) @@ -51,7 +51,8 @@ class ApiClient(): "grant_type": "password", "username": phone_number, "password": token, - "scope": 'introspection education.course:retrieve education.course:submit superapp.applications:list accounts.account:request_ott accounts.profile:list accounts.profile:retrieve accounts.account:retrieve accounts.account:update accounts.profile:inquiry accounts.account:change_password wallet.transaction:list wallet.invoice:create wallet.transaction:retrieve wallet.invoice:pay wallet.invoice:receipt wallet.deposit:submit wallet.deposit:verify wallet.withdraw:submit wallet.withdraw:verify ', + # "scope": 'introspection education.course:retrieve education.course:submit superapp.applications:list accounts.account:request_ott accounts.profile:list accounts.profile:retrieve accounts.account:retrieve accounts.account:update accounts.profile:inquiry accounts.account:change_password wallet.transaction:list wallet.invoice:create wallet.transaction:retrieve wallet.invoice:pay wallet.invoice:receipt wallet.deposit:submit wallet.deposit:verify wallet.withdraw:submit wallet.withdraw:verify ', + "scope": 'introspection', "auth_fields": 'phone_number:ott' } auth = (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET) @@ -156,6 +157,7 @@ class ApiClient(): } url = f'{API_URI}/{path}' response = self._request(url, method='PUT', data=data) + print(response) return response and 'code' in response, response def introspect_account(self, token): @@ -185,19 +187,29 @@ class ApiClient(): return self._request(url=url) - def get_wallet_balance(self): - url = 'http://127.0.0.1:8000/wallet/api/wallet/default/balance' + def get_application_preferences(self): + url = 'https://preferences.gooyal.com/data/application/' return self._request(url=url) +auth_data = {'access_token': 'DJ1mycH9r5FCwaNgDA9nB9uU3KWt9m', 'expires_in': 36000, 'token_type': 'Bearer', 'scope': 'accounts.account:change_password accounts.account:update accounts.account:retrieve wallet.wallet:get_balance billboard_merchant.billboard:retrieve billboard_merchant.billboard:update billboard_merchant.billboard:create wallet.user:transaction_list data_crud.data:retrieve data_crud.data:update data_crud.data:delete', 'refresh_token': 'VF4P11tfcnGv9tc2u0qH6035OW5qU4'} + client = ApiClient('+989106853582') +# client.auth_data = auth_data # print(client.login_as_client_credentials()) print(client.request_otp()) -print(client.otp_login('12345')) -print(client.introspect_account('V3LUQ9OryHOy6q5iWwLBRAtRBm80ex')) -print(client.get_wallet_balance()) +import time +start = time.time() +print(client.otp_login('77502')) +# end = time.time() +# print(end - start) +# print(client.get_application_preferences()) +# exit() +# print(client.introspect_account('V3LUQ9OryHOy6q5iWwLBRAtRBm80ex')) +# print(client.get_wallet_balance()) # ott = client.get_application_token()[1]['ott'] # print(client.ott_login(ott)) # print(ott) +client.update_account('test') diff --git a/main/settings.py b/main/settings.py index 13d8103..fcf4d23 100644 --- a/main/settings.py +++ b/main/settings.py @@ -48,7 +48,7 @@ INSTALLED_APPS = [ 'crispy_forms', 'crispy_bootstrap5', 'django_filters', - 'jalali_date', + # 'jalali_date', 'django_minio_backend.apps.DjangoMinioBackendConfig', # local apps @@ -119,6 +119,11 @@ REST_FRAMEWORK = { # 'DEFAULT_THROTTLE_CLASSES': [ # 'rest_framework.throttling.AnonRateThrottle', # ], + 'DEFAULT_THROTTLE_CLASSES': [ + 'apps.gooyal_oauth2.throttling.TokenLimitThrottle', + # 'rest_framework.throttling.AnonRateThrottle', + # 'rest_framework.throttling.UserRateThrottle' + ], 'DEFAULT_THROTTLE_RATES': { 'otp_min': '2/min', 'otp_day': '50/day', @@ -227,7 +232,6 @@ LANGUAGES = [ ('tr', _('Turkish')), ] - LOCALE_PATHS = [ BASE_DIR / 'locale', ] @@ -277,11 +281,9 @@ PAYAM_SMS_PASSWORD = config('PAYAM_SMS_PASSWORD') PAYAM_SMS_CLIENT_ID = config('PAYAM_SMS_CLIENT_ID') PAYAM_SMS_CLIENT_SECRET = config('PAYAM_SMS_CLIENT_SECRET') - SMS_OTP_SIGNITURE = config('SMS_OTP_SIGNITURE', '') -SMS_SEND=config('SMS_SEND', True, cast=bool) -TEST_PHONENUMBERS=config('TEST_PHONENUMBERS', [], cast=Csv(post_process=list)) - +SMS_SEND = config('SMS_SEND', True, cast=bool) +TEST_PHONENUMBERS = config('TEST_PHONENUMBERS', [], cast=Csv(post_process=list)) CACHES = { "default": { @@ -307,8 +309,10 @@ STORAGES = { "MINIO_ENDPOINT": config('MINIO_ENDPOINT', default='drive.gooyal.com'), "MINIO_USE_HTTPS": config('MINIO_USE_HTTPS', default=True, cast=bool), - "MINIO_EXTERNAL_ENDPOINT": config('MINIO_EXTERNAL_ENDPOINT', default='drive.gooyal.com'), # Default is same as MINIO_ENDPOINT - "MINIO_EXTERNAL_ENDPOINT_USE_HTTPS": config('MINIO_EXTERNAL_ENDPOINT_USE_HTTPS', default=True, cast=bool), # Default is same as MINIO_USE_HTTPS + "MINIO_EXTERNAL_ENDPOINT": config('MINIO_EXTERNAL_ENDPOINT', default='drive.gooyal.com'), + # Default is same as MINIO_ENDPOINT + "MINIO_EXTERNAL_ENDPOINT_USE_HTTPS": config('MINIO_EXTERNAL_ENDPOINT_USE_HTTPS', default=True, cast=bool), + # Default is same as MINIO_USE_HTTPS "MINIO_REGION": None, # Default is set to None "MINIO_ACCESS_KEY": config('MINIO_ACCESS_KEY'), "MINIO_SECRET_KEY": config('MINIO_SECRET_KEY'), @@ -357,8 +361,10 @@ MINIO_HTTP_CLIENT: urllib3.poolmanager.PoolManager = urllib3.PoolManager( MINIO_ENDPOINT = config('MINIO_ENDPOINT', default='drive.gooyal.ir') MINIO_USE_HTTPS = config('MINIO_USE_HTTPS', default=True, cast=bool) -MINIO_EXTERNAL_ENDPOINT = config('MINIO_EXTERNAL_ENDPOINT', default='drive.gooyal.com') # Default is same as MINIO_ENDPOINT -MINIO_EXTERNAL_ENDPOINT_USE_HTTPS = config('MINIO_EXTERNAL_ENDPOINT_USE_HTTPS', default=True, cast=bool) # Default is same as MINIO_USE_HTTPS +MINIO_EXTERNAL_ENDPOINT = config('MINIO_EXTERNAL_ENDPOINT', + default='drive.gooyal.com') # Default is same as MINIO_ENDPOINT +MINIO_EXTERNAL_ENDPOINT_USE_HTTPS = config('MINIO_EXTERNAL_ENDPOINT_USE_HTTPS', default=True, + cast=bool) # Default is same as MINIO_USE_HTTPS MINIO_REGION = None # Default is set to None MINIO_ACCESS_KEY = config('MINIO_ACCESS_KEY') MINIO_SECRET_KEY = config('MINIO_SECRET_KEY') @@ -377,3 +383,4 @@ MINIO_PUBLIC_BUCKETS = [ MINIO_POLICY_HOOKS: List[Tuple[str, dict]] = [] MINIO_BUCKET_CHECK_ON_SAVE = True # Default: True // Creates bucket if missing, then save +MOBIN_SMS_TOKEN = config('MOBIN_SMS_TOKEN', default='') diff --git a/run.sh b/run.sh index 0eb9a8e..e24f8ab 100755 --- a/run.sh +++ b/run.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -while ! nc -z $DB_HOST 5432 ; do - echo "APP Waiting for the DB Server" - sleep 3 -done +#while ! nc -z $DB_HOST 5432 ; do +# echo "APP Waiting for the DB Server" +# sleep 3 +#done #python3 manage.py collectstatic --noinput python3 manage.py migrate gunicorn main.wsgi:application --bind 0.0.0.0:8000 -w 4 diff --git a/templates/core/home.html b/templates/core/home.html index f1dc136..e8d159a 100644 --- a/templates/core/home.html +++ b/templates/core/home.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}Accounts: Home{% endblock %} {% block page_title %}Accounts: Home{% endblock %} diff --git a/templates/gooyal_oauth2/application_create.html b/templates/gooyal_oauth2/application_create.html index 0603b09..0d1dc01 100644 --- a/templates/gooyal_oauth2/application_create.html +++ b/templates/gooyal_oauth2/application_create.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}Application Register{% endblock %} {% block page_title %}Application Register{% endblock %} diff --git a/templates/gooyal_oauth2/application_detail.html b/templates/gooyal_oauth2/application_detail.html index 1c15954..db64873 100644 --- a/templates/gooyal_oauth2/application_detail.html +++ b/templates/gooyal_oauth2/application_detail.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}Application Detail: {{ object }}{% endblock %} {% block page_title %}Application Detail: {{ object }}{% endblock %} diff --git a/templates/gooyal_oauth2/application_list.html b/templates/gooyal_oauth2/application_list.html index 2a7db52..4b6e4a7 100644 --- a/templates/gooyal_oauth2/application_list.html +++ b/templates/gooyal_oauth2/application_list.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% load static %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% load crispy_forms_tags %} {% load tags %} diff --git a/templates/gooyal_oauth2/application_scope_create.html b/templates/gooyal_oauth2/application_scope_create.html index 315c839..6e866e2 100644 --- a/templates/gooyal_oauth2/application_scope_create.html +++ b/templates/gooyal_oauth2/application_scope_create.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}Application Scope Register{% endblock %} {% block page_title %}Application Scope Register{% endblock %} diff --git a/templates/gooyal_oauth2/application_scope_detail.html b/templates/gooyal_oauth2/application_scope_detail.html index 97723ad..d9fa265 100644 --- a/templates/gooyal_oauth2/application_scope_detail.html +++ b/templates/gooyal_oauth2/application_scope_detail.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}Application Scope Detail: {{ object.name }}{% endblock %} {% block page_title %}Application Scope Detail: {{ object.name }}{% endblock %} diff --git a/templates/gooyal_oauth2/application_scope_list.html b/templates/gooyal_oauth2/application_scope_list.html index 6a7eaa7..05edada 100644 --- a/templates/gooyal_oauth2/application_scope_list.html +++ b/templates/gooyal_oauth2/application_scope_list.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% load static %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% load crispy_forms_tags %} {% load tags %} diff --git a/templates/gooyal_oauth2/application_scope_update.html b/templates/gooyal_oauth2/application_scope_update.html index f91b4bc..7d1b83a 100644 --- a/templates/gooyal_oauth2/application_scope_update.html +++ b/templates/gooyal_oauth2/application_scope_update.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}Application Update: {{ object.name }}{% endblock %} {% block page_title %}Application Update: {{ object.name }}{% endblock %} diff --git a/templates/gooyal_oauth2/application_update.html b/templates/gooyal_oauth2/application_update.html index e3b78b6..5c555f7 100644 --- a/templates/gooyal_oauth2/application_update.html +++ b/templates/gooyal_oauth2/application_update.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}Application Update: {{ object.name }}{% endblock %} {% block page_title %}Application Update: {{ object.name }}{% endblock %} diff --git a/templates/users/profile_detail.html b/templates/users/profile_detail.html index b9ad4ff..c949034 100644 --- a/templates/users/profile_detail.html +++ b/templates/users/profile_detail.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}user account{% endblock %} {% block page_title %}user account{% endblock %} diff --git a/templates/users/profile_update.html b/templates/users/profile_update.html index 9e6715d..de768f0 100644 --- a/templates/users/profile_update.html +++ b/templates/users/profile_update.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}user account{% endblock %} {% block page_title %}user account{% endblock %} diff --git a/utils/clients/payam_sms.py b/utils/clients/payam_sms.py deleted file mode 100644 index 6f6295d..0000000 --- a/utils/clients/payam_sms.py +++ /dev/null @@ -1,50 +0,0 @@ -# TODO: this is time fourced code. refactor - -import requests -from django.conf import settings - - -def get_access_token(): - payam_sms_system_name = settings.PAYAM_SMS_SYSTEM_NAME - payam_sms_username = settings.PAYAM_SMS_USERNAME - payam_sms_password = settings.PAYAM_SMS_PASSWORD - payam_sms_client_id = settings.PAYAM_SMS_CLIENT_ID - payam_sms_client_secret = settings.PAYAM_SMS_CLIENT_SECRET - auth = (payam_sms_client_id, payam_sms_client_secret) - url = f"https://www.payamsms.com/auth/oauth/token?systemName={payam_sms_system_name}&username={payam_sms_username}&password={payam_sms_password}&scope=webservice&grant_type=password" - - response_object = requests.post(url, auth=auth, json={}) - response = response_object.json() - return response["access_token"] - - -def send_sms(phone_number, message): - try: - url = "https://www.payamsms.com/panel/webservice/send" - - # access_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJzb2JhbiIsInNjb3BlIjpbIndlYnNlcnZpY2UiXSwiZXhwIjoxNzQ0MTAyMDU5LCJ1c2VyRGV0YWlscyI6eyJpZCI6MjU0MCwidXNlck5hbWUiOiJzb2JhbiIsInJvbGUiOiJVU0VSIiwiZW5hYmxlZCI6dHJ1ZSwic3lzdGVtSWQiOjcyMiwiYWNjZXNzIjp7InJvbGUiOlsiYWRkIiwiZWRpdCIsImdldCIsImRlbGV0ZSJdLCJwaG9uZUJvb2siOlsiYWRkIiwiZWRpdCIsImdldExvY2FsIiwiZ2V0R2xvYmFsIiwiZGVsZXRlIl0sInNtcyI6WyJzaW5nbGUiLCJnZXRMb2NhbCIsImdldEdsb2JhbCIsImJ1bGsiLCJkYiJdLCJyZXBvcnQiOlsiZ2V0TW9HbG9iYWwiLCJnZXREYWlseUxvY2FsIiwiZ2V0TW9Mb2NhbCIsImdldERhaWx5R2xvYmFsIl0sIndzIjpbIndzIl0sInVzZXIiOlsiYWRkIiwiZWRpdCIsImdldExvY2FsIiwiZ2V0IiwiZ2V0R2xvYmFsIiwiZGVsZXRlIl0sInRvb2xzIjpbInJhY2UiLCJwZXJpb2RpYyIsInBvbGxpbmciLCJzZWNyZXRvcnkiLCJhdXRvYW5zd2VyIl0sImZpbmFuY2UiOlsidGRyIiwiY2hhcmdlIl19LCJyb2xlSWQiOjQ0NDAsImNoaWxkcmVuSWRzIjpbXSwic3lzdGVtVHlwZSI6Ik5PUk1BTCIsInN5c3RlbVBheW1lbnRUeXBlIjoiREVCSVQiLCJzeXN0ZW1OYW1lIjoic29iYW4iLCJzZW5kV2l0aE91dFBlcm1pc3Npb25TeXN0ZW0iOmZhbHNlLCJtaXNDdXN0b21lciI6Itio2YbYr9in2LEg2LPZiNio2KfZhiDYs9in2YXYp9mG2YciLCJwYXJlbnRVc2VyTmFtZSI6bnVsbCwicGFyZW50SWQiOm51bGwsImFkbWluIjp0cnVlfSwiYXV0aG9yaXRpZXMiOlsiVVNFUiJdLCJqdGkiOiJNeU5vTml2RURmc3YxUDR3U20tbTR2Y2NHODgiLCJjbGllbnRfaWQiOiJzb2JhbiJ9.9mdneDduK4OVH2zsXvRqvXt2qwpLvs0vCuseLaB-LCo' - access_token = get_access_token() - - body = [ - { - "sender": "98200000443295", - "recipient": phone_number, - "body": message, - # "customerId": "1", - # "sendDate": "2024-03-04 10:17:00" - } - ] - - headers = { - 'Authorization': f'Bearer {access_token}', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - } - - response_object = requests.post(url, headers=headers, json=body, timeout=10) - print(response_object.text) - except Exception as e: - print(e) - - -# send_sms("989106853582", "تست") diff --git a/utils/clients/sms/fake/client.py b/utils/clients/sms/fake/client.py new file mode 100644 index 0000000..cba10bc --- /dev/null +++ b/utils/clients/sms/fake/client.py @@ -0,0 +1,6 @@ +from utils.clients.sms.sms import BaseSMSClient + + +class FakeClient(BaseSMSClient): + def send_sms(self, phone_number, message): + pass diff --git a/utils/clients/sms/mobin_sms/client.py b/utils/clients/sms/mobin_sms/client.py new file mode 100644 index 0000000..93ec55f --- /dev/null +++ b/utils/clients/sms/mobin_sms/client.py @@ -0,0 +1,43 @@ +import urllib.parse +import requests + +from django.conf import settings +import logging + +from utils.clients.sms.sms import BaseSMSClient + +logger = logging.getLogger(__name__) + +class MobinSMSClient(BaseSMSClient): + access_token = settings.MOBIN_SMS_TOKEN + base_url = 'https://connect.mobinsms.com' + sender = '9890008050' + + def send_sms(self, phone_number, message): + return self.send_sms_quick([phone_number], message) + + def send_sms_quick(self, phone_number_list, message): + try: + path = '/v1/send/quick' + url = urllib.parse.urljoin(self.base_url, path) + + body = { + "from": self.sender, + "to": phone_number_list, + "body": message, + "unique_id": "" # UDH (optional field) + } + + headers = { + "X-API-Key": self.access_token, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + + response = requests.post(url, headers=headers, json=body, timeout=10) + logger.info(response.text) + return response.json() + + except Exception as e: + logger.exception(f'error sending sms to {phone_number_list}: {e}') + diff --git a/utils/clients/sms/mobin_sms/https___panel.mobinsms.com_data_connect-v1.7.json b/utils/clients/sms/mobin_sms/https___panel.mobinsms.com_data_connect-v1.7.json new file mode 100644 index 0000000..5e55ed7 --- /dev/null +++ b/utils/clients/sms/mobin_sms/https___panel.mobinsms.com_data_connect-v1.7.json @@ -0,0 +1,387 @@ +{ + "info": { + "_postman_id": "9cc27226-bee8-4b29-a924-a9e14bb61d14", + "name": "Connect API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "2960438" + }, + "item": [ + { + "name": "Send by Pattern", + "protocolProfileBehavior": { + "followRedirects": true, + "disableUrlEncoding": false, + "disableCookies": false + }, + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-API-Key", + "value": "{{apiKey}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"from\": \"989000xxxx\",\n\t\"to\": [\"989126880345\"],\n\t\"pattern\": \"YOUR_PATTERN_KEY\",\n\t\"data\": {\n\t\t\"0\": \"Var 0\",\n\t\t\"1\": \"Var 1\",\n\t\t\"2\": \"Var 2\",\n\t\t\"3\": \"Var 3\"\n\t}\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/v1/send/pattern", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "send", + "pattern" + ] + } + }, + "response": [] + }, + { + "name": "Send Quick", + "protocolProfileBehavior": { + "followRedirects": true, + "disableUrlEncoding": false, + "disableCookies": false + }, + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-API-Key", + "value": "{{apiKey}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"from\": \"989000xxxx\",\n\t\"to\": [\"989126880345\", \"989127761851\"],\n\t\"body\": \"Your message\",\n \"unique_id\": \"\" // UDH (optional field)\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/v1/send/quick", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "send", + "quick" + ] + } + }, + "response": [] + }, + { + "name": "Get Status", + "protocolProfileBehavior": { + "disableBodyPruning": true, + "followRedirects": true, + "disableUrlEncoding": false, + "disableCookies": false + }, + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-API-Key", + "value": "{{apiKey}}" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/v1/message/status?message_id=1893", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "message", + "status" + ], + "query": [ + { + "key": "unique_id", + "value": "20", + "disabled": true + }, + { + "key": "message_id", + "value": "1893" + } + ] + } + }, + "response": [ + { + "name": "Delivered Message", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "User-Agent", + "value": "insomnia/11.6.2" + }, + { + "key": "X-API-Key", + "value": "07e7e9c79652b857aa77b8f3d40cc4314321970e1a5f78b9fc18e497aca2bf40" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"from\": \"9890001777\",\n\t\"to\": [\"989126880345\"],\n\t\"body\": \"Your message\",\n \"unique_id\": \"6\" // UDH (optional field)\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/v1/message/status?message_id=1852", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "message", + "status" + ], + "query": [ + { + "key": "message_id", + "value": "1852" + }, + { + "key": "unique_id", + "value": "webengage-message-id", + "disabled": true + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Server", + "value": "nginx" + }, + { + "key": "Date", + "value": "Sun, 09 Nov 2025 16:20:37 GMT" + }, + { + "key": "Content-Type", + "value": "application/json", + "description": "", + "type": "text" + }, + { + "key": "Content-Length", + "value": "38" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"id\": 274,\n \"status\": \"2\", // 0: Pending, 1: Sent, 2: Delivered\n \"unique_id\": \"\"\n}" + } + ] + }, + { + "name": "Webengage", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-API-Key", + "value": "{{apiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"version\": \"2.0\",\n \"smsData\": {\n \"toNumber\": \"989195712939\",\n \"fromNumber\": \"9890006950\",\n \"body\": \"Text message body\"\n },\n \"metadata\": {\n \"campaignType\": \"PROMOTIONAL\",\n \"timestamp\": \"2018-01-25T10:24:16+0000\",\n \"messageId\": \"webengage-message-id222\",\n \"custom\": {\n \"key1\": \"val1\",\n \"key2\": \"val2\"\n },\n \"indiaDLT\": {\n \"contentTemplateId\": \"xyz\",\n \"principalEntityId\": \"abc\",\n \"telemarketerId\": \"tm1,tm2\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/v1/integration/webengage", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "integration", + "webengage" + ] + } + }, + "response": [] + }, + { + "name": "Send Bulk", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-API-Key", + "value": "{{apiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"from\": \"989000xxxx\",\n \"on_duplicate\": \"skip\", // Skip to send only unique ones, reject to stop sending any message even if one is not unique\n \"messages\": [\n {\n \"to\": \"989126880345\",\n \"body\": \"Your message\",\n \"unique_id\": \"1\" // UDH: Optional\n },\n {\n \"to\": \"989127865454\",\n \"body\": \"Your message 2\",\n \"unique_id\": \"20\" // UDH: Optional\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/v1/send/bulk", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "send", + "bulk" + ] + } + }, + "response": [] + }, + { + "name": "Account Balance", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-API-Key", + "value": "{{apiKey}}" + } + ], + "url": { + "raw": "{{baseURL}}/v1/account/balance", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "account", + "balance" + ], + + } + }, + "response": [] + }, + { + "name": "Send vOTP", + "request": { + "method": "POST", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}", + "type": "text" + }, + { + "key": "content-type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"from\": \"9890002999\",\n\t\"to\": \"989126880345\",\n\t\"code\": \"123456\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/v1/send/votp", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "send", + "votp" + ] + } + }, + "response": [] + } + ], + "variable": [ + { + "key": "baseURL", + "value": "", + "type": "default" + }, + { + "key": "apiKey", + "value": "", + "type": "default" + } + ] +} \ No newline at end of file diff --git a/utils/clients/sms/payam_sms/client.py b/utils/clients/sms/payam_sms/client.py new file mode 100644 index 0000000..ec4b57b --- /dev/null +++ b/utils/clients/sms/payam_sms/client.py @@ -0,0 +1,54 @@ +# TODO: this is time fourced code. refactor + +import requests +from django.conf import settings +import logging + +from utils.clients.sms.sms import BaseSMSClient + +logger = logging.getLogger(__name__) + +class PayamSMSClient(BaseSMSClient): + def get_access_token(self): + payam_sms_system_name = settings.PAYAM_SMS_SYSTEM_NAME + payam_sms_username = settings.PAYAM_SMS_USERNAME + payam_sms_password = settings.PAYAM_SMS_PASSWORD + payam_sms_client_id = settings.PAYAM_SMS_CLIENT_ID + payam_sms_client_secret = settings.PAYAM_SMS_CLIENT_SECRET + auth = (payam_sms_client_id, payam_sms_client_secret) + url = f"https://www.payamsms.com/auth/oauth/token?systemName={payam_sms_system_name}&username={payam_sms_username}&password={payam_sms_password}&scope=webservice&grant_type=password" + + response_object = requests.post(url, auth=auth, json={}) + response = response_object.json() + return response["access_token"] + + + def send_sms(self, phone_number, message): + try: + url = "https://www.payamsms.com/panel/webservice/send" + + # access_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJzb2JhbiIsInNjb3BlIjpbIndlYnNlcnZpY2UiXSwiZXhwIjoxNzQ0MTAyMDU5LCJ1c2VyRGV0YWlscyI6eyJpZCI6MjU0MCwidXNlck5hbWUiOiJzb2JhbiIsInJvbGUiOiJVU0VSIiwiZW5hYmxlZCI6dHJ1ZSwic3lzdGVtSWQiOjcyMiwiYWNjZXNzIjp7InJvbGUiOlsiYWRkIiwiZWRpdCIsImdldCIsImRlbGV0ZSJdLCJwaG9uZUJvb2siOlsiYWRkIiwiZWRpdCIsImdldExvY2FsIiwiZ2V0R2xvYmFsIiwiZGVsZXRlIl0sInNtcyI6WyJzaW5nbGUiLCJnZXRMb2NhbCIsImdldEdsb2JhbCIsImJ1bGsiLCJkYiJdLCJyZXBvcnQiOlsiZ2V0TW9HbG9iYWwiLCJnZXREYWlseUxvY2FsIiwiZ2V0TW9Mb2NhbCIsImdldERhaWx5R2xvYmFsIl0sIndzIjpbIndzIl0sInVzZXIiOlsiYWRkIiwiZWRpdCIsImdldExvY2FsIiwiZ2V0IiwiZ2V0R2xvYmFsIiwiZGVsZXRlIl0sInRvb2xzIjpbInJhY2UiLCJwZXJpb2RpYyIsInBvbGxpbmciLCJzZWNyZXRvcnkiLCJhdXRvYW5zd2VyIl0sImZpbmFuY2UiOlsidGRyIiwiY2hhcmdlIl19LCJyb2xlSWQiOjQ0NDAsImNoaWxkcmVuSWRzIjpbXSwic3lzdGVtVHlwZSI6Ik5PUk1BTCIsInN5c3RlbVBheW1lbnRUeXBlIjoiREVCSVQiLCJzeXN0ZW1OYW1lIjoic29iYW4iLCJzZW5kV2l0aE91dFBlcm1pc3Npb25TeXN0ZW0iOmZhbHNlLCJtaXNDdXN0b21lciI6Itio2YbYr9in2LEg2LPZiNio2KfZhiDYs9in2YXYp9mG2YciLCJwYXJlbnRVc2VyTmFtZSI6bnVsbCwicGFyZW50SWQiOm51bGwsImFkbWluIjp0cnVlfSwiYXV0aG9yaXRpZXMiOlsiVVNFUiJdLCJqdGkiOiJNeU5vTml2RURmc3YxUDR3U20tbTR2Y2NHODgiLCJjbGllbnRfaWQiOiJzb2JhbiJ9.9mdneDduK4OVH2zsXvRqvXt2qwpLvs0vCuseLaB-LCo' + access_token = self.get_access_token() + + body = [ + { + "sender": "98200000443295", + "recipient": phone_number, + "body": message, + # "customerId": "1", + # "sendDate": "2024-03-04 10:17:00" + } + ] + + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + + response_object = requests.post(url, headers=headers, json=body, timeout=10) + logger.info(response_object.text) + return response_object + except Exception as e: + logger.exception(f'error sending sms to {phone_number}: {e}') + diff --git a/utils/clients/sms/sms.py b/utils/clients/sms/sms.py new file mode 100644 index 0000000..35dd319 --- /dev/null +++ b/utils/clients/sms/sms.py @@ -0,0 +1,3 @@ +class BaseSMSClient: + def send_sms(self, phone_number, message): + raise NotImplementedError() \ No newline at end of file