From 4b277bc95fffac019ff25e52edb4546699af5f15 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Mon, 16 Mar 2026 13:43:59 +0330 Subject: [PATCH 01/27] mobin sms client --- accounts/settings.py | 3 ++- utils/clients/mobin_sms/client.py | 34 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 utils/clients/mobin_sms/client.py diff --git a/accounts/settings.py b/accounts/settings.py index 460f1ad..22ba980 100644 --- a/accounts/settings.py +++ b/accounts/settings.py @@ -48,7 +48,7 @@ INSTALLED_APPS = [ 'crispy_forms', 'crispy_bootstrap5', 'django_filters', - 'jalali_date', + # 'jalali_date', 'django_minio_backend.apps.DjangoMinioBackendConfig', # local apps @@ -408,3 +408,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='') \ No newline at end of file diff --git a/utils/clients/mobin_sms/client.py b/utils/clients/mobin_sms/client.py new file mode 100644 index 0000000..d23c5e0 --- /dev/null +++ b/utils/clients/mobin_sms/client.py @@ -0,0 +1,34 @@ +import urllib.parse +import requests + +from django.conf import settings + + +class MobinSMSClient: + 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): + 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) + print(response.text) + return response.json() From c0f65b713f65f666354bf5c7710f1b5d8eae4356 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Mon, 16 Mar 2026 15:55:54 +0330 Subject: [PATCH 02/27] sms policy --- apps/core/migrations/0001_initial.py | 31 ++ utils/clients/sms/fake/client.py | 6 + ..._panel.mobinsms.com_data_connect-v1.7.json | 387 ++++++++++++++++++ utils/clients/sms/sms.py | 3 + 4 files changed, 427 insertions(+) create mode 100644 apps/core/migrations/0001_initial.py create mode 100644 utils/clients/sms/fake/client.py create mode 100644 utils/clients/sms/mobin_sms/https___panel.mobinsms.com_data_connect-v1.7.json create mode 100644 utils/clients/sms/sms.py diff --git a/apps/core/migrations/0001_initial.py b/apps/core/migrations/0001_initial.py new file mode 100644 index 0000000..30c7f22 --- /dev/null +++ b/apps/core/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 6.0.2 on 2026-03-16 12:23 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SMSPolicy', + fields=[ + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True)), + ('preferred', models.CharField(choices=[('fake', 'fake'), ('payam_sms', 'payam_sms'), ('mobin_sms', 'mobin_sms')], default='fake', max_length=16)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, unique=True)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/utils/clients/sms/fake/client.py b/utils/clients/sms/fake/client.py new file mode 100644 index 0000000..cba10bc --- /dev/null +++ b/utils/clients/sms/fake/client.py @@ -0,0 +1,6 @@ +from utils.clients.sms.sms import BaseSMSClient + + +class FakeClient(BaseSMSClient): + def send_sms(self, phone_number, message): + pass diff --git a/utils/clients/sms/mobin_sms/https___panel.mobinsms.com_data_connect-v1.7.json b/utils/clients/sms/mobin_sms/https___panel.mobinsms.com_data_connect-v1.7.json new file mode 100644 index 0000000..5e55ed7 --- /dev/null +++ b/utils/clients/sms/mobin_sms/https___panel.mobinsms.com_data_connect-v1.7.json @@ -0,0 +1,387 @@ +{ + "info": { + "_postman_id": "9cc27226-bee8-4b29-a924-a9e14bb61d14", + "name": "Connect API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "2960438" + }, + "item": [ + { + "name": "Send by Pattern", + "protocolProfileBehavior": { + "followRedirects": true, + "disableUrlEncoding": false, + "disableCookies": false + }, + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-API-Key", + "value": "{{apiKey}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"from\": \"989000xxxx\",\n\t\"to\": [\"989126880345\"],\n\t\"pattern\": \"YOUR_PATTERN_KEY\",\n\t\"data\": {\n\t\t\"0\": \"Var 0\",\n\t\t\"1\": \"Var 1\",\n\t\t\"2\": \"Var 2\",\n\t\t\"3\": \"Var 3\"\n\t}\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/v1/send/pattern", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "send", + "pattern" + ] + } + }, + "response": [] + }, + { + "name": "Send Quick", + "protocolProfileBehavior": { + "followRedirects": true, + "disableUrlEncoding": false, + "disableCookies": false + }, + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-API-Key", + "value": "{{apiKey}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"from\": \"989000xxxx\",\n\t\"to\": [\"989126880345\", \"989127761851\"],\n\t\"body\": \"Your message\",\n \"unique_id\": \"\" // UDH (optional field)\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/v1/send/quick", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "send", + "quick" + ] + } + }, + "response": [] + }, + { + "name": "Get Status", + "protocolProfileBehavior": { + "disableBodyPruning": true, + "followRedirects": true, + "disableUrlEncoding": false, + "disableCookies": false + }, + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-API-Key", + "value": "{{apiKey}}" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/v1/message/status?message_id=1893", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "message", + "status" + ], + "query": [ + { + "key": "unique_id", + "value": "20", + "disabled": true + }, + { + "key": "message_id", + "value": "1893" + } + ] + } + }, + "response": [ + { + "name": "Delivered Message", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "User-Agent", + "value": "insomnia/11.6.2" + }, + { + "key": "X-API-Key", + "value": "07e7e9c79652b857aa77b8f3d40cc4314321970e1a5f78b9fc18e497aca2bf40" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"from\": \"9890001777\",\n\t\"to\": [\"989126880345\"],\n\t\"body\": \"Your message\",\n \"unique_id\": \"6\" // UDH (optional field)\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/v1/message/status?message_id=1852", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "message", + "status" + ], + "query": [ + { + "key": "message_id", + "value": "1852" + }, + { + "key": "unique_id", + "value": "webengage-message-id", + "disabled": true + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Server", + "value": "nginx" + }, + { + "key": "Date", + "value": "Sun, 09 Nov 2025 16:20:37 GMT" + }, + { + "key": "Content-Type", + "value": "application/json", + "description": "", + "type": "text" + }, + { + "key": "Content-Length", + "value": "38" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "cookie": [], + "body": "{\n \"id\": 274,\n \"status\": \"2\", // 0: Pending, 1: Sent, 2: Delivered\n \"unique_id\": \"\"\n}" + } + ] + }, + { + "name": "Webengage", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-API-Key", + "value": "{{apiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"version\": \"2.0\",\n \"smsData\": {\n \"toNumber\": \"989195712939\",\n \"fromNumber\": \"9890006950\",\n \"body\": \"Text message body\"\n },\n \"metadata\": {\n \"campaignType\": \"PROMOTIONAL\",\n \"timestamp\": \"2018-01-25T10:24:16+0000\",\n \"messageId\": \"webengage-message-id222\",\n \"custom\": {\n \"key1\": \"val1\",\n \"key2\": \"val2\"\n },\n \"indiaDLT\": {\n \"contentTemplateId\": \"xyz\",\n \"principalEntityId\": \"abc\",\n \"telemarketerId\": \"tm1,tm2\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/v1/integration/webengage", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "integration", + "webengage" + ] + } + }, + "response": [] + }, + { + "name": "Send Bulk", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-API-Key", + "value": "{{apiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"from\": \"989000xxxx\",\n \"on_duplicate\": \"skip\", // Skip to send only unique ones, reject to stop sending any message even if one is not unique\n \"messages\": [\n {\n \"to\": \"989126880345\",\n \"body\": \"Your message\",\n \"unique_id\": \"1\" // UDH: Optional\n },\n {\n \"to\": \"989127865454\",\n \"body\": \"Your message 2\",\n \"unique_id\": \"20\" // UDH: Optional\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/v1/send/bulk", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "send", + "bulk" + ] + } + }, + "response": [] + }, + { + "name": "Account Balance", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-API-Key", + "value": "{{apiKey}}" + } + ], + "url": { + "raw": "{{baseURL}}/v1/account/balance", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "account", + "balance" + ], + + } + }, + "response": [] + }, + { + "name": "Send vOTP", + "request": { + "method": "POST", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}", + "type": "text" + }, + { + "key": "content-type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"from\": \"9890002999\",\n\t\"to\": \"989126880345\",\n\t\"code\": \"123456\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/v1/send/votp", + "host": [ + "{{baseURL}}" + ], + "path": [ + "v1", + "send", + "votp" + ] + } + }, + "response": [] + } + ], + "variable": [ + { + "key": "baseURL", + "value": "", + "type": "default" + }, + { + "key": "apiKey", + "value": "", + "type": "default" + } + ] +} \ No newline at end of file diff --git a/utils/clients/sms/sms.py b/utils/clients/sms/sms.py new file mode 100644 index 0000000..35dd319 --- /dev/null +++ b/utils/clients/sms/sms.py @@ -0,0 +1,3 @@ +class BaseSMSClient: + def send_sms(self, phone_number, message): + raise NotImplementedError() \ No newline at end of file From 29e4b4054cdbe4c83a8543c6c4becc7e7edf1eaf Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Mon, 16 Mar 2026 16:02:22 +0330 Subject: [PATCH 03/27] sms policy --- apps/core/models.py | 33 ++++++++++++++++ apps/users/models.py | 12 +++--- utils/clients/mobin_sms/client.py | 34 ----------------- utils/clients/payam_sms.py | 50 ------------------------- utils/clients/sms/mobin_sms/client.py | 43 +++++++++++++++++++++ utils/clients/sms/payam_sms/client.py | 54 +++++++++++++++++++++++++++ 6 files changed, 136 insertions(+), 90 deletions(-) delete mode 100644 utils/clients/mobin_sms/client.py delete mode 100644 utils/clients/payam_sms.py create mode 100644 utils/clients/sms/mobin_sms/client.py create mode 100644 utils/clients/sms/payam_sms/client.py diff --git a/apps/core/models.py b/apps/core/models.py index 56987d4..0a5682e 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -1,2 +1,35 @@ from django.db import models +from django.db.models import TextChoices from django.utils.translation import gettext_lazy as _ + +from apps.gooyal_oauth2.settings import oauth2_settings +from utils.clients.sms.sms import BaseSMSClient +from utils.models import BaseModel +from utils.clients.sms.payam_sms.client import PayamSMSClient +from utils.clients.sms.mobin_sms.client import MobinSMSClient +from utils.clients.sms.fake.client import FakeClient + +import logging +logger = logging.getLogger(__name__) + + + +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) + + @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() diff --git a/apps/users/models.py b/apps/users/models.py index 4da2cee..2eb6c67 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -15,8 +15,7 @@ from django.utils.translation import gettext_lazy as _ import uuid from django_minio_backend import MinioBackend, iso_date_prefix - -from utils.clients.payam_sms import send_sms +from apps.core.models import SMSPolicy from .provinces_and_cities import state # from .tasks import send_notification @@ -250,10 +249,11 @@ class User(AbstractUser): # # f"code is: {body}\n" # f"{settings.SMS_OTP_SIGNITURE}" ) - if settings.SMS_SEND: - send_sms(self.phone_number.strip('+'), message) - else: - logger.info(f'otp for: {self.phone_number} is {message}') + + sms_client = SMSPolicy.get_client() + sms_client.send_sms(self.phone_number.strip('+'), message) + + logger.info(f'otp for: {self.phone_number} is {message}') return # TODO: enable celery send_notification( diff --git a/utils/clients/mobin_sms/client.py b/utils/clients/mobin_sms/client.py deleted file mode 100644 index d23c5e0..0000000 --- a/utils/clients/mobin_sms/client.py +++ /dev/null @@ -1,34 +0,0 @@ -import urllib.parse -import requests - -from django.conf import settings - - -class MobinSMSClient: - 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): - 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) - print(response.text) - return response.json() diff --git a/utils/clients/payam_sms.py b/utils/clients/payam_sms.py deleted file mode 100644 index 6f6295d..0000000 --- a/utils/clients/payam_sms.py +++ /dev/null @@ -1,50 +0,0 @@ -# TODO: this is time fourced code. refactor - -import requests -from django.conf import settings - - -def get_access_token(): - payam_sms_system_name = settings.PAYAM_SMS_SYSTEM_NAME - payam_sms_username = settings.PAYAM_SMS_USERNAME - payam_sms_password = settings.PAYAM_SMS_PASSWORD - payam_sms_client_id = settings.PAYAM_SMS_CLIENT_ID - payam_sms_client_secret = settings.PAYAM_SMS_CLIENT_SECRET - auth = (payam_sms_client_id, payam_sms_client_secret) - url = f"https://www.payamsms.com/auth/oauth/token?systemName={payam_sms_system_name}&username={payam_sms_username}&password={payam_sms_password}&scope=webservice&grant_type=password" - - response_object = requests.post(url, auth=auth, json={}) - response = response_object.json() - return response["access_token"] - - -def send_sms(phone_number, message): - try: - url = "https://www.payamsms.com/panel/webservice/send" - - # access_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJzb2JhbiIsInNjb3BlIjpbIndlYnNlcnZpY2UiXSwiZXhwIjoxNzQ0MTAyMDU5LCJ1c2VyRGV0YWlscyI6eyJpZCI6MjU0MCwidXNlck5hbWUiOiJzb2JhbiIsInJvbGUiOiJVU0VSIiwiZW5hYmxlZCI6dHJ1ZSwic3lzdGVtSWQiOjcyMiwiYWNjZXNzIjp7InJvbGUiOlsiYWRkIiwiZWRpdCIsImdldCIsImRlbGV0ZSJdLCJwaG9uZUJvb2siOlsiYWRkIiwiZWRpdCIsImdldExvY2FsIiwiZ2V0R2xvYmFsIiwiZGVsZXRlIl0sInNtcyI6WyJzaW5nbGUiLCJnZXRMb2NhbCIsImdldEdsb2JhbCIsImJ1bGsiLCJkYiJdLCJyZXBvcnQiOlsiZ2V0TW9HbG9iYWwiLCJnZXREYWlseUxvY2FsIiwiZ2V0TW9Mb2NhbCIsImdldERhaWx5R2xvYmFsIl0sIndzIjpbIndzIl0sInVzZXIiOlsiYWRkIiwiZWRpdCIsImdldExvY2FsIiwiZ2V0IiwiZ2V0R2xvYmFsIiwiZGVsZXRlIl0sInRvb2xzIjpbInJhY2UiLCJwZXJpb2RpYyIsInBvbGxpbmciLCJzZWNyZXRvcnkiLCJhdXRvYW5zd2VyIl0sImZpbmFuY2UiOlsidGRyIiwiY2hhcmdlIl19LCJyb2xlSWQiOjQ0NDAsImNoaWxkcmVuSWRzIjpbXSwic3lzdGVtVHlwZSI6Ik5PUk1BTCIsInN5c3RlbVBheW1lbnRUeXBlIjoiREVCSVQiLCJzeXN0ZW1OYW1lIjoic29iYW4iLCJzZW5kV2l0aE91dFBlcm1pc3Npb25TeXN0ZW0iOmZhbHNlLCJtaXNDdXN0b21lciI6Itio2YbYr9in2LEg2LPZiNio2KfZhiDYs9in2YXYp9mG2YciLCJwYXJlbnRVc2VyTmFtZSI6bnVsbCwicGFyZW50SWQiOm51bGwsImFkbWluIjp0cnVlfSwiYXV0aG9yaXRpZXMiOlsiVVNFUiJdLCJqdGkiOiJNeU5vTml2RURmc3YxUDR3U20tbTR2Y2NHODgiLCJjbGllbnRfaWQiOiJzb2JhbiJ9.9mdneDduK4OVH2zsXvRqvXt2qwpLvs0vCuseLaB-LCo' - access_token = get_access_token() - - body = [ - { - "sender": "98200000443295", - "recipient": phone_number, - "body": message, - # "customerId": "1", - # "sendDate": "2024-03-04 10:17:00" - } - ] - - headers = { - 'Authorization': f'Bearer {access_token}', - 'Content-Type': 'application/json', - 'Accept': 'application/json', - } - - response_object = requests.post(url, headers=headers, json=body, timeout=10) - print(response_object.text) - except Exception as e: - print(e) - - -# send_sms("989106853582", "تست") diff --git a/utils/clients/sms/mobin_sms/client.py b/utils/clients/sms/mobin_sms/client.py new file mode 100644 index 0000000..93ec55f --- /dev/null +++ b/utils/clients/sms/mobin_sms/client.py @@ -0,0 +1,43 @@ +import urllib.parse +import requests + +from django.conf import settings +import logging + +from utils.clients.sms.sms import BaseSMSClient + +logger = logging.getLogger(__name__) + +class MobinSMSClient(BaseSMSClient): + access_token = settings.MOBIN_SMS_TOKEN + base_url = 'https://connect.mobinsms.com' + sender = '9890008050' + + def send_sms(self, phone_number, message): + return self.send_sms_quick([phone_number], message) + + def send_sms_quick(self, phone_number_list, message): + try: + path = '/v1/send/quick' + url = urllib.parse.urljoin(self.base_url, path) + + body = { + "from": self.sender, + "to": phone_number_list, + "body": message, + "unique_id": "" # UDH (optional field) + } + + headers = { + "X-API-Key": self.access_token, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + + response = requests.post(url, headers=headers, json=body, timeout=10) + logger.info(response.text) + return response.json() + + except Exception as e: + logger.exception(f'error sending sms to {phone_number_list}: {e}') + diff --git a/utils/clients/sms/payam_sms/client.py b/utils/clients/sms/payam_sms/client.py new file mode 100644 index 0000000..ec4b57b --- /dev/null +++ b/utils/clients/sms/payam_sms/client.py @@ -0,0 +1,54 @@ +# TODO: this is time fourced code. refactor + +import requests +from django.conf import settings +import logging + +from utils.clients.sms.sms import BaseSMSClient + +logger = logging.getLogger(__name__) + +class PayamSMSClient(BaseSMSClient): + def get_access_token(self): + payam_sms_system_name = settings.PAYAM_SMS_SYSTEM_NAME + payam_sms_username = settings.PAYAM_SMS_USERNAME + payam_sms_password = settings.PAYAM_SMS_PASSWORD + payam_sms_client_id = settings.PAYAM_SMS_CLIENT_ID + payam_sms_client_secret = settings.PAYAM_SMS_CLIENT_SECRET + auth = (payam_sms_client_id, payam_sms_client_secret) + url = f"https://www.payamsms.com/auth/oauth/token?systemName={payam_sms_system_name}&username={payam_sms_username}&password={payam_sms_password}&scope=webservice&grant_type=password" + + response_object = requests.post(url, auth=auth, json={}) + response = response_object.json() + return response["access_token"] + + + def send_sms(self, phone_number, message): + try: + url = "https://www.payamsms.com/panel/webservice/send" + + # access_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJzb2JhbiIsInNjb3BlIjpbIndlYnNlcnZpY2UiXSwiZXhwIjoxNzQ0MTAyMDU5LCJ1c2VyRGV0YWlscyI6eyJpZCI6MjU0MCwidXNlck5hbWUiOiJzb2JhbiIsInJvbGUiOiJVU0VSIiwiZW5hYmxlZCI6dHJ1ZSwic3lzdGVtSWQiOjcyMiwiYWNjZXNzIjp7InJvbGUiOlsiYWRkIiwiZWRpdCIsImdldCIsImRlbGV0ZSJdLCJwaG9uZUJvb2siOlsiYWRkIiwiZWRpdCIsImdldExvY2FsIiwiZ2V0R2xvYmFsIiwiZGVsZXRlIl0sInNtcyI6WyJzaW5nbGUiLCJnZXRMb2NhbCIsImdldEdsb2JhbCIsImJ1bGsiLCJkYiJdLCJyZXBvcnQiOlsiZ2V0TW9HbG9iYWwiLCJnZXREYWlseUxvY2FsIiwiZ2V0TW9Mb2NhbCIsImdldERhaWx5R2xvYmFsIl0sIndzIjpbIndzIl0sInVzZXIiOlsiYWRkIiwiZWRpdCIsImdldExvY2FsIiwiZ2V0IiwiZ2V0R2xvYmFsIiwiZGVsZXRlIl0sInRvb2xzIjpbInJhY2UiLCJwZXJpb2RpYyIsInBvbGxpbmciLCJzZWNyZXRvcnkiLCJhdXRvYW5zd2VyIl0sImZpbmFuY2UiOlsidGRyIiwiY2hhcmdlIl19LCJyb2xlSWQiOjQ0NDAsImNoaWxkcmVuSWRzIjpbXSwic3lzdGVtVHlwZSI6Ik5PUk1BTCIsInN5c3RlbVBheW1lbnRUeXBlIjoiREVCSVQiLCJzeXN0ZW1OYW1lIjoic29iYW4iLCJzZW5kV2l0aE91dFBlcm1pc3Npb25TeXN0ZW0iOmZhbHNlLCJtaXNDdXN0b21lciI6Itio2YbYr9in2LEg2LPZiNio2KfZhiDYs9in2YXYp9mG2YciLCJwYXJlbnRVc2VyTmFtZSI6bnVsbCwicGFyZW50SWQiOm51bGwsImFkbWluIjp0cnVlfSwiYXV0aG9yaXRpZXMiOlsiVVNFUiJdLCJqdGkiOiJNeU5vTml2RURmc3YxUDR3U20tbTR2Y2NHODgiLCJjbGllbnRfaWQiOiJzb2JhbiJ9.9mdneDduK4OVH2zsXvRqvXt2qwpLvs0vCuseLaB-LCo' + access_token = self.get_access_token() + + body = [ + { + "sender": "98200000443295", + "recipient": phone_number, + "body": message, + # "customerId": "1", + # "sendDate": "2024-03-04 10:17:00" + } + ] + + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + + response_object = requests.post(url, headers=headers, json=body, timeout=10) + logger.info(response_object.text) + return response_object + except Exception as e: + logger.exception(f'error sending sms to {phone_number}: {e}') + From b786a957e4529567e03ccfca9bd751933af84ed5 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Mon, 16 Mar 2026 16:10:23 +0330 Subject: [PATCH 04/27] sms policy --- apps/core/admin.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/core/admin.py b/apps/core/admin.py index c6fe108..d64818f 100644 --- a/apps/core/admin.py +++ b/apps/core/admin.py @@ -1,2 +1,12 @@ from django.contrib import admin +from .models import SMSPolicy +class SMSPolicyAdmin(admin.ModelAdmin): + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + +admin.site.register(SMSPolicy, SMSPolicyAdmin) From 6dbb86d7eedef6ce50308c948b8f75253390ab3e Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Mon, 16 Mar 2026 16:18:15 +0330 Subject: [PATCH 05/27] sms policy --- apps/core/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/core/admin.py b/apps/core/admin.py index d64818f..2b7593b 100644 --- a/apps/core/admin.py +++ b/apps/core/admin.py @@ -5,7 +5,7 @@ class SMSPolicyAdmin(admin.ModelAdmin): def has_add_permission(self, request): return False - def has_change_permission(self, request, obj=None): + def has_delete_permission(self, request, obj=None): return False From 0cd2b96963c2a40e145e893bcd56eacd6958d9a4 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Tue, 17 Mar 2026 13:44:59 +0330 Subject: [PATCH 06/27] temporary break point --- apps/core/urls.py | 4 +++- apps/core/views.py | 9 +++++++++ run.sh | 14 +++++++------- templates/core/home.html | 2 +- templates/gooyal_oauth2/application_create.html | 2 +- templates/gooyal_oauth2/application_detail.html | 2 +- templates/gooyal_oauth2/application_list.html | 2 +- .../gooyal_oauth2/application_scope_create.html | 2 +- .../gooyal_oauth2/application_scope_detail.html | 2 +- .../gooyal_oauth2/application_scope_list.html | 2 +- .../gooyal_oauth2/application_scope_update.html | 2 +- templates/gooyal_oauth2/application_update.html | 2 +- templates/users/profile_detail.html | 2 +- templates/users/profile_update.html | 2 +- 14 files changed, 30 insertions(+), 19 deletions(-) diff --git a/apps/core/urls.py b/apps/core/urls.py index 7f82b8a..cbf7a57 100644 --- a/apps/core/urls.py +++ b/apps/core/urls.py @@ -1,7 +1,9 @@ from django.urls import path, include -from .views import HomeView +from .views import HomeView, TestIpView + app_name = "core" urlpatterns = [ path('', HomeView.as_view(), name='home'), + path('test-ip', TestIpView.as_view(), name='test-ip'), ] diff --git a/apps/core/views.py b/apps/core/views.py index a3b2197..871e71d 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -8,3 +8,12 @@ from django.views.generic import TemplateView class HomeView(TemplateView): template_name = 'core/home.html' + +class TestIpView(TemplateView): + template_name = 'core/home.html' + + def get(self, request, *args, **kwargs): + breakpoint() + return super(TestIpView, self).get(request, *args, **kwargs) + + diff --git a/run.sh b/run.sh index 80e55fd..e4dfb50 100755 --- a/run.sh +++ b/run.sh @@ -1,9 +1,9 @@ #!/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 accounts.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 +#python3 manage.py migrate +gunicorn accounts.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 diff --git a/templates/core/home.html b/templates/core/home.html index f1dc136..e8d159a 100644 --- a/templates/core/home.html +++ b/templates/core/home.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}Accounts: Home{% endblock %} {% block page_title %}Accounts: Home{% endblock %} diff --git a/templates/gooyal_oauth2/application_create.html b/templates/gooyal_oauth2/application_create.html index 0603b09..0d1dc01 100644 --- a/templates/gooyal_oauth2/application_create.html +++ b/templates/gooyal_oauth2/application_create.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}Application Register{% endblock %} {% block page_title %}Application Register{% endblock %} diff --git a/templates/gooyal_oauth2/application_detail.html b/templates/gooyal_oauth2/application_detail.html index 1c15954..db64873 100644 --- a/templates/gooyal_oauth2/application_detail.html +++ b/templates/gooyal_oauth2/application_detail.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}Application Detail: {{ object }}{% endblock %} {% block page_title %}Application Detail: {{ object }}{% endblock %} diff --git a/templates/gooyal_oauth2/application_list.html b/templates/gooyal_oauth2/application_list.html index 2a7db52..4b6e4a7 100644 --- a/templates/gooyal_oauth2/application_list.html +++ b/templates/gooyal_oauth2/application_list.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% load static %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% load crispy_forms_tags %} {% load tags %} diff --git a/templates/gooyal_oauth2/application_scope_create.html b/templates/gooyal_oauth2/application_scope_create.html index 315c839..6e866e2 100644 --- a/templates/gooyal_oauth2/application_scope_create.html +++ b/templates/gooyal_oauth2/application_scope_create.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}Application Scope Register{% endblock %} {% block page_title %}Application Scope Register{% endblock %} diff --git a/templates/gooyal_oauth2/application_scope_detail.html b/templates/gooyal_oauth2/application_scope_detail.html index 97723ad..d9fa265 100644 --- a/templates/gooyal_oauth2/application_scope_detail.html +++ b/templates/gooyal_oauth2/application_scope_detail.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}Application Scope Detail: {{ object.name }}{% endblock %} {% block page_title %}Application Scope Detail: {{ object.name }}{% endblock %} diff --git a/templates/gooyal_oauth2/application_scope_list.html b/templates/gooyal_oauth2/application_scope_list.html index 6a7eaa7..05edada 100644 --- a/templates/gooyal_oauth2/application_scope_list.html +++ b/templates/gooyal_oauth2/application_scope_list.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% load static %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% load crispy_forms_tags %} {% load tags %} diff --git a/templates/gooyal_oauth2/application_scope_update.html b/templates/gooyal_oauth2/application_scope_update.html index f91b4bc..7d1b83a 100644 --- a/templates/gooyal_oauth2/application_scope_update.html +++ b/templates/gooyal_oauth2/application_scope_update.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}Application Update: {{ object.name }}{% endblock %} {% block page_title %}Application Update: {{ object.name }}{% endblock %} diff --git a/templates/gooyal_oauth2/application_update.html b/templates/gooyal_oauth2/application_update.html index e3b78b6..5c555f7 100644 --- a/templates/gooyal_oauth2/application_update.html +++ b/templates/gooyal_oauth2/application_update.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}Application Update: {{ object.name }}{% endblock %} {% block page_title %}Application Update: {{ object.name }}{% endblock %} diff --git a/templates/users/profile_detail.html b/templates/users/profile_detail.html index b9ad4ff..c949034 100644 --- a/templates/users/profile_detail.html +++ b/templates/users/profile_detail.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}user account{% endblock %} {% block page_title %}user account{% endblock %} diff --git a/templates/users/profile_update.html b/templates/users/profile_update.html index 9e6715d..de768f0 100644 --- a/templates/users/profile_update.html +++ b/templates/users/profile_update.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load static %} {% load crispy_forms_tags %} -{% load jalali_tags %} +{#{% load jalali_tags %}#} {% block title %}user account{% endblock %} {% block page_title %}user account{% endblock %} From e1b34b24822e9fabeb41ee377cffc6a6e9857de9 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Tue, 17 Mar 2026 14:19:12 +0330 Subject: [PATCH 07/27] temporary break point --- apps/core/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/core/views.py b/apps/core/views.py index 871e71d..e0ad885 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -13,7 +13,7 @@ class TestIpView(TemplateView): template_name = 'core/home.html' def get(self, request, *args, **kwargs): - breakpoint() + print(request.headers) return super(TestIpView, self).get(request, *args, **kwargs) From 2d1079f0c238183e7c445de295b53a56c5e6aab5 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Tue, 17 Mar 2026 15:03:58 +0330 Subject: [PATCH 08/27] get header data from prod --- apps/core/models.py | 3 +++ apps/core/views.py | 20 ++++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/core/models.py b/apps/core/models.py index 0a5682e..ce61cb6 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -24,6 +24,9 @@ class SMSPolicy(BaseModel): 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] diff --git a/apps/core/views.py b/apps/core/views.py index e0ad885..de3b9dd 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -3,6 +3,7 @@ from django.shortcuts import render from django.utils.decorators import method_decorator from django.views.generic import TemplateView + # Create your views here. # @method_decorator(login_required, name='dispatch') class HomeView(TemplateView): @@ -14,6 +15,21 @@ class TestIpView(TemplateView): 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) - - From 4d5f1983c79b206aa69b3c9b7e37b266f04df8ef Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Tue, 17 Mar 2026 15:10:56 +0330 Subject: [PATCH 09/27] edit text for sms --- apps/users/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/users/models.py b/apps/users/models.py index 2eb6c67..dac8845 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -244,7 +244,7 @@ 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}" From 014c674961eb5a397e19048f6c9bfa18a5c4c1ad Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Sun, 22 Mar 2026 17:10:59 +0330 Subject: [PATCH 10/27] store headers in access token detail --- apps/gooyal_oauth2/validators.py | 167 ++++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 2 deletions(-) diff --git a/apps/gooyal_oauth2/validators.py b/apps/gooyal_oauth2/validators.py index 797cddf..4a1f0d7 100755 --- a/apps/gooyal_oauth2/validators.py +++ b/apps/gooyal_oauth2/validators.py @@ -1,22 +1,35 @@ 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 .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 +90,153 @@ 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 = deepcopy(dict(request.headers)) + for header in dict(request.headers): + if type(headers[header]) not in [str, bool, int, float, tuple, list]: + print(f'unserializable header: {header} -> {headers[header]}') + headers.pop(header) + + 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}, + ) + + +Session \ No newline at end of file From 25c53f3a67df93180076cdcb9737b5390927c3c0 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Sun, 22 Mar 2026 17:28:49 +0330 Subject: [PATCH 11/27] store headers in access token detail --- apps/gooyal_oauth2/validators.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/gooyal_oauth2/validators.py b/apps/gooyal_oauth2/validators.py index 4a1f0d7..bb83c13 100755 --- a/apps/gooyal_oauth2/validators.py +++ b/apps/gooyal_oauth2/validators.py @@ -221,11 +221,11 @@ class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223 id_token = token.get("id_token", None) if id_token: id_token = self._load_id_token(id_token) - headers = deepcopy(dict(request.headers)) - for header in dict(request.headers): - if type(headers[header]) not in [str, bool, int, float, tuple, list]: - print(f'unserializable header: {header} -> {headers[header]}') - headers.pop(header) + 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, From e1d0339759a623397a0d0f80569ee880308072a1 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Mon, 6 Apr 2026 13:23:14 +0330 Subject: [PATCH 12/27] limit tokens --- apps/core/decorators.py | 31 +++++ apps/core/migrations/0002_config.py | 30 +++++ apps/core/models.py | 80 ++++++++++++- apps/core/serializers.py | 9 ++ apps/core/urls.py | 10 +- apps/core/views.py | 49 +++++++- apps/gooyal_oauth2/admin.py | 9 +- apps/gooyal_oauth2/decorators.py | 2 + ...ce_remove_application_resource_and_more.py | 34 ++++++ apps/gooyal_oauth2/models.py | 40 +------ apps/gooyal_oauth2/tests.py | 111 ++++++++++++++++++ apps/gooyal_oauth2/validators.py | 23 +++- .../migrations/0008_alter_user_options.py | 17 +++ utils/exceptions.py | 51 ++++++-- 14 files changed, 436 insertions(+), 60 deletions(-) create mode 100644 apps/core/decorators.py create mode 100644 apps/core/migrations/0002_config.py create mode 100644 apps/core/serializers.py create mode 100644 apps/gooyal_oauth2/decorators.py create mode 100644 apps/gooyal_oauth2/migrations/0006_remove_scope_resource_remove_application_resource_and_more.py create mode 100644 apps/gooyal_oauth2/tests.py create mode 100644 apps/users/migrations/0008_alter_user_options.py diff --git a/apps/core/decorators.py b/apps/core/decorators.py new file mode 100644 index 0000000..3c547bc --- /dev/null +++ b/apps/core/decorators.py @@ -0,0 +1,31 @@ +from functools import wraps + +from django.utils import timezone + +from apps.core.models import Config, ConfigValueChoices +from utils.exceptions import ServiceUnavailable + + +def service_availability(key:str): + def with_params(view_func): + @wraps(view_func) + def wrapper(self, request, *args, **kwargs): + print(key) + print(f"Executing {view_func.__name__} action") + + service_is_available = Config.get_value_of(f"SERVICE_IS_AVAILABLE_{key.upper()}", "True", ConfigValueChoices.BOOLEAN) + config = Config.objects.get(key=f"SERVICE_IS_AVAILABLE_{key.upper()}") + if not service_is_available: + raise ServiceUnavailable( + detail={ + # "code": 'service_unavailable_at_now', + # "timestamp": timezone.now().isoformat(), + "message": config.description or "این سرویس در حال حاضر در دسترس نیست" + }, + ) + + return view_func(self, request, *args, **kwargs) + + return wrapper + + return with_params diff --git a/apps/core/migrations/0002_config.py b/apps/core/migrations/0002_config.py new file mode 100644 index 0000000..5ffcf17 --- /dev/null +++ b/apps/core/migrations/0002_config.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.2 on 2026-04-06 05:59 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Config', + fields=[ + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True)), + ('key', models.CharField(db_index=True, max_length=255, unique=True, verbose_name='key')), + ('value', models.TextField(blank=True, null=True, verbose_name='value')), + ('value_type', models.CharField(choices=[('INT', 'Integer'), ('FLOAT', 'Float'), ('BOOLEAN', 'Boolean'), ('STRING', 'String'), ('DATE', 'Date'), ('DATETIME', 'DateTime'), ('JSON', 'JSON')], max_length=64)), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('comment', models.TextField(blank=True, null=True, verbose_name='comment')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/core/models.py b/apps/core/models.py index ce61cb6..f4ade93 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -1,17 +1,21 @@ -from django.db import models +import logging + from django.db.models import TextChoices -from django.utils.translation import gettext_lazy as _ 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 -from utils.clients.sms.payam_sms.client import PayamSMSClient -from utils.clients.sms.mobin_sms.client import MobinSMSClient -from utils.clients.sms.fake.client import FakeClient -import logging 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): @@ -19,6 +23,7 @@ class SMSClienChoises(TextChoices): 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) @@ -36,3 +41,66 @@ class SMSPolicy(BaseModel): return PayamSMSClient() else: return FakeClient() + + +class ConfigValueChoices(models.TextChoices): + INT = 'INT', _('Integer') + FLOAT = 'FLOAT', _('Float') + BOOLEAN = 'BOOLEAN', _('Boolean') + STRING = 'STRING', _('String') + DATE = 'DATE', _('Date') + DATETIME = 'DATETIME', _('DateTime') + JSON = 'JSON', _('JSON') + + +class Config(BaseModel): + key = models.CharField(_('key'), max_length=255, unique=True, db_index=True) + value = models.TextField(_('value'), null=True, blank=True) + value_type = models.CharField(choices=ConfigValueChoices.choices, max_length=64) + description = models.TextField(_('description'), null=True, blank=True) + comment = models.TextField(_('comment'), null=True, blank=True) + + def get_value(self): + return Config.get_value_of(self.key) + + @staticmethod + def type_cast(value, value_type): + + try: + if value_type == ConfigValueChoices.STRING.value: + value = str(value) + + elif value_type == ConfigValueChoices.BOOLEAN.value: + value = value.lower() == 'true' + + elif value_type == ConfigValueChoices.DATE.value: + value = datetime.fromisoformat(value) + + elif value_type == ConfigValueChoices.DATETIME.value: + value = datetime.fromisoformat(value) + + elif value_type == ConfigValueChoices.JSON.value: + value = json.loads(value) + + elif value_type == ConfigValueChoices.INT.value: + value = int(value) + + elif value_type == ConfigValueChoices.FLOAT.value: + value = float(value) + + except Exception as e: + value = None + + return value + + @staticmethod + def get_value_of(key, default=None, casting_type=None): + # TODO: use cache + config, created = Config.objects.get_or_create(key=key, defaults={'value': default}) + + if config.value is None: + return default + + value = Config.type_cast(config.value, casting_type or config.value_type) + + return value diff --git a/apps/core/serializers.py b/apps/core/serializers.py new file mode 100644 index 0000000..16a58a1 --- /dev/null +++ b/apps/core/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from .models import Config + + +class ConfigSerializer(serializers.ModelSerializer): + class Meta: + model = Config + fields = ['key', 'value', 'value_type'] diff --git a/apps/core/urls.py b/apps/core/urls.py index cbf7a57..7aec53e 100644 --- a/apps/core/urls.py +++ b/apps/core/urls.py @@ -1,9 +1,17 @@ from django.urls import path, include -from .views import HomeView, TestIpView +from rest_framework.routers import DefaultRouter + +from .views import HomeView, TestIpView, HealthcheckView, StatusView app_name = "core" +router = DefaultRouter() + +# router.register('api/config', ConfigViewSet, basename='configs') + urlpatterns = [ path('', HomeView.as_view(), name='home'), path('test-ip', TestIpView.as_view(), name='test-ip'), + path('status', StatusView.as_view(), name='status'), + path('healthcheck', HealthcheckView.as_view(), name='healthcheck'), ] diff --git a/apps/core/views.py b/apps/core/views.py index de3b9dd..66a1bc4 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -2,13 +2,30 @@ 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' @@ -33,3 +50,33 @@ class TestIpView(TemplateView): 'Priority': 'u=0, i'} return super(TestIpView, self).get(request, *args, **kwargs) + + +class ConfigViewSet(mixins.ListModelMixin, GenericViewSet): + queryset = Config.objects.all() + serializer_class = ConfigSerializer + permission_classes = [AllowAny] + + +class StatusView(APIView): + permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] + required_alternate_scopes = { + "GET": [["accounts.status:get"]], + } + + @service_availability('status') + def get(self, request, format=None): + + data = { + "status": "ok", + } + return Response(data) + + +class HealthcheckView(APIView): + permission_classes = [AllowAny] + def get(self, request, format=None): + data = { + "is_healthy": True + } + return Response(data) diff --git a/apps/gooyal_oauth2/admin.py b/apps/gooyal_oauth2/admin.py index 60d341d..2624558 100755 --- a/apps/gooyal_oauth2/admin.py +++ b/apps/gooyal_oauth2/admin.py @@ -7,7 +7,7 @@ from django.contrib.admin.sites import NotRegistered from oauth2_provider.admin import ApplicationAdmin -from .models import Application, Resource, Scope +from .models import Application, Scope from .forms import ApplicationForm @@ -23,14 +23,9 @@ class ApplicationAdmin(ApplicationAdmin): form = ApplicationForm -@admin.register(Resource) -class ResourceAdmin(admin.ModelAdmin): - list_display = ("name", "user", "expires") - - @admin.register(Scope) class ScopeAdmin(admin.ModelAdmin): - list_display = ('name', "resource", 'description', 'is_default') + list_display = ('name', 'description', 'is_default') # admin.site.register(RestrictedApplication, RestrictedApplicationAdmin) diff --git a/apps/gooyal_oauth2/decorators.py b/apps/gooyal_oauth2/decorators.py new file mode 100644 index 0000000..717c3a8 --- /dev/null +++ b/apps/gooyal_oauth2/decorators.py @@ -0,0 +1,2 @@ +def session_limit_count(count=0): + pass diff --git a/apps/gooyal_oauth2/migrations/0006_remove_scope_resource_remove_application_resource_and_more.py b/apps/gooyal_oauth2/migrations/0006_remove_scope_resource_remove_application_resource_and_more.py new file mode 100644 index 0000000..691e9b2 --- /dev/null +++ b/apps/gooyal_oauth2/migrations/0006_remove_scope_resource_remove_application_resource_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.2 on 2026-04-05 13:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gooyal_oauth2', '0005_alter_refreshtoken_unique_together_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='scope', + name='resource', + ), + migrations.RemoveField( + model_name='application', + name='resource', + ), + migrations.AddField( + model_name='application', + name='max_allowed_session', + field=models.PositiveIntegerField(default=1), + ), + migrations.AlterField( + model_name='application', + name='authorization_grant_type', + field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('urn:ietf:params:oauth:grant-type:device_code', 'Device Code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=44), + ), + migrations.DeleteModel( + name='Resource', + ), + ] diff --git a/apps/gooyal_oauth2/models.py b/apps/gooyal_oauth2/models.py index cafa003..61c1c5a 100644 --- a/apps/gooyal_oauth2/models.py +++ b/apps/gooyal_oauth2/models.py @@ -23,19 +23,6 @@ from .settings import oauth2_settings logger = logging.getLogger(__name__) -class Resource(BaseModel): - name = models.CharField(max_length=255) - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, - related_name="resources" - ) - expires = models.DateTimeField() - - def __str__(self): - return self.name - - class Application(AbstractApplication, BaseModel): """ Application model for use with Django OAuth Toolkit that allows the scopes @@ -49,15 +36,10 @@ class Application(AbstractApplication, BaseModel): on_delete=models.PROTECT ) allowed_scope = models.TextField(blank=True) - resource = models.OneToOneField( - Resource, - models.PROTECT, - blank=True, null=True, - help_text='The resource of application.', - related_name='application' - ) avatar = models.ImageField(upload_to='avatars', null=True, blank=True) + max_allowed_session = models.PositiveIntegerField(default=1) + @property def allowed_scopes(self): @@ -96,13 +78,6 @@ class Scope(BaseModel): help_text='The application to which the scope belongs.', related_name='scopes' ) - resource = models.ForeignKey( - Resource, - models.PROTECT, - blank=True, null=True, - help_text='The resource of scope.', - related_name='scopes' - ) #: The name of the scope name = models.CharField( max_length=255, @@ -122,19 +97,10 @@ class Scope(BaseModel): @property def final_name(self): - args = [] - if self.resource: - args.append(self.resource.name) - - args.append(self.name) - return '.'.join(args) + return self.name @property def final_description(self): - resource_name = self.resource and self.resource.name - - if resource_name: - return f"{resource_name} -> {self.description}" return self.description @classmethod diff --git a/apps/gooyal_oauth2/tests.py b/apps/gooyal_oauth2/tests.py new file mode 100644 index 0000000..8336d53 --- /dev/null +++ b/apps/gooyal_oauth2/tests.py @@ -0,0 +1,111 @@ +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 + +from apps.core.models import Config +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(TestCase): + 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' + + visitor_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb47005a') + + expire_datetime = timezone.now() + timedelta(seconds=3600) + expire_datetime.isoformat() + + def setUp(self): + self.notifications_push_user_success_patcher = patch('apps.users.models.User.notify', + mock_notifications_push_user_success) + + self.notifications_push_user_success_patcher.start() + + user, _ = User.objects.get_or_create(pk=self.user_uuid) + self.user = user + + self.application, _created = Application.objects.get_or_create( + client_id=self.client_id, + uuid=self.application_uuid, + user_id=self.user_uuid, + max_allowed_session=1 + ) + + # 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_2) + self.assertEqual(response.status_code, 503) + + response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_1) + self.assertEqual(response.status_code, 200) + + def test_1(self): + self.assertTrue(True) + diff --git a/apps/gooyal_oauth2/validators.py b/apps/gooyal_oauth2/validators.py index bb83c13..5bdb8a5 100755 --- a/apps/gooyal_oauth2/validators.py +++ b/apps/gooyal_oauth2/validators.py @@ -16,6 +16,7 @@ from oauth2_provider.models import get_access_token_model, get_application_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 @@ -238,5 +239,25 @@ class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223 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 -Session \ No newline at end of file + 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') diff --git a/apps/users/migrations/0008_alter_user_options.py b/apps/users/migrations/0008_alter_user_options.py new file mode 100644 index 0000000..f62e225 --- /dev/null +++ b/apps/users/migrations/0008_alter_user_options.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.2 on 2026-03-16 12:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0007_alter_user_otp'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={'ordering': ['-date_joined'], 'verbose_name': 'user', 'verbose_name_plural': 'users'}, + ), + ] diff --git a/utils/exceptions.py b/utils/exceptions.py index 756d9a4..d85b168 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -1,25 +1,52 @@ import logging +from django.conf import settings +from django.utils import timezone from rest_framework.exceptions import APIException from rest_framework.views import exception_handler as drf_exception_handler logger = logging.getLogger(__name__) def exception_handler(exc, context): + logger.exception(exc) # پاسخ پیش‌فرض DRF را دریافت می‌کنیم response = drf_exception_handler(exc, context) if response is not None: - # ساختار دلخواه خود را تعریف می‌کنیم response_data = { 'success': False, 'status_code': response.status_code, - 'message': 'An error occurred', - 'details': {} + 'status_message': str(exc.default_detail), + 'details': exc.detail, } if isinstance(exc, APIException): - response_data['message'] = exc.detail if isinstance(exc.detail, str) else "Validation error" + 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 + except: + error = '' + + # if isinstance(exc.detail, str): + # message = exc.detail + # elif isinstance(exc.detail, dict): + # if 'message' in exc.detail or 'string' in exc.detail: + # message = exc.detail.get('message') or exc.detail.get('string') + # else: + # message = error + # + # else: + # message = error + # + # response_data['message'] = message response_data['details'] = exc.detail if isinstance(exc.detail, dict) else {} + response_data['details']['error'] = error + response_data['details']['timestamp'] = timezone.now().isoformat() response.data = response_data @@ -43,14 +70,24 @@ class ErrorMiddleware: { "success": False, "status_code": 500, - "message": "Internal server error", - "details": str(exception) # فقط در حالت توسعه! در تولید بهتر است لاگ شود. + "status_message": "Internal server error", + "details": str(exception) if settings.DEBUG else None, }, status=500 ) +from rest_framework.exceptions import APIException +from rest_framework import status +from django.utils.translation import gettext_lazy as _ + class UnprocessableEntity(APIException): status_code = 422 default_detail = 'The request was well-formed but cannot be processed due to semantic errors.' - default_code = 'unprocessable_entity' \ No newline at end of file + default_code = 'unprocessable_entity' + +class ServiceUnavailable(APIException): + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_ = _('SERVICE_UNAVAILABLE') + default_code = 'service_unavailable' + default_detail = 'Service Unavailable' From c1c55c45a69fb4f45e11859950886fec6b2d26a0 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Mon, 6 Apr 2026 14:34:54 +0330 Subject: [PATCH 13/27] bug fix --- apps/gooyal_oauth2/scopes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/gooyal_oauth2/scopes.py b/apps/gooyal_oauth2/scopes.py index 64d979f..3cce1d2 100644 --- a/apps/gooyal_oauth2/scopes.py +++ b/apps/gooyal_oauth2/scopes.py @@ -18,7 +18,7 @@ class Scopes(BaseScopes): def get_queryset(self, application=None): queryset = Scope.objects.all() if application: - queryset = queryset.filter(name__in=application.allowed_scopes).order_by('resource__uuid') + queryset = queryset.filter(name__in=application.allowed_scopes) return queryset From be4df13c7817b23ce1a08c39b63ca0d5f61dc9e4 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Tue, 7 Apr 2026 09:47:00 +0330 Subject: [PATCH 14/27] revoke token test --- apps/core/admin.py | 7 +- apps/gooyal_oauth2/tests.py | 107 ++++++++++++++++-- apps/gooyal_oauth2/urls.py | 4 +- apps/gooyal_oauth2/validators.py | 6 + .../views/{introspect.py => oauth_views.py} | 14 +++ 5 files changed, 124 insertions(+), 14 deletions(-) rename apps/gooyal_oauth2/views/{introspect.py => oauth_views.py} (91%) diff --git a/apps/core/admin.py b/apps/core/admin.py index 2b7593b..69b3eff 100644 --- a/apps/core/admin.py +++ b/apps/core/admin.py @@ -1,5 +1,6 @@ from django.contrib import admin -from .models import SMSPolicy +from .models import SMSPolicy, Config + class SMSPolicyAdmin(admin.ModelAdmin): def has_add_permission(self, request): @@ -9,4 +10,8 @@ class SMSPolicyAdmin(admin.ModelAdmin): return False +class ConfigAdmin(admin.ModelAdmin): + list_display = ['key', 'value', 'value_type', 'get_value', 'description', 'comment'] + +admin.site.register(Config, ConfigAdmin) admin.site.register(SMSPolicy, SMSPolicyAdmin) diff --git a/apps/gooyal_oauth2/tests.py b/apps/gooyal_oauth2/tests.py index 8336d53..df017f3 100644 --- a/apps/gooyal_oauth2/tests.py +++ b/apps/gooyal_oauth2/tests.py @@ -1,3 +1,4 @@ +import base64 import json import uuid from datetime import timedelta @@ -8,15 +9,18 @@ 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 +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(): @@ -26,32 +30,43 @@ def mock_notifications_push_user_success(user_uuid, title, message, priority=5, return data -class GooyalOAuth2Tests(TestCase): +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) - user, _ = User.objects.get_or_create(pk=self.user_uuid) - self.user = user + scope = Scope.objects.create(name='accounts.status:get', description='accounts.status:get') + + self.application = Application.objects.create( - self.application, _created = Application.objects.get_or_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 + max_allowed_session=1, + allowed_scope='accounts.status:get', ) # self.sys_date_patcher = patch('simata_safte.models.get_sys_date', mock_get_sys_date) @@ -90,6 +105,7 @@ class GooyalOAuth2Tests(TestCase): **{ "token": self.access_token_2, "user": self.user, + # "client_id": self.client_id, # "client_owner": owner, "application_id": self.application_uuid, @@ -100,12 +116,81 @@ class GooyalOAuth2Tests(TestCase): auth_2 = self._create_authorization_header(access_token_2.token) - response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_2) - self.assertEqual(response.status_code, 503) - response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_1) self.assertEqual(response.status_code, 200) - def test_1(self): - self.assertTrue(True) + 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', + "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'] + + print(AccessToken.objects.count()) + + data = { + "token": access_token, + } + + result = self.client.post(reverse("gooyal_oauth2:revoke-token"), data=data) + print(result.content) + print(result.status_code) + + print(AccessToken.objects.count()) + + diff --git a/apps/gooyal_oauth2/urls.py b/apps/gooyal_oauth2/urls.py index 62e9055..f0668e1 100644 --- a/apps/gooyal_oauth2/urls.py +++ b/apps/gooyal_oauth2/urls.py @@ -3,7 +3,7 @@ from django.urls import re_path, path from oauth2_provider import views from rest_framework import routers -from .views.introspect import IntrospectTokenView, IntrospectApplicationView, TokenView +from .views.oauth_views import IntrospectTokenView, IntrospectApplicationView, TokenView, RevokeTokenView from .views import apis as api_views from .views import pages app_name = "gooyal_oauth2" @@ -14,7 +14,7 @@ app_name = "gooyal_oauth2" base_urlpatterns = [ re_path(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"), re_path(r"^token/$", TokenView.as_view(), name="token"), - re_path(r"^revoke_token/$", views.RevokeTokenView.as_view(), name="revoke-token"), + re_path(r"^revoke_token/$", RevokeTokenView.as_view(), name="revoke-token"), re_path(r"^introspect/$", IntrospectTokenView.as_view(), name="introspect"), re_path(r"^introspect_application/$", IntrospectApplicationView.as_view(), name="introspect-application"), ] diff --git a/apps/gooyal_oauth2/validators.py b/apps/gooyal_oauth2/validators.py index 5bdb8a5..dc75f1c 100755 --- a/apps/gooyal_oauth2/validators.py +++ b/apps/gooyal_oauth2/validators.py @@ -261,3 +261,9 @@ class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223 return result else: raise ServiceUnavailable(code='max_allowed_session_reached') + + else: + return result + + def revoke_token(self, token, token_type_hint, request, *args, **kwargs): + return super().revoke_token(token, token_type_hint, request, *args, **kwargs) \ No newline at end of file diff --git a/apps/gooyal_oauth2/views/introspect.py b/apps/gooyal_oauth2/views/oauth_views.py similarity index 91% rename from apps/gooyal_oauth2/views/introspect.py rename to apps/gooyal_oauth2/views/oauth_views.py index fca06b1..8c48607 100644 --- a/apps/gooyal_oauth2/views/introspect.py +++ b/apps/gooyal_oauth2/views/oauth_views.py @@ -167,3 +167,17 @@ class TokenView(OAuthLibMixin, View): response[k] = v return response +@method_decorator(csrf_exempt, name="dispatch") +@method_decorator(login_not_required, name="dispatch") +class RevokeTokenView(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 From 9c81505a4626ee2085a83a115b4ce3b9e51046a0 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Tue, 7 Apr 2026 10:07:54 +0330 Subject: [PATCH 15/27] list session api --- apps/users/serializers.py | 9 +++++++++ apps/users/urls.py | 3 ++- apps/users/views.py | 17 ++++++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/users/serializers.py b/apps/users/serializers.py index ff7b3e0..de3e137 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -1,4 +1,5 @@ from django.core.validators import RegexValidator +from oauth2_provider.models import get_access_token_model from rest_framework import serializers from apps.users.models import User @@ -133,3 +134,11 @@ 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): + class Meta: + model = AccessToken + fields = ('uuid', 'token', 'created', "detail") + read_only_fields = ['uuid', 'token', 'created', "detail"] diff --git a/apps/users/urls.py b/apps/users/urls.py index c8f4d6e..789744e 100644 --- a/apps/users/urls.py +++ b/apps/users/urls.py @@ -2,7 +2,7 @@ from django.urls import path from django.contrib.auth.views import LogoutView from .views import UserListView, UserPublicRetrieveView, AccountView, RequestOTPView, ChangePasswordView, \ OTPLoginView, ProfileDetailView, ProfileUpdateView, RequestOTTView, UserCurrentAvatarUrlView, UserInquiryView, \ - UserDetailedRetrieveView + UserDetailedRetrieveView, UserSessionListView app_name = "users" @@ -13,6 +13,7 @@ urlpatterns = [ path('account/update/', ProfileUpdateView.as_view(), name='account_update'), path('api/account/', AccountView.as_view(), name='account_api'), path('api/users/', UserListView.as_view(), name='user_list_api'), + path('api/sessions/', UserSessionListView.as_view(), name='user_sessions_api'), path('api/users//', UserPublicRetrieveView.as_view(), name='user_public_retrieve_api'), path('api/users//avatar', UserCurrentAvatarUrlView.as_view(), name='user_avatar_api'), path('api/users//details', UserDetailedRetrieveView.as_view(), name='user_detailed_retrieve_api'), diff --git a/apps/users/views.py b/apps/users/views.py index a5fca61..c2b6e62 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -20,7 +20,7 @@ from apps.users.forms import OTPAuthenticationForm, ProfileUpdateForm from apps.users.models import User from apps.users.provinces_and_cities import State from apps.users.serializers import PublicUserSerializer, AccountSerializer, RequestOTPSerializer, RequestOTTSerializer, \ - ChangePasswordSerializer, UserInquirySerializer + ChangePasswordSerializer, UserInquirySerializer, SessionSerializer from utils.throttles import RequestOTPDayRateThrottle, RequestOTPMinRateThrottle UserModel = get_user_model() @@ -180,3 +180,18 @@ 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,) + + 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() + From 5f0684d354ca0ed752a3657ef21b9cdc8138f55c Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Tue, 7 Apr 2026 14:47:22 +0330 Subject: [PATCH 16/27] list session api --- apps/gooyal_oauth2/tests.py | 29 +++-- apps/gooyal_oauth2/urls.py | 4 +- apps/gooyal_oauth2/validators.py | 22 +++- apps/gooyal_oauth2/views/oauth_views.py | 2 +- apps/users/models.py | 6 +- apps/users/serializers.py | 18 ++- apps/users/tests.py | 155 +++++++++++++++++++++++- 7 files changed, 220 insertions(+), 16 deletions(-) diff --git a/apps/gooyal_oauth2/tests.py b/apps/gooyal_oauth2/tests.py index df017f3..4ff6f17 100644 --- a/apps/gooyal_oauth2/tests.py +++ b/apps/gooyal_oauth2/tests.py @@ -180,17 +180,32 @@ class GooyalOAuth2Tests(APITestCase): result = self.client.post(reverse("gooyal_oauth2:token"), data=data) access_token = result.json()['access_token'] - print(AccessToken.objects.count()) - - data = { + revoke_data = { "token": access_token, } - result = self.client.post(reverse("gooyal_oauth2:revoke-token"), data=data) - print(result.content) - print(result.status_code) + 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) + + + - print(AccessToken.objects.count()) diff --git a/apps/gooyal_oauth2/urls.py b/apps/gooyal_oauth2/urls.py index f0668e1..78f2c19 100644 --- a/apps/gooyal_oauth2/urls.py +++ b/apps/gooyal_oauth2/urls.py @@ -3,7 +3,7 @@ from django.urls import re_path, path from oauth2_provider import views from rest_framework import routers -from .views.oauth_views import IntrospectTokenView, IntrospectApplicationView, TokenView, RevokeTokenView +from .views.oauth_views import IntrospectTokenView, IntrospectApplicationView, TokenView, GooyalRevokeTokenView from .views import apis as api_views from .views import pages app_name = "gooyal_oauth2" @@ -14,7 +14,7 @@ app_name = "gooyal_oauth2" base_urlpatterns = [ re_path(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"), re_path(r"^token/$", TokenView.as_view(), name="token"), - re_path(r"^revoke_token/$", RevokeTokenView.as_view(), name="revoke-token"), + re_path(r"^revoke_token/$", views.RevokeTokenView.as_view(), name="revoke-token"), re_path(r"^introspect/$", IntrospectTokenView.as_view(), name="introspect"), re_path(r"^introspect_application/$", IntrospectApplicationView.as_view(), name="introspect-application"), ] diff --git a/apps/gooyal_oauth2/validators.py b/apps/gooyal_oauth2/validators.py index dc75f1c..6d47be1 100755 --- a/apps/gooyal_oauth2/validators.py +++ b/apps/gooyal_oauth2/validators.py @@ -266,4 +266,24 @@ class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223 return result def revoke_token(self, token, token_type_hint, request, *args, **kwargs): - return super().revoke_token(token, token_type_hint, request, *args, **kwargs) \ No newline at end of file + """ + Revoke an access or refresh token. + + :param token: The token string. + :param token_type_hint: access_token or refresh_token. + :param request: The HTTP Request (oauthlib.common.Request) + """ + if token_type_hint not in ["access_token", "refresh_token"]: + token_type_hint = None + + token_types = { + "access_token": AccessToken, + "refresh_token": RefreshToken, + } + + token_type = token_types.get(token_type_hint, AccessToken) + + try: + token_type.objects.get(pk=token).revoke() + except: + token_type.objects.get(token=token).revoke() diff --git a/apps/gooyal_oauth2/views/oauth_views.py b/apps/gooyal_oauth2/views/oauth_views.py index 8c48607..561d1bf 100644 --- a/apps/gooyal_oauth2/views/oauth_views.py +++ b/apps/gooyal_oauth2/views/oauth_views.py @@ -169,7 +169,7 @@ class TokenView(OAuthLibMixin, View): @method_decorator(csrf_exempt, name="dispatch") @method_decorator(login_not_required, name="dispatch") -class RevokeTokenView(OAuthLibMixin, View): +class GooyalRevokeTokenView(OAuthLibMixin, View): """ Implements an endpoint to revoke access or refresh tokens """ diff --git a/apps/users/models.py b/apps/users/models.py index dac8845..0ab60af 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -195,6 +195,8 @@ class User(AbstractUser): if result: self.otp = None + self.otp_expire = None + if not self.date_joined: self.date_joined = timezone.now() else: @@ -202,7 +204,9 @@ class User(AbstractUser): self.save() else: result = False - + self.otp = None + self.otp_expire = None + self.save() return result diff --git a/apps/users/serializers.py b/apps/users/serializers.py index de3e137..e4bf1cc 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -138,7 +138,21 @@ class ChangePasswordSerializer(serializers.Serializer): 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', 'token', 'created', "detail") - read_only_fields = ['uuid', 'token', 'created', "detail"] + fields = ('uuid', 'created', "detail", "is_current") + read_only_fields = ['uuid', 'created', "detail", "is_current" ] diff --git a/apps/users/tests.py b/apps/users/tests.py index 7ce503c..8fa80f6 100644 --- a/apps/users/tests.py +++ b/apps/users/tests.py @@ -1,3 +1,154 @@ -from django.test import TestCase +import base64 +import json +import uuid +from datetime import timedelta +from unicodedata import category +from unittest.mock import patch + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from oauth2_provider.models import get_access_token_model, get_application_model +from rest_framework.test import APIClient, APITestCase + + +from apps.core.models import Config +from apps.gooyal_oauth2.models import Scope +from apps.users.models import User + +AccessToken = get_access_token_model() +Application = get_application_model() + + +class UserTests(APITestCase): + user_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb47005f') + access_token_1 = 'au4naVsdKCbKNOhnElPyXcrwSnqqFbm' + access_token_2 = 'vu4naVsdKCbKNOhnElPyXcrwSnqqFbm' + application_uuid = uuid.UUID('a14e8b86-8f4a-44d9-b29d-badceb47005f') + client_id = '4INGOCMoulE0fNY1SQlTbPtsWqqxGj2DdqjADq6u' + client_secret = '4INGOCMoulE0fNY1SQlTbPtsWqqxGj2DdqjADq6u' + + visitor_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb47005a') + + expire_datetime = timezone.now() + timedelta(seconds=3600) + expire_datetime.isoformat() + client = APIClient() + + def setUp(self): + from django.conf import settings + settings.SMS_SEND = False + self.user_phone_number = '+989100000000' + self.user = User.objects.create(pk=self.user_uuid, phone_number=self.user_phone_number) + + Scope.objects.create(name='accounts.status:get', description='accounts.status:get') + Scope.objects.create(name='accounts.account:retrieve', description='accounts.account:retrieve') + + self.application = Application.objects.create( + client_id=self.client_id, + client_secret=self.client_secret, + authorization_grant_type='password', + hash_client_secret=False, + uuid=self.application_uuid, + user_id=self.user_uuid, + max_allowed_session=1, + allowed_scope='accounts.status:get accounts.account:retrieve', + ) + + # self.sys_date_patcher = patch('simata_safte.models.get_sys_date', mock_get_sys_date) + # self.set_id_patcher = patch('simata_safte.models.set_id', mock_set_id) + # self.sign_pdf_patcher = patch('simata_safte.models.sign_pdf', mock_sign_pdf) + # self.check_pdf_signature_patcher = patch('simata_safte.models.check_pdf_signature', mock_check_pdf_signature) + + def tearDown(self): + super().tearDown() + + + def _create_authorization_header(self, token): + return "Bearer {0}".format(token) + + def test_authentication_allow(self): + access_token_1 = AccessToken.objects.create( + **{ + "token": self.access_token_1, + "user": self.user, + # "client_id": self.client_id, + # "client_owner": owner, + "application_id": self.application_uuid, + "scope": 'accounts.status:get accounts.account:retrieve', + "expires": self.expire_datetime.isoformat(), + }, + ) + + auth_1 = self._create_authorization_header(access_token_1.token) + + response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_1) + + self.assertContains(response, 'status') + + access_token_2 = AccessToken.objects.create( + **{ + "token": self.access_token_2, + "user": self.user, + + # "client_id": self.client_id, + # "client_owner": owner, + "application_id": self.application_uuid, + "scope": 'accounts.status:get', + "expires": self.expire_datetime.isoformat(), + }, + ) + + auth_2 = self._create_authorization_header(access_token_2.token) + + response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_1) + self.assertEqual(response.status_code, 200) + + response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_2) + self.assertEqual(response.status_code, 503) + + self.application.max_allowed_session = 0 + self.application.save() + self.application.refresh_from_db() + + response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_2) + self.assertEqual(response.status_code, 200) + + def basic_auth_string(self, username, password): + """ساخت Basic Auth string""" + import base64 + user_pass = f"{username}:{password}" + basic_credentials = base64.b64encode(user_pass.encode('utf-8')).decode('utf-8') + + return basic_credentials + + def login(self): + self.user.set_otp() + + data = { + "grant_type": "password", + "username": self.user_phone_number, + "password": '77501', + "scope": 'accounts.status:get accounts.account:retrieve', + "auth_fields": 'phone_number:otp' + } + self.client.credentials( + HTTP_AUTHORIZATION='Basic ' + self.basic_auth_string(self.client_id, self.client_secret) + ) + + result = self.client.post(reverse("gooyal_oauth2:token"), data=data) + access_token = result.json()['access_token'] + self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + access_token) + + self.assertEqual(result.status_code, 200) + return result + + + def test_getSessions_allOK_success(self): + self.login() + response = self.client.get(reverse("users:user_sessions_api")) + self.assertEqual(response.json()['results'][0]['is_current'] , True) + + + + -# Create your tests here. From 9f284fbfff4724a1f9f3bc008a2d230801c6524d Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Wed, 8 Apr 2026 10:49:03 +0330 Subject: [PATCH 17/27] throttle --- apps/gooyal_oauth2/throttling.py | 48 ++++++++++++++++++++++++++++++ apps/gooyal_oauth2/validators.py | 50 +++++++++++++++----------------- 2 files changed, 72 insertions(+), 26 deletions(-) create mode 100644 apps/gooyal_oauth2/throttling.py diff --git a/apps/gooyal_oauth2/throttling.py b/apps/gooyal_oauth2/throttling.py new file mode 100644 index 0000000..6f5414a --- /dev/null +++ b/apps/gooyal_oauth2/throttling.py @@ -0,0 +1,48 @@ +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: + 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: + False + # raise ServiceUnavailable(code='max_allowed_session_reached') + + else: + return True + + def wait(self): + """ + Optionally, return a recommended number of seconds to wait before + the next request. + """ + return None + + +from rest_framework.exceptions import Throttled \ No newline at end of file diff --git a/apps/gooyal_oauth2/validators.py b/apps/gooyal_oauth2/validators.py index 6d47be1..0928ccc 100755 --- a/apps/gooyal_oauth2/validators.py +++ b/apps/gooyal_oauth2/validators.py @@ -23,7 +23,6 @@ from django.db import router, transaction log = logging.getLogger("oauth2_provider") - UserModel = get_user_model() Application = get_application_model() AccessToken = get_access_token_model() @@ -32,7 +31,6 @@ Grant = get_grant_model() RefreshToken = get_refresh_token_model() - class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223 def validate_user(self, username, password, client, request, *args, **kwargs): auth_fields = getattr(request, 'auth_fields', 'username:password').split(':') @@ -240,30 +238,30 @@ class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223 ) # 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 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): """ From 492d06b5bae00fc8b687ac8cfd67247f2ebf3cfc Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Wed, 8 Apr 2026 10:49:14 +0330 Subject: [PATCH 18/27] throttle --- accounts/settings.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/accounts/settings.py b/accounts/settings.py index 22ba980..5b50729 100644 --- a/accounts/settings.py +++ b/accounts/settings.py @@ -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': '200/day', @@ -225,7 +230,6 @@ LANGUAGES = [ ('tr', _('Turkish')), ] - LOCALE_PATHS = [ BASE_DIR / 'locale', ] @@ -275,11 +279,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": { @@ -311,7 +313,8 @@ LOGGING = { 'url': f'{LOKI_BASE_PUBLIC_URL}/loki/api/v1/push', # Loki url. Defaults to localhost. Optional. # 'auth': ("user", "password"), # Basic auth to authenticate with loki. Default is None (i.e. no auth). Optional 'tags': {"app": "accounts"}, # Tags / Labels to attach to the log. Optional, but strongly encoraged to use. - 'mode': 'thread', # Push mode. Can be 'sync' or 'thread'. Sync is blocking, thread is non-blocking. Defaults to sync. Optional. + 'mode': 'thread', + # Push mode. Can be 'sync' or 'thread'. Sync is blocking, thread is non-blocking. Defaults to sync. Optional. }, 'console': { 'level': 'DEBUG', @@ -338,8 +341,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'), @@ -388,8 +393,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') @@ -408,4 +415,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='') \ No newline at end of file +MOBIN_SMS_TOKEN = config('MOBIN_SMS_TOKEN', default='') From 0365d17095575351f94dea53fa86f30f1ba1922d Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Wed, 8 Apr 2026 11:23:11 +0330 Subject: [PATCH 19/27] conflict error --- apps/core/views.py | 1 + apps/gooyal_oauth2/tests.py | 3 ++- apps/gooyal_oauth2/throttling.py | 15 +++------------ utils/exceptions.py | 9 ++++++++- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/core/views.py b/apps/core/views.py index 66a1bc4..a052e5e 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -75,6 +75,7 @@ class StatusView(APIView): class HealthcheckView(APIView): permission_classes = [AllowAny] + def get(self, request, format=None): data = { "is_healthy": True diff --git a/apps/gooyal_oauth2/tests.py b/apps/gooyal_oauth2/tests.py index 4ff6f17..f2433c8 100644 --- a/apps/gooyal_oauth2/tests.py +++ b/apps/gooyal_oauth2/tests.py @@ -9,6 +9,7 @@ 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 @@ -120,7 +121,7 @@ class GooyalOAuth2Tests(APITestCase): self.assertEqual(response.status_code, 200) response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_2) - self.assertEqual(response.status_code, 503) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) self.application.max_allowed_session = 0 self.application.save() diff --git a/apps/gooyal_oauth2/throttling.py b/apps/gooyal_oauth2/throttling.py index 6f5414a..03c5e70 100644 --- a/apps/gooyal_oauth2/throttling.py +++ b/apps/gooyal_oauth2/throttling.py @@ -1,6 +1,8 @@ from rest_framework.throttling import UserRateThrottle from apps.gooyal_oauth2.models import AccessToken +from utils.exceptions import Conflict + def get_application(request): try: @@ -31,18 +33,7 @@ class TokenLimitThrottle: if request.auth.token in active_tokens: return True else: - False - # raise ServiceUnavailable(code='max_allowed_session_reached') + raise Conflict(code='max_allowed_session_reached') else: return True - - def wait(self): - """ - Optionally, return a recommended number of seconds to wait before - the next request. - """ - return None - - -from rest_framework.exceptions import Throttled \ No newline at end of file diff --git a/utils/exceptions.py b/utils/exceptions.py index d85b168..8c9c36d 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -82,7 +82,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' @@ -91,3 +91,10 @@ class ServiceUnavailable(APIException): default_ = _('SERVICE_UNAVAILABLE') default_code = 'service_unavailable' default_detail = 'Service Unavailable' + + +class Conflict(APIException): + status_code = status.HTTP_409_CONFLICT + default_ = _('CONFLICT') + default_code = 'conflict' + default_detail = 'Conflict' From 7256578ec7f25b68ba0dfd8224c147615c018d01 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Wed, 8 Apr 2026 11:30:07 +0330 Subject: [PATCH 20/27] conflict error --- apps/gooyal_oauth2/throttling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/gooyal_oauth2/throttling.py b/apps/gooyal_oauth2/throttling.py index 03c5e70..6373213 100644 --- a/apps/gooyal_oauth2/throttling.py +++ b/apps/gooyal_oauth2/throttling.py @@ -1,7 +1,6 @@ from rest_framework.throttling import UserRateThrottle from apps.gooyal_oauth2.models import AccessToken -from utils.exceptions import Conflict def get_application(request): @@ -33,6 +32,7 @@ class TokenLimitThrottle: if request.auth.token in active_tokens: return True else: + from utils.exceptions import Conflict raise Conflict(code='max_allowed_session_reached') else: From 0e9ca49ca04692dcf8d3a2ee19068a4a33c8d336 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Sat, 11 Apr 2026 12:55:06 +0330 Subject: [PATCH 21/27] conflict error --- ...scope_resource_remove_application_resource_and_more.py | 2 +- apps/gooyal_oauth2/models.py | 2 +- apps/gooyal_oauth2/throttling.py | 8 ++++++++ apps/users/views.py | 2 ++ utils/exceptions.py | 3 +++ 5 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/gooyal_oauth2/migrations/0006_remove_scope_resource_remove_application_resource_and_more.py b/apps/gooyal_oauth2/migrations/0006_remove_scope_resource_remove_application_resource_and_more.py index 691e9b2..ee76906 100644 --- a/apps/gooyal_oauth2/migrations/0006_remove_scope_resource_remove_application_resource_and_more.py +++ b/apps/gooyal_oauth2/migrations/0006_remove_scope_resource_remove_application_resource_and_more.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='application', name='max_allowed_session', - field=models.PositiveIntegerField(default=1), + field=models.PositiveIntegerField(default=0), ), migrations.AlterField( model_name='application', diff --git a/apps/gooyal_oauth2/models.py b/apps/gooyal_oauth2/models.py index 61c1c5a..89bcab1 100644 --- a/apps/gooyal_oauth2/models.py +++ b/apps/gooyal_oauth2/models.py @@ -38,7 +38,7 @@ class Application(AbstractApplication, BaseModel): allowed_scope = models.TextField(blank=True) avatar = models.ImageField(upload_to='avatars', null=True, blank=True) - max_allowed_session = models.PositiveIntegerField(default=1) + max_allowed_session = models.PositiveIntegerField(default=0) @property diff --git a/apps/gooyal_oauth2/throttling.py b/apps/gooyal_oauth2/throttling.py index 6373213..392cabf 100644 --- a/apps/gooyal_oauth2/throttling.py +++ b/apps/gooyal_oauth2/throttling.py @@ -26,6 +26,9 @@ class TokenLimitThrottle: 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) @@ -37,3 +40,8 @@ class TokenLimitThrottle: else: return True + + return True + + def wait(self): + pass diff --git a/apps/users/views.py b/apps/users/views.py index c2b6e62..9607ec6 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -187,6 +187,8 @@ class UserSessionListView(generics.ListAPIView): serializer_class = SessionSerializer filter_backends = (DjangoFilterBackend,) + max_allowed_session = 0 + required_alternate_scopes = { "GET": [["accounts.account:retrieve"]], } diff --git a/utils/exceptions.py b/utils/exceptions.py index 8c9c36d..ca3ac1e 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -56,6 +56,7 @@ def exception_handler(exc, context): # در فایل middleware.py from django.http import JsonResponse + class ErrorMiddleware: def __init__(self, get_response): self.get_response = get_response @@ -76,6 +77,7 @@ class ErrorMiddleware: status=500 ) + from rest_framework.exceptions import APIException from rest_framework import status from django.utils.translation import gettext_lazy as _ @@ -86,6 +88,7 @@ class UnprocessableEntity(APIException): default_detail = 'The request was well-formed but cannot be processed due to semantic errors.' default_code = 'unprocessable_entity' + class ServiceUnavailable(APIException): status_code = status.HTTP_503_SERVICE_UNAVAILABLE default_ = _('SERVICE_UNAVAILABLE') From 3796a50ca8b6d806aaff681ae8b6839a443b0acb Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Sat, 11 Apr 2026 13:24:33 +0330 Subject: [PATCH 22/27] rename main directory from accounts to main --- {accounts => main}/__init__.py | 0 {accounts => main}/asgi.py | 0 {accounts => main}/broker_rpc.py | 0 {accounts => main}/celery.py | 0 {accounts => main}/settings.py | 2 +- {accounts => main}/urls.py | 0 {accounts => main}/wsgi.py | 0 7 files changed, 1 insertion(+), 1 deletion(-) rename {accounts => main}/__init__.py (100%) rename {accounts => main}/asgi.py (100%) rename {accounts => main}/broker_rpc.py (100%) rename {accounts => main}/celery.py (100%) rename {accounts => main}/settings.py (99%) rename {accounts => main}/urls.py (100%) rename {accounts => main}/wsgi.py (100%) diff --git a/accounts/__init__.py b/main/__init__.py similarity index 100% rename from accounts/__init__.py rename to main/__init__.py diff --git a/accounts/asgi.py b/main/asgi.py similarity index 100% rename from accounts/asgi.py rename to main/asgi.py diff --git a/accounts/broker_rpc.py b/main/broker_rpc.py similarity index 100% rename from accounts/broker_rpc.py rename to main/broker_rpc.py diff --git a/accounts/celery.py b/main/celery.py similarity index 100% rename from accounts/celery.py rename to main/celery.py diff --git a/accounts/settings.py b/main/settings.py similarity index 99% rename from accounts/settings.py rename to main/settings.py index 5b50729..3648b18 100644 --- a/accounts/settings.py +++ b/main/settings.py @@ -349,7 +349,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": True, + "MINIO_CONSISTENCY_CHECK_ON_START": False, "MINIO_DEFAULT_BUCKET": config('MINIO_MEDIA_FILES_BUCKET'), # replacement for MEDIA_ROOT # MINIO_STATIC_FILES_BUCKET = 'my-static-files-bucket' # replacement for STATIC_ROOT diff --git a/accounts/urls.py b/main/urls.py similarity index 100% rename from accounts/urls.py rename to main/urls.py diff --git a/accounts/wsgi.py b/main/wsgi.py similarity index 100% rename from accounts/wsgi.py rename to main/wsgi.py From ada0391922d74c2be6ee8ec334795e68cc228139 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Sat, 11 Apr 2026 14:50:03 +0330 Subject: [PATCH 23/27] logger --- .gitignore | 1 + main/other_settings/__init__.py | 0 main/other_settings/logging.py | 141 ++++++++++++++++++++++++++++++++ main/settings.py | 40 +-------- run.sh | 2 +- 5 files changed, 146 insertions(+), 38 deletions(-) create mode 100644 main/other_settings/__init__.py create mode 100644 main/other_settings/logging.py diff --git a/.gitignore b/.gitignore index 0d9702f..d96bde8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ delme*.py .env /venv/ oidc.key +/logs/*.log diff --git a/main/other_settings/__init__.py b/main/other_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main/other_settings/logging.py b/main/other_settings/logging.py new file mode 100644 index 0000000..1a857f0 --- /dev/null +++ b/main/other_settings/logging.py @@ -0,0 +1,141 @@ +import os + +def get_settings(key): + from django.conf import settings + return getattr(settings, 'BASE_DIR') + +BASE_DIR = get_settings('BASE_DIR') + +LOG_DIR = BASE_DIR / 'logs' +if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR) + + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{asctime} [{levelname}] {name} {module} {process:d} {thread:d} {message}', + 'style': '{', + }, + 'standard': { + 'format': '{asctime} [{levelname}] {name}: {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'filters': { + 'require_debug_true': { + '()': 'django.utils.log.RequireDebugTrue', + }, + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse', + }, + }, + 'handlers': { + # هندلر برای نوشتن در فایل + 'file': { + 'level': 'INFO', + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': os.path.join(LOG_DIR, 'accounts.log'), + # 'maxBytes': 1024 * 1024 * 10, # 10 MB + 'when': 'midnight', + 'interval': 5, + 'backupCount': 30, + 'formatter': 'verbose', + 'encoding': 'utf-8', + }, + # هندلر برای خطاها در فایل جداگانه + 'error_file': { + 'level': 'ERROR', + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': os.path.join(LOG_DIR, 'errors.log'), + # 'maxBytes': 1024 * 1024 * 10, + 'when': 'midnight', + 'interval': 5, + 'backupCount': 30, + 'formatter': 'verbose', + 'encoding': 'utf-8', + }, + # هندلر برای کنسول (فقط در حالت DEBUG) + 'console': { + 'level': 'DEBUG', + 'filters': ['require_debug_true'], + 'class': 'logging.StreamHandler', + 'formatter': 'simple', + }, + # هندلر برای کنسول همیشه فعال (حتی در production) + 'console_always': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'standard', + }, + }, + 'loggers': { + # لاگر ریشه + '': { # empty string = root logger + 'handlers': ['console', 'file', 'error_file'], + 'level': 'INFO', + 'propagate': True, + }, + # لاگر اختصاصی برای Django + 'django': { + 'handlers': ['console', 'file'], + 'level': 'INFO', + 'propagate': False, + }, + # لاگر اختصاصی برای درخواست‌ها + 'django.request': { + 'handlers': ['file', 'error_file', 'console'], + 'level': 'ERROR', + 'propagate': False, + }, + # # لاگر اختصاصی برای برنامه خودتان + # 'root': { + # 'handlers': ['console', 'file', 'error_file'], + # 'level': 'DEBUG', + # 'propagate': False, + # }, + }, +} + +# LOKI_BASE_PUBLIC_URL = config('LOKI_BASE_PUBLIC_URL', default=None, cast=str) +# { +# 'version': 1, +# 'disable_existing_loggers': False, +# 'formatters': { +# 'loki': { +# 'class': 'utils.logs.LokiFormatter', # required +# }, +# }, +# +# 'handlers': { +# 'loki': { +# 'level': 'DEBUG', # Log level. Required +# 'class': 'utils.logs.LokiHandler', # Required +# 'formatter': 'loki', # Loki formatter. Required +# 'timeout': 2, # Post request timeout, default is 0.5. Optional +# 'url': f'{LOKI_BASE_PUBLIC_URL}/loki/api/v1/push', # Loki url. Defaults to localhost. Optional. +# # 'auth': ("user", "password"), # Basic auth to authenticate with loki. Default is None (i.e. no auth). Optional +# 'tags': {"app": "accounts"}, # Tags / Labels to attach to the log. Optional, but strongly encoraged to use. +# 'mode': 'thread', +# # Push mode. Can be 'sync' or 'thread'. Sync is blocking, thread is non-blocking. Defaults to sync. Optional. +# }, +# 'console': { +# 'level': 'DEBUG', +# 'class': 'logging.StreamHandler', +# # 'formatter': 'verbose', +# }, +# }, +# 'loggers': { +# '': { +# 'handlers': ['console', 'loki'], +# 'level': 'INFO', +# 'propagate': True, +# }, +# }, +# } \ No newline at end of file diff --git a/main/settings.py b/main/settings.py index 3648b18..3dd9f8f 100644 --- a/main/settings.py +++ b/main/settings.py @@ -140,7 +140,7 @@ SPECTACULAR_SETTINGS = { "PARSER_WHITELIST": ["rest_framework.parsers.JSONParser"], } -ROOT_URLCONF = 'accounts.urls' +ROOT_URLCONF = 'main.urls' TEMPLATES = [ { @@ -164,7 +164,7 @@ AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', ) -WSGI_APPLICATION = 'accounts.wsgi.application' +WSGI_APPLICATION = 'main.wsgi.application' # Database # https://docs.djangoproject.com/en/5.0/ref/settings/#databases @@ -293,43 +293,9 @@ CACHES = { } } -LOKI_BASE_PUBLIC_URL = config('LOKI_BASE_PUBLIC_URL', default=None, cast=str) -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'loki': { - 'class': 'utils.logs.LokiFormatter', # required - }, - }, - 'handlers': { - 'loki': { - 'level': 'DEBUG', # Log level. Required - 'class': 'utils.logs.LokiHandler', # Required - 'formatter': 'loki', # Loki formatter. Required - 'timeout': 2, # Post request timeout, default is 0.5. Optional - 'url': f'{LOKI_BASE_PUBLIC_URL}/loki/api/v1/push', # Loki url. Defaults to localhost. Optional. - # 'auth': ("user", "password"), # Basic auth to authenticate with loki. Default is None (i.e. no auth). Optional - 'tags': {"app": "accounts"}, # Tags / Labels to attach to the log. Optional, but strongly encoraged to use. - 'mode': 'thread', - # Push mode. Can be 'sync' or 'thread'. Sync is blocking, thread is non-blocking. Defaults to sync. Optional. - }, - 'console': { - 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - # 'formatter': 'verbose', - }, - }, - 'loggers': { - '': { - 'handlers': ['console', 'loki'], - 'level': 'INFO', - 'propagate': True, - }, - }, -} +from main.other_settings.logging import LOGGING from datetime import timedelta from typing import List, Tuple diff --git a/run.sh b/run.sh index e4dfb50..051a5d0 100755 --- a/run.sh +++ b/run.sh @@ -5,5 +5,5 @@ #done #python3 manage.py collectstatic --noinput #python3 manage.py migrate -gunicorn accounts.wsgi:application --bind 0.0.0.0:8000 -w 4 +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 From 5db1c6f0701c78b46e06eacbf92fbefa25c894e9 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Sat, 11 Apr 2026 15:28:05 +0330 Subject: [PATCH 24/27] logger --- main/asgi.py | 2 +- main/broker_rpc.py | 2 +- main/wsgi.py | 2 +- manage.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/main/asgi.py b/main/asgi.py index 3116ceb..f41decb 100644 --- a/main/asgi.py +++ b/main/asgi.py @@ -11,6 +11,6 @@ import os from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'accounts.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') application = get_asgi_application() diff --git a/main/broker_rpc.py b/main/broker_rpc.py index 6b322c4..30950c1 100644 --- a/main/broker_rpc.py +++ b/main/broker_rpc.py @@ -1,6 +1,6 @@ #!/usr/bin/env python import os -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'accounts.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') from utils.broker import get_rpc_broker_consumer diff --git a/main/wsgi.py b/main/wsgi.py index d4e6b0e..2d4a6a6 100644 --- a/main/wsgi.py +++ b/main/wsgi.py @@ -11,6 +11,6 @@ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'accounts.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') application = get_wsgi_application() diff --git a/manage.py b/manage.py index 7a22e43..063eacc 100644 --- a/manage.py +++ b/manage.py @@ -5,7 +5,7 @@ import sys def main(): - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'accounts.settings') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') try: from django.core.management import execute_from_command_line except ImportError as exc: From 3d587fb63d0aca1d2d2360e1fef35f4b3ed9a029 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Sat, 11 Apr 2026 15:43:37 +0330 Subject: [PATCH 25/27] log instead of logs dir --- main/other_settings/logging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/other_settings/logging.py b/main/other_settings/logging.py index 1a857f0..74038a3 100644 --- a/main/other_settings/logging.py +++ b/main/other_settings/logging.py @@ -6,7 +6,7 @@ def get_settings(key): BASE_DIR = get_settings('BASE_DIR') -LOG_DIR = BASE_DIR / 'logs' +LOG_DIR = BASE_DIR / 'log' if not os.path.exists(LOG_DIR): os.makedirs(LOG_DIR) From c35accc8acfc36341ab9c150c6bd9fb3a0353f02 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Sun, 12 Apr 2026 12:42:02 +0330 Subject: [PATCH 26/27] less fields in user admin --- apps/users/admin.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/users/admin.py b/apps/users/admin.py index f0da7b2..05a6999 100644 --- a/apps/users/admin.py +++ b/apps/users/admin.py @@ -11,10 +11,7 @@ class UserAdmin(admin.ModelAdmin): 'first_name', 'last_name', 'username', - 'email', 'phone_number', - 'password', - 'otp', 'is_staff', 'is_superuser', 'groups', @@ -24,10 +21,9 @@ class UserAdmin(admin.ModelAdmin): 'last_update', 'otp_expire', 'otp_try', - 'balance', ] readonly_fields = ['last_update', 'uuid'] - list_display = ['pk', 'first_name', 'last_name', 'phone_number', 'date_joined', 'last_update'] + list_display = ['pk', 'date_joined', 'last_update'] search_fields = ['pk', 'first_name', 'last_name', 'phone_number'] From 3dd62a01734331534ae8d69d11d3eeb69e505b9a Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Sat, 25 Apr 2026 02:10:47 +0330 Subject: [PATCH 27/27] better exception --- utils/exceptions.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/utils/exceptions.py b/utils/exceptions.py index ca3ac1e..0c6dd2e 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -6,6 +6,8 @@ from rest_framework.exceptions import APIException from rest_framework.views import exception_handler as drf_exception_handler logger = logging.getLogger(__name__) + + def exception_handler(exc, context): logger.exception(exc) # پاسخ پیش‌فرض DRF را دریافت می‌کنیم @@ -15,8 +17,8 @@ def exception_handler(exc, context): response_data = { 'success': False, 'status_code': response.status_code, - 'status_message': str(exc.default_detail), - 'details': exc.detail, + 'status_message': str(getattr(exc, 'default_detail', exc)), + 'details': getattr(exc, 'detail', None), } if isinstance(exc, APIException): @@ -44,7 +46,10 @@ def exception_handler(exc, context): # message = error # # response_data['message'] = message - response_data['details'] = exc.detail if isinstance(exc.detail, dict) else {} + if isinstance(exc.detail, dict): + response_data['details'] = exc.detail + else: + response_data['details'] = {"message": exc.detail} response_data['details']['error'] = error response_data['details']['timestamp'] = timezone.now().isoformat() @@ -84,7 +89,7 @@ from django.utils.translation import gettext_lazy as _ class UnprocessableEntity(APIException): - status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + status_code = 422 default_detail = 'The request was well-formed but cannot be processed due to semantic errors.' default_code = 'unprocessable_entity'