merge from upstream

This commit is contained in:
Sayyid Hamid Mahdavi 2026-04-28 16:36:46 +03:30
commit f400eb6ab4
46 changed files with 1605 additions and 152 deletions

1
.gitignore vendored
View file

@ -7,3 +7,4 @@ delme*.py
/venv/ /venv/
oidc.key oidc.key
/log/ /log/
/logs/*.log

View file

@ -1,2 +1,17 @@
from django.contrib import admin 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)

31
apps/core/decorators.py Normal file
View file

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

View file

@ -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,
},
),
]

View file

@ -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,
},
),
]

View file

@ -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.db import models
from django.utils.translation import gettext_lazy as _ 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

9
apps/core/serializers.py Normal file
View file

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

View file

@ -1,7 +1,17 @@
from django.urls import path, include 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" app_name = "core"
router = DefaultRouter()
# router.register('api/config', ConfigViewSet, basename='configs')
urlpatterns = [ urlpatterns = [
path('', HomeView.as_view(), name='home'), 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'),
] ]

View file

@ -2,9 +2,82 @@ from django.contrib.auth.decorators import login_required
from django.shortcuts import render from django.shortcuts import render
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.generic import TemplateView 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') # @method_decorator(login_required, name='dispatch')
class HomeView(TemplateView): class HomeView(TemplateView):
template_name = 'core/home.html' 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)

View file

@ -7,7 +7,7 @@ from django.contrib.admin.sites import NotRegistered
from oauth2_provider.admin import ApplicationAdmin from oauth2_provider.admin import ApplicationAdmin
from .models import Application, Resource, Scope from .models import Application, Scope
from .forms import ApplicationForm from .forms import ApplicationForm
@ -23,14 +23,9 @@ class ApplicationAdmin(ApplicationAdmin):
form = ApplicationForm form = ApplicationForm
@admin.register(Resource)
class ResourceAdmin(admin.ModelAdmin):
list_display = ("name", "user", "expires")
@admin.register(Scope) @admin.register(Scope)
class ScopeAdmin(admin.ModelAdmin): class ScopeAdmin(admin.ModelAdmin):
list_display = ('name', "resource", 'description', 'is_default') list_display = ('name', 'description', 'is_default')
# admin.site.register(RestrictedApplication, RestrictedApplicationAdmin) # admin.site.register(RestrictedApplication, RestrictedApplicationAdmin)

View file

@ -0,0 +1,2 @@
def session_limit_count(count=0):
pass

View file

@ -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',
),
]

View file

@ -23,19 +23,6 @@ from .settings import oauth2_settings
logger = logging.getLogger(__name__) 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): class Application(AbstractApplication, BaseModel):
""" """
Application model for use with Django OAuth Toolkit that allows the scopes Application model for use with Django OAuth Toolkit that allows the scopes
@ -49,15 +36,10 @@ class Application(AbstractApplication, BaseModel):
on_delete=models.PROTECT on_delete=models.PROTECT
) )
allowed_scope = models.TextField(blank=True) 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) avatar = models.ImageField(upload_to='avatars', null=True, blank=True)
max_allowed_session = models.PositiveIntegerField(default=0)
@property @property
def allowed_scopes(self): def allowed_scopes(self):
@ -96,13 +78,6 @@ class Scope(BaseModel):
help_text='The application to which the scope belongs.', help_text='The application to which the scope belongs.',
related_name='scopes' 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 #: The name of the scope
name = models.CharField( name = models.CharField(
max_length=255, max_length=255,
@ -122,19 +97,10 @@ class Scope(BaseModel):
@property @property
def final_name(self): def final_name(self):
args = [] return self.name
if self.resource:
args.append(self.resource.name)
args.append(self.name)
return '.'.join(args)
@property @property
def final_description(self): def final_description(self):
resource_name = self.resource and self.resource.name
if resource_name:
return f"{resource_name} -> {self.description}"
return self.description return self.description
@classmethod @classmethod

View file

@ -18,7 +18,7 @@ class Scopes(BaseScopes):
def get_queryset(self, application=None): def get_queryset(self, application=None):
queryset = Scope.objects.all() queryset = Scope.objects.all()
if application: if application:
queryset = queryset.filter(name__in=application.allowed_scopes).order_by('resource__uuid') queryset = queryset.filter(name__in=application.allowed_scopes)
return queryset return queryset

212
apps/gooyal_oauth2/tests.py Normal file
View file

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

View file

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

View file

@ -3,7 +3,7 @@ from django.urls import re_path, path
from oauth2_provider import views from oauth2_provider import views
from rest_framework import routers 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 apis as api_views
from .views import pages from .views import pages
app_name = "gooyal_oauth2" app_name = "gooyal_oauth2"

View file

@ -1,22 +1,34 @@
import base64 import base64
import binascii import binascii
import logging import logging
from copy import deepcopy
from datetime import datetime, timedelta from datetime import datetime, timedelta
from urllib.parse import unquote_plus from urllib.parse import unquote_plus
import requests import requests
# import service_clients # import service_clients
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.utils import timezone
from django.utils.timezone import make_aware 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 oauth2_provider.oauth2_validators import OAuth2Validator as BaseOAuth2Validator
from requests import Session
from utils.exceptions import ServiceUnavailable
from .settings import oauth2_settings from .settings import oauth2_settings
from django.conf import settings from django.conf import settings
from django.db import router, transaction
log = logging.getLogger("oauth2_provider") log = logging.getLogger("oauth2_provider")
AccessTokenModel = get_access_token_model()
UserModel = get_user_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 class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223
@ -77,3 +89,199 @@ class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223
def get_oidc_issuer_endpoint(self, request): def get_oidc_issuer_endpoint(self, request):
return oauth2_settings.oidc_issuer(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()

View file

@ -167,3 +167,17 @@ class TokenView(OAuthLibMixin, View):
response[k] = v response[k] = v
return response 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

View file

@ -11,10 +11,7 @@ class UserAdmin(admin.ModelAdmin):
'first_name', 'first_name',
'last_name', 'last_name',
'username', 'username',
'email',
'phone_number', 'phone_number',
'password',
'otp',
'is_staff', 'is_staff',
'is_superuser', 'is_superuser',
'groups', 'groups',
@ -24,10 +21,9 @@ class UserAdmin(admin.ModelAdmin):
'last_update', 'last_update',
'otp_expire', 'otp_expire',
'otp_try', 'otp_try',
'balance',
] ]
readonly_fields = ['last_update', 'uuid'] 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'] search_fields = ['pk', 'first_name', 'last_name', 'phone_number']

View file

@ -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'},
),
]

View file

@ -15,8 +15,7 @@ from django.utils.translation import gettext_lazy as _
import uuid import uuid
from django_minio_backend import MinioBackend, iso_date_prefix from django_minio_backend import MinioBackend, iso_date_prefix
from apps.core.models import SMSPolicy
from utils.clients.payam_sms import send_sms
from .provinces_and_cities import state from .provinces_and_cities import state
# from .tasks import send_notification # from .tasks import send_notification
@ -193,6 +192,8 @@ class User(AbstractUser):
if result: if result:
self.otp = None self.otp = None
self.otp_expire = None
if not self.date_joined: if not self.date_joined:
self.date_joined = timezone.now() self.date_joined = timezone.now()
else: else:
@ -200,7 +201,9 @@ class User(AbstractUser):
self.save() self.save()
else: else:
result = False result = False
self.otp = None
self.otp_expire = None
self.save()
return result return result
@ -247,9 +250,10 @@ class User(AbstractUser):
# # f"code is: {body}\n" # # f"code is: {body}\n"
# f"{settings.SMS_OTP_SIGNITURE}" # f"{settings.SMS_OTP_SIGNITURE}"
) )
if settings.SMS_SEND:
send_sms(self.phone_number.strip('+'), message) sms_client = SMSPolicy.get_client()
else: sms_client.send_sms(self.phone_number.strip('+'), message)
logger.info(f'otp for: {self.phone_number} is {message}') logger.info(f'otp for: {self.phone_number} is {message}')
return return
# TODO: enable celery # TODO: enable celery

View file

@ -1,4 +1,5 @@
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from oauth2_provider.models import get_access_token_model
from rest_framework import serializers from rest_framework import serializers
from apps.users.models import User from apps.users.models import User
@ -126,3 +127,25 @@ class ChangePasswordSerializer(serializers.Serializer):
old_password = serializers.CharField(required=True) old_password = serializers.CharField(required=True)
old_password_field = serializers.CharField(default='password') old_password_field = serializers.CharField(default='password')
new_password = serializers.CharField(required=True) 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" ]

View file

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

View file

@ -2,7 +2,7 @@ from django.urls import path
from django.contrib.auth.views import LogoutView from django.contrib.auth.views import LogoutView
from .views import UserListView, UserPublicRetrieveView, AccountView, RequestOTPView, ChangePasswordView, \ from .views import UserListView, UserPublicRetrieveView, AccountView, RequestOTPView, ChangePasswordView, \
OTPLoginView, ProfileDetailView, ProfileUpdateView, RequestOTTView, UserCurrentAvatarUrlView, UserInquiryView, \ OTPLoginView, ProfileDetailView, ProfileUpdateView, RequestOTTView, UserCurrentAvatarUrlView, UserInquiryView, \
UserDetailedRetrieveView UserDetailedRetrieveView, UserSessionListView
app_name = "users" app_name = "users"
@ -13,6 +13,7 @@ urlpatterns = [
path('account/update/', ProfileUpdateView.as_view(), name='account_update'), path('account/update/', ProfileUpdateView.as_view(), name='account_update'),
path('api/account/', AccountView.as_view(), name='account_api'), path('api/account/', AccountView.as_view(), name='account_api'),
path('api/users/', UserListView.as_view(), name='user_list_api'), path('api/users/', UserListView.as_view(), name='user_list_api'),
path('api/sessions/', UserSessionListView.as_view(), name='user_sessions_api'),
path('api/users/<uuid>/', UserPublicRetrieveView.as_view(), name='user_public_retrieve_api'), path('api/users/<uuid>/', UserPublicRetrieveView.as_view(), name='user_public_retrieve_api'),
path('api/users/<uuid>/avatar', UserCurrentAvatarUrlView.as_view(), name='user_avatar_api'), path('api/users/<uuid>/avatar', UserCurrentAvatarUrlView.as_view(), name='user_avatar_api'),
path('api/users/<uuid>/details', UserDetailedRetrieveView.as_view(), name='user_detailed_retrieve_api'), path('api/users/<uuid>/details', UserDetailedRetrieveView.as_view(), name='user_detailed_retrieve_api'),

View file

@ -20,7 +20,7 @@ from apps.users.forms import OTPAuthenticationForm, ProfileUpdateForm
from apps.users.models import User from apps.users.models import User
from apps.users.provinces_and_cities import State from apps.users.provinces_and_cities import State
from apps.users.serializers import PublicUserSerializer, AccountSerializer, RequestOTPSerializer, RequestOTTSerializer, \ from apps.users.serializers import PublicUserSerializer, AccountSerializer, RequestOTPSerializer, RequestOTTSerializer, \
ChangePasswordSerializer, UserInquirySerializer ChangePasswordSerializer, UserInquirySerializer, SessionSerializer
from utils.throttles import RequestOTPDayRateThrottle, RequestOTPMinRateThrottle, NumberedRequestOTPDayRateThrottle, NumberedRequestOTPMinRateThrottle from utils.throttles import RequestOTPDayRateThrottle, RequestOTPMinRateThrottle, NumberedRequestOTPDayRateThrottle, NumberedRequestOTPMinRateThrottle
UserModel = get_user_model() UserModel = get_user_model()
@ -183,3 +183,20 @@ class ProfileUpdateView(UpdateView):
def form_valid(self, form): def form_valid(self, form):
return super().form_valid(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()

View file

@ -3,10 +3,10 @@ import time
import requests import requests
OAUTH_CLIENT_ID = 'xrFXKf53jrxVOygbLEoqrtOlUwBP00jIQ4zVpzc6' OAUTH_CLIENT_ID = '4INGOCMoulE0fNY1SQlTbPtsWqqxGj2DdqjADq6u'
OAUTH_CLIENT_SECRET = 'qelUdRCdEEalVdko5aHRojxsC3CaL29yjwxmc6FeWs39SeVSb6aM9mNrYQixMJNTYQK7s2wffwPW94JPPbP6jTdd9dSf1mlqYcr6hW2COuayFUk4jP33OWu4taUYP2F5' OAUTH_CLIENT_SECRET = 'KPc4dMSztNwAIB3vii3geXrzC1mKUIsAztz3t2ylC3HlrgJudJWhdrtoY6XeRJIuadTAREYYsXk9XtFHUDfPVcJvyfHMpNkz2VvghwrijhVprok0VYV7XrOirJ5nFUxD'
API_URI = 'https://accounts.gooyal.com' API_URI = 'https://accounts.gooyal.ir'
# API_URI = 'http://127.0.0.1:8000' # API_URI = 'http://127.0.0.1:8000'
@ -27,7 +27,7 @@ class ApiClient():
"grant_type": "password", "grant_type": "password",
"username": phone_number, "username": phone_number,
"password": password, "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_fields": 'phone_number:otp'
} }
auth = (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET) auth = (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET)
@ -51,7 +51,8 @@ class ApiClient():
"grant_type": "password", "grant_type": "password",
"username": phone_number, "username": phone_number,
"password": token, "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_fields": 'phone_number:ott'
} }
auth = (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET) auth = (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET)
@ -156,6 +157,7 @@ class ApiClient():
} }
url = f'{API_URI}/{path}' url = f'{API_URI}/{path}'
response = self._request(url, method='PUT', data=data) response = self._request(url, method='PUT', data=data)
print(response)
return response and 'code' in response, response return response and 'code' in response, response
def introspect_account(self, token): def introspect_account(self, token):
@ -185,19 +187,29 @@ class ApiClient():
return self._request(url=url) return self._request(url=url)
def get_wallet_balance(self): def get_application_preferences(self):
url = 'http://127.0.0.1:8000/wallet/api/wallet/default/balance' url = 'https://preferences.gooyal.com/data/application/'
return self._request(url=url) 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 = ApiClient('+989106853582')
# client.auth_data = auth_data
# print(client.login_as_client_credentials()) # print(client.login_as_client_credentials())
print(client.request_otp()) print(client.request_otp())
print(client.otp_login('12345')) import time
print(client.introspect_account('V3LUQ9OryHOy6q5iWwLBRAtRBm80ex')) start = time.time()
print(client.get_wallet_balance()) 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'] # ott = client.get_application_token()[1]['ott']
# print(client.ott_login(ott)) # print(client.ott_login(ott))
# print(ott) # print(ott)
client.update_account('test')

View file

@ -48,7 +48,7 @@ INSTALLED_APPS = [
'crispy_forms', 'crispy_forms',
'crispy_bootstrap5', 'crispy_bootstrap5',
'django_filters', 'django_filters',
'jalali_date', # 'jalali_date',
'django_minio_backend.apps.DjangoMinioBackendConfig', 'django_minio_backend.apps.DjangoMinioBackendConfig',
# local apps # local apps
@ -119,6 +119,11 @@ REST_FRAMEWORK = {
# 'DEFAULT_THROTTLE_CLASSES': [ # 'DEFAULT_THROTTLE_CLASSES': [
# 'rest_framework.throttling.AnonRateThrottle', # 'rest_framework.throttling.AnonRateThrottle',
# ], # ],
'DEFAULT_THROTTLE_CLASSES': [
'apps.gooyal_oauth2.throttling.TokenLimitThrottle',
# 'rest_framework.throttling.AnonRateThrottle',
# 'rest_framework.throttling.UserRateThrottle'
],
'DEFAULT_THROTTLE_RATES': { 'DEFAULT_THROTTLE_RATES': {
'otp_min': '2/min', 'otp_min': '2/min',
'otp_day': '50/day', 'otp_day': '50/day',
@ -227,7 +232,6 @@ LANGUAGES = [
('tr', _('Turkish')), ('tr', _('Turkish')),
] ]
LOCALE_PATHS = [ LOCALE_PATHS = [
BASE_DIR / 'locale', BASE_DIR / 'locale',
] ]
@ -277,12 +281,10 @@ PAYAM_SMS_PASSWORD = config('PAYAM_SMS_PASSWORD')
PAYAM_SMS_CLIENT_ID = config('PAYAM_SMS_CLIENT_ID') PAYAM_SMS_CLIENT_ID = config('PAYAM_SMS_CLIENT_ID')
PAYAM_SMS_CLIENT_SECRET = config('PAYAM_SMS_CLIENT_SECRET') PAYAM_SMS_CLIENT_SECRET = config('PAYAM_SMS_CLIENT_SECRET')
SMS_OTP_SIGNITURE = config('SMS_OTP_SIGNITURE', '') SMS_OTP_SIGNITURE = config('SMS_OTP_SIGNITURE', '')
SMS_SEND = config('SMS_SEND', True, cast=bool) SMS_SEND = config('SMS_SEND', True, cast=bool)
TEST_PHONENUMBERS = config('TEST_PHONENUMBERS', [], cast=Csv(post_process=list)) TEST_PHONENUMBERS = config('TEST_PHONENUMBERS', [], cast=Csv(post_process=list))
CACHES = { CACHES = {
"default": { "default": {
"BACKEND": "django_redis.cache.RedisCache", "BACKEND": "django_redis.cache.RedisCache",
@ -307,8 +309,10 @@ STORAGES = {
"MINIO_ENDPOINT": config('MINIO_ENDPOINT', default='drive.gooyal.com'), "MINIO_ENDPOINT": config('MINIO_ENDPOINT', default='drive.gooyal.com'),
"MINIO_USE_HTTPS": config('MINIO_USE_HTTPS', default=True, cast=bool), "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": config('MINIO_EXTERNAL_ENDPOINT', default='drive.gooyal.com'),
"MINIO_EXTERNAL_ENDPOINT_USE_HTTPS": config('MINIO_EXTERNAL_ENDPOINT_USE_HTTPS', default=True, cast=bool), # Default is same as MINIO_USE_HTTPS # 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_REGION": None, # Default is set to None
"MINIO_ACCESS_KEY": config('MINIO_ACCESS_KEY'), "MINIO_ACCESS_KEY": config('MINIO_ACCESS_KEY'),
"MINIO_SECRET_KEY": config('MINIO_SECRET_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_ENDPOINT = config('MINIO_ENDPOINT', default='drive.gooyal.ir')
MINIO_USE_HTTPS = config('MINIO_USE_HTTPS', default=True, cast=bool) 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 = config('MINIO_EXTERNAL_ENDPOINT',
MINIO_EXTERNAL_ENDPOINT_USE_HTTPS = config('MINIO_EXTERNAL_ENDPOINT_USE_HTTPS', default=True, cast=bool) # Default is same as MINIO_USE_HTTPS 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_REGION = None # Default is set to None
MINIO_ACCESS_KEY = config('MINIO_ACCESS_KEY') MINIO_ACCESS_KEY = config('MINIO_ACCESS_KEY')
MINIO_SECRET_KEY = config('MINIO_SECRET_KEY') MINIO_SECRET_KEY = config('MINIO_SECRET_KEY')
@ -377,3 +383,4 @@ MINIO_PUBLIC_BUCKETS = [
MINIO_POLICY_HOOKS: List[Tuple[str, dict]] = [] MINIO_POLICY_HOOKS: List[Tuple[str, dict]] = []
MINIO_BUCKET_CHECK_ON_SAVE = True # Default: True // Creates bucket if missing, then save MINIO_BUCKET_CHECK_ON_SAVE = True # Default: True // Creates bucket if missing, then save
MOBIN_SMS_TOKEN = config('MOBIN_SMS_TOKEN', default='')

8
run.sh
View file

@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
while ! nc -z $DB_HOST 5432 ; do #while ! nc -z $DB_HOST 5432 ; do
echo "APP Waiting for the DB Server" # echo "APP Waiting for the DB Server"
sleep 3 # sleep 3
done #done
#python3 manage.py collectstatic --noinput #python3 manage.py collectstatic --noinput
python3 manage.py migrate python3 manage.py migrate
gunicorn main.wsgi:application --bind 0.0.0.0:8000 -w 4 gunicorn main.wsgi:application --bind 0.0.0.0:8000 -w 4

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load jalali_tags %} {#{% load jalali_tags %}#}
{% block title %}Accounts: Home{% endblock %} {% block title %}Accounts: Home{% endblock %}
{% block page_title %}Accounts: Home{% endblock %} {% block page_title %}Accounts: Home{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load jalali_tags %} {#{% load jalali_tags %}#}
{% block title %}Application Register{% endblock %} {% block title %}Application Register{% endblock %}
{% block page_title %}Application Register{% endblock %} {% block page_title %}Application Register{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load jalali_tags %} {#{% load jalali_tags %}#}
{% block title %}Application Detail: {{ object }}{% endblock %} {% block title %}Application Detail: {{ object }}{% endblock %}
{% block page_title %}Application Detail: {{ object }}{% endblock %} {% block page_title %}Application Detail: {{ object }}{% endblock %}

View file

@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load jalali_tags %} {#{% load jalali_tags %}#}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load tags %} {% load tags %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load jalali_tags %} {#{% load jalali_tags %}#}
{% block title %}Application Scope Register{% endblock %} {% block title %}Application Scope Register{% endblock %}
{% block page_title %}Application Scope Register{% endblock %} {% block page_title %}Application Scope Register{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load jalali_tags %} {#{% load jalali_tags %}#}
{% block title %}Application Scope Detail: {{ object.name }}{% endblock %} {% block title %}Application Scope Detail: {{ object.name }}{% endblock %}
{% block page_title %}Application Scope Detail: {{ object.name }}{% endblock %} {% block page_title %}Application Scope Detail: {{ object.name }}{% endblock %}

View file

@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load jalali_tags %} {#{% load jalali_tags %}#}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load tags %} {% load tags %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load jalali_tags %} {#{% load jalali_tags %}#}
{% block title %}Application Update: {{ object.name }}{% endblock %} {% block title %}Application Update: {{ object.name }}{% endblock %}
{% block page_title %}Application Update: {{ object.name }}{% endblock %} {% block page_title %}Application Update: {{ object.name }}{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load jalali_tags %} {#{% load jalali_tags %}#}
{% block title %}Application Update: {{ object.name }}{% endblock %} {% block title %}Application Update: {{ object.name }}{% endblock %}
{% block page_title %}Application Update: {{ object.name }}{% endblock %} {% block page_title %}Application Update: {{ object.name }}{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load jalali_tags %} {#{% load jalali_tags %}#}
{% block title %}user account{% endblock %} {% block title %}user account{% endblock %}
{% block page_title %}user account{% endblock %} {% block page_title %}user account{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load jalali_tags %} {#{% load jalali_tags %}#}
{% block title %}user account{% endblock %} {% block title %}user account{% endblock %}
{% block page_title %}user account{% endblock %} {% block page_title %}user account{% endblock %}

View file

@ -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", "تست")

View file

@ -0,0 +1,6 @@
from utils.clients.sms.sms import BaseSMSClient
class FakeClient(BaseSMSClient):
def send_sms(self, phone_number, message):
pass

View file

@ -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}')

View file

@ -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"
}
]
}

View file

@ -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}')

3
utils/clients/sms/sms.py Normal file
View file

@ -0,0 +1,3 @@
class BaseSMSClient:
def send_sms(self, phone_number, message):
raise NotImplementedError()