diff --git a/.gitignore b/.gitignore index d96bde8..f9979e8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ delme*.py .env /venv/ oidc.key +/log/ /logs/*.log diff --git a/Dockerfile b/Dockerfile index 298a33a..9e378fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/apps/gooyal_oauth2/tests.py b/apps/gooyal_oauth2/tests.py index f2433c8..443e240 100644 --- a/apps/gooyal_oauth2/tests.py +++ b/apps/gooyal_oauth2/tests.py @@ -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) - - - - - - - diff --git a/apps/users/filters.py b/apps/users/filters.py index 3e15c05..9754c70 100644 --- a/apps/users/filters.py +++ b/apps/users/filters.py @@ -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'], + } \ No newline at end of file diff --git a/apps/users/management/commands/send_sms_by_uuid_list.py b/apps/users/management/commands/send_sms_by_uuid_list.py new file mode 100644 index 0000000..92c46e9 --- /dev/null +++ b/apps/users/management/commands/send_sms_by_uuid_list.py @@ -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}') diff --git a/apps/users/models.py b/apps/users/models.py index 0ab60af..cc1ac4d 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -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: diff --git a/apps/users/serializers.py b/apps/users/serializers.py index 773c310..11f2b71 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -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 diff --git a/apps/users/urls.py b/apps/users/urls.py index 789744e..99aeea2 100644 --- a/apps/users/urls.py +++ b/apps/users/urls.py @@ -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//', UserPublicRetrieveView.as_view(), name='user_public_retrieve_api'), path('api/users//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'), ] + diff --git a/apps/users/views.py b/apps/users/views.py index 1bf7abe..71d9b06 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -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): diff --git a/client.py b/client.py index 57e85d2..29a1577 100644 --- a/client.py +++ b/client.py @@ -3,10 +3,10 @@ import time import requests -OAUTH_CLIENT_ID = 'xrFXKf53jrxVOygbLEoqrtOlUwBP00jIQ4zVpzc6' -OAUTH_CLIENT_SECRET = 'qelUdRCdEEalVdko5aHRojxsC3CaL29yjwxmc6FeWs39SeVSb6aM9mNrYQixMJNTYQK7s2wffwPW94JPPbP6jTdd9dSf1mlqYcr6hW2COuayFUk4jP33OWu4taUYP2F5' +OAUTH_CLIENT_ID = '4INGOCMoulE0fNY1SQlTbPtsWqqxGj2DdqjADq6u' +OAUTH_CLIENT_SECRET = 'KPc4dMSztNwAIB3vii3geXrzC1mKUIsAztz3t2ylC3HlrgJudJWhdrtoY6XeRJIuadTAREYYsXk9XtFHUDfPVcJvyfHMpNkz2VvghwrijhVprok0VYV7XrOirJ5nFUxD' -API_URI = 'https://accounts.gooyal.com' +API_URI = 'https://accounts.gooyal.ir' # API_URI = 'http://127.0.0.1:8000' @@ -27,7 +27,7 @@ class ApiClient(): "grant_type": "password", "username": phone_number, "password": password, - "scope": 'accounts.account:request_ott accounts.account:change_password accounts.profile:inquiry accounts.account:update accounts.account:retrieve accounts.profile:retrieve accounts.profile:list introspection wallet.wallet:get_balance', + "scope": 'introspection', "auth_fields": 'phone_number:otp' } auth = (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET) @@ -51,7 +51,8 @@ class ApiClient(): "grant_type": "password", "username": phone_number, "password": token, - "scope": 'introspection education.course:retrieve education.course:submit superapp.applications:list accounts.account:request_ott accounts.profile:list accounts.profile:retrieve accounts.account:retrieve accounts.account:update accounts.profile:inquiry accounts.account:change_password wallet.transaction:list wallet.invoice:create wallet.transaction:retrieve wallet.invoice:pay wallet.invoice:receipt wallet.deposit:submit wallet.deposit:verify wallet.withdraw:submit wallet.withdraw:verify ', + # "scope": 'introspection education.course:retrieve education.course:submit superapp.applications:list accounts.account:request_ott accounts.profile:list accounts.profile:retrieve accounts.account:retrieve accounts.account:update accounts.profile:inquiry accounts.account:change_password wallet.transaction:list wallet.invoice:create wallet.transaction:retrieve wallet.invoice:pay wallet.invoice:receipt wallet.deposit:submit wallet.deposit:verify wallet.withdraw:submit wallet.withdraw:verify ', + "scope": 'introspection', "auth_fields": 'phone_number:ott' } auth = (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET) @@ -156,6 +157,7 @@ class ApiClient(): } url = f'{API_URI}/{path}' response = self._request(url, method='PUT', data=data) + print(response) return response and 'code' in response, response def introspect_account(self, token): @@ -185,19 +187,29 @@ class ApiClient(): return self._request(url=url) - def get_wallet_balance(self): - url = 'http://127.0.0.1:8000/wallet/api/wallet/default/balance' + def get_application_preferences(self): + url = 'https://preferences.gooyal.com/data/application/' return self._request(url=url) +auth_data = {'access_token': 'DJ1mycH9r5FCwaNgDA9nB9uU3KWt9m', 'expires_in': 36000, 'token_type': 'Bearer', 'scope': 'accounts.account:change_password accounts.account:update accounts.account:retrieve wallet.wallet:get_balance billboard_merchant.billboard:retrieve billboard_merchant.billboard:update billboard_merchant.billboard:create wallet.user:transaction_list data_crud.data:retrieve data_crud.data:update data_crud.data:delete', 'refresh_token': 'VF4P11tfcnGv9tc2u0qH6035OW5qU4'} + client = ApiClient('+989106853582') +# client.auth_data = auth_data # print(client.login_as_client_credentials()) print(client.request_otp()) -print(client.otp_login('12345')) -print(client.introspect_account('V3LUQ9OryHOy6q5iWwLBRAtRBm80ex')) -print(client.get_wallet_balance()) +import time +start = time.time() +print(client.otp_login('77502')) +# end = time.time() +# print(end - start) +# print(client.get_application_preferences()) +# exit() +# print(client.introspect_account('V3LUQ9OryHOy6q5iWwLBRAtRBm80ex')) +# print(client.get_wallet_balance()) # ott = client.get_application_token()[1]['ott'] # print(client.ott_login(ott)) # print(ott) +client.update_account('test') diff --git a/main/celery.py b/main/celery.py index 6b7fc88..697b017 100644 --- a/main/celery.py +++ b/main/celery.py @@ -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') # diff --git a/main/settings.py b/main/settings.py index 922f371..427ba0f 100644 --- a/main/settings.py +++ b/main/settings.py @@ -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 diff --git a/requirements.in b/requirements.in index ccc7cf9..9caa867 100644 --- a/requirements.in +++ b/requirements.in @@ -21,4 +21,5 @@ django_redis django_minio_backend daphne Twisted[tls,http2] -packaging==25.0 \ No newline at end of file +packaging==25.0 +unidecode \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1eb7ad6..ebd29a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -246,3 +246,4 @@ zope-interface==8.2 # via # gevent # twisted +unidecode==1.4.0 diff --git a/run.sh b/run.sh index 051a5d0..e24f8ab 100755 --- a/run.sh +++ b/run.sh @@ -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 \ No newline at end of file +#daphne -b 0.0.0.0 -p 8000 main.asgi:application \ No newline at end of file diff --git a/utils/exceptions.py b/utils/exceptions.py index 0c6dd2e..8e730cd 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -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' diff --git a/utils/throttles.py b/utils/throttles.py index 33b9e9c..fb4f5ee 100644 --- a/utils/throttles.py +++ b/utils/throttles.py @@ -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' + +