merge from master

This commit is contained in:
Sayyid Hamid Mahdavi 2026-07-10 12:46:34 +03:30
commit f711ee34a1
17 changed files with 173 additions and 74 deletions

1
.gitignore vendored
View file

@ -6,4 +6,5 @@ delme*.py
.env
/venv/
oidc.key
/log/
/logs/*.log

View file

@ -1,9 +1,9 @@
FROM debian:13
FROM base:debian13
ENV PYTHONUNBUFFERED 1
WORKDIR /app
RUN apt update
RUN apt install gnupg2 netcat-traditional libssl-dev libcrypto++-dev lsb-release python3-pip -y
COPY requirements.txt /app/requirements.txt
RUN pip3 install --break-system-packages -r requirements.txt
RUN pip3 install --break-system-packages setuptools
#RUN apt update
#RUN apt install gnupg2 netcat-traditional libssl-dev libcrypto++-dev lsb-release python3-pip -y
#COPY requirements.txt /app/requirements.txt
#RUN pip3 install --break-system-packages -r requirements.txt
#RUN pip3 install --break-system-packages setuptools
#ENTRYPOINT ["./run.sh"]

View file

@ -8,19 +8,12 @@ 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
@ -31,6 +24,12 @@ def mock_notifications_push_user_success(user_uuid, title, message, priority=5,
return data
from oauth2_provider.models import get_access_token_model, get_application_model
AccessToken = get_access_token_model()
Application = get_application_model()
class GooyalOAuth2Tests(APITestCase):
user_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb47005f')
access_token_1 = 'au4naVsdKCbKNOhnElPyXcrwSnqqFbm'
@ -46,6 +45,7 @@ class GooyalOAuth2Tests(APITestCase):
client = APIClient()
def setUp(self):
from django.conf import settings
settings.SMS_SEND = False
@ -203,10 +203,3 @@ class GooyalOAuth2Tests(APITestCase):
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

@ -1,12 +1,15 @@
import django_filters
from .models import User
class ListUUIDFilter(django_filters.BaseInFilter, django_filters.UUIDFilter):
pass
class UserFilter(django_filters.FilterSet):
uuid_in = ListUUIDFilter(field_name='uuid', required=True)
class Meta:
model = User
fields = {
'uuid': ['in'],
}
# 'uuid': ['in'],
}

View file

@ -0,0 +1,60 @@
from apps.users.models import *
import os
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
help = "send sms by uuid list"
def add_arguments(self, parser):
parser.add_argument('message_file', type=str, help='Message as a file')
parser.add_argument('uuids_file', type=str, help='File with one UUID per line')
def handle(self, *args, **options):
print(options['message_file'])
print(options['uuids_file'])
message_file_path = options['message_file']
uuids_file_path = options['uuids_file']
if not os.path.exists(message_file_path):
raise CommandError(f'File "{message_file_path}" does not exist')
if not os.path.exists(uuids_file_path):
raise CommandError(f'File "{uuids_file_path}" does not exist')
uuids = []
invalid_lines = []
message: str = ''''''
with open(message_file_path, 'r', encoding='utf-8') as f:
message = f.read()
with open(uuids_file_path, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line: # Skip empty lines
continue
try:
# Validate and normalize UUID
valid_uuid = str(uuid.UUID(line))
uuids.append(valid_uuid)
self.stdout.write(f'Line {line_num}: ✓ {valid_uuid}')
except (ValueError, AttributeError, TypeError):
invalid_lines.append((line_num, line))
self.stdout.write(
self.style.ERROR(f'Line {line_num}: ✗ Invalid UUID "{line}"')
)
sms_client = SMSPolicy.get_client()
users = User.objects.filter(uuid__in=uuids)
empty_phone_numbers = list(filter(lambda u: not u.phone_number, users))
if empty_phone_numbers:
logger.error(f'not exist phone numbers for following uuids')
print(empty_phone_numbers)
logger.error(f'end of not exist phone numbers')
return
for user in users:
logger.info(f'try to send message for: {user.uuid} and {user.phone_number}')
sms_client.send_sms(user.phone_number.strip('+'), message)
logger.info(f'sent message for: {user.uuid} and {user.phone_number}')

View file

@ -160,9 +160,6 @@ class User(AbstractUser):
return state.get_province_by_id(self.province)
def set_otp(self):
if self.otp_expire and (self.otp_expire + timedelta(seconds=MAX_OTP_VALID_DURATION)) > timezone.now():
raise Exception(_('otp expire time not reached.'))
if not settings.SMS_SEND:
self._otp = '77501'
elif self.phone_number in settings.TEST_PHONENUMBERS:

View file

@ -5,6 +5,7 @@ from rest_framework import serializers
from apps.users.models import User
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from unidecode import unidecode
from utils.exceptions import UnprocessableEntity
@ -62,6 +63,10 @@ class RequestOTPSerializer(serializers.ModelSerializer):
read_only_fields = ['otp_expire', 'ttl']
def validate(self, attrs):
attrs['phone_number'] = unidecode(attrs['phone_number'])
return attrs
def save(self, **kwargs):
# TODO: move it to query set
validated_data = self.validated_data
@ -71,16 +76,9 @@ class RequestOTPSerializer(serializers.ModelSerializer):
user = User.objects.create_user(**validated_data)
if not user.otp_is_valid():
try:
user.set_otp()
except Exception as e:
raise UnprocessableEntity(_('otp expire time not reached'))
user.set_otp()
user.send_otp()
else:
raise UnprocessableEntity(_('otp expire time not reached yet'))
self.instance = user
return user

View file

@ -2,17 +2,17 @@ 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, UserSessionListView
UserDetailedRetrieveView, UserSessionListView, UserProfileView
app_name = "users"
urlpatterns = [
path('login/', OTPLoginView.as_view(), name='login'),
path('logout/', LogoutView.as_view(), name='logout'),
path('account/', ProfileDetailView.as_view(), name='account_detail'),
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/users/all', UserListView.as_view(), name='user_list_api'),
path('api/users/', UserProfileView.as_view(), name='user_profile_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'),
@ -22,3 +22,4 @@ urlpatterns = [
path('api/change_password/', ChangePasswordView.as_view(), name='change_password_api'),
path('api/inquiry/', UserInquiryView.as_view(), name='user_inquiry_api'),
]

View file

@ -26,6 +26,7 @@ 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, SessionSerializer, ReferralSetSerializer
from utils.throttles import RequestOTPDayRateThrottle, RequestOTPMinRateThrottle, NumberedRequestOTPDayRateThrottle, NumberedRequestOTPMinRateThrottle
from utils.clients.promotions_client import promote_user
from utils.throttles import RequestOTPDayRateThrottle, RequestOTPMinRateThrottle
from utils import exceptions
@ -33,17 +34,27 @@ from utils import exceptions
UserModel = get_user_model()
class UserListView(generics.ListAPIView):
class BaseUserListView(generics.ListAPIView):
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
queryset = User.objects.all()
serializer_class = PublicUserSerializer
filter_backends = (DjangoFilterBackend,)
filterset_class = UserFilter
# Make filterset_class abstract - must be defined in subclasses
filterset_class = None
class UserProfileView(BaseUserListView):
filterset_class = UserFilter
required_alternate_scopes = {
"GET": [["accounts.profile:list"]],
}
class UserListView(BaseUserListView):
# filterset_class = UserFilter
required_alternate_scopes = {
"GET": [["accounts.users:list"]],
}
class UserPublicRetrieveView(generics.RetrieveAPIView):
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
@ -99,7 +110,10 @@ class RequestOTPView(generics.CreateAPIView):
permission_classes = []
serializer_class = RequestOTPSerializer
required_scopes = []
throttle_classes = [RequestOTPMinRateThrottle, RequestOTPDayRateThrottle]
throttle_classes = [RequestOTPMinRateThrottle,
RequestOTPDayRateThrottle,
NumberedRequestOTPDayRateThrottle,
NumberedRequestOTPMinRateThrottle]
class RequestOTTView(generics.CreateAPIView):

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

@ -6,7 +6,7 @@
# from django.conf import settings
#
# # set the default Django settings module for the 'celery' program.
# # os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'accounts.settings')
# # os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings')
#
# app = Celery('notification_service')
#

View file

@ -126,7 +126,9 @@ REST_FRAMEWORK = {
],
'DEFAULT_THROTTLE_RATES': {
'otp_min': '2/min',
'otp_day': '200/day',
'otp_day': '50/day',
'numbered_otp_min': '1/min',
'numbered_otp_day': '10/day',
},
'EXCEPTION_HANDLER': 'utils.exceptions.exception_handler',
}
@ -293,7 +295,7 @@ CACHES = {
}
}
LOKI_BASE_PUBLIC_URL = config('LOKI_BASE_PUBLIC_URL', default=None, cast=str)
from main.other_settings.logging import LOGGING
@ -304,10 +306,10 @@ STORAGES = {
"default": {
"BACKEND": "django_minio_backend.models.MinioBackend",
"OPTIONS": {
"MINIO_ENDPOINT": config('MINIO_ENDPOINT', default='drive.gooyal.com'),
"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'),
"MINIO_EXTERNAL_ENDPOINT": config('MINIO_EXTERNAL_ENDPOINT', default='drive.gooyal.ir'),
# 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
@ -315,7 +317,7 @@ STORAGES = {
"MINIO_ACCESS_KEY": config('MINIO_ACCESS_KEY'),
"MINIO_SECRET_KEY": config('MINIO_SECRET_KEY'),
"MINIO_URL_EXPIRY_HOURS": timedelta(days=1), # Default is 7 days (longest) if not defined
"MINIO_CONSISTENCY_CHECK_ON_START": False,
"MINIO_CONSISTENCY_CHECK_ON_START": True,
"MINIO_DEFAULT_BUCKET": config('MINIO_MEDIA_FILES_BUCKET'), # replacement for MEDIA_ROOT
# MINIO_STATIC_FILES_BUCKET = 'my-static-files-bucket' # replacement for STATIC_ROOT
@ -360,7 +362,7 @@ 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
default='drive.gooyal.ir') # 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

View file

@ -21,4 +21,5 @@ django_redis
django_minio_backend
daphne
Twisted[tls,http2]
packaging==25.0
packaging==25.0
unidecode

View file

@ -246,3 +246,4 @@ zope-interface==8.2
# via
# gevent
# twisted
unidecode==1.4.0

4
run.sh
View file

@ -4,6 +4,6 @@
# sleep 3
#done
#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
#daphne -b 0.0.0.0 -p 8000 accounts.asgi:application
#daphne -b 0.0.0.0 -p 8000 main.asgi:application

View file

@ -23,14 +23,9 @@ def exception_handler(exc, context):
if isinstance(exc, APIException):
try:
if isinstance(exc.detail, dict):
if 'code' in exc.detail:
error = exc.detail['code']
else:
attr = next(iter(exc.detail))
error = exc.detail[attr].code
else:
error = exc.detail.code or exc.code
error = getattr(getattr(exc, 'detail', None), 'code', None) or getattr(exc, 'code', None)
if not error:
error = getattr(exc, 'default_code')
except:
error = ''
@ -46,10 +41,7 @@ def exception_handler(exc, context):
# message = error
#
# response_data['message'] = message
if isinstance(exc.detail, dict):
response_data['details'] = exc.detail
else:
response_data['details'] = {"message": exc.detail}
response_data['details'] = {"message": exc.detail}
response_data['details']['error'] = error
response_data['details']['timestamp'] = timezone.now().isoformat()
@ -89,7 +81,7 @@ from django.utils.translation import gettext_lazy as _
class UnprocessableEntity(APIException):
status_code = 422
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
default_detail = 'The request was well-formed but cannot be processed due to semantic errors.'
default_code = 'unprocessable_entity'

View file

@ -16,9 +16,33 @@ class RequestOTPDayRateThrottle(SimpleRateThrottle):
}
class RequestOTPMinRateThrottle(RequestOTPDayRateThrottle):
scope = 'otp_min'
class NumberedRequestOTPDayRateThrottle(SimpleRateThrottle):
scope = 'numbered_otp_day'
def get_ident(self, request):
return request.data.get('phone_number')
def get_cache_key(self, request, view):
if request.user and request.user.is_authenticated:
ident = request.user.pk
else:
ident = self.get_ident(request)
return self.cache_format % {
'scope': self.scope,
'ident': ident
}
class NumberedRequestOTPMinRateThrottle(NumberedRequestOTPDayRateThrottle):
scope = 'numbered_otp_min'