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/
oidc.key
/log/
/logs/*.log

View file

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

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

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
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,14 +245,15 @@ 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:
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

View file

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

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 .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/<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>/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.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()

View file

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

View file

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

8
run.sh
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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